use anyhow::Result;
use clap::Parser;
use std::path::PathBuf;
#[derive(Parser, Debug)]
#[command(name = "ripmap")]
#[command(version)]
#[command(about, long_about = None)]
pub struct Cli {
#[arg(value_name = "FILES")]
pub files: Vec<String>,
#[arg(short, long)]
pub focus: Option<String>,
#[arg(long, value_name = "FILES")]
pub other: Vec<PathBuf>,
#[arg(short = 't', long, default_value = "8192")]
pub tokens: usize,
#[arg(long)]
pub refresh: bool,
#[arg(long, default_value = "true")]
pub color: bool,
#[arg(long, default_value = "true")]
pub directory: bool,
#[arg(long)]
pub stats: bool,
#[arg(long)]
pub git_weight: bool,
#[arg(long)]
pub diagnose: bool,
#[arg(long)]
pub calls: bool,
#[arg(short, long, default_value = ".")]
pub root: PathBuf,
#[arg(short, long)]
pub verbose: bool,
#[arg(long)]
pub join: bool,
#[arg(short = 'e', long = "ext", value_name = "EXT")]
pub extensions: Vec<String>,
#[arg(long)]
pub no_color: bool,
}
const JOIN_SIZE_WARNING_THRESHOLD: usize = 200 * 1024;
fn main() -> Result<()> {
let cli = Cli::parse();
if cli.join {
run_join_mode(&cli)?;
return Ok(());
}
let output = run(&cli)?;
println!("{}", output);
Ok(())
}
fn run_join_mode(cli: &Cli) -> Result<()> {
use ripmap::discovery::find_source_files;
let use_color = cli.color && !cli.no_color;
let root = cli.root.canonicalize().map_err(|e| {
anyhow::anyhow!(
"Failed to resolve root path '{}': {}",
cli.root.display(),
e
)
})?;
if cli.verbose {
eprintln!("🔗 ripmap join mode");
eprintln!("📂 Scanning: {}", root.display());
}
let all_files = find_source_files(&root, false)?;
let files: Vec<_> = if cli.extensions.is_empty() {
all_files
} else {
let exts: std::collections::HashSet<_> = cli
.extensions
.iter()
.map(|e| e.strip_prefix('.').unwrap_or(e).to_lowercase())
.collect();
all_files
.into_iter()
.filter(|f| {
f.extension()
.map(|e| exts.contains(&e.to_string_lossy().to_lowercase()))
.unwrap_or(false)
})
.collect()
};
if files.is_empty() {
return Err(anyhow::anyhow!(
"No files to join. Provide paths or directories, or check --ext filters."
));
}
let mut segments: Vec<(String, String)> = Vec::new();
let mut total_size: usize = 0;
for fpath in files.iter() {
let content = match std::fs::read_to_string(fpath) {
Ok(c) => c,
Err(_) => continue, };
let rel_path = fpath
.strip_prefix(&root)
.unwrap_or(fpath)
.to_string_lossy()
.to_string();
total_size += content.len();
segments.push((rel_path, content));
}
segments.sort_by(|a, b| a.0.cmp(&b.0));
if total_size > JOIN_SIZE_WARNING_THRESHOLD {
let size_kb = total_size / 1024;
eprintln!(
"⚠️ Warning: Output is {}KB ({} files). Consider using the default map mode for large codebases.",
size_kb,
segments.len()
);
}
if cli.verbose {
eprintln!(
"✓ Joining {} files ({:.1}KB)",
segments.len(),
total_size as f64 / 1024.0
);
}
let separator = "─".repeat(80);
for (rel_path, content) in &segments {
if use_color {
println!("\n\x1b[1;34m{}\x1b[0m", separator);
println!("\x1b[1;37;44m {} \x1b[0m", rel_path);
println!("\x1b[1;34m{}\x1b[0m\n", separator);
} else {
println!("\n{}", separator);
println!(" {} ", rel_path);
println!("{}\n", separator);
}
print!("{}", content);
if !content.ends_with('\n') {
println!();
}
}
if use_color {
println!("\n\x1b[1;34m{}\x1b[0m", separator);
} else {
println!("\n{}", separator);
}
Ok(())
}
fn run(cli: &Cli) -> Result<String> {
use ripmap::callgraph::ResolverBuilder;
use ripmap::config::Config;
use ripmap::discovery::find_source_files_with_config;
use ripmap::extraction::{Parser, extract_tags};
use ripmap::ranking::{BoostCalculator, PageRanker};
use ripmap::rendering::DirectoryRenderer;
use ripmap::types::{DetailLevel, RankingConfig};
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use std::time::Instant;
let start = Instant::now();
let root = cli.root.canonicalize().map_err(|e| {
anyhow::anyhow!(
"Failed to resolve root path '{}': {}",
cli.root.display(),
e
)
})?;
let file_config = Config::load(&root);
if cli.verbose {
eprintln!("🗺️ ripmap v{}", env!("CARGO_PKG_VERSION"));
eprintln!("📂 Scanning: {}", root.display());
eprintln!("{}", file_config.display_summary());
}
let files = find_source_files_with_config(&root, &file_config, false)?;
if files.is_empty() {
return Ok("No source files found. Check your path and .gitignore settings.".into());
}
if cli.verbose {
eprintln!("✓ Found {} files ({:.2?})", files.len(), start.elapsed());
}
let extract_start = Instant::now();
let parser = Parser::new();
let mut tags_by_file: HashMap<String, Vec<ripmap::Tag>> = HashMap::new();
let mut total_tags = 0;
for file in &files {
let rel_fname = file
.strip_prefix(&root)
.unwrap_or(file)
.to_string_lossy()
.to_string();
match extract_tags(file, &rel_fname, &parser) {
Ok(tags) => {
total_tags += tags.len();
if !tags.is_empty() {
tags_by_file.insert(rel_fname, tags);
}
}
Err(_) => continue, }
}
if cli.verbose {
eprintln!(
"✓ Extracted {} tags from {} files ({:.2?})",
total_tags,
tags_by_file.len(),
extract_start.elapsed()
);
}
let rank_start = Instant::now();
let config = RankingConfig::default();
let page_ranker = PageRanker::new(config.clone());
let chat_fnames: Vec<String> = cli
.files
.iter()
.filter_map(|f| {
let path = std::path::Path::new(f);
if path.exists() {
path.strip_prefix(&root)
.ok()
.or(Some(path))
.map(|p| p.to_string_lossy().to_string())
} else {
None
}
})
.collect();
let file_ranks = page_ranker.compute_ranks(&tags_by_file, &chat_fnames);
if cli.verbose {
eprintln!(
"✓ Computed PageRank for {} files ({:.2?})",
file_ranks.len(),
rank_start.elapsed()
);
}
let cg_start = Instant::now();
let all_tags: Vec<ripmap::Tag> = tags_by_file
.values()
.flat_map(|tags| tags.iter().cloned())
.collect();
let resolver = ResolverBuilder::new()
.same_file(true)
.type_hints(true)
.imports(true)
.name_match(true)
.build();
let call_graph = resolver.build_graph(&all_tags);
if cli.verbose {
let stats = resolver.stats(&all_tags);
let resolved = stats.total_calls - stats.unresolved;
eprintln!(
"✓ Built call graph: {} functions, {} calls ({:.2?})",
call_graph.function_count(),
call_graph.call_count(),
cg_start.elapsed()
);
eprintln!(
" Resolution: {:.1}% success ({} resolved, {} unresolved)",
stats.resolution_rate() * 100.0,
resolved,
stats.unresolved
);
}
let function_ranks = page_ranker.compute_function_ranks(&call_graph);
let symbol_ranks: HashMap<(Arc<str>, Arc<str>), f64> = function_ranks
.into_iter()
.map(|(func_id, rank)| ((func_id.file, func_id.name), rank))
.collect();
if cli.verbose && !symbol_ranks.is_empty() {
eprintln!(
"✓ Computed function-level PageRank for {} symbols",
symbol_ranks.len()
);
}
use ripmap::ranking::{FocusResolver, TestCouplingDetector};
let coupling_detector =
TestCouplingDetector::new().with_min_confidence(config.test_coupling_min_confidence);
let file_paths: Vec<_> = files
.iter()
.map(|f| f.strip_prefix(&root).unwrap_or(f).to_path_buf())
.collect();
let test_couplings = coupling_detector.detect_couplings(&file_paths);
let test_coupling_edges = coupling_detector.as_symbol_edges(&test_couplings);
if cli.verbose && !test_couplings.is_empty() {
eprintln!("✓ Detected {} test↔source couplings", test_couplings.len());
}
let focus_expansion_weights = if cli.focus.is_some() {
let focus_start = Instant::now();
let focus_resolver = FocusResolver::new(&root);
let focus_targets: Vec<String> = cli
.focus
.as_ref()
.map(|f| f.split_whitespace().map(String::from).collect())
.unwrap_or_default();
let (_matched_files, matched_idents) =
focus_resolver.resolve(&focus_targets, &tags_by_file);
let matched_set: HashSet<String> = matched_idents;
let mut symbol_edges = call_graph.as_symbol_edges();
symbol_edges.extend(test_coupling_edges.clone());
let expanded = focus_resolver.expand_via_graph(
&matched_set,
&symbol_edges,
config.focus_expansion_max_hops,
config.focus_expansion_decay,
);
if cli.verbose && !expanded.is_empty() {
eprintln!(
"✓ Focus expansion: {} symbols via call graph ({:.2?})",
expanded.len(),
focus_start.elapsed()
);
}
Some(expanded)
} else {
None
};
let boost_start = Instant::now();
let boost_calculator = BoostCalculator::new(config);
let chat_fnames_set: HashSet<String> = chat_fnames.into_iter().collect();
let mentioned_fnames: HashSet<String> = cli.files.iter().cloned().collect();
let mentioned_idents: HashSet<String> = cli
.focus
.as_ref()
.map(|f| f.split_whitespace().map(String::from).collect())
.unwrap_or_default();
let symbol_caller_weights = call_graph.caller_weights();
let mut caller_weights: HashMap<String, f64> = HashMap::new();
for ((file, _symbol), count) in &symbol_caller_weights {
*caller_weights.entry(file.to_string()).or_insert(0.0) += *count as f64;
}
for weight in caller_weights.values_mut() {
*weight = 1.0 + (*weight).ln().max(0.0);
}
let ranked_tags = boost_calculator.apply_boosts(
&tags_by_file,
&file_ranks,
if symbol_ranks.is_empty() {
None
} else {
Some(&symbol_ranks)
},
&chat_fnames_set,
&mentioned_fnames,
&mentioned_idents,
&HashSet::new(), None, if caller_weights.is_empty() {
None
} else {
Some(&caller_weights)
},
focus_expansion_weights.as_ref(),
);
if cli.verbose {
eprintln!(
"✓ Applied boosts to {} tags ({:.2?})",
ranked_tags.len(),
boost_start.elapsed()
);
}
let render_start = Instant::now();
let token_counter = |s: &str| s.len() / 4;
let renderer = DirectoryRenderer::new(Box::new(token_counter));
let detail = if cli.tokens >= 16384 {
DetailLevel::High
} else if cli.tokens >= 4096 {
DetailLevel::Medium
} else {
DetailLevel::Low
};
let output = renderer.render_with_calls(
&ranked_tags,
detail,
&HashMap::new(), &HashMap::new(), if cli.calls { Some(&call_graph) } else { None },
);
if cli.verbose {
eprintln!("✓ Rendered output ({:.2?})", render_start.elapsed());
eprintln!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
eprintln!("Total time: {:.2?}", start.elapsed());
}
let header = format!(
"# Ranking: {} | {} symbols | ~{} tokens\n",
if ranked_tags.len() > 100 {
"high (dense)"
} else {
"low (sparse)"
},
ranked_tags.len(),
token_counter(&output)
);
if cli.stats {
let stats = format!(
"\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\
## Statistics\n\
Files discovered: {}\n\
Files with tags: {}\n\
Tags extracted: {}\n\
Ranked symbols: {}\n\
Total time: {:.2?}\n",
files.len(),
tags_by_file.len(),
total_tags,
ranked_tags.len(),
start.elapsed()
);
Ok(format!("{}{}{}", header, output, stats))
} else {
Ok(format!("{}{}", header, output))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_cli_parse_minimal() {
let cli = Cli::parse_from(&["ripmap"]);
assert_eq!(cli.files.len(), 0);
assert_eq!(cli.tokens, 8192);
assert!(cli.directory);
assert!(cli.color);
}
#[test]
fn test_cli_parse_with_files() {
let cli = Cli::parse_from(&["ripmap", "src/lib.rs", "src/main.rs"]);
assert_eq!(cli.files, vec!["src/lib.rs", "src/main.rs"]);
}
#[test]
fn test_cli_parse_focus() {
let cli = Cli::parse_from(&["ripmap", "--focus", "auth parser"]);
assert_eq!(cli.focus, Some("auth parser".into()));
}
#[test]
fn test_cli_parse_tokens() {
let cli = Cli::parse_from(&["ripmap", "--tokens", "4096"]);
assert_eq!(cli.tokens, 4096);
}
#[test]
fn test_cli_parse_flags() {
let cli = Cli::parse_from(&[
"ripmap",
"--refresh",
"--stats",
"--git-weight",
"--verbose",
"--diagnose",
]);
assert!(cli.refresh);
assert!(cli.stats);
assert!(cli.git_weight);
assert!(cli.verbose);
assert!(cli.diagnose);
}
#[test]
fn test_cli_parse_root() {
let cli = Cli::parse_from(&["ripmap", "--root", "/tmp/test"]);
assert_eq!(cli.root, PathBuf::from("/tmp/test"));
}
#[test]
fn test_run_on_ripmap_itself() -> Result<()> {
let cli = Cli {
files: vec![],
focus: None,
other: vec![],
tokens: 8192,
refresh: false,
color: true,
directory: true,
stats: false,
git_weight: false,
diagnose: false,
calls: false,
root: PathBuf::from("."),
verbose: false,
join: false,
extensions: vec![],
no_color: false,
};
let output = run(&cli)?;
assert!(output.contains("# Ranking:"), "Missing ranking header");
assert!(output.contains("symbols"), "Missing symbols count");
assert!(output.contains("tokens"), "Missing tokens estimate");
Ok(())
}
#[test]
fn test_cli_parse_join_mode() {
let cli = Cli::parse_from(&["ripmap", "--join", "-e", "rs"]);
assert!(cli.join);
assert_eq!(cli.extensions, vec!["rs"]);
}
#[test]
fn test_cli_parse_multiple_extensions() {
let cli = Cli::parse_from(&["ripmap", "-e", "py", "-e", "pyi", "--ext", "rs"]);
assert_eq!(cli.extensions, vec!["py", "pyi", "rs"]);
}
#[test]
fn test_cli_parse_no_color() {
let cli = Cli::parse_from(&["ripmap", "--no-color"]);
assert!(cli.no_color);
}
}