use anyhow::Result;
use colored::Colorize;
use serde::Serialize;
use serde_json::{Value, json};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use walkdir::WalkDir;
use crate::paths::{Env, ProjectPaths, managed_settings_paths};
use crate::secrets;
pub struct Options {
pub path: PathBuf,
pub show_secrets: bool,
pub json: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Scope {
User,
Project,
Local,
Managed,
}
impl Scope {
fn label(&self) -> &'static str {
match self {
Scope::User => "user",
Scope::Project => "project",
Scope::Local => "local",
Scope::Managed => "managed",
}
}
}
#[derive(Debug, Serialize)]
struct Contribution {
scope: Scope,
file: PathBuf,
value: Value,
shadowed: bool,
}
#[derive(Debug, Serialize)]
struct Resolved {
key: String,
effective: Value,
contributions: Vec<Contribution>,
}
pub fn run(env: &Env, opts: Options) -> Result<ExitCode> {
let root = opts.path.canonicalize().unwrap_or(opts.path.clone());
let project = ProjectPaths::new(&root);
let mut sources: Vec<(Scope, PathBuf, Value)> = Vec::new();
if let Some(v) = read_json(&env.user_settings()) {
sources.push((Scope::User, env.user_settings(), v));
}
if let Some(v) = read_json(&project.settings()) {
sources.push((Scope::Project, project.settings(), v));
}
if let Some(v) = read_json(&project.local_settings()) {
sources.push((Scope::Local, project.local_settings(), v));
}
for managed in managed_settings_paths() {
if managed.is_file() {
if let Some(v) = read_json(&managed) {
sources.push((Scope::Managed, managed, v));
}
} else if managed.is_dir() {
if let Ok(entries) = std::fs::read_dir(&managed) {
for entry in entries.flatten() {
let p = entry.path();
if p.extension().and_then(|e| e.to_str()) == Some("json")
&& let Some(v) = read_json(&p)
{
sources.push((Scope::Managed, p, v));
}
}
}
}
}
let hooks = collect_hooks(&sources);
let resolved: Vec<Resolved> = resolve_settings(&sources)
.into_iter()
.filter(|r| !r.key.starts_with("hooks."))
.collect();
let claude_mds = collect_claude_md(&project, env);
let contradictions = detect_contradictions(&claude_mds);
let skills = collect_dirs(&[env.user_skills_dir(), project.skills_dir()], "SKILL.md");
let commands = collect_files(&[env.user_commands_dir(), project.commands_dir()]);
let agents = collect_files(&[env.user_agents_dir(), project.agents_dir()]);
let mcp_servers = collect_mcp_servers(env, &project);
let worktrees = collect_worktrees(&project);
let mut resolved = resolved;
if !opts.show_secrets {
for r in &mut resolved {
if path_looks_sensitive(&r.key) {
r.effective = mask_value_in_place(r.effective.clone());
for c in &mut r.contributions {
c.value = mask_value_in_place(c.value.clone());
}
}
}
}
if opts.json {
emit_json(
&root,
&resolved,
&claude_mds,
&contradictions,
&skills,
&commands,
&agents,
&hooks,
&mcp_servers,
&worktrees,
);
} else {
emit_human(
&root,
&resolved,
&claude_mds,
&contradictions,
&skills,
&commands,
&agents,
&hooks,
&mcp_servers,
&worktrees,
);
}
Ok(ExitCode::SUCCESS)
}
fn read_json(path: &Path) -> Option<Value> {
let text = std::fs::read_to_string(path).ok()?;
serde_json::from_str(&text).ok()
}
fn resolve_settings(sources: &[(Scope, PathBuf, Value)]) -> Vec<Resolved> {
let mut by_path: BTreeMap<String, Vec<(Scope, PathBuf, Value)>> = BTreeMap::new();
for (scope, file, value) in sources {
let mut leaves = Vec::new();
flatten(value, String::new(), &mut leaves);
for (k, v) in leaves {
by_path
.entry(k)
.or_default()
.push((*scope, file.clone(), v));
}
}
let mut out = Vec::new();
for (key, mut contribs) in by_path {
contribs.sort_by_key(|(s, _, _)| *s);
let all_arrays = contribs.iter().all(|(_, _, v)| v.is_array());
let (effective, contributions) = if all_arrays {
let mut merged: Vec<Value> = Vec::new();
for (_, _, v) in &contribs {
if let Value::Array(arr) = v {
for item in arr {
if !merged.iter().any(|m| m == item) {
merged.push(item.clone());
}
}
}
}
let contributions = contribs
.iter()
.map(|(s, f, v)| Contribution {
scope: *s,
file: f.clone(),
value: v.clone(),
shadowed: false,
})
.collect();
(Value::Array(merged), contributions)
} else {
let winner_idx = contribs.len() - 1;
let effective = contribs[winner_idx].2.clone();
let contributions = contribs
.iter()
.enumerate()
.map(|(i, (s, f, v))| Contribution {
scope: *s,
file: f.clone(),
value: v.clone(),
shadowed: i != winner_idx,
})
.collect();
(effective, contributions)
};
out.push(Resolved {
key,
effective,
contributions,
});
}
out
}
fn flatten(value: &Value, prefix: String, out: &mut Vec<(String, Value)>) {
match value {
Value::Object(map) => {
for (k, v) in map {
let new = if prefix.is_empty() {
k.clone()
} else {
format!("{prefix}.{k}")
};
flatten(v, new, out);
}
}
_ => out.push((prefix, value.clone())),
}
}
fn path_looks_sensitive(dotted: &str) -> bool {
dotted
.rsplit('.')
.next()
.is_some_and(secrets::key_looks_sensitive)
|| secrets::key_looks_sensitive(dotted)
}
fn mask_value_in_place(mut v: Value) -> Value {
match &mut v {
Value::String(s) => {
*s = secrets::mask(s);
}
Value::Object(_) | Value::Array(_) => {
secrets::mask_value(&mut v);
}
_ => {}
}
v
}
#[derive(Debug, Serialize)]
struct ClaudeMd {
file: PathBuf,
scope: ClaudeMdScope,
bytes: u64,
}
#[derive(Debug, Serialize, Clone, Copy, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
enum ClaudeMdScope {
User,
Project,
Local,
Ancestor,
}
fn collect_claude_md(project: &ProjectPaths, env: &Env) -> Vec<ClaudeMd> {
let mut out = Vec::new();
let user = env.user_claude_md();
if let Ok(m) = std::fs::metadata(&user) {
out.push(ClaudeMd {
file: user,
scope: ClaudeMdScope::User,
bytes: m.len(),
});
}
let mut current = project.root.clone();
let original_root = current.clone();
loop {
for (name, scope) in [
(
"CLAUDE.md",
if current == original_root {
ClaudeMdScope::Project
} else {
ClaudeMdScope::Ancestor
},
),
("CLAUDE.local.md", ClaudeMdScope::Local),
] {
let p = current.join(name);
if let Ok(m) = std::fs::metadata(&p) {
out.push(ClaudeMd {
file: p,
scope,
bytes: m.len(),
});
}
}
match current.parent() {
Some(parent) if parent != current => current = parent.to_path_buf(),
_ => break,
}
}
let walker = WalkDir::new(&project.root)
.max_depth(4)
.into_iter()
.filter_entry(|e| !is_vendored_dir(e.path()));
for entry in walker.filter_map(|e| e.ok()) {
let p = entry.path();
if p.file_name().and_then(|n| n.to_str()) == Some("CLAUDE.md") && p != project.claude_md() {
if out.iter().any(|c| c.file == p) {
continue;
}
if let Ok(m) = entry.metadata() {
out.push(ClaudeMd {
file: p.to_path_buf(),
scope: ClaudeMdScope::Project,
bytes: m.len(),
});
}
}
}
out
}
const VENDORED_DIRS: &[&str] = &[
"node_modules",
"vendor",
"target",
".git",
".venv",
"venv",
"__pycache__",
];
fn is_vendored_dir(path: &Path) -> bool {
if !path.is_dir() {
return false;
}
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
return false;
};
VENDORED_DIRS.contains(&name)
}
#[derive(Debug, Serialize)]
struct Contradiction {
a_file: PathBuf,
b_file: PathBuf,
a_line: String,
b_line: String,
keyword: String,
}
type Directive = (Polarity, String, String);
fn detect_contradictions(files: &[ClaudeMd]) -> Vec<Contradiction> {
let mut lines_by_file: Vec<(PathBuf, Vec<Directive>)> = Vec::new();
for f in files {
let Ok(text) = std::fs::read_to_string(&f.file) else {
continue;
};
let mut entries = Vec::new();
for line in text.lines() {
if let Some((pol, kw, raw)) = parse_directive(line) {
entries.push((pol, kw, raw));
}
}
if !entries.is_empty() {
lines_by_file.push((f.file.clone(), entries));
}
}
let mut out = Vec::new();
for i in 0..lines_by_file.len() {
for j in (i + 1)..lines_by_file.len() {
for (a_pol, a_kw, a_raw) in &lines_by_file[i].1 {
for (b_pol, b_kw, b_raw) in &lines_by_file[j].1 {
if a_kw == b_kw && a_pol != b_pol {
out.push(Contradiction {
a_file: lines_by_file[i].0.clone(),
b_file: lines_by_file[j].0.clone(),
a_line: a_raw.clone(),
b_line: b_raw.clone(),
keyword: a_kw.clone(),
});
}
}
}
}
}
out
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Polarity {
Do,
Dont,
}
fn parse_directive(line: &str) -> Option<(Polarity, String, String)> {
let trimmed = line.trim_start_matches(['-', '*', '#', ' ', '\t']).trim();
if trimmed.is_empty() {
return None;
}
let lower = trimmed.to_ascii_lowercase();
let (polarity, rest) = if let Some(rest) = lower.strip_prefix("never ") {
(Polarity::Dont, rest)
} else if let Some(rest) = lower.strip_prefix("don't ") {
(Polarity::Dont, rest)
} else if let Some(rest) = lower.strip_prefix("do not ") {
(Polarity::Dont, rest)
} else if let Some(rest) = lower.strip_prefix("always ") {
(Polarity::Do, rest)
} else if let Some(rest) = lower.strip_prefix("must ") {
(Polarity::Do, rest)
} else {
return None;
};
let keyword: String = rest
.split_whitespace()
.filter(|w| !STOPWORDS.contains(w))
.take(2)
.collect::<Vec<_>>()
.join(" ");
if keyword.is_empty() {
return None;
}
Some((polarity, keyword, trimmed.to_string()))
}
const STOPWORDS: &[&str] = &[
"the", "a", "an", "to", "in", "on", "at", "of", "for", "and", "or", "but", "with", "this",
"that", "any", "all",
];
#[derive(Debug, Serialize)]
struct LocatedDir {
name: String,
file: PathBuf,
scope: &'static str,
}
fn collect_dirs(roots: &[PathBuf], required_file: &str) -> Vec<LocatedDir> {
let mut out = Vec::new();
for (i, root) in roots.iter().enumerate() {
let scope = if i == 0 { "user" } else { "project" };
if !root.is_dir() {
continue;
}
let Ok(entries) = std::fs::read_dir(root) else {
continue;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() && path.join(required_file).is_file() {
out.push(LocatedDir {
name: path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default(),
file: path.join(required_file),
scope,
});
}
}
}
out
}
#[derive(Debug, Serialize)]
struct LocatedFile {
name: String,
file: PathBuf,
scope: &'static str,
}
fn collect_files(roots: &[PathBuf]) -> Vec<LocatedFile> {
let mut out = Vec::new();
for (i, root) in roots.iter().enumerate() {
let scope = if i == 0 { "user" } else { "project" };
if !root.is_dir() {
continue;
}
let walker = WalkDir::new(root)
.max_depth(3)
.into_iter()
.filter_entry(|e| !is_vendored_dir(e.path()));
for entry in walker.filter_map(|e| e.ok()) {
let p = entry.path();
if p.is_file() && p.extension().and_then(|e| e.to_str()) == Some("md") {
out.push(LocatedFile {
name: p
.file_stem()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default(),
file: p.to_path_buf(),
scope,
});
}
}
}
out
}
#[derive(Debug, Serialize)]
struct Hook {
event: String,
#[serde(skip_serializing_if = "Option::is_none")]
matcher: Option<String>,
kind: String,
command: String,
scope: Scope,
file: PathBuf,
}
fn collect_hooks(sources: &[(Scope, PathBuf, Value)]) -> Vec<Hook> {
let mut out = Vec::new();
for (scope, file, value) in sources {
let Some(events) = value.get("hooks").and_then(Value::as_object) else {
continue;
};
for (event_name, groups) in events {
let Some(group_arr) = groups.as_array() else {
continue;
};
for group in group_arr {
let matcher = group
.get("matcher")
.and_then(Value::as_str)
.map(String::from)
.filter(|s| !s.is_empty());
let Some(entries) = group.get("hooks").and_then(Value::as_array) else {
continue;
};
for entry in entries {
let kind = entry
.get("type")
.and_then(Value::as_str)
.unwrap_or("command")
.to_string();
let command = entry
.get("command")
.and_then(Value::as_str)
.map(String::from)
.unwrap_or_default();
out.push(Hook {
event: event_name.clone(),
matcher: matcher.clone(),
kind,
command,
scope: *scope,
file: file.clone(),
});
}
}
}
}
out.sort_by(|a, b| {
a.event
.cmp(&b.event)
.then(a.scope.cmp(&b.scope))
.then(a.file.cmp(&b.file))
});
out
}
#[derive(Debug, Serialize)]
struct McpServer {
name: String,
scope: &'static str,
file: PathBuf,
command: Option<String>,
url: Option<String>,
disabled: bool,
}
fn collect_mcp_servers(env: &Env, project: &ProjectPaths) -> Vec<McpServer> {
let mut out = Vec::new();
let candidates: &[(&str, PathBuf)] = &[
("user", env.claude_json.clone()),
("project", project.mcp_json()),
("managed", project.managed_mcp_json()),
];
for (scope, path) in candidates {
let Some(v) = read_json(path) else { continue };
let Some(servers) = v.get("mcpServers").and_then(Value::as_object) else {
continue;
};
for (name, def) in servers {
out.push(McpServer {
name: name.clone(),
scope,
file: path.clone(),
command: def.get("command").and_then(Value::as_str).map(String::from),
url: def.get("url").and_then(Value::as_str).map(String::from),
disabled: def
.get("disabled")
.and_then(Value::as_bool)
.unwrap_or(false),
});
}
}
out
}
#[derive(Debug, Serialize)]
struct Worktree {
name: String,
file: PathBuf,
}
fn collect_worktrees(project: &ProjectPaths) -> Vec<Worktree> {
let dir = project.worktrees_dir();
if !dir.is_dir() {
return Vec::new();
}
let mut out = Vec::new();
if let Ok(entries) = std::fs::read_dir(&dir) {
for entry in entries.flatten() {
let p = entry.path();
if p.is_dir() {
out.push(Worktree {
name: p
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default(),
file: p,
});
}
}
}
out
}
#[allow(clippy::too_many_arguments)]
fn emit_human(
root: &Path,
resolved: &[Resolved],
claude_mds: &[ClaudeMd],
contradictions: &[Contradiction],
skills: &[LocatedDir],
commands: &[LocatedFile],
agents: &[LocatedFile],
hooks: &[Hook],
mcp_servers: &[McpServer],
worktrees: &[Worktree],
) {
println!("{} {}", "resolved for".bold(), root.display());
println!();
println!("{}", "settings".bold().underline());
if resolved.is_empty() {
println!(" (no settings found)");
} else {
for r in resolved {
let val_str = format_value(&r.effective);
println!(" {} = {}", r.key.cyan(), val_str);
for c in &r.contributions {
let tag = if c.shadowed {
format!("[{} shadowed]", c.scope.label())
.dimmed()
.strikethrough()
.to_string()
} else if c.value.is_array() {
format!("[{} merged]", c.scope.label()).green().to_string()
} else {
format!("[{}]", c.scope.label()).green().to_string()
};
println!(
" {tag} {} = {}",
c.file.display(),
format_value(&c.value).dimmed()
);
}
}
}
println!();
println!("{}", "CLAUDE.md".bold().underline());
if claude_mds.is_empty() {
println!(" (none)");
} else {
for c in claude_mds {
let scope = match c.scope {
ClaudeMdScope::User => "user",
ClaudeMdScope::Project => "project",
ClaudeMdScope::Local => "local",
ClaudeMdScope::Ancestor => "ancestor",
};
println!(" [{scope}] {} ({} bytes)", c.file.display(), c.bytes);
}
}
if !contradictions.is_empty() {
println!();
println!(" {}", "contradictions:".yellow().bold());
for c in contradictions {
println!(" keyword `{}`", c.keyword);
println!(" {} — {}", c.a_file.display(), c.a_line.dimmed());
println!(" {} — {}", c.b_file.display(), c.b_line.dimmed());
}
}
println!();
print_section(
"skills",
skills
.iter()
.map(|s| (s.name.as_str(), s.file.as_path(), s.scope)),
);
print_section(
"commands",
commands
.iter()
.map(|c| (c.name.as_str(), c.file.as_path(), c.scope)),
);
print_section(
"agents",
agents
.iter()
.map(|a| (a.name.as_str(), a.file.as_path(), a.scope)),
);
println!("{}", "hooks".bold().underline());
if hooks.is_empty() {
println!(" (none)");
} else {
let mut current_event = "";
for h in hooks {
if h.event != current_event {
println!(" {}", h.event.bold());
current_event = &h.event;
}
let matcher = h.matcher.as_deref().unwrap_or("*");
println!(
" [{}] {} ({}): {}",
h.scope.label(),
matcher.cyan(),
h.kind,
truncate_oneline(&h.command, 80)
);
println!(" {}", h.file.display().to_string().dimmed());
}
}
println!();
println!("{}", "mcp servers".bold().underline());
if mcp_servers.is_empty() {
println!(" (none)");
} else {
for s in mcp_servers {
let target = s
.command
.as_deref()
.or(s.url.as_deref())
.unwrap_or("<unreachable>");
let dis = if s.disabled {
" (disabled)".red().to_string()
} else {
String::new()
};
println!(" [{}] {} -> {target}{dis}", s.scope, s.name);
println!(" {}", s.file.display().to_string().dimmed());
}
}
println!();
println!("{}", "worktrees".bold().underline());
if worktrees.is_empty() {
println!(" (none)");
} else {
for w in worktrees {
println!(" {} — {}", w.name, w.file.display());
}
}
}
fn print_section<'a>(title: &str, iter: impl Iterator<Item = (&'a str, &'a Path, &'a str)>) {
println!("{}", title.bold().underline());
let mut empty = true;
for (name, file, scope) in iter {
empty = false;
println!(" [{scope}] {name}");
println!(" {}", file.display().to_string().dimmed());
}
if empty {
println!(" (none)");
}
println!();
}
fn format_value(v: &Value) -> String {
match v {
Value::Array(arr) if arr.len() <= 6 => {
let inner: Vec<String> = arr.iter().map(format_value).collect();
format!("[{}]", inner.join(", "))
}
Value::Array(arr) => format!("<array of {} items>", arr.len()),
_ => serde_json::to_string(v).unwrap_or_default(),
}
}
fn truncate_oneline(s: &str, max: usize) -> String {
let collapsed: String = s.split_whitespace().collect::<Vec<_>>().join(" ");
if collapsed.len() <= max {
collapsed
} else {
let mut t: String = collapsed.chars().take(max).collect();
t.push('…');
t
}
}
#[allow(clippy::too_many_arguments)]
fn emit_json(
root: &Path,
resolved: &[Resolved],
claude_mds: &[ClaudeMd],
contradictions: &[Contradiction],
skills: &[LocatedDir],
commands: &[LocatedFile],
agents: &[LocatedFile],
hooks: &[Hook],
mcp_servers: &[McpServer],
worktrees: &[Worktree],
) {
let v = json!({
"root": root,
"settings": resolved,
"claude_md": claude_mds,
"contradictions": contradictions,
"skills": skills,
"commands": commands,
"agents": agents,
"hooks": hooks,
"mcp_servers": mcp_servers,
"worktrees": worktrees,
});
println!("{}", serde_json::to_string_pretty(&v).expect("serialize"));
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn s(scope: Scope, file: &str, v: Value) -> (Scope, PathBuf, Value) {
(scope, PathBuf::from(file), v)
}
#[test]
fn scalar_higher_scope_wins_lower_shadowed() {
let sources = vec![
s(
Scope::User,
"u",
json!({ "permissions": { "defaultMode": "ask" } }),
),
s(
Scope::Project,
"p",
json!({ "permissions": { "defaultMode": "bypass" } }),
),
];
let r = resolve_settings(&sources);
let entry = r
.iter()
.find(|r| r.key == "permissions.defaultMode")
.unwrap();
assert_eq!(entry.effective, json!("bypass"));
let user = entry
.contributions
.iter()
.find(|c| c.scope == Scope::User)
.unwrap();
let project = entry
.contributions
.iter()
.find(|c| c.scope == Scope::Project)
.unwrap();
assert!(user.shadowed);
assert!(!project.shadowed);
}
#[test]
fn managed_wins_over_everything() {
let sources = vec![
s(
Scope::User,
"u",
json!({ "permissions": { "defaultMode": "ask" } }),
),
s(
Scope::Project,
"p",
json!({ "permissions": { "defaultMode": "bypass" } }),
),
s(
Scope::Local,
"l",
json!({ "permissions": { "defaultMode": "allow" } }),
),
s(
Scope::Managed,
"m",
json!({ "permissions": { "defaultMode": "deny" } }),
),
];
let r = resolve_settings(&sources);
let entry = r
.iter()
.find(|r| r.key == "permissions.defaultMode")
.unwrap();
assert_eq!(entry.effective, json!("deny"));
for c in &entry.contributions {
assert_eq!(c.shadowed, c.scope != Scope::Managed);
}
}
#[test]
fn arrays_concat_and_dedupe_across_scopes() {
let sources = vec![
s(
Scope::User,
"u",
json!({ "permissions": { "deny": ["Read(./.env)", "Bash(rm:*)"] } }),
),
s(
Scope::Project,
"p",
json!({ "permissions": { "deny": ["Read(./.env)", "Read(./secrets/**)"] } }),
),
];
let r = resolve_settings(&sources);
let entry = r.iter().find(|r| r.key == "permissions.deny").unwrap();
let eff = entry.effective.as_array().unwrap();
assert_eq!(eff.len(), 3, "deduped union");
assert!(eff.contains(&json!("Read(./.env)")));
assert!(eff.contains(&json!("Bash(rm:*)")));
assert!(eff.contains(&json!("Read(./secrets/**)")));
assert!(entry.contributions.iter().all(|c| !c.shadowed));
}
#[test]
fn parse_directive_detects_polarity() {
let (pol, kw, _) = parse_directive("- never commit secrets to git").unwrap();
assert_eq!(pol, Polarity::Dont);
assert!(kw.starts_with("commit"));
let (pol, _, _) = parse_directive("Always run cargo fmt").unwrap();
assert_eq!(pol, Polarity::Do);
assert!(parse_directive("This is a paragraph.").is_none());
}
#[test]
fn contradiction_detected_across_files() {
let dir = tempfile::tempdir().unwrap();
let a = dir.path().join("A.md");
let b = dir.path().join("B.md");
std::fs::write(&a, "- Always commit signed.\n").unwrap();
std::fs::write(&b, "- Never commit signed.\n").unwrap();
let files = vec![
ClaudeMd {
file: a.clone(),
scope: ClaudeMdScope::User,
bytes: 0,
},
ClaudeMd {
file: b.clone(),
scope: ClaudeMdScope::Project,
bytes: 0,
},
];
let c = detect_contradictions(&files);
assert_eq!(c.len(), 1);
assert!(c[0].keyword.starts_with("commit"));
}
#[test]
fn vendored_dirs_are_skipped() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::write(root.join("CLAUDE.md"), "ok").unwrap();
let vendor = root.join("node_modules").join("some-pkg");
std::fs::create_dir_all(&vendor).unwrap();
std::fs::write(vendor.join("CLAUDE.md"), "noise").unwrap();
let nested_vendor = root.join("apps").join("web").join("node_modules").join("x");
std::fs::create_dir_all(&nested_vendor).unwrap();
std::fs::write(nested_vendor.join("CLAUDE.md"), "noise").unwrap();
let nested_legit = root.join("apps").join("web");
std::fs::create_dir_all(&nested_legit).unwrap();
std::fs::write(nested_legit.join("CLAUDE.md"), "legit").unwrap();
let project = ProjectPaths::new(root);
let env = Env::new(
Some(root.join(".claude.json")),
Some(root.join(".claude-home")),
);
let mds = collect_claude_md(&project, &env);
let paths: Vec<_> = mds.iter().map(|m| m.file.clone()).collect();
assert!(
paths
.iter()
.any(|p| p.ends_with("CLAUDE.md") && !p.to_string_lossy().contains("node_modules")),
"expected project root CLAUDE.md, got: {paths:?}"
);
assert!(
paths.iter().any(|p| p == &nested_legit.join("CLAUDE.md")),
"expected nested legit CLAUDE.md, got: {paths:?}"
);
assert!(
!paths
.iter()
.any(|p| p.to_string_lossy().contains("node_modules")),
"node_modules CLAUDE.md must not appear: {paths:?}"
);
}
#[test]
fn collect_hooks_flattens_groups_and_tags_provenance() {
let sources = vec![
s(
Scope::User,
"u",
json!({
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "echo user-bash" }
]
}
]
}
}),
),
s(
Scope::Local,
"l",
json!({
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "block --no-verify" }
]
}
],
"Stop": [
{ "hooks": [ { "type": "command", "command": "say done" } ] }
]
}
}),
),
];
let hooks = collect_hooks(&sources);
assert_eq!(hooks.len(), 3);
assert_eq!(hooks[0].event, "PreToolUse");
assert_eq!(hooks[0].scope, Scope::User);
assert_eq!(hooks[0].command, "echo user-bash");
assert_eq!(hooks[0].matcher.as_deref(), Some("Bash"));
assert_eq!(hooks[1].event, "PreToolUse");
assert_eq!(hooks[1].scope, Scope::Local);
assert_eq!(hooks[1].command, "block --no-verify");
assert_eq!(hooks[2].event, "Stop");
assert_eq!(hooks[2].matcher, None, "no matcher in this group");
assert_eq!(hooks[2].command, "say done");
}
#[test]
fn truncate_oneline_collapses_whitespace_and_truncates() {
let s = "line one\n line two\tline three";
assert_eq!(truncate_oneline(s, 100), "line one line two line three");
let truncated = truncate_oneline(s, 10);
assert!(truncated.ends_with('…'), "{truncated}");
assert!(truncated.chars().count() <= 11);
}
#[test]
fn hooks_filtered_out_of_settings_section() {
let entries = vec![
Resolved {
key: "permissions.defaultMode".into(),
effective: json!("ask"),
contributions: vec![],
},
Resolved {
key: "hooks.PreToolUse".into(),
effective: json!([]),
contributions: vec![],
},
];
let filtered: Vec<_> = entries
.into_iter()
.filter(|r| !r.key.starts_with("hooks."))
.collect();
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].key, "permissions.defaultMode");
}
}