max / alloy
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
1 file changed,
+371 insertions,
-15 deletions
| @@ -20,12 +20,58 @@ | |||
| 20 | 20 | //! Copying costs divergence, and divergence is how somebody loses the mako | |
| 21 | 21 | //! config they spent an evening on. So no file is ever overwritten on the | |
| 22 | 22 | //! strength of the console having written it once: the question "did the user | |
| 23 | - | //! edit this" is answered from content alone, by comparing what is on disk | |
| 24 | - | //! against **both** shipped renders. Matching either one means the file is | |
| 25 | - | //! still the image's and may be replaced; matching neither means it is the | |
| 26 | - | //! user's and is left exactly where it is. No state file, no hash manifest, | |
| 27 | - | //! nothing to go stale, and the same answer on every run, which is also what | |
| 28 | - | //! makes apply idempotent. | |
| 23 | + | //! edit this" is answered from content, by comparing what is on disk against | |
| 24 | + | //! **both** shipped renders. Matching either one means the file is still the | |
| 25 | + | //! image's and may be replaced; matching neither means it is the user's and is | |
| 26 | + | //! left exactly where it is. | |
| 27 | + | //! | |
| 28 | + | //! ## Why two renders are not enough, and what the ledger adds | |
| 29 | + | //! | |
| 30 | + | //! Those two renders are the *current* image's. They answer the mode switch | |
| 31 | + | //! completely, which is what this verb was built for, and they are blind to the | |
| 32 | + | //! other thing that moves a shipped config: a new image. A home seeded at one | |
| 33 | + | //! image holds that image's render; change the template and both renders move, | |
| 34 | + | //! so the file on disk matches neither and reads as edited. Measured, not | |
| 35 | + | //! reasoned: `an_untouched_file_survives_the_image_moving_under_it` fails | |
| 36 | + | //! against the two-render test alone. | |
| 37 | + | //! | |
| 38 | + | //! That is not a cosmetic wrong answer. It is the whole delivery path for a | |
| 39 | + | //! config fix — `/etc/skel` seeds new users only, so this verb is the only | |
| 40 | + | //! thing that reaches a home that already exists — and it withholds the fix | |
| 41 | + | //! from exactly the people who need it while telling them they edited a file | |
| 42 | + | //! they never opened. | |
| 43 | + | //! | |
| 44 | + | //! So the console records what it put down, at [`SEEDED`]: one SHA-256 per | |
| 45 | + | //! path, rewritten each run. A file whose digest matches that record is ours | |
| 46 | + | //! however old the render is, and may be replaced. This is the state file the | |
| 47 | + | //! first draft of this module ruled out ("no state file, no hash manifest, | |
| 48 | + | //! nothing to go stale"), and that ruling is kept for the case it was made | |
| 49 | + | //! about and reversed for this one. | |
| 50 | + | //! | |
| 51 | + | //! The alternative that would have preserved it — shipping every digest ever | |
| 52 | + | //! rendered, so the image rather than the home carries the history — loses to | |
| 53 | + | //! `1372b159`: distribution is builders, not images, so a user renders their | |
| 54 | + | //! own skeleton from their own theme and no ledger we ship has ever seen it. | |
| 55 | + | //! Their configs would read as edited forever. Past that, an old digest cannot | |
| 56 | + | //! be regenerated faithfully, because rendering yesterday's template with | |
| 57 | + | //! today's skelgen answers for a file that was never shipped. | |
| 58 | + | //! | |
| 59 | + | //! Three properties keep the cost down. A path with no record falls back to the | |
| 60 | + | //! two-render test, so a missing or deleted ledger loses the fix rather than a | |
| 61 | + | //! file. A [`Decision::Keep`] records nothing, so an edit is never adopted as | |
| 62 | + | //! ours. And the record is written from the render, never from what was found | |
| 63 | + | //! on disk, so the ledger cannot learn a file it did not write. | |
| 64 | + | //! | |
| 65 | + | //! ## The one-time transition, which is a release constraint | |
| 66 | + | //! | |
| 67 | + | //! A home is enrolled the first time this runs against it: every file still | |
| 68 | + | //! holding its render reads [`Outcome::Current`] and is recorded. Logins | |
| 69 | + | //! precede image upgrades, so an ordinary machine enrolls before it ever needs | |
| 70 | + | //! the ledger. The exception is the image that introduces the ledger: any file | |
| 71 | + | //! whose template *also* changed in that image is unrecognisable on arrival | |
| 72 | + | //! and is kept, with no record to rescue it, and stays kept until someone runs | |
| 73 | + | //! `--force`. So ship the ledger in an image that changes no template, and | |
| 74 | + | //! move the templates in the next one. | |
| 29 | 75 | //! | |
| 30 | 76 | //! ## Nothing here may fail a login | |
| 31 | 77 | //! | |
| @@ -52,6 +98,14 @@ | |||
| 52 | 98 | /// The dark tree. Not seeded anywhere: it exists to be applied from. | |
| 53 | 99 | const SKEL_NIGHT: &str = "/usr/share/alloy/skel-night"; | |
| 54 | 100 | ||
| 101 | + | /// What the console last wrote into this home, one digest per path. | |
| 102 | + | /// | |
| 103 | + | /// Beside `monitors.toml` rather than under `$XDG_STATE_HOME`, for the reason | |
| 104 | + | /// `monitors.rs` gives for its own placement: these are all files written by | |
| 105 | + | /// the console about this machine, and a home where Alloy's bookkeeping is | |
| 106 | + | /// split across two directories is worse than either choice made consistently. | |
| 107 | + | const SEEDED: &str = ".config/alloy/seeded.toml"; | |
| 108 | + | ||
| 55 | 109 | /// Where a file the user edited goes before `--force` replaces it. | |
| 56 | 110 | const BACKUP_SUFFIX: &str = ".alloy-bak"; | |
| 57 | 111 | ||
| @@ -80,6 +134,9 @@ | |||
| 80 | 134 | day: PathBuf, | |
| 81 | 135 | night: PathBuf, | |
| 82 | 136 | home: PathBuf, | |
| 137 | + | /// Where this home's ledger lives. Derived from `home` in the real case and | |
| 138 | + | /// named separately so a test can drive the whole verb over a scratch tree. | |
| 139 | + | seeded: PathBuf, | |
| 83 | 140 | } | |
| 84 | 141 | ||
| 85 | 142 | impl Trees { | |
| @@ -87,6 +144,7 @@ | |||
| 87 | 144 | Self { | |
| 88 | 145 | day: PathBuf::from(SKEL_DAY), | |
| 89 | 146 | night: PathBuf::from(SKEL_NIGHT), | |
| 147 | + | seeded: home.join(SEEDED), | |
| 90 | 148 | home, | |
| 91 | 149 | } | |
| 92 | 150 | } | |
| @@ -110,6 +168,74 @@ | |||
| 110 | 168 | } | |
| 111 | 169 | } | |
| 112 | 170 | ||
| 171 | + | /// The digest of one render, as the ledger holds it. | |
| 172 | + | /// | |
| 173 | + | /// The whole SHA-256, unlike the monitor fingerprint, which keeps 64 bits | |
| 174 | + | /// because a person opens that file to see what Alloy thinks is attached. | |
| 175 | + | /// Nobody reads this one, so legibility buys nothing, and the stakes are not | |
| 176 | + | /// the same: a collision here is a config file somebody wrote silently | |
| 177 | + | /// replaced by a render. | |
| 178 | + | fn digest(bytes: &[u8]) -> String { | |
| 179 | + | use sha2::{Digest as _, Sha256}; | |
| 180 | + | ||
| 181 | + | let mut hasher = Sha256::new(); | |
| 182 | + | hasher.update(bytes); | |
| 183 | + | let mut hex = String::with_capacity(64); | |
| 184 | + | for byte in hasher.finalize() { | |
| 185 | + | use std::fmt::Write as _; | |
| 186 | + | let _ = write!(hex, "{byte:02x}"); | |
| 187 | + | } | |
| 188 | + | hex | |
| 189 | + | } | |
| 190 | + | ||
| 191 | + | /// Path to the digest of the render the console last put there. | |
| 192 | + | /// | |
| 193 | + | /// `BTreeMap` so the file is stable between writes, matching `monitors.rs`: a | |
| 194 | + | /// table that reorders itself every login is a table nobody can diff. | |
| 195 | + | #[derive(Debug, Clone, Default, PartialEq, serde::Deserialize, serde::Serialize)] | |
| 196 | + | struct Seeded { | |
| 197 | + | #[serde(default)] | |
| 198 | + | files: std::collections::BTreeMap<String, String>, | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | impl Seeded { | |
| 202 | + | /// Parse, treating a damaged file as an empty ledger. | |
| 203 | + | /// | |
| 204 | + | /// Forgiving for the reason every path in this module is: it rides the | |
| 205 | + | /// login. An empty ledger costs a config fix, and a hard error costs the | |
| 206 | + | /// session. | |
| 207 | + | fn parse(raw: &str) -> Self { | |
| 208 | + | toml::from_str(raw).unwrap_or_default() | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | fn to_toml(&self) -> String { | |
| 212 | + | let body = toml::to_string_pretty(self).unwrap_or_default(); | |
| 213 | + | format!( | |
| 214 | + | "# Generated by Alloy. The SHA-256 of the skeleton file this console\n\ | |
| 215 | + | # last wrote at each path, so a config fix in a later image can tell\n\ | |
| 216 | + | # its own render from something you edited. Rewritten whole, so hand\n\ | |
| 217 | + | # edits are lost. Deleting this file costs you the next config fix\n\ | |
| 218 | + | # and nothing else: your own edits are kept either way.\n\n{body}" | |
| 219 | + | ) | |
| 220 | + | } | |
| 221 | + | ||
| 222 | + | /// What we last wrote at `relative`, if we have written it. | |
| 223 | + | /// | |
| 224 | + | /// Keyed by the path as a string with `/` separators. Every path here comes | |
| 225 | + | /// from walking the image's own skeleton, so it is UTF-8 and relative by | |
| 226 | + | /// construction; a path that somehow is not simply has no record, which is | |
| 227 | + | /// the same conservative answer as never having been written. | |
| 228 | + | fn get(&self, relative: &Path) -> Option<&str> { | |
| 229 | + | self.files.get(relative.to_str()?).map(String::as_str) | |
| 230 | + | } | |
| 231 | + | ||
| 232 | + | fn record(&mut self, relative: &Path, digest: String) { | |
| 233 | + | if let Some(key) = relative.to_str() { | |
| 234 | + | self.files.insert(key.to_string(), digest); | |
| 235 | + | } | |
| 236 | + | } | |
| 237 | + | } | |
| 238 | + | ||
| 113 | 239 | /// What happened to one file. | |
| 114 | 240 | #[derive(Debug, PartialEq, Eq)] | |
| 115 | 241 | enum Outcome { | |
| @@ -297,9 +423,15 @@ | |||
| 297 | 423 | ||
| 298 | 424 | let manifest = manifest(&trees.night); | |
| 299 | 425 | let mut report = Report::default(); | |
| 426 | + | // An unreadable or absent ledger is an empty one, which is the first-login | |
| 427 | + | // case and the deleted-file case at once. Both mean the same thing: nothing | |
| 428 | + | // is known about this home yet, so every file answers on its renders alone. | |
| 429 | + | let mut seeded = std::fs::read_to_string(&trees.seeded) | |
| 430 | + | .map_or_else(|_| Seeded::default(), |raw| Seeded::parse(&raw)); | |
| 431 | + | let before = seeded.clone(); | |
| 300 | 432 | ||
| 301 | 433 | for relative in &manifest { | |
| 302 | - | let outcome = apply_one(trees, mode, relative, options); | |
| 434 | + | let outcome = apply_one(trees, mode, relative, options, &mut seeded); | |
| 303 | 435 | report.count(&outcome); | |
| 304 | 436 | ||
| 305 | 437 | let shown = contract_home(&trees.home.join(relative)); | |
| @@ -323,9 +455,41 @@ | |||
| 323 | 455 | } | |
| 324 | 456 | } | |
| 325 | 457 | ||
| 458 | + | // Once, at the end, and only when something moved. An ordinary login finds | |
| 459 | + | // every file current, records the digests it already holds, and writes | |
| 460 | + | // nothing — which keeps the mtime of a file on the boot path from changing | |
| 461 | + | // on every single login for no reason. | |
| 462 | + | if !options.dry_run && seeded != before { | |
| 463 | + | save_seeded(&trees.seeded, &seeded, err); | |
| 464 | + | } | |
| 465 | + | ||
| 326 | 466 | let _ = writeln!(out, "{}", report.summary(mode, options.dry_run)); | |
| 327 | 467 | } | |
| 328 | 468 | ||
| 469 | + | /// Write the ledger, complaining rather than failing. | |
| 470 | + | /// | |
| 471 | + | /// Losing it costs the next config fix and nothing a user can see today, so it | |
| 472 | + | /// warns to stderr like every other error path here and the login continues. | |
| 473 | + | fn save_seeded(path: &Path, seeded: &Seeded, err: &mut impl Write) { | |
| 474 | + | if let Some(parent) = path.parent() | |
| 475 | + | && let Err(why) = std::fs::create_dir_all(parent) | |
| 476 | + | { | |
| 477 | + | let _ = writeln!( | |
| 478 | + | err, | |
| 479 | + | "alloy theme: cannot create {}: {why}", | |
| 480 | + | contract_home(parent) | |
| 481 | + | ); | |
| 482 | + | return; | |
| 483 | + | } | |
| 484 | + | if let Err(why) = std::fs::write(path, seeded.to_toml()) { | |
| 485 | + | let _ = writeln!( | |
| 486 | + | err, | |
| 487 | + | "alloy theme: cannot record what was applied to {}: {why}", | |
| 488 | + | contract_home(path) | |
| 489 | + | ); | |
| 490 | + | } | |
| 491 | + | } | |
| 492 | + | ||
| 329 | 493 | /// Every file under `root`, as paths relative to it. | |
| 330 | 494 | /// | |
| 331 | 495 | /// The manifest is derived rather than listed, so adding a template to | |
| @@ -359,7 +523,13 @@ | |||
| 359 | 523 | } | |
| 360 | 524 | } | |
| 361 | 525 | ||
| 362 | - | fn apply_one(trees: &Trees, mode: &str, relative: &Path, options: &Options) -> Outcome { | |
| 526 | + | fn apply_one( | |
| 527 | + | trees: &Trees, | |
| 528 | + | mode: &str, | |
| 529 | + | relative: &Path, | |
| 530 | + | options: &Options, | |
| 531 | + | seeded: &mut Seeded, | |
| 532 | + | ) -> Outcome { | |
| 363 | 533 | let source = trees.source(mode).join(relative); | |
| 364 | 534 | let Ok(want) = std::fs::read(&source) else { | |
| 365 | 535 | return Outcome::Failed(format!("cannot read {}", source.display())); | |
| @@ -371,7 +541,14 @@ | |||
| 371 | 541 | let target = trees.home.join(relative); | |
| 372 | 542 | let current = std::fs::read(&target).ok(); | |
| 373 | 543 | ||
| 374 | - | match decide(current.as_deref(), &want, other.as_deref()) { | |
| 544 | + | let decision = decide( | |
| 545 | + | current.as_deref(), | |
| 546 | + | &want, | |
| 547 | + | other.as_deref(), | |
| 548 | + | seeded.get(relative), | |
| 549 | + | ); | |
| 550 | + | ||
| 551 | + | let outcome = match decision { | |
| 375 | 552 | Decision::Current => Outcome::Current, | |
| 376 | 553 | Decision::Keep if !options.force => Outcome::Kept, | |
| 377 | 554 | Decision::Keep => { | |
| @@ -399,7 +576,20 @@ | |||
| 399 | 576 | Err(why) => Outcome::Failed(why), | |
| 400 | 577 | } | |
| 401 | 578 | } | |
| 579 | + | }; | |
| 580 | + | ||
| 581 | + | // Recorded from the outcome and not from the decision, so it says what is on | |
| 582 | + | // disk rather than what was intended: a write that failed records nothing, | |
| 583 | + | // and `--force` over an edited file records, which the decision alone would | |
| 584 | + | // have got backwards in both directions. Always the digest of the render, | |
| 585 | + | // never of what was found there, so the ledger cannot learn a file the | |
| 586 | + | // console did not write. A `Kept` file leaves the old entry in place, so | |
| 587 | + | // reverting an edit is recognised again. | |
| 588 | + | if !options.dry_run && matches!(outcome, Outcome::Applied | Outcome::Current) { | |
| 589 | + | seeded.record(relative, digest(&want)); | |
| 402 | 590 | } | |
| 591 | + | ||
| 592 | + | outcome | |
| 403 | 593 | } | |
| 404 | 594 | ||
| 405 | 595 | /// What the pristine test decides for one file. | |
| @@ -418,13 +608,28 @@ | |||
| 418 | 608 | /// Byte-exact rather than a normalizing compare: the files are rendered by one | |
| 419 | 609 | /// program from one template, so anything that differs at all differs because | |
| 420 | 610 | /// somebody typed it. | |
| 421 | - | fn decide(current: Option<&[u8]>, want: &[u8], other: Option<&[u8]>) -> Decision { | |
| 611 | + | /// | |
| 612 | + | /// `seeded` is what the ledger says this console last wrote here, and it is | |
| 613 | + | /// what carries the answer across an image upgrade: the two renders can only | |
| 614 | + | /// recognise the render shipped *now*, while the digest recognises the one that | |
| 615 | + | /// was shipped when the file was written. Checked last, so it never overrides a | |
| 616 | + | /// live render match, and only ever turns a `Keep` into a `Write`. | |
| 617 | + | fn decide( | |
| 618 | + | current: Option<&[u8]>, | |
| 619 | + | want: &[u8], | |
| 620 | + | other: Option<&[u8]>, | |
| 621 | + | seeded: Option<&str>, | |
| 622 | + | ) -> Decision { | |
| 422 | 623 | match current { | |
| 423 | 624 | // A home that predates a newly added template. Seeding it is the same | |
| 424 | 625 | // thing `useradd` would have done had the file existed then. | |
| 425 | 626 | None => Decision::Write, | |
| 426 | 627 | Some(current) if current == want => Decision::Current, | |
| 427 | 628 | Some(current) if other.is_some_and(|other| current == other) => Decision::Write, | |
| 629 | + | // Ours, from an image that has since moved. The digest is computed only | |
| 630 | + | // here, on the files about to be called the user's, so an ordinary login | |
| 631 | + | // where everything is current hashes nothing. | |
| 632 | + | Some(current) if seeded.is_some_and(|seeded| digest(current) == seeded) => Decision::Write, | |
| 428 | 633 | Some(_) => Decision::Keep, | |
| 429 | 634 | } | |
| 430 | 635 | } | |
| @@ -503,6 +708,7 @@ | |||
| 503 | 708 | day: root.join("skel"), | |
| 504 | 709 | night: root.join("skel-night"), | |
| 505 | 710 | home: root.join("home"), | |
| 711 | + | seeded: root.join("home").join(SEEDED), | |
| 506 | 712 | }; | |
| 507 | 713 | for dir in [&trees.day, &trees.night, &trees.home] { | |
| 508 | 714 | std::fs::create_dir_all(dir).unwrap(); | |
| @@ -647,13 +853,13 @@ | |||
| 647 | 853 | ||
| 648 | 854 | #[test] | |
| 649 | 855 | fn a_file_that_is_not_there_yet_is_written() { | |
| 650 | - | assert_eq!(decide(None, b"day", Some(b"night")), Decision::Write); | |
| 856 | + | assert_eq!(decide(None, b"day", Some(b"night"), None), Decision::Write); | |
| 651 | 857 | } | |
| 652 | 858 | ||
| 653 | 859 | #[test] | |
| 654 | 860 | fn a_file_already_in_this_mode_is_left_alone() { | |
| 655 | 861 | assert_eq!( | |
| 656 | - | decide(Some(b"day"), b"day", Some(b"night")), | |
| 862 | + | decide(Some(b"day"), b"day", Some(b"night"), None), | |
| 657 | 863 | Decision::Current | |
| 658 | 864 | ); | |
| 659 | 865 | } | |
| @@ -661,7 +867,7 @@ | |||
| 661 | 867 | #[test] | |
| 662 | 868 | fn a_file_holding_the_other_render_is_the_switch() { | |
| 663 | 869 | assert_eq!( | |
| 664 | - | decide(Some(b"night"), b"day", Some(b"night")), | |
| 870 | + | decide(Some(b"night"), b"day", Some(b"night"), None), | |
| 665 | 871 | Decision::Write | |
| 666 | 872 | ); | |
| 667 | 873 | } | |
| @@ -669,7 +875,7 @@ | |||
| 669 | 875 | #[test] | |
| 670 | 876 | fn a_file_matching_neither_render_belongs_to_the_user() { | |
| 671 | 877 | assert_eq!( | |
| 672 | - | decide(Some(b"mine"), b"day", Some(b"night")), | |
| 878 | + | decide(Some(b"mine"), b"day", Some(b"night"), None), | |
| 673 | 879 | Decision::Keep | |
| 674 | 880 | ); | |
| 675 | 881 | } | |
| @@ -678,11 +884,247 @@ | |||
| 678 | 884 | // The conservative branch is the one that has to hold when a source is gone. | |
| 679 | 885 | #[test] | |
| 680 | 886 | fn without_a_counterpart_an_unfamiliar_file_is_still_kept() { | |
| 681 | - | assert_eq!(decide(Some(b"mine"), b"day", None), Decision::Keep); | |
| 887 | + | assert_eq!(decide(Some(b"mine"), b"day", None, None), Decision::Keep); | |
| 888 | + | } | |
| 889 | + | ||
| 890 | + | // ---- the ledger, which is the pristine test across an image ---- | |
| 891 | + | ||
| 892 | + | // The case the two-render test cannot answer, and the reason the ledger | |
| 893 | + | // exists. The file on disk is a render neither source carries any more, | |
| 894 | + | // because the template moved with the image; the digest says the console | |
| 895 | + | // put it there, so the fix lands. | |
| 896 | + | #[test] | |
| 897 | + | fn a_render_from_an_older_image_is_still_ours() { | |
| 898 | + | assert_eq!( | |
| 899 | + | decide( | |
| 900 | + | Some(b"old day"), | |
| 901 | + | b"new day", | |
| 902 | + | Some(b"new night"), | |
| 903 | + | Some(&digest(b"old day")) | |
| 904 | + | ), | |
| 905 | + | Decision::Write | |
| 906 | + | ); | |
| 907 | + | } | |
| 908 | + | ||
| 909 | + | // The same shape with the digest of something else, which is what a real | |
| 910 | + | // edit looks like: a file matching no render and no record. | |
| 911 | + | #[test] | |
| 912 | + | fn a_record_of_a_different_file_does_not_claim_this_one() { | |
| 913 | + | assert_eq!( | |
| 914 | + | decide( | |
| 915 | + | Some(b"mine"), | |
| 916 | + | b"new day", | |
| 917 | + | Some(b"new night"), | |
| 918 | + | Some(&digest(b"old day")) | |
| 919 | + | ), | |
| 920 | + | Decision::Keep | |
| 921 | + | ); | |
| 922 | + | } | |
| 923 | + | ||
| 924 | + | // The ledger only ever adds a Write. A live render match is the stronger | |
| 925 | + | // answer and must not be downgraded by a stale record, or a file already | |
| 926 | + | // correct would be rewritten every login. | |
| 927 | + | #[test] | |
| 928 | + | fn a_stale_record_does_not_override_a_current_file() { | |
| 929 | + | assert_eq!( | |
| 930 | + | decide( | |
| 931 | + | Some(b"day"), | |
| 932 | + | b"day", | |
| 933 | + | Some(b"night"), | |
| 934 | + | Some(&digest(b"something else")) | |
| 935 | + | ), | |
| 936 | + | Decision::Current | |
| 937 | + | ); | |
| 938 | + | } | |
| 939 | + | ||
| 940 | + | #[test] | |
| 941 | + | fn a_damaged_ledger_reads_as_an_empty_one() { | |
| 942 | + | assert_eq!(Seeded::parse("this is not toml {{{"), Seeded::default()); | |
| 943 | + | } | |
| 944 | + | ||
| 945 | + | #[test] | |
| 946 | + | fn the_ledger_round_trips_through_its_own_format() { | |
| 947 | + | let mut seeded = Seeded::default(); | |
| 948 | + | seeded.record(Path::new(".config/mako/config"), digest(b"rendered")); | |
| 949 | + | ||
| 950 | + | let read_back = Seeded::parse(&seeded.to_toml()); | |
| 951 | + | ||
| 952 | + | assert_eq!(read_back, seeded); | |
| 953 | + | assert_eq!( | |
| 954 | + | read_back.get(Path::new(".config/mako/config")), | |
| 955 | + | Some(digest(b"rendered").as_str()) | |
| 956 | + | ); | |
| 682 | 957 | } | |
| 683 | 958 | ||
| 684 | 959 | // ---- the verb over real trees ---- | |
| 685 | 960 | ||
| 961 | + | // The whole point, end to end: a home seeded at one image, a template that | |
| 962 | + | // moves under it, and the fix arriving anyway. Before the ledger this run | |
| 963 | + | // printed "kept your edited ~/.config/mako/config" about a file nobody had | |
| 964 | + | // touched, and the fix reached nobody already running. | |
| 965 | + | #[test] | |
| 966 | + | fn an_untouched_file_survives_the_image_moving_under_it() { | |
| 967 | + | let scratch = Scratch::new("image-moved"); | |
| 968 | + | scratch.shipped(".config/mako/config", "old day", "old night"); | |
| 969 | + | scratch.home(".config/mako/config", "old day"); | |
| 970 | + | ||
| 971 | + | // The login on the old image. Nothing to do, and the home is enrolled. | |
| 972 | + | let (_, _) = scratch.run(DAY, &plain()); | |
| 973 | + | ||
| 974 | + | // The new image lands, carrying a fixed template. | |
| 975 | + | scratch.shipped(".config/mako/config", "new day", "new night"); | |
| 976 | + | let (out, _) = scratch.run(DAY, &plain()); | |
| 977 | + | ||
| 978 | + | assert_eq!( | |
| 979 | + | scratch.read_home(".config/mako/config").as_deref(), | |
| 980 | + | Some("new day") | |
| 981 | + | ); | |
| 982 | + | assert!( | |
| 983 | + | !out.contains("kept"), | |
| 984 | + | "a file nobody edited was called edited: {out}" | |
| 985 | + | ); | |
| 986 | + | } | |
| 987 | + | ||
| 988 | + | // The other half, and the one that must not regress: the same image move, | |
| 989 | + | // over a file the user did edit. The ledger holds a digest for it, and the | |
| 990 | + | // digest is of the render rather than of the edit, so it does not match. | |
| 991 | + | #[test] | |
| 992 | + | fn an_edited_file_survives_the_image_moving_under_it() { | |
| 993 | + | let scratch = Scratch::new("image-moved-edited"); | |
| 994 | + | scratch.shipped(".config/mako/config", "old day", "old night"); | |
| 995 | + | scratch.home(".config/mako/config", "old day"); | |
| 996 | + | let (_, _) = scratch.run(DAY, &plain()); | |
| 997 | + | ||
| 998 | + | scratch.home(".config/mako/config", "mine, hard won"); | |
| 999 | + | scratch.shipped(".config/mako/config", "new day", "new night"); | |
| 1000 | + | let (out, _) = scratch.run(DAY, &plain()); | |
| 1001 | + | ||
| 1002 | + | assert_eq!( | |
| 1003 | + | scratch.read_home(".config/mako/config").as_deref(), | |
| 1004 | + | Some("mine, hard won") | |
| 1005 | + | ); | |
| 1006 | + | assert!(out.contains("kept"), "the edit was not reported: {out}"); | |
| 1007 | + | } | |
| 1008 | + | ||
| 1009 | + | // A home the console has never run against, whose file the user had already | |
| 1010 | + | // edited before the ledger existed. There is no record to consult and there | |
| 1011 | + | // must never be one invented: the edit is kept, and stays kept. | |
| 1012 | + | #[test] | |
| 1013 | + | fn an_edit_made_before_the_ledger_existed_is_never_adopted() { | |
| 1014 | + | let scratch = Scratch::new("pre-ledger-edit"); | |
| 1015 | + | scratch.shipped(".config/mako/config", "day", "night"); | |
| 1016 | + | scratch.home(".config/mako/config", "mine"); | |
| 1017 | + | ||
| 1018 | + | let (out, _) = scratch.run(DAY, &plain()); | |
| 1019 | + | assert!(out.contains("kept"), "{out}"); | |
| 1020 | + | ||
| 1021 | + | // Second run, now that a ledger file exists for the other paths. | |
| 1022 | + | scratch.shipped(".config/mako/config", "moved day", "moved night"); | |
| 1023 | + | let (out, _) = scratch.run(DAY, &plain()); | |
| 1024 | + | ||
| 1025 | + | assert_eq!( | |
| 1026 | + | scratch.read_home(".config/mako/config").as_deref(), | |
| 1027 | + | Some("mine") | |
| 1028 | + | ); | |
| 1029 | + | assert!(out.contains("kept"), "{out}"); | |
| 1030 | + | } | |
| 1031 | + | ||
| 1032 | + | // `--force` is the escape hatch the transition note points at, so the file | |
| 1033 | + | // it rewrites has to be recorded, or the machine is back where it started | |
| 1034 | + | // at the next image. | |
| 1035 | + | #[test] | |
| 1036 | + | fn force_enrolls_the_file_it_overwrote() { | |
| 1037 | + | let scratch = Scratch::new("force-enrols"); | |
| 1038 | + | scratch.shipped(".config/mako/config", "day", "night"); | |
| 1039 | + | scratch.home(".config/mako/config", "mine"); | |
| 1040 | + | ||
| 1041 | + | let forced = Options { |
Lines truncated