use camino::{Utf8Path, Utf8PathBuf};
use crate::applied::AppliedState;
use crate::config::{GlobalConfig, ProjectEntry};
use crate::error::{Error, Result};
use crate::preset::TemplateRef;
use crate::runner::{hash_content, plan_pj};
use crate::ui;
use super::{resolve_pj_root, select_registered_projects};
pub async fn run(
at: Option<Utf8PathBuf>,
all: bool,
tags: Vec<String>,
interactive: bool,
no_color: bool,
) -> Result<()> {
if all {
return run_all(tags, no_color);
}
run_single(at, interactive, no_color).await
}
async fn run_single(at: Option<Utf8PathBuf>, interactive: bool, no_color: bool) -> Result<()> {
let cwd = resolve_pj_root(at)?;
let pj_root = crate::paths::find_pj_root(&cwd).ok_or_else(|| {
Error::Config(format!(
"no .kata/applied.toml found at or above {cwd}; run `kata init` first"
))
})?;
let applied = AppliedState::load(&pj_root)?;
let templates: Vec<TemplateRef> = applied
.templates
.iter()
.map(|t| TemplateRef {
source: t.source.clone(),
rev: Some(t.rev.clone()),
subdir: t.subdir.clone(),
})
.collect();
let project = ProjectEntry {
name: pj_root.file_name().unwrap_or("kata-project").to_string(),
path: pj_root.clone(),
tags: vec![],
overrides: None,
};
let base_dir = applied.base_dir.clone().unwrap_or(cwd);
let plans = plan_pj(
project,
pj_root.clone(),
templates,
base_dir,
toml::Table::new(),
interactive,
Default::default(),
)
.await?;
ui::print_pj_header(
pj_root.file_name().unwrap_or("project"),
pj_root.as_str(),
no_color,
);
for (dst, kind, _diff) in &plans {
ui::print_plan(dst, *kind, no_color);
}
Ok(())
}
fn run_all(tags: Vec<String>, _no_color: bool) -> Result<()> {
let config = GlobalConfig::load()?;
let projects = select_registered_projects(&config, &tags);
if projects.is_empty() {
if tags.is_empty() {
println!(
"no projects registered yet — `kata register` from inside a kata-managed PJ to add one."
);
} else {
println!("no registered projects matched all of: {tags:?}");
}
return Ok(());
}
let rows: Vec<DriftRow> = projects.iter().map(DriftRow::from_entry).collect();
let name_w = rows.iter().map(|r| r.name.len()).max().unwrap_or(4).max(4);
let path_w = rows.iter().map(|r| r.path.len()).max().unwrap_or(4).max(4);
let tracked_w = 7;
let drift_w = rows
.iter()
.map(|r| r.drift_summary.len())
.max()
.unwrap_or(5)
.max(5);
println!(
"{:<name_w$} {:<path_w$} {:<tracked_w$} {:<drift_w$} STATUS",
"NAME",
"PATH",
"TRACKED",
"DRIFT",
name_w = name_w,
path_w = path_w,
tracked_w = tracked_w,
drift_w = drift_w,
);
for r in &rows {
println!(
"{:<name_w$} {:<path_w$} {:<tracked_w$} {:<drift_w$} {}",
r.name,
r.path,
r.tracked,
r.drift_summary,
r.status,
name_w = name_w,
path_w = path_w,
tracked_w = tracked_w,
drift_w = drift_w,
);
for line in &r.drift_detail {
println!(" {line}");
}
}
Ok(())
}
struct DriftRow {
name: String,
path: String,
tracked: String,
drift_summary: String,
status: String,
drift_detail: Vec<String>,
}
impl DriftRow {
fn from_entry(entry: &ProjectEntry) -> Self {
let path = entry.path.as_str().to_string();
if !entry.path.exists() {
return Self {
name: entry.name.clone(),
path,
tracked: "-".into(),
drift_summary: "-".into(),
status: "missing dir".into(),
drift_detail: vec![],
};
}
let applied = match AppliedState::load(&entry.path) {
Ok(a) => a,
Err(e) => {
return Self {
name: entry.name.clone(),
path,
tracked: "-".into(),
drift_summary: "-".into(),
status: format!("error: {e}"),
drift_detail: vec![],
};
}
};
if applied.templates.is_empty() {
return Self {
name: entry.name.clone(),
path,
tracked: "0".into(),
drift_summary: "-".into(),
status: "not init'd".into(),
drift_detail: vec![],
};
}
let (tracked, drift_detail) = check_drift(&entry.path, &applied);
let drift_summary = if drift_detail.is_empty() {
"clean".into()
} else {
format!("{} drifted", drift_detail.len())
};
let status = if drift_detail.is_empty() {
"ok".into()
} else {
"drift".into()
};
Self {
name: entry.name.clone(),
path,
tracked: tracked.to_string(),
drift_summary,
status,
drift_detail,
}
}
}
fn check_drift(pj_root: &Utf8Path, applied: &AppliedState) -> (usize, Vec<String>) {
let mut tracked = 0;
let mut drift = Vec::new();
for (dst_rel, file_state) in &applied.files {
let Some(expected) = file_state.content_hash.as_deref() else {
continue;
};
tracked += 1;
let dst_abs = pj_root.join(dst_rel);
match std::fs::read(dst_abs.as_std_path()) {
Ok(body) => {
let actual = hash_content(&body);
if actual != expected {
drift.push(format!(
"{dst_rel} (modified — disk diverges from applied.toml)"
));
}
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
drift.push(format!("{dst_rel} (missing — file deleted since apply)"));
}
Err(e) => {
drift.push(format!("{dst_rel} (unreadable — {e})"));
}
}
}
(tracked, drift)
}