use std::path::{Path, PathBuf};
use std::process;
mod analyze;
mod deps;
mod diff;
mod hotspot;
mod plugin;
mod tangled;
mod trend;
use cha_core::{Config, Finding, PluginRegistry, SourceFile};
use clap::{CommandFactory, Parser, ValueEnum};
use clap_complete::engine::ArgValueCandidates;
#[derive(Clone, ValueEnum)]
enum Format {
Terminal,
Json,
Llm,
Sarif,
Html,
}
#[derive(Clone, ValueEnum, PartialEq, Eq, PartialOrd, Ord)]
enum FailLevel {
Hint,
Warning,
Error,
}
#[derive(Clone, ValueEnum)]
pub(crate) enum DepsFormat {
Dot,
Json,
Mermaid,
}
#[derive(Clone, ValueEnum)]
pub(crate) enum DepsDepth {
File,
Dir,
}
#[derive(Clone, ValueEnum, Default)]
pub(crate) enum DepsType {
#[default]
Imports,
Classes,
Calls,
}
#[derive(Parser)]
#[command(
name = "cha",
version,
about = "察 — Code quality & architecture analysis engine"
)]
enum Cli {
Analyze {
paths: Vec<String>,
#[arg(long, default_value = "terminal")]
format: Format,
#[arg(long)]
fail_on: Option<FailLevel>,
#[arg(long)]
diff: bool,
#[arg(long)]
stdin_diff: bool,
#[arg(long, value_delimiter = ',', add = ArgValueCandidates::new(plugin_candidates))]
plugin: Vec<String>,
#[arg(long)]
no_cache: bool,
#[arg(long)]
baseline: Option<String>,
#[arg(long, short)]
output: Option<String>,
#[arg(long)]
strictness: Option<String>,
},
Baseline {
paths: Vec<String>,
#[arg(long, short)]
output: Option<String>,
},
Parse {
paths: Vec<String>,
},
Init,
Schema,
Fix {
paths: Vec<String>,
#[arg(long)]
diff: bool,
#[arg(long)]
dry_run: bool,
},
Plugin {
#[command(subcommand)]
cmd: PluginCmd,
},
Deps {
paths: Vec<String>,
#[arg(long, default_value = "dot")]
format: DepsFormat,
#[arg(long, default_value = "file")]
depth: DepsDepth,
#[arg(long, default_value = "imports")]
r#type: DepsType,
#[arg(long)]
filter: Option<String>,
#[arg(long)]
exact: bool,
#[arg(long)]
detail: bool,
},
Trend {
#[arg(short, long, default_value = "10")]
count: usize,
#[arg(long, default_value = "terminal")]
format: Format,
},
Hotspot {
#[arg(short, long, default_value = "100")]
count: usize,
#[arg(short, long, default_value = "20")]
top: usize,
#[arg(long, default_value = "terminal")]
format: Format,
},
Completions {
shell: Option<clap_complete::Shell>,
},
}
#[derive(clap::Subcommand)]
enum PluginCmd {
New {
name: String,
},
Build,
List,
Install {
path: String,
},
Remove {
name: String,
},
}
impl DiffMode {
fn from_flags(diff: bool, stdin_diff: bool) -> Self {
if stdin_diff {
Self::Stdin
} else if diff {
Self::Git
} else {
Self::None
}
}
}
fn main() {
clap_complete::CompleteEnv::with_factory(Cli::command).complete();
let cli = Cli::parse();
let code = dispatch(cli);
if code != 0 {
process::exit(code);
}
}
fn dispatch(cli: Cli) -> i32 {
match cli {
Cli::Analyze {
paths,
format,
fail_on,
diff,
stdin_diff,
plugin,
no_cache,
baseline,
output,
strictness,
} => {
let mode = DiffMode::from_flags(diff, stdin_diff);
if no_cache {
let cwd = std::env::current_dir().unwrap_or_default();
let _ = std::fs::remove_dir_all(cwd.join(".cha/cache"));
}
analyze::cmd_analyze(&analyze::AnalyzeOpts {
paths: &paths,
format: &format,
fail_on: fail_on.as_ref(),
diff_mode: mode,
plugin_filter: &plugin,
baseline_path: baseline.as_deref(),
output_path: output.as_deref(),
strictness: strictness.as_deref(),
})
}
other => {
run_other(other);
0
}
}
}
fn run_other(cli: Cli) {
match cli {
Cli::Baseline { paths, output } => cmd_baseline(&paths, output.as_deref()),
Cli::Parse { paths } => cmd_parse(&paths),
Cli::Init | Cli::Schema => cmd_init_or_schema(cli),
Cli::Fix {
paths,
diff,
dry_run,
} => cmd_fix(&paths, diff, dry_run),
Cli::Plugin { cmd } => cmd_plugin(cmd),
Cli::Trend { count, format } => trend::cmd_trend(count, &format),
Cli::Hotspot { count, top, format } => hotspot::cmd_hotspot(count, top, &format),
Cli::Deps {
paths,
format,
depth,
r#type,
filter,
exact,
detail,
} => deps::cmd_deps(
&paths,
&format,
&depth,
&r#type,
filter.as_deref(),
exact,
detail,
),
Cli::Completions { shell } => cmd_completions(shell),
_ => unreachable!(),
}
}
fn cmd_completions(shell: Option<clap_complete::Shell>) {
let Some(shell) = shell else {
println!("Enable shell completions for cha (with dynamic plugin name support):\n");
println!(" bash: eval \"$(cha completions bash)\"");
println!(" zsh: eval \"$(cha completions zsh)\"");
println!(" fish: cha completions fish | source");
println!("\nTo make it permanent, add the line to your shell config file:");
println!(" bash: ~/.bashrc");
println!(" zsh: ~/.zshrc");
println!(" fish: ~/.config/fish/config.fish");
return;
};
let shell_name = match shell {
clap_complete::Shell::Bash => "bash",
clap_complete::Shell::Zsh => "zsh",
clap_complete::Shell::Fish => "fish",
clap_complete::Shell::PowerShell => "powershell",
clap_complete::Shell::Elvish => "elvish",
_ => {
eprintln!("Unsupported shell for dynamic completions, falling back to static");
let mut cmd = Cli::command();
clap_complete::generate(shell, &mut cmd, "cha", &mut std::io::stdout());
return;
}
};
let exe = std::env::current_exe().unwrap_or_else(|_| PathBuf::from("cha"));
let output = std::process::Command::new(&exe)
.env("COMPLETE", shell_name)
.output();
if let Ok(o) = output {
use std::io::Write;
std::io::stdout().write_all(&o.stdout).ok();
}
}
fn cmd_init_or_schema(cli: Cli) {
match cli {
Cli::Init => cmd_init(),
Cli::Schema => println!("{}", cha_core::findings_json_schema()),
_ => unreachable!(),
}
}
fn cmd_plugin(cmd: PluginCmd) {
match cmd {
PluginCmd::New { name } => plugin::cmd_new(&name),
PluginCmd::Build => plugin::cmd_build(),
PluginCmd::List => plugin::cmd_list(),
PluginCmd::Install { path } => plugin::cmd_install(&path),
PluginCmd::Remove { name } => plugin::cmd_remove(&name),
}
}
pub(crate) fn new_progress_bar(len: u64) -> indicatif::ProgressBar {
let pb = indicatif::ProgressBar::new(len);
pb.set_style(
indicatif::ProgressStyle::default_bar()
.template("{spinner:.green} [{bar:40.cyan/blue}] {pos}/{len}")
.unwrap()
.tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏", "✓"])
.progress_chars("█▓░"),
);
pb.enable_steady_tick(std::time::Duration::from_millis(80));
pb
}
pub(crate) fn collect_files(paths: &[String]) -> Vec<PathBuf> {
let targets: Vec<&str> = if paths.is_empty() {
vec!["."]
} else {
paths.iter().map(|s| s.as_str()).collect()
};
let mut files = Vec::new();
for target in targets {
let path = PathBuf::from(target);
if path.is_file() {
files.push(path);
} else {
walk_directory(&path, &mut files);
}
}
files
}
fn walk_directory(root: &Path, files: &mut Vec<PathBuf>) {
let walker = ignore::WalkBuilder::new(root)
.hidden(true)
.git_ignore(true)
.git_global(true)
.filter_entry(|e| {
let name = e.file_name().to_string_lossy();
!matches!(name.as_ref(), "target" | "node_modules" | "dist" | "build")
})
.build();
for entry in walker.flatten() {
if entry.file_type().is_some_and(|ft| ft.is_file()) {
files.push(entry.into_path());
}
}
}
pub(crate) fn git_diff_files() -> Vec<PathBuf> {
let output = std::process::Command::new("git")
.args(["diff", "--name-only", "--diff-filter=ACMR", "HEAD"])
.output();
match output {
Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout)
.lines()
.map(PathBuf::from)
.collect(),
_ => {
eprintln!("warning: git diff failed, analyzing all files");
vec![]
}
}
}
enum DiffMode {
None,
Git,
Stdin,
}
fn cmd_baseline(paths: &[String], output: Option<&str>) {
let cwd = std::env::current_dir().unwrap_or_default();
let root_config = Config::load(&cwd);
let files = analyze::filter_excluded(collect_files(paths), &root_config.exclude, &cwd);
let findings = analyze::run_analysis(&files, &cwd, &[]);
let baseline = cha_core::Baseline::from_findings(&findings, &cwd);
let out = Path::new(output.unwrap_or(".cha/baseline.json"));
match baseline.save(out) {
Ok(()) => println!(
"Baseline saved to {} ({} findings)",
out.display(),
baseline.fingerprints.len()
),
Err(e) => eprintln!("Failed to save baseline: {e}"),
}
}
fn cmd_parse(paths: &[String]) {
let files = collect_files(paths);
for path in &files {
let content = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(e) => {
eprintln!("error reading {}: {}", path.display(), e);
continue;
}
};
let file = SourceFile::new(path.clone(), content);
if let Some(model) = cha_parser::parse_file(&file) {
print_model(&path.to_string_lossy(), &model);
}
}
}
fn print_model(path: &str, model: &cha_parser::SourceModel) {
println!("=== {} ({}) ===", path, model.language);
println!(" lines: {}", model.total_lines);
println!(" functions: {}", model.functions.len());
for f in &model.functions {
println!(
" - {} (L{}-L{}, {} lines, complexity {})",
f.name, f.start_line, f.end_line, f.line_count, f.complexity
);
}
println!(" classes: {}", model.classes.len());
for c in &model.classes {
println!(
" - {} (L{}-L{}, {} methods, {} lines)",
c.name, c.start_line, c.end_line, c.method_count, c.line_count
);
}
println!(" imports: {}", model.imports.len());
for i in &model.imports {
println!(" - {} (L{})", i.source, i.line);
}
}
fn cmd_init() {
let path = Path::new(".cha.toml");
if path.exists() {
eprintln!(".cha.toml already exists");
process::exit(1);
}
std::fs::write(path, include_str!("../../static/default.cha.toml"))
.expect("failed to write .cha.toml");
println!("Created .cha.toml");
}
fn cmd_fix(paths: &[String], diff: bool, dry_run: bool) {
let files = analyze::resolve_files(paths, diff);
if files.is_empty() {
println!("No files to fix.");
return;
}
let project_root = std::env::current_dir().unwrap_or_default();
let filter = vec!["naming".to_string()];
let findings = analyze::run_analysis(&files, &project_root, &filter);
let fixable: Vec<&Finding> = findings
.iter()
.filter(|f| f.smell_name == "naming_convention")
.collect();
if fixable.is_empty() {
println!("Nothing to fix.");
return;
}
let fixed: usize = fixable.iter().filter_map(|f| apply_fix(f, dry_run)).count();
let label = if dry_run {
"would be applied"
} else {
"applied"
};
println!("{fixed} fix(es) {label}.");
}
fn apply_fix(finding: &Finding, dry_run: bool) -> Option<()> {
let name = finding.location.name.as_ref()?;
let new_name = to_pascal_case(name);
if new_name == *name {
return None;
}
let path = &finding.location.path;
let content = std::fs::read_to_string(path).ok()?;
let replaced = content.replace(name.as_str(), &new_name);
if replaced == content {
return None;
}
if dry_run {
println!(" {name} → {new_name} in {}", path.display());
} else {
std::fs::write(path, &replaced).ok()?;
println!(" Fixed: {name} → {new_name} in {}", path.display());
}
Some(())
}
fn to_pascal_case(name: &str) -> String {
let mut chars = name.chars();
match chars.next() {
None => String::new(),
Some(c) => c.to_uppercase().to_string() + chars.as_str(),
}
}
fn plugin_candidates() -> Vec<clap_complete::CompletionCandidate> {
let cwd = std::env::current_dir().unwrap_or_default();
let config = Config::load(&cwd);
let registry = PluginRegistry::from_config(&config, &cwd);
let mut candidates: Vec<_> = registry
.plugin_info()
.into_iter()
.map(|(name, desc)| {
let c = clap_complete::CompletionCandidate::new(name);
if desc.is_empty() {
c
} else {
c.help(Some(desc.into()))
}
})
.collect();
for &(name, desc) in analyze::POST_ANALYSIS_PASSES {
let c = clap_complete::CompletionCandidate::new(name);
candidates.push(if desc.is_empty() {
c
} else {
c.help(Some(desc.into()))
});
}
candidates
}