use std::process;
use std::time::Instant;
use anyhow::Result;
use clap::Parser;
use tracing::info;
use morpharch::cli::{Cli, Commands};
use morpharch::commands;
use morpharch::config::{MorphArchConfig, ProjectConfig};
use morpharch::db::Database;
use morpharch::git_scanner;
use morpharch::utils;
fn main() {
let cli = Cli::parse();
utils::init_logging(cli.verbose);
if let Err(err) = run(cli) {
utils::print_error(&err);
process::exit(1);
}
}
fn run(cli: Cli) -> Result<()> {
let config = MorphArchConfig::load()?;
info!(db_path = %config.db_path.display(), "Configuration ready");
let db = Database::open(&config.db_path)?;
match cli.command {
Commands::Scan { path, max_commits } => {
let repo_root = git_scanner::resolve_repo_root(&path)?;
let repo_id = git_scanner::repo_id_for_path(&path)?;
let project_config = ProjectConfig::load(&repo_root)?;
let limit = if max_commits == 0 {
usize::MAX
} else {
max_commits
};
execute_scan(
&repo_root,
&repo_id,
&config.cache_dir,
&db,
limit,
&project_config,
)?;
}
Commands::Watch {
path,
max_commits,
max_snapshots,
} => {
let repo_root = git_scanner::resolve_repo_root(&path)?;
let repo_id = git_scanner::repo_id_for_path(&path)?;
let project_config = ProjectConfig::load(&repo_root)?;
let limit = if max_commits == 0 {
usize::MAX
} else {
max_commits
};
let rt = tokio::runtime::Runtime::new()?;
rt.block_on(commands::watch::run_watch(
&repo_root,
&repo_id,
&config.cache_dir,
db,
limit,
max_snapshots,
&project_config,
))?;
}
Commands::ListGraphs { path } => {
let repo_id = git_scanner::repo_id_for_path(&path)?;
execute_list_graphs(&db, &repo_id)?;
}
Commands::Analyze { commit, path } => {
let repo_root = git_scanner::resolve_repo_root(&path)?;
let repo_id = git_scanner::repo_id_for_path(&path)?;
let project_config = ProjectConfig::load(&repo_root)?;
commands::analyze::run_analyze(
&repo_root,
&repo_id,
commit.as_deref(),
&db,
&project_config,
)?;
}
Commands::ListDrift { path } => {
let repo_id = git_scanner::repo_id_for_path(&path)?;
execute_list_drift(&db, &repo_id)?;
}
}
Ok(())
}
fn execute_scan(
path: &std::path::Path,
repo_id: &str,
cache_dir: &std::path::Path,
db: &Database,
max_commits: usize,
project_config: &ProjectConfig,
) -> Result<()> {
println!("Scanning repository: {}", path.display());
println!();
let start = Instant::now();
let result =
commands::scan::run_scan(path, repo_id, cache_dir, db, max_commits, project_config)?;
let elapsed = start.elapsed();
let total_commits = db.commit_count(repo_id)?;
let total_graphs = db.graph_snapshot_count(repo_id)?;
println!(
"Done: {} commits scanned, {} graphs + {} drift scores calculated in {:.1}s",
result.commits_scanned,
result.graphs_created,
result.drifts_calculated,
elapsed.as_secs_f64()
);
if total_commits > result.commits_scanned {
println!(
"Database totals: {} commits, {} graph snapshots stored",
total_commits, total_graphs
);
}
Ok(())
}
fn execute_list_graphs(db: &Database, repo_id: &str) -> Result<()> {
let total = db.graph_snapshot_count(repo_id)?;
if total == 0 {
println!("No graph snapshots yet. Run 'morpharch scan <path>' first.");
return Ok(());
}
let graphs = db.list_recent_graphs(repo_id, 10)?;
println!("Recent graph snapshots ({total} total):");
println!();
let header = format!(
"{:<9} {:<50} {:>6} {:>6} {}",
"HASH", "MESSAGE", "NODES", "EDGES", "DATE"
);
println!("{header}");
let separator = "─".repeat(95);
println!("{separator}");
for (hash, message, timestamp, nodes, edges) in &graphs {
let short_hash = if hash.len() >= 7 { &hash[..7] } else { hash };
let first_line = message.lines().next().unwrap_or("");
let truncated = if first_line.len() > 50 {
format!("{}…", &first_line[..49])
} else {
first_line.to_string()
};
let date = chrono::DateTime::from_timestamp(*timestamp, 0)
.map(|dt| dt.format("%Y-%m-%d %H:%M").to_string())
.unwrap_or_else(|| "?".to_string());
println!(
"{:<9} {:<50} {:>6} {:>6} {}",
short_hash, truncated, nodes, edges, date
);
}
println!();
println!("Total: {total} graph snapshots");
Ok(())
}
fn execute_list_drift(db: &Database, repo_id: &str) -> Result<()> {
let trend = db.list_drift_trend(repo_id, 20)?;
if trend.is_empty() {
println!("No drift data yet. Run 'morpharch scan <path>' first.");
return Ok(());
}
println!("Drift Score Trend (last {} commits):", trend.len());
println!();
let header = format!(
"{:<9} {:<35} {:>6} {:>6} {:>7} {:>7} {}",
"HASH", "MESSAGE", "NODES", "EDGES", "DRIFT", "DELTA", "DATE"
);
println!("{header}");
let separator = "─".repeat(100);
println!("{separator}");
let mut prev_drift: Option<u8> = None;
let reversed: Vec<_> = trend.iter().rev().collect();
for (hash, message, nodes, edges, drift_total, timestamp) in &reversed {
let short_hash = if hash.len() >= 7 { &hash[..7] } else { hash };
let first_line = message.lines().next().unwrap_or("");
let truncated = if first_line.len() > 35 {
format!("{}…", &first_line[..34])
} else {
first_line.to_string()
};
let drift_str = drift_total
.map(|d| format!("{d}"))
.unwrap_or_else(|| "—".to_string());
let delta_str = match (*drift_total, prev_drift) {
(Some(curr), Some(prev)) => {
let d = curr as i32 - prev as i32;
if d > 0 {
format!("+{d}")
} else if d < 0 {
format!("{d}")
} else {
"0".to_string()
}
}
_ => "—".to_string(),
};
let date = chrono::DateTime::from_timestamp(*timestamp, 0)
.map(|dt| dt.format("%Y-%m-%d %H:%M").to_string())
.unwrap_or_else(|| "?".to_string());
println!(
"{:<9} {:<35} {:>6} {:>6} {:>7} {:>7} {}",
short_hash, truncated, nodes, edges, drift_str, delta_str, date
);
prev_drift = *drift_total;
}
println!();
println!("Total: {} commits analyzed", trend.len());
Ok(())
}