use std::path::{Path, PathBuf};
use anyhow::{Context, Result, bail};
use serde::Serialize;
use crate::agent_check::{self, Posture};
use crate::aliases::{self, Alias};
use crate::cli::ConfigLintArgs;
use crate::profile::{self, Profile};
#[derive(Debug, Serialize)]
struct Finding {
file: String,
rule: &'static str,
message: String,
#[serde(skip_serializing_if = "Option::is_none")]
hint: Option<String>,
}
#[derive(Debug, Serialize)]
struct Report {
findings: Vec<Finding>,
ok: bool,
}
pub fn run(args: ConfigLintArgs) -> Result<i32> {
let cwd = std::env::current_dir().context("resolving current directory")?;
let files = match &args.path {
Some(p) => {
if !p.is_file() {
bail!("no such config file: {}", p.display());
}
vec![p.clone()]
}
None => pool_files(&cwd),
};
let mut findings = Vec::new();
for file in &files {
lint_file(file, &cwd, &mut findings);
}
let ok = findings.is_empty();
let exit = if ok { 0 } else { 1 };
if args.json {
let report = Report { findings, ok };
println!(
"{}",
serde_json::to_string_pretty(&crate::VersionedResult::new(&report))?
);
return Ok(exit);
}
render_human(&findings, &files);
Ok(exit)
}
fn pool_files(cwd: &Path) -> Vec<PathBuf> {
let mut files: Vec<PathBuf> = Vec::new();
if let Some(user) = profile::user_config_path()
&& user.is_file()
{
files.push(user);
}
files.extend(profile::discover_project_configs(cwd));
files
}
fn lint_file(path: &Path, cwd: &Path, findings: &mut Vec<Finding>) {
let file = path.display().to_string();
let content = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(e) => {
findings.push(Finding {
file,
rule: "read",
message: format!("could not read file: {e}"),
hint: None,
});
return;
}
};
let cfg = match profile::pool::parse_config_str(&content) {
Ok(c) => c,
Err(e) => {
findings.push(Finding {
file,
rule: "parse",
message: format!("{e:#}"),
hint: Some("fix the TOML / remove the unknown key so the file loads".to_string()),
});
return;
}
};
let mut alias_names: Vec<&String> = cfg.alias.keys().collect();
alias_names.sort();
for name in alias_names {
if aliases::is_builtin_subcommand(name) {
findings.push(Finding {
file: file.clone(),
rule: "builtin-shadow",
message: format!(
"alias `{name}` is shadowed by the built-in `{name}` subcommand; it never dispatches"
),
hint: Some("rename the alias to a verb that isn't a built-in".to_string()),
});
}
}
let mut profile_names: Vec<&String> = cfg.profile.keys().collect();
profile_names.sort();
for name in profile_names {
let profile = &cfg.profile[name];
if let Some(agent) = &profile.agent {
check_pinned_agent(
agent,
&format!("profile.{name}"),
Some(profile_posture(profile)),
cwd,
&file,
findings,
);
}
}
let mut alias_keys: Vec<&String> = cfg.alias.keys().collect();
alias_keys.sort();
for name in alias_keys {
let alias = &cfg.alias[name];
if let Some(agent) = &alias.agent {
check_pinned_agent(
agent,
&format!("alias.{name}"),
alias_posture(alias),
cwd,
&file,
findings,
);
}
}
}
fn check_pinned_agent(
agent: &str,
source: &str,
posture: Option<Posture>,
cwd: &Path,
file: &str,
findings: &mut Vec<Finding>,
) {
let Some(agent_path) = agent_check::find_agent_file(agent, cwd) else {
findings.push(Finding {
file: file.to_string(),
rule: "missing-agent",
message: format!("{source}: pinned agent `{agent}` not found"),
hint: Some(
"check the name, or that the agent exists under .claude/agents/ (project or ~)"
.to_string(),
),
});
return;
};
let Some(posture) = posture else { return };
let Ok(content) = std::fs::read_to_string(&agent_path) else {
return;
};
let Some(declared) = agent_check::parse_tools(&content) else {
return;
};
let missing = agent_check::missing_tools_for_posture(&declared, &posture);
if !missing.is_empty() {
findings.push(Finding {
file: file.to_string(),
rule: "agent-tool-mismatch",
message: format!(
"{source}: agent `{agent}` declares tools not granted by this entry's flags: [{}]",
missing.join(", ")
),
hint: Some(
"intentional? add --no-agent-check to the entry's flags; otherwise grant via --allow-tool / --writable / --full-auto"
.to_string(),
),
});
}
}
fn profile_posture(p: &Profile) -> Posture {
Posture {
writable: p.writable.unwrap_or(false),
full_auto: p.full_auto.unwrap_or(false),
allow_tool: p.allow_tool.clone(),
}
}
fn alias_posture(alias: &Alias) -> Option<Posture> {
use clap::Parser;
let mut argv: Vec<String> = vec!["roba".to_string()];
argv.extend(alias.flags.iter().cloned());
argv.push("placeholder".to_string());
crate::cli::Cli::try_parse_from(&argv)
.ok()
.map(|cli| Posture::from_args(&cli.ask))
}
fn render_human(findings: &[Finding], files: &[PathBuf]) {
if findings.is_empty() {
if files.is_empty() {
println!("no roba.toml found in the config pool");
} else {
println!("no issues found ({} file(s) checked)", files.len());
}
return;
}
let mut current: Option<&str> = None;
for f in findings {
if current != Some(f.file.as_str()) {
println!("{}", f.file);
current = Some(f.file.as_str());
}
println!(" [{}] {}", f.rule, f.message);
if let Some(hint) = &f.hint {
println!(" hint: {hint}");
}
}
let n = findings.len();
let plural = if n == 1 { "" } else { "s" };
println!("\n{n} issue{plural} found");
}
#[cfg(test)]
mod tests {
use super::*;
fn write(dir: &Path, name: &str, content: &str) -> PathBuf {
let path = dir.join(name);
std::fs::write(&path, content).unwrap();
path
}
fn findings_for(dir: &Path, content: &str) -> Vec<Finding> {
let path = write(dir, "roba.toml", content);
let mut findings = Vec::new();
lint_file(&path, dir, &mut findings);
findings
}
#[test]
fn clean_config_has_no_findings() {
let dir = tempfile::tempdir().unwrap();
let findings = findings_for(
dir.path(),
"readonly = true\n\n[profile.review]\ngit_diff = true\n\n[alias.r]\ntemplate = \"review ${@}\"\n",
);
assert!(findings.is_empty(), "expected clean, got: {findings:?}");
}
#[test]
fn builtin_shadowing_alias_is_flagged() {
let dir = tempfile::tempdir().unwrap();
let findings = findings_for(dir.path(), "[alias.show]\ntemplate = \"x ${@}\"\n");
assert_eq!(findings.len(), 1, "got: {findings:?}");
assert_eq!(findings[0].rule, "builtin-shadow");
assert!(findings[0].message.contains("show"), "{:?}", findings[0]);
}
#[test]
fn another_builtin_shadowing_alias_is_flagged() {
let dir = tempfile::tempdir().unwrap();
let findings = findings_for(dir.path(), "[alias.cost]\ntemplate = \"x ${@}\"\n");
assert_eq!(findings.len(), 1, "got: {findings:?}");
assert_eq!(findings[0].rule, "builtin-shadow");
}
#[test]
fn non_builtin_alias_is_not_flagged() {
let dir = tempfile::tempdir().unwrap();
let findings = findings_for(dir.path(), "[alias.my-verb]\ntemplate = \"x ${@}\"\n");
assert!(findings.is_empty(), "got: {findings:?}");
}
#[test]
fn parse_error_is_a_finding_and_short_circuits() {
let dir = tempfile::tempdir().unwrap();
let findings = findings_for(
dir.path(),
"totally_bogus_key = true\n\n[alias.cost]\ntemplate = \"x\"\n",
);
assert_eq!(findings.len(), 1, "got: {findings:?}");
assert_eq!(findings[0].rule, "parse");
assert!(
findings[0].message.contains("totally_bogus_key")
|| findings[0].message.contains("unknown field"),
"{:?}",
findings[0]
);
}
#[test]
fn missing_pinned_agent_in_profile_is_flagged() {
let dir = tempfile::tempdir().unwrap();
let findings = findings_for(dir.path(), "[profile.x]\nagent = \"nope-not-here\"\n");
assert_eq!(findings.len(), 1, "got: {findings:?}");
assert_eq!(findings[0].rule, "missing-agent");
assert!(
findings[0].message.contains("nope-not-here"),
"{:?}",
findings[0]
);
assert!(
findings[0].message.contains("profile.x"),
"{:?}",
findings[0]
);
}
#[test]
fn present_pinned_agent_with_matching_tools_is_clean() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join(".claude/agents")).unwrap();
std::fs::write(
dir.path().join(".claude/agents/reader.md"),
"---\nname: reader\ntools: Read, Glob, Grep\n---\nbody\n",
)
.unwrap();
let findings = findings_for(dir.path(), "[profile.x]\nagent = \"reader\"\n");
assert!(findings.is_empty(), "got: {findings:?}");
}
#[test]
fn agent_tool_mismatch_in_readonly_profile_is_flagged() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join(".claude/agents")).unwrap();
std::fs::write(
dir.path().join(".claude/agents/writer.md"),
"---\nname: writer\ntools: Read, Edit, Write, Bash\n---\nbody\n",
)
.unwrap();
let findings = findings_for(dir.path(), "[profile.x]\nagent = \"writer\"\n");
assert_eq!(findings.len(), 1, "got: {findings:?}");
assert_eq!(findings[0].rule, "agent-tool-mismatch");
assert!(findings[0].message.contains("Edit"), "{:?}", findings[0]);
}
#[test]
fn agent_tool_mismatch_silenced_by_full_auto_profile() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join(".claude/agents")).unwrap();
std::fs::write(
dir.path().join(".claude/agents/writer.md"),
"---\nname: writer\ntools: Read, Edit, Write, Bash\n---\nbody\n",
)
.unwrap();
let findings = findings_for(
dir.path(),
"[profile.x]\nfull_auto = true\nagent = \"writer\"\n",
);
assert!(findings.is_empty(), "got: {findings:?}");
}
#[test]
fn alias_posture_parses_writable_flag() {
let alias = Alias {
flags: vec!["--writable".to_string()],
..Alias::default()
};
let posture = alias_posture(&alias).expect("--writable parses");
assert!(posture.writable);
assert!(!posture.full_auto);
}
#[test]
fn alias_posture_is_none_for_conflicting_flags() {
let alias = Alias {
flags: vec!["--readonly".to_string(), "--full-auto".to_string()],
..Alias::default()
};
assert!(alias_posture(&alias).is_none());
}
#[test]
fn profile_posture_maps_typed_fields() {
let p = Profile {
writable: Some(true),
allow_tool: vec!["Bash(git:*)".to_string()],
..Profile::default()
};
let posture = profile_posture(&p);
assert!(posture.writable);
assert!(!posture.full_auto);
assert_eq!(posture.allow_tool, vec!["Bash(git:*)".to_string()]);
}
}