use crate::format::strip_control_chars;
use crate::lint::{self, Diagnosis, Outcome, Status};
fn join_clean(values: &[String]) -> String {
values
.iter()
.map(|v| strip_control_chars(v).into_owned())
.collect::<Vec<_>>()
.join(", ")
}
#[derive(Debug, Clone, Copy)]
pub struct Style {
pub no_glyphs: bool,
pub verbose: bool,
pub quiet: bool,
pub explain: bool,
pub color: bool,
}
impl Style {
pub fn should_show(&self, o: &Outcome) -> bool {
if self.quiet && !lint::is_failure(&o.status) {
return false;
}
if !self.verbose && matches!(o.status, Status::NotApplicable) {
return false;
}
true
}
}
pub fn render(outcomes: &[Outcome], style: Style) -> String {
let mut buf = String::new();
for o in outcomes.iter().filter(|o| style.should_show(o)) {
buf.push_str(&render_one(o, style));
buf.push('\n');
}
buf
}
fn render_one(o: &Outcome, style: Style) -> String {
let raw_tag = status_tag(o, style.no_glyphs);
let tag = colourize_tag(raw_tag, &o.status, style.color, o.severity);
let mut line = format!("{tag} {command}", command = strip_control_chars(&o.command));
if style.explain && lint::is_failure(&o.status) {
for ex in explain_lines(o) {
line.push('\n');
line.push_str(" ");
line.push_str(&ex);
}
return line;
}
if let Some(d) = detail_line(o) {
line.push('\n');
line.push_str(" ");
line.push_str(&d);
}
line
}
fn detail_line(o: &Outcome) -> Option<String> {
match &o.status {
Status::Ok => {
return o.resolved.as_ref().map(|p| {
let sources = if o.matched_sources.is_empty() {
String::from("(no source matched)")
} else {
format!("source: {}", join_clean(&o.matched_sources))
};
format!(
"{} — {}",
strip_control_chars(&p.to_string_lossy()),
sources
)
});
}
Status::Skip => return Some("optional; not on PATH".into()),
Status::NotApplicable => return Some("excluded by os filter".into()),
_ => {}
}
lint::diagnose(o).map(|d| detail_one_liner(o, &d))
}
fn detail_one_liner(o: &Outcome, diagnosis: &Diagnosis) -> String {
let resolved = resolved_or_placeholder(o);
match diagnosis {
Diagnosis::WrongSource {
matched,
prefer_missed,
avoid_hits: _,
} => format!(
"resolved: {resolved} — matched sources: [{}], prefer: [{}], avoid: [{}]",
join_clean(matched),
join_clean(prefer_missed),
join_clean(&o.avoid),
),
Diagnosis::UnknownSource { prefer } => format!(
"resolved: {resolved} — matched no defined source; prefer: [{}]",
join_clean(prefer),
),
Diagnosis::NotFound { .. } => "not found on PATH".into(),
Diagnosis::NotExecutable { reason, .. } => format!(
"resolved: {resolved} — not executable: {}",
strip_control_chars(reason)
),
Diagnosis::Config { message } => strip_control_chars(message).into_owned(),
}
}
fn status_tag(o: &Outcome, no_glyphs: bool) -> &'static str {
use crate::config::Severity as RuleSeverity;
let is_warn_failure = lint::is_failure(&o.status) && o.severity == RuleSeverity::Warn;
match (&o.status, no_glyphs, is_warn_failure) {
(Status::Ok, false, _) => "[OK] ",
(
Status::NgWrongSource
| Status::NgUnknownSource
| Status::NgNotFound
| Status::NgNotExecutable,
false,
true,
) => "[warn]",
(
Status::NgWrongSource
| Status::NgUnknownSource
| Status::NgNotFound
| Status::NgNotExecutable,
false,
false,
) => "[NG] ",
(Status::Skip, false, _) => "[skip]",
(Status::NotApplicable, false, _) => "[n/a] ",
(Status::ConfigError, false, _) => "[ERR] ",
(Status::Ok, true, _) => "OK ",
(
Status::NgWrongSource
| Status::NgUnknownSource
| Status::NgNotFound
| Status::NgNotExecutable,
true,
true,
) => "warn ",
(
Status::NgWrongSource
| Status::NgUnknownSource
| Status::NgNotFound
| Status::NgNotExecutable,
true,
false,
) => "NG ",
(Status::Skip, true, _) => "skip ",
(Status::NotApplicable, true, _) => "n/a ",
(Status::ConfigError, true, _) => "ERR ",
}
}
fn colourize_tag(
raw: &'static str,
status: &Status,
color: bool,
severity: crate::config::Severity,
) -> String {
if !color {
return raw.to_string();
}
use crate::config::Severity as RuleSeverity;
let is_warn_failure = lint::is_failure(status) && severity == RuleSeverity::Warn;
let code = match (status, is_warn_failure) {
(Status::Ok, _) => "32", (_, true) => "33", (Status::ConfigError, _) => "31",
(
Status::NgWrongSource
| Status::NgUnknownSource
| Status::NgNotFound
| Status::NgNotExecutable,
false,
) => "31", (Status::Skip | Status::NotApplicable, _) => "33", };
format!("\x1b[{code}m{raw}\x1b[0m")
}
pub fn explain_lines(o: &Outcome) -> Vec<String> {
let Some(diagnosis) = lint::diagnose(o) else {
return Vec::new();
};
explain_lines_from(o, &diagnosis)
}
fn explain_lines_from(o: &Outcome, diagnosis: &Diagnosis) -> Vec<String> {
let resolved = resolved_or_placeholder(o);
match diagnosis {
Diagnosis::WrongSource {
matched,
prefer_missed,
avoid_hits,
} => vec![
format!("resolved: {resolved}"),
format!("matched sources: {}", join_or_none(matched)),
format!("prefer: {}", join_or_none(prefer_missed)),
format!("avoid: {}", join_or_none(&o.avoid)),
format!(
"diagnosis: {}",
wrong_source_sentence(matched, prefer_missed, avoid_hits)
),
format!(
"hint: run `pathlint trace {}` for uninstall guidance.",
strip_control_chars(&o.command)
),
],
Diagnosis::UnknownSource { prefer } => vec![
format!("resolved: {resolved}"),
"matched sources: (none — path is outside every defined source)".into(),
format!("prefer: {}", join_or_none(prefer)),
format!("avoid: {}", join_or_none(&o.avoid)),
"diagnosis: command resolves from a directory not declared in any \
[source.<name>]. Either add a source for that directory or remove the \
directory from PATH."
.into(),
format!(
"hint: run `pathlint trace {}` to see the full path; \
add `[source.X]` matching it if you want this case to pass.",
strip_control_chars(&o.command)
),
],
Diagnosis::NotFound { prefer } => vec![
format!("command: {}", strip_control_chars(&o.command)),
format!("prefer: {}", join_or_none(prefer)),
"diagnosis: command not found on any PATH entry.".into(),
"hint: install it via one of the prefer sources, \
or pass `optional = true` if the rule should accept absence."
.into(),
],
Diagnosis::NotExecutable { reason, matched } => vec![
format!("resolved: {resolved}"),
format!("matched sources: {}", join_or_none(matched)),
format!(
"diagnosis: not executable — {}",
strip_control_chars(reason)
),
"hint: another file/directory of the same name shadows the binary, \
or the file lost its +x bit / became a broken symlink."
.into(),
],
Diagnosis::Config { message } => vec![
format!("config error: {}", strip_control_chars(message)),
"hint: check spelling against `pathlint catalog list --names-only`.".into(),
],
}
}
fn resolved_or_placeholder(o: &Outcome) -> String {
o.resolved
.as_ref()
.map(|p| strip_control_chars(&p.to_string_lossy()).into_owned())
.unwrap_or_else(|| "<unresolved>".into())
}
fn join_or_none(v: &[String]) -> String {
if v.is_empty() {
"(none)".into()
} else {
join_clean(v)
}
}
fn wrong_source_sentence(
matched: &[String],
prefer_missed: &[String],
avoid_hits: &[String],
) -> String {
if !avoid_hits.is_empty() {
return format!(
"resolved path matched `avoid` source(s) [{}]; rule forbids these.",
join_clean(avoid_hits)
);
}
if prefer_missed.is_empty() {
return "resolved path matched a source the rule rejects.".into();
}
format!(
"resolved path matched [{}], none of which are in `prefer` [{}].",
join_clean(matched),
join_clean(prefer_missed)
)
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn ng_wrong_source(matched: &[&str], prefer: &[&str], avoid: &[&str]) -> Outcome {
Outcome {
command: "rg".into(),
status: Status::NgWrongSource,
resolved: Some(PathBuf::from("/usr/local/bin/rg")),
matched_sources: matched.iter().map(|s| s.to_string()).collect(),
prefer: prefer.iter().map(|s| s.to_string()).collect(),
avoid: avoid.iter().map(|s| s.to_string()).collect(),
severity: crate::config::Severity::Error,
reason: None,
}
}
#[test]
fn explain_lines_empty_for_ok_status() {
let o = 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,
reason: None,
};
assert!(explain_lines(&o).is_empty());
}
#[test]
fn explain_lines_wrong_source_lists_resolved_matched_prefer_avoid() {
let o = ng_wrong_source(&["scoop"], &["cargo"], &[]);
let lines = explain_lines(&o);
assert_eq!(lines[0], "resolved: /usr/local/bin/rg");
assert_eq!(lines[1], "matched sources: scoop");
assert_eq!(lines[2], "prefer: cargo");
assert_eq!(lines[3], "avoid: (none)");
assert!(lines[4].starts_with("diagnosis:"));
assert!(lines[4].contains("none of which are in `prefer`"));
assert!(lines[5].starts_with("hint:"));
assert!(lines[5].contains("pathlint trace rg"));
}
#[test]
fn explain_lines_wrong_source_calls_out_avoid_hit_explicitly() {
let o = ng_wrong_source(&["winget"], &["cargo"], &["winget"]);
let lines = explain_lines(&o);
let diagnosis = lines.iter().find(|l| l.starts_with("diagnosis:")).unwrap();
assert!(
diagnosis.contains("matched `avoid` source(s) [winget]"),
"diagnosis: {diagnosis}"
);
}
#[test]
fn explain_lines_unknown_source_says_path_outside_every_source() {
let o = Outcome {
command: "rg".into(),
status: Status::NgUnknownSource,
resolved: Some(PathBuf::from("/opt/custom/bin/rg")),
matched_sources: vec![],
prefer: vec!["cargo".into()],
avoid: vec![],
severity: crate::config::Severity::Error,
reason: None,
};
let lines = explain_lines(&o);
assert!(
lines
.iter()
.any(|l| l.contains("path is outside every defined source")),
);
assert!(
lines
.iter()
.any(|l| l.starts_with("hint:") && l.contains("[source.X]")),
);
}
#[test]
fn explain_lines_not_found_advises_install_or_optional() {
let o = Outcome {
command: "ghost".into(),
status: Status::NgNotFound,
resolved: None,
matched_sources: vec![],
prefer: vec!["cargo".into()],
avoid: vec![],
severity: crate::config::Severity::Error,
reason: None,
};
let lines = explain_lines(&o);
assert!(lines.iter().any(|l| l.contains("not found on any PATH")));
assert!(lines.iter().any(|l| l.contains("optional = true")));
}
#[test]
fn explain_lines_not_executable_carries_reason_and_shadow_hint() {
let o = Outcome {
command: "rg".into(),
status: Status::NgNotExecutable,
resolved: Some(PathBuf::from("/tmp/rg")),
matched_sources: vec!["custom".into()],
prefer: vec!["custom".into()],
avoid: vec![],
severity: crate::config::Severity::Error,
reason: Some("is a directory".into()),
};
let lines = explain_lines(&o);
assert!(
lines
.iter()
.any(|l| l.contains("not executable — is a directory"))
);
assert!(lines.iter().any(|l| l.contains("shadows the binary")));
}
fn style(explain: bool) -> Style {
Style {
no_glyphs: false,
verbose: false,
quiet: false,
explain,
color: false,
}
}
#[test]
fn render_without_explain_keeps_one_line_detail() {
let outcomes = vec![ng_wrong_source(&["scoop"], &["cargo"], &[])];
let out = render(&outcomes, style(false));
assert!(out.contains("matched sources: [scoop]"), "out: {out}");
let n_lines = out.trim_end().lines().count();
assert_eq!(n_lines, 2, "out:\n{out}");
}
#[test]
fn render_with_explain_emits_multiline_breakdown() {
let outcomes = vec![ng_wrong_source(&["scoop"], &["cargo"], &[])];
let out = render(&outcomes, style(true));
let n_lines = out.trim_end().lines().count();
assert_eq!(n_lines, 7, "out:\n{out}");
assert!(out.contains(" resolved: /usr/local/bin/rg"));
assert!(out.contains(" diagnosis:"));
assert!(out.contains(" hint: run `pathlint trace rg`"));
}
#[test]
fn warn_severity_failure_uses_warn_tag_instead_of_ng() {
let warn_outcome = Outcome {
severity: crate::config::Severity::Warn,
..ng_wrong_source(&["scoop"], &["cargo"], &[])
};
let out = render(&[warn_outcome], style(false));
assert!(out.contains("[warn]"), "out: {out}");
assert!(!out.contains("[NG]"), "warn must not also tag NG: {out}");
}
#[test]
fn warn_severity_no_glyph_uses_lower_warn_tag() {
let warn_outcome = Outcome {
severity: crate::config::Severity::Warn,
..ng_wrong_source(&["scoop"], &["cargo"], &[])
};
let s = Style {
no_glyphs: true,
..style(false)
};
let out = render(&[warn_outcome], s);
assert!(out.contains("warn "), "out: {out}");
assert!(!out.contains("NG "), "out: {out}");
}
#[test]
fn render_explain_skips_ok_outcomes_unchanged() {
let ok = 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,
reason: None,
};
let out = render(&[ok], style(true));
assert!(out.contains("[OK]"), "out: {out:?}");
assert!(out.contains("rg"), "out: {out:?}");
assert!(out.contains("source: cargo"));
assert_eq!(out.trim_end().lines().count(), 2);
}
#[test]
fn render_strips_control_chars_from_command() {
let evil = Outcome {
command: "rg\x1b[31m".into(),
..ng_wrong_source(&["scoop"], &["cargo"], &[])
};
let out = render(&[evil], style(false));
assert!(!out.contains('\x1b'), "raw escape leaked: {out:?}");
assert!(out.contains("rg?[31m"), "expected stripped form: {out:?}");
}
#[test]
fn render_strips_control_chars_from_matched_sources() {
let evil = Outcome {
command: "rg".into(),
status: Status::Ok,
resolved: Some(PathBuf::from("/usr/bin/rg")),
matched_sources: vec!["evil\x1b[31m".into()],
prefer: vec![],
avoid: vec![],
severity: crate::config::Severity::Error,
reason: None,
};
let out = render(&[evil], style(false));
assert!(!out.contains('\x1b'));
assert!(out.contains("source: evil?[31m"));
}
#[test]
fn explain_lines_config_error_quotes_the_underlying_message() {
let o = Outcome {
command: "rg".into(),
status: Status::ConfigError,
resolved: None,
matched_sources: vec![],
prefer: vec![],
avoid: vec![],
severity: crate::config::Severity::Error,
reason: Some("undefined source name: typo".into()),
};
let lines = explain_lines(&o);
assert!(lines[0].contains("undefined source name: typo"));
assert!(lines[1].contains("catalog list"));
}
fn outcome_with(status: Status) -> Outcome {
Outcome {
command: "rg".into(),
status,
resolved: None,
matched_sources: vec![],
prefer: vec![],
avoid: vec![],
severity: crate::config::Severity::Error,
reason: None,
}
}
#[test]
fn should_show_default_style_keeps_ok_skip_failure_drops_not_applicable() {
let s = style(false);
assert!(s.should_show(&outcome_with(Status::Ok)));
assert!(s.should_show(&outcome_with(Status::Skip)));
assert!(s.should_show(&outcome_with(Status::NgNotFound)));
assert!(!s.should_show(&outcome_with(Status::NotApplicable)));
}
#[test]
fn should_show_verbose_keeps_not_applicable() {
let s = Style {
verbose: true,
..style(false)
};
assert!(s.should_show(&outcome_with(Status::NotApplicable)));
}
#[test]
fn should_show_quiet_drops_non_failures() {
let s = Style {
quiet: true,
..style(false)
};
assert!(!s.should_show(&outcome_with(Status::Ok)));
assert!(!s.should_show(&outcome_with(Status::Skip)));
assert!(!s.should_show(&outcome_with(Status::NotApplicable)));
assert!(s.should_show(&outcome_with(Status::NgNotFound)));
assert!(s.should_show(&outcome_with(Status::NgWrongSource)));
}
#[test]
fn should_show_quiet_overrides_verbose_for_non_failures() {
let s = Style {
quiet: true,
verbose: true,
..style(false)
};
assert!(!s.should_show(&outcome_with(Status::Ok)));
assert!(!s.should_show(&outcome_with(Status::NotApplicable)));
}
}