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 | |
| 10 | use crate::llm::{extract_json_number, extract_json_string, extract_json_string_array, json_escape}; |
| 11 | |
| 12 | use oxedyne_fe2o3_core::prelude::*; |
| 13 | |
| 14 | |
| 15 | /// The most tags one Diamond may carry; the excess is dropped. |
| 16 | const MAX_TAGS: usize = 8; |
| 17 | |
| 18 | /// The most characters one tag may carry; the excess is truncated. |
| 19 | const 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. |
| 29 | pub 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 | |
| 51 | impl 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. |
| 137 | const 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. |
| 151 | pub 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. |
| 175 | pub 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)] |
| 204 | mod 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)] |
| 531 | mod 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 | } |