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 expand = expander(look.system);
533    for file in &effects.files {
534        let path = expand(&file.path);
535        let when = file
536            .when
537            .as_deref()
538            .map(|when| format!(" (when {when})"))
539            .unwrap_or_default();
540        let shown = PathBuf::from(&path);
541        let exists = look.system.exists(&shown);
542        let mut found = change(
543            Certainty::Expected,
544            Area::File,
545            format!("{path}: {}{when}", file.change),
546        );
547        if !file.render.is_empty() {
548            if !exists {
549                found
550                    .text
551                    .push_str("; it is not on this machine, so this does not apply");
552            } else {
553                let arguments: Vec<String> = file.render.iter().map(|word| expand(word)).collect();
554                match (read_text(look.system, &shown), (look.render)(&arguments)) {
555                    (Some(before), Ok(after)) => {
556                        found.certainty = Certainty::Known;
557                        found.diff = diff(&path, &before, &after);
558                        if found.diff.as_ref().is_some_and(|diff| !diff.changed) {
559                            found
560                                .text
561                                .push_str("; it already says this, so nothing changes");
562                        }
563                    }
564                    (_, Err(why)) => found.text.push_str(&printable(&format!(
565                        "; its new contents could not be worked out: {why}"
566                    ))),
567                    (None, _) => found.text.push_str("; it cannot be read here to compare"),
568                }
569            }
570        } else if file.path.contains("{time}") || !exists {
571            found.text.push_str(" (created)");
572        }
573        changes.push(found);
574    }
575    if let Some(when) = &effects.takes_effect {
576        changes.push(change(
577            Certainty::Expected,
578            Area::Boot,
579            format!("takes effect: {when}"),
580        ));
581    }
582    for unknown in &effects.unknown {
583        changes.push(change(Certainty::Unknown, Area::Other, unknown.clone()));
584    }
585    changes
586}
587
588/// Fill in a declared path's placeholders for this machine.
589fn expander(system: &dyn System) -> impl Fn(&str) -> String {
590    let grub = if system.exists(Path::new("/boot/grub2")) {
591        "/boot/grub2"
592    } else {
593        "/boot/grub"
594    };
595    move |text: &str| {
596        text.replace("{grub}", grub)
597            .replace("{time}", "<date and time of the run>")
598    }
599}
600
601/// Whether the files an entry's installer can render already say what it
602/// would write, however they came to: `Some(true)` when every one here
603/// does, `Some(false)` when one does not, `None` when there is none to
604/// render on this machine.
605pub(crate) fn rendered_in_place(
606    entry: &Entry,
607    system: &dyn System,
608    render: &dyn Fn(&[String]) -> Result<String, String>,
609) -> Option<bool> {
610    let expand = expander(system);
611    let mut found = None;
612    for file in entry
613        .effects
614        .files
615        .iter()
616        .filter(|file| !file.render.is_empty())
617    {
618        let path = PathBuf::from(expand(&file.path));
619        if !system.exists(&path) {
620            continue;
621        }
622        let arguments: Vec<String> = file.render.iter().map(|word| expand(word)).collect();
623        let (Some(now), Ok(after)) = (read_text(system, &path), render(&arguments)) else {
624            return None;
625        };
626        if now != after {
627            return Some(false);
628        }
629        found = Some(true);
630    }
631    found
632}
633
634/// The user paths an entry says it writes: file by file from its pinned
635/// source where that is on this machine and the installer can list them.
636fn writes(entry: &Entry, file: &Path, look: &Look) -> Vec<Change> {
637    let system = look.system;
638    let mut why =
639        "the new contents come from the entry's pinned source, read as it runs".to_string();
640    if let Some(component) = dotfiles_component(file) {
641        match dotfiles(entry, component, look) {
642            Ok(changes) => return changes,
643            Err(reason) => why = reason,
644        }
645    }
646    let (_, _, home) = system.user();
647    entry
648        .writes
649        .iter()
650        .map(|written| {
651            let path = match written.strip_prefix("~/") {
652                Some(rest) => home.join(rest),
653                None => PathBuf::from(written),
654            };
655            let what = if system.is_link(&path) {
656                "replaced; it is a link now, which the installer refuses to overwrite if Home Manager manages it"
657            } else if system.exists(&path) {
658                "replaced, keeping the current one as a backup"
659            } else {
660                "created"
661            };
662            let mut found = change(Certainty::Expected, Area::File, format!("{written}: {what}"));
663            if system.exists(&path) {
664                found.text.push_str("; ");
665                found.text.push_str(&why);
666            }
667            found
668        })
669        .collect()
670}
671
672/// The dotfiles component a script installs, from its name.
673fn dotfiles_component(file: &Path) -> Option<&'static str> {
674    let name = file.file_stem()?.to_str()?.strip_prefix("dotfiles-")?;
675    DOTFILES.into_iter().find(|component| *component == name)
676}
677
678#[derive(Deserialize)]
679struct Listing {
680    files: Vec<Listed>,
681    folders: Vec<String>,
682}
683
684#[derive(Deserialize)]
685struct Listed {
686    path: String,
687    state: String,
688    /// The new contents, when they are text.
689    text: Option<String>,
690}
691
692/// A dotfiles component's files, compared with the ones here, from the
693/// installer's own listing of its pinned source; or why that cannot be done.
694fn dotfiles(entry: &Entry, component: &str, look: &Look) -> Result<Vec<Change>, String> {
695    let source = match (look.pinned)("dotfiles") {
696        Pinned::Here(source) => source,
697        Pinned::Missing {
698            repository,
699            revision,
700        } => {
701            return Err(format!(
702                "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",
703                revision.get(..12).unwrap_or(&revision),
704                entry.id
705            ));
706        }
707        Pinned::Unusable(why) => {
708            return Err(printable(&format!(
709                "the new contents come from the entry's pinned source, which cannot be compared: {why}"
710            )));
711        }
712    };
713    let arguments = [
714        DOTFILES_PREVIEW.to_string(),
715        source.display().to_string(),
716        component.to_string(),
717    ];
718    let listing: Listing = (look.render)(&arguments)
719        .map_err(|why| printable(&format!("its new contents could not be worked out: {why}")))
720        .and_then(|text| {
721            serde_json::from_str(&text)
722                .map_err(|why| format!("its new contents could not be worked out: {why}"))
723        })?;
724    let (_, _, home) = look.system.user();
725    let shown = |path: &str| printable(&crate::drift::tilde(&home, path));
726    let in_folder = |path: &str| {
727        listing
728            .folders
729            .iter()
730            .find(|folder| Path::new(path).starts_with(folder.as_str()))
731    };
732    let compared = |listed: &Listed, text: String| {
733        let mut found = change(Certainty::Known, Area::File, text);
734        match (&listed.text, listed.state.as_str()) {
735            (None, "new" | "changed") => found
736                .text
737                .push_str("; it is not text, so it is not shown side by side"),
738            (Some(after), "new") => found.diff = diff(&shown(&listed.path), "", after),
739            (Some(after), "changed") => match read_text(look.system, Path::new(&listed.path)) {
740                Some(before) => found.diff = diff(&shown(&listed.path), &before, after),
741                None => found.text.push_str("; it cannot be read here to compare"),
742            },
743            _ => {}
744        }
745        found
746    };
747    let mut changes = Vec::new();
748    for folder in &listing.folders {
749        let inside: Vec<&Listed> = listing
750            .files
751            .iter()
752            .filter(|listed| in_folder(&listed.path) == Some(folder))
753            .collect();
754        let count = |state: &str| inside.iter().filter(|listed| listed.state == state).count();
755        let what = if look.system.exists(Path::new(folder)) {
756            format!(
757                "replaced as a whole, keeping the current folder as a backup: {} changed, {} added, {} left in the backup only, {} the same",
758                count("changed"),
759                count("new"),
760                count("removed"),
761                count("same")
762            )
763        } else {
764            format!("created, with {} files", inside.len())
765        };
766        changes.push(change(
767            Certainty::Known,
768            Area::File,
769            format!("{}: {what}", shown(folder)),
770        ));
771        let differing: Vec<&&Listed> = inside
772            .iter()
773            .filter(|listed| listed.state != "same")
774            .collect();
775        for listed in differing.iter().take(FOLDER_FILES) {
776            let what = match listed.state.as_str() {
777                "new" => "added",
778                "changed" => "changed",
779                _ => "only in the current folder, so it stays in the backup",
780            };
781            changes.push(compared(listed, format!("{}: {what}", shown(&listed.path))));
782        }
783        if differing.len() > FOLDER_FILES {
784            changes.push(change(
785                Certainty::Known,
786                Area::File,
787                format!(
788                    "{}: and {} more files that differ",
789                    shown(folder),
790                    differing.len() - FOLDER_FILES
791                ),
792            ));
793        }
794    }
795    for listed in listing
796        .files
797        .iter()
798        .filter(|listed| in_folder(&listed.path).is_none())
799    {
800        let what = match listed.state.as_str() {
801            "new" => "created",
802            "changed" => "replaced, keeping the current one as a backup",
803            _ => "already says this; it is written again, keeping the current one as a backup",
804        };
805        changes.push(compared(listed, format!("{}: {what}", shown(&listed.path))));
806    }
807    Ok(changes)
808}
809
810/// Where `fetch_source` keeps its downloads.
811fn sources_folder() -> Option<PathBuf> {
812    match std::env::var_os("XDG_DATA_HOME").filter(|value| !value.is_empty()) {
813        Some(data) => Some(PathBuf::from(data)),
814        None => Some(PathBuf::from(std::env::var_os("HOME")?).join(".local/share")),
815    }
816    .map(|data| data.join("helixforge/sources"))
817}
818
819/// Find a pinned source on this machine, as `fetch_source` would before
820/// using it: at its revision and unedited. `scripts` is the unpacked
821/// scripts folder. Only reads.
822pub fn pinned_source(scripts: &Path, name: &str) -> Pinned {
823    match sources_folder() {
824        Some(sources) => pinned_in(scripts, &sources, name),
825        None => Pinned::Unusable("there is no home folder".to_string()),
826    }
827}
828
829fn pinned_in(scripts: &Path, sources: &Path, name: &str) -> Pinned {
830    let Some((_, repository, revision)) = crate::trust::pins(scripts)
831        .into_iter()
832        .find(|(pinned, _, _)| pinned == name)
833    else {
834        return Pinned::Unusable(format!("{name} is not a pinned source"));
835    };
836    let folder = sources.join(format!("{name}-{revision}"));
837    if !folder.exists() {
838        return Pinned::Missing {
839            repository,
840            revision,
841        };
842    }
843    let git = |arguments: &[&str]| {
844        std::process::Command::new("git")
845            .arg("-C")
846            .arg(&folder)
847            .args(arguments)
848            .stdin(std::process::Stdio::null())
849            .output()
850            .ok()
851            .filter(|output| output.status.success())
852            .map(|output| String::from_utf8_lossy(&output.stdout).trim().to_string())
853    };
854    if git(&["rev-parse", "HEAD"]).as_deref() != Some(revision.as_str()) {
855        return Pinned::Unusable(format!(
856            "{} is not at the pinned revision",
857            folder.display()
858        ));
859    }
860    match git(&["status", "--porcelain", "--untracked-files=all"]) {
861        Some(status) if status.is_empty() => Pinned::Here(folder),
862        Some(_) => Pinned::Unusable(format!(
863            "{} has edits; move it aside to fetch a fresh copy",
864            folder.display()
865        )),
866        None => Pinned::Unusable(format!("git cannot read {}", folder.display())),
867    }
868}
869
870/// The pinned sources a plan's dotfiles entries read.
871pub fn sources_for(plan: &Plan) -> Vec<&'static str> {
872    let mut names = Vec::new();
873    for step in &plan.steps {
874        if let Step::Script { file, .. } = step
875            && dotfiles_component(file).is_some()
876            && !names.contains(&"dotfiles")
877        {
878            names.push("dotfiles");
879        }
880    }
881    names
882}
883
884/// Download a pinned source into the folder the installers read it from,
885/// with the installers' own `fetch_source`, which checks the revision. It
886/// changes nothing else. `scripts` is the unpacked scripts folder.
887pub fn fetch_source(scripts: &Path, name: &str) -> Result<PathBuf, String> {
888    if let Pinned::Unusable(why) = pinned_source(scripts, name) {
889        return Err(why);
890    }
891    let output = std::process::Command::new("sh")
892        .args(["-ec", ". ./common.sh; fetch_source \"$1\"", "sh", name])
893        .current_dir(scripts.join("installers/commander"))
894        .env(crate::LIB_VARIABLE, scripts.join("shared"))
895        .stdin(std::process::Stdio::null())
896        .output()
897        .map_err(|error| format!("cannot run sh: {error}"))?;
898    if !output.status.success() {
899        let mut why = String::from_utf8_lossy(&output.stderr).trim().to_string();
900        if why.is_empty() {
901            why = String::from_utf8_lossy(&output.stdout).trim().to_string();
902        }
903        return Err(printable(&why));
904    }
905    match pinned_source(scripts, name) {
906        Pinned::Here(folder) => Ok(folder),
907        Pinned::Missing { .. } => Err(format!("{name} was not downloaded")),
908        Pinned::Unusable(why) => Err(why),
909    }
910}
911
912/// Services and system paths the script names, which it may change.
913fn seen_in(file: &Path) -> Vec<Change> {
914    let Ok(text) = std::fs::read_to_string(file) else {
915        return Vec::new();
916    };
917    let mut services: Vec<String> = Vec::new();
918    for line in text.lines().filter(|line| {
919        let line = line.trim_start();
920        // Comments and messages name commands without running them.
921        !line.starts_with('#') && !line.starts_with("printf") && !line.starts_with("echo")
922    }) {
923        let Some(at) = line.find("systemctl ") else {
924            continue;
925        };
926        let words: Vec<&str> = line[at + "systemctl ".len()..].split_whitespace().collect();
927        let Some(verb) = words.iter().find(|word| !word.starts_with('-')) else {
928            continue;
929        };
930        if !matches!(
931            *verb,
932            "enable" | "disable" | "start" | "stop" | "restart" | "reload" | "mask"
933        ) {
934            continue;
935        }
936        let unit = words
937            .iter()
938            .skip_while(|word| *word != verb)
939            .skip(1)
940            .find(|word| !word.starts_with('-'))
941            .map(|word| word.trim_matches(|c: char| "\"';.,)".contains(c)))
942            .unwrap_or("a unit");
943        let unit = if unit.starts_with('$') {
944            "a unit it chooses"
945        } else {
946            unit
947        };
948        let text = format!("may {verb} {unit}");
949        if !services.contains(&text) {
950            services.push(text);
951        }
952    }
953    services
954        .into_iter()
955        .map(|text| {
956            change(
957                Certainty::Expected,
958                Area::Service,
959                format!("{text} (seen in its script)"),
960            )
961        })
962        .collect()
963}
964
965fn read_text(system: &dyn System, path: &Path) -> Option<String> {
966    let text = system.read(path)?;
967    (text.len() <= DIFF_BYTES).then_some(text)
968}
969
970/// Compare a file's lines now and after, side by side. None when either is
971/// too long to compare line by line.
972pub fn diff(path: &str, before: &str, after: &str) -> Option<Diff> {
973    let old: Vec<&str> = before.lines().collect();
974    let new: Vec<&str> = after.lines().collect();
975    if old.len() > DIFF_LINES || new.len() > DIFF_LINES {
976        return None;
977    }
978    // Longest common subsequence, then walk it.
979    let (n, m) = (old.len(), new.len());
980    let mut table = vec![vec![0u32; m + 1]; n + 1];
981    for i in (0..n).rev() {
982        for j in (0..m).rev() {
983            table[i][j] = if old[i] == new[j] {
984                table[i + 1][j + 1] + 1
985            } else {
986                table[i + 1][j].max(table[i][j + 1])
987            };
988        }
989    }
990    let mut rows = Vec::new();
991    let (mut i, mut j) = (0, 0);
992    let mut removed: Vec<&str> = Vec::new();
993    let mut added: Vec<&str> = Vec::new();
994    let flush = |rows: &mut Vec<Row>, removed: &mut Vec<&str>, added: &mut Vec<&str>| {
995        let pairs = removed.len().max(added.len());
996        for index in 0..pairs {
997            let before = removed.get(index).map(|line| printable(line));
998            let after = added.get(index).map(|line| printable(line));
999            let tag = match (&before, &after) {
1000                (Some(_), Some(_)) => Tag::Changed,
1001                (Some(_), None) => Tag::Removed,
1002                _ => Tag::Added,
1003            };
1004            rows.push(Row { before, after, tag });
1005        }
1006        removed.clear();
1007        added.clear();
1008    };
1009    while i < n || j < m {
1010        if i < n && j < m && old[i] == new[j] {
1011            flush(&mut rows, &mut removed, &mut added);
1012            rows.push(Row {
1013                before: Some(printable(old[i])),
1014                after: Some(printable(new[j])),
1015                tag: Tag::Same,
1016            });
1017            i += 1;
1018            j += 1;
1019        } else if j < m && (i == n || table[i][j + 1] >= table[i + 1][j]) {
1020            added.push(new[j]);
1021            j += 1;
1022        } else {
1023            removed.push(old[i]);
1024            i += 1;
1025        }
1026    }
1027    flush(&mut rows, &mut removed, &mut added);
1028    let changed = rows.iter().any(|row| row.tag != Tag::Same);
1029    Some(Diff {
1030        path: printable(path),
1031        rows,
1032        changed,
1033    })
1034}
1035
1036impl Diff {
1037    /// The rows that differ, each with up to `context` unchanged rows around
1038    /// it; None between runs marks the rows left out.
1039    pub fn hunks(&self, context: usize) -> Vec<Option<&Row>> {
1040        let keep: Vec<bool> = (0..self.rows.len())
1041            .map(|index| {
1042                let low = index.saturating_sub(context);
1043                let high = (index + context + 1).min(self.rows.len());
1044                self.rows[low..high].iter().any(|row| row.tag != Tag::Same)
1045            })
1046            .collect();
1047        let mut shown = Vec::new();
1048        let mut gap = false;
1049        for (index, row) in self.rows.iter().enumerate() {
1050            if keep[index] {
1051                if gap && !shown.is_empty() {
1052                    shown.push(None);
1053                }
1054                gap = false;
1055                shown.push(Some(row));
1056            } else {
1057                gap = true;
1058            }
1059        }
1060        shown
1061    }
1062
1063    /// The differing rows as a unified diff, for the command line.
1064    pub fn unified(&self) -> String {
1065        let mut text = format!("--- {} (now)\n+++ {} (after)\n", self.path, self.path);
1066        for row in self.hunks(2) {
1067            match row {
1068                None => text.push_str("@@\n"),
1069                Some(row) => match row.tag {
1070                    Tag::Same => {
1071                        text.push_str(&format!(" {}\n", row.before.as_deref().unwrap_or("")))
1072                    }
1073                    _ => {
1074                        if let Some(before) = &row.before {
1075                            text.push_str(&format!("-{before}\n"));
1076                        }
1077                        if let Some(after) = &row.after {
1078                            text.push_str(&format!("+{after}\n"));
1079                        }
1080                    }
1081                },
1082            }
1083        }
1084        text
1085    }
1086}
1087
1088/// Run a renderer from the installers' folder: `python3 configure.py ARGS`,
1089/// which only prints. `scripts` is the unpacked scripts folder.
1090pub fn render_with(scripts: &Path, arguments: &[String]) -> Result<String, String> {
1091    if !arguments.first().is_some_and(|action| {
1092        helixforge_core::RENDERERS.contains(&action.as_str()) || action == DOTFILES_PREVIEW
1093    }) {
1094        return Err("not a renderer".to_string());
1095    }
1096    let output = std::process::Command::new("python3")
1097        .arg("-B")
1098        .arg("./configure.py")
1099        .args(arguments)
1100        .current_dir(scripts.join("installers/commander"))
1101        .env("LC_ALL", "C.UTF-8")
1102        .stdin(std::process::Stdio::null())
1103        .output()
1104        .map_err(|error| format!("cannot run python3: {error}"))?;
1105    if output.status.success() {
1106        Ok(String::from_utf8_lossy(&output.stdout).into_owned())
1107    } else {
1108        Err(String::from_utf8_lossy(&output.stderr).trim().to_string())
1109    }
1110}
1111
1112#[cfg(test)]
1113mod tests {
1114    use super::*;
1115    use helixforge_catalog::Catalog;
1116    use std::collections::HashMap;
1117
1118    #[derive(Default)]
1119    struct Fake {
1120        files: HashMap<PathBuf, String>,
1121        commands: HashMap<String, String>,
1122    }
1123
1124    impl System for Fake {
1125        fn read(&self, path: &Path) -> Option<String> {
1126            self.files.get(path).cloned()
1127        }
1128        fn exists(&self, path: &Path) -> bool {
1129            self.files.contains_key(path)
1130        }
1131        fn list(&self, _: &Path) -> Vec<String> {
1132            Vec::new()
1133        }
1134        fn link(&self, _: &Path) -> Option<PathBuf> {
1135            None
1136        }
1137        fn run(&self, command: &[&str]) -> Option<(bool, String)> {
1138            self.commands
1139                .get(&command.join(" "))
1140                .map(|text| (true, text.clone()))
1141        }
1142        fn resolve(&self, _: &str) -> Result<Vec<String>, String> {
1143            Err("offline".into())
1144        }
1145        fn space(&self, _: &Path) -> Option<(u64, u64)> {
1146            None
1147        }
1148        fn owner(&self, _: &Path) -> Option<u32> {
1149            None
1150        }
1151        fn is_link(&self, _: &Path) -> bool {
1152            false
1153        }
1154        fn canonical(&self, _: &Path) -> Option<PathBuf> {
1155            None
1156        }
1157        fn user(&self) -> (u32, u32, PathBuf) {
1158            (1000, 1000, PathBuf::from("/home/me"))
1159        }
1160        fn on_path(&self, _: &str) -> bool {
1161            false
1162        }
1163        fn processes(&self) -> Vec<String> {
1164            Vec::new()
1165        }
1166    }
1167
1168    fn arch() -> LinuxInfo {
1169        LinuxInfo {
1170            id: "arch".into(),
1171            pretty_name: "Arch Linux".into(),
1172            id_like: vec![],
1173            manager: Some(PackageManager::Arch),
1174        }
1175    }
1176
1177    #[test]
1178    fn diffs_pair_changed_lines_and_keep_context() {
1179        let found = diff("f", "a\nb\nc\nd\ne\nf\ng\n", "a\nB\nc\nd\ne\nf\ng\nh\n").unwrap();
1180        assert!(found.changed);
1181        let tags: Vec<Tag> = found.rows.iter().map(|row| row.tag).collect();
1182        assert_eq!(tags[1], Tag::Changed);
1183        assert_eq!(*tags.last().unwrap(), Tag::Added);
1184        let shown = found.hunks(1);
1185        assert!(
1186            shown.contains(&None),
1187            "rows between the changes are left out"
1188        );
1189        let unified = found.unified();
1190        assert!(unified.contains("-b\n+B\n"), "{unified}");
1191        assert!(unified.contains("+h\n"));
1192        assert!(!diff("f", "same\n", "same\n").unwrap().changed);
1193        // Escape sequences in a file never reach the terminal.
1194        let evil = diff("f", "", "\u{1b}[2J\n").unwrap();
1195        assert!(!evil.rows[0].after.as_ref().unwrap().contains('\u{1b}'));
1196    }
1197
1198    #[test]
1199    fn a_grub_theme_shows_its_new_settings_and_what_cannot_be_known() {
1200        let catalog = Catalog::builtin(false).unwrap();
1201        let entry = catalog.find("grub-dracula").unwrap();
1202        let mut fake = Fake::default();
1203        fake.files.insert(
1204            PathBuf::from("/etc/default/grub"),
1205            "GRUB_TIMEOUT=5\nGRUB_CMDLINE_LINUX_DEFAULT=\"quiet\"\n".into(),
1206        );
1207        let render = |arguments: &[String]| -> Result<String, String> {
1208            assert_eq!(arguments[0], "grub");
1209            assert_eq!(arguments[1], "/etc/default/grub");
1210            assert!(arguments[2].starts_with("/boot/grub/themes/commander-dracula-"));
1211            Ok(format!(
1212                "GRUB_TIMEOUT=5\nGRUB_CMDLINE_LINUX_DEFAULT=\"quiet\"\nGRUB_THEME=\"{}\"\n",
1213                arguments[2]
1214            ))
1215        };
1216        let look = Look {
1217            system: &fake,
1218            render: &render,
1219            pinned: &|_| Pinned::Unusable("none".into()),
1220        };
1221        let planned = crate::plan_with(
1222            [entry],
1223            &arch(),
1224            &|id| catalog.find(id),
1225            crate::Machine {
1226                holds: &|_| false,
1227                on_path: &|_| false,
1228            },
1229        )
1230        .unwrap();
1231        let found = preview(&planned, &|id| catalog.find(id), &arch(), &look);
1232        assert_eq!(found.len(), 1);
1233        let changes = &found[0].changes;
1234        let grub = changes
1235            .iter()
1236            .find(|change| change.text.starts_with("/etc/default/grub"))
1237            .unwrap();
1238        assert_eq!(grub.certainty, Certainty::Known);
1239        let diff = grub.diff.as_ref().unwrap();
1240        assert!(diff.changed);
1241        assert!(diff.unified().contains("+GRUB_THEME="));
1242        assert!(changes.iter().any(|change| change.area == Area::Boot));
1243        assert!(
1244            changes
1245                .iter()
1246                .any(|change| change.certainty == Certainty::Unknown
1247                    && change.text.contains("grub-mkconfig"))
1248        );
1249        assert!(
1250            changes
1251                .iter()
1252                .any(|change| change.text.contains("<date and time of the run>"))
1253        );
1254    }
1255
1256    #[test]
1257    fn packages_list_what_the_package_manager_would_add() {
1258        let catalog = Catalog::builtin(false).unwrap();
1259        let mut fake = Fake::default();
1260        fake.commands.insert(
1261            "pacman -Sp --print-format %n %v neovim".into(),
1262            "luajit 2.1-1\nneovim 0.11.0-1\n".into(),
1263        );
1264        let render = |_: &[String]| Err("none".to_string());
1265        let look = Look {
1266            system: &fake,
1267            render: &render,
1268            pinned: &|_| Pinned::Unusable("none".into()),
1269        };
1270        let planned = crate::plan_with(
1271            [catalog.find("neovim").unwrap()],
1272            &arch(),
1273            &|id| catalog.find(id),
1274            crate::Machine {
1275                holds: &|_| false,
1276                on_path: &|_| false,
1277            },
1278        )
1279        .unwrap();
1280        let found = preview(&planned, &|id| catalog.find(id), &arch(), &look);
1281        let texts: Vec<&str> = found[0]
1282            .changes
1283            .iter()
1284            .map(|change| change.text.as_str())
1285            .collect();
1286        assert!(
1287            texts[0].starts_with("installs neovim from pacman"),
1288            "{texts:?}"
1289        );
1290        assert!(
1291            texts
1292                .iter()
1293                .any(|text| text.contains("also install: luajit 2.1-1")),
1294            "{texts:?}"
1295        );
1296    }
1297
1298    #[test]
1299    fn a_restore_shows_the_file_it_puts_back() {
1300        let mut fake = Fake::default();
1301        fake.files
1302            .insert(PathBuf::from("/home/me/.config/x.conf"), "new\n".into());
1303        fake.files
1304            .insert(PathBuf::from("/home/me/.config/x.conf.bak"), "old\n".into());
1305        let backup = crate::backups::Backup {
1306            original: PathBuf::from("/home/me/.config/x.conf"),
1307            backup: PathBuf::from("/home/me/.config/x.conf.bak"),
1308            taken: None,
1309            system: false,
1310            directory: false,
1311        };
1312        let render = |_: &[String]| Err("none".to_string());
1313        let look = Look {
1314            system: &fake,
1315            render: &render,
1316            pinned: &|_| Pinned::Unusable("none".into()),
1317        };
1318        let found = preview(
1319            &crate::backups::plan_restore(&backup),
1320            &|_| None,
1321            &arch(),
1322            &look,
1323        );
1324        let diff = found[0].changes[0].diff.as_ref().unwrap();
1325        assert_eq!(diff.rows[0].before.as_deref(), Some("new"));
1326        assert_eq!(diff.rows[0].after.as_deref(), Some("old"));
1327    }
1328
1329    /// A dotfiles entry's step, made directly: a plan would leave it out
1330    /// on a machine where Home Manager manages the file.
1331    fn dotfiles_plan(catalog: &Catalog, id: &str) -> Plan {
1332        let entry = catalog.find(id).unwrap();
1333        Plan {
1334            steps: vec![Step::Script {
1335                id: entry.id.clone(),
1336                name: entry.name.clone(),
1337                file: PathBuf::from(format!("/scripts/installers/commander/{id}.sh")),
1338                program: "sh".into(),
1339                args: Vec::new(),
1340                interactive: true,
1341            }],
1342            ..Plan::default()
1343        }
1344    }
1345
1346    #[test]
1347    fn dotfiles_are_compared_file_by_file_from_their_pinned_source() {
1348        let catalog = Catalog::builtin(false).unwrap();
1349        let mut fake = Fake::default();
1350        fake.files
1351            .insert(PathBuf::from("/home/me/.config/nvim"), String::new());
1352        fake.files.insert(
1353            PathBuf::from("/home/me/.config/nvim/init.lua"),
1354            "-- mine\n".into(),
1355        );
1356        let render = |arguments: &[String]| -> Result<String, String> {
1357            assert_eq!(arguments, ["dotfiles-preview", "/sources/dotfiles", "nvim"]);
1358            Ok(serde_json::json!({
1359                "folders": ["/home/me/.config/nvim"],
1360                "files": [
1361                    {"path": "/home/me/.config/nvim/init.lua", "state": "changed", "text": "require(\"commander\")\n"},
1362                    {"path": "/home/me/.config/nvim/lua/new.lua", "state": "new", "text": "-- new\n"},
1363                    {"path": "/home/me/.config/nvim/logo.png", "state": "new", "text": null},
1364                    {"path": "/home/me/.config/nvim/local.lua", "state": "removed", "text": null},
1365                    {"path": "/home/me/.config/nvim/same.lua", "state": "same", "text": "x\n"},
1366                ],
1367            })
1368            .to_string())
1369        };
1370        let look = Look {
1371            system: &fake,
1372            render: &render,
1373            pinned: &|name| {
1374                assert_eq!(name, "dotfiles");
1375                Pinned::Here(PathBuf::from("/sources/dotfiles"))
1376            },
1377        };
1378        let planned = dotfiles_plan(&catalog, "dotfiles-nvim");
1379        let found = preview(&planned, &|id| catalog.find(id), &arch(), &look);
1380        let changes = &found[0].changes;
1381        let texts: Vec<&str> = changes.iter().map(|change| change.text.as_str()).collect();
1382        assert_eq!(
1383            texts[0],
1384            "~/.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"
1385        );
1386        let init = &changes[1];
1387        assert_eq!(init.certainty, Certainty::Known);
1388        assert_eq!(init.text, "~/.config/nvim/init.lua: changed");
1389        let unified = init.diff.as_ref().unwrap().unified();
1390        assert!(
1391            unified.contains("--- ~/.config/nvim/init.lua (now)"),
1392            "{unified}"
1393        );
1394        assert!(
1395            unified.contains("--- mine\n+require(\"commander\")"),
1396            "{unified}"
1397        );
1398        assert!(
1399            changes[2]
1400                .diff
1401                .as_ref()
1402                .unwrap()
1403                .unified()
1404                .contains("+-- new")
1405        );
1406        assert!(texts[3].ends_with("it is not text, so it is not shown side by side"));
1407        assert_eq!(
1408            texts[4],
1409            "~/.config/nvim/local.lua: only in the current folder, so it stays in the backup"
1410        );
1411        assert!(!texts.iter().any(|text| text.contains("same.lua")));
1412        assert!(
1413            texts
1414                .last()
1415                .unwrap()
1416                .starts_with("anything else the script does"),
1417            "{texts:?}"
1418        );
1419    }
1420
1421    #[test]
1422    fn dotfiles_without_their_source_say_how_to_get_it() {
1423        let catalog = Catalog::builtin(false).unwrap();
1424        let mut fake = Fake::default();
1425        fake.files.insert(
1426            PathBuf::from("/home/me/.config/starship.toml"),
1427            "x\n".into(),
1428        );
1429        let render = |_: &[String]| -> Result<String, String> { panic!("nothing to render") };
1430        let look = Look {
1431            system: &fake,
1432            render: &render,
1433            pinned: &|_| Pinned::Missing {
1434                repository: "Commanderx-code/dotfiles".into(),
1435                revision: "42eea7a576fcdc1fefe8ff70d73bf417f5fdc89c".into(),
1436            },
1437        };
1438        let planned = dotfiles_plan(&catalog, "dotfiles-starship");
1439        let found = preview(&planned, &|id| catalog.find(id), &arch(), &look);
1440        let starship = &found[0].changes[0];
1441        assert_eq!(starship.certainty, Certainty::Expected);
1442        assert!(
1443            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`"),
1444            "{}",
1445            starship.text
1446        );
1447        assert_eq!(sources_for(&planned), ["dotfiles"]);
1448        // An installer that refuses says why, as it would when it runs.
1449        let refused = |_: &[String]| -> Result<String, String> {
1450            Err(
1451                "/home/me/.config/starship.toml is symlink-managed; update its owner instead."
1452                    .into(),
1453            )
1454        };
1455        let look = Look {
1456            system: &fake,
1457            render: &refused,
1458            pinned: &|_| Pinned::Here(PathBuf::from("/sources/dotfiles")),
1459        };
1460        let found = preview(&planned, &|id| catalog.find(id), &arch(), &look);
1461        assert!(found[0].changes[0].text.ends_with(
1462            "its new contents could not be worked out: /home/me/.config/starship.toml is symlink-managed; update its owner instead."
1463        ));
1464    }
1465
1466    #[test]
1467    fn a_pinned_source_is_found_only_at_its_revision() {
1468        let catalog = Catalog::builtin(false).unwrap();
1469        let scripts = catalog.root().join("scripts");
1470        let empty = tempfile::tempdir().unwrap();
1471        assert!(matches!(
1472            pinned_in(&scripts, empty.path(), "nothing"),
1473            Pinned::Unusable(_)
1474        ));
1475        let Pinned::Missing {
1476            repository,
1477            revision,
1478        } = pinned_in(&scripts, empty.path(), "dotfiles")
1479        else {
1480            panic!("an empty folder has no sources");
1481        };
1482        assert_eq!(repository, "Commanderx-code/dotfiles");
1483        assert_eq!(revision.len(), 40);
1484        std::fs::create_dir(empty.path().join(format!("dotfiles-{revision}"))).unwrap();
1485        assert!(matches!(
1486            pinned_in(&scripts, empty.path(), "dotfiles"),
1487            Pinned::Unusable(why) if why.contains("not at the pinned revision")
1488        ));
1489    }
1490}