Skip to main content

helixforge_engine/
preview.rs

1//! The system change preview: what a plan is expected to change on this
2//! machine, step by step: files (side by side where the new contents can be
3//! worked out without writing anything), packages, services and when it
4//! takes effect. Every change says how it is known: worked out here,
5//! declared by the entry, or impossible to tell before the step runs. It
6//! only reads files and asks package managers read-only questions.
7
8use crate::{Plan, Step, doctor::System};
9use helixforge_core::{Entry, PackageManager, printable};
10use helixforge_platform::LinuxInfo;
11use serde::{Deserialize, Serialize};
12use std::path::{Path, PathBuf};
13
14/// How a change is known.
15#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize)]
16#[serde(rename_all = "kebab-case")]
17pub enum Certainty {
18    /// Worked out on this machine: a package manager's own answer, a file
19    /// rendered by the installer's own code, a backup's contents.
20    Known,
21    /// Declared by the entry, or seen in its script.
22    Expected,
23    /// Cannot be told before the step runs.
24    Unknown,
25}
26
27impl Certainty {
28    pub fn label(self) -> &'static str {
29        match self {
30            Certainty::Known => "known",
31            Certainty::Expected => "expected",
32            Certainty::Unknown => "not known in advance",
33        }
34    }
35
36    pub fn mark(self) -> &'static str {
37        match self {
38            Certainty::Known => "✓",
39            Certainty::Expected => "~",
40            Certainty::Unknown => "?",
41        }
42    }
43}
44
45/// What a change touches.
46#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
47#[serde(rename_all = "kebab-case")]
48pub enum Area {
49    File,
50    Package,
51    Service,
52    Boot,
53    Other,
54}
55
56/// One expected change.
57#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
58pub struct Change {
59    pub certainty: Certainty,
60    pub area: Area,
61    pub text: String,
62    /// The file's contents now and afterwards, when both are known.
63    pub diff: Option<Diff>,
64}
65
66/// One step's changes.
67#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
68pub struct StepPreview {
69    pub id: String,
70    pub name: String,
71    pub changes: Vec<Change>,
72}
73
74/// A side-by-side comparison of a file now and after the step.
75#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
76pub struct Diff {
77    pub path: String,
78    /// Rows pairing the two sides: a line now, a line after, or both.
79    pub rows: Vec<Row>,
80    /// Whether anything differs.
81    pub changed: bool,
82}
83
84#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
85pub struct Row {
86    pub before: Option<String>,
87    pub after: Option<String>,
88    pub tag: Tag,
89}
90
91#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
92#[serde(rename_all = "kebab-case")]
93pub enum Tag {
94    Same,
95    Changed,
96    Removed,
97    Added,
98}
99
100/// What the preview asks the machine; tests answer instead.
101pub struct Look<'a> {
102    pub system: &'a dyn System,
103    /// Run one of the installers' `configure.py` renderers, which only
104    /// print: the new contents of a file, or why not.
105    pub render: &'a dyn Fn(&[String]) -> Result<String, String>,
106    /// Where a pinned source `fetch_source` downloads is on this machine.
107    pub pinned: &'a dyn Fn(&str) -> Pinned,
108}
109
110/// A pinned source, as found on this machine.
111#[derive(Clone, Debug, Eq, PartialEq)]
112pub enum Pinned {
113    /// Downloaded, at its pinned revision and unedited.
114    Here(PathBuf),
115    /// Not downloaded yet: the repository and revision `fetch_source` takes.
116    Missing {
117        repository: String,
118        revision: String,
119    },
120    /// There but not usable as it is, or not a pinned source at all.
121    Unusable(String),
122}
123
124/// The dotfiles components `dotfiles.sh` installs.
125const DOTFILES: [&str; 5] = ["nvim", "fastfetch", "starship", "konsole", "ghostty"];
126
127/// What lists a dotfiles component's files without writing them.
128const DOTFILES_PREVIEW: &str = "dotfiles-preview";
129
130/// Dotfiles folders with more changed files than this list the rest as a
131/// count.
132const FOLDER_FILES: usize = 40;
133
134/// Files larger than this, or with lines past this count, get no diff.
135const DIFF_BYTES: usize = 1 << 20;
136const DIFF_LINES: usize = 4_000;
137
138/// Preview a plan's changes.
139pub fn preview<'a>(
140    plan: &Plan,
141    lookup: &dyn Fn(&str) -> Option<&'a Entry>,
142    platform: &LinuxInfo,
143    look: &Look,
144) -> Vec<StepPreview> {
145    plan.steps
146        .iter()
147        .map(|step| StepPreview {
148            id: step.id().to_string(),
149            name: step.name().to_string(),
150            changes: step_changes(step, lookup(step.id()), platform, look),
151        })
152        .collect()
153}
154
155fn change(certainty: Certainty, area: Area, text: impl Into<String>) -> Change {
156    Change {
157        certainty,
158        area,
159        text: printable(&text.into()),
160        diff: None,
161    }
162}
163
164fn step_changes(
165    step: &Step,
166    entry: Option<&Entry>,
167    platform: &LinuxInfo,
168    look: &Look,
169) -> Vec<Change> {
170    let mut changes = Vec::new();
171    match step {
172        Step::Package {
173            sources, removal, ..
174        } => {
175            let Some(first) = sources.first() else {
176                return changes;
177            };
178            let names: Vec<String> = package_names(first);
179            if *removal {
180                changes.push(change(
181                    Certainty::Known,
182                    Area::Package,
183                    format!("removes {} through {}", names.join(", "), first.label),
184                ));
185                changes.extend(simulate(platform.manager, &names, true, look.system));
186            } else {
187                changes.push(change(
188                    Certainty::Known,
189                    Area::Package,
190                    format!("installs {} from {}", names.join(", "), first.label),
191                ));
192                if first.label != "Flathub" && !first.label.starts_with("the AUR") {
193                    changes.extend(simulate(platform.manager, &names, false, look.system));
194                }
195                for other in &sources[1..] {
196                    changes.push(change(
197                        Certainty::Expected,
198                        Area::Package,
199                        format!("if that fails or leaves it missing, from {}", other.label),
200                    ));
201                }
202                if let Some(entry) = entry
203                    && let Some(identity) = entry.identity()
204                    && !identity.conflicts.is_empty()
205                {
206                    changes.push(change(
207                        Certainty::Expected,
208                        Area::Package,
209                        format!(
210                            "replaces {} where installed; the package manager asks first",
211                            identity.conflicts.join(", ")
212                        ),
213                    ));
214                }
215                if platform.manager == Some(PackageManager::Debian) {
216                    changes.push(change(
217                        Certainty::Unknown,
218                        Area::Service,
219                        "Debian and Ubuntu packages start the services they install; which, if any, is up to the package",
220                    ));
221                }
222            }
223        }
224        Step::Script { file, .. } => {
225            if let Some(entry) = entry {
226                changes.extend(declared(entry, look));
227                changes.extend(writes(entry, file, look));
228            }
229            // Declared services say more than a look at the script.
230            if entry.is_none_or(|entry| entry.effects.services.is_empty()) {
231                changes.extend(seen_in(file));
232            }
233            changes.push(change(
234                Certainty::Unknown,
235                Area::Other,
236                if entry.is_some_and(|entry| !entry.effects.is_empty() || !entry.writes.is_empty()) {
237                    "anything else the script does as it runs, and the answers it asks you for"
238                } else {
239                    "what the script changes: it decides as it runs, so HelixForge cannot show it in advance"
240                },
241            ));
242        }
243        Step::Command { line, .. } => {
244            changes.push(change(
245                Certainty::Known,
246                Area::Other,
247                format!("runs {line}"),
248            ));
249            changes.push(change(
250                Certainty::Unknown,
251                Area::Other,
252                "what the command changes",
253            ));
254        }
255        Step::Nix { action, .. } => match action {
256            helixforge_core::NixAction::ProfileInstall { installable } => {
257                changes.push(change(
258                    Certainty::Known,
259                    Area::Package,
260                    format!("adds {installable} to your Nix profile; nothing outside the Nix store and your profile changes"),
261                ));
262            }
263            helixforge_core::NixAction::HomeManagerSwitch { flake } => {
264                changes.push(change(
265                    Certainty::Known,
266                    Area::Other,
267                    format!("switches Home Manager to {flake}"),
268                ));
269                changes.push(change(
270                    Certainty::Unknown,
271                    Area::File,
272                    "which files and packages change: the flake decides; home-manager build, then nvd diff, shows it",
273                ));
274            }
275        },
276        Step::Native { .. } => changes.push(change(
277            Certainty::Known,
278            Area::Other,
279            "only reads the system and prints what it finds",
280        )),
281        Step::Shell {
282            display,
283            changes: made,
284            ..
285        } => {
286            if made.is_empty() {
287                changes.push(change(
288                    Certainty::Known,
289                    Area::Other,
290                    format!("runs {display}"),
291                ));
292            }
293            for made in made {
294                let mut found = change(
295                    Certainty::Known,
296                    Area::File,
297                    format!("{}: {}", made.path.display(), made.text),
298                );
299                if let Some(from) = &made.from {
300                    let before = read_text(look.system, &made.path).unwrap_or_default();
301                    match read_text(look.system, from) {
302                        Some(after) => {
303                            found.diff = diff(&made.path.display().to_string(), &before, &after)
304                        }
305                        None => {
306                            found
307                                .text
308                                .push_str(" (its contents cannot be read here to compare)");
309                        }
310                    }
311                }
312                changes.push(found);
313            }
314        }
315        Step::Update {
316            source, targets, ..
317        } => {
318            if source.label == "pacman" && targets.len() > 1 || step.id() == "system-update" {
319                match look.system.run(&["pacman", "-Qu"]) {
320                    Some((true, text)) if !text.trim().is_empty() => {
321                        let lines: Vec<&str> = text.lines().collect();
322                        changes.push(change(
323                            Certainty::Known,
324                            Area::Package,
325                            format!(
326                                "updates {} packages from the repository metadata on this machine: {}",
327                                lines.len(),
328                                lines.join("; ")
329                            ),
330                        ));
331                    }
332                    _ => changes.push(change(
333                        Certainty::Unknown,
334                        Area::Package,
335                        "which packages: pacman -Syu reads the repositories again first",
336                    )),
337                }
338                changes.push(change(
339                    Certainty::Unknown,
340                    Area::Package,
341                    "packages newer than the metadata on this machine, which pacman -Syu downloads first",
342                ));
343            } else {
344                for target in targets {
345                    changes.push(change(
346                        Certainty::Known,
347                        Area::Package,
348                        format!(
349                            "updates {}{} through {}",
350                            target.name,
351                            target
352                                .from
353                                .as_deref()
354                                .map(|from| format!(" from {from}"))
355                                .unwrap_or_default(),
356                            source.label
357                        ),
358                    ));
359                }
360            }
361        }
362    }
363    changes
364}
365
366/// The package names a source installs: its commands' last words.
367fn package_names(source: &crate::Source) -> Vec<String> {
368    source
369        .commands
370        .last()
371        .map(|command| {
372            command
373                .iter()
374                .rev()
375                .take_while(|word| {
376                    !word.starts_with('-')
377                        && !matches!(
378                            word.as_str(),
379                            "install" | "add" | "remove" | "del" | "-S" | "-R" | "sudo"
380                        )
381                })
382                .cloned()
383                .collect::<Vec<_>>()
384                .into_iter()
385                .rev()
386                .collect()
387        })
388        .unwrap_or_default()
389}
390
391/// What the package manager says it would do, asked without root.
392fn simulate(
393    manager: Option<PackageManager>,
394    names: &[String],
395    removal: bool,
396    system: &dyn System,
397) -> Vec<Change> {
398    let names: Vec<&str> = names
399        .iter()
400        .map(String::as_str)
401        .filter(|name| helixforge_core::valid_package_name(name))
402        .collect();
403    if names.is_empty() {
404        return Vec::new();
405    }
406    let mut changes = Vec::new();
407    match manager {
408        Some(PackageManager::Arch) => {
409            let mut command = vec![
410                "pacman",
411                if removal { "-Rp" } else { "-Sp" },
412                "--print-format",
413                "%n %v",
414            ];
415            command.extend(&names);
416            match system.run(&command) {
417                Some((true, text)) => {
418                    let all: Vec<&str> = text
419                        .lines()
420                        .map(str::trim)
421                        .filter(|line| !line.is_empty())
422                        .collect();
423                    let others: Vec<&str> = all
424                        .iter()
425                        .copied()
426                        .filter(|line| {
427                            !names
428                                .iter()
429                                .any(|name| line.split(' ').next() == Some(name))
430                        })
431                        .collect();
432                    changes.push(change(
433                        Certainty::Known,
434                        Area::Package,
435                        if others.is_empty() {
436                            format!(
437                                "pacman lists nothing else to {}",
438                                if removal { "remove" } else { "install" }
439                            )
440                        } else {
441                            format!(
442                                "pacman would also {}: {}",
443                                if removal { "remove" } else { "install" },
444                                others.join(", ")
445                            )
446                        },
447                    ));
448                }
449                _ => changes.push(change(
450                    Certainty::Unknown,
451                    Area::Package,
452                    "which dependencies: pacman could not say from the metadata on this machine",
453                )),
454            }
455        }
456        Some(PackageManager::Debian) => {
457            let mut command = vec![
458                "apt-get",
459                "-s",
460                "-q",
461                if removal { "remove" } else { "install" },
462            ];
463            command.extend(&names);
464            match system.run(&command) {
465                Some((true, text)) => {
466                    let pick = |prefix: &str| -> Vec<String> {
467                        text.lines()
468                            .filter_map(|line| line.strip_prefix(prefix))
469                            .filter_map(|rest| rest.split_whitespace().next())
470                            .filter(|name| !names.contains(name))
471                            .map(String::from)
472                            .collect()
473                    };
474                    let installs = pick("Inst ");
475                    let removes = pick("Remv ");
476                    if !installs.is_empty() {
477                        changes.push(change(
478                            Certainty::Known,
479                            Area::Package,
480                            format!("apt would also install: {}", installs.join(", ")),
481                        ));
482                    }
483                    if !removes.is_empty() {
484                        changes.push(change(
485                            Certainty::Known,
486                            Area::Package,
487                            format!("apt would remove: {}", removes.join(", ")),
488                        ));
489                    }
490                    if installs.is_empty() && removes.is_empty() {
491                        changes.push(change(
492                            Certainty::Known,
493                            Area::Package,
494                            "apt lists nothing else to change",
495                        ));
496                    }
497                }
498                _ => changes.push(change(
499                    Certainty::Unknown,
500                    Area::Package,
501                    "which dependencies: apt could not simulate it here",
502                )),
503            }
504        }
505        Some(other) => changes.push(change(
506            Certainty::Unknown,
507            Area::Package,
508            format!(
509                "which dependencies: {} lists them only with root, as it starts",
510                other.executable()
511            ),
512        )),
513        None => {}
514    }
515    changes
516}
517
518/// The entry's declared effects, with new contents rendered where it can.
519fn declared(entry: &Entry, look: &Look) -> Vec<Change> {
520    let effects = &entry.effects;
521    let mut changes = Vec::new();
522    for package in &effects.packages {
523        changes.push(change(
524            Certainty::Expected,
525            Area::Package,
526            format!("installs {package}"),
527        ));
528    }
529    for service in &effects.services {
530        changes.push(change(Certainty::Expected, Area::Service, service.clone()));
531    }
532    let grub = if look.system.exists(Path::new("/boot/grub2")) {
533        "/boot/grub2"
534    } else {
535        "/boot/grub"
536    };
537    let expand = |text: &str| {
538        text.replace("{grub}", grub)
539            .replace("{time}", "<date and time of the run>")
540    };
541    for file in &effects.files {
542        let path = expand(&file.path);
543        let when = file
544            .when
545            .as_deref()
546            .map(|when| format!(" (when {when})"))
547            .unwrap_or_default();
548        let shown = PathBuf::from(&path);
549        let exists = look.system.exists(&shown);
550        let mut found = change(
551            Certainty::Expected,
552            Area::File,
553            format!("{path}: {}{when}", file.change),
554        );
555        if !file.render.is_empty() {
556            if !exists {
557                found
558                    .text
559                    .push_str("; it is not on this machine, so this does not apply");
560            } else {
561                let arguments: Vec<String> = file.render.iter().map(|word| expand(word)).collect();
562                match (read_text(look.system, &shown), (look.render)(&arguments)) {
563                    (Some(before), Ok(after)) => {
564                        found.certainty = Certainty::Known;
565                        found.diff = diff(&path, &before, &after);
566                        if found.diff.as_ref().is_some_and(|diff| !diff.changed) {
567                            found
568                                .text
569                                .push_str("; it already says this, so nothing changes");
570                        }
571                    }
572                    (_, Err(why)) => found.text.push_str(&printable(&format!(
573                        "; its new contents could not be worked out: {why}"
574                    ))),
575                    (None, _) => found.text.push_str("; it cannot be read here to compare"),
576                }
577            }
578        } else if file.path.contains("{time}") || !exists {
579            found.text.push_str(" (created)");
580        }
581        changes.push(found);
582    }
583    if let Some(when) = &effects.takes_effect {
584        changes.push(change(
585            Certainty::Expected,
586            Area::Boot,
587            format!("takes effect: {when}"),
588        ));
589    }
590    for unknown in &effects.unknown {
591        changes.push(change(Certainty::Unknown, Area::Other, unknown.clone()));
592    }
593    changes
594}
595
596/// The user paths an entry says it writes: file by file from its pinned
597/// source where that is on this machine and the installer can list them.
598fn writes(entry: &Entry, file: &Path, look: &Look) -> Vec<Change> {
599    let system = look.system;
600    let mut why =
601        "the new contents come from the entry's pinned source, read as it runs".to_string();
602    if let Some(component) = dotfiles_component(file) {
603        match dotfiles(entry, component, look) {
604            Ok(changes) => return changes,
605            Err(reason) => why = reason,
606        }
607    }
608    let (_, _, home) = system.user();
609    entry
610        .writes
611        .iter()
612        .map(|written| {
613            let path = match written.strip_prefix("~/") {
614                Some(rest) => home.join(rest),
615                None => PathBuf::from(written),
616            };
617            let what = if system.is_link(&path) {
618                "replaced; it is a link now, which the installer refuses to overwrite if Home Manager manages it"
619            } else if system.exists(&path) {
620                "replaced, keeping the current one as a backup"
621            } else {
622                "created"
623            };
624            let mut found = change(Certainty::Expected, Area::File, format!("{written}: {what}"));
625            if system.exists(&path) {
626                found.text.push_str("; ");
627                found.text.push_str(&why);
628            }
629            found
630        })
631        .collect()
632}
633
634/// The dotfiles component a script installs, from its name.
635fn dotfiles_component(file: &Path) -> Option<&'static str> {
636    let name = file.file_stem()?.to_str()?.strip_prefix("dotfiles-")?;
637    DOTFILES.into_iter().find(|component| *component == name)
638}
639
640#[derive(Deserialize)]
641struct Listing {
642    files: Vec<Listed>,
643    folders: Vec<String>,
644}
645
646#[derive(Deserialize)]
647struct Listed {
648    path: String,
649    state: String,
650    /// The new contents, when they are text.
651    text: Option<String>,
652}
653
654/// A dotfiles component's files, compared with the ones here, from the
655/// installer's own listing of its pinned source; or why that cannot be done.
656fn dotfiles(entry: &Entry, component: &str, look: &Look) -> Result<Vec<Change>, String> {
657    let source = match (look.pinned)("dotfiles") {
658        Pinned::Here(source) => source,
659        Pinned::Missing {
660            repository,
661            revision,
662        } => {
663            return Err(format!(
664                "the new contents come from github.com/{repository} at commit {}, which is not on this machine yet; `helixforge --effects plan --fetch-sources {}` downloads it to compare them",
665                revision.get(..12).unwrap_or(&revision),
666                entry.id
667            ));
668        }
669        Pinned::Unusable(why) => {
670            return Err(printable(&format!(
671                "the new contents come from the entry's pinned source, which cannot be compared: {why}"
672            )));
673        }
674    };
675    let arguments = [
676        DOTFILES_PREVIEW.to_string(),
677        source.display().to_string(),
678        component.to_string(),
679    ];
680    let listing: Listing = (look.render)(&arguments)
681        .map_err(|why| printable(&format!("its new contents could not be worked out: {why}")))
682        .and_then(|text| {
683            serde_json::from_str(&text)
684                .map_err(|why| format!("its new contents could not be worked out: {why}"))
685        })?;
686    let (_, _, home) = look.system.user();
687    let shown = |path: &str| printable(&crate::drift::tilde(&home, path));
688    let in_folder = |path: &str| {
689        listing
690            .folders
691            .iter()
692            .find(|folder| Path::new(path).starts_with(folder.as_str()))
693    };
694    let compared = |listed: &Listed, text: String| {
695        let mut found = change(Certainty::Known, Area::File, text);
696        match (&listed.text, listed.state.as_str()) {
697            (None, "new" | "changed") => found
698                .text
699                .push_str("; it is not text, so it is not shown side by side"),
700            (Some(after), "new") => found.diff = diff(&shown(&listed.path), "", after),
701            (Some(after), "changed") => match read_text(look.system, Path::new(&listed.path)) {
702                Some(before) => found.diff = diff(&shown(&listed.path), &before, after),
703                None => found.text.push_str("; it cannot be read here to compare"),
704            },
705            _ => {}
706        }
707        found
708    };
709    let mut changes = Vec::new();
710    for folder in &listing.folders {
711        let inside: Vec<&Listed> = listing
712            .files
713            .iter()
714            .filter(|listed| in_folder(&listed.path) == Some(folder))
715            .collect();
716        let count = |state: &str| inside.iter().filter(|listed| listed.state == state).count();
717        let what = if look.system.exists(Path::new(folder)) {
718            format!(
719                "replaced as a whole, keeping the current folder as a backup: {} changed, {} added, {} left in the backup only, {} the same",
720                count("changed"),
721                count("new"),
722                count("removed"),
723                count("same")
724            )
725        } else {
726            format!("created, with {} files", inside.len())
727        };
728        changes.push(change(
729            Certainty::Known,
730            Area::File,
731            format!("{}: {what}", shown(folder)),
732        ));
733        let differing: Vec<&&Listed> = inside
734            .iter()
735            .filter(|listed| listed.state != "same")
736            .collect();
737        for listed in differing.iter().take(FOLDER_FILES) {
738            let what = match listed.state.as_str() {
739                "new" => "added",
740                "changed" => "changed",
741                _ => "only in the current folder, so it stays in the backup",
742            };
743            changes.push(compared(listed, format!("{}: {what}", shown(&listed.path))));
744        }
745        if differing.len() > FOLDER_FILES {
746            changes.push(change(
747                Certainty::Known,
748                Area::File,
749                format!(
750                    "{}: and {} more files that differ",
751                    shown(folder),
752                    differing.len() - FOLDER_FILES
753                ),
754            ));
755        }
756    }
757    for listed in listing
758        .files
759        .iter()
760        .filter(|listed| in_folder(&listed.path).is_none())
761    {
762        let what = match listed.state.as_str() {
763            "new" => "created",
764            "changed" => "replaced, keeping the current one as a backup",
765            _ => "already says this; it is written again, keeping the current one as a backup",
766        };
767        changes.push(compared(listed, format!("{}: {what}", shown(&listed.path))));
768    }
769    Ok(changes)
770}
771
772/// Where `fetch_source` keeps its downloads.
773fn sources_folder() -> Option<PathBuf> {
774    match std::env::var_os("XDG_DATA_HOME").filter(|value| !value.is_empty()) {
775        Some(data) => Some(PathBuf::from(data)),
776        None => Some(PathBuf::from(std::env::var_os("HOME")?).join(".local/share")),
777    }
778    .map(|data| data.join("helixforge/sources"))
779}
780
781/// Find a pinned source on this machine, as `fetch_source` would before
782/// using it: at its revision and unedited. `scripts` is the unpacked
783/// scripts folder. Only reads.
784pub fn pinned_source(scripts: &Path, name: &str) -> Pinned {
785    match sources_folder() {
786        Some(sources) => pinned_in(scripts, &sources, name),
787        None => Pinned::Unusable("there is no home folder".to_string()),
788    }
789}
790
791fn pinned_in(scripts: &Path, sources: &Path, name: &str) -> Pinned {
792    let Some((_, repository, revision)) = crate::trust::pins(scripts)
793        .into_iter()
794        .find(|(pinned, _, _)| pinned == name)
795    else {
796        return Pinned::Unusable(format!("{name} is not a pinned source"));
797    };
798    let folder = sources.join(format!("{name}-{revision}"));
799    if !folder.exists() {
800        return Pinned::Missing {
801            repository,
802            revision,
803        };
804    }
805    let git = |arguments: &[&str]| {
806        std::process::Command::new("git")
807            .arg("-C")
808            .arg(&folder)
809            .args(arguments)
810            .stdin(std::process::Stdio::null())
811            .output()
812            .ok()
813            .filter(|output| output.status.success())
814            .map(|output| String::from_utf8_lossy(&output.stdout).trim().to_string())
815    };
816    if git(&["rev-parse", "HEAD"]).as_deref() != Some(revision.as_str()) {
817        return Pinned::Unusable(format!(
818            "{} is not at the pinned revision",
819            folder.display()
820        ));
821    }
822    match git(&["status", "--porcelain", "--untracked-files=all"]) {
823        Some(status) if status.is_empty() => Pinned::Here(folder),
824        Some(_) => Pinned::Unusable(format!(
825            "{} has edits; move it aside to fetch a fresh copy",
826            folder.display()
827        )),
828        None => Pinned::Unusable(format!("git cannot read {}", folder.display())),
829    }
830}
831
832/// The pinned sources a plan's dotfiles entries read.
833pub fn sources_for(plan: &Plan) -> Vec<&'static str> {
834    let mut names = Vec::new();
835    for step in &plan.steps {
836        if let Step::Script { file, .. } = step
837            && dotfiles_component(file).is_some()
838            && !names.contains(&"dotfiles")
839        {
840            names.push("dotfiles");
841        }
842    }
843    names
844}
845
846/// Download a pinned source into the folder the installers read it from,
847/// with the installers' own `fetch_source`, which checks the revision. It
848/// changes nothing else. `scripts` is the unpacked scripts folder.
849pub fn fetch_source(scripts: &Path, name: &str) -> Result<PathBuf, String> {
850    if let Pinned::Unusable(why) = pinned_source(scripts, name) {
851        return Err(why);
852    }
853    let output = std::process::Command::new("sh")
854        .args(["-ec", ". ./common.sh; fetch_source \"$1\"", "sh", name])
855        .current_dir(scripts.join("installers/commander"))
856        .env(crate::LIB_VARIABLE, scripts.join("shared"))
857        .stdin(std::process::Stdio::null())
858        .output()
859        .map_err(|error| format!("cannot run sh: {error}"))?;
860    if !output.status.success() {
861        let mut why = String::from_utf8_lossy(&output.stderr).trim().to_string();
862        if why.is_empty() {
863            why = String::from_utf8_lossy(&output.stdout).trim().to_string();
864        }
865        return Err(printable(&why));
866    }
867    match pinned_source(scripts, name) {
868        Pinned::Here(folder) => Ok(folder),
869        Pinned::Missing { .. } => Err(format!("{name} was not downloaded")),
870        Pinned::Unusable(why) => Err(why),
871    }
872}
873
874/// Services and system paths the script names, which it may change.
875fn seen_in(file: &Path) -> Vec<Change> {
876    let Ok(text) = std::fs::read_to_string(file) else {
877        return Vec::new();
878    };
879    let mut services: Vec<String> = Vec::new();
880    for line in text.lines().filter(|line| {
881        let line = line.trim_start();
882        // Comments and messages name commands without running them.
883        !line.starts_with('#') && !line.starts_with("printf") && !line.starts_with("echo")
884    }) {
885        let Some(at) = line.find("systemctl ") else {
886            continue;
887        };
888        let words: Vec<&str> = line[at + "systemctl ".len()..].split_whitespace().collect();
889        let Some(verb) = words.iter().find(|word| !word.starts_with('-')) else {
890            continue;
891        };
892        if !matches!(
893            *verb,
894            "enable" | "disable" | "start" | "stop" | "restart" | "reload" | "mask"
895        ) {
896            continue;
897        }
898        let unit = words
899            .iter()
900            .skip_while(|word| *word != verb)
901            .skip(1)
902            .find(|word| !word.starts_with('-'))
903            .map(|word| word.trim_matches(|c: char| "\"';.,)".contains(c)))
904            .unwrap_or("a unit");
905        let unit = if unit.starts_with('$') {
906            "a unit it chooses"
907        } else {
908            unit
909        };
910        let text = format!("may {verb} {unit}");
911        if !services.contains(&text) {
912            services.push(text);
913        }
914    }
915    services
916        .into_iter()
917        .map(|text| {
918            change(
919                Certainty::Expected,
920                Area::Service,
921                format!("{text} (seen in its script)"),
922            )
923        })
924        .collect()
925}
926
927fn read_text(system: &dyn System, path: &Path) -> Option<String> {
928    let text = system.read(path)?;
929    (text.len() <= DIFF_BYTES).then_some(text)
930}
931
932/// Compare a file's lines now and after, side by side. None when either is
933/// too long to compare line by line.
934pub fn diff(path: &str, before: &str, after: &str) -> Option<Diff> {
935    let old: Vec<&str> = before.lines().collect();
936    let new: Vec<&str> = after.lines().collect();
937    if old.len() > DIFF_LINES || new.len() > DIFF_LINES {
938        return None;
939    }
940    // Longest common subsequence, then walk it.
941    let (n, m) = (old.len(), new.len());
942    let mut table = vec![vec![0u32; m + 1]; n + 1];
943    for i in (0..n).rev() {
944        for j in (0..m).rev() {
945            table[i][j] = if old[i] == new[j] {
946                table[i + 1][j + 1] + 1
947            } else {
948                table[i + 1][j].max(table[i][j + 1])
949            };
950        }
951    }
952    let mut rows = Vec::new();
953    let (mut i, mut j) = (0, 0);
954    let mut removed: Vec<&str> = Vec::new();
955    let mut added: Vec<&str> = Vec::new();
956    let flush = |rows: &mut Vec<Row>, removed: &mut Vec<&str>, added: &mut Vec<&str>| {
957        let pairs = removed.len().max(added.len());
958        for index in 0..pairs {
959            let before = removed.get(index).map(|line| printable(line));
960            let after = added.get(index).map(|line| printable(line));
961            let tag = match (&before, &after) {
962                (Some(_), Some(_)) => Tag::Changed,
963                (Some(_), None) => Tag::Removed,
964                _ => Tag::Added,
965            };
966            rows.push(Row { before, after, tag });
967        }
968        removed.clear();
969        added.clear();
970    };
971    while i < n || j < m {
972        if i < n && j < m && old[i] == new[j] {
973            flush(&mut rows, &mut removed, &mut added);
974            rows.push(Row {
975                before: Some(printable(old[i])),
976                after: Some(printable(new[j])),
977                tag: Tag::Same,
978            });
979            i += 1;
980            j += 1;
981        } else if j < m && (i == n || table[i][j + 1] >= table[i + 1][j]) {
982            added.push(new[j]);
983            j += 1;
984        } else {
985            removed.push(old[i]);
986            i += 1;
987        }
988    }
989    flush(&mut rows, &mut removed, &mut added);
990    let changed = rows.iter().any(|row| row.tag != Tag::Same);
991    Some(Diff {
992        path: printable(path),
993        rows,
994        changed,
995    })
996}
997
998impl Diff {
999    /// The rows that differ, each with up to `context` unchanged rows around
1000    /// it; None between runs marks the rows left out.
1001    pub fn hunks(&self, context: usize) -> Vec<Option<&Row>> {
1002        let keep: Vec<bool> = (0..self.rows.len())
1003            .map(|index| {
1004                let low = index.saturating_sub(context);
1005                let high = (index + context + 1).min(self.rows.len());
1006                self.rows[low..high].iter().any(|row| row.tag != Tag::Same)
1007            })
1008            .collect();
1009        let mut shown = Vec::new();
1010        let mut gap = false;
1011        for (index, row) in self.rows.iter().enumerate() {
1012            if keep[index] {
1013                if gap && !shown.is_empty() {
1014                    shown.push(None);
1015                }
1016                gap = false;
1017                shown.push(Some(row));
1018            } else {
1019                gap = true;
1020            }
1021        }
1022        shown
1023    }
1024
1025    /// The differing rows as a unified diff, for the command line.
1026    pub fn unified(&self) -> String {
1027        let mut text = format!("--- {} (now)\n+++ {} (after)\n", self.path, self.path);
1028        for row in self.hunks(2) {
1029            match row {
1030                None => text.push_str("@@\n"),
1031                Some(row) => match row.tag {
1032                    Tag::Same => {
1033                        text.push_str(&format!(" {}\n", row.before.as_deref().unwrap_or("")))
1034                    }
1035                    _ => {
1036                        if let Some(before) = &row.before {
1037                            text.push_str(&format!("-{before}\n"));
1038                        }
1039                        if let Some(after) = &row.after {
1040                            text.push_str(&format!("+{after}\n"));
1041                        }
1042                    }
1043                },
1044            }
1045        }
1046        text
1047    }
1048}
1049
1050/// Run a renderer from the installers' folder: `python3 configure.py ARGS`,
1051/// which only prints. `scripts` is the unpacked scripts folder.
1052pub fn render_with(scripts: &Path, arguments: &[String]) -> Result<String, String> {
1053    if !arguments.first().is_some_and(|action| {
1054        helixforge_core::RENDERERS.contains(&action.as_str()) || action == DOTFILES_PREVIEW
1055    }) {
1056        return Err("not a renderer".to_string());
1057    }
1058    let output = std::process::Command::new("python3")
1059        .arg("-B")
1060        .arg("./configure.py")
1061        .args(arguments)
1062        .current_dir(scripts.join("installers/commander"))
1063        .env("LC_ALL", "C.UTF-8")
1064        .stdin(std::process::Stdio::null())
1065        .output()
1066        .map_err(|error| format!("cannot run python3: {error}"))?;
1067    if output.status.success() {
1068        Ok(String::from_utf8_lossy(&output.stdout).into_owned())
1069    } else {
1070        Err(String::from_utf8_lossy(&output.stderr).trim().to_string())
1071    }
1072}
1073
1074#[cfg(test)]
1075mod tests {
1076    use super::*;
1077    use helixforge_catalog::Catalog;
1078    use std::collections::HashMap;
1079
1080    #[derive(Default)]
1081    struct Fake {
1082        files: HashMap<PathBuf, String>,
1083        commands: HashMap<String, String>,
1084    }
1085
1086    impl System for Fake {
1087        fn read(&self, path: &Path) -> Option<String> {
1088            self.files.get(path).cloned()
1089        }
1090        fn exists(&self, path: &Path) -> bool {
1091            self.files.contains_key(path)
1092        }
1093        fn list(&self, _: &Path) -> Vec<String> {
1094            Vec::new()
1095        }
1096        fn link(&self, _: &Path) -> Option<PathBuf> {
1097            None
1098        }
1099        fn run(&self, command: &[&str]) -> Option<(bool, String)> {
1100            self.commands
1101                .get(&command.join(" "))
1102                .map(|text| (true, text.clone()))
1103        }
1104        fn resolve(&self, _: &str) -> Result<Vec<String>, String> {
1105            Err("offline".into())
1106        }
1107        fn space(&self, _: &Path) -> Option<(u64, u64)> {
1108            None
1109        }
1110        fn owner(&self, _: &Path) -> Option<u32> {
1111            None
1112        }
1113        fn is_link(&self, _: &Path) -> bool {
1114            false
1115        }
1116        fn canonical(&self, _: &Path) -> Option<PathBuf> {
1117            None
1118        }
1119        fn user(&self) -> (u32, u32, PathBuf) {
1120            (1000, 1000, PathBuf::from("/home/me"))
1121        }
1122        fn on_path(&self, _: &str) -> bool {
1123            false
1124        }
1125        fn processes(&self) -> Vec<String> {
1126            Vec::new()
1127        }
1128    }
1129
1130    fn arch() -> LinuxInfo {
1131        LinuxInfo {
1132            id: "arch".into(),
1133            pretty_name: "Arch Linux".into(),
1134            id_like: vec![],
1135            manager: Some(PackageManager::Arch),
1136        }
1137    }
1138
1139    #[test]
1140    fn diffs_pair_changed_lines_and_keep_context() {
1141        let found = diff("f", "a\nb\nc\nd\ne\nf\ng\n", "a\nB\nc\nd\ne\nf\ng\nh\n").unwrap();
1142        assert!(found.changed);
1143        let tags: Vec<Tag> = found.rows.iter().map(|row| row.tag).collect();
1144        assert_eq!(tags[1], Tag::Changed);
1145        assert_eq!(*tags.last().unwrap(), Tag::Added);
1146        let shown = found.hunks(1);
1147        assert!(
1148            shown.contains(&None),
1149            "rows between the changes are left out"
1150        );
1151        let unified = found.unified();
1152        assert!(unified.contains("-b\n+B\n"), "{unified}");
1153        assert!(unified.contains("+h\n"));
1154        assert!(!diff("f", "same\n", "same\n").unwrap().changed);
1155        // Escape sequences in a file never reach the terminal.
1156        let evil = diff("f", "", "\u{1b}[2J\n").unwrap();
1157        assert!(!evil.rows[0].after.as_ref().unwrap().contains('\u{1b}'));
1158    }
1159
1160    #[test]
1161    fn a_grub_theme_shows_its_new_settings_and_what_cannot_be_known() {
1162        let catalog = Catalog::builtin(false).unwrap();
1163        let entry = catalog.find("grub-dracula").unwrap();
1164        let mut fake = Fake::default();
1165        fake.files.insert(
1166            PathBuf::from("/etc/default/grub"),
1167            "GRUB_TIMEOUT=5\nGRUB_CMDLINE_LINUX_DEFAULT=\"quiet\"\n".into(),
1168        );
1169        let render = |arguments: &[String]| -> Result<String, String> {
1170            assert_eq!(arguments[0], "grub");
1171            assert_eq!(arguments[1], "/etc/default/grub");
1172            assert!(arguments[2].starts_with("/boot/grub/themes/commander-dracula-"));
1173            Ok(format!(
1174                "GRUB_TIMEOUT=5\nGRUB_CMDLINE_LINUX_DEFAULT=\"quiet\"\nGRUB_THEME=\"{}\"\n",
1175                arguments[2]
1176            ))
1177        };
1178        let look = Look {
1179            system: &fake,
1180            render: &render,
1181            pinned: &|_| Pinned::Unusable("none".into()),
1182        };
1183        let planned = crate::plan_with(
1184            [entry],
1185            &arch(),
1186            &|id| catalog.find(id),
1187            crate::Machine {
1188                holds: &|_| false,
1189                on_path: &|_| false,
1190            },
1191        )
1192        .unwrap();
1193        let found = preview(&planned, &|id| catalog.find(id), &arch(), &look);
1194        assert_eq!(found.len(), 1);
1195        let changes = &found[0].changes;
1196        let grub = changes
1197            .iter()
1198            .find(|change| change.text.starts_with("/etc/default/grub"))
1199            .unwrap();
1200        assert_eq!(grub.certainty, Certainty::Known);
1201        let diff = grub.diff.as_ref().unwrap();
1202        assert!(diff.changed);
1203        assert!(diff.unified().contains("+GRUB_THEME="));
1204        assert!(changes.iter().any(|change| change.area == Area::Boot));
1205        assert!(
1206            changes
1207                .iter()
1208                .any(|change| change.certainty == Certainty::Unknown
1209                    && change.text.contains("grub-mkconfig"))
1210        );
1211        assert!(
1212            changes
1213                .iter()
1214                .any(|change| change.text.contains("<date and time of the run>"))
1215        );
1216    }
1217
1218    #[test]
1219    fn packages_list_what_the_package_manager_would_add() {
1220        let catalog = Catalog::builtin(false).unwrap();
1221        let mut fake = Fake::default();
1222        fake.commands.insert(
1223            "pacman -Sp --print-format %n %v neovim".into(),
1224            "luajit 2.1-1\nneovim 0.11.0-1\n".into(),
1225        );
1226        let render = |_: &[String]| Err("none".to_string());
1227        let look = Look {
1228            system: &fake,
1229            render: &render,
1230            pinned: &|_| Pinned::Unusable("none".into()),
1231        };
1232        let planned = crate::plan_with(
1233            [catalog.find("neovim").unwrap()],
1234            &arch(),
1235            &|id| catalog.find(id),
1236            crate::Machine {
1237                holds: &|_| false,
1238                on_path: &|_| false,
1239            },
1240        )
1241        .unwrap();
1242        let found = preview(&planned, &|id| catalog.find(id), &arch(), &look);
1243        let texts: Vec<&str> = found[0]
1244            .changes
1245            .iter()
1246            .map(|change| change.text.as_str())
1247            .collect();
1248        assert!(
1249            texts[0].starts_with("installs neovim from pacman"),
1250            "{texts:?}"
1251        );
1252        assert!(
1253            texts
1254                .iter()
1255                .any(|text| text.contains("also install: luajit 2.1-1")),
1256            "{texts:?}"
1257        );
1258    }
1259
1260    #[test]
1261    fn a_restore_shows_the_file_it_puts_back() {
1262        let mut fake = Fake::default();
1263        fake.files
1264            .insert(PathBuf::from("/home/me/.config/x.conf"), "new\n".into());
1265        fake.files
1266            .insert(PathBuf::from("/home/me/.config/x.conf.bak"), "old\n".into());
1267        let backup = crate::backups::Backup {
1268            original: PathBuf::from("/home/me/.config/x.conf"),
1269            backup: PathBuf::from("/home/me/.config/x.conf.bak"),
1270            taken: None,
1271            system: false,
1272            directory: false,
1273        };
1274        let render = |_: &[String]| Err("none".to_string());
1275        let look = Look {
1276            system: &fake,
1277            render: &render,
1278            pinned: &|_| Pinned::Unusable("none".into()),
1279        };
1280        let found = preview(
1281            &crate::backups::plan_restore(&backup),
1282            &|_| None,
1283            &arch(),
1284            &look,
1285        );
1286        let diff = found[0].changes[0].diff.as_ref().unwrap();
1287        assert_eq!(diff.rows[0].before.as_deref(), Some("new"));
1288        assert_eq!(diff.rows[0].after.as_deref(), Some("old"));
1289    }
1290
1291    /// A dotfiles entry's step, made directly: a plan would leave it out
1292    /// on a machine where Home Manager manages the file.
1293    fn dotfiles_plan(catalog: &Catalog, id: &str) -> Plan {
1294        let entry = catalog.find(id).unwrap();
1295        Plan {
1296            steps: vec![Step::Script {
1297                id: entry.id.clone(),
1298                name: entry.name.clone(),
1299                file: PathBuf::from(format!("/scripts/installers/commander/{id}.sh")),
1300                program: "sh".into(),
1301                args: Vec::new(),
1302                interactive: true,
1303            }],
1304            ..Plan::default()
1305        }
1306    }
1307
1308    #[test]
1309    fn dotfiles_are_compared_file_by_file_from_their_pinned_source() {
1310        let catalog = Catalog::builtin(false).unwrap();
1311        let mut fake = Fake::default();
1312        fake.files
1313            .insert(PathBuf::from("/home/me/.config/nvim"), String::new());
1314        fake.files.insert(
1315            PathBuf::from("/home/me/.config/nvim/init.lua"),
1316            "-- mine\n".into(),
1317        );
1318        let render = |arguments: &[String]| -> Result<String, String> {
1319            assert_eq!(arguments, ["dotfiles-preview", "/sources/dotfiles", "nvim"]);
1320            Ok(serde_json::json!({
1321                "folders": ["/home/me/.config/nvim"],
1322                "files": [
1323                    {"path": "/home/me/.config/nvim/init.lua", "state": "changed", "text": "require(\"commander\")\n"},
1324                    {"path": "/home/me/.config/nvim/lua/new.lua", "state": "new", "text": "-- new\n"},
1325                    {"path": "/home/me/.config/nvim/logo.png", "state": "new", "text": null},
1326                    {"path": "/home/me/.config/nvim/local.lua", "state": "removed", "text": null},
1327                    {"path": "/home/me/.config/nvim/same.lua", "state": "same", "text": "x\n"},
1328                ],
1329            })
1330            .to_string())
1331        };
1332        let look = Look {
1333            system: &fake,
1334            render: &render,
1335            pinned: &|name| {
1336                assert_eq!(name, "dotfiles");
1337                Pinned::Here(PathBuf::from("/sources/dotfiles"))
1338            },
1339        };
1340        let planned = dotfiles_plan(&catalog, "dotfiles-nvim");
1341        let found = preview(&planned, &|id| catalog.find(id), &arch(), &look);
1342        let changes = &found[0].changes;
1343        let texts: Vec<&str> = changes.iter().map(|change| change.text.as_str()).collect();
1344        assert_eq!(
1345            texts[0],
1346            "~/.config/nvim: replaced as a whole, keeping the current folder as a backup: 1 changed, 2 added, 1 left in the backup only, 1 the same"
1347        );
1348        let init = &changes[1];
1349        assert_eq!(init.certainty, Certainty::Known);
1350        assert_eq!(init.text, "~/.config/nvim/init.lua: changed");
1351        let unified = init.diff.as_ref().unwrap().unified();
1352        assert!(
1353            unified.contains("--- ~/.config/nvim/init.lua (now)"),
1354            "{unified}"
1355        );
1356        assert!(
1357            unified.contains("--- mine\n+require(\"commander\")"),
1358            "{unified}"
1359        );
1360        assert!(
1361            changes[2]
1362                .diff
1363                .as_ref()
1364                .unwrap()
1365                .unified()
1366                .contains("+-- new")
1367        );
1368        assert!(texts[3].ends_with("it is not text, so it is not shown side by side"));
1369        assert_eq!(
1370            texts[4],
1371            "~/.config/nvim/local.lua: only in the current folder, so it stays in the backup"
1372        );
1373        assert!(!texts.iter().any(|text| text.contains("same.lua")));
1374        assert!(
1375            texts
1376                .last()
1377                .unwrap()
1378                .starts_with("anything else the script does"),
1379            "{texts:?}"
1380        );
1381    }
1382
1383    #[test]
1384    fn dotfiles_without_their_source_say_how_to_get_it() {
1385        let catalog = Catalog::builtin(false).unwrap();
1386        let mut fake = Fake::default();
1387        fake.files.insert(
1388            PathBuf::from("/home/me/.config/starship.toml"),
1389            "x\n".into(),
1390        );
1391        let render = |_: &[String]| -> Result<String, String> { panic!("nothing to render") };
1392        let look = Look {
1393            system: &fake,
1394            render: &render,
1395            pinned: &|_| Pinned::Missing {
1396                repository: "Commanderx-code/dotfiles".into(),
1397                revision: "42eea7a576fcdc1fefe8ff70d73bf417f5fdc89c".into(),
1398            },
1399        };
1400        let planned = dotfiles_plan(&catalog, "dotfiles-starship");
1401        let found = preview(&planned, &|id| catalog.find(id), &arch(), &look);
1402        let starship = &found[0].changes[0];
1403        assert_eq!(starship.certainty, Certainty::Expected);
1404        assert!(
1405            starship.text.contains("github.com/Commanderx-code/dotfiles at commit 42eea7a576fc, which is not on this machine yet; `helixforge --effects plan --fetch-sources dotfiles-starship`"),
1406            "{}",
1407            starship.text
1408        );
1409        assert_eq!(sources_for(&planned), ["dotfiles"]);
1410        // An installer that refuses says why, as it would when it runs.
1411        let refused = |_: &[String]| -> Result<String, String> {
1412            Err(
1413                "/home/me/.config/starship.toml is symlink-managed; update its owner instead."
1414                    .into(),
1415            )
1416        };
1417        let look = Look {
1418            system: &fake,
1419            render: &refused,
1420            pinned: &|_| Pinned::Here(PathBuf::from("/sources/dotfiles")),
1421        };
1422        let found = preview(&planned, &|id| catalog.find(id), &arch(), &look);
1423        assert!(found[0].changes[0].text.ends_with(
1424            "its new contents could not be worked out: /home/me/.config/starship.toml is symlink-managed; update its owner instead."
1425        ));
1426    }
1427
1428    #[test]
1429    fn a_pinned_source_is_found_only_at_its_revision() {
1430        let catalog = Catalog::builtin(false).unwrap();
1431        let scripts = catalog.root().join("scripts");
1432        let empty = tempfile::tempdir().unwrap();
1433        assert!(matches!(
1434            pinned_in(&scripts, empty.path(), "nothing"),
1435            Pinned::Unusable(_)
1436        ));
1437        let Pinned::Missing {
1438            repository,
1439            revision,
1440        } = pinned_in(&scripts, empty.path(), "dotfiles")
1441        else {
1442            panic!("an empty folder has no sources");
1443        };
1444        assert_eq!(repository, "Commanderx-code/dotfiles");
1445        assert_eq!(revision.len(), 40);
1446        std::fs::create_dir(empty.path().join(format!("dotfiles-{revision}"))).unwrap();
1447        assert!(matches!(
1448            pinned_in(&scripts, empty.path(), "dotfiles"),
1449            Pinned::Unusable(why) if why.contains("not at the pinned revision")
1450        ));
1451    }
1452}