use crate::json;
use anyhow::{Context, Result, bail};
use clap::Subcommand;
use helixforge_catalog::Catalog;
use helixforge_core::{Action, printable};
use helixforge_engine::{
Machine, doctor,
drift::{self, Baseline, Report},
history,
inventory::{Inventory, which},
plan_with, profiles,
};
use helixforge_platform::LinuxInfo;
use std::path::{Path, PathBuf};
#[derive(Subcommand)]
pub enum DriftCommand {
Check {
baseline: Option<String>,
#[arg(long, conflicts_with = "baseline")]
profile: Option<String>,
#[arg(long)]
exit_code: bool,
},
Save {
name: String,
#[arg(long = "path", value_name = "PATH")]
paths: Vec<PathBuf>,
},
List,
Export {
name: String,
file: Option<PathBuf>,
},
Import { file: PathBuf },
Delete { name: String },
}
struct Here {
inventory: Inventory,
home: PathBuf,
history: Vec<history::Record>,
}
impl Here {
fn gather(platform: &LinuxInfo) -> Self {
Here {
inventory: Inventory::gather(platform),
home: std::env::var_os("HOME")
.map(PathBuf::from)
.unwrap_or_default(),
history: history::read(1000).unwrap_or_default(),
}
}
fn sources<'a>(
&'a self,
catalog: &'a Catalog,
platform: &'a LinuxInfo,
settings: &'a dyn Fn() -> std::collections::BTreeMap<String, String>,
) -> drift::Sources<'a> {
drift::Sources {
catalog,
platform,
inventory: &self.inventory,
on_path: &which,
system: &doctor::Here,
home: &self.home,
history: &self.history,
settings,
}
}
}
fn settings() -> std::collections::BTreeMap<String, String> {
drift::settings_here(&doctor::Here)
}
pub fn run(
command: Option<DriftCommand>,
catalog: &Catalog,
platform: &LinuxInfo,
as_json: bool,
) -> Result<()> {
let directory = drift::dir().context("cannot find the configuration folder")?;
let command = command.unwrap_or(DriftCommand::Check {
baseline: None,
profile: None,
exit_code: false,
});
match command {
DriftCommand::Check {
baseline,
profile,
exit_code,
} => {
let here = Here::gather(platform);
let sources = here.sources(catalog, platform, &settings);
let (report, against) = match profile {
Some(name) => {
let (id, title, entries) = find_profile(catalog, &name)?;
let report = drift::compare_profile(&title, &entries, &sources);
(report, Against::Profile(id))
}
None => {
let baseline = match baseline {
Some(name) => load(&directory, &name)?,
None => drift::load_all(&directory)
.0
.into_iter()
.next()
.map(|(_, baseline)| baseline)
.context(
"no baseline saved yet: helixforge drift save <name> records this machine as one",
)?,
};
let report = drift::compare(&baseline, &sources);
(report, Against::Baseline(baseline))
}
};
let (restore, unavailable) = available(&report.restore(), catalog, platform);
if as_json {
json::print("drift", json::drift(&report, &against, &restore));
} else {
print_report(&report, &against, &restore, &unavailable, platform);
}
if exit_code && !report.differences.is_empty() {
std::process::exit(1);
}
Ok(())
}
_ if as_json && !matches!(command, DriftCommand::List) => {
bail!("--json is for drift check and drift list")
}
DriftCommand::List => {
let (found, problems) = drift::load_all(&directory);
if as_json {
json::print("baselines", json::baselines(&found));
return Ok(());
}
for problem in problems {
eprintln!("Baseline skipped: {}", printable(&problem));
}
if found.is_empty() {
println!(
"No baselines yet. helixforge drift save <name> records this machine as one."
);
}
for (slug, baseline) in &found {
println!(
"{slug} {} {} on {}\n {} entries, {} files, {} settings, {} runs",
printable(&baseline.name),
crate::when(baseline.taken),
printable(&baseline.system),
baseline.entries.len(),
baseline.files.len(),
baseline.settings.len(),
baseline.applied.len()
);
}
Ok(())
}
DriftCommand::Save { name, paths } => {
let here = Here::gather(platform);
let sources = here.sources(catalog, platform, &settings);
let current = std::env::current_dir().unwrap_or_default();
let extra: Vec<String> = paths
.iter()
.map(|path| current.join(path).display().to_string())
.collect();
let baseline = drift::capture(&name, history::now(), &sources, &extra);
let file = drift::save(&directory, &baseline)?;
println!(
"Saved {}: {} entries, {} configuration paths, {} settings and {} runs, in {}.\nCompare later with: helixforge drift check \"{}\"",
printable(&baseline.name),
baseline.entries.len(),
baseline.files.len(),
baseline.settings.len(),
baseline.applied.len(),
file.display(),
printable(&baseline.name)
);
Ok(())
}
DriftCommand::Export { name, file } => {
let (slug, baseline) = named(&directory, &name)?;
let file = file.unwrap_or_else(|| PathBuf::from(format!("{slug}.toml")));
drift::write(&file, &baseline)?;
println!(
"Exported {} to {}; on another machine: helixforge drift check {}",
printable(&baseline.name),
file.display(),
file.display()
);
Ok(())
}
DriftCommand::Import { file } => {
let baseline = drift::read(&file)?;
let saved = drift::save(&directory, &baseline)?;
println!(
"Imported {} as {}; compare with: helixforge drift check \"{}\"",
printable(&baseline.name),
saved.display(),
printable(&baseline.name)
);
Ok(())
}
DriftCommand::Delete { name } => {
let (slug, baseline) = named(&directory, &name)?;
let path = directory.join(format!("{slug}.toml"));
std::fs::remove_file(&path)
.with_context(|| format!("cannot delete {}", path.display()))?;
println!("Deleted {}", printable(&baseline.name));
Ok(())
}
}
}
pub enum Against {
Baseline(Baseline),
Profile(String),
}
fn named(directory: &Path, name: &str) -> Result<(String, Baseline)> {
drift::find(directory, name)
.with_context(|| format!("no baseline named '{name}'; see helixforge drift list"))
}
fn load(directory: &Path, name: &str) -> Result<Baseline> {
let path = Path::new(name);
if (name.contains('/') || name.ends_with(".toml")) && path.is_file() {
return Ok(drift::read(path)?);
}
Ok(named(directory, name)?.1)
}
fn find_profile(catalog: &Catalog, name: &str) -> Result<(String, String, Vec<String>)> {
let lowered = name.to_lowercase();
let user = profiles::slug(name).map(|slug| format!("{}{slug}", profiles::PREFIX));
catalog
.entries()
.into_iter()
.find_map(|entry| match &entry.action {
Action::Profile { entries }
if entry.id == name
|| Some(&entry.id) == user.as_ref()
|| entry.name.to_lowercase() == lowered =>
{
Some((entry.id.clone(), entry.name.clone(), entries.clone()))
}
_ => None,
})
.with_context(|| format!("no profile named '{name}'; see helixforge list"))
}
pub fn available(
ids: &[String],
catalog: &Catalog,
platform: &LinuxInfo,
) -> (Vec<String>, Vec<String>) {
let nothing = Machine {
holds: &|_| false,
on_path: &|_| false,
};
ids.iter().cloned().partition(|id| {
catalog.find(id).is_some_and(|entry| {
plan_with([entry], platform, &|id| catalog.find(id), nothing).is_ok()
})
})
}
fn print_report(
report: &Report,
against: &Against,
restore: &[String],
unavailable: &[String],
platform: &LinuxInfo,
) {
match against {
Against::Baseline(baseline) => println!(
"Compared with the baseline {}, saved {} on {}.",
printable(&baseline.name),
crate::when(baseline.taken),
printable(&baseline.system)
),
Against::Profile(id) => println!("Compared with the profile {} ({id}).", report.against),
}
for unknown in &report.unknown {
println!("Not in this catalog, so not compared: {unknown}");
}
if report.differences.is_empty() {
println!(
"No differences: {} things are as they were.",
report.matched
);
return;
}
let width = report
.differences
.iter()
.map(|difference| difference.subject.chars().count())
.max()
.unwrap_or(0)
.min(40);
for difference in &report.differences {
println!(
"{:<9} {:<width$} {}",
difference.kind.label(),
difference.subject,
difference.note
);
}
let counts: Vec<String> = report
.counts()
.into_iter()
.map(|(label, count)| format!("{count} {}", label.to_lowercase()))
.collect();
println!("\n{}; {} the same.", counts.join(", "), report.matched);
if !restore.is_empty() {
println!(
"To put back what is missing (planned and confirmed first): helixforge run {}",
restore.join(" ")
);
}
if !unavailable.is_empty() {
println!(
"Not available on {}: {}",
printable(&platform.pretty_name),
unavailable.join(", ")
);
}
}