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