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