use std::path::PathBuf;
use std::io::{self, Read};
use anyhow::{Context, Result, bail};
use clap::{Parser, Subcommand};
use gid_core::{
Graph, Node, Edge, NodeStatus,
load_graph, save_graph,
parser::find_graph_file,
query::QueryEngine,
validator::Validator,
CodeGraph, CodeNode, NodeKind,
analyze_impact, format_impact_for_llm,
assess_complexity_from_graph, assess_risk_level,
build_unified_graph,
HistoryManager,
render, VisualFormat,
analyze as advise_analyze,
generate_graph_prompt, parse_llm_response,
generate_semantify_prompt, apply_heuristic_layers,
preview_rename, apply_rename,
preview_merge, apply_merge,
preview_split, apply_split, SplitDefinition,
preview_extract, apply_extract,
};
#[derive(Parser)]
#[command(name = "gid")]
#[command(author, version, about = "Graph Indexed Development - unified graph-based project tool")]
struct Cli {
#[arg(short, long, global = true)]
graph: Option<PathBuf>,
#[arg(long, global = true)]
json: bool,
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
Init {
#[arg(short, long)]
name: Option<String>,
#[arg(short, long)]
desc: Option<String>,
},
Read,
Validate,
Tasks {
#[arg(short, long)]
status: Option<String>,
#[arg(short, long)]
ready: bool,
},
TaskUpdate {
id: String,
#[arg(short, long)]
status: String,
},
Complete {
id: String,
},
AddNode {
id: String,
title: String,
#[arg(short, long)]
desc: Option<String>,
#[arg(short, long)]
status: Option<String>,
#[arg(short, long)]
tags: Option<String>,
#[arg(long, name = "type")]
node_type: Option<String>,
},
RemoveNode {
id: String,
},
AddEdge {
from: String,
to: String,
#[arg(short, long, default_value = "depends_on")]
relation: String,
},
RemoveEdge {
from: String,
to: String,
#[arg(short, long)]
relation: Option<String>,
},
#[command(subcommand)]
Query(QueryCommands),
EditGraph {
operations: String,
},
Extract {
#[arg(default_value = ".")]
dir: PathBuf,
#[arg(short, long, default_value = "summary")]
format: String,
#[arg(short, long)]
output: Option<PathBuf>,
},
Analyze {
file: PathBuf,
#[arg(short, long)]
callers: bool,
#[arg(long)]
callees: bool,
#[arg(short, long)]
impact: bool,
},
CodeSearch {
keywords: String,
#[arg(short, long, default_value = ".")]
dir: PathBuf,
#[arg(long)]
format_llm: Option<usize>,
},
CodeFailures {
#[arg(long)]
changed: String,
#[arg(long)]
p2p: Option<String>,
#[arg(long)]
f2p: Option<String>,
#[arg(short, long, default_value = ".")]
dir: PathBuf,
},
CodeSymptoms {
problem: String,
#[arg(long, default_value = "[]")]
tests: String,
#[arg(short, long, default_value = ".")]
dir: PathBuf,
},
CodeTrace {
symptoms: String,
#[arg(long, default_value = "5")]
depth: usize,
#[arg(long, default_value = "10")]
max_chains: usize,
#[arg(short, long, default_value = ".")]
dir: PathBuf,
},
CodeComplexity {
nodes: String,
#[arg(short, long, default_value = ".")]
dir: PathBuf,
},
CodeImpact {
files: String,
#[arg(short, long, default_value = ".")]
dir: PathBuf,
},
CodeSnippets {
keywords: String,
#[arg(long, default_value = "30")]
max_lines: usize,
#[arg(short, long, default_value = ".")]
dir: PathBuf,
},
Schema {
#[arg(default_value = ".")]
dir: PathBuf,
},
FileSummary {
file: String,
#[arg(short, long, default_value = ".")]
dir: PathBuf,
},
#[command(subcommand)]
History(HistoryCommands),
Visual {
#[arg(short, long, default_value = "ascii")]
format: String,
#[arg(short, long)]
output: Option<PathBuf>,
},
Advise {
#[arg(long)]
errors_only: bool,
},
Design {
requirements: Option<String>,
#[arg(long)]
parse: bool,
},
Semantify {
#[arg(long)]
heuristic: bool,
#[arg(long)]
parse: bool,
},
#[command(subcommand)]
Refactor(RefactorCommands),
}
#[derive(Subcommand)]
enum QueryCommands {
Impact {
node: String,
},
Deps {
node: String,
#[arg(short, long)]
transitive: bool,
},
Path {
from: String,
to: String,
},
CommonCause {
a: String,
b: String,
},
Topo,
}
#[derive(Subcommand)]
enum HistoryCommands {
List,
Save {
#[arg(short, long)]
message: Option<String>,
},
Diff {
version: String,
},
Restore {
version: String,
#[arg(short, long)]
force: bool,
},
}
#[derive(Subcommand)]
enum RefactorCommands {
Rename {
old: String,
new: String,
#[arg(long)]
apply: bool,
},
Merge {
a: String,
b: String,
new_id: String,
#[arg(long)]
apply: bool,
},
Split {
node: String,
#[arg(short, long, value_delimiter = ',')]
into: Vec<String>,
#[arg(long)]
apply: bool,
},
Extract {
#[arg(short, long, value_delimiter = ',')]
nodes: Vec<String>,
#[arg(short, long)]
parent: String,
#[arg(short, long)]
title: String,
#[arg(long)]
apply: bool,
},
}
fn main() -> Result<()> {
let cli = Cli::parse();
match cli.command {
Commands::Init { name, desc } => cmd_init(name, desc, cli.json),
Commands::Read => cmd_read(resolve_graph_path(cli.graph)?, cli.json),
Commands::Validate => cmd_validate(resolve_graph_path(cli.graph)?, cli.json),
Commands::Tasks { status, ready } => cmd_tasks(resolve_graph_path(cli.graph)?, status, ready, cli.json),
Commands::TaskUpdate { id, status } => cmd_task_update(resolve_graph_path(cli.graph)?, &id, &status, cli.json),
Commands::Complete { id } => cmd_complete(resolve_graph_path(cli.graph)?, &id, cli.json),
Commands::AddNode { id, title, desc, status, tags, node_type } => {
cmd_add_node(resolve_graph_path(cli.graph)?, &id, &title, desc, status, tags, node_type, cli.json)
}
Commands::RemoveNode { id } => cmd_remove_node(resolve_graph_path(cli.graph)?, &id, cli.json),
Commands::AddEdge { from, to, relation } => {
cmd_add_edge(resolve_graph_path(cli.graph)?, &from, &to, &relation, cli.json)
}
Commands::RemoveEdge { from, to, relation } => {
cmd_remove_edge(resolve_graph_path(cli.graph)?, &from, &to, relation.as_deref(), cli.json)
}
Commands::Query(qc) => match qc {
QueryCommands::Impact { node } => cmd_query_impact(resolve_graph_path(cli.graph)?, &node, cli.json),
QueryCommands::Deps { node, transitive } => {
cmd_query_deps(resolve_graph_path(cli.graph)?, &node, transitive, cli.json)
}
QueryCommands::Path { from, to } => cmd_query_path(resolve_graph_path(cli.graph)?, &from, &to, cli.json),
QueryCommands::CommonCause { a, b } => cmd_query_common(resolve_graph_path(cli.graph)?, &a, &b, cli.json),
QueryCommands::Topo => cmd_query_topo(resolve_graph_path(cli.graph)?, cli.json),
},
Commands::EditGraph { operations } => cmd_edit_graph(resolve_graph_path(cli.graph)?, &operations, cli.json),
Commands::Extract { dir, format, output } => cmd_extract(&dir, &format, output.as_deref(), cli.json),
Commands::Analyze { file, callers, callees, impact } => cmd_analyze(&file, callers, callees, impact, cli.json),
Commands::CodeSearch { keywords, dir, format_llm } => cmd_code_search(&dir, &keywords, format_llm, cli.json),
Commands::CodeFailures { changed, p2p, f2p, dir } => cmd_code_failures(&dir, &changed, p2p.as_deref(), f2p.as_deref(), cli.json),
Commands::CodeSymptoms { problem, tests, dir } => cmd_code_symptoms(&dir, &problem, &tests, cli.json),
Commands::CodeTrace { symptoms, depth, max_chains, dir } => cmd_code_trace(&dir, &symptoms, depth, max_chains, cli.json),
Commands::CodeComplexity { nodes, dir } => cmd_code_complexity(&dir, &nodes, cli.json),
Commands::CodeImpact { files, dir } => cmd_code_impact(&dir, &files, cli.json),
Commands::CodeSnippets { keywords, max_lines, dir } => cmd_code_snippets(&dir, &keywords, max_lines, cli.json),
Commands::Schema { dir } => cmd_schema(&dir, cli.json),
Commands::FileSummary { file, dir } => cmd_file_summary(&dir, &file, cli.json),
Commands::History(hc) => {
let graph_path = resolve_graph_path(cli.graph)?;
let gid_dir = graph_path.parent().unwrap_or(std::path::Path::new("."));
match hc {
HistoryCommands::List => cmd_history_list(gid_dir, cli.json),
HistoryCommands::Save { message } => cmd_history_save(&graph_path, gid_dir, message.as_deref(), cli.json),
HistoryCommands::Diff { version } => cmd_history_diff(&graph_path, gid_dir, &version, cli.json),
HistoryCommands::Restore { version, force } => cmd_history_restore(&graph_path, gid_dir, &version, force, cli.json),
}
}
Commands::Visual { format, output } => cmd_visual(resolve_graph_path(cli.graph)?, &format, output.as_deref(), cli.json),
Commands::Advise { errors_only } => cmd_advise(resolve_graph_path(cli.graph)?, errors_only, cli.json),
Commands::Design { requirements, parse } => cmd_design(requirements, parse, cli.graph, cli.json),
Commands::Semantify { heuristic, parse } => cmd_semantify(resolve_graph_path(cli.graph)?, heuristic, parse, cli.json),
Commands::Refactor(rc) => match rc {
RefactorCommands::Rename { old, new, apply } => {
cmd_refactor_rename(resolve_graph_path(cli.graph)?, &old, &new, apply, cli.json)
}
RefactorCommands::Merge { a, b, new_id, apply } => {
cmd_refactor_merge(resolve_graph_path(cli.graph)?, &a, &b, &new_id, apply, cli.json)
}
RefactorCommands::Split { node, into, apply } => {
cmd_refactor_split(resolve_graph_path(cli.graph)?, &node, &into, apply, cli.json)
}
RefactorCommands::Extract { nodes, parent, title, apply } => {
cmd_refactor_extract(resolve_graph_path(cli.graph)?, &nodes, &parent, &title, apply, cli.json)
}
},
}
}
fn resolve_graph_path(provided: Option<PathBuf>) -> Result<PathBuf> {
if let Some(p) = provided {
return Ok(p);
}
let cwd = std::env::current_dir()?;
find_graph_file(&cwd).context(
"No graph file found. Use --graph <path> or run 'gid init' to create one."
)
}
fn cmd_init(name: Option<String>, desc: Option<String>, json: bool) -> Result<()> {
let path = PathBuf::from(".gid/graph.yml");
if path.exists() {
bail!("Graph file already exists: {}", path.display());
}
let project_name = name.unwrap_or_else(|| {
std::env::current_dir()
.ok()
.and_then(|p| p.file_name().map(|s| s.to_string_lossy().to_string()))
.unwrap_or_else(|| "project".to_string())
});
let graph = Graph {
project: Some(gid_core::ProjectMeta {
name: project_name.clone(),
description: desc,
}),
nodes: Vec::new(),
edges: Vec::new(),
};
save_graph(&graph, &path)?;
if json {
println!("{}", serde_json::json!({
"success": true,
"path": path.display().to_string(),
"project": project_name
}));
} else {
println!("✓ Created {}", path.display());
println!(" Project: {}", project_name);
}
Ok(())
}
fn cmd_read(path: PathBuf, json: bool) -> Result<()> {
let graph = load_graph(&path)?;
if json {
println!("{}", serde_json::to_string_pretty(&graph)?);
} else {
let yaml = serde_yaml::to_string(&graph)?;
print!("{}", yaml);
}
Ok(())
}
fn cmd_validate(path: PathBuf, json: bool) -> Result<()> {
let graph = load_graph(&path)?;
let validator = Validator::new(&graph);
let result = validator.validate();
if json {
println!("{}", serde_json::json!({
"valid": result.is_valid(),
"issues": result.issue_count(),
"orphan_nodes": result.orphan_nodes,
"missing_refs": result.missing_refs.iter().map(|r| {
serde_json::json!({"from": r.edge_from, "to": r.edge_to, "missing": r.missing_node})
}).collect::<Vec<_>>(),
"cycles": result.cycles,
"duplicate_nodes": result.duplicate_nodes,
}));
} else {
println!("{}", result);
}
if !result.is_valid() {
std::process::exit(1);
}
Ok(())
}
fn cmd_tasks(path: PathBuf, status_filter: Option<String>, ready_only: bool, json: bool) -> Result<()> {
let graph = load_graph(&path)?;
let tasks: Vec<&Node> = if ready_only {
graph.ready_tasks()
} else if let Some(status_str) = &status_filter {
let status: NodeStatus = status_str.parse()?;
graph.tasks_by_status(&status)
} else {
graph.nodes.iter().collect()
};
if json {
let tasks_json: Vec<_> = tasks.iter().map(|t| {
serde_json::json!({
"id": t.id,
"title": t.title,
"status": t.status.to_string(),
"tags": t.tags,
"description": t.description,
})
}).collect();
let summary = graph.summary();
println!("{}", serde_json::json!({
"tasks": tasks_json,
"summary": {
"total": summary.total_nodes,
"todo": summary.todo,
"in_progress": summary.in_progress,
"done": summary.done,
"blocked": summary.blocked,
"ready": summary.ready,
}
}));
} else {
if tasks.is_empty() {
println!("No tasks found.");
} else {
for task in &tasks {
let tags = if task.tags.is_empty() {
String::new()
} else {
format!(" [{}]", task.tags.join(", "))
};
println!("{} {} — {}{}", status_icon(&task.status), task.id, task.title, tags);
}
}
let summary = graph.summary();
println!("\n{}", summary);
}
Ok(())
}
fn cmd_task_update(path: PathBuf, id: &str, status_str: &str, json: bool) -> Result<()> {
let mut graph = load_graph(&path)?;
let status: NodeStatus = status_str.parse()?;
if !graph.update_status(id, status.clone()) {
bail!("Node not found: {}", id);
}
save_graph(&graph, &path)?;
if json {
println!("{}", serde_json::json!({
"success": true,
"id": id,
"status": status.to_string()
}));
} else {
println!("✓ Updated {} to {}", id, status);
}
Ok(())
}
fn cmd_complete(path: PathBuf, id: &str, json: bool) -> Result<()> {
let mut graph = load_graph(&path)?;
if graph.get_node(id).is_none() {
bail!("Node not found: {}", id);
}
let ready_before: std::collections::HashSet<String> = graph
.ready_tasks()
.iter()
.map(|n| n.id.clone())
.collect();
graph.update_status(id, NodeStatus::Done);
save_graph(&graph, &path)?;
let ready_after: std::collections::HashSet<String> = graph
.ready_tasks()
.iter()
.map(|n| n.id.clone())
.collect();
let newly_unblocked: Vec<&String> = ready_after.difference(&ready_before).collect();
if json {
println!("{}", serde_json::json!({
"success": true,
"id": id,
"newly_unblocked": newly_unblocked
}));
} else {
println!("✓ Completed: {}", id);
if !newly_unblocked.is_empty() {
println!("\n🔓 Newly unblocked tasks:");
for task_id in newly_unblocked {
if let Some(task) = graph.get_node(task_id) {
println!(" {} — {}", task.id, task.title);
}
}
}
}
Ok(())
}
fn cmd_add_node(
path: PathBuf,
id: &str,
title: &str,
desc: Option<String>,
status: Option<String>,
tags: Option<String>,
node_type: Option<String>,
json: bool,
) -> Result<()> {
let mut graph = load_graph(&path)?;
if graph.get_node(id).is_some() {
bail!("Node already exists: {}", id);
}
let mut node = Node::new(id, title);
if let Some(d) = desc {
node.description = Some(d);
}
if let Some(s) = status {
node.status = s.parse()?;
}
if let Some(t) = tags {
node.tags = t.split(',').map(|s| s.trim().to_string()).collect();
}
if let Some(nt) = node_type {
node.node_type = Some(nt);
}
graph.add_node(node);
save_graph(&graph, &path)?;
if json {
println!("{}", serde_json::json!({"success": true, "id": id}));
} else {
println!("✓ Added node: {}", id);
}
Ok(())
}
fn cmd_remove_node(path: PathBuf, id: &str, json: bool) -> Result<()> {
let mut graph = load_graph(&path)?;
if graph.remove_node(id).is_none() {
bail!("Node not found: {}", id);
}
save_graph(&graph, &path)?;
if json {
println!("{}", serde_json::json!({"success": true, "id": id}));
} else {
println!("✓ Removed node: {} (and associated edges)", id);
}
Ok(())
}
fn cmd_add_edge(path: PathBuf, from: &str, to: &str, relation: &str, json: bool) -> Result<()> {
let mut graph = load_graph(&path)?;
if graph.get_node(from).is_none() {
bail!("Source node not found: {}", from);
}
if graph.get_node(to).is_none() {
bail!("Target node not found: {}", to);
}
if relation == "depends_on" {
let validator = Validator::new(&graph);
if validator.would_create_cycle(from, to) {
bail!("Adding this edge would create a cycle");
}
}
graph.add_edge(Edge::new(from, to, relation));
save_graph(&graph, &path)?;
if json {
println!("{}", serde_json::json!({"success": true, "from": from, "to": to, "relation": relation}));
} else {
println!("✓ Added edge: {} → {} ({})", from, to, relation);
}
Ok(())
}
fn cmd_remove_edge(path: PathBuf, from: &str, to: &str, relation: Option<&str>, json: bool) -> Result<()> {
let mut graph = load_graph(&path)?;
let before = graph.edges.len();
graph.remove_edge(from, to, relation);
let after = graph.edges.len();
if before == after {
bail!("No matching edge found: {} → {}", from, to);
}
save_graph(&graph, &path)?;
let removed = before - after;
if json {
println!("{}", serde_json::json!({"success": true, "removed": removed}));
} else {
println!("✓ Removed {} edge(s) from {} → {}", removed, from, to);
}
Ok(())
}
fn cmd_query_impact(path: PathBuf, node: &str, json: bool) -> Result<()> {
let graph = load_graph(&path)?;
if graph.get_node(node).is_none() {
bail!("Node not found: {}", node);
}
let engine = QueryEngine::new(&graph);
let impacted = engine.impact(node);
if json {
let nodes: Vec<_> = impacted.iter().map(|n| serde_json::json!({"id": n.id, "title": n.title})).collect();
println!("{}", serde_json::json!({"node": node, "impacted": nodes}));
} else {
if impacted.is_empty() {
println!("No nodes would be affected by changes to '{}'", node);
} else {
println!("Changes to '{}' would affect {} node(s):", node, impacted.len());
for n in impacted {
println!(" {} — {}", n.id, n.title);
}
}
}
Ok(())
}
fn cmd_query_deps(path: PathBuf, node: &str, transitive: bool, json: bool) -> Result<()> {
let graph = load_graph(&path)?;
if graph.get_node(node).is_none() {
bail!("Node not found: {}", node);
}
let engine = QueryEngine::new(&graph);
let deps = engine.deps(node, transitive);
if json {
let nodes: Vec<_> = deps.iter().map(|n| serde_json::json!({
"id": n.id, "title": n.title, "status": n.status.to_string()
})).collect();
println!("{}", serde_json::json!({"node": node, "transitive": transitive, "dependencies": nodes}));
} else {
let label = if transitive { "Transitive" } else { "Direct" };
if deps.is_empty() {
println!("'{}' has no {} dependencies", node, label.to_lowercase());
} else {
println!("{} dependencies of '{}' ({}):", label, node, deps.len());
for n in deps {
println!(" {} {} — {}", status_icon(&n.status), n.id, n.title);
}
}
}
Ok(())
}
fn cmd_query_path(path: PathBuf, from: &str, to: &str, json: bool) -> Result<()> {
let graph = load_graph(&path)?;
if graph.get_node(from).is_none() {
bail!("Node not found: {}", from);
}
if graph.get_node(to).is_none() {
bail!("Node not found: {}", to);
}
let engine = QueryEngine::new(&graph);
let result = engine.path(from, to);
if json {
println!("{}", serde_json::json!({"from": from, "to": to, "path": result}));
} else {
match result {
Some(p) => {
println!("Path from '{}' to '{}' ({} hops):", from, to, p.len() - 1);
println!(" {}", p.join(" → "));
}
None => {
println!("No path found between '{}' and '{}'", from, to);
}
}
}
Ok(())
}
fn cmd_query_common(path: PathBuf, a: &str, b: &str, json: bool) -> Result<()> {
let graph = load_graph(&path)?;
if graph.get_node(a).is_none() {
bail!("Node not found: {}", a);
}
if graph.get_node(b).is_none() {
bail!("Node not found: {}", b);
}
let engine = QueryEngine::new(&graph);
let common = engine.common_cause(a, b);
if json {
let nodes: Vec<_> = common.iter().map(|n| serde_json::json!({"id": n.id, "title": n.title})).collect();
println!("{}", serde_json::json!({"a": a, "b": b, "common": nodes}));
} else {
if common.is_empty() {
println!("'{}' and '{}' have no common dependencies", a, b);
} else {
println!("Common dependencies of '{}' and '{}' ({}):", a, b, common.len());
for n in common {
println!(" {} — {}", n.id, n.title);
}
}
}
Ok(())
}
fn cmd_query_topo(path: PathBuf, json: bool) -> Result<()> {
let graph = load_graph(&path)?;
let engine = QueryEngine::new(&graph);
match engine.topological_sort() {
Ok(order) => {
if json {
println!("{}", serde_json::json!({"order": order}));
} else {
println!("Topological order ({} nodes):", order.len());
for (i, id) in order.iter().enumerate() {
if let Some(node) = graph.get_node(id) {
println!(" {}. {} — {}", i + 1, id, node.title);
} else {
println!(" {}. {}", i + 1, id);
}
}
}
}
Err(e) => {
if json {
println!("{}", serde_json::json!({"error": e.to_string()}));
} else {
println!("Cannot produce topological order: {}", e);
}
std::process::exit(1);
}
}
Ok(())
}
fn cmd_edit_graph(path: PathBuf, operations_json: &str, json: bool) -> Result<()> {
let mut graph = load_graph(&path)?;
let ops: Vec<serde_json::Value> = serde_json::from_str(operations_json)
.context("Invalid JSON. Expected an array of operations.")?;
let mut applied = 0;
for op in ops {
let op_type = op.get("op").and_then(|v| v.as_str()).unwrap_or("");
match op_type {
"add_node" => {
let id = op.get("id").and_then(|v| v.as_str()).context("add_node: missing 'id'")?;
let title = op.get("title").and_then(|v| v.as_str()).context("add_node: missing 'title'")?;
if graph.get_node(id).is_none() {
let mut node = Node::new(id, title);
if let Some(d) = op.get("description").and_then(|v| v.as_str()) {
node.description = Some(d.to_string());
}
if let Some(s) = op.get("status").and_then(|v| v.as_str()) {
node.status = s.parse().unwrap_or(NodeStatus::Todo);
}
if let Some(arr) = op.get("tags").and_then(|v| v.as_array()) {
node.tags = arr.iter().filter_map(|v| v.as_str().map(String::from)).collect();
}
graph.add_node(node);
applied += 1;
}
}
"remove_node" => {
let id = op.get("id").and_then(|v| v.as_str()).context("remove_node: missing 'id'")?;
if graph.remove_node(id).is_some() {
applied += 1;
}
}
"add_edge" => {
let from = op.get("from").and_then(|v| v.as_str()).context("add_edge: missing 'from'")?;
let to = op.get("to").and_then(|v| v.as_str()).context("add_edge: missing 'to'")?;
let relation = op.get("relation").and_then(|v| v.as_str()).unwrap_or("depends_on");
graph.add_edge(Edge::new(from, to, relation));
applied += 1;
}
"remove_edge" => {
let from = op.get("from").and_then(|v| v.as_str()).context("remove_edge: missing 'from'")?;
let to = op.get("to").and_then(|v| v.as_str()).context("remove_edge: missing 'to'")?;
let relation = op.get("relation").and_then(|v| v.as_str());
let before = graph.edges.len();
graph.remove_edge(from, to, relation);
if graph.edges.len() < before {
applied += 1;
}
}
"update_status" => {
let id = op.get("id").and_then(|v| v.as_str()).context("update_status: missing 'id'")?;
let status = op.get("status").and_then(|v| v.as_str()).context("update_status: missing 'status'")?;
if let Ok(s) = status.parse() {
if graph.update_status(id, s) {
applied += 1;
}
}
}
other => {
if !json {
println!("⚠ Unknown operation: {}", other);
}
}
}
}
save_graph(&graph, &path)?;
if json {
println!("{}", serde_json::json!({"success": true, "applied": applied}));
} else {
println!("✓ Applied {} operation(s)", applied);
}
Ok(())
}
fn cmd_extract(dir: &PathBuf, format: &str, output: Option<&std::path::Path>, json_flag: bool) -> Result<()> {
let dir = if dir.is_absolute() {
dir.clone()
} else {
std::env::current_dir()?.join(dir)
};
if !dir.exists() {
bail!("Directory not found: {}", dir.display());
}
if !json_flag {
eprintln!("Extracting code graph from {}...", dir.display());
}
let code_graph = CodeGraph::extract_from_dir(&dir);
let existing_graph = if let Some(out_path) = output {
if out_path.exists() {
load_graph(out_path).ok()
} else {
None
}
} else {
None
};
let task_graph = existing_graph.unwrap_or_else(Graph::default);
let unified = build_unified_graph(&code_graph, &task_graph);
let output_str = match format {
"yaml" | "yml" => serde_yaml::to_string(&unified)?,
"json" => serde_json::to_string_pretty(&unified)?,
"summary" | _ => {
if json_flag {
serde_json::to_string_pretty(&unified)?
} else {
let file_count = unified.nodes.iter()
.filter(|n| n.node_type.as_deref() == Some("file"))
.count();
let class_count = unified.nodes.iter()
.filter(|n| n.node_type.as_deref() == Some("class"))
.count();
let func_count = unified.nodes.iter()
.filter(|n| n.node_type.as_deref() == Some("function"))
.count();
let task_count = unified.nodes.iter()
.filter(|n| n.node_type.is_none() ||
!["file", "class", "function", "module"].contains(&n.node_type.as_deref().unwrap_or("")))
.count();
let import_count = unified.edges.iter()
.filter(|e| e.relation == "imports")
.count();
let call_count = unified.edges.iter()
.filter(|e| e.relation == "calls")
.count();
let mut s = format!(
"Code Graph Summary\n{}\n\n",
"=".repeat(50)
);
s.push_str(&format!("📊 {} files, {} classes/structs, {} functions\n",
file_count, class_count, func_count));
if task_count > 0 {
s.push_str(&format!("📋 {} task nodes (preserved from existing graph)\n", task_count));
}
s.push_str(&format!("🔗 {} edges ({} imports, {} calls)\n\n",
unified.edges.len(), import_count, call_count));
let mut file_entities: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
for node in &unified.nodes {
if let Some(file_path) = node.metadata.get("file_path").and_then(|v| v.as_str()) {
if node.node_type.as_deref() != Some("file") {
*file_entities.entry(file_path.to_string()).or_default() += 1;
}
}
}
let mut files: Vec<_> = file_entities.into_iter().collect();
files.sort_by(|a, b| b.1.cmp(&a.1));
s.push_str("Top files by entity count:\n");
for (file, count) in files.iter().take(10) {
s.push_str(&format!(" 📄 {} ({} entities)\n", file, count));
}
if files.len() > 10 {
s.push_str(&format!(" ... and {} more files\n", files.len() - 10));
}
s
}
}
};
if let Some(out_path) = output {
std::fs::write(out_path, &output_str)?;
if !json_flag {
println!("✓ Wrote unified graph to {}", out_path.display());
}
} else {
print!("{}", output_str);
}
Ok(())
}
fn cmd_analyze(file: &PathBuf, show_callers: bool, show_callees: bool, show_impact: bool, json_flag: bool) -> Result<()> {
let project_root = find_project_root(file)?;
if !json_flag {
eprintln!("Analyzing {} (project root: {})...", file.display(), project_root.display());
}
let graph = CodeGraph::extract_from_dir(&project_root);
let abs_file = if file.is_absolute() {
file.clone()
} else {
std::env::current_dir()?.join(file)
};
let rel_path = abs_file.strip_prefix(&project_root)
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|_| file.to_string_lossy().to_string());
let file_nodes: Vec<&CodeNode> = graph.nodes.iter()
.filter(|n| n.file_path == rel_path && n.kind != NodeKind::File)
.collect();
if json_flag {
let mut result = serde_json::json!({
"file": rel_path,
"entities": file_nodes.len(),
"nodes": file_nodes.iter().map(|n| serde_json::json!({
"id": n.id,
"name": n.name,
"kind": format!("{:?}", n.kind),
"line": n.line,
})).collect::<Vec<_>>()
});
if show_callers {
let callers_map: std::collections::HashMap<_, _> = file_nodes.iter().map(|n| {
let callers = graph.get_callers(&n.id);
(n.id.clone(), callers.iter().map(|c| serde_json::json!({
"name": c.name,
"file": c.file_path
})).collect::<Vec<_>>())
}).collect();
result["callers"] = serde_json::json!(callers_map);
}
println!("{}", serde_json::to_string_pretty(&result)?);
return Ok(());
}
if file_nodes.is_empty() {
println!("No code entities found in {}", rel_path);
return Ok(());
}
println!("📄 {} — {} entities\n", rel_path, file_nodes.len());
for node in &file_nodes {
let icon = match node.kind {
NodeKind::Class => "🔷",
NodeKind::Function => "🔹",
_ => "📦",
};
let line_info = node.line.map(|l| format!(":L{}", l)).unwrap_or_default();
println!("{} {}{}", icon, node.name, line_info);
if show_callers {
let callers = graph.get_callers(&node.id);
if !callers.is_empty() {
println!(" ↑ Callers ({}):", callers.len());
for caller in callers.iter().take(5) {
println!(" {} ({})", caller.name, caller.file_path);
}
if callers.len() > 5 {
println!(" ... and {} more", callers.len() - 5);
}
}
}
if show_callees {
let callees = graph.get_callees(&node.id);
if !callees.is_empty() {
println!(" ↓ Callees ({}):", callees.len());
for callee in callees.iter().take(5) {
println!(" {} ({})", callee.name, callee.file_path);
}
if callees.len() > 5 {
println!(" ... and {} more", callees.len() - 5);
}
}
}
println!();
}
if show_impact {
println!("\n{}\n", "=".repeat(50));
let analysis = analyze_impact(&[rel_path.clone()], &graph);
print!("{}", format_impact_for_llm(&analysis));
}
Ok(())
}
fn find_project_root(file: &PathBuf) -> Result<PathBuf> {
let abs_file = if file.is_absolute() {
file.clone()
} else {
std::env::current_dir()?.join(file)
};
let start = abs_file.parent().unwrap_or(&abs_file);
let mut current = start;
loop {
if current.join(".git").exists() {
return Ok(current.to_path_buf());
}
match current.parent() {
Some(parent) => current = parent,
None => break,
}
}
let markers = ["Cargo.toml", "package.json", "pyproject.toml", "setup.py"];
current = start;
let mut found = None;
loop {
for marker in &markers {
if current.join(marker).exists() {
found = Some(current.to_path_buf());
}
}
match current.parent() {
Some(parent) => current = parent,
None => break,
}
}
found.or_else(|| std::env::current_dir().ok()).context("Could not find project root")
}
fn cmd_history_list(gid_dir: &std::path::Path, json: bool) -> Result<()> {
let mgr = HistoryManager::new(gid_dir);
let entries = mgr.list_snapshots()?;
if json {
println!("{}", serde_json::to_string_pretty(&entries)?);
} else {
if entries.is_empty() {
println!("No history entries found.");
println!("Run `gid history save` to create a snapshot.");
} else {
println!("\n📜 Graph History");
println!("{}", "═".repeat(60));
for (i, entry) in entries.iter().enumerate() {
let latest = if i == 0 { " (latest)" } else { "" };
println!("\n {}{}", entry.filename, latest);
println!(" {} | {} nodes, {} edges",
entry.timestamp, entry.node_count, entry.edge_count);
if let Some(ref msg) = entry.message {
println!(" Message: {}", msg);
}
}
println!("\nUse `gid history diff <version>` to compare.");
println!("Use `gid history restore <version>` to restore.");
}
}
Ok(())
}
fn cmd_history_save(graph_path: &PathBuf, gid_dir: &std::path::Path, message: Option<&str>, json: bool) -> Result<()> {
let graph = load_graph(graph_path)?;
let mgr = HistoryManager::new(gid_dir);
let filename = mgr.save_snapshot(&graph, message)?;
if json {
println!("{}", serde_json::json!({"success": true, "filename": filename}));
} else {
println!("✓ Saved snapshot: {}", filename);
}
Ok(())
}
fn cmd_history_diff(graph_path: &PathBuf, gid_dir: &std::path::Path, version: &str, json: bool) -> Result<()> {
let current = load_graph(graph_path)?;
let mgr = HistoryManager::new(gid_dir);
let diff = mgr.diff_against(version, ¤t)?;
if json {
println!("{}", serde_json::to_string_pretty(&diff)?);
} else {
println!("\n📊 Comparing {} → current\n", version);
println!("{}", diff);
}
Ok(())
}
fn cmd_history_restore(graph_path: &PathBuf, gid_dir: &std::path::Path, version: &str, force: bool, json: bool) -> Result<()> {
if !force && !json {
println!("Warning: This will overwrite the current graph.");
println!("Use --force to confirm.");
return Ok(());
}
let mgr = HistoryManager::new(gid_dir);
mgr.restore(version, graph_path)?;
if json {
println!("{}", serde_json::json!({"success": true, "restored": version}));
} else {
println!("✓ Restored graph from {}", version);
}
Ok(())
}
fn cmd_visual(path: PathBuf, format: &str, output: Option<&std::path::Path>, json: bool) -> Result<()> {
let graph = load_graph(&path)?;
let fmt: VisualFormat = format.parse()?;
let result = render(&graph, fmt);
if let Some(out_path) = output {
std::fs::write(out_path, &result)?;
if json {
println!("{}", serde_json::json!({"success": true, "output": out_path.display().to_string()}));
} else {
println!("✓ Wrote visualization to {}", out_path.display());
}
} else {
if json && fmt == VisualFormat::Ascii {
println!("{}", serde_json::json!({"format": format, "output": result}));
} else {
print!("{}", result);
}
}
Ok(())
}
fn cmd_advise(path: PathBuf, errors_only: bool, json: bool) -> Result<()> {
let graph = load_graph(&path)?;
let mut result = advise_analyze(&graph);
if errors_only {
result.items.retain(|a| a.severity == gid_core::Severity::Error);
}
if json {
println!("{}", serde_json::to_string_pretty(&result)?);
} else {
println!("{}", result);
}
if !result.passed {
std::process::exit(1);
}
Ok(())
}
fn cmd_design(requirements: Option<String>, parse: bool, graph_path: Option<PathBuf>, json: bool) -> Result<()> {
if parse {
let mut response = String::new();
io::stdin().read_to_string(&mut response)?;
let graph = parse_llm_response(&response)?;
if let Some(path) = graph_path {
save_graph(&graph, &path)?;
if json {
println!("{}", serde_json::json!({"success": true, "path": path.display().to_string()}));
} else {
println!("✓ Saved graph to {}", path.display());
}
} else {
if json {
println!("{}", serde_json::to_string_pretty(&graph)?);
} else {
println!("{}", serde_yaml::to_string(&graph)?);
}
}
} else {
let reqs = match requirements {
Some(r) => r,
None => {
let mut s = String::new();
eprintln!("Enter requirements (Ctrl+D to finish):");
io::stdin().read_to_string(&mut s)?;
s
}
};
let prompt = generate_graph_prompt(&reqs);
if json {
println!("{}", serde_json::json!({"prompt": prompt}));
} else {
println!("{}", prompt);
}
}
Ok(())
}
fn cmd_semantify(path: PathBuf, heuristic: bool, parse: bool, json: bool) -> Result<()> {
let mut graph = load_graph(&path)?;
if heuristic {
let assigned = apply_heuristic_layers(&mut graph);
save_graph(&graph, &path)?;
if json {
println!("{}", serde_json::json!({"success": true, "assigned": assigned}));
} else {
println!("✓ Assigned layers to {} nodes using heuristics", assigned);
}
} else if parse {
let mut response = String::new();
io::stdin().read_to_string(&mut response)?;
let result = gid_core::parse_semantify_response(&response)?;
let applied = gid_core::apply_proposals(&mut graph, &result.proposals);
save_graph(&graph, &path)?;
if json {
println!("{}", serde_json::json!({"success": true, "applied": applied}));
} else {
println!("✓ Applied {} semantic upgrades", applied);
}
} else {
let prompt = generate_semantify_prompt(&graph);
if json {
println!("{}", serde_json::json!({"prompt": prompt}));
} else {
println!("{}", prompt);
}
}
Ok(())
}
fn cmd_refactor_rename(path: PathBuf, old: &str, new: &str, apply: bool, json: bool) -> Result<()> {
let mut graph = load_graph(&path)?;
if let Some(preview) = preview_rename(&graph, old, new) {
if apply {
if apply_rename(&mut graph, old, new) {
save_graph(&graph, &path)?;
if json {
println!("{}", serde_json::json!({"success": true, "renamed": {"from": old, "to": new}}));
} else {
println!("✓ Renamed {} to {}", old, new);
}
} else {
bail!("Failed to apply rename");
}
} else {
if json {
println!("{}", serde_json::to_string_pretty(&preview)?);
} else {
println!("{}", preview);
println!("\nUse --apply to execute these changes.");
}
}
} else {
bail!("Node not found: {}", old);
}
Ok(())
}
fn cmd_refactor_merge(path: PathBuf, a: &str, b: &str, new_id: &str, apply: bool, json: bool) -> Result<()> {
let mut graph = load_graph(&path)?;
if let Some(preview) = preview_merge(&graph, a, b, new_id) {
if apply {
if apply_merge(&mut graph, a, b, new_id) {
save_graph(&graph, &path)?;
if json {
println!("{}", serde_json::json!({"success": true, "merged": {"a": a, "b": b, "new_id": new_id}}));
} else {
println!("✓ Merged {} and {} into {}", a, b, new_id);
}
} else {
bail!("Failed to apply merge");
}
} else {
if json {
println!("{}", serde_json::to_string_pretty(&preview)?);
} else {
println!("{}", preview);
println!("\nUse --apply to execute these changes.");
}
}
} else {
bail!("One or both nodes not found: {}, {}", a, b);
}
Ok(())
}
fn cmd_refactor_split(path: PathBuf, node: &str, into: &[String], apply: bool, json: bool) -> Result<()> {
let mut graph = load_graph(&path)?;
let splits: Vec<SplitDefinition> = into.iter().map(|id| SplitDefinition {
id: id.clone(),
title: id.clone(),
description: None,
tags: vec![],
}).collect();
if let Some(preview) = preview_split(&graph, node, &splits) {
if apply {
let created = apply_split(&mut graph, node, &splits);
if !created.is_empty() {
save_graph(&graph, &path)?;
if json {
println!("{}", serde_json::json!({"success": true, "created": created}));
} else {
println!("✓ Split {} into: {}", node, created.join(", "));
}
} else {
bail!("Failed to apply split");
}
} else {
if json {
println!("{}", serde_json::to_string_pretty(&preview)?);
} else {
println!("{}", preview);
println!("\nUse --apply to execute these changes.");
}
}
} else {
bail!("Node not found: {}", node);
}
Ok(())
}
fn cmd_refactor_extract(path: PathBuf, nodes: &[String], parent: &str, title: &str, apply: bool, json: bool) -> Result<()> {
let mut graph = load_graph(&path)?;
if let Some(preview) = preview_extract(&graph, nodes, parent, title) {
if apply {
if apply_extract(&mut graph, nodes, parent, title) {
save_graph(&graph, &path)?;
if json {
println!("{}", serde_json::json!({"success": true, "parent": parent, "extracted": nodes}));
} else {
println!("✓ Extracted {} nodes into '{}'", nodes.len(), parent);
}
} else {
bail!("Failed to apply extract");
}
} else {
if json {
println!("{}", serde_json::to_string_pretty(&preview)?);
} else {
println!("{}", preview);
println!("\nUse --apply to execute these changes.");
}
}
} else {
bail!("One or more nodes not found");
}
Ok(())
}
fn resolve_dir(dir: &PathBuf) -> Result<PathBuf> {
let d = if dir.is_absolute() {
dir.clone()
} else {
std::env::current_dir()?.join(dir)
};
if !d.exists() {
bail!("Directory not found: {}", d.display());
}
Ok(d)
}
fn cmd_code_search(dir: &PathBuf, keywords_str: &str, format_llm: Option<usize>, json: bool) -> Result<()> {
let dir = resolve_dir(dir)?;
let graph = CodeGraph::extract_from_dir(&dir);
let keywords: Vec<&str> = keywords_str.split(',').map(|s| s.trim()).collect();
if let Some(max_chars) = format_llm {
let output = graph.format_for_llm(&keywords, max_chars);
if json {
println!("{}", serde_json::json!({"formatted": output}));
} else {
print!("{}", output);
}
} else {
let nodes = graph.find_relevant_nodes(&keywords);
if json {
let items: Vec<_> = nodes.iter().map(|n| serde_json::json!({
"id": n.id, "name": n.name, "kind": format!("{:?}", n.kind),
"file": n.file_path, "line": n.line,
})).collect();
println!("{}", serde_json::to_string_pretty(&items)?);
} else {
if nodes.is_empty() {
println!("No relevant nodes found for: {}", keywords_str);
} else {
println!("Found {} relevant nodes:\n", nodes.len());
for n in nodes.iter().take(50) {
let icon = match n.kind {
NodeKind::File | NodeKind::Module => "📄",
NodeKind::Class => "🔷",
NodeKind::Function => "🔹",
};
let line = n.line.map(|l| format!(":L{}", l)).unwrap_or_default();
println!(" {} {} ({}{})", icon, n.name, n.file_path, line);
}
if nodes.len() > 50 {
println!(" ... and {} more", nodes.len() - 50);
}
}
}
}
Ok(())
}
fn cmd_code_failures(dir: &PathBuf, changed_str: &str, p2p: Option<&str>, f2p: Option<&str>, json: bool) -> Result<()> {
let dir = resolve_dir(dir)?;
let graph = CodeGraph::extract_from_dir(&dir);
let changed: Vec<&str> = changed_str.split(',').map(|s| s.trim()).collect();
let p2p_tests: Vec<String> = p2p.unwrap_or("").split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect();
let f2p_tests: Vec<String> = f2p.unwrap_or("").split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect();
let analysis = graph.trace_causal_chains(&changed, &p2p_tests, &f2p_tests);
if json {
println!("{}", serde_json::json!({"analysis": analysis}));
} else {
if analysis.is_empty() {
println!("No test failures to analyze.");
} else {
print!("{}", analysis);
}
}
Ok(())
}
fn cmd_code_symptoms(dir: &PathBuf, problem: &str, tests: &str, json: bool) -> Result<()> {
let dir = resolve_dir(dir)?;
let graph = CodeGraph::extract_from_dir(&dir);
let nodes = graph.find_symptom_nodes(problem, tests);
if json {
let items: Vec<_> = nodes.iter().map(|n| serde_json::json!({
"id": n.id, "name": n.name, "kind": format!("{:?}", n.kind),
"file": n.file_path, "line": n.line, "is_test": n.is_test,
})).collect();
println!("{}", serde_json::to_string_pretty(&items)?);
} else {
if nodes.is_empty() {
println!("No symptom nodes found.");
} else {
println!("Found {} symptom nodes:\n", nodes.len());
for n in &nodes {
let icon = if n.is_test { "🧪" } else { match n.kind {
NodeKind::Class => "🔷",
NodeKind::Function => "🔹",
NodeKind::File | NodeKind::Module => "📄",
}};
let line = n.line.map(|l| format!(":L{}", l)).unwrap_or_default();
println!(" {} {} ({}{})", icon, n.name, n.file_path, line);
}
}
}
Ok(())
}
fn cmd_code_trace(dir: &PathBuf, symptoms_str: &str, depth: usize, max_chains: usize, json: bool) -> Result<()> {
let dir = resolve_dir(dir)?;
let graph = CodeGraph::extract_from_dir(&dir);
let symptom_ids: Vec<&str> = symptoms_str.split(',').map(|s| s.trim()).collect();
let chains = graph.trace_causal_chains_from_symptoms(&symptom_ids, depth, max_chains);
if json {
let items: Vec<_> = chains.iter().map(|c| serde_json::json!({
"symptom": c.symptom_node_id,
"chain": c.chain.iter().map(|n| serde_json::json!({
"node_id": n.node_id, "name": n.node_name,
"file": n.file_path, "line": n.line,
"edge": n.edge_to_next,
})).collect::<Vec<_>>(),
})).collect();
println!("{}", serde_json::to_string_pretty(&items)?);
} else {
if chains.is_empty() {
println!("No causal chains found.");
} else {
println!("Found {} causal chains:\n", chains.len());
for (i, chain) in chains.iter().enumerate() {
println!("Chain {} (from {}):", i + 1, chain.symptom_node_id);
for (j, node) in chain.chain.iter().enumerate() {
let arrow = if let Some(ref edge) = node.edge_to_next {
format!(" --[{}]-->", edge)
} else {
String::new()
};
let line = node.line.map(|l| format!(":L{}", l)).unwrap_or_default();
println!(" {}. {} ({}{}){}", j + 1, node.node_name, node.file_path, line, arrow);
}
println!();
}
}
}
Ok(())
}
fn cmd_code_complexity(dir: &PathBuf, nodes_str: &str, json: bool) -> Result<()> {
let dir = resolve_dir(dir)?;
let graph = CodeGraph::extract_from_dir(&dir);
let keywords: Vec<&str> = nodes_str.split(',').map(|s| s.trim()).collect();
let node_ids: Vec<&str> = keywords.clone();
let report = assess_complexity_from_graph(&graph, &keywords, 0);
let risk = assess_risk_level(&graph, &node_ids);
if json {
println!("{}", serde_json::json!({
"complexity": format!("{:?}", report.complexity),
"relevant_nodes": report.relevant_nodes,
"relevant_files": report.relevant_files,
"risk_level": format!("{:?}", risk),
"summary": report.summary,
}));
} else {
println!("Complexity: {:?}", report.complexity);
println!("Risk level: {:?}", risk);
println!("Relevant: {} nodes across {} files", report.relevant_nodes, report.relevant_files);
println!("\n{}", report.summary);
}
Ok(())
}
fn cmd_code_impact(dir: &PathBuf, files_str: &str, json: bool) -> Result<()> {
let dir = resolve_dir(dir)?;
let graph = CodeGraph::extract_from_dir(&dir);
let files: Vec<String> = files_str.split(',').map(|s| s.trim().to_string()).collect();
let analysis = analyze_impact(&files, &graph);
let formatted = format_impact_for_llm(&analysis);
if json {
println!("{}", serde_json::json!({
"files_changed": files,
"risk_level": format!("{:?}", analysis.risk_level),
"affected_source": analysis.affected_source.len(),
"affected_tests": analysis.affected_tests.len(),
"formatted": formatted,
}));
} else {
print!("{}", formatted);
}
Ok(())
}
fn cmd_code_snippets(dir: &PathBuf, keywords_str: &str, max_lines: usize, json: bool) -> Result<()> {
let dir = resolve_dir(dir)?;
let graph = CodeGraph::extract_from_dir(&dir);
let keywords: Vec<&str> = keywords_str.split(',').map(|s| s.trim()).collect();
let relevant = graph.find_relevant_nodes(&keywords);
let snippets = graph.extract_snippets(&relevant, &dir, max_lines);
if json {
println!("{}", serde_json::to_string_pretty(&snippets)?);
} else {
if snippets.is_empty() {
println!("No snippets found for: {}", keywords_str);
} else {
for (node_id, snippet) in &snippets {
let name = graph.node_by_id(node_id).map(|n| n.name.as_str()).unwrap_or(node_id);
println!("━━━ {} ━━━", name);
println!("{}", snippet);
println!();
}
}
}
Ok(())
}
fn cmd_schema(dir: &PathBuf, json: bool) -> Result<()> {
let dir = if dir.is_absolute() {
dir.clone()
} else {
std::env::current_dir()?.join(dir)
};
if !dir.exists() {
bail!("Directory not found: {}", dir.display());
}
let graph = CodeGraph::extract_from_dir(&dir);
let schema = graph.get_schema();
if json {
println!("{}", serde_json::json!({"schema": schema}));
} else {
print!("{}", schema);
}
Ok(())
}
fn cmd_file_summary(dir: &PathBuf, file: &str, json: bool) -> Result<()> {
let dir = if dir.is_absolute() {
dir.clone()
} else {
std::env::current_dir()?.join(dir)
};
if !dir.exists() {
bail!("Directory not found: {}", dir.display());
}
let graph = CodeGraph::extract_from_dir(&dir);
let summary = graph.get_file_summary(file);
if json {
println!("{}", serde_json::json!({"file": file, "summary": summary}));
} else {
print!("{}", summary);
}
Ok(())
}
fn status_icon(status: &NodeStatus) -> &'static str {
match status {
NodeStatus::Todo => "○",
NodeStatus::InProgress => "◐",
NodeStatus::Done => "●",
NodeStatus::Blocked => "✗",
NodeStatus::Cancelled => "⊘",
}
}