use tracing::{debug, info};
use crate::commands::{
handler_description, handler_symbol, DisplayConflict, DisplayFile, DisplayNote, DisplayPack,
PackStatusResult,
};
use crate::config::mappings_to_rules;
use crate::conflicts;
use crate::handlers::symlink::resolve_target;
use crate::handlers::{self, HANDLER_SYMLINK};
use crate::packs::orchestration::{self, ExecutionContext};
use crate::packs::{self};
use crate::rules::Scanner;
use crate::Result;
enum Health {
Pending,
PendingConflict { reason: String },
Deployed,
DeployedWithError { label: String, reason: String },
Broken(String),
Stale(String),
}
impl Health {
fn style(&self) -> &'static str {
match self {
Health::Pending => "pending",
Health::PendingConflict { .. } => "warning",
Health::Deployed => "deployed",
Health::DeployedWithError { .. } => "broken",
Health::Broken(_) => "broken",
Health::Stale(_) => "stale",
}
}
fn label(&self, handler: &str) -> String {
match self {
Health::Pending | Health::PendingConflict { .. } => match handler {
"symlink" => "pending".into(),
"shell" => "not sourced".into(),
"path" => "not in PATH".into(),
"install" => "never run".into(),
"homebrew" => "not installed".into(),
_ => "pending".into(),
},
Health::Deployed => match handler {
"symlink" => "deployed".into(),
"shell" => "sourced".into(),
"path" => "in PATH".into(),
"install" => "installed".into(),
"homebrew" => "installed".into(),
_ => "deployed".into(),
},
Health::DeployedWithError { label, .. } => label.clone(),
Health::Broken(reason) => reason.clone(),
Health::Stale(reason) => reason.clone(),
}
}
fn footnote_reason(&self) -> Option<&str> {
match self {
Health::PendingConflict { reason } => Some(reason.as_str()),
Health::DeployedWithError { reason, .. } => Some(reason.as_str()),
_ => None,
}
}
}
fn describe_blocking_target(
user_target: &std::path::Path,
fs: &dyn crate::fs::Fs,
home: &std::path::Path,
) -> String {
let display = if let Ok(rel) = user_target.strip_prefix(home) {
format!("~/{}", rel.display())
} else {
user_target.display().to_string()
};
let kind = if fs.is_dir(user_target) {
"directory"
} else {
"file"
};
format!("{display} (existing {kind}) — `dodot up` will refuse without `--force`")
}
fn verify_symlink(
source: &std::path::Path,
pack: &str,
rel_path: &str,
config: &crate::handlers::HandlerConfig,
ctx: &ExecutionContext,
) -> Health {
let filename = match source.file_name() {
Some(f) => f,
None => return Health::Pending,
};
let data_link = ctx
.paths
.handler_data_dir(pack, HANDLER_SYMLINK)
.join(filename);
if !ctx.fs.is_symlink(&data_link) {
if ctx.fs.exists(&data_link) {
return Health::Broken("broken: data link exists but is not a symlink".into());
}
let user_target = resolve_target(pack, rel_path, config, ctx.paths.as_ref());
if !ctx.fs.is_symlink(&user_target) && ctx.fs.exists(&user_target) {
if crate::equivalence::is_equivalent(&user_target, source, ctx.fs.as_ref()) {
return Health::Pending;
}
let reason =
describe_blocking_target(&user_target, ctx.fs.as_ref(), ctx.paths.home_dir());
return Health::PendingConflict { reason };
}
return Health::Pending;
}
match ctx.fs.readlink(&data_link) {
Ok(target) if target == source => {}
Ok(target) => {
return Health::Broken(format!("broken: data link points to {}", target.display()));
}
Err(_) => return Health::Broken("broken: cannot read data link".into()),
}
if !ctx.fs.exists(source) {
return Health::Broken("broken: source file missing".into());
}
let user_target = resolve_target(pack, rel_path, config, ctx.paths.as_ref());
if ctx.fs.is_symlink(&user_target) {
match ctx.fs.readlink(&user_target) {
Ok(link_target) if link_target == data_link => {
Health::Deployed
}
Ok(_) => {
Health::Stale("stale: user link points elsewhere, re-deploy to fix".into())
}
Err(_) => Health::Broken("broken: cannot read user link".into()),
}
} else if ctx.fs.exists(&user_target) {
if crate::equivalence::is_equivalent(&user_target, source, ctx.fs.as_ref()) {
Health::Stale("stale: user link missing, re-deploy to fix".into())
} else {
Health::Broken("conflict: non-symlink file at target path".into())
}
} else {
Health::Stale("stale: user link missing, re-deploy to fix".into())
}
}
fn verify_staged(
source: &std::path::Path,
pack: &str,
handler: &str,
ctx: &ExecutionContext,
) -> Health {
let filename = match source.file_name() {
Some(f) => f,
None => return Health::Pending,
};
let data_link = ctx.paths.handler_data_dir(pack, handler).join(filename);
if !ctx.fs.is_symlink(&data_link) {
if ctx.fs.exists(&data_link) {
return Health::Broken("broken: data link exists but is not a symlink".into());
}
return Health::Pending;
}
match ctx.fs.readlink(&data_link) {
Ok(target) if target == source => {}
Ok(target) => {
return Health::Broken(format!("broken: data link points to {}", target.display()));
}
Err(_) => return Health::Broken("broken: cannot read data link".into()),
}
if !ctx.fs.exists(source) {
return Health::Broken("broken: source file missing".into());
}
if handler == "shell" {
let filename_str = filename.to_string_lossy();
let sidecar = crate::shell::error_sidecar_path(ctx.paths.as_ref(), pack, &filename_str);
if ctx.fs.exists(&sidecar) {
if let Ok(body) = ctx.fs.read_to_string(&sidecar) {
let reason = body.trim().to_string();
if !reason.is_empty() {
return Health::DeployedWithError {
label: "syntax error".into(),
reason,
};
}
}
}
if let Some((label, reason)) = recent_runtime_failures(source, pack, &filename_str, ctx) {
return Health::DeployedWithError { label, reason };
}
}
Health::Deployed
}
const RUNTIME_FAILURE_WINDOW: usize = 5;
const STATUS_STDERR_BUDGET: usize = 240;
fn recent_runtime_failures(
source: &std::path::Path,
pack: &str,
filename: &str,
ctx: &ExecutionContext,
) -> Option<(String, String)> {
let profiles = crate::probe::shell_init::read_recent_profiles(
ctx.fs.as_ref(),
ctx.paths.as_ref(),
RUNTIME_FAILURE_WINDOW,
)
.ok()?;
if profiles.is_empty() {
return None;
}
let target_str = source.to_string_lossy();
let mut runs_seen = 0;
let mut runs_failed = 0;
let mut last_failure_exit: Option<i32> = None;
let mut last_failure_stderr: Option<String> = None;
for profile in &profiles {
if let Some(entry) = profile
.entries
.iter()
.find(|e| e.phase == "source" && e.target == target_str)
{
runs_seen += 1;
if entry.exit_status != 0 {
runs_failed += 1;
if last_failure_exit.is_none() {
last_failure_exit = Some(entry.exit_status);
last_failure_stderr = profile
.errors
.iter()
.find(|er| er.target == target_str)
.map(|er| er.message.trim_end().to_string())
.filter(|s| !s.is_empty());
}
}
}
}
let last_exit = last_failure_exit?;
if runs_failed == 0 {
return None;
}
let label = format!("exited {last_exit} ({runs_failed}/{runs_seen})");
let mut reason = format!(
"non-zero exit in {runs_failed} of {runs_seen} recent shell startups (last failure: exit {last_exit})."
);
if let Some(stderr) = last_failure_stderr {
reason.push_str(" stderr: ");
reason.push_str(&truncate_for_footnote(&stderr, STATUS_STDERR_BUDGET));
}
reason.push_str(&format!(
" Run `dodot probe shell-init {pack}/{filename}` for per-run history and full stderr."
));
Some((label, reason))
}
fn truncate_for_footnote(stderr: &str, budget: usize) -> String {
let one_line = stderr.replace('\n', " ↵ ");
if one_line.chars().count() <= budget {
return one_line;
}
let truncated: String = one_line.chars().take(budget).collect();
format!("{truncated}…")
}
pub fn status(pack_filter: Option<&[String]>, ctx: &ExecutionContext) -> Result<PackStatusResult> {
info!("starting status command");
let mut warnings = Vec::new();
if let Some(names) = pack_filter {
warnings = orchestration::validate_pack_names(names, ctx)?;
}
let root_config = ctx.config_manager.root_config()?;
let packs::DiscoveredPacks {
packs: mut all_packs,
ignored: mut ignored_packs,
} = packs::scan_packs(
ctx.fs.as_ref(),
ctx.paths.dotfiles_root(),
&root_config.pack.ignore,
)?;
info!(count = all_packs.len(), "discovered packs");
if let Some(names) = pack_filter {
all_packs.retain(|p| names.iter().any(|n| n == &p.display_name || n == &p.name));
ignored_packs.retain(|name| {
names
.iter()
.any(|n| n == name || n == crate::packs::display_name_for(name))
});
}
let registry = handlers::create_registry(ctx.fs.as_ref());
let mut display_packs = Vec::new();
let mut notes: Vec<DisplayNote> = Vec::new();
let mut pack_intents = Vec::new();
for mut pack in all_packs {
info!(pack = %pack.display_name, "checking pack status");
let pack_config = ctx.config_manager.config_for_pack(&pack.path)?;
pack.config = pack_config.to_handler_config();
let rules = mappings_to_rules(&pack_config.mappings);
let scanner = Scanner::new(ctx.fs.as_ref());
let entries = scanner.walk_pack(&pack.path, &pack_config.pack.ignore)?;
let preprocess_result = if pack_config.preprocessor.enabled {
let root_config = ctx.config_manager.root_config()?;
let (registry, _secret_registry) = crate::preprocessing::default_registry(
&pack_config.preprocessor,
&root_config.secret,
ctx.paths.as_ref(),
ctx.command_runner.clone(),
)?;
if !registry.is_empty() {
match crate::preprocessing::pipeline::preprocess_pack(
entries,
®istry,
&pack,
ctx.fs.as_ref(),
ctx.datastore.as_ref(),
ctx.paths.as_ref(),
crate::preprocessing::PreprocessMode::Passive,
false,
) {
Ok(r) => r,
Err(err) => {
warnings.push(format!(
"preprocessing failed for pack '{}': {}",
pack.display_name, err
));
crate::preprocessing::pipeline::PreprocessResult::passthrough(Vec::new())
}
}
} else {
crate::preprocessing::pipeline::PreprocessResult::passthrough(entries)
}
} else {
crate::preprocessing::pipeline::PreprocessResult::passthrough(entries)
};
let all_entries = preprocess_result.merged_entries();
let matches = scanner.match_entries(&all_entries, &rules, &pack.name);
match orchestration::plan_pack(&pack, ctx, crate::preprocessing::PreprocessMode::Passive) {
Ok(plan) => {
warnings.extend(plan.warnings);
pack_intents.push((pack.display_name.clone(), plan.intents));
}
Err(err) => {
warnings.push(format!(
"could not collect intents for pack '{}'; conflict detection may be incomplete: {}",
pack.display_name, err
));
}
}
let mut files = Vec::new();
for m in &matches {
let rel_str = m.relative_path.to_string_lossy().into_owned();
if m.handler == HANDLER_SYMLINK
&& !cfg!(target_os = "macos")
&& (rel_str == "_lib" || rel_str.starts_with("_lib/"))
{
continue;
}
let health = match m.handler.as_str() {
"symlink" => {
verify_symlink(&m.absolute_path, &pack.name, &rel_str, &pack.config, ctx)
}
"shell" | "path" => verify_staged(&m.absolute_path, &pack.name, &m.handler, ctx),
_ => {
let handler = registry.get(m.handler.as_str());
let deployed = handler
.and_then(|h| {
h.check_status(&m.absolute_path, &pack.name, ctx.datastore.as_ref())
.ok()
})
.map(|s| s.deployed)
.unwrap_or(false);
if deployed {
Health::Deployed
} else {
Health::Pending
}
}
};
let user_target = if m.handler == HANDLER_SYMLINK {
let target = resolve_target(&pack.name, &rel_str, &pack.config, ctx.paths.as_ref());
let home = ctx.paths.home_dir();
let display = if let Ok(rel) = target.strip_prefix(home) {
format!("~/{}", rel.display())
} else {
target.display().to_string()
};
Some(display)
} else {
None
};
let status_label = health.label(&m.handler);
let note_ref = health.footnote_reason().map(|reason| {
notes.push(DisplayNote {
body: reason.to_string(),
hint: None,
});
notes.len() as u32
});
files.push(DisplayFile {
name: rel_str.clone(),
symbol: handler_symbol(&m.handler).into(),
description: handler_description(&m.handler, &rel_str, user_target.as_deref()),
status: health.style().into(),
status_label,
handler: m.handler.clone(),
note_ref,
});
}
display_packs.push(DisplayPack::new(pack.display_name.clone(), files));
}
let detected_conflicts = conflicts::detect_cross_pack_conflicts(&pack_intents, ctx.fs.as_ref());
let home = ctx.paths.home_dir();
let display_conflicts: Vec<DisplayConflict> = detected_conflicts
.iter()
.map(|c| DisplayConflict::from_conflict(c, home))
.collect();
if !display_conflicts.is_empty() {
info!(
count = display_conflicts.len(),
"cross-pack conflicts detected"
);
} else {
debug!("no cross-pack conflicts");
}
let ignored_display: Vec<String> = ignored_packs
.iter()
.map(|d| crate::packs::display_name_for(d).to_string())
.collect();
Ok(PackStatusResult {
message: None,
dry_run: false,
packs: display_packs,
warnings,
notes,
conflicts: display_conflicts,
ignored_packs: ignored_display,
view_mode: ctx.view_mode.as_str().into(),
group_mode: ctx.group_mode.as_str().into(),
})
}