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::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}
107
108/// Files larger than this, or with lines past this count, get no diff.
109const DIFF_BYTES: usize = 1 << 20;
110const DIFF_LINES: usize = 4_000;
111
112/// Preview a plan's changes.
113pub fn preview<'a>(
114    plan: &Plan,
115    lookup: &dyn Fn(&str) -> Option<&'a Entry>,
116    platform: &LinuxInfo,
117    look: &Look,
118) -> Vec<StepPreview> {
119    plan.steps
120        .iter()
121        .map(|step| StepPreview {
122            id: step.id().to_string(),
123            name: step.name().to_string(),
124            changes: step_changes(step, lookup(step.id()), platform, look),
125        })
126        .collect()
127}
128
129fn change(certainty: Certainty, area: Area, text: impl Into<String>) -> Change {
130    Change {
131        certainty,
132        area,
133        text: printable(&text.into()),
134        diff: None,
135    }
136}
137
138fn step_changes(
139    step: &Step,
140    entry: Option<&Entry>,
141    platform: &LinuxInfo,
142    look: &Look,
143) -> Vec<Change> {
144    let mut changes = Vec::new();
145    match step {
146        Step::Package {
147            sources, removal, ..
148        } => {
149            let Some(first) = sources.first() else {
150                return changes;
151            };
152            let names: Vec<String> = package_names(first);
153            if *removal {
154                changes.push(change(
155                    Certainty::Known,
156                    Area::Package,
157                    format!("removes {} through {}", names.join(", "), first.label),
158                ));
159                changes.extend(simulate(platform.manager, &names, true, look.system));
160            } else {
161                changes.push(change(
162                    Certainty::Known,
163                    Area::Package,
164                    format!("installs {} from {}", names.join(", "), first.label),
165                ));
166                if first.label != "Flathub" && !first.label.starts_with("the AUR") {
167                    changes.extend(simulate(platform.manager, &names, false, look.system));
168                }
169                for other in &sources[1..] {
170                    changes.push(change(
171                        Certainty::Expected,
172                        Area::Package,
173                        format!("if that fails or leaves it missing, from {}", other.label),
174                    ));
175                }
176                if let Some(entry) = entry
177                    && let Some(identity) = entry.identity()
178                    && !identity.conflicts.is_empty()
179                {
180                    changes.push(change(
181                        Certainty::Expected,
182                        Area::Package,
183                        format!(
184                            "replaces {} where installed; the package manager asks first",
185                            identity.conflicts.join(", ")
186                        ),
187                    ));
188                }
189                if platform.manager == Some(PackageManager::Debian) {
190                    changes.push(change(
191                        Certainty::Unknown,
192                        Area::Service,
193                        "Debian and Ubuntu packages start the services they install; which, if any, is up to the package",
194                    ));
195                }
196            }
197        }
198        Step::Script { file, .. } => {
199            if let Some(entry) = entry {
200                changes.extend(declared(entry, look));
201                changes.extend(writes(entry, look.system));
202            }
203            // Declared services say more than a look at the script.
204            if entry.is_none_or(|entry| entry.effects.services.is_empty()) {
205                changes.extend(seen_in(file));
206            }
207            changes.push(change(
208                Certainty::Unknown,
209                Area::Other,
210                if entry.is_some_and(|entry| !entry.effects.is_empty()) {
211                    "anything else the script does as it runs, and the answers it asks you for"
212                } else {
213                    "what the script changes: it decides as it runs, so HelixForge cannot show it in advance"
214                },
215            ));
216        }
217        Step::Command { line, .. } => {
218            changes.push(change(
219                Certainty::Known,
220                Area::Other,
221                format!("runs {line}"),
222            ));
223            changes.push(change(
224                Certainty::Unknown,
225                Area::Other,
226                "what the command changes",
227            ));
228        }
229        Step::Nix { action, .. } => match action {
230            helixforge_core::NixAction::ProfileInstall { installable } => {
231                changes.push(change(
232                    Certainty::Known,
233                    Area::Package,
234                    format!("adds {installable} to your Nix profile; nothing outside the Nix store and your profile changes"),
235                ));
236            }
237            helixforge_core::NixAction::HomeManagerSwitch { flake } => {
238                changes.push(change(
239                    Certainty::Known,
240                    Area::Other,
241                    format!("switches Home Manager to {flake}"),
242                ));
243                changes.push(change(
244                    Certainty::Unknown,
245                    Area::File,
246                    "which files and packages change: the flake decides; home-manager build, then nvd diff, shows it",
247                ));
248            }
249        },
250        Step::Native { .. } => changes.push(change(
251            Certainty::Known,
252            Area::Other,
253            "only reads the system and prints what it finds",
254        )),
255        Step::Shell {
256            display,
257            changes: made,
258            ..
259        } => {
260            if made.is_empty() {
261                changes.push(change(
262                    Certainty::Known,
263                    Area::Other,
264                    format!("runs {display}"),
265                ));
266            }
267            for made in made {
268                let mut found = change(
269                    Certainty::Known,
270                    Area::File,
271                    format!("{}: {}", made.path.display(), made.text),
272                );
273                if let Some(from) = &made.from {
274                    let before = read_text(look.system, &made.path).unwrap_or_default();
275                    match read_text(look.system, from) {
276                        Some(after) => {
277                            found.diff = diff(&made.path.display().to_string(), &before, &after)
278                        }
279                        None => {
280                            found
281                                .text
282                                .push_str(" (its contents cannot be read here to compare)");
283                        }
284                    }
285                }
286                changes.push(found);
287            }
288        }
289        Step::Update {
290            source, targets, ..
291        } => {
292            if source.label == "pacman" && targets.len() > 1 || step.id() == "system-update" {
293                match look.system.run(&["pacman", "-Qu"]) {
294                    Some((true, text)) if !text.trim().is_empty() => {
295                        let lines: Vec<&str> = text.lines().collect();
296                        changes.push(change(
297                            Certainty::Known,
298                            Area::Package,
299                            format!(
300                                "updates {} packages from the repository metadata on this machine: {}",
301                                lines.len(),
302                                lines.join("; ")
303                            ),
304                        ));
305                    }
306                    _ => changes.push(change(
307                        Certainty::Unknown,
308                        Area::Package,
309                        "which packages: pacman -Syu reads the repositories again first",
310                    )),
311                }
312                changes.push(change(
313                    Certainty::Unknown,
314                    Area::Package,
315                    "packages newer than the metadata on this machine, which pacman -Syu downloads first",
316                ));
317            } else {
318                for target in targets {
319                    changes.push(change(
320                        Certainty::Known,
321                        Area::Package,
322                        format!(
323                            "updates {}{} through {}",
324                            target.name,
325                            target
326                                .from
327                                .as_deref()
328                                .map(|from| format!(" from {from}"))
329                                .unwrap_or_default(),
330                            source.label
331                        ),
332                    ));
333                }
334            }
335        }
336    }
337    changes
338}
339
340/// The package names a source installs: its commands' last words.
341fn package_names(source: &crate::Source) -> Vec<String> {
342    source
343        .commands
344        .last()
345        .map(|command| {
346            command
347                .iter()
348                .rev()
349                .take_while(|word| {
350                    !word.starts_with('-')
351                        && !matches!(
352                            word.as_str(),
353                            "install" | "add" | "remove" | "del" | "-S" | "-R" | "sudo"
354                        )
355                })
356                .cloned()
357                .collect::<Vec<_>>()
358                .into_iter()
359                .rev()
360                .collect()
361        })
362        .unwrap_or_default()
363}
364
365/// What the package manager says it would do, asked without root.
366fn simulate(
367    manager: Option<PackageManager>,
368    names: &[String],
369    removal: bool,
370    system: &dyn System,
371) -> Vec<Change> {
372    let names: Vec<&str> = names
373        .iter()
374        .map(String::as_str)
375        .filter(|name| helixforge_core::valid_package_name(name))
376        .collect();
377    if names.is_empty() {
378        return Vec::new();
379    }
380    let mut changes = Vec::new();
381    match manager {
382        Some(PackageManager::Arch) => {
383            let mut command = vec![
384                "pacman",
385                if removal { "-Rp" } else { "-Sp" },
386                "--print-format",
387                "%n %v",
388            ];
389            command.extend(&names);
390            match system.run(&command) {
391                Some((true, text)) => {
392                    let all: Vec<&str> = text
393                        .lines()
394                        .map(str::trim)
395                        .filter(|line| !line.is_empty())
396                        .collect();
397                    let others: Vec<&str> = all
398                        .iter()
399                        .copied()
400                        .filter(|line| {
401                            !names
402                                .iter()
403                                .any(|name| line.split(' ').next() == Some(name))
404                        })
405                        .collect();
406                    changes.push(change(
407                        Certainty::Known,
408                        Area::Package,
409                        if others.is_empty() {
410                            format!(
411                                "pacman lists nothing else to {}",
412                                if removal { "remove" } else { "install" }
413                            )
414                        } else {
415                            format!(
416                                "pacman would also {}: {}",
417                                if removal { "remove" } else { "install" },
418                                others.join(", ")
419                            )
420                        },
421                    ));
422                }
423                _ => changes.push(change(
424                    Certainty::Unknown,
425                    Area::Package,
426                    "which dependencies: pacman could not say from the metadata on this machine",
427                )),
428            }
429        }
430        Some(PackageManager::Debian) => {
431            let mut command = vec![
432                "apt-get",
433                "-s",
434                "-q",
435                if removal { "remove" } else { "install" },
436            ];
437            command.extend(&names);
438            match system.run(&command) {
439                Some((true, text)) => {
440                    let pick = |prefix: &str| -> Vec<String> {
441                        text.lines()
442                            .filter_map(|line| line.strip_prefix(prefix))
443                            .filter_map(|rest| rest.split_whitespace().next())
444                            .filter(|name| !names.contains(name))
445                            .map(String::from)
446                            .collect()
447                    };
448                    let installs = pick("Inst ");
449                    let removes = pick("Remv ");
450                    if !installs.is_empty() {
451                        changes.push(change(
452                            Certainty::Known,
453                            Area::Package,
454                            format!("apt would also install: {}", installs.join(", ")),
455                        ));
456                    }
457                    if !removes.is_empty() {
458                        changes.push(change(
459                            Certainty::Known,
460                            Area::Package,
461                            format!("apt would remove: {}", removes.join(", ")),
462                        ));
463                    }
464                    if installs.is_empty() && removes.is_empty() {
465                        changes.push(change(
466                            Certainty::Known,
467                            Area::Package,
468                            "apt lists nothing else to change",
469                        ));
470                    }
471                }
472                _ => changes.push(change(
473                    Certainty::Unknown,
474                    Area::Package,
475                    "which dependencies: apt could not simulate it here",
476                )),
477            }
478        }
479        Some(other) => changes.push(change(
480            Certainty::Unknown,
481            Area::Package,
482            format!(
483                "which dependencies: {} lists them only with root, as it starts",
484                other.executable()
485            ),
486        )),
487        None => {}
488    }
489    changes
490}
491
492/// The entry's declared effects, with new contents rendered where it can.
493fn declared(entry: &Entry, look: &Look) -> Vec<Change> {
494    let effects = &entry.effects;
495    let mut changes = Vec::new();
496    for package in &effects.packages {
497        changes.push(change(
498            Certainty::Expected,
499            Area::Package,
500            format!("installs {package}"),
501        ));
502    }
503    for service in &effects.services {
504        changes.push(change(Certainty::Expected, Area::Service, service.clone()));
505    }
506    let grub = if look.system.exists(Path::new("/boot/grub2")) {
507        "/boot/grub2"
508    } else {
509        "/boot/grub"
510    };
511    let expand = |text: &str| {
512        text.replace("{grub}", grub)
513            .replace("{time}", "<date and time of the run>")
514    };
515    for file in &effects.files {
516        let path = expand(&file.path);
517        let when = file
518            .when
519            .as_deref()
520            .map(|when| format!(" (when {when})"))
521            .unwrap_or_default();
522        let shown = PathBuf::from(&path);
523        let exists = look.system.exists(&shown);
524        let mut found = change(
525            Certainty::Expected,
526            Area::File,
527            format!("{path}: {}{when}", file.change),
528        );
529        if !file.render.is_empty() {
530            if !exists {
531                found
532                    .text
533                    .push_str("; it is not on this machine, so this does not apply");
534            } else {
535                let arguments: Vec<String> = file.render.iter().map(|word| expand(word)).collect();
536                match (read_text(look.system, &shown), (look.render)(&arguments)) {
537                    (Some(before), Ok(after)) => {
538                        found.certainty = Certainty::Known;
539                        found.diff = diff(&path, &before, &after);
540                        if found.diff.as_ref().is_some_and(|diff| !diff.changed) {
541                            found
542                                .text
543                                .push_str("; it already says this, so nothing changes");
544                        }
545                    }
546                    (_, Err(why)) => found.text.push_str(&printable(&format!(
547                        "; its new contents could not be worked out: {why}"
548                    ))),
549                    (None, _) => found.text.push_str("; it cannot be read here to compare"),
550                }
551            }
552        } else if file.path.contains("{time}") || !exists {
553            found.text.push_str(" (created)");
554        }
555        changes.push(found);
556    }
557    if let Some(when) = &effects.takes_effect {
558        changes.push(change(
559            Certainty::Expected,
560            Area::Boot,
561            format!("takes effect: {when}"),
562        ));
563    }
564    for unknown in &effects.unknown {
565        changes.push(change(Certainty::Unknown, Area::Other, unknown.clone()));
566    }
567    changes
568}
569
570/// The user paths an entry says it writes.
571fn writes(entry: &Entry, system: &dyn System) -> Vec<Change> {
572    let (_, _, home) = system.user();
573    entry
574        .writes
575        .iter()
576        .map(|written| {
577            let path = match written.strip_prefix("~/") {
578                Some(rest) => home.join(rest),
579                None => PathBuf::from(written),
580            };
581            let what = if system.is_link(&path) {
582                "replaced; it is a link now, which the installer refuses to overwrite if Home Manager manages it"
583            } else if system.exists(&path) {
584                "replaced, keeping the current one as a backup"
585            } else {
586                "created"
587            };
588            let mut found = change(Certainty::Expected, Area::File, format!("{written}: {what}"));
589            if system.exists(&path) {
590                found.text.push_str("; the new contents come from the entry's pinned source, read as it runs");
591            }
592            found
593        })
594        .collect()
595}
596
597/// Services and system paths the script names, which it may change.
598fn seen_in(file: &Path) -> Vec<Change> {
599    let Ok(text) = std::fs::read_to_string(file) else {
600        return Vec::new();
601    };
602    let mut services: Vec<String> = Vec::new();
603    for line in text.lines().filter(|line| {
604        let line = line.trim_start();
605        // Comments and messages name commands without running them.
606        !line.starts_with('#') && !line.starts_with("printf") && !line.starts_with("echo")
607    }) {
608        let Some(at) = line.find("systemctl ") else {
609            continue;
610        };
611        let words: Vec<&str> = line[at + "systemctl ".len()..].split_whitespace().collect();
612        let Some(verb) = words.iter().find(|word| !word.starts_with('-')) else {
613            continue;
614        };
615        if !matches!(
616            *verb,
617            "enable" | "disable" | "start" | "stop" | "restart" | "reload" | "mask"
618        ) {
619            continue;
620        }
621        let unit = words
622            .iter()
623            .skip_while(|word| *word != verb)
624            .skip(1)
625            .find(|word| !word.starts_with('-'))
626            .map(|word| word.trim_matches(|c: char| "\"';.,)".contains(c)))
627            .unwrap_or("a unit");
628        let unit = if unit.starts_with('$') {
629            "a unit it chooses"
630        } else {
631            unit
632        };
633        let text = format!("may {verb} {unit}");
634        if !services.contains(&text) {
635            services.push(text);
636        }
637    }
638    services
639        .into_iter()
640        .map(|text| {
641            change(
642                Certainty::Expected,
643                Area::Service,
644                format!("{text} (seen in its script)"),
645            )
646        })
647        .collect()
648}
649
650fn read_text(system: &dyn System, path: &Path) -> Option<String> {
651    let text = system.read(path)?;
652    (text.len() <= DIFF_BYTES).then_some(text)
653}
654
655/// Compare a file's lines now and after, side by side. None when either is
656/// too long to compare line by line.
657pub fn diff(path: &str, before: &str, after: &str) -> Option<Diff> {
658    let old: Vec<&str> = before.lines().collect();
659    let new: Vec<&str> = after.lines().collect();
660    if old.len() > DIFF_LINES || new.len() > DIFF_LINES {
661        return None;
662    }
663    // Longest common subsequence, then walk it.
664    let (n, m) = (old.len(), new.len());
665    let mut table = vec![vec![0u32; m + 1]; n + 1];
666    for i in (0..n).rev() {
667        for j in (0..m).rev() {
668            table[i][j] = if old[i] == new[j] {
669                table[i + 1][j + 1] + 1
670            } else {
671                table[i + 1][j].max(table[i][j + 1])
672            };
673        }
674    }
675    let mut rows = Vec::new();
676    let (mut i, mut j) = (0, 0);
677    let mut removed: Vec<&str> = Vec::new();
678    let mut added: Vec<&str> = Vec::new();
679    let flush = |rows: &mut Vec<Row>, removed: &mut Vec<&str>, added: &mut Vec<&str>| {
680        let pairs = removed.len().max(added.len());
681        for index in 0..pairs {
682            let before = removed.get(index).map(|line| printable(line));
683            let after = added.get(index).map(|line| printable(line));
684            let tag = match (&before, &after) {
685                (Some(_), Some(_)) => Tag::Changed,
686                (Some(_), None) => Tag::Removed,
687                _ => Tag::Added,
688            };
689            rows.push(Row { before, after, tag });
690        }
691        removed.clear();
692        added.clear();
693    };
694    while i < n || j < m {
695        if i < n && j < m && old[i] == new[j] {
696            flush(&mut rows, &mut removed, &mut added);
697            rows.push(Row {
698                before: Some(printable(old[i])),
699                after: Some(printable(new[j])),
700                tag: Tag::Same,
701            });
702            i += 1;
703            j += 1;
704        } else if j < m && (i == n || table[i][j + 1] >= table[i + 1][j]) {
705            added.push(new[j]);
706            j += 1;
707        } else {
708            removed.push(old[i]);
709            i += 1;
710        }
711    }
712    flush(&mut rows, &mut removed, &mut added);
713    let changed = rows.iter().any(|row| row.tag != Tag::Same);
714    Some(Diff {
715        path: printable(path),
716        rows,
717        changed,
718    })
719}
720
721impl Diff {
722    /// The rows that differ, each with up to `context` unchanged rows around
723    /// it; None between runs marks the rows left out.
724    pub fn hunks(&self, context: usize) -> Vec<Option<&Row>> {
725        let keep: Vec<bool> = (0..self.rows.len())
726            .map(|index| {
727                let low = index.saturating_sub(context);
728                let high = (index + context + 1).min(self.rows.len());
729                self.rows[low..high].iter().any(|row| row.tag != Tag::Same)
730            })
731            .collect();
732        let mut shown = Vec::new();
733        let mut gap = false;
734        for (index, row) in self.rows.iter().enumerate() {
735            if keep[index] {
736                if gap && !shown.is_empty() {
737                    shown.push(None);
738                }
739                gap = false;
740                shown.push(Some(row));
741            } else {
742                gap = true;
743            }
744        }
745        shown
746    }
747
748    /// The differing rows as a unified diff, for the command line.
749    pub fn unified(&self) -> String {
750        let mut text = format!("--- {} (now)\n+++ {} (after)\n", self.path, self.path);
751        for row in self.hunks(2) {
752            match row {
753                None => text.push_str("@@\n"),
754                Some(row) => match row.tag {
755                    Tag::Same => {
756                        text.push_str(&format!(" {}\n", row.before.as_deref().unwrap_or("")))
757                    }
758                    _ => {
759                        if let Some(before) = &row.before {
760                            text.push_str(&format!("-{before}\n"));
761                        }
762                        if let Some(after) = &row.after {
763                            text.push_str(&format!("+{after}\n"));
764                        }
765                    }
766                },
767            }
768        }
769        text
770    }
771}
772
773/// Run a renderer from the installers' folder: `python3 configure.py ARGS`,
774/// which only prints. `scripts` is the unpacked scripts folder.
775pub fn render_with(scripts: &Path, arguments: &[String]) -> Result<String, String> {
776    if !arguments
777        .first()
778        .is_some_and(|action| helixforge_core::RENDERERS.contains(&action.as_str()))
779    {
780        return Err("not a renderer".to_string());
781    }
782    let output = std::process::Command::new("python3")
783        .arg("-B")
784        .arg("./configure.py")
785        .args(arguments)
786        .current_dir(scripts.join("installers/commander"))
787        .env("LC_ALL", "C.UTF-8")
788        .stdin(std::process::Stdio::null())
789        .output()
790        .map_err(|error| format!("cannot run python3: {error}"))?;
791    if output.status.success() {
792        Ok(String::from_utf8_lossy(&output.stdout).into_owned())
793    } else {
794        Err(String::from_utf8_lossy(&output.stderr).trim().to_string())
795    }
796}
797
798#[cfg(test)]
799mod tests {
800    use super::*;
801    use helixforge_catalog::Catalog;
802    use std::collections::HashMap;
803
804    #[derive(Default)]
805    struct Fake {
806        files: HashMap<PathBuf, String>,
807        commands: HashMap<String, String>,
808    }
809
810    impl System for Fake {
811        fn read(&self, path: &Path) -> Option<String> {
812            self.files.get(path).cloned()
813        }
814        fn exists(&self, path: &Path) -> bool {
815            self.files.contains_key(path)
816        }
817        fn list(&self, _: &Path) -> Vec<String> {
818            Vec::new()
819        }
820        fn link(&self, _: &Path) -> Option<PathBuf> {
821            None
822        }
823        fn run(&self, command: &[&str]) -> Option<(bool, String)> {
824            self.commands
825                .get(&command.join(" "))
826                .map(|text| (true, text.clone()))
827        }
828        fn resolve(&self, _: &str) -> Result<Vec<String>, String> {
829            Err("offline".into())
830        }
831        fn space(&self, _: &Path) -> Option<(u64, u64)> {
832            None
833        }
834        fn owner(&self, _: &Path) -> Option<u32> {
835            None
836        }
837        fn is_link(&self, _: &Path) -> bool {
838            false
839        }
840        fn canonical(&self, _: &Path) -> Option<PathBuf> {
841            None
842        }
843        fn user(&self) -> (u32, u32, PathBuf) {
844            (1000, 1000, PathBuf::from("/home/me"))
845        }
846        fn on_path(&self, _: &str) -> bool {
847            false
848        }
849        fn processes(&self) -> Vec<String> {
850            Vec::new()
851        }
852    }
853
854    fn arch() -> LinuxInfo {
855        LinuxInfo {
856            id: "arch".into(),
857            pretty_name: "Arch Linux".into(),
858            id_like: vec![],
859            manager: Some(PackageManager::Arch),
860        }
861    }
862
863    #[test]
864    fn diffs_pair_changed_lines_and_keep_context() {
865        let found = diff("f", "a\nb\nc\nd\ne\nf\ng\n", "a\nB\nc\nd\ne\nf\ng\nh\n").unwrap();
866        assert!(found.changed);
867        let tags: Vec<Tag> = found.rows.iter().map(|row| row.tag).collect();
868        assert_eq!(tags[1], Tag::Changed);
869        assert_eq!(*tags.last().unwrap(), Tag::Added);
870        let shown = found.hunks(1);
871        assert!(
872            shown.contains(&None),
873            "rows between the changes are left out"
874        );
875        let unified = found.unified();
876        assert!(unified.contains("-b\n+B\n"), "{unified}");
877        assert!(unified.contains("+h\n"));
878        assert!(!diff("f", "same\n", "same\n").unwrap().changed);
879        // Escape sequences in a file never reach the terminal.
880        let evil = diff("f", "", "\u{1b}[2J\n").unwrap();
881        assert!(!evil.rows[0].after.as_ref().unwrap().contains('\u{1b}'));
882    }
883
884    #[test]
885    fn a_grub_theme_shows_its_new_settings_and_what_cannot_be_known() {
886        let catalog = Catalog::builtin(false).unwrap();
887        let entry = catalog.find("grub-dracula").unwrap();
888        let mut fake = Fake::default();
889        fake.files.insert(
890            PathBuf::from("/etc/default/grub"),
891            "GRUB_TIMEOUT=5\nGRUB_CMDLINE_LINUX_DEFAULT=\"quiet\"\n".into(),
892        );
893        let render = |arguments: &[String]| -> Result<String, String> {
894            assert_eq!(arguments[0], "grub");
895            assert_eq!(arguments[1], "/etc/default/grub");
896            assert!(arguments[2].starts_with("/boot/grub/themes/commander-dracula-"));
897            Ok(format!(
898                "GRUB_TIMEOUT=5\nGRUB_CMDLINE_LINUX_DEFAULT=\"quiet\"\nGRUB_THEME=\"{}\"\n",
899                arguments[2]
900            ))
901        };
902        let look = Look {
903            system: &fake,
904            render: &render,
905        };
906        let planned = crate::plan_with(
907            [entry],
908            &arch(),
909            &|id| catalog.find(id),
910            crate::Machine {
911                holds: &|_| false,
912                on_path: &|_| false,
913            },
914        )
915        .unwrap();
916        let found = preview(&planned, &|id| catalog.find(id), &arch(), &look);
917        assert_eq!(found.len(), 1);
918        let changes = &found[0].changes;
919        let grub = changes
920            .iter()
921            .find(|change| change.text.starts_with("/etc/default/grub"))
922            .unwrap();
923        assert_eq!(grub.certainty, Certainty::Known);
924        let diff = grub.diff.as_ref().unwrap();
925        assert!(diff.changed);
926        assert!(diff.unified().contains("+GRUB_THEME="));
927        assert!(changes.iter().any(|change| change.area == Area::Boot));
928        assert!(
929            changes
930                .iter()
931                .any(|change| change.certainty == Certainty::Unknown
932                    && change.text.contains("grub-mkconfig"))
933        );
934        assert!(
935            changes
936                .iter()
937                .any(|change| change.text.contains("<date and time of the run>"))
938        );
939    }
940
941    #[test]
942    fn packages_list_what_the_package_manager_would_add() {
943        let catalog = Catalog::builtin(false).unwrap();
944        let mut fake = Fake::default();
945        fake.commands.insert(
946            "pacman -Sp --print-format %n %v neovim".into(),
947            "luajit 2.1-1\nneovim 0.11.0-1\n".into(),
948        );
949        let render = |_: &[String]| Err("none".to_string());
950        let look = Look {
951            system: &fake,
952            render: &render,
953        };
954        let planned = crate::plan_with(
955            [catalog.find("neovim").unwrap()],
956            &arch(),
957            &|id| catalog.find(id),
958            crate::Machine {
959                holds: &|_| false,
960                on_path: &|_| false,
961            },
962        )
963        .unwrap();
964        let found = preview(&planned, &|id| catalog.find(id), &arch(), &look);
965        let texts: Vec<&str> = found[0]
966            .changes
967            .iter()
968            .map(|change| change.text.as_str())
969            .collect();
970        assert!(
971            texts[0].starts_with("installs neovim from pacman"),
972            "{texts:?}"
973        );
974        assert!(
975            texts
976                .iter()
977                .any(|text| text.contains("also install: luajit 2.1-1")),
978            "{texts:?}"
979        );
980    }
981
982    #[test]
983    fn a_restore_shows_the_file_it_puts_back() {
984        let mut fake = Fake::default();
985        fake.files
986            .insert(PathBuf::from("/home/me/.config/x.conf"), "new\n".into());
987        fake.files
988            .insert(PathBuf::from("/home/me/.config/x.conf.bak"), "old\n".into());
989        let backup = crate::backups::Backup {
990            original: PathBuf::from("/home/me/.config/x.conf"),
991            backup: PathBuf::from("/home/me/.config/x.conf.bak"),
992            taken: None,
993            system: false,
994            directory: false,
995        };
996        let render = |_: &[String]| Err("none".to_string());
997        let look = Look {
998            system: &fake,
999            render: &render,
1000        };
1001        let found = preview(
1002            &crate::backups::plan_restore(&backup),
1003            &|_| None,
1004            &arch(),
1005            &look,
1006        );
1007        let diff = found[0].changes[0].diff.as_ref().unwrap();
1008        assert_eq!(diff.rows[0].before.as_deref(), Some("new"));
1009        assert_eq!(diff.rows[0].after.as_deref(), Some("old"));
1010    }
1011}