use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
#[derive(Debug, Parser)]
#[command(
name = "ferrograph",
version,
about,
long_version = concat!(include_str!("../assets/ascii-banner.txt"), "\n v", env!("CARGO_PKG_VERSION"))
)]
pub struct Cli {
#[arg(long, global = true)]
pub json: bool,
#[command(subcommand)]
pub command: Command,
}
#[derive(Debug, Subcommand)]
pub enum Command {
Index {
#[arg(default_value = ".")]
path: PathBuf,
#[arg(short, long)]
output: Option<PathBuf>,
},
Query {
#[arg(short, long)]
db: Option<PathBuf>,
query: String,
},
Search {
#[arg(short, long)]
db: Option<PathBuf>,
query: String,
#[arg(short, long)]
case_insensitive: bool,
},
Status {
#[arg(default_value = ".")]
path: PathBuf,
},
Watch {
#[arg(default_value = ".")]
path: PathBuf,
#[arg(short, long, required = true)]
output: Option<PathBuf>,
},
Dead {
#[arg(short, long)]
db: Option<PathBuf>,
#[arg(short, long)]
file: Option<String>,
},
Blast {
#[arg(short, long)]
db: Option<PathBuf>,
node_id: String,
},
Callers {
#[arg(short, long)]
db: Option<PathBuf>,
node_id: String,
#[arg(long, default_value = "1")]
depth: u32,
},
Info {
#[arg(short, long)]
db: Option<PathBuf>,
node_id: String,
},
Modules {
#[arg(short, long)]
db: Option<PathBuf>,
#[arg(short, long)]
root: Option<String>,
},
Traits {
#[arg(short, long)]
db: Option<PathBuf>,
trait_name: String,
},
Claude {
#[command(subcommand)]
action: ClaudeAction,
},
Mcp,
}
#[derive(Debug, Subcommand)]
pub enum ClaudeAction {
Install,
Uninstall,
Status,
}
pub fn run(cli: Cli) -> Result<()> {
let json = cli.json;
match cli.command {
Command::Index { path, output } => run_index(&path, output.as_ref()),
Command::Query { db, query } => run_query(db.as_ref(), &query, json),
Command::Search {
db,
query,
case_insensitive,
} => run_search(db.as_ref(), &query, case_insensitive, json),
Command::Status { path } => run_status(&path, json),
Command::Watch { path, output } => run_watch(&path, output.as_ref()),
Command::Dead { db, file } => run_dead(db.as_ref(), file.as_deref(), json),
Command::Blast { db, node_id } => run_blast(db.as_ref(), &node_id, json),
Command::Callers { db, node_id, depth } => run_callers(db.as_ref(), &node_id, depth, json),
Command::Info { db, node_id } => run_info(db.as_ref(), &node_id, json),
Command::Modules { db, root } => run_modules(db.as_ref(), root.as_deref(), json),
Command::Traits { db, trait_name } => run_traits(db.as_ref(), &trait_name, json),
Command::Claude { ref action } => crate::claude::run(action, json),
Command::Mcp => run_mcp(),
}
}
fn run_mcp() -> Result<()> {
let rt = tokio::runtime::Runtime::new()?;
rt.block_on(crate::mcp::run_stdio())
.map_err(|e| anyhow::anyhow!("{e}"))?;
Ok(())
}
fn default_db_path() -> Option<PathBuf> {
std::env::current_dir().ok().map(|p| p.join(".ferrograph"))
}
fn resolve_db_path(db: Option<&PathBuf>) -> Result<PathBuf> {
db.cloned()
.or_else(default_db_path)
.context("No graph database path (use --db or run from a directory with .ferrograph)")
}
fn run_index(path: &Path, output: Option<&PathBuf>) -> Result<()> {
let store = if let Some(out) = output {
crate::graph::Store::new_persistent(out)
.with_context(|| format!("Failed to create persistent store at {}", out.display()))?
} else {
crate::graph::Store::new_memory()?
};
let config = crate::pipeline::PipelineConfig::default();
crate::pipeline::run_pipeline(&store, path, &config)?;
if let Some(out) = output {
println!("Indexed {} into {}", path.display(), out.display());
} else {
let nodes = store.node_count()?;
let edges = store.edge_count()?;
println!(
"Indexed {} (in-memory: {nodes} nodes, {edges} edges; use --output to persist)",
path.display()
);
}
Ok(())
}
fn run_query(db: Option<&PathBuf>, query: &str, json: bool) -> Result<()> {
let db_path = resolve_db_path(db)?;
if !db_path.exists() {
anyhow::bail!(
"Graph database not found at {}. Run 'ferrograph index --output {}' first.",
db_path.display(),
db_path.display()
);
}
let store = crate::graph::Store::new_persistent(&db_path)
.with_context(|| format!("Failed to open graph at {}", db_path.display()))?;
let result = crate::ops::query(&store, query).context("Query execution failed")?;
if json {
println!("{}", serde_json::to_string_pretty(&result)?);
} else {
for row in &result.rows {
let cols: Vec<String> = row
.iter()
.map(|v| match v {
serde_json::Value::String(s) => s.clone(),
other => other.to_string(),
})
.collect();
println!("{}", cols.join("\t"));
}
}
Ok(())
}
fn run_search(db: Option<&PathBuf>, query: &str, case_insensitive: bool, json: bool) -> Result<()> {
let db_path = resolve_db_path(db)?;
if !db_path.exists() {
anyhow::bail!(
"Graph database not found at {}. Run 'ferrograph index --output {}' first.",
db_path.display(),
db_path.display()
);
}
let store = crate::graph::Store::new_persistent(&db_path)
.with_context(|| format!("Failed to open graph at {}", db_path.display()))?;
let result = crate::ops::search(&store, query, case_insensitive, 10_000, 0)?;
if json {
println!("{}", serde_json::to_string_pretty(&result)?);
} else {
for item in &result.results {
let payload_display = item.payload.as_deref().unwrap_or("—");
println!("{}\t{}\t{payload_display}", item.id, item.node_type);
}
}
Ok(())
}
fn run_watch(path: &Path, output: Option<&PathBuf>) -> Result<()> {
let out = output
.ok_or_else(|| anyhow::anyhow!("Watch requires --output (path to graph database)"))?;
let store = crate::graph::Store::new_persistent(out)
.with_context(|| format!("Failed to open graph at {}", out.display()))?;
let config = crate::pipeline::PipelineConfig::default();
crate::watch::watch_and_reindex(&store, path, &config)
}
fn run_dead(db: Option<&PathBuf>, file: Option<&str>, json: bool) -> Result<()> {
let db_path = resolve_db_path(db)?;
if !db_path.exists() {
anyhow::bail!(
"Graph database not found at {}. Run 'ferrograph index --output {}' first.",
db_path.display(),
db_path.display()
);
}
let store = crate::graph::Store::new_persistent(&db_path)
.with_context(|| format!("Failed to open graph at {}", db_path.display()))?;
let result = crate::ops::dead_code(&store, file)?;
if json {
println!("{}", serde_json::to_string_pretty(&result)?);
} else {
for node in &result.dead_nodes {
let payload = node.payload.as_deref().unwrap_or("—");
println!("{}\t{}\t{payload}", node.id, node.node_type);
}
println!(
"\n{} dead nodes found (source: {})",
result.count, result.source
);
println!("\n{}", crate::ops::DEAD_CODE_CAVEAT);
}
Ok(())
}
fn run_blast(db: Option<&PathBuf>, node_id: &str, json: bool) -> Result<()> {
let db_path = resolve_db_path(db)?;
if !db_path.exists() {
anyhow::bail!(
"Graph database not found at {}. Run 'ferrograph index --output {}' first.",
db_path.display(),
db_path.display()
);
}
let store = crate::graph::Store::new_persistent(&db_path)
.with_context(|| format!("Failed to open graph at {}", db_path.display()))?;
let result = crate::ops::blast_radius(&store, node_id)?;
if json {
println!("{}", serde_json::to_string_pretty(&result)?);
} else {
for node in &result.reachable_nodes {
let payload = node.payload.as_deref().unwrap_or("—");
println!("{}\t{}\t{payload}", node.id, node.node_type);
}
println!("\n{} nodes in blast radius of {}", result.count, node_id);
}
Ok(())
}
fn run_callers(db: Option<&PathBuf>, node_id: &str, depth: u32, json: bool) -> Result<()> {
let db_path = resolve_db_path(db)?;
if !db_path.exists() {
anyhow::bail!(
"Graph database not found at {}. Run 'ferrograph index --output {}' first.",
db_path.display(),
db_path.display()
);
}
let store = crate::graph::Store::new_persistent(&db_path)
.with_context(|| format!("Failed to open graph at {}", db_path.display()))?;
let result = crate::ops::callers(&store, node_id, depth)?;
if json {
println!("{}", serde_json::to_string_pretty(&result)?);
} else {
for node in &result.callers {
let payload = node.payload.as_deref().unwrap_or("—");
println!("{}\t{}\t{payload}", node.id, node.node_type);
}
println!("\n{} callers of {}", result.count, node_id);
}
Ok(())
}
fn run_info(db: Option<&PathBuf>, node_id: &str, json: bool) -> Result<()> {
let db_path = resolve_db_path(db)?;
if !db_path.exists() {
anyhow::bail!(
"Graph database not found at {}. Run 'ferrograph index --output {}' first.",
db_path.display(),
db_path.display()
);
}
let store = crate::graph::Store::new_persistent(&db_path)
.with_context(|| format!("Failed to open graph at {}", db_path.display()))?;
let info = crate::ops::node_info(&store, node_id)?;
match info {
Some(n) => {
if json {
println!("{}", serde_json::to_string_pretty(&n)?);
} else {
let payload = n.payload.as_deref().unwrap_or("—");
println!("{}\t{}\t{payload}", n.id, n.node_type);
if !n.outgoing_edges.is_empty() {
println!("\n Outgoing edges:");
for e in &n.outgoing_edges {
let p = e.payload.as_deref().unwrap_or("—");
println!(" --[{}]--> {}\t{}\t{p}", e.edge_type, e.id, e.node_type);
}
}
if !n.incoming_edges.is_empty() {
println!("\n Incoming edges:");
for e in &n.incoming_edges {
let p = e.payload.as_deref().unwrap_or("—");
println!(" <--[{}]-- {}\t{}\t{p}", e.edge_type, e.id, e.node_type);
}
}
}
}
None => {
if json {
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"error": "Node not found",
"node_id": node_id
}))?
);
} else {
println!("Node not found: {node_id}");
}
}
}
Ok(())
}
fn run_modules(db: Option<&PathBuf>, root: Option<&str>, json: bool) -> Result<()> {
let db_path = resolve_db_path(db)?;
if !db_path.exists() {
anyhow::bail!(
"Graph database not found at {}. Run 'ferrograph index --output {}' first.",
db_path.display(),
db_path.display()
);
}
let store = crate::graph::Store::new_persistent(&db_path)
.with_context(|| format!("Failed to open graph at {}", db_path.display()))?;
let result = crate::ops::module_graph(&store, root)?;
if json {
println!("{}", serde_json::to_string_pretty(&result)?);
} else {
for edge in &result.edges {
println!(
"{} ({}) --> {} ({})",
edge.from_id, edge.from_type, edge.to_id, edge.to_type
);
}
println!("\n{} containment edges", result.count);
}
Ok(())
}
fn run_traits(db: Option<&PathBuf>, trait_name: &str, json: bool) -> Result<()> {
let db_path = resolve_db_path(db)?;
if !db_path.exists() {
anyhow::bail!(
"Graph database not found at {}. Run 'ferrograph index --output {}' first.",
db_path.display(),
db_path.display()
);
}
let store = crate::graph::Store::new_persistent(&db_path)
.with_context(|| format!("Failed to open graph at {}", db_path.display()))?;
let result = crate::ops::trait_implementors(&store, trait_name)?;
if json {
println!("{}", serde_json::to_string_pretty(&result)?);
} else {
for node in &result.implementors {
let payload = node.payload.as_deref().unwrap_or("—");
println!("{}\t{}\t{payload}", node.id, node.node_type);
}
println!("\n{} implementors of \"{}\"", result.count, trait_name);
}
Ok(())
}
fn run_status(path: &Path, json: bool) -> Result<()> {
let db_path = if path.is_dir() {
path.join(".ferrograph")
} else {
path.to_path_buf()
};
if !db_path.exists() {
println!(
"No graph at {}. Run 'ferrograph index --output {}' first.",
path.display(),
db_path.display()
);
return Ok(());
}
let store = crate::graph::Store::new_persistent(&db_path)
.with_context(|| format!("Failed to open graph at {}", db_path.display()))?;
let result = crate::ops::status(&store, &db_path)?;
if json {
println!("{}", serde_json::to_string_pretty(&result)?);
} else {
println!("Graph: {}", result.db_path);
if let Some(ts) = result.indexed_at {
println!(" indexed_at: {ts}");
}
println!();
println!(" Nodes ({} total):", result.node_count);
for (type_name, count) in &result.nodes_by_type {
println!(" {type_name:<20} {count:>6}");
}
println!();
println!(" Edges ({} total):", result.edge_count);
for (type_name, count) in &result.edges_by_type {
println!(" {type_name:<20} {count:>6}");
}
}
Ok(())
}