use crate::config::Relation;
use crate::doctor::{Diagnostic, Kind, Severity};
use crate::lint::{self, Diagnosis, Outcome, Status};
use crate::os_detect::Os;
use crate::sort::{SortNote, SortPlan};
use crate::where_cmd::{Found, Provenance, UninstallHint, WhereOutcome};
pub fn doctor_line(d: &Diagnostic, entries: &[String]) -> String {
if let Kind::Conflict { diagnostic, groups } = &d.kind {
return doctor_conflict(entries, diagnostic, groups);
}
let tag = match d.severity {
Severity::Error => "[ERR] ",
Severity::Warn => "[warn]",
};
let detail = match &d.kind {
Kind::Duplicate { first_index } => {
let first_path = entries.get(*first_index).cloned().unwrap_or_default();
format!(
"duplicate of entry #{first} ({first_path})",
first = first_index,
first_path = strip_control_chars(&first_path),
)
}
Kind::Missing => "directory does not exist".into(),
Kind::Shortenable { suggestion } => {
format!("could be written as {}", strip_control_chars(suggestion))
}
Kind::TrailingSlash => "trailing slash; some shells handle this oddly".into(),
Kind::CaseVariant { canonical } => format!(
"case / slash variant of {}; OS treats them as one directory",
strip_control_chars(canonical)
),
Kind::ShortName => "Windows 8.3 short name in PATH; long-name form is more portable".into(),
Kind::Malformed { reason } => format!("malformed entry: {}", strip_control_chars(reason)),
Kind::Conflict { .. } => unreachable!("handled by early return above"),
};
format!(
"{tag} #{idx:>3} {entry}\n {detail}",
idx = d.index,
entry = strip_control_chars(&d.entry)
)
}
fn doctor_conflict(entries: &[String], diagnostic: &str, groups: &[Vec<usize>]) -> String {
let safe_diag = strip_control_chars(diagnostic);
let header = if safe_diag == "mise_activate_both" {
"[warn] mise activate exposes both shim and install layers (PATH order matters)".to_string()
} else {
format!("[warn] {safe_diag}: multiple conflicting sources are active in PATH")
};
let mut buf = format!("{header}\n");
for (idx, group) in groups.iter().enumerate() {
buf.push_str(&format!(" group #{idx}:\n"));
for &i in group {
let entry = entries.get(i).cloned().unwrap_or_default();
buf.push_str(&format!(
" #{i:>3} {}\n",
strip_control_chars(&entry)
));
}
}
buf.pop(); buf
}
pub fn where_human(found: &Found) -> String {
let mut buf = String::new();
buf.push_str(&strip_control_chars(&found.command));
buf.push('\n');
buf.push_str(&format!(
" resolved: {}\n",
strip_control_chars(&found.resolved.to_string_lossy())
));
if found.matched_sources.is_empty() {
buf.push_str(" sources: (no source matched)\n");
} else {
let cleaned: Vec<String> = found
.matched_sources
.iter()
.map(|s| strip_control_chars(s).into_owned())
.collect();
buf.push_str(&format!(" sources: {}\n", cleaned.join(", ")));
}
if let Some(Provenance::WrapperInstaller {
installer,
plugin_segment,
}) = &found.provenance
{
let installer = strip_control_chars(installer);
let plugin_segment = strip_control_chars(plugin_segment);
buf.push_str(&format!(
" provenance: {installer} (via mise plugin `{plugin_segment}`)\n"
));
}
match &found.uninstall {
UninstallHint::Command { command } => {
buf.push_str(&format!(" hint: {command}"));
}
UninstallHint::NoTemplate { source } => {
buf.push_str(&format!(
" hint: (no uninstall template for source `{}`)",
strip_control_chars(source)
));
}
UninstallHint::NoSource => {
buf.push_str(" hint: (no source matched — pathlint cannot guess)");
}
}
buf
}
pub fn where_not_found(command: &str) -> String {
format!("{} — not found on PATH", strip_control_chars(command))
}
pub fn where_outcome(outcome: &WhereOutcome) -> String {
match outcome {
WhereOutcome::Found(f) => where_human(f),
WhereOutcome::NotFound => {
String::new()
}
}
}
pub fn sort_human(plan: &SortPlan) -> String {
let mut buf = String::new();
if plan.is_noop() {
buf.push_str("pathlint sort: PATH is already in a satisfying order.\n");
for note in &plan.notes {
push_sort_note(&mut buf, note);
}
if buf.ends_with('\n') {
buf.pop();
}
return buf;
}
buf.push_str("pathlint sort: proposed PATH order (--dry-run; not applied).\n\n");
let width = plan.original.len().max(plan.sorted.len()).to_string().len();
let original_header = "before".to_string();
let sorted_header = "after".to_string();
let col_w = plan
.original
.iter()
.chain(plan.sorted.iter())
.map(|s| s.len())
.max()
.unwrap_or(0)
.max(original_header.len());
buf.push_str(&format!(
" {:>w$} {:<col$} {:>w$} {:<col$}\n",
"#",
original_header,
"#",
sorted_header,
w = width,
col = col_w,
));
let rows = plan.original.len().max(plan.sorted.len());
for i in 0..rows {
let lhs = plan.original.get(i).cloned().unwrap_or_default();
let rhs = plan.sorted.get(i).cloned().unwrap_or_default();
buf.push_str(&format!(
" {:>w$} {:<col$} {:>w$} {:<col$}\n",
i,
lhs,
i,
rhs,
w = width,
col = col_w,
));
}
if !plan.moves.is_empty() {
buf.push_str("\nmoved:\n");
for m in &plan.moves {
buf.push_str(&format!(
" #{from} -> #{to}: {entry}\n reason: {reason}\n",
from = m.from,
to = m.to,
entry = m.entry,
reason = m.reason,
));
}
}
for note in &plan.notes {
push_sort_note(&mut buf, note);
}
if buf.ends_with('\n') {
buf.pop();
}
buf
}
fn push_sort_note(buf: &mut String, note: &SortNote) {
match note {
SortNote::UnsatisfiablePrefer { command, prefer } => {
buf.push_str(&format!(
"\nnote: `{command}` cannot be satisfied by reordering — no PATH entry matches `prefer = [{}]`. Install via one of those sources, or relax the rule.\n",
prefer.join(", "),
));
}
}
}
pub fn sort_json(plan: &SortPlan) -> Result<String, serde_json::Error> {
serde_json::to_string_pretty(plan)
}
pub fn relations_human(relations: &[Relation]) -> String {
if relations.is_empty() {
return "no relations declared".to_string();
}
let s = |v: &str| strip_control_chars(v).into_owned();
let mut buf = String::new();
for (i, rel) in relations.iter().enumerate() {
if i > 0 {
buf.push('\n');
}
match rel {
Relation::AliasOf { parent, children } => {
let kids: Vec<String> = children.iter().map(|c| s(c)).collect();
buf.push_str(&format!(
"alias_of: `{}` → [{}]",
s(parent),
kids.join(", ")
));
}
Relation::ConflictsWhenBothInPath {
sources,
diagnostic,
} => {
let names: Vec<String> = sources.iter().map(|n| s(n)).collect();
buf.push_str(&format!(
"conflicts_when_both_in_path: [{}] (diagnostic: `{}`)",
names.join(", "),
s(diagnostic),
));
}
Relation::ServedByVia {
host,
guest_pattern,
guest_provider,
installer_token,
} => {
let via = match installer_token {
Some(tok) if tok != guest_provider => {
format!(" (installer token `{}`)", s(tok))
}
_ => String::new(),
};
buf.push_str(&format!(
"served_by_via: `{}` serves `{}` from `{}`{via}",
s(host),
s(guest_pattern),
s(guest_provider),
));
}
Relation::DependsOn { source, target } => {
buf.push_str(&format!("depends_on: `{}` → `{}`", s(source), s(target),));
}
Relation::PreferOrderOver { earlier, later } => {
buf.push_str(&format!(
"prefer_order_over: `{}` should appear before `{}`",
s(earlier),
s(later),
));
}
}
}
buf
}
pub fn relations_json(relations: &[Relation]) -> Result<String, serde_json::Error> {
serde_json::to_string_pretty(relations)
}
pub fn doctor_json(diags: &[&Diagnostic]) -> Result<String, serde_json::Error> {
serde_json::to_string_pretty(diags)
}
pub fn check_json(outcomes: &[Outcome]) -> Result<String, serde_json::Error> {
let view: Vec<OutcomeView<'_>> = outcomes.iter().map(OutcomeView::from).collect();
serde_json::to_string_pretty(&view)
}
#[derive(serde::Serialize)]
struct OutcomeView<'a> {
command: &'a str,
status: &'a Status,
severity: crate::config::Severity,
#[serde(skip_serializing_if = "Option::is_none")]
resolved: Option<String>,
matched_sources: &'a [String],
#[serde(skip_serializing_if = "<[String]>::is_empty")]
prefer: &'a [String],
#[serde(skip_serializing_if = "<[String]>::is_empty")]
avoid: &'a [String],
#[serde(skip_serializing_if = "Option::is_none")]
diagnosis: Option<Diagnosis>,
}
impl<'a> From<&'a Outcome> for OutcomeView<'a> {
fn from(o: &'a Outcome) -> Self {
OutcomeView {
command: &o.command,
status: &o.status,
severity: o.severity,
resolved: o.resolved.as_ref().map(|p| p.display().to_string()),
matched_sources: &o.matched_sources,
prefer: &o.prefer,
avoid: &o.avoid,
diagnosis: lint::diagnose(o),
}
}
}
pub fn where_json(command: &str, outcome: &WhereOutcome) -> Result<String, serde_json::Error> {
#[derive(serde::Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
enum Out<'a> {
NotFound {
command: &'a str,
},
Found {
#[serde(flatten)]
inner: &'a Found,
},
}
let payload = match outcome {
WhereOutcome::NotFound => Out::NotFound { command },
WhereOutcome::Found(f) => Out::Found { inner: f },
};
serde_json::to_string_pretty(&payload)
}
pub fn strip_control_chars(s: &str) -> std::borrow::Cow<'_, str> {
if s.bytes().all(|b| !is_disallowed_byte(b)) {
return std::borrow::Cow::Borrowed(s);
}
let mut out = String::with_capacity(s.len());
for c in s.chars() {
if c == '\t' || c == '\n' {
out.push(c);
} else if (c as u32) < 0x20 || c as u32 == 0x7F {
out.push('?');
} else {
out.push(c);
}
}
std::borrow::Cow::Owned(out)
}
fn is_disallowed_byte(b: u8) -> bool {
matches!(b, 0..=0x08 | 0x0B..=0x1F | 0x7F)
}
pub fn posix_quote(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
out.push('\'');
for c in s.chars() {
if c == '\'' {
out.push_str("'\\''");
} else {
out.push(c);
}
}
out.push('\'');
out
}
pub fn powershell_quote(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
out.push('\'');
for c in s.chars() {
if c == '\'' {
out.push('\'');
out.push('\'');
} else {
out.push(c);
}
}
out.push('\'');
out
}
pub fn quote_for(os: Os, s: &str) -> String {
match os {
Os::Windows => powershell_quote(s),
Os::Macos | Os::Linux | Os::Termux => posix_quote(s),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn entries(strs: &[&str]) -> Vec<String> {
strs.iter().map(|s| s.to_string()).collect()
}
fn found_minimal() -> Found {
Found {
command: "rustc".into(),
resolved: PathBuf::from("/home/u/.cargo/bin/rustc"),
matched_sources: vec!["cargo".into()],
uninstall: UninstallHint::Command {
command: "cargo uninstall rustc".into(),
},
provenance: None,
}
}
#[test]
fn warn_diagnostic_has_warn_tag_and_indented_detail() {
let d = Diagnostic {
index: 3,
entry: "/usr/bin".into(),
severity: Severity::Warn,
kind: Kind::Missing,
};
let out = doctor_line(&d, &entries(&[]));
assert!(out.starts_with("[warn]"));
assert!(out.contains("# 3 /usr/bin"));
assert!(out.contains(" directory does not exist"));
}
#[test]
fn error_diagnostic_uses_err_tag() {
let d = Diagnostic {
index: 0,
entry: "C:\\foo|bar".into(),
severity: Severity::Error,
kind: Kind::Malformed {
reason: "illegal character '|' in path".into(),
},
};
let out = doctor_line(&d, &entries(&[]));
assert!(out.starts_with("[ERR]"));
assert!(out.contains("malformed entry: illegal character"));
}
#[test]
fn duplicate_renders_first_index_with_back_reference() {
let entries = entries(&["/usr/bin", "/foo/bar", "/usr/bin"]);
let d = Diagnostic {
index: 2,
entry: "/usr/bin".into(),
severity: Severity::Warn,
kind: Kind::Duplicate { first_index: 0 },
};
let out = doctor_line(&d, &entries);
assert!(
out.contains("duplicate of entry #0 (/usr/bin)"),
"out: {out}"
);
}
#[test]
fn shortenable_renders_suggestion_string() {
let d = Diagnostic {
index: 5,
entry: "C:\\Users\\who\\.cargo\\bin".into(),
severity: Severity::Warn,
kind: Kind::Shortenable {
suggestion: "%UserProfile%\\.cargo\\bin".into(),
},
};
let out = doctor_line(&d, &entries(&[]));
assert!(out.contains("could be written as %UserProfile%\\.cargo\\bin"));
}
#[test]
fn where_human_minimal_has_command_resolved_sources_hint_in_order() {
let out = where_human(&found_minimal());
let lines: Vec<&str> = out.lines().collect();
assert_eq!(lines[0], "rustc");
assert!(lines[1].starts_with(" resolved: "));
assert!(lines[2].starts_with(" sources: cargo"));
assert_eq!(lines[3], " hint: cargo uninstall rustc");
assert!(!out.ends_with('\n'));
}
#[test]
fn where_human_includes_provenance_line_when_set() {
let mut f = found_minimal();
f.matched_sources = vec!["mise_installs".into(), "mise".into()];
f.provenance = Some(Provenance::WrapperInstaller {
installer: "cargo".to_string(),
plugin_segment: "cargo-foo".into(),
});
f.uninstall = UninstallHint::Command {
command: "mise uninstall cargo:foo".into(),
};
let out = where_human(&f);
assert!(out.contains("provenance: cargo (via mise plugin `cargo-foo`)"));
}
#[test]
fn where_human_uninstall_no_template_names_the_source() {
let mut f = found_minimal();
f.uninstall = UninstallHint::NoTemplate {
source: "aqua".into(),
};
let out = where_human(&f);
assert!(
out.contains("(no uninstall template for source `aqua`)"),
"out: {out}"
);
}
#[test]
fn where_human_uninstall_no_source_says_pathlint_cannot_guess() {
let mut f = found_minimal();
f.matched_sources = Vec::new();
f.uninstall = UninstallHint::NoSource;
let out = where_human(&f);
assert!(out.contains("sources: (no source matched)"), "out: {out}");
assert!(
out.contains("(no source matched — pathlint cannot guess)"),
"out: {out}"
);
}
#[test]
fn where_not_found_is_single_line_with_em_dash() {
let out = where_not_found("ghost");
assert_eq!(out, "ghost — not found on PATH");
assert!(!out.ends_with('\n'));
}
#[test]
fn where_not_found_strips_control_chars_from_command() {
let out = where_not_found("rg\x1b[31m");
assert!(!out.contains('\x1b'), "raw escape leaked: {out:?}");
assert!(out.contains("rg?[31m"), "expected stripped form: {out:?}");
}
#[test]
fn where_human_no_template_strips_control_chars_from_source() {
let mut f = found_minimal();
f.uninstall = UninstallHint::NoTemplate {
source: "aqua\x1b[31m".into(),
};
let out = where_human(&f);
assert!(!out.contains('\x1b'), "raw escape leaked: {out:?}");
assert!(out.contains("aqua?[31m"), "expected stripped form: {out:?}");
}
#[test]
fn where_json_found_carries_kind_discriminators() {
let out = where_json("rustc", &WhereOutcome::Found(found_minimal())).unwrap();
let v: serde_json::Value = serde_json::from_str(&out).unwrap();
assert_eq!(v["kind"], "found");
assert_eq!(v["command"], "rustc");
assert_eq!(v["uninstall"]["kind"], "command");
assert_eq!(v["uninstall"]["command"], "cargo uninstall rustc");
assert!(v["provenance"].is_null());
}
#[test]
fn where_json_not_found_is_compact() {
let out = where_json("ghost", &WhereOutcome::NotFound).unwrap();
let v: serde_json::Value = serde_json::from_str(&out).unwrap();
assert_eq!(v["kind"], "not_found");
assert_eq!(v["command"], "ghost");
assert!(v.get("resolved").is_none());
}
#[test]
fn where_json_provenance_emits_kind_and_segment() {
let mut f = found_minimal();
f.provenance = Some(Provenance::WrapperInstaller {
installer: "cargo".to_string(),
plugin_segment: "cargo-foo".into(),
});
let out = where_json("foo", &WhereOutcome::Found(f)).unwrap();
let v: serde_json::Value = serde_json::from_str(&out).unwrap();
assert_eq!(v["provenance"]["kind"], "wrapper_installer");
assert_eq!(v["provenance"]["installer"], "cargo");
assert_eq!(v["provenance"]["plugin_segment"], "cargo-foo");
}
fn check_outcome_ok() -> Outcome {
Outcome {
command: "rg".into(),
status: Status::Ok,
resolved: Some(PathBuf::from("/home/u/.cargo/bin/rg")),
matched_sources: vec!["cargo".into()],
prefer: vec!["cargo".into()],
avoid: vec![],
severity: crate::config::Severity::Error,
}
}
fn check_outcome_wrong_source() -> Outcome {
Outcome {
command: "rg".into(),
status: Status::NgWrongSource,
resolved: Some(PathBuf::from("/usr/local/bin/rg")),
matched_sources: vec!["scoop".into()],
prefer: vec!["cargo".into()],
avoid: vec![],
severity: crate::config::Severity::Error,
}
}
fn sort_plan_noop() -> SortPlan {
SortPlan {
original: vec!["/usr/bin".into(), "/home/u/.cargo/bin".into()],
sorted: vec!["/usr/bin".into(), "/home/u/.cargo/bin".into()],
moves: vec![],
notes: vec![],
}
}
fn sort_plan_swap() -> SortPlan {
SortPlan {
original: vec!["/usr/bin".into(), "/home/u/.cargo/bin".into()],
sorted: vec!["/home/u/.cargo/bin".into(), "/usr/bin".into()],
moves: vec![
crate::sort::EntryMove {
entry: "/home/u/.cargo/bin".into(),
from: 1,
to: 0,
reason: "preferred source for `rg`".into(),
},
crate::sort::EntryMove {
entry: "/usr/bin".into(),
from: 0,
to: 1,
reason: "displaced by a preferred entry".into(),
},
],
notes: vec![],
}
}
#[test]
fn sort_human_noop_says_already_sorted() {
let out = sort_human(&sort_plan_noop());
assert!(out.contains("already in a satisfying order"), "out: {out}");
assert!(!out.ends_with('\n'), "no trailing newline");
}
#[test]
fn sort_human_swap_renders_both_columns_and_moved_section() {
let out = sort_human(&sort_plan_swap());
assert!(out.contains("before"), "out: {out}");
assert!(out.contains("after"), "out: {out}");
assert!(out.contains("--dry-run"), "must mention dry-run: {out}");
assert!(out.contains("moved:"), "must list moves: {out}");
assert!(out.contains("preferred source for `rg`"));
}
#[test]
fn sort_human_unsatisfiable_note_appears_after_diff() {
let mut plan = sort_plan_noop();
plan.notes.push(SortNote::UnsatisfiablePrefer {
command: "rg".into(),
prefer: vec!["cargo".into()],
});
let out = sort_human(&plan);
assert!(out.contains("`rg` cannot be satisfied"));
assert!(out.contains("`prefer = [cargo]`"));
}
#[test]
fn sort_json_serializes_plan_verbatim() {
let out = sort_json(&sort_plan_swap()).unwrap();
let v: serde_json::Value = serde_json::from_str(&out).unwrap();
assert_eq!(v["original"][0], "/usr/bin");
assert_eq!(v["sorted"][0], "/home/u/.cargo/bin");
assert_eq!(v["moves"][0]["from"], 1);
assert_eq!(v["moves"][0]["to"], 0);
assert_eq!(v["moves"][0]["entry"], "/home/u/.cargo/bin");
}
#[test]
fn sort_json_note_carries_kind_discriminator() {
let mut plan = sort_plan_noop();
plan.notes.push(SortNote::UnsatisfiablePrefer {
command: "rg".into(),
prefer: vec!["cargo".into()],
});
let out = sort_json(&plan).unwrap();
let v: serde_json::Value = serde_json::from_str(&out).unwrap();
assert_eq!(v["notes"][0]["kind"], "unsatisfiable_prefer");
assert_eq!(v["notes"][0]["command"], "rg");
}
#[test]
fn doctor_json_emits_top_level_array_with_kind_discriminator() {
let d_missing = Diagnostic {
index: 3,
entry: "/usr/bin".into(),
severity: Severity::Warn,
kind: Kind::Missing,
};
let d_short = Diagnostic {
index: 5,
entry: "C:\\Users\\who\\.cargo\\bin".into(),
severity: Severity::Warn,
kind: Kind::Shortenable {
suggestion: "%UserProfile%\\.cargo\\bin".into(),
},
};
let out = doctor_json(&[&d_missing, &d_short]).unwrap();
let v: serde_json::Value = serde_json::from_str(&out).unwrap();
assert!(v.is_array());
assert_eq!(v[0]["index"], 3);
assert_eq!(v[0]["entry"], "/usr/bin");
assert_eq!(v[0]["severity"], "warn");
assert_eq!(v[0]["kind"], "missing");
assert!(v[0].get("suggestion").is_none());
assert_eq!(v[1]["kind"], "shortenable");
assert_eq!(v[1]["suggestion"], "%UserProfile%\\.cargo\\bin");
}
#[test]
fn doctor_json_malformed_carries_error_severity_and_reason() {
let d = Diagnostic {
index: 0,
entry: "C:\\foo|bar".into(),
severity: Severity::Error,
kind: Kind::Malformed {
reason: "illegal character '|' in path".into(),
},
};
let out = doctor_json(&[&d]).unwrap();
let v: serde_json::Value = serde_json::from_str(&out).unwrap();
assert_eq!(v[0]["severity"], "error");
assert_eq!(v[0]["kind"], "malformed");
assert!(v[0]["reason"].as_str().unwrap().contains("illegal"));
}
#[test]
fn doctor_json_duplicate_carries_first_index() {
let d = Diagnostic {
index: 2,
entry: "/usr/bin".into(),
severity: Severity::Warn,
kind: Kind::Duplicate { first_index: 0 },
};
let out = doctor_json(&[&d]).unwrap();
let v: serde_json::Value = serde_json::from_str(&out).unwrap();
assert_eq!(v[0]["kind"], "duplicate");
assert_eq!(v[0]["first_index"], 0);
}
#[test]
fn doctor_json_conflict_carries_diagnostic_and_groups() {
let d = Diagnostic {
index: 0,
entry: "/home/u/.local/share/mise/shims".into(),
severity: Severity::Warn,
kind: Kind::Conflict {
diagnostic: "mise_activate_both".into(),
groups: vec![vec![0], vec![1, 2]],
},
};
let out = doctor_json(&[&d]).unwrap();
let v: serde_json::Value = serde_json::from_str(&out).unwrap();
assert_eq!(v[0]["kind"], "conflict");
assert_eq!(v[0]["diagnostic"], "mise_activate_both");
assert_eq!(v[0]["groups"][0][0], 0);
assert_eq!(v[0]["groups"][1][0], 1);
assert_eq!(v[0]["groups"][1][1], 2);
}
#[test]
fn doctor_conflict_strips_hostile_diagnostic() {
let entries = entries(&["/foo/a\x1b[31m_evil", "/foo/b"]);
let d = Diagnostic {
index: 0,
entry: entries[0].clone(),
severity: Severity::Warn,
kind: Kind::Conflict {
diagnostic: "evil\x1b[31mlabel\rFAKE".into(),
groups: vec![vec![0], vec![1]],
},
};
let out = doctor_line(&d, &entries);
assert!(!out.contains('\x1b'), "ANSI escape leaked through: {out:?}");
assert!(
!out.contains('\r'),
"carriage return leaked through: {out:?}"
);
assert!(
out.contains("evil?[31mlabel?FAKE"),
"stripped diagnostic must still be visible with replacements: {out:?}"
);
assert!(
out.contains("/foo/a?[31m_evil"),
"stripped entry must still be enumerated: {out:?}"
);
}
#[test]
fn doctor_json_empty_diagnostics_yields_empty_array() {
let out = doctor_json(&[]).unwrap();
let v: serde_json::Value = serde_json::from_str(&out).unwrap();
assert!(v.is_array());
assert_eq!(v.as_array().unwrap().len(), 0);
}
#[test]
fn check_json_emits_array_with_status_resolved_and_diagnosis() {
let out = check_json(&[check_outcome_ok(), check_outcome_wrong_source()]).unwrap();
let v: serde_json::Value = serde_json::from_str(&out).unwrap();
assert_eq!(v[0]["command"], "rg");
assert_eq!(v[0]["status"], "ok");
assert_eq!(v[0]["resolved"], "/home/u/.cargo/bin/rg");
assert!(
v[0].get("diagnosis").is_none(),
"ok must not carry diagnosis"
);
assert_eq!(v[1]["status"], "ng_wrong_source");
assert_eq!(v[1]["diagnosis"]["kind"], "wrong_source");
assert_eq!(v[1]["diagnosis"]["matched"][0], "scoop");
assert_eq!(v[1]["diagnosis"]["prefer_missed"][0], "cargo");
}
#[test]
fn check_json_omits_empty_prefer_and_avoid_for_ok() {
let out = check_json(&[check_outcome_ok()]).unwrap();
let v: serde_json::Value = serde_json::from_str(&out).unwrap();
assert!(v[0].get("prefer").is_some());
assert!(v[0].get("avoid").is_none(), "empty avoid leaked");
}
#[test]
fn check_json_resolved_field_absent_when_outcome_has_no_path() {
let not_found = Outcome {
command: "ghost".into(),
status: Status::NgNotFound,
resolved: None,
matched_sources: vec![],
prefer: vec!["cargo".into()],
avoid: vec![],
severity: crate::config::Severity::Error,
};
let out = check_json(&[not_found]).unwrap();
let v: serde_json::Value = serde_json::from_str(&out).unwrap();
assert!(v[0].get("resolved").is_none(), "resolved leaked: {out}");
assert_eq!(v[0]["diagnosis"]["kind"], "not_found");
}
#[test]
fn check_json_skip_outcome_has_no_diagnosis() {
let skip = Outcome {
command: "tooly".into(),
status: Status::Skip,
resolved: None,
matched_sources: vec![],
prefer: vec![],
avoid: vec![],
severity: crate::config::Severity::Error,
};
let out = check_json(&[skip]).unwrap();
let v: serde_json::Value = serde_json::from_str(&out).unwrap();
assert_eq!(v[0]["status"], "skip");
assert!(v[0].get("diagnosis").is_none());
}
#[test]
fn mise_activate_both_lists_each_layer_separately() {
let entries = entries(&[
"/home/u/.local/share/mise/shims",
"/home/u/.local/share/mise/installs/python/3.14/bin",
"/home/u/.local/share/mise/installs/node/25.9.0/bin",
]);
let d = Diagnostic {
index: 0,
entry: entries[0].clone(),
severity: Severity::Warn,
kind: Kind::Conflict {
diagnostic: "mise_activate_both".into(),
groups: vec![vec![0], vec![1, 2]],
},
};
let out = doctor_line(&d, &entries);
assert!(out.starts_with(
"[warn] mise activate exposes both shim and install layers (PATH order matters)"
));
assert!(out.contains("group #0:\n # 0 /home/u/.local/share/mise/shims"));
assert!(out.contains("group #1:\n # 1"));
assert!(out.contains("\n # 2 /home/u/.local/share/mise/installs/node/25.9.0/bin"));
assert!(!out.ends_with('\n'));
}
#[test]
fn doctor_line_strips_control_chars_from_entry() {
let d = Diagnostic {
index: 0,
entry: "/foo\x1b[31m/bar".into(),
severity: Severity::Warn,
kind: Kind::Missing,
};
let out = doctor_line(&d, &[]);
assert!(!out.contains('\x1b'), "raw escape: {out:?}");
assert!(out.contains("/foo?[31m/bar"));
}
#[test]
fn relations_human_strips_control_chars_from_diagnostic() {
let rels = vec![Relation::ConflictsWhenBothInPath {
sources: vec!["a".into(), "b".into()],
diagnostic: "evil\x1b[31m".into(),
}];
let out = relations_human(&rels);
assert!(!out.contains('\x1b'));
assert!(out.contains("evil?[31m"));
}
#[test]
fn user_conflict_uses_generic_header() {
let entries = entries(&["/foo/a", "/foo/b"]);
let d = Diagnostic {
index: 0,
entry: entries[0].clone(),
severity: Severity::Warn,
kind: Kind::Conflict {
diagnostic: "foo_overlap".into(),
groups: vec![vec![0], vec![1]],
},
};
let out = doctor_line(&d, &entries);
assert!(
out.starts_with("[warn] foo_overlap: multiple conflicting"),
"out: {out}"
);
assert!(out.contains("group #0:"));
assert!(out.contains("group #1:"));
}
fn alias_of(parent: &str, children: &[&str]) -> Relation {
Relation::AliasOf {
parent: parent.into(),
children: children.iter().map(|s| s.to_string()).collect(),
}
}
fn conflicts(sources: &[&str], diagnostic: &str) -> Relation {
Relation::ConflictsWhenBothInPath {
sources: sources.iter().map(|s| s.to_string()).collect(),
diagnostic: diagnostic.into(),
}
}
fn served(host: &str, pattern: &str, provider: &str) -> Relation {
Relation::ServedByVia {
host: host.into(),
guest_pattern: pattern.into(),
guest_provider: provider.into(),
installer_token: None,
}
}
fn depends(source: &str, target: &str) -> Relation {
Relation::DependsOn {
source: source.into(),
target: target.into(),
}
}
#[test]
fn relations_human_empty_says_no_relations() {
let out = relations_human(&[]);
assert_eq!(out, "no relations declared");
}
#[test]
fn relations_human_renders_alias_of_with_arrow_and_children() {
let rels = vec![alias_of("mise", &["mise_shims", "mise_installs"])];
let out = relations_human(&rels);
assert!(out.starts_with("alias_of:"), "out: {out}");
assert!(out.contains("`mise`"), "out: {out}");
assert!(out.contains("mise_shims, mise_installs"), "out: {out}");
}
#[test]
fn relations_human_renders_conflicts_with_diagnostic_name() {
let rels = vec![conflicts(
&["mise_shims", "mise_installs"],
"mise_activate_both",
)];
let out = relations_human(&rels);
assert!(out.starts_with("conflicts_when_both_in_path:"));
assert!(out.contains("mise_shims, mise_installs"));
assert!(out.contains("`mise_activate_both`"));
}
#[test]
fn relations_human_renders_served_by_via_with_pattern_and_provider() {
let rels = vec![served("mise_installs", "cargo-*", "cargo")];
let out = relations_human(&rels);
assert!(out.starts_with("served_by_via:"));
assert!(out.contains("`mise_installs`"));
assert!(out.contains("`cargo-*`"));
assert!(out.contains("`cargo`"));
}
#[test]
fn relations_human_renders_depends_on_with_source_and_target() {
let rels = vec![depends("paru", "pacman")];
let out = relations_human(&rels);
assert!(out.starts_with("depends_on:"));
assert!(out.contains("`paru`"));
assert!(out.contains("`pacman`"));
}
#[test]
fn relations_human_separates_multiple_relations_by_newline() {
let rels = vec![alias_of("mise", &["mise_shims"]), depends("paru", "pacman")];
let out = relations_human(&rels);
assert_eq!(out.lines().count(), 2, "out:\n{out}");
assert!(!out.ends_with('\n'));
}
#[test]
fn relations_json_is_an_array_with_kind_discriminator() {
let rels = vec![
alias_of("mise", &["mise_shims"]),
conflicts(&["a", "b"], "x"),
served("h", "p-*", "g"),
depends("a", "b"),
];
let out = relations_json(&rels).unwrap();
let v: serde_json::Value = serde_json::from_str(&out).unwrap();
let arr = v.as_array().unwrap();
assert_eq!(arr.len(), 4);
assert_eq!(arr[0]["kind"], "alias_of");
assert_eq!(arr[1]["kind"], "conflicts_when_both_in_path");
assert_eq!(arr[2]["kind"], "served_by_via");
assert_eq!(arr[3]["kind"], "depends_on");
}
#[test]
fn relations_json_alias_of_carries_parent_and_children_at_top_level() {
let rels = vec![alias_of("mise", &["mise_shims", "mise_installs"])];
let out = relations_json(&rels).unwrap();
let v: serde_json::Value = serde_json::from_str(&out).unwrap();
assert_eq!(v[0]["parent"], "mise");
assert_eq!(v[0]["children"][0], "mise_shims");
assert_eq!(v[0]["children"][1], "mise_installs");
}
#[test]
fn relations_json_served_by_via_keeps_pattern_field() {
let rels = vec![served("mise_installs", "cargo-*", "cargo")];
let out = relations_json(&rels).unwrap();
let v: serde_json::Value = serde_json::from_str(&out).unwrap();
assert_eq!(v[0]["host"], "mise_installs");
assert_eq!(v[0]["guest_pattern"], "cargo-*");
assert_eq!(v[0]["guest_provider"], "cargo");
}
#[test]
fn posix_quote_wraps_simple_input_in_single_quotes() {
assert_eq!(posix_quote("lazygit"), "'lazygit'");
}
#[test]
fn posix_quote_neutralises_metachars() {
assert_eq!(posix_quote("$(rm -rf ~)"), "'$(rm -rf ~)'");
assert_eq!(posix_quote("a;b`c"), "'a;b`c'");
assert_eq!(posix_quote("with\nnewline"), "'with\nnewline'");
}
#[test]
fn posix_quote_handles_embedded_single_quote() {
assert_eq!(posix_quote("it's"), "'it'\\''s'");
}
#[test]
fn powershell_quote_doubles_inner_single_quotes() {
assert_eq!(powershell_quote("it's"), "'it''s'");
assert_eq!(powershell_quote("plain"), "'plain'");
}
#[test]
fn quote_for_dispatches_by_os() {
assert_eq!(quote_for(Os::Linux, "it's"), "'it'\\''s'");
assert_eq!(quote_for(Os::Macos, "it's"), "'it'\\''s'");
assert_eq!(quote_for(Os::Termux, "it's"), "'it'\\''s'");
assert_eq!(quote_for(Os::Windows, "it's"), "'it''s'");
}
#[test]
fn strip_control_chars_keeps_plain_text() {
let s = "Hello, world!";
let out = strip_control_chars(s);
assert!(matches!(out, std::borrow::Cow::Borrowed(_)));
assert_eq!(out, s);
}
#[test]
fn strip_control_chars_replaces_ansi_escape() {
let out = strip_control_chars("rg\x1b[31m");
assert_eq!(out, "rg?[31m");
}
#[test]
fn strip_control_chars_preserves_tab_and_newline() {
let out = strip_control_chars("a\tb\nc");
assert_eq!(out, "a\tb\nc");
}
#[test]
fn strip_control_chars_replaces_del() {
let out = strip_control_chars("x\x7Fy");
assert_eq!(out, "x?y");
}
#[test]
fn where_human_strips_ansi_escape_in_command() {
let f = Found {
command: "rg\x1b[31m".into(),
resolved: PathBuf::from("/usr/bin/rg"),
matched_sources: vec!["apt".into()],
uninstall: UninstallHint::NoTemplate {
source: "apt".into(),
},
provenance: None,
};
let out = where_human(&f);
assert!(!out.contains('\x1b'), "raw escape leaked: {out:?}");
assert!(out.contains("rg?[31m"));
}
}