Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/src/diamond_meta.rs

23.3 KiB, 1 run

created by r2519314175:943, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1//! The target-agnostic core of a Diamond's metadata: the `meta.json` shape, its
2//! parse/serialise pair, and tag normalisation.
3//!
4//! The OPFS edge that reads and writes the file lives in
5//! [`crate::wasm::diamond`], which is compiled only for wasm32 and so cannot be
6//! reached by the native test suite. What is pure sits here instead, where it
7//! is tested: the parse in particular, because a `meta.json` written before a
8//! field existed must still open the Diamond it describes.
9
10use crate::llm::{extract_json_number, extract_json_string, extract_json_string_array, json_escape};
11
12use oxedyne_fe2o3_core::prelude::*;
13
14
15/// The most tags one Diamond may carry; the excess is dropped.
16const MAX_TAGS: usize = 8;
17
18/// The most characters one tag may carry; the excess is truncated.
19const MAX_TAG_LEN: usize = 24;
20
21
22/// Per-Diamond metadata held in `meta.json`.
23///
24/// Two stamps, because they answer two different questions. `updated` means
25/// *worked on* and orders the rail; `touched` means *changed at all* and is what
26/// the cross-device merge compares. Tagging moves the second and not the first,
27/// which is the whole reason there are two: filing a Diamond must not shuffle it
28/// to the top of the rail, and it must still travel.
29pub struct Meta {
30 /// Human-readable Diamond name.
31 pub name: String,
32 /// Current crystal version (the latest snapshot).
33 pub version: u64,
34 /// Last-worked-on wall-clock time in whole milliseconds; the rail's order.
35 pub updated: u64,
36 /// Last-changed-in-any-way wall-clock time in whole milliseconds; the
37 /// merge's freshness test.
38 pub touched: u64,
39 /// User-defined tags, as [`normalise_tags`] leaves them.
40 pub tags: Vec<String>,
41 /// The toolchains the user granted this Diamond, as [`normalise_kits`] leaves them.
42 ///
43 /// A GRANT and not a tag, and the difference is the whole reason this is its own field rather
44 /// than a reserved tag name. A tag is an arbitrary string the user types; deriving a
45 /// toolchain grant from one would mean a Diamond filed under "rust" quietly reaching the
46 /// compiler, which is a permission nobody gave. What is in here reaches
47 /// `Toolkit::parse` and decides what a COMMAND may touch outside the workspace.
48 pub kits: Vec<String>,
49}
50
51impl Meta {
52
53 /// Serialise to a compact single-line JSON object.
54 pub fn to_json(&self) -> String {
55 fmt!(
56 "{{\"name\":\"{}\",\"crystal_version\":{},\"updated\":{},\"touched\":{},\"tags\":{},\"toolkits\":{}}}",
57 json_escape(&self.name), self.version, self.updated, self.touched, self.tags_json(),
58 self.kits_json(),
59 )
60 }
61
62 /// The tags as a JSON array of strings, `[]` when there are none.
63 ///
64 /// Shared with the Diamond list, which carries the same array per Diamond.
65 pub fn tags_json(&self) -> String {
66 let items: Vec<String> = self.tags.iter()
67 .map(|t| fmt!("\"{}\"", json_escape(t)))
68 .collect();
69 fmt!("[{}]", items.join(","))
70 }
71
72 /// The granted toolkits as a JSON array of strings, `[]` when there are none.
73 pub fn kits_json(&self) -> String {
74 let items: Vec<String> = self.kits.iter()
75 .map(|k| fmt!("\"{}\"", json_escape(k)))
76 .collect();
77 fmt!("[{}]", items.join(","))
78 }
79
80 /// Parse from the stored JSON, tolerating missing fields.
81 ///
82 /// `tags` postdates every Diamond already on a user's device, so a `meta.json`
83 /// without the field parses to no tags rather than failing: a strict parse
84 /// here would shut every Diamond made before tags existed.
85 pub fn from_json(s: &str) -> Self {
86 Self {
87 name: extract_json_string(s, "name").unwrap_or_default(),
88 // `brief_version` is what this field was called before the rename.
89 // A workspace written by an older build still says that, and reading
90 // only the new name would report every Diamond as being at version 0
91 // -- which is not a cosmetic error: the next fold would then snapshot
92 // over `versions/0000.md` and the real history would be overwritten.
93 version: extract_json_number(s, "crystal_version")
94 .or_else(|| extract_json_number(s, "brief_version"))
95 .unwrap_or(0),
96 updated: extract_json_number(s, "updated").unwrap_or(0),
97 // `touched` postdates every Diamond already on a device, and a
98 // missing one reads as the stamp that was there: before it existed,
99 // every change moved `updated`, so `updated` IS when the Diamond was
100 // last changed. Defaulting to 0 instead would tell the merge that
101 // every existing Diamond is infinitely stale, and the first device to
102 // touch anything would overwrite the other's copy of all of them.
103 touched: extract_json_number(s, "touched")
104 .unwrap_or_else(|| extract_json_number(s, "updated").unwrap_or(0)),
105 // NORMALISED ON THE WAY IN, not only on the way out. `normalise_tags`
106 // caps the list at MAX_TAGS and each tag at MAX_TAG_LEN, and it was
107 // applied to what a caller SET and never to what was read back — so a
108 // `meta.json` that had somehow acquired a huge tags array kept it
109 // through every read-modify-write for ever, and nothing could heal it.
110 // One of a user's Diamonds reached 702 MB, and reading it killed the
111 // tab (see `read_meta`). Normalising here means the very next write
112 // puts the file right.
113 tags: normalise_tags(&extract_json_string_array(s, "tags").unwrap_or_default()),
114 // Postdates every Diamond already on a device, and an absent field means no grant --
115 // which is both the honest reading and the safe one. A toolkit is off by default.
116 kits: normalise_kits(
117 &extract_json_string_array(s, "toolkits").unwrap_or_default()),
118 }
119 }
120}
121
122
123/// The toolkit names this build knows, in the order they are offered.
124///
125/// Named here as strings rather than reached for from `crate::tools::Toolkit`, because this module
126/// is the STORE and the store's job is to hold what the user chose, not to decide what it means.
127/// A name that survives this is still put through `Toolkit::parse` before it grants anything, and
128/// one this build does not know grants nothing there.
129///
130/// **This list and `Toolkit` are two copies of one fact, and they have already drifted once.**
131/// `git` was added to the enum, to the fence's `TOOLKIT_ROOTS` and to the page's `KITS` row, and
132/// not to this array -- so the chip drew, the click was written, the store dropped the name, and
133/// the chip redrew unlit. Nothing was broken and nothing said anything: the store was doing
134/// exactly what it is for, against a list that was one name short. Add a toolkit here whenever one
135/// is added there, and see `test_every_toolkit_the_engine_knows_is_a_name_the_store_keeps`, which
136/// fails when they disagree.
137const KNOWN_KITS: [&str; 5] = ["rust", "node", "python", "go", "git"];
138
139/// Normalise a caller's toolkit grants into the form the store holds.
140///
141/// Lowercased, trimmed, de-duplicated keeping first-seen order, and -- unlike a tag -- checked
142/// against [`KNOWN_KITS`]. A tag is an arbitrary string and this is not: it decides what a command
143/// may reach outside the workspace, so a name nobody can act on has no business being stored as
144/// though somebody had granted something.
145///
146/// An unknown name is DROPPED rather than refused, so a `meta.json` written by a later build that
147/// knows a fifth toolchain still opens, carrying the grants this build does understand.
148///
149/// # Arguments
150/// * `kits` - The names the caller offered.
151pub fn normalise_kits(kits: &[String]) -> Vec<String> {
152 let mut out: Vec<String> = Vec::new();
153 for kit in kits {
154 let name = kit.trim().to_lowercase();
155 if !KNOWN_KITS.contains(&name.as_str()) {
156 continue;
157 }
158 if !out.contains(&name) {
159 out.push(name);
160 }
161 }
162 out
163}
164
165/// Normalise a caller's tags into the form the store holds.
166///
167/// Tags are typed by hand, so nothing about them can be assumed: each is
168/// trimmed, its internal whitespace runs collapsed to a single space, and it is
169/// lowercased, so `Person` and `person` are one tag rather than two. Empties
170/// are dropped, duplicates fall away keeping first-seen order, each tag is
171/// capped at [`MAX_TAG_LEN`] characters and the list at [`MAX_TAGS`].
172///
173/// Nothing here knows any tag by name: a tag is an arbitrary string, and which
174/// ones to suggest is the interface's business alone.
175pub fn normalise_tags(tags: &[String]) -> Vec<String> {
176 let mut out: Vec<String> = Vec::new();
177 for tag in tags {
178 // `split_whitespace` trims and collapses in one pass.
179 let mut norm = String::new();
180 for (i, word) in tag.split_whitespace().enumerate() {
181 if i > 0 {
182 norm.push(' ');
183 }
184 norm.push_str(&word.to_lowercase());
185 }
186 if norm.is_empty() {
187 continue;
188 }
189 // Cap by characters, not bytes, so a multi-byte tag is never cut
190 // mid-character.
191 let capped: String = norm.chars().take(MAX_TAG_LEN).collect();
192 if !out.contains(&capped) {
193 out.push(capped);
194 }
195 if out.len() == MAX_TAGS {
196 break; // the excess is dropped, not truncated into the last tag
197 }
198 }
199 out
200}
201
202
203#[cfg(test)]
204mod tests {
205 use super::*;
206
207 // ── The parse: what an existing Diamond depends on ─────────────────
208
209 #[test]
210 fn test_meta_written_before_the_rename_keeps_its_version() {
211 // The version field was called `brief_version` until the brief became a
212 // crystal. Every Diamond on a device that has not been opened since then
213 // still says so, and reading only the new name would report version 0 --
214 // which is not cosmetic: the next fold would snapshot over
215 // `versions/0000.md` and overwrite the real history.
216 let old = Meta::from_json(r#"{"name":"Old","brief_version":7,"updated":123}"#);
217 assert_eq!(7, old.version);
218 assert_eq!("Old", old.name);
219
220 // And the current name still wins where both somehow appear.
221 let both = Meta::from_json(r#"{"name":"X","crystal_version":9,"brief_version":7}"#);
222 assert_eq!(9, both.version);
223 }
224
225 #[test]
226 fn test_meta_without_tags_still_opens_its_diamond() {
227 // Every Diamond on a user's device predates the tags field. Its meta.json
228 // says nothing about tags, and it must still parse -- with its name,
229 // version and stamp intact, and simply no tags. A strict parse here
230 // would brick every Diamond anyone already has.
231 let old = r#"{"name":"X","crystal_version":3,"updated":123}"#;
232 let meta = Meta::from_json(old);
233 assert_eq!("X", meta.name);
234 assert_eq!(3, meta.version);
235 assert_eq!(123, meta.updated);
236 assert!(meta.tags.is_empty(), "a missing field is no tags, not a failure");
237 }
238
239 #[test]
240 fn test_meta_without_touched_reads_as_updated() {
241 // Every Diamond already on a device was written before the second stamp
242 // existed, and back then every change moved `updated` -- so `updated` is
243 // exactly when such a Diamond was last changed. Reading the missing field
244 // as 0 would instead declare all of them infinitely stale, and the first
245 // device to change anything would overwrite the other device's copy of
246 // every Diamond it holds.
247 let old = Meta::from_json(r#"{"name":"X","crystal_version":3,"updated":123,"tags":[]}"#);
248 assert_eq!(123, old.updated);
249 assert_eq!(123, old.touched, "a missing second stamp is the first one");
250
251 // And a Diamond so old it carries no stamp at all still parses.
252 let ancient = Meta::from_json(r#"{"name":"Y","brief_version":2}"#);
253 assert_eq!(0, ancient.updated);
254 assert_eq!(0, ancient.touched);
255 }
256
257 #[test]
258 fn test_the_two_stamps_round_trip_apart() {
259 // Tagging moves `touched` and leaves `updated` where it was, so the file
260 // has to be able to say two different things. A serialisation that wrote
261 // one over the other would put the rail's order and the merge's freshness
262 // back into the same number, which is the bug this field exists to fix.
263 let meta = Meta {
264 name: fmt!("Filed"),
265 version: 4,
266 updated: 1_700_000_000_000,
267 touched: 1_700_000_009_999,
268 tags: vec![fmt!("work")],
269 kits: Vec::new(),
270 };
271 let back = Meta::from_json(&meta.to_json());
272 assert_eq!(1_700_000_000_000, back.updated);
273 assert_eq!(1_700_000_009_999, back.touched);
274 }
275
276 #[test]
277 fn test_a_meta_with_tags_round_trips() {
278 let meta = Meta {
279 name: fmt!("Ship the thing"),
280 version: 7,
281 updated: 1_700_000_000_000,
282 touched: 1_700_000_000_000,
283 tags: vec![fmt!("work"), fmt!("urgent")],
284 kits: Vec::new(),
285 };
286 let back = Meta::from_json(&meta.to_json());
287 assert_eq!("Ship the thing", back.name);
288 assert_eq!(7, back.version);
289 assert_eq!(1_700_000_000_000, back.updated);
290 assert_eq!(vec![fmt!("work"), fmt!("urgent")], back.tags);
291 }
292
293 #[test]
294 fn test_a_meta_with_no_tags_round_trips_as_an_empty_array() {
295 let meta = Meta { name: fmt!("Quiet"), version: 0, updated: 1, touched: 1, tags: Vec::new(), kits: Vec::new() };
296 let json = meta.to_json();
297 assert!(json.contains("\"tags\":[]"), "{}", json);
298 assert!(Meta::from_json(&json).tags.is_empty());
299 }
300
301 #[test]
302 fn test_tags_needing_escapes_survive_the_round_trip() {
303 // A tag is an arbitrary string, so it can hold the very characters the
304 // JSON it is written into uses. Written naively, `he said "hi"` closes
305 // the string early and the file no longer parses.
306 //
307 // Every tag here is one `normalise_tags` would leave ALONE — lowercase,
308 // single-spaced, short — so what is measured is the ESCAPING and nothing
309 // else. It used to include `two\nlines`, which stopped surviving verbatim
310 // the day reads began normalising; that is the normaliser working, not the
311 // escaping failing, and the two now have a test each.
312 let tags = vec![
313 fmt!("he said \"hi\""),
314 fmt!("back\\slash"),
315 fmt!("caf\u{e9} \u{65e5}\u{672c}"), // multi-byte, passed through raw
316 fmt!("bell\u{7}"), // a control character, \u-escaped
317 ];
318 let meta = Meta { name: fmt!("N"), version: 1, updated: 2, touched: 2, tags: tags.clone(), kits: Vec::new() };
319 assert_eq!(tags, Meta::from_json(&meta.to_json()).tags);
320 }
321
322 #[test]
323 fn test_reading_normalises_tags_so_a_damaged_file_heals() {
324 // THE READ PATH NORMALISES, and it did not use to. `normalise_tags` was
325 // applied to what a caller SET and never to what came back off disk, so a
326 // `meta.json` that had acquired more tags than the cap allows kept them
327 // through every read-modify-write for ever. One of a user's Diamonds
328 // reached 702 MB that way and reading it killed the tab.
329 //
330 // Written straight into the JSON rather than through `Meta`, because a
331 // `Meta` built by this process is exactly what CANNOT produce the file
332 // under test.
333 let many: Vec<String> = (0..40).map(|i| fmt!("\"Tag {}\"", i)).collect();
334 let json = fmt!(
335 "{{\"name\":\"N\",\"crystal_version\":1,\"updated\":2,\"touched\":2,\
336 \"tags\":[{}],\"toolkits\":[]}}", many.join(","));
337 let back = Meta::from_json(&json);
338 assert_eq!(back.tags.len(), MAX_TAGS,
339 "forty tags off disk must come back capped, or nothing ever heals the file");
340 assert_eq!(back.tags[0], "tag 0", "and normalised, not merely truncated");
341 // And the very next write puts the file right.
342 assert!(back.to_json().len() < json.len(),
343 "the rewrite must be smaller than what was read, or the damage persists");
344
345 // Whitespace inside a tag collapses on the way in, exactly as it does on
346 // the way out, so the two paths cannot disagree about what a tag IS.
347 let nl = Meta::from_json(
348 "{\"name\":\"N\",\"crystal_version\":1,\"updated\":2,\"touched\":2,\
349 \"tags\":[\"two\\nlines\"],\"toolkits\":[]}");
350 assert_eq!(nl.tags, vec![fmt!("two lines")]);
351 }
352
353 #[test]
354 fn test_a_name_holding_a_tags_key_does_not_become_the_tags() {
355 // The parse is a scan, not a grammar, so a value that reads like a field
356 // is worth pinning: the real field wins because it is the one whose key
357 // follows a comma.
358 let meta = Meta {
359 name: fmt!("\"tags\":[\"fake\"]"),
360 version: 1,
361 updated: 2,
362 touched: 2,
363 tags: vec![fmt!("real")],
364 kits: Vec::new(),
365 };
366 assert_eq!(vec![fmt!("real")], Meta::from_json(&meta.to_json()).tags);
367 }
368
369 // ── Normalisation: what the store is spared ──────────────────────
370
371 #[test]
372 fn test_case_and_whitespace_fold_into_one_tag() {
373 let got = normalise_tags(&[fmt!(" Person "), fmt!("PERSON"), fmt!("person")]);
374 assert_eq!(vec![fmt!("person")], got, "one tag, however it was typed");
375 }
376
377 #[test]
378 fn test_internal_whitespace_runs_collapse() {
379 let got = normalise_tags(&[fmt!("Big\t\tRed \n Book")]);
380 assert_eq!(vec![fmt!("big red book")], got);
381 }
382
383 #[test]
384 fn test_empty_and_blank_tags_are_dropped() {
385 let got = normalise_tags(&[fmt!("work"), fmt!(""), fmt!(" "), fmt!("\t\n")]);
386 assert_eq!(vec![fmt!("work")], got);
387 }
388
389 #[test]
390 fn test_duplicates_fall_away_keeping_first_seen_order() {
391 // Order is the user's, not the alphabet's: the list reads back as typed.
392 let got = normalise_tags(&[fmt!("zeta"), fmt!("alpha"), fmt!("zeta"), fmt!("beta")]);
393 assert_eq!(vec![fmt!("zeta"), fmt!("alpha"), fmt!("beta")], got);
394 }
395
396 #[test]
397 fn test_a_long_tag_is_capped_at_twenty_four_characters() {
398 let got = normalise_tags(&[fmt!("abcdefghijklmnopqrstuvwxyz")]); // 26
399 assert_eq!(vec![fmt!("abcdefghijklmnopqrstuvwx")], got);
400 assert_eq!(24, got[0].chars().count());
401 }
402
403 #[test]
404 fn test_a_long_multibyte_tag_is_capped_by_characters_not_bytes() {
405 // Capping bytes would cut a character in half and corrupt the tag.
406 let got = normalise_tags(&[fmt!("{}", "\u{65e5}".repeat(30))]);
407 assert_eq!(24, got[0].chars().count());
408 assert_eq!(fmt!("{}", "\u{65e5}".repeat(24)), got[0]);
409 }
410
411 #[test]
412 fn test_tags_that_differ_only_past_the_cap_are_one_tag() {
413 // They are the same stored tag, so the dedupe has to run after the cap.
414 let a = fmt!("aaaaaaaaaaaaaaaaaaaaaaaa-one");
415 let b = fmt!("aaaaaaaaaaaaaaaaaaaaaaaa-two");
416 assert_eq!(1, normalise_tags(&[a, b]).len());
417 }
418
419 #[test]
420 fn test_only_the_first_eight_tags_are_kept() {
421 let many: Vec<String> = (0..12).map(|i| fmt!("tag{}", i)).collect();
422 let got = normalise_tags(&many);
423 assert_eq!(8, got.len());
424 assert_eq!(fmt!("tag0"), got[0]);
425 assert_eq!(fmt!("tag7"), got[7], "the excess is dropped from the end");
426 }
427
428 #[test]
429 fn test_the_cap_counts_what_is_kept_not_what_was_offered() {
430 // Nine tags, but the duplicates and the blank are not tags at all, so
431 // what survives is under the cap and nothing is lost to it.
432 let offered = vec![
433 fmt!("a"), fmt!("A"), fmt!(""), fmt!("b"), fmt!("b"),
434 fmt!("c"), fmt!("d"), fmt!("e"), fmt!("f"),
435 ];
436 assert_eq!(
437 vec![fmt!("a"), fmt!("b"), fmt!("c"), fmt!("d"), fmt!("e"), fmt!("f")],
438 normalise_tags(&offered),
439 );
440 }
441
442 #[test]
443 fn test_normalised_tags_round_trip_through_the_stored_json() {
444 // The two halves have to agree: what normalisation produces is exactly
445 // what the file gives back, or the store drifts from the interface.
446 let tags = normalise_tags(&[fmt!(" Work "), fmt!("Deep\tDiamond"), fmt!("work")]);
447 let meta = Meta { name: fmt!("N"), version: 2, updated: 3, touched: 3, tags: tags.clone(),
448 kits: Vec::new() };
449 assert_eq!(tags, Meta::from_json(&meta.to_json()).tags);
450 }
451
452 #[test]
453 fn test_a_toolkit_grant_is_only_ever_a_name_this_build_can_act_on() {
454 // A tag is an arbitrary string; this is not. What is stored here decides what a command
455 // may reach outside the workspace, so anything unrecognised is dropped rather than kept
456 // as though somebody had granted something nobody can name.
457 assert_eq!(vec![fmt!("rust"), fmt!("go")],
458 normalise_kits(&[fmt!(" Rust "), fmt!("emacs"), fmt!("GO"), fmt!("rust")]));
459 }
460
461 /// Every toolkit the engine knows is a name this store will keep.
462 ///
463 /// The two lists drifted once and it cost a user an evening: `git` reached the enum, the
464 /// fence and the page's chip row, but not `KNOWN_KITS`, so the grant was written, silently
465 /// dropped, and the chip redrew unlit with nothing said. The store was right and the list was
466 /// short, which is the hardest shape of this to see.
467 #[test]
468 fn test_every_toolkit_the_engine_knows_is_a_name_the_store_keeps() {
469 for kit in crate::tools::Toolkit::all() {
470 let name = fmt!("{}", kit.name());
471 assert_eq!(vec![name.clone()], normalise_kits(&[name.clone()]),
472 "the engine offers the toolkit '{}' and the store drops it, so granting it \
473 writes nothing and the chip cannot light -- add it to KNOWN_KITS", name);
474 }
475 assert!(normalise_kits(&[fmt!("")]).is_empty());
476 }
477
478 /// ...and a name the page will draw a chip for.
479 ///
480 /// The fourth copy of the same list, and the one nothing was watching. `KITS` in
481 /// `www/js/daimond.js` is hand-written, so a toolkit that reaches the enum, the store and
482 /// both fences and not that array is a grant with no way to give it: the row simply does
483 /// not hold a chip, and nothing on the screen says the question exists. That is the exact
484 /// failure the test above this one was written for, one copy further out.
485 ///
486 /// Read from the file rather than mirrored here, because a mirror is a fifth copy.
487 #[test]
488 fn test_every_toolkit_the_engine_knows_has_a_chip_the_page_draws() {
489 let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("www/js/daimond.js");
490 let src = match std::fs::read_to_string(&path) {
491 Ok(s) => s,
492 // Read from the tree, so a build without it proves nothing either way rather
493 // than failing on somebody's packaging.
494 Err(_) => return,
495 };
496 let list = match src.find("var KITS = [") {
497 Some(i) => match src[i..].find("];") {
498 Some(j) => &src[i..i + j],
499 None => panic!("the KITS array in www/js/daimond.js has no end"),
500 },
501 None => panic!("www/js/daimond.js no longer holds a `var KITS = [`"),
502 };
503 for kit in crate::tools::Toolkit::all() {
504 let want = fmt!("name: '{}'", kit.name());
505 assert!(list.contains(&want),
506 "the engine offers the toolkit '{}' and the Workspace panel draws no chip for it, so nobody can grant it -- add {} to KITS in www/js/daimond.js",
507 kit.name(), want);
508 }
509 }
510
511 #[test]
512 fn test_a_meta_written_before_toolkits_existed_grants_none() {
513 // The failure that would matter is the opposite one: a missing field read as a grant.
514 let old = r#"{"name":"N","crystal_version":1,"updated":2,"touched":2,"tags":["work"]}"#;
515 let m = Meta::from_json(old);
516 assert_eq!(fmt!("N"), m.name);
517 assert!(m.kits.is_empty(), "an absent field is not a grant");
518 }
519
520 #[test]
521 fn test_toolkit_grants_round_trip_through_the_stored_json() {
522 let meta = Meta {
523 name: fmt!("N"), version: 2, updated: 3, touched: 3,
524 tags: Vec::new(), kits: vec![fmt!("rust"), fmt!("node")],
525 };
526 assert_eq!(vec![fmt!("rust"), fmt!("node")], Meta::from_json(&meta.to_json()).kits);
527 }
528}
529
530#[cfg(test)]
531mod real_file_tests {
532 use super::*;
533
534 #[test]
535 fn test_a_real_meta_json_off_a_users_disk_parses_whole() {
536 // Verbatim from a user's laptop, read out of OPFS. Pasted here because a
537 // release of mine rebuilt every imported `meta.json` and left fifteen
538 // Diamonds with no name, and the theory was that the file was not in the
539 // shape this parser expects. It is. The theory was wrong, and this is what
540 // makes that a fact rather than an opinion.
541 let real = "{\"name\":\"AI labelling\",\"crystal_version\":2,\"updated\":1785923558559,\
542 \"touched\":1785923558559,\"tags\":[\"open source\",\"project\"],\"toolkits\":[]}";
543 let m = Meta::from_json(real);
544 assert_eq!(m.name, "AI labelling");
545 assert_eq!(m.version, 2);
546 assert_eq!(m.updated, 1785923558559);
547 assert_eq!(m.touched, 1785923558559);
548 assert_eq!(m.tags, vec![fmt!("open source"), fmt!("project")]);
549
550 // And one written before `toolkits` existed, which is the other real shape
551 // on that disk.
552 let older = "{\"name\":\"81b Redfern Street North Perth\",\"crystal_version\":0,\
553 \"updated\":1785547737996,\"touched\":1785547737996,\
554 \"tags\":[\"location\",\"residence\",\"property\"]}";
555 let o = Meta::from_json(older);
556 assert_eq!(o.name, "81b Redfern Street North Perth");
557 assert!(o.kits.is_empty());
558 }
559}