1use 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#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize)]
16#[serde(rename_all = "kebab-case")]
17pub enum Certainty {
18 Known,
21 Expected,
23 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#[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#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
58pub struct Change {
59 pub certainty: Certainty,
60 pub area: Area,
61 pub text: String,
62 pub diff: Option<Diff>,
64}
65
66#[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#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
76pub struct Diff {
77 pub path: String,
78 pub rows: Vec<Row>,
80 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
100pub struct Look<'a> {
102 pub system: &'a dyn System,
103 pub render: &'a dyn Fn(&[String]) -> Result<String, String>,
106}
107
108const DIFF_BYTES: usize = 1 << 20;
110const DIFF_LINES: usize = 4_000;
111
112pub 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 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
340fn 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
365fn 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
492fn 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
570fn 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
597fn 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 !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
655pub 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 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 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 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
773pub 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 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}