mod cli;
use std::fs::File;
use std::io::{self, BufWriter, Write};
use std::path::PathBuf;
use std::process::ExitCode;
use anyhow::{Context, Result};
use clap::Parser;
use colored::Colorize;
use tracing_subscriber::EnvFilter;
use codelens_core::config::Config;
use codelens_core::git::{self, GitClient};
use codelens_core::insight::scoring::default::DefaultModel;
use codelens_core::insight::{health, hotspot, trend};
use codelens_core::output::{create_output, OutputOptions, Report};
use codelens_core::{analyze, LanguageRegistry};
use crate::cli::{Cli, OutputFormatArg, SortByArg};
fn main() -> ExitCode {
match run() {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("{}: {e:#}", "error".red().bold());
ExitCode::FAILURE
}
}
}
fn run() -> Result<()> {
let cli = Cli::parse();
init_tracing(cli.output.verbose);
if cli.advanced.list_languages {
return list_languages();
}
if let Some(ref command) = cli.command {
return match command {
cli::Command::Health(args) => run_health(args),
cli::Command::Hotspot(args) => run_hotspot(args),
cli::Command::Trend(args) => run_trend(args),
};
}
let config = build_config(&cli)?;
let paths: Vec<PathBuf> = if cli.paths.is_empty() {
vec![PathBuf::from(".")]
} else {
cli.paths.clone()
};
let result = analyze(&paths, &config).context("Analysis failed")?;
let output_options = OutputOptions {
summary_only: cli.output.summary,
sort_by: cli.output.sort.into(),
top_n: cli.output.top,
colorize: !cli.output.quiet,
show_git_info: cli.advanced.git_info,
};
let formatter = create_output(cli.output.format.into());
if cli.output.quiet {
return Ok(());
}
let report = Report::Analysis(result);
if let Some(ref path) = cli.output.output_file {
let file = File::create(path).context("Failed to create output file")?;
let mut writer = BufWriter::new(file);
formatter.write(&report, &output_options, &mut writer)?;
writer.flush()?;
println!("Output written to: {}", path.display().to_string().green());
} else {
let stdout = io::stdout();
let mut writer = stdout.lock();
formatter.write(&report, &output_options, &mut writer)?;
}
Ok(())
}
fn init_tracing(verbose: bool) {
let filter = if verbose {
EnvFilter::new("debug")
} else {
EnvFilter::new("warn")
};
tracing_subscriber::fmt()
.with_env_filter(filter)
.with_target(false)
.without_time()
.init();
}
fn list_languages() -> Result<()> {
let registry = LanguageRegistry::with_builtin()?;
println!("{}", "Supported Languages".bold());
println!("{}", "─".repeat(40));
let mut languages: Vec<_> = registry.all().collect();
languages.sort_by(|a, b| a.name.cmp(&b.name));
let count = languages.len();
for lang in languages {
let exts = lang.extensions.join(", ");
println!(" {} {}", lang.name.cyan(), format!("({})", exts).dimmed());
}
println!();
println!("Total: {} languages", count.to_string().green());
Ok(())
}
fn build_config(cli: &Cli) -> Result<Config> {
use codelens_core::config::{FilterConfig, OutputConfig};
use codelens_core::walker::WalkerConfig;
let base_config = if !cli.advanced.no_config {
if let Some(ref path) = cli.advanced.config {
Some(codelens_core::config::load_config_file(path)?)
} else {
let default_paths = [".codelens.toml", ".code_stats.yaml", ".code_stats.yml"];
default_paths.iter().find_map(|p| {
let path = PathBuf::from(p);
if path.exists() {
codelens_core::config::load_config_file(&path).ok()
} else {
None
}
})
}
} else {
None
};
let mut config = base_config.unwrap_or_default();
config.walker = WalkerConfig {
threads: cli.advanced.threads.unwrap_or_else(num_cpus::get),
use_gitignore: !cli.filter.no_gitignore,
max_depth: cli.filter.depth,
..config.walker
};
config.filter = FilterConfig {
excludes: cli.filter.exclude.clone().unwrap_or_default(),
exclude_files: cli
.filter
.exclude_files
.as_ref()
.map(|s| vec![s.clone()])
.unwrap_or_default(),
include_files: cli
.filter
.include_files
.as_ref()
.map(|s| vec![s.clone()])
.unwrap_or_default(),
languages: cli.filter.lang.clone().unwrap_or_default(),
min_lines: cli.filter.min_lines,
max_lines: cli.filter.max_lines,
smart_exclude: !cli.filter.no_smart_exclude,
include_all: cli.filter.all,
..config.filter
};
config.output = OutputConfig {
format: cli.output.format.into(),
file: cli.output.output_file.clone(),
summary_only: cli.output.summary,
sort_by: cli.output.sort.into(),
top_n: cli.output.top,
verbose: cli.output.verbose,
quiet: cli.output.quiet,
show_git_info: cli.advanced.git_info,
};
Ok(config)
}
fn run_health(args: &cli::HealthArgs) -> Result<()> {
let config = build_config_from_args(&args.filter, &args.output)?;
let result = analyze(&args.paths, &config).context("Analysis failed")?;
let model = DefaultModel::new();
let top_n = args.output.top.unwrap_or(10);
let report = health::score(&result, &model, top_n);
write_report(Report::Health(report), &args.output)
}
fn run_hotspot(args: &cli::HotspotArgs) -> Result<()> {
let config = build_config_from_args(&args.filter, &args.output)?;
let git_client = GitClient::detect(&args.paths[0]).context("Not a git repository")?;
let since = git::parse_since(&args.since);
let result = analyze(&args.paths, &config).context("Analysis failed")?;
let churns = git_client.file_churn(&since)?;
let total_commits = git_client.commit_count(&since)?;
let top_n = args.output.top.unwrap_or(20);
let report = hotspot::analyze(&churns, &result, &args.since, total_commits, top_n);
write_report(Report::Hotspot(report), &args.output)
}
fn run_trend(args: &cli::TrendArgs) -> Result<()> {
let project_root = args
.paths
.first()
.cloned()
.unwrap_or_else(|| PathBuf::from("."));
if args.list {
let metas = trend::list_snapshots(&project_root)?;
if metas.is_empty() {
println!("No snapshots found. Use --save to create one.");
return Ok(());
}
for meta in &metas {
let label = meta.label.as_deref().unwrap_or("");
let commit = meta.git_commit.as_deref().unwrap_or("");
println!(
" {} {} {}",
meta.timestamp.format("%Y-%m-%d %H:%M:%S"),
label,
commit
);
}
println!("\nTotal: {} snapshots", metas.len());
return Ok(());
}
if args.save {
let config = Config::default();
let result = analyze(&[&project_root], &config).context("Analysis failed")?;
let (git_commit, git_branch) = GitClient::detect(&project_root)
.and_then(|c| c.repo_info())
.map(|info| (info.commit, info.branch))
.unwrap_or((None, None));
let path = trend::save_snapshot(
&project_root,
result,
args.label.clone(),
git_commit,
git_branch,
)?;
println!("Snapshot saved to: {}", path.display().to_string().green());
return Ok(());
}
let (from_ref, to_ref) = if let Some(ref refs) = args.compare {
(refs[0].as_str(), refs[1].as_str())
} else {
("latest~1", "latest")
};
let report = trend::diff(&project_root, from_ref, to_ref)?;
write_report(Report::Trend(report), &args.output)
}
fn write_report(report: Report, output_args: &cli::OutputArgs) -> Result<()> {
let output_options = OutputOptions {
summary_only: output_args.summary,
sort_by: output_args.sort.into(),
top_n: output_args.top,
colorize: !output_args.quiet,
show_git_info: false,
};
let formatter = create_output(output_args.format.into());
if output_args.quiet {
return Ok(());
}
if let Some(ref path) = output_args.output_file {
let file = File::create(path).context("Failed to create output file")?;
let mut writer = BufWriter::new(file);
formatter.write(&report, &output_options, &mut writer)?;
writer.flush()?;
println!("Output written to: {}", path.display().to_string().green());
} else {
let stdout = io::stdout();
let mut writer = stdout.lock();
formatter.write(&report, &output_options, &mut writer)?;
}
Ok(())
}
fn build_config_from_args(filter: &cli::FilterArgs, _output: &cli::OutputArgs) -> Result<Config> {
use codelens_core::config::FilterConfig;
use codelens_core::walker::WalkerConfig;
Ok(Config {
walker: WalkerConfig {
threads: num_cpus::get(),
use_gitignore: !filter.no_gitignore,
max_depth: filter.depth,
..WalkerConfig::default()
},
filter: FilterConfig {
excludes: filter.exclude.clone().unwrap_or_default(),
languages: filter.lang.clone().unwrap_or_default(),
smart_exclude: !filter.no_smart_exclude,
include_all: filter.all,
..FilterConfig::default()
},
..Config::default()
})
}
impl From<OutputFormatArg> for codelens_core::config::OutputFormatType {
fn from(arg: OutputFormatArg) -> Self {
match arg {
OutputFormatArg::Console => Self::Console,
OutputFormatArg::Json => Self::Json,
OutputFormatArg::Csv => Self::Csv,
OutputFormatArg::Markdown => Self::Markdown,
OutputFormatArg::Html => Self::Html,
}
}
}
impl From<SortByArg> for codelens_core::config::SortBy {
fn from(arg: SortByArg) -> Self {
match arg {
SortByArg::Lines => Self::Lines,
SortByArg::Files => Self::Files,
SortByArg::Code => Self::Code,
SortByArg::Name => Self::Name,
SortByArg::Size => Self::Size,
}
}
}