use anyhow::{anyhow, bail, Context, Result};
use colored::Colorize;
use regex::Regex;
use serde::Serialize;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use crate::config::{DeclaredVsWiredProfile, StrayMarkConfig};
use crate::utils;
pub struct Args {
pub path: String,
pub profile: Option<String>,
pub declared_glob: Option<String>,
pub wired_glob: Option<String>,
pub declared_pattern: Option<String>,
pub wired_pattern: Option<String>,
pub show_orphans: bool,
pub output: String,
}
#[derive(Serialize)]
struct Finding {
symbol: String,
source: String,
}
#[derive(Serialize)]
struct Report {
path: String,
profile: String,
declared_count: usize,
wired_count: usize,
declared_not_wired: Vec<Finding>,
#[serde(skip_serializing_if = "Option::is_none")]
wired_not_declared: Option<Vec<Finding>>,
}
pub fn run(args: Args) -> Result<()> {
let base = PathBuf::from(&args.path)
.canonicalize()
.unwrap_or_else(|_| PathBuf::from(&args.path));
let (profile_name, profile) = resolve_profile(&args, &base)?;
let declared_re = compile(&profile.declared_pattern, "declared")?;
let wired_re = compile(&profile.wired_pattern, "wired")?;
let declared = collect_symbols(&base, &profile.declared_glob, &declared_re)
.with_context(|| format!("scanning declared glob `{}`", profile.declared_glob))?;
let wired = collect_symbols(&base, &profile.wired_glob, &wired_re)
.with_context(|| format!("scanning wired glob `{}`", profile.wired_glob))?;
let declared_not_wired: Vec<Finding> = declared
.iter()
.filter(|(sym, _)| !wired.contains_key(*sym))
.map(|(sym, src)| Finding { symbol: sym.clone(), source: src.clone() })
.collect();
let wired_not_declared = if args.show_orphans {
Some(
wired
.iter()
.filter(|(sym, _)| !declared.contains_key(*sym))
.map(|(sym, src)| Finding { symbol: sym.clone(), source: src.clone() })
.collect::<Vec<_>>(),
)
} else {
None
};
let report = Report {
path: base.display().to_string(),
profile: profile_name,
declared_count: declared.len(),
wired_count: wired.len(),
declared_not_wired,
wired_not_declared,
};
match args.output.as_str() {
"json" => print_json(&report),
"markdown" => print_markdown(&report),
_ => print_text(&report),
}
if !report.declared_not_wired.is_empty() {
std::process::exit(1);
}
Ok(())
}
fn resolve_profile(args: &Args, base: &Path) -> Result<(String, DeclaredVsWiredProfile)> {
if let Some(name) = &args.profile {
let project_root = utils::resolve_project_root(&args.path)
.map(|r| r.path)
.unwrap_or_else(|| base.to_path_buf());
let config = StrayMarkConfig::load(&project_root)
.context("loading .straymark/config.yml for --profile lookup")?;
let profile = config
.declared_vs_wired
.profiles
.into_iter()
.find(|p| p.name == *name)
.ok_or_else(|| {
anyhow!(
"profile '{}' not found in .straymark/config.yml under `declared_vs_wired.profiles`.\n \
hint: define it there, or pass --declared-glob/--wired-glob/--declared-pattern/--wired-pattern inline.",
name
)
})?;
return Ok((name.clone(), profile));
}
match (
&args.declared_glob,
&args.wired_glob,
&args.declared_pattern,
&args.wired_pattern,
) {
(Some(dg), Some(wg), Some(dp), Some(wp)) => Ok((
"inline".to_string(),
DeclaredVsWiredProfile {
name: "inline".to_string(),
declared_glob: dg.clone(),
declared_pattern: dp.clone(),
wired_glob: wg.clone(),
wired_pattern: wp.clone(),
},
)),
_ => bail!(
"declared-vs-wired needs either --profile <name> (from .straymark/config.yml) \
or all four of --declared-glob, --wired-glob, --declared-pattern, --wired-pattern."
),
}
}
fn compile(pattern: &str, side: &str) -> Result<Regex> {
let re = Regex::new(pattern)
.with_context(|| format!("invalid {side} regex: `{pattern}`"))?;
if re.captures_len() < 2 {
bail!(
"the {side} pattern `{}` has no capture group — wrap the symbol name in parentheses, \
e.g. `fn (\\w+)`.",
pattern
);
}
Ok(re)
}
fn collect_symbols(base: &Path, glob_pat: &str, re: &Regex) -> Result<BTreeMap<String, String>> {
let pattern = format!("{}/{}", base.display(), glob_pat);
let mut out: BTreeMap<String, String> = BTreeMap::new();
let entries = glob::glob(&pattern)
.with_context(|| format!("invalid glob pattern: `{glob_pat}`"))?;
for entry in entries {
let path = match entry {
Ok(p) => p,
Err(_) => continue,
};
if !path.is_file() {
continue;
}
let content = match std::fs::read_to_string(&path) {
Ok(c) => c,
Err(_) => continue, };
let rel = path
.strip_prefix(base)
.unwrap_or(&path)
.display()
.to_string();
for caps in re.captures_iter(&content) {
if let Some(m) = caps.get(1) {
out.entry(m.as_str().to_string()).or_insert_with(|| rel.clone());
}
}
}
Ok(out)
}
fn print_text(report: &Report) {
println!();
println!(" {}", "StrayMark Analyze — declared-vs-wired".bold().cyan());
println!(" {}", report.path.dimmed());
println!(
" {} {} {} {} declared / {} wired",
"Profile:".dimmed(),
report.profile.bold(),
"Symbols:".dimmed(),
report.declared_count.to_string().bold(),
report.wired_count.to_string().bold(),
);
println!();
if report.declared_not_wired.is_empty() {
println!(
" {} Every declared symbol has a wiring counterpart.",
"✓".green().bold()
);
} else {
println!(
" {} {} declared symbol(s) with NO wiring counterpart:",
"✗".red().bold(),
report.declared_not_wired.len().to_string().red().bold(),
);
for f in &report.declared_not_wired {
println!(
" {} {} {}",
"-".red(),
f.symbol.bold(),
format!("({})", f.source).dimmed()
);
}
println!();
println!(
" {} a declared surface with no implementation is the \"surface declaration",
"→".yellow().bold()
);
println!(" without wiring\" anti-pattern (POLISH-CHARTER-PATTERN.md sub-class 5).");
}
if let Some(orphans) = &report.wired_not_declared {
println!();
if orphans.is_empty() {
println!(
" {} No wired-but-undeclared symbols.",
"·".dimmed()
);
} else {
println!(
" {} {} wired symbol(s) no declaration references (often benign):",
"·".dimmed(),
orphans.len()
);
for f in orphans {
println!(" {} {} {}", "-".dimmed(), f.symbol, format!("({})", f.source).dimmed());
}
}
}
println!();
}
fn print_json(report: &Report) {
let json = serde_json::to_string_pretty(report).unwrap_or_else(|_| "{}".into());
println!("{}", json);
}
fn print_markdown(report: &Report) {
println!("# StrayMark Analyze — declared-vs-wired");
println!();
println!("**Path:** `{}`", report.path);
println!("**Profile:** `{}`", report.profile);
println!(
"**Symbols:** {} declared / {} wired",
report.declared_count, report.wired_count
);
println!();
println!("## Declared but not wired");
println!();
if report.declared_not_wired.is_empty() {
println!("_None — every declared symbol has a wiring counterpart._");
} else {
println!("| Symbol | Declared in |");
println!("|--------|-------------|");
for f in &report.declared_not_wired {
println!("| `{}` | `{}` |", f.symbol, f.source);
}
}
if let Some(orphans) = &report.wired_not_declared {
println!();
println!("## Wired but not declared");
println!();
if orphans.is_empty() {
println!("_None._");
} else {
println!("| Symbol | Wired in |");
println!("|--------|----------|");
for f in orphans {
println!("| `{}` | `{}` |", f.symbol, f.source);
}
}
}
println!();
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn compile_rejects_pattern_without_capture_group() {
assert!(compile(r"fn \w+", "declared").is_err());
assert!(compile(r"fn (\w+)", "declared").is_ok());
}
#[test]
fn collect_symbols_extracts_capture_group_1() {
let tmp = tempfile::TempDir::new().unwrap();
std::fs::write(tmp.path().join("a.rs"), "fn foo() {}\nfn bar() {}\n").unwrap();
let re = Regex::new(r"fn (\w+)").unwrap();
let syms = collect_symbols(tmp.path(), "*.rs", &re).unwrap();
assert!(syms.contains_key("foo"));
assert!(syms.contains_key("bar"));
assert_eq!(syms["foo"], "a.rs");
}
#[test]
fn collect_symbols_handles_nested_glob() {
let tmp = tempfile::TempDir::new().unwrap();
std::fs::create_dir_all(tmp.path().join("client/src")).unwrap();
std::fs::write(tmp.path().join("client/src/proxy.rs"), "fn complete_auth() {}\n").unwrap();
let re = Regex::new(r"fn (\w+)").unwrap();
let syms = collect_symbols(tmp.path(), "client/**/*.rs", &re).unwrap();
assert!(syms.contains_key("complete_auth"));
}
#[test]
fn resolve_profile_requires_all_four_inline_flags() {
let base = PathBuf::from(".");
let args = Args {
path: ".".into(),
profile: None,
declared_glob: Some("a/*.rs".into()),
wired_glob: None, declared_pattern: Some(r"fn (\w+)".into()),
wired_pattern: Some(r"fn (\w+)".into()),
show_orphans: false,
output: "text".into(),
};
assert!(resolve_profile(&args, &base).is_err());
}
}