pub(crate) mod analyze;
mod benchmark;
mod claude_hook;
mod debt;
pub(crate) mod diff;
mod diff_hunks;
mod doctor;
mod embedded_scripts;
mod findings;
mod fix;
mod graph;
mod init;
mod status;
#[cfg(not(target_os = "windows"))]
mod tui;
pub mod watch;
pub mod worker;
use crate::detectors::{DEEP_ONLY_DETECTOR_FACTORIES, DEFAULT_DETECTOR_FACTORIES};
use crate::parsers::GRAPH_NATIVE_LANGUAGES;
use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use std::path::{Path, PathBuf};
use std::sync::LazyLock;
static ROOT_ABOUT: LazyLock<String> = LazyLock::new(|| {
format!(
"Graph-powered code health analysis — detect code smells, security issues, and architectural debt across {} graph-native languages",
GRAPH_NATIVE_LANGUAGES.len(),
)
});
static ROOT_LONG_ABOUT: LazyLock<String> = LazyLock::new(|| {
format!(
"Repotoire builds a knowledge graph of your codebase and runs {} pure Rust detectors ({} default + {} deep-scan) to find code smells, security vulnerabilities, and architectural issues that traditional linters miss.\n\n100% LOCAL by default — No account needed. No data leaves your machine unless you opt in.\n\nRun without a subcommand to analyze the current directory:\n repotoire .\n\nFull graph analysis (tree-sitter): {}\nSecurity/quality scanning: Ruby, PHP, Kotlin, Swift (regex-based detectors)",
DEFAULT_DETECTOR_FACTORIES.len() + DEEP_ONLY_DETECTOR_FACTORIES.len(),
DEFAULT_DETECTOR_FACTORIES.len(),
DEEP_ONLY_DETECTOR_FACTORIES.len(),
GRAPH_NATIVE_LANGUAGES.join(", "),
)
});
static ANALYZE_ABOUT: LazyLock<String> = LazyLock::new(|| {
format!(
"Analyze codebase for issues (runs {} default detectors, or all {} with --all-detectors)",
DEFAULT_DETECTOR_FACTORIES.len(),
DEFAULT_DETECTOR_FACTORIES.len() + DEEP_ONLY_DETECTOR_FACTORIES.len(),
)
});
#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
pub enum TelemetryAction {
On,
Off,
Status,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
pub enum LogLevel {
Error,
Warn,
Info,
Debug,
Trace,
}
impl LogLevel {
pub fn as_filter_str(self) -> &'static str {
match self {
LogLevel::Error => "error",
LogLevel::Warn => "warn",
LogLevel::Info => "info",
LogLevel::Debug => "debug",
LogLevel::Trace => "trace",
}
}
}
fn parse_workers(s: &str) -> Result<usize, String> {
let n: usize = s
.parse()
.map_err(|_| format!("'{}' is not a valid number", s))?;
if n == 0 {
Err("workers must be at least 1".to_string())
} else if n > 64 {
Err("workers cannot exceed 64".to_string())
} else {
Ok(n)
}
}
#[derive(Parser, Debug)]
#[command(name = "repotoire")]
#[command(
version,
about = ROOT_ABOUT.as_str(),
long_about = ROOT_LONG_ABOUT.as_str(),
after_help = "\
Examples:
repotoire . Analyze current directory
repotoire analyze . --format json JSON output for scripting
repotoire analyze . --fail-on high CI mode: exit 1 on high+ findings
repotoire findings --severity high Show only high+ findings
repotoire graph functions List all functions in the graph
Documentation: https://github.com/Zach-hammad/repotoire"
)]
pub struct Cli {
#[arg(global = true, default_value = ".")]
pub path: PathBuf,
#[arg(long, global = true, default_value = "warn")]
pub log_level: LogLevel,
#[arg(long, global = true, default_value = "8", value_parser = parse_workers)]
pub workers: usize,
#[command(subcommand)]
pub command: Option<Commands>,
}
#[derive(Subcommand, Debug)]
pub enum Commands {
Init,
#[command(about = ANALYZE_ABOUT.as_str(), after_help = "\
Examples:
repotoire analyze . Analyze current directory
repotoire analyze /path/to/repo Analyze a specific repo
repotoire analyze . --format json JSON output for scripting
repotoire analyze . --format sarif -o results.sarif.json SARIF for GitHub Code Scanning
repotoire analyze . --format html -o report.html Standalone HTML report
repotoire analyze . --severity high Only show high/critical findings
repotoire analyze . --fail-on high Exit code 1 if high+ findings (CI mode)
repotoire analyze . --explain-score Show full scoring breakdown")]
Analyze {
#[arg(long, short = 'f', default_value = "text")]
format: crate::reporters::OutputFormat,
#[arg(long, short = 'o')]
output: Option<PathBuf>,
#[arg(long)]
json_sidecar: Option<PathBuf>,
#[arg(long)]
severity: Option<crate::models::Severity>,
#[arg(long)]
top: Option<usize>,
#[arg(long, default_value = "1")]
page: usize,
#[arg(long, default_value = "20")]
per_page: usize,
#[arg(long)]
skip_detector: Vec<String>,
#[arg(long, hide = true)]
thorough: bool,
#[arg(long)]
all_detectors: bool,
#[arg(long)]
no_external: bool,
#[arg(long)]
relaxed: bool,
#[arg(long, default_value = "0")]
max_files: usize,
#[arg(long)]
fail_on: Option<crate::models::Severity>,
#[arg(long)]
no_emoji: bool,
#[arg(long)]
explain_score: bool,
#[arg(long)]
verify: bool,
#[arg(long, hide = true)]
since: Option<String>,
#[arg(long)]
rank: bool,
#[arg(long)]
timings: bool,
#[arg(long, value_name = "THRESHOLD")]
min_confidence: Option<f64>,
#[arg(long)]
show_all: bool,
#[arg(long, hide = true)]
force_reanalyze: bool,
#[arg(long)]
no_auto_calibrate: bool,
},
#[command(after_help = "\
Workflow:
repotoire analyze . # Run 1: establishes baseline
# ... make changes ...
repotoire analyze . # Run 2: snapshots run 1 as baseline, generates new findings
repotoire diff # Instant: compares baseline vs current (~10ms)
Examples:
repotoire diff Diff latest vs previous analysis
repotoire diff main Diff against main branch
repotoire diff --all Show ALL new findings (not just your changes)
repotoire diff --changed Show findings in changed files only
repotoire diff --format json JSON output for CI
repotoire diff --fail-on high Exit 1 if new high+ findings in your hunks
repotoire diff --format sarif SARIF with only hunk-level findings
repotoire diff HEAD --working-tree New findings in your uncommitted changes")]
Diff {
#[arg(value_name = "BASE_REF")]
base_ref: Option<String>,
#[arg(long, short = 'f', default_value = "text")]
format: crate::reporters::OutputFormat,
#[arg(long)]
fail_on: Option<crate::models::Severity>,
#[arg(long)]
no_emoji: bool,
#[arg(long, short = 'o')]
output: Option<PathBuf>,
#[arg(long)]
all: bool,
#[arg(long)]
changed: bool,
#[arg(long = "working-tree", visible_alias = "uncommitted")]
working_tree: bool,
},
#[command(after_help = "\
Examples:
repotoire findings . List findings (page 1, 20 per page)
repotoire findings . --page 2 View page 2
repotoire findings . --per-page 50 Show 50 findings per page
repotoire findings . --per-page 0 Show all findings (no pagination)
repotoire findings . --severity high Only high/critical findings
repotoire findings . 5 Show details for finding #5
repotoire findings . --json JSON output for scripting
repotoire findings . -i Interactive TUI mode")]
Findings {
#[arg(long, short = 'n')]
index: Option<usize>,
#[arg(value_name = "INDEX_OR_PATH")]
positional_index: Option<String>,
#[arg(long)]
json: bool,
#[arg(long)]
top: Option<usize>,
#[arg(long)]
severity: Option<crate::models::Severity>,
#[arg(long, default_value = "1")]
page: usize,
#[arg(long, default_value = "20")]
per_page: usize,
#[arg(long, short = 'i')]
interactive: bool,
#[arg(long)]
accept: bool,
#[arg(long, requires = "accept")]
reason: Option<String>,
},
#[command(after_help = "\
Examples:
repotoire fix . 3 Generate fix for finding #3 (AI-powered)
repotoire fix . 3 --no-ai Rule-based fix only (no API key needed)
repotoire fix . 3 --dry-run Preview fix without applying
repotoire fix . 3 --apply Apply fix directly to source files
repotoire fix . --auto Apply all available fixes without prompts")]
Fix {
#[arg(default_value = "0")]
index: usize,
#[arg(long)]
apply: bool,
#[arg(long)]
no_ai: bool,
#[arg(long)]
dry_run: bool,
#[arg(long)]
auto: bool,
},
#[command(after_help = "\
Examples:
repotoire graph functions List all functions in the graph
repotoire graph classes List all classes
repotoire graph files List all parsed files
repotoire graph calls Show function call relationships
repotoire graph imports Show import relationships
repotoire graph stats Show graph node/edge counts
repotoire graph functions --format json JSON output for scripting")]
Graph {
query: String,
#[arg(long, default_value = "table")]
format: crate::reporters::OutputFormat,
},
#[command(after_help = "\
Examples:
repotoire stats . Show graph stats for current directory
repotoire stats /path/to/repo Show graph stats for a specific repo")]
Stats,
Status,
Doctor,
Watch {
#[arg(long)]
severity: Option<crate::models::Severity>,
#[arg(long)]
all_detectors: bool,
},
Calibrate,
#[command(after_help = "\
Examples:
repotoire claude-hook install Wire the hook into ~/.claude/settings.json
repotoire claude-hook uninstall Remove the hook from settings.json
repotoire claude-hook install --allow-dev-binary Allow installing a target/debug build (rare)")]
ClaudeHook {
#[command(subcommand)]
action: claude_hook::ClaudeHookAction,
},
Version,
Config {
#[command(subcommand)]
action: ConfigAction,
},
Feedback {
index: usize,
#[arg(long, conflicts_with = "fp")]
tp: bool,
#[arg(long, conflicts_with = "tp")]
fp: bool,
#[arg(long)]
reason: Option<String>,
},
Train {
#[arg(long, default_value = "100")]
epochs: usize,
#[arg(long, default_value = "0.01")]
learning_rate: f32,
#[arg(long)]
stats: bool,
},
Benchmark {
#[arg(long, short = 'f', default_value = "text")]
format: crate::reporters::OutputFormat,
},
#[command(after_help = "\
Examples:
repotoire debt . Show top 20 debt hotspots
repotoire debt . --top 50 Show top 50 files
repotoire debt . --filter src/detectors Filter to a specific directory")]
Debt {
#[arg(long)]
filter: Option<String>,
#[arg(long, default_value = "20")]
top: usize,
},
#[command(name = "__worker", hide = true)]
Worker,
}
#[derive(Subcommand, Debug)]
pub enum ConfigAction {
Init,
Show,
Set {
key: String,
value: String,
},
Telemetry {
action: TelemetryAction,
},
}
fn extract_command_name(cmd: &Option<Commands>) -> (String, Option<String>) {
match cmd {
Some(Commands::Analyze { .. }) => ("analyze".into(), None),
Some(Commands::Diff { .. }) => ("diff".into(), None),
Some(Commands::Findings { .. }) => ("findings".into(), None),
Some(Commands::Fix { .. }) => ("fix".into(), None),
Some(Commands::Graph { .. }) => ("graph".into(), None),
Some(Commands::Stats) => ("stats".into(), None),
Some(Commands::Status) => ("status".into(), None),
Some(Commands::Doctor) => ("doctor".into(), None),
Some(Commands::Watch { .. }) => ("watch".into(), None),
Some(Commands::Calibrate) => ("calibrate".into(), None),
Some(Commands::ClaudeHook { action }) => {
let sub = match action {
claude_hook::ClaudeHookAction::Install { .. } => Some("install".into()),
claude_hook::ClaudeHookAction::Uninstall => Some("uninstall".into()),
claude_hook::ClaudeHookAction::Run => Some("run".into()),
};
("claude-hook".into(), sub)
}
Some(Commands::Version) => ("version".into(), None),
Some(Commands::Init) => ("init".into(), None),
Some(Commands::Feedback { .. }) => ("feedback".into(), None),
Some(Commands::Train { .. }) => ("train".into(), None),
Some(Commands::Benchmark { .. }) => ("benchmark".into(), None),
Some(Commands::Debt { .. }) => ("debt".into(), None),
Some(Commands::Config { action }) => match action {
ConfigAction::Telemetry { .. } => ("config".into(), Some("telemetry".into())),
ConfigAction::Init => ("config".into(), Some("init".into())),
ConfigAction::Show => ("config".into(), Some("show".into())),
ConfigAction::Set { .. } => ("config".into(), Some("set".into())),
},
Some(Commands::Worker) => ("worker".into(), None),
None => ("analyze".into(), None),
}
}
pub fn run(cli: Cli, telemetry: crate::telemetry::Telemetry) -> Result<()> {
rayon::ThreadPoolBuilder::new()
.num_threads(cli.workers)
.stack_size(8 * 1024 * 1024) .build_global()
.ok();
let cmd_start = std::time::Instant::now();
let (cmd_name, cmd_sub) = extract_command_name(&cli.command);
let result = match cli.command {
Some(Commands::Init) => init::run(&cli.path),
Some(Commands::Analyze {
format,
output,
json_sidecar,
severity,
top,
page,
per_page,
skip_detector,
thorough,
all_detectors,
no_external: _,
relaxed,
max_files,
fail_on,
no_emoji,
explain_score,
verify,
since,
rank,
timings,
min_confidence,
show_all,
force_reanalyze,
no_auto_calibrate,
}) => {
if thorough {
eprintln!("⚠️ --thorough is deprecated. External tools now run by default when available.");
eprintln!(" Use --no-external to skip external tools. --thorough will be removed in a future release.");
}
if relaxed {
eprintln!("\x1b[33mWarning: --relaxed is deprecated and will be removed in a future version.\x1b[0m");
eprintln!("\x1b[33m The default output already shows what matters.\x1b[0m");
eprintln!("\x1b[33m Use --severity high for explicit filtering.\x1b[0m");
}
if since.is_some() {
eprintln!("\x1b[33mWarning: --since is deprecated and will be removed in a future version.\x1b[0m");
eprintln!(
"\x1b[33m Incremental mode automatically skips unchanged files.\x1b[0m"
);
eprintln!("\x1b[33m Use `repotoire diff <ref>` to compare against a branch/tag.\x1b[0m");
}
let effective_severity = if relaxed && severity.is_none() {
Some(crate::models::Severity::High)
} else {
severity
};
let no_git = !cli.path.join(".git").exists();
let effective_min_confidence = min_confidence;
let skip_detectors: Vec<String> = skip_detector
.into_iter()
.map(|s| analyze::normalize_to_kebab(&s))
.collect();
let analysis_config = crate::engine::AnalysisConfig {
workers: cli.workers,
skip_detectors,
max_files,
no_git,
verify,
all_detectors,
force_reanalyze,
};
let output_options = crate::engine::OutputOptions {
format,
output_path: output,
severity_filter: effective_severity,
min_confidence: effective_min_confidence,
show_all,
top,
page,
per_page,
no_emoji,
explain_score,
rank,
timings,
fail_on,
json_sidecar,
};
analyze::run_engine(
&cli.path,
analysis_config,
output_options,
&telemetry,
no_auto_calibrate,
)
}
Some(Commands::Diff {
base_ref,
format,
fail_on,
no_emoji,
output,
all,
changed,
working_tree,
}) => diff::run(diff::RunArgs {
repo_path: &cli.path,
base_ref,
format,
fail_on,
no_emoji,
output: output.as_deref(),
all,
changed,
working_tree,
telemetry: &telemetry,
}),
Some(Commands::Findings {
index,
positional_index,
json,
top,
severity,
page,
per_page,
interactive,
accept,
reason,
}) => {
let (positional_idx, path_override) = match positional_index.as_deref() {
None => (None, None),
Some(s) => match s.parse::<usize>() {
Ok(n) if n >= 1 => (Some(n), None),
_ => (None, Some(std::path::PathBuf::from(s))),
},
};
let effective_index = positional_idx.or(index);
let effective_path: &Path = path_override.as_deref().unwrap_or(&cli.path);
if accept {
findings::accept_findings(effective_path, effective_index, reason)
} else if interactive {
findings::run_interactive(effective_path)
} else {
findings::run(findings::RunArgs {
path: effective_path,
index: effective_index,
json,
top,
severity,
page,
per_page,
})
}
}
Some(Commands::Fix {
index,
apply,
no_ai,
dry_run,
auto,
}) => fix::run(
&cli.path,
Some(index).filter(|&i| i > 0),
apply,
no_ai,
dry_run,
auto,
&telemetry,
),
Some(Commands::Graph { query, format }) => graph::run(&cli.path, &query, format),
Some(Commands::Stats) => graph::stats(&cli.path),
Some(Commands::Status) => status::run(&cli.path),
Some(Commands::Doctor) => doctor::run(),
Some(Commands::Watch {
severity,
all_detectors,
}) => watch::run(watch::RunArgs {
path: &cli.path,
severity,
all_detectors,
workers: cli.workers,
no_emoji: false,
quiet: false,
telemetry: &telemetry,
}),
Some(Commands::Calibrate) => run_calibrate(&cli.path),
Some(Commands::ClaudeHook { action }) => match action {
claude_hook::ClaudeHookAction::Install { allow_dev_binary } => {
claude_hook::run_install(allow_dev_binary)
}
claude_hook::ClaudeHookAction::Uninstall => claude_hook::run_uninstall(),
claude_hook::ClaudeHookAction::Run => {
let code = claude_hook::run_hook();
if code == std::process::ExitCode::SUCCESS {
Ok(())
} else {
anyhow::bail!("claude-hook run exited non-zero")
}
}
},
Some(Commands::Version) => {
println!("repotoire {}", env!("CARGO_PKG_VERSION"));
let hash = env!("BUILD_GIT_HASH");
let date = env!("BUILD_DATE");
let allocator = env!("BUILD_ALLOCATOR");
if !hash.is_empty() {
println!("commit: {hash}");
}
if !date.is_empty() {
println!("built: {date}");
}
println!("allocator: {allocator}");
Ok(())
}
Some(Commands::Config { action }) => run_config_action(action),
Some(Commands::Feedback {
index,
tp,
fp,
reason,
}) => {
use crate::classifier::FeedbackCollector;
let findings_path = crate::cache::findings_cache_path(&cli.path);
if !findings_path.exists() {
anyhow::bail!("No analysis results found. Run 'repotoire analyze' first.");
}
let content = std::fs::read_to_string(&findings_path).with_context(|| {
format!(
"Failed to read findings cache at {}",
findings_path.display()
)
})?;
let json_val: serde_json::Value =
serde_json::from_str(&content).with_context(|| {
format!(
"Failed to parse findings cache at {} as JSON",
findings_path.display()
)
})?;
let findings_value = json_val.get("findings").cloned().with_context(|| {
format!(
"Findings cache at {} is missing the `findings` field; \
the file is malformed. Re-run `repotoire analyze`.",
findings_path.display()
)
})?;
let findings: Vec<crate::models::Finding> = serde_json::from_value(findings_value)
.with_context(|| {
format!(
"Failed to deserialize findings from cache at {}. \
The cache may be from an incompatible repotoire version. \
Re-run `repotoire analyze` to regenerate it.",
findings_path.display()
)
})?;
if !findings.is_empty() && findings.iter().all(|f| !f.is_valid()) {
anyhow::bail!(
"Findings cache at {} is corrupt: every entry failed semantic validation. \
Re-run `repotoire analyze` to regenerate it.",
findings_path.display()
);
}
let findings: Vec<crate::models::Finding> =
findings.into_iter().filter(|f| f.is_valid()).collect();
if index == 0 || index > findings.len() {
anyhow::bail!(
"Invalid finding index {}. Valid range: 1-{}",
index,
findings.len()
);
}
let finding = &findings[index - 1];
let is_tp = tp || !fp;
let collector = FeedbackCollector::default();
collector.record(finding, is_tp, reason.clone())?;
let label = if is_tp {
"TRUE POSITIVE"
} else {
"FALSE POSITIVE"
};
println!("✅ Labeled finding #{} as {}", index, label);
println!(" {}: {}", finding.detector, finding.title);
if let Some(r) = &reason {
println!(" Reason: {}", r);
}
println!("\n Data saved to: {}", collector.data_path().display());
let stats = collector.stats()?;
println!(
"\n Total labeled: {} ({} TP, {} FP)",
stats.total, stats.true_positives, stats.false_positives
);
if let crate::telemetry::Telemetry::Active(ref state) = telemetry {
if let Some(distinct_id) = &state.distinct_id {
let file_ext = finding
.affected_files
.first()
.and_then(|p| p.extension())
.and_then(|e| e.to_str())
.unwrap_or("");
let had_alternative_branch = finding.alternative_branch.is_some();
let predicted_label = finding.alternative_branch.as_ref().map(|alt| {
crate::classifier::feedback::predicted_label_from_alt(alt.label).to_string()
});
let alternative_severity = finding
.alternative_branch
.as_ref()
.map(|alt| alt.severity.to_string());
let prediction_reason_kinds: Vec<String> = finding
.prediction_reasons
.iter()
.map(|r| crate::classifier::feedback::reason_kind(r).to_string())
.collect();
let original_severity = finding.original_severity.map(|s| s.to_string());
let event = crate::telemetry::events::DetectorFeedback {
repo_id: crate::telemetry::config::compute_repo_id(&cli.path),
detector: finding.detector.clone(),
verdict: if is_tp {
"true_positive".into()
} else {
"false_positive".into()
},
severity: finding.severity.to_string(),
language: crate::telemetry::events::ext_to_language(file_ext).to_string(),
file_extension: if file_ext.is_empty() {
None
} else {
Some(file_ext.to_string())
},
finding_title: Some(finding.title.clone()),
reason: reason.clone(),
version: env!("CARGO_PKG_VERSION").to_string(),
had_alternative_branch,
predicted_label,
alternative_severity,
prediction_reason_kinds,
original_severity,
..Default::default()
};
let props = serde_json::to_value(&event).unwrap_or_default();
crate::telemetry::posthog::capture_queued(
"detector_feedback",
distinct_id,
props,
);
}
}
Ok(())
}
Some(Commands::Train {
epochs,
learning_rate,
stats,
}) => {
use crate::classifier::{train, FeedbackCollector, TrainConfig};
let collector = FeedbackCollector::default();
if stats {
let training_stats = collector.stats()?;
println!("{}", training_stats);
return Ok(());
}
let config = TrainConfig {
epochs,
learning_rate,
..Default::default()
};
println!("🧠 Training classifier...\n");
let result = train(&config).map_err(|e| anyhow::anyhow!("Training failed: {}", e))?;
println!("\n✅ Training complete!");
println!(" Epochs: {}", result.epochs);
println!(" Train accuracy: {:.1}%", result.train_accuracy * 100.0);
if let Some(val_acc) = result.val_accuracy {
println!(" Val accuracy: {:.1}%", val_acc * 100.0);
}
println!(" Model saved to: {}", result.model_path.display());
println!("\n The trained model will be used automatically with --verify.");
Ok(())
}
Some(Commands::Benchmark { format }) => benchmark::run(&cli.path, format, &telemetry),
Some(Commands::Debt { filter, top }) => debt::run(&cli.path, filter.as_deref(), top),
Some(Commands::Worker) => crate::cli::worker::run(),
None => {
check_unknown_subcommand(&cli.path)?;
let analysis_config = crate::engine::AnalysisConfig {
workers: cli.workers,
..Default::default()
};
let output_options = crate::engine::OutputOptions::default();
analyze::run_engine(
&cli.path,
analysis_config,
output_options,
&telemetry,
false,
)
}
};
if let crate::telemetry::Telemetry::Active(ref state) = telemetry {
if let Some(distinct_id) = &state.distinct_id {
if crate::telemetry::events::should_track_command(&cmd_name, cmd_sub.as_deref()) {
let event = crate::telemetry::events::CommandUsed {
command: cmd_name,
subcommand: cmd_sub,
flags: Vec::new(),
duration_ms: cmd_start.elapsed().as_millis() as u64,
exit_code: if result.is_ok() { 0 } else { 1 },
version: env!("CARGO_PKG_VERSION").to_string(),
os: std::env::consts::OS.to_string(),
ci: std::env::var("CI").is_ok(),
};
let props = serde_json::to_value(&event).unwrap_or_default();
crate::telemetry::posthog::capture_queued("command_used", distinct_id, props);
}
}
}
result
}
fn run_calibrate(path: &std::path::Path) -> anyhow::Result<()> {
use crate::calibrate::{collect_metrics, StyleProfile};
use crate::parsers::parse_file;
let repo_path = std::fs::canonicalize(path)?;
println!(
"🎯 Calibrating adaptive thresholds for {}\n",
repo_path.display()
);
let files = crate::cli::analyze::files::collect_file_list(
&repo_path,
&crate::config::ExcludeConfig::default(),
)?;
println!(" Scanning {} files...", files.len());
let mut parse_results = Vec::new();
for file_path in &files {
if let Ok(result) = parse_file(file_path) {
let loc = std::fs::read_to_string(file_path)
.map(|c| c.lines().count())
.unwrap_or(0);
parse_results.push((result, loc));
}
}
let commit_sha = std::process::Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(&repo_path)
.output()
.ok()
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim().to_string());
let profile = collect_metrics(&parse_results, files.len(), commit_sha);
profile.print_table();
profile.save(&repo_path)?;
println!(
"\n✅ Saved to {}\n",
repo_path
.join(".repotoire")
.join(StyleProfile::FILENAME)
.display()
);
println!("Detectors will now use adaptive thresholds on next analyze.");
Ok(())
}
fn set_config_value(key: &str, value: &str) -> anyhow::Result<()> {
use crate::config::UserConfig;
let config_path = UserConfig::user_config_path()
.ok_or_else(|| anyhow::anyhow!("Could not determine config path"))?;
let mut content = if config_path.exists() {
std::fs::read_to_string(&config_path)?
} else {
UserConfig::init_user_config()?;
std::fs::read_to_string(&config_path)?
};
let toml_key = key.replace('.', "_").replace("ai_", "");
if content.contains(&format!("# {} =", toml_key)) {
content = content.replace(
&format!("# {} =", toml_key),
&format!("{} = \"{}\" #", toml_key, value),
);
} else if content.contains(&format!("{} =", toml_key)) {
let re = regex::Regex::new(&format!(r#"{}\s*=\s*"[^"]*""#, toml_key))?;
content = re
.replace(&content, format!("{} = \"{}\"", toml_key, value))
.to_string();
} else {
if !content.contains("[ai]") {
content.push_str("\n[ai]\n");
}
content.push_str(&format!("{} = \"{}\"\n", toml_key, value));
}
std::fs::write(&config_path, content)?;
println!("✅ Set {} in {}", key, config_path.display());
Ok(())
}
fn check_unknown_subcommand(path: &std::path::Path) -> anyhow::Result<()> {
let path_str = path.to_string_lossy();
let looks_like_command = !path.exists()
&& !path_str.contains('/')
&& !path_str.contains('\\')
&& !path_str.starts_with('.');
if !looks_like_command {
return Ok(());
}
let known_commands = [
"init", "analyze", "diff", "findings", "fix", "graph", "stats", "status", "doctor",
"version", "debt",
];
if !known_commands.contains(&path_str.as_ref()) {
anyhow::bail!(
"Unknown command '{}'. Run 'repotoire --help' for available commands.\n\nDid you mean one of: {}?",
path_str,
known_commands.join(", ")
);
}
Ok(())
}
fn run_config_action(action: ConfigAction) -> anyhow::Result<()> {
use crate::config::UserConfig;
match action {
ConfigAction::Init => {
let path = UserConfig::init_user_config()?;
println!("✅ Config initialized at: {}", path.display());
println!("\nEdit to add your API key:");
println!(" {}", path.display());
println!("\nOr set via environment:");
println!(" export ANTHROPIC_API_KEY=\"sk-ant-...\"");
Ok(())
}
ConfigAction::Show => show_config(),
ConfigAction::Set { key, value } => set_config_value(&key, &value),
ConfigAction::Telemetry { action } => run_telemetry_action(action),
}
}
fn run_telemetry_action(action: TelemetryAction) -> anyhow::Result<()> {
match action {
TelemetryAction::On => set_telemetry_enabled(true),
TelemetryAction::Off => set_telemetry_enabled(false),
TelemetryAction::Status => show_telemetry_status(),
}
}
fn set_telemetry_enabled(enabled: bool) -> anyhow::Result<()> {
let config_path = crate::config::UserConfig::user_config_path()
.ok_or_else(|| anyhow::anyhow!("Could not determine config directory"))?;
if let Some(parent) = config_path.parent() {
std::fs::create_dir_all(parent)?;
}
let mut content = std::fs::read_to_string(&config_path).unwrap_or_default();
let (old, new) = if enabled {
("enabled = false", "enabled = true")
} else {
("enabled = true", "enabled = false")
};
if content.contains("[telemetry]") {
content = content.replace(old, new);
} else {
content.push_str(&format!("\n[telemetry]\n{}\n", new));
}
std::fs::write(&config_path, &content)?;
if enabled {
let _ = crate::telemetry::config::TelemetryState::load();
println!("Telemetry enabled. Thank you for helping improve repotoire!");
println!("See what's collected: https://repotoire.com/telemetry");
} else {
println!("Telemetry disabled.");
}
Ok(())
}
fn show_telemetry_status() -> anyhow::Result<()> {
let state = crate::telemetry::config::TelemetryState::load()?;
if state.is_enabled() {
println!("Telemetry: enabled");
if let Some(id) = &state.distinct_id {
println!("Anonymous ID: {}", &id[..8]);
}
} else {
println!("Telemetry: disabled");
}
println!("\nManage: repotoire config telemetry on|off");
println!("Details: https://repotoire.com/telemetry");
Ok(())
}
fn show_config() -> anyhow::Result<()> {
let config = crate::config::UserConfig::load()?;
println!("📁 Config paths:");
if let Some(user_path) = crate::config::UserConfig::user_config_path() {
let status = if user_path.exists() {
"✓"
} else {
"(not found)"
};
println!(" User: {} {}", user_path.display(), status);
}
let proj_status = if std::path::Path::new("repotoire.toml").exists() {
"✓"
} else {
"(not found)"
};
println!(" Project: ./repotoire.toml {}", proj_status);
println!();
if config.use_ollama() {
println!("🤖 AI Backend: ollama");
println!(" Ollama URL: {}", config.ollama_url());
println!(" Ollama Model: {}", config.ollama_model());
} else if config.has_ai_key() {
println!("🤖 AI Backend: {}", config.ai_backend());
println!(" API key: ✓ configured");
} else {
println!("🤖 AI Backend: none (optional — set an API key for AI fixes)");
}
Ok(())
}