Skip to main content

codesynapse_cli/
cli.rs

1use crate::ui;
2use clap::{CommandFactory, Parser, Subcommand};
3use codesynapse_core::analyze::Analyzer;
4use codesynapse_core::build::GraphBuilder;
5use codesynapse_core::cache::FileCache;
6use codesynapse_core::cluster::CommunityDetector;
7use codesynapse_core::config::{CodeSynapseConfig, LlmConfig};
8use codesynapse_core::detect::Detector;
9use codesynapse_core::diagnostics::{
10    diagnose_file, format_diagnostic_json, format_diagnostic_report,
11};
12use codesynapse_core::error::Result;
13use codesynapse_core::export::Exporter;
14use codesynapse_core::extract::Extractor;
15use codesynapse_core::global_graph::{
16    embed_global_graph, global_add, global_add_force, global_list, global_remove,
17};
18use codesynapse_core::graph::{GraphStore, MemoryGraphStore, SledGraphStore, StoreBackend};
19use codesynapse_core::llm_extract::build_extractor;
20use codesynapse_core::query::QueryEngine;
21use codesynapse_core::ts_extract::{
22    JsonPackageExtractor, McpConfigExtractor, TsBashExtractor, TsCExtractor, TsCSharpExtractor,
23    TsCmakeExtractor, TsCppExtractor, TsDartExtractor, TsGoExtractor, TsGroovyExtractor,
24    TsHaskellExtractor, TsJavaExtractor, TsJavaScriptExtractor, TsKotlinExtractor, TsLuaExtractor,
25    TsPhpExtractor, TsPythonExtractor, TsRacketExtractor, TsRubyExtractor, TsRustExtractor,
26    TsScalaExtractor, TsSqlExtractor, TsSvelteExtractor, TsSwiftExtractor, TsTypeScriptExtractor,
27    TsVueExtractor, TsZigExtractor,
28};
29use codesynapse_core::types::FileType;
30use codesynapse_core::types::{Edge, Node};
31use codesynapse_core::watch::{WatchConfig, Watcher};
32use console::style;
33use rayon::prelude::*;
34use std::collections::HashSet;
35use std::path::PathBuf;
36
37#[derive(Parser)]
38#[command(name = "codesynapse", version = env!("CARGO_PKG_VERSION"))]
39pub struct Cli {
40    #[command(subcommand)]
41    pub command: Command,
42}
43
44#[derive(Debug, Subcommand)]
45pub enum Command {
46    /// Extract code structure from source files
47    Extract {
48        path: PathBuf,
49        #[arg(long)]
50        format: Option<String>,
51    },
52    /// Build graph from extraction fragments
53    Build {
54        path: PathBuf,
55        #[arg(long, short = 'o')]
56        output: Option<PathBuf>,
57        #[arg(long, value_delimiter = ',')]
58        format: Option<Vec<String>>,
59        #[arg(long)]
60        no_llm: bool,
61        /// Enable LLM extraction for doc/paper files (provider from codesynapse.toml or env)
62        #[arg(long)]
63        llm: bool,
64        #[arg(long)]
65        code_only: bool,
66        #[arg(long)]
67        update: bool,
68        #[arg(long)]
69        force: bool,
70        #[arg(long, short = 'j')]
71        jobs: Option<usize>,
72    },
73    /// Run community detection
74    Cluster { path: PathBuf },
75    /// Analyze the graph
76    Analyze { path: PathBuf },
77    /// Export graph in various formats
78    Export {
79        path: PathBuf,
80        #[arg(long)]
81        json: bool,
82        #[arg(long)]
83        html: bool,
84        #[arg(long)]
85        graphml: bool,
86        #[arg(long)]
87        obsidian: bool,
88    },
89    /// Query the graph (BM25 + optional dense search via RRF)
90    Query {
91        query: String,
92        /// Path to graph.json exported by the build command.
93        #[arg(long)]
94        graph: Option<PathBuf>,
95        /// Path to model directory with tokenizer.json + model.safetensors.
96        #[arg(long)]
97        model_path: Option<PathBuf>,
98        /// Traversal mode: bfs or dfs.
99        #[arg(long, default_value = "bfs")]
100        mode: String,
101        /// Traversal depth.
102        #[arg(long, default_value_t = 2)]
103        depth: usize,
104    },
105    /// Find shortest path between two nodes
106    Path {
107        source: String,
108        target: String,
109        #[arg(long)]
110        path: Option<PathBuf>,
111    },
112    /// Show graph statistics
113    Stats { path: PathBuf },
114    /// Validate extraction JSON
115    Validate { path: PathBuf },
116    /// Merge two graph JSON files
117    Merge { a: PathBuf, b: PathBuf },
118    /// Git merge driver for graph JSON files (union merge)
119    #[command(name = "merge-driver")]
120    MergeDriver {
121        /// Base version (ignored — present for git merge driver %O %A %B compatibility)
122        base: PathBuf,
123        /// Current version (written back with merged result)
124        current: PathBuf,
125        /// Other version to merge in
126        other: PathBuf,
127    },
128    /// Start MCP server (stdio transport for AI clients)
129    Mcp {
130        /// Override global registry directory (default: ~/.codesynapse)
131        #[arg(long)]
132        global_dir: Option<PathBuf>,
133    },
134    /// Start gRPC server
135    Serve {
136        #[arg(long)]
137        graph: Option<PathBuf>,
138        path: Option<PathBuf>,
139        /// TCP port for gRPC listener (default: 50051)
140        #[arg(long, default_value = "50051")]
141        port: u16,
142    },
143    /// Diff two graphs for structural equivalence
144    #[command(alias = "compare")]
145    Diff {
146        path: PathBuf,
147        #[arg(long)]
148        baseline: PathBuf,
149    },
150    /// Explain a node — show its details and relationships
151    Explain {
152        id: String,
153        #[arg(long)]
154        path: Option<PathBuf>,
155    },
156    /// Find nodes affected by changes to a given node
157    Affected {
158        id: String,
159        #[arg(long)]
160        path: Option<PathBuf>,
161        /// Load graph from JSON file instead of sled store
162        #[arg(long)]
163        graph: Option<PathBuf>,
164        #[arg(long, default_value = "2")]
165        depth: usize,
166        /// Filter to specific relation types (can be repeated)
167        #[arg(long = "relation", value_name = "RELATION")]
168        relations: Vec<String>,
169    },
170    /// Print an ASCII tree of the graph structure
171    Tree {
172        path: PathBuf,
173        #[arg(long)]
174        root: Option<String>,
175        #[arg(long, default_value = "3")]
176        max_depth: usize,
177    },
178    /// Benchmark the extraction pipeline
179    Benchmark {
180        path: PathBuf,
181        #[arg(long, default_value = "3")]
182        runs: usize,
183    },
184    /// Perform temporal risk analysis on the graph
185    Risk {
186        path: PathBuf,
187        #[arg(long)]
188        dry_run: bool,
189    },
190    /// Incrementally update the graph from changed sources
191    Update {
192        path: PathBuf,
193        #[arg(long)]
194        force: bool,
195    },
196    /// Watch a directory for changes and rebuild automatically
197    Watch {
198        #[arg(long, short = 'p')]
199        path: Option<PathBuf>,
200        #[arg(long, short = 'o')]
201        output: Option<PathBuf>,
202    },
203    /// Ingest content from a URL (web page, GitHub, paper, etc.)
204    Ingest {
205        url: String,
206        #[arg(long)]
207        output: Option<PathBuf>,
208    },
209    /// Manage git hooks for automatic graph building
210    Hook {
211        #[command(subcommand)]
212        action: HookAction,
213    },
214    /// Initialize a new codesynapse.toml config in the current directory
215    Init {
216        #[arg(long)]
217        path: Option<PathBuf>,
218        #[arg(long)]
219        force: bool,
220    },
221    /// Generate shell completions
222    Completions { shell: String },
223    /// Save the last query or analysis result to a file
224    SaveResult {
225        #[arg(long, short = 'o')]
226        output: Option<PathBuf>,
227    },
228    /// Install codesynapse for a specific platform (cursor, claude, etc.)
229    Install {
230        #[arg(long)]
231        platform: String,
232    },
233    /// Manage Claude Desktop integration
234    Claude {
235        #[command(subcommand)]
236        action: PlatformAction,
237    },
238    /// Manage OpenCode integration
239    Opencode {
240        #[command(subcommand)]
241        action: PlatformAction,
242    },
243    /// Manage Codex integration
244    Codex {
245        #[command(subcommand)]
246        action: PlatformAction,
247    },
248    /// Manage CodeBuddy integration
249    Codebuddy {
250        #[command(subcommand)]
251        action: PlatformAction,
252    },
253    /// Manage Claw integration
254    Claw {
255        #[command(subcommand)]
256        action: PlatformAction,
257    },
258    /// Manage Droid integration
259    Droid {
260        #[command(subcommand)]
261        action: PlatformAction,
262    },
263    /// Manage Cursor integration
264    Cursor {
265        #[command(subcommand)]
266        action: PlatformAction,
267    },
268    /// Manage VS Code integration
269    Vscode {
270        #[command(subcommand)]
271        action: PlatformAction,
272    },
273    /// Manage Aider integration
274    Aider {
275        #[command(subcommand)]
276        action: PlatformAction,
277    },
278    /// Manage GitHub Copilot integration
279    Copilot {
280        #[command(subcommand)]
281        action: PlatformAction,
282    },
283    /// Manage Trae integration
284    Trae {
285        #[command(subcommand)]
286        action: PlatformAction,
287    },
288    /// Manage Trae CN integration
289    #[command(name = "trae-cn")]
290    TraeCn {
291        #[command(subcommand)]
292        action: PlatformAction,
293    },
294    /// Manage Antigravity integration
295    Antigravity {
296        #[command(subcommand)]
297        action: PlatformAction,
298    },
299    /// Manage Hermes integration
300    Hermes {
301        #[command(subcommand)]
302        action: PlatformAction,
303    },
304    /// Manage Kiro integration
305    Kiro {
306        #[command(subcommand)]
307        action: PlatformAction,
308    },
309    /// Manage Pi integration
310    Pi {
311        #[command(subcommand)]
312        action: PlatformAction,
313    },
314    /// Manage Devin integration
315    Devin {
316        #[command(subcommand)]
317        action: PlatformAction,
318    },
319    /// Manage the cross-repo global graph (~/.codesynapse/)
320    Global {
321        #[command(subcommand)]
322        action: GlobalAction,
323    },
324    /// Diagnose multigraph edge-collapse issues in a graph JSON file
325    Diagnose {
326        /// Path to graph.json to diagnose
327        #[arg(long)]
328        graph: PathBuf,
329        /// Output JSON instead of human-readable report
330        #[arg(long)]
331        json: bool,
332        /// Max examples per edge group (default: 3)
333        #[arg(long, default_value_t = 3)]
334        max_examples: usize,
335        /// Force directed graph interpretation
336        #[arg(long)]
337        directed: bool,
338        /// Force undirected graph interpretation
339        #[arg(long)]
340        undirected: bool,
341    },
342    /// Generate Mermaid call-flow architecture HTML from a graph.json
343    #[command(name = "callflow-html")]
344    CallflowHtml {
345        /// Optional path to graph.json (positional)
346        graph_path: Option<PathBuf>,
347        /// Path to graph.json (long flag, overrides positional)
348        #[arg(long)]
349        graph: Option<PathBuf>,
350        /// Output HTML file path
351        #[arg(long, short = 'o')]
352        output: Option<PathBuf>,
353        /// Project root directory
354        #[arg(long)]
355        project: Option<PathBuf>,
356        /// Maximum auto-derived sections (default: 15)
357        #[arg(long, default_value_t = 15)]
358        max_sections: usize,
359        /// Mermaid diagram scale (0.65–1.8, default: 1.0)
360        #[arg(long, default_value_t = 1.0)]
361        diagram_scale: f64,
362        /// Max nodes per section diagram (default: 18)
363        #[arg(long, default_value_t = 18)]
364        max_diagram_nodes: usize,
365        /// Max edges per section diagram (default: 24)
366        #[arg(long, default_value_t = 24)]
367        max_diagram_edges: usize,
368    },
369    /// Generate D3 collapsible-tree HTML from a graph.json (filesystem hierarchy view)
370    #[command(name = "tree-html")]
371    TreeHtml {
372        /// Path to graph.json
373        #[arg(long)]
374        graph: Option<PathBuf>,
375        /// Output HTML file path
376        #[arg(long, short = 'o')]
377        output: Option<PathBuf>,
378        /// Override common root directory
379        #[arg(long)]
380        root: Option<String>,
381        /// Max children per directory node before truncation (default: 200)
382        #[arg(long, default_value_t = 200)]
383        max_children: usize,
384        /// Project label for root node
385        #[arg(long)]
386        label: Option<String>,
387    },
388    /// Clone a GitHub repo to a local cache dir (or pull if already cloned)
389    Clone {
390        /// GitHub URL to clone
391        url: String,
392        /// Branch to clone/pull
393        #[arg(long)]
394        branch: Option<String>,
395        /// Override destination directory
396        #[arg(long)]
397        out: Option<PathBuf>,
398    },
399    /// Show open PRs dashboard (requires gh CLI)
400    Prs {
401        /// Override detected default/base branch
402        #[arg(long)]
403        base: Option<String>,
404        /// Target GitHub repo (owner/repo)
405        #[arg(long)]
406        repo: Option<String>,
407        /// Max PRs to fetch (default: 50)
408        #[arg(long, default_value_t = 50)]
409        limit: usize,
410        /// Path to graph.json for blast-radius calculation
411        #[arg(long)]
412        graph: Option<PathBuf>,
413    },
414    /// Print the codesynapse version string
415    Version,
416    /// No-op hook-check (installed agents call this on every tool use; always exits 0)
417    #[command(name = "hook-check")]
418    HookCheck,
419    /// Check if semantic re-extraction is pending for a path (cron-safe)
420    #[command(name = "check-update")]
421    CheckUpdate { path: PathBuf },
422    /// Check semantic cache for a list of files; writes .codesynapse_cached.json + .codesynapse_uncached.txt
423    #[command(name = "cache-check")]
424    CacheCheck {
425        /// File containing paths to check (one per line)
426        files_from: PathBuf,
427        /// Root directory for relative paths and output (default: current dir)
428        #[arg(long, default_value = ".")]
429        root: PathBuf,
430    },
431    /// Merge chunk JSON files produced by parallel semantic extraction
432    #[command(name = "merge-chunks")]
433    MergeChunks {
434        /// Chunk JSON files to merge
435        files: Vec<PathBuf>,
436        /// Output path for merged JSON
437        #[arg(long)]
438        out: PathBuf,
439    },
440    /// Merge cached semantic results with freshly-extracted chunks
441    #[command(name = "merge-semantic")]
442    MergeSemantic {
443        /// Path to cached results (.codesynapse_cached.json)
444        #[arg(long)]
445        cached: Option<PathBuf>,
446        /// Path to newly-extracted chunk results
447        #[arg(long)]
448        new: Option<PathBuf>,
449        /// Output path for merged JSON
450        #[arg(long)]
451        out: PathBuf,
452    },
453    /// Register codesynapse MCP server in installed AI clients
454    Setup {
455        /// Register only this client (claude-code, cursor, vscode, windsurf, zed, continue, jetbrains)
456        #[arg(long)]
457        client: Option<String>,
458        /// Workspace directory for VS Code .vscode/mcp.json (default: current dir)
459        #[arg(long)]
460        workspace: Option<PathBuf>,
461    },
462    /// Answer an architecture question using the graph (same as codesynapse_resolve MCP tool)
463    Resolve {
464        /// Natural-language question or symbol name
465        query: String,
466        /// Number of top nodes to return
467        #[arg(long, default_value_t = 5)]
468        limit: usize,
469    },
470    /// Manage source modules: add, refresh, list, remove
471    Module {
472        #[command(subcommand)]
473        action: ModuleAction,
474    },
475    /// Manage anonymous usage telemetry (opt-in)
476    Telemetry {
477        #[command(subcommand)]
478        action: TelemetryAction,
479    },
480}
481
482#[derive(Debug, Subcommand)]
483pub enum TelemetryAction {
484    /// Enable anonymous usage telemetry
485    On,
486    /// Disable telemetry and delete any buffered data
487    Off,
488    /// Show current telemetry status
489    Status,
490}
491
492#[derive(Debug, Subcommand)]
493pub enum PlatformAction {
494    /// Install the integration
495    Install,
496    /// Uninstall the integration
497    Uninstall,
498}
499
500#[derive(Debug, Subcommand)]
501pub enum HookAction {
502    /// Install git hooks for automatic graph building
503    Install {
504        #[arg(long)]
505        path: Option<PathBuf>,
506    },
507    /// Remove git hooks
508    Uninstall {
509        #[arg(long)]
510        path: Option<PathBuf>,
511    },
512    /// Show hook installation status
513    Status {
514        #[arg(long)]
515        path: Option<PathBuf>,
516    },
517}
518
519#[derive(Debug, Subcommand)]
520pub enum GlobalAction {
521    /// Add a local graph JSON into the global graph
522    Add {
523        /// Path to graph.json to merge in
524        path: PathBuf,
525        /// Repo tag/name (default: directory name)
526        #[arg(long = "as")]
527        tag: Option<String>,
528    },
529    /// Remove a repo's nodes from the global graph
530    Remove {
531        /// Repo tag to remove
532        tag: String,
533    },
534    /// List repos in the global graph
535    List,
536    /// Print path to the global graph directory
537    Path,
538}
539
540#[derive(Debug, Subcommand)]
541pub enum ModuleAction {
542    /// Extract a source directory, register in global graph, save to modules.conf
543    Add {
544        /// Module name — supports hierarchy (e.g. core/context-impl)
545        name: String,
546        /// Source directory to extract from
547        source: PathBuf,
548        /// Path to modules.conf (default: ~/.codesynapse/modules.conf)
549        #[arg(long)]
550        modules_conf: Option<PathBuf>,
551        /// Re-extract even if graph is up to date
552        #[arg(long)]
553        force: bool,
554        /// Enable LLM extraction for doc/paper files (provider from codesynapse.toml or env)
555        #[arg(long)]
556        llm: bool,
557    },
558    /// Re-extract modules registered in modules.conf
559    Refresh {
560        /// Module name to refresh (default: all)
561        name: Option<String>,
562        /// Path to modules.conf (default: ~/.codesynapse/modules.conf)
563        #[arg(long)]
564        modules_conf: Option<PathBuf>,
565        /// Re-extract even if graph is up to date
566        #[arg(long)]
567        force: bool,
568        /// Enable LLM extraction for doc/paper files (provider from codesynapse.toml or env)
569        #[arg(long)]
570        llm: bool,
571    },
572    /// List modules registered in modules.conf with node counts
573    List {
574        /// Path to modules.conf (default: ~/.codesynapse/modules.conf)
575        #[arg(long)]
576        modules_conf: Option<PathBuf>,
577    },
578    /// Remove a module from modules.conf and global graph
579    Remove {
580        /// Module name to remove
581        name: String,
582        /// Path to modules.conf (default: ~/.codesynapse/modules.conf)
583        #[arg(long)]
584        modules_conf: Option<PathBuf>,
585    },
586}
587
588pub(crate) fn make_extractor() -> Extractor {
589    let mut extractor = Extractor::new();
590    extractor.register("py", Box::new(TsPythonExtractor));
591    extractor.register("js", Box::new(TsJavaScriptExtractor));
592    extractor.register("jsx", Box::new(TsJavaScriptExtractor));
593    extractor.register("mjs", Box::new(TsJavaScriptExtractor));
594    extractor.register("cjs", Box::new(TsJavaScriptExtractor));
595    extractor.register("ts", Box::new(TsTypeScriptExtractor));
596    extractor.register("tsx", Box::new(TsTypeScriptExtractor));
597    extractor.register("mts", Box::new(TsTypeScriptExtractor));
598    extractor.register("cts", Box::new(TsTypeScriptExtractor));
599    extractor.register("go", Box::new(TsGoExtractor));
600    extractor.register("rs", Box::new(TsRustExtractor));
601    extractor.register("java", Box::new(TsJavaExtractor));
602    extractor.register("c", Box::new(TsCExtractor));
603    extractor.register("h", Box::new(TsCExtractor));
604    extractor.register("cpp", Box::new(TsCppExtractor));
605    extractor.register("cxx", Box::new(TsCppExtractor));
606    extractor.register("hpp", Box::new(TsCppExtractor));
607    extractor.register("cs", Box::new(TsCSharpExtractor));
608    extractor.register("kt", Box::new(TsKotlinExtractor));
609    extractor.register("kts", Box::new(TsKotlinExtractor));
610    extractor.register("swift", Box::new(TsSwiftExtractor));
611    extractor.register("php", Box::new(TsPhpExtractor));
612    extractor.register("rb", Box::new(TsRubyExtractor));
613    extractor.register("sql", Box::new(TsSqlExtractor));
614    extractor.register("sh", Box::new(TsBashExtractor));
615    extractor.register("bash", Box::new(TsBashExtractor));
616    extractor.register("vue", Box::new(TsVueExtractor));
617    extractor.register("svelte", Box::new(TsSvelteExtractor));
618    extractor.register("json", Box::new(JsonPackageExtractor));
619    extractor.register("mcp.json", Box::new(McpConfigExtractor));
620    extractor.register("scala", Box::new(TsScalaExtractor));
621    extractor.register("dart", Box::new(TsDartExtractor));
622    extractor.register("lua", Box::new(TsLuaExtractor));
623    extractor.register("zig", Box::new(TsZigExtractor));
624    extractor.register("hs", Box::new(TsHaskellExtractor));
625    extractor.register("lhs", Box::new(TsHaskellExtractor));
626    extractor.register("groovy", Box::new(TsGroovyExtractor));
627    extractor.register("gvy", Box::new(TsGroovyExtractor));
628    extractor.register("cmake", Box::new(TsCmakeExtractor));
629    extractor.register("rkt", Box::new(TsRacketExtractor));
630    extractor
631}
632
633pub(crate) fn read_modules_conf(path: &std::path::Path) -> Vec<(String, PathBuf)> {
634    let Ok(text) = std::fs::read_to_string(path) else {
635        return vec![];
636    };
637    text.lines()
638        .filter(|l| !l.trim().is_empty() && !l.trim_start().starts_with('#'))
639        .filter_map(|l| {
640            let mut parts = l.splitn(2, '|');
641            let name = parts.next()?.trim().to_string();
642            let source = parts.next()?.trim().to_string();
643            if name.is_empty() || source.is_empty() {
644                return None;
645            }
646            Some((name, PathBuf::from(source)))
647        })
648        .collect()
649}
650
651pub(crate) fn upsert_modules_conf(
652    path: &std::path::Path,
653    name: &str,
654    source: &std::path::Path,
655) -> std::io::Result<()> {
656    let existing = if path.exists() {
657        std::fs::read_to_string(path)?
658    } else {
659        if let Some(parent) = path.parent() {
660            std::fs::create_dir_all(parent)?;
661        }
662        String::new()
663    };
664    let entry = format!("{}|{}", name, source.display());
665    let prefix = format!("{}|", name);
666    let mut lines: Vec<String> = existing.lines().map(|l| l.to_string()).collect();
667    let pos = lines.iter().position(|l| {
668        let t = l.trim();
669        !t.starts_with('#') && t.starts_with(&prefix)
670    });
671    match pos {
672        Some(i) => lines[i] = entry,
673        None => lines.push(entry),
674    }
675    let mut content = lines.join("\n");
676    if !content.is_empty() {
677        content.push('\n');
678    }
679    std::fs::write(path, content)
680}
681
682pub(crate) fn remove_from_modules_conf(path: &std::path::Path, name: &str) -> std::io::Result<()> {
683    if !path.exists() {
684        return Ok(());
685    }
686    let text = std::fs::read_to_string(path)?;
687    let prefix = format!("{}|", name);
688    let filtered: Vec<&str> = text
689        .lines()
690        .filter(|l| {
691            let t = l.trim();
692            t.starts_with('#') || t.is_empty() || !t.starts_with(&prefix)
693        })
694        .collect();
695    let mut content = filtered.join("\n");
696    if !content.is_empty() {
697        content.push('\n');
698    }
699    std::fs::write(path, content)
700}
701
702pub(crate) fn find_git_root(start: &std::path::Path) -> Option<PathBuf> {
703    let mut dir = start.to_path_buf();
704    loop {
705        if dir.join(".git").is_dir() {
706            return Some(dir);
707        }
708        if !dir.pop() {
709            return None;
710        }
711    }
712}
713
714pub(crate) fn gitignore_codesynapse_artifacts(source_root: &std::path::Path) {
715    let Some(git_root) = find_git_root(source_root) else {
716        return;
717    };
718    let exclude = git_root.join(".git").join("info").join("exclude");
719    if let Some(parent) = exclude.parent() {
720        std::fs::create_dir_all(parent).ok();
721    }
722    let existing = std::fs::read_to_string(&exclude).unwrap_or_default();
723    let mut content = existing.clone();
724    for pattern in &[".codesynapse-store", "codesynapse-out"] {
725        if !existing.lines().any(|l| l.trim() == *pattern) {
726            content.push_str(pattern);
727            content.push('\n');
728        }
729    }
730    if content != existing {
731        std::fs::write(&exclude, content).ok();
732    }
733}
734
735pub(crate) fn has_newer_sources(
736    source_root: &std::path::Path,
737    graph_json: &std::path::Path,
738) -> bool {
739    let Ok(meta) = std::fs::metadata(graph_json) else {
740        return true;
741    };
742    let Ok(graph_mtime) = meta.modified() else {
743        return true;
744    };
745    walkdir::WalkDir::new(source_root)
746        .follow_links(false)
747        .into_iter()
748        .filter_map(|e| e.ok())
749        .filter(|e| e.file_type().is_file())
750        .filter(|e| {
751            e.path()
752                .extension()
753                .and_then(|s| s.to_str())
754                .map(|ext| {
755                    matches!(
756                        ext,
757                        "java"
758                            | "py"
759                            | "js"
760                            | "ts"
761                            | "tsx"
762                            | "jsx"
763                            | "mjs"
764                            | "cjs"
765                            | "mts"
766                            | "cts"
767                            | "rs"
768                            | "go"
769                            | "kt"
770                            | "kts"
771                            | "cs"
772                            | "cpp"
773                            | "cxx"
774                            | "c"
775                            | "h"
776                            | "hpp"
777                            | "rb"
778                            | "php"
779                            | "swift"
780                            | "scala"
781                            | "dart"
782                            | "lua"
783                            | "zig"
784                            | "hs"
785                            | "groovy"
786                            | "gvy"
787                            | "sh"
788                            | "bash"
789                            | "vue"
790                            | "svelte"
791                            | "sql"
792                            | "rkt"
793                    )
794                })
795                .unwrap_or(false)
796        })
797        .any(|e| {
798            std::fs::metadata(e.path())
799                .and_then(|m| m.modified())
800                .map(|mtime| mtime > graph_mtime)
801                .unwrap_or(false)
802        })
803}
804
805pub(crate) fn build_graph_to_json(
806    source: &std::path::Path,
807    output_json: &std::path::Path,
808    force: bool,
809    llm: bool,
810) -> Result<(usize, usize)> {
811    let output_dir = output_json.parent().unwrap_or(output_json);
812    std::fs::create_dir_all(output_dir)?;
813
814    let config = CodeSynapseConfig::discover(source).ok();
815    let store_path = source.join(".codesynapse-store");
816    let store = SledGraphStore::open(&store_path)?;
817    let backend = StoreBackend::Sled(store);
818    let builder = codesynapse_core::build::GraphBuilder::new(Box::new(backend));
819
820    let detector = Detector::new(source);
821    let files = detector.discover(source)?;
822
823    let extractor = make_extractor();
824    let cache = codesynapse_core::cache::FileCache::from_output_dir(output_dir);
825    if force {
826        cache.clear().ok();
827    }
828
829    let results: Vec<(String, Vec<Node>, Vec<Edge>, bool)> = files
830        .par_iter()
831        .filter(|f| f.file_type.as_str() == "code")
832        .filter_map(|file| {
833            let src = std::fs::read(&file.path).ok()?;
834            let hash = codesynapse_core::cache::FileCache::compute_hash(&src);
835            if !force {
836                if let Some(cached) = cache.get_cached(&hash) {
837                    return Some((file.relative_path.clone(), cached.nodes, cached.edges, true));
838                }
839            }
840            let fragment = extractor.extract_file(&file.path, &src).ok()?;
841            cache.set_cached(&hash, &fragment).ok();
842            Some((
843                file.relative_path.clone(),
844                fragment.nodes,
845                fragment.edges,
846                false,
847            ))
848        })
849        .collect();
850
851    let mut all_fragments: Vec<(String, Vec<Node>, Vec<Edge>)> =
852        results.into_iter().map(|(p, n, e, _)| (p, n, e)).collect();
853
854    let use_llm = llm || config.as_ref().and_then(|c| c.llm.as_ref()).is_some();
855    if use_llm {
856        let llm_config = config
857            .as_ref()
858            .and_then(|c| c.llm.clone())
859            .unwrap_or(LlmConfig {
860                provider: None,
861                model: None,
862                api_key: None,
863                base_url: None,
864            });
865        if let Ok(llm_extractor) = build_extractor(&llm_config) {
866            let doc_files: Vec<_> = files
867                .iter()
868                .filter(|f| matches!(f.file_type, FileType::Document | FileType::Paper))
869                .collect();
870            let mut llm_nodes = 0usize;
871            let mut llm_edges = 0usize;
872            for file in &doc_files {
873                if let Ok(src) = std::fs::read(&file.path) {
874                    if let Ok(fragment) = llm_extractor.extract(&src, &file.path) {
875                        llm_nodes += fragment.nodes.len();
876                        llm_edges += fragment.edges.len();
877                        all_fragments.push((
878                            file.relative_path.clone(),
879                            fragment.nodes,
880                            fragment.edges,
881                        ));
882                    }
883                }
884            }
885            let _ = (llm_nodes, llm_edges);
886        }
887    }
888
889    builder.build_from_fragments(all_fragments)?;
890    drop(builder); // release sled lock before re-opening
891
892    let store2 = SledGraphStore::open(&store_path)?;
893    let backend2 = StoreBackend::Sled(store2);
894    let stored_nodes = backend2.node_count()?;
895    let stored_edges = backend2.edge_count()?;
896    let all_nodes = backend2.get_all_nodes()?;
897    let all_edges = backend2.get_all_edges()?;
898
899    codesynapse_core::export::Exporter.to_json_compat_file(&all_nodes, &all_edges, output_json)?;
900
901    Ok((stored_nodes, stored_edges))
902}
903
904pub(crate) fn extract_path_to_json(path: &std::path::Path) -> Result<String> {
905    let detector = Detector::new(path);
906    let files = detector.discover(path)?;
907    let extractor = make_extractor();
908    let mut fragments = Vec::new();
909    for file in &files {
910        if file.file_type.as_str() == "code" {
911            let source = std::fs::read(&file.path)?;
912            if let Ok(fragment) = extractor.extract_file(&file.path, &source) {
913                fragments.push(fragment);
914            }
915        }
916    }
917    Ok(serde_json::to_string(&fragments)?)
918}
919
920pub fn run(cli: Cli) -> Result<()> {
921    match cli.command {
922        Command::Extract { path, format } => {
923            if matches!(format.as_deref(), Some("json")) {
924                let json = extract_path_to_json(&path)?;
925                println!("{}", json);
926            } else {
927                let detector = Detector::new(&path);
928                let files = detector.discover(&path)?;
929                let extractor = make_extractor();
930                for file in &files {
931                    if file.file_type.as_str() == "code" {
932                        let source = std::fs::read(&file.path)?;
933                        match extractor.extract_file(&file.path, &source) {
934                            Ok(fragment) => {
935                                println!(
936                                    "{}: {} nodes, {} edges",
937                                    file.relative_path,
938                                    fragment.nodes.len(),
939                                    fragment.edges.len()
940                                );
941                            }
942                            Err(e) => {
943                                eprintln!("Error extracting {}: {}", file.relative_path, e);
944                            }
945                        }
946                    }
947                }
948            }
949        }
950        Command::Build {
951            path,
952            output,
953            format,
954            no_llm,
955            llm,
956            code_only,
957            update,
958            force,
959            jobs,
960        } => {
961            // Load config from codesynapse.toml if present
962            let config = CodeSynapseConfig::discover(&path).ok();
963
964            // Merge CLI args with config (CLI takes precedence)
965            let output_dir = output
966                .clone()
967                .or_else(|| {
968                    config
969                        .as_ref()
970                        .and_then(|c| c.output.clone())
971                        .map(PathBuf::from)
972                })
973                .unwrap_or_else(|| path.join("codesynapse-out"));
974            std::fs::create_dir_all(&output_dir)?;
975
976            let formats: Vec<String> = format.clone().unwrap_or_else(|| {
977                config
978                    .as_ref()
979                    .and_then(|c| c.formats.clone())
980                    .unwrap_or_else(|| vec!["json".to_string()])
981            });
982
983            let no_llm = no_llm || config.as_ref().is_some_and(|c| c.no_llm);
984            let code_only = code_only || config.as_ref().is_some_and(|c| c.code_only);
985
986            let store_path = path.join(".codesynapse-store");
987            let store = SledGraphStore::open(&store_path)?;
988            let backend = StoreBackend::Sled(store);
989            let builder = GraphBuilder::new(Box::new(backend));
990
991            let detector = Detector::new(&path);
992            let files = detector.discover(&path)?;
993
994            let mut extractor = Extractor::new();
995            extractor.register("py", Box::new(TsPythonExtractor));
996            extractor.register("js", Box::new(TsJavaScriptExtractor));
997            extractor.register("jsx", Box::new(TsJavaScriptExtractor));
998            extractor.register("mjs", Box::new(TsJavaScriptExtractor));
999            extractor.register("cjs", Box::new(TsJavaScriptExtractor));
1000            extractor.register("ts", Box::new(TsTypeScriptExtractor));
1001            extractor.register("tsx", Box::new(TsTypeScriptExtractor));
1002            extractor.register("mts", Box::new(TsTypeScriptExtractor));
1003            extractor.register("cts", Box::new(TsTypeScriptExtractor));
1004            extractor.register("go", Box::new(TsGoExtractor));
1005            extractor.register("rs", Box::new(TsRustExtractor));
1006            extractor.register("java", Box::new(TsJavaExtractor));
1007            extractor.register("c", Box::new(TsCExtractor));
1008            extractor.register("h", Box::new(TsCExtractor));
1009            extractor.register("cpp", Box::new(TsCppExtractor));
1010            extractor.register("cxx", Box::new(TsCppExtractor));
1011            extractor.register("hpp", Box::new(TsCppExtractor));
1012            extractor.register("cs", Box::new(TsCSharpExtractor));
1013            extractor.register("kt", Box::new(TsKotlinExtractor));
1014            extractor.register("kts", Box::new(TsKotlinExtractor));
1015            extractor.register("swift", Box::new(TsSwiftExtractor));
1016            extractor.register("php", Box::new(TsPhpExtractor));
1017            extractor.register("rb", Box::new(TsRubyExtractor));
1018            extractor.register("sql", Box::new(TsSqlExtractor));
1019            extractor.register("sh", Box::new(TsBashExtractor));
1020            extractor.register("bash", Box::new(TsBashExtractor));
1021            extractor.register("vue", Box::new(TsVueExtractor));
1022            extractor.register("svelte", Box::new(TsSvelteExtractor));
1023            extractor.register("json", Box::new(JsonPackageExtractor));
1024            extractor.register("mcp.json", Box::new(McpConfigExtractor));
1025            extractor.register("scala", Box::new(TsScalaExtractor));
1026            extractor.register("dart", Box::new(TsDartExtractor));
1027            extractor.register("lua", Box::new(TsLuaExtractor));
1028            extractor.register("zig", Box::new(TsZigExtractor));
1029            extractor.register("hs", Box::new(TsHaskellExtractor));
1030            extractor.register("lhs", Box::new(TsHaskellExtractor));
1031            extractor.register("groovy", Box::new(TsGroovyExtractor));
1032            extractor.register("gvy", Box::new(TsGroovyExtractor));
1033            extractor.register("cmake", Box::new(TsCmakeExtractor));
1034            extractor.register("rkt", Box::new(TsRacketExtractor));
1035
1036            let cache = FileCache::from_output_dir(&output_dir);
1037            if force {
1038                cache.clear().ok();
1039                println!("Cache cleared (--force)");
1040            }
1041
1042            if let Some(num_threads) = jobs {
1043                rayon::ThreadPoolBuilder::new()
1044                    .num_threads(num_threads)
1045                    .build_global()
1046                    .ok();
1047            }
1048
1049            let results: Vec<(String, Vec<Node>, Vec<Edge>, bool)> = files
1050                .par_iter()
1051                .filter(|file| file.file_type.as_str() == "code")
1052                .filter_map(|file| {
1053                    let source = match std::fs::read(&file.path) {
1054                        Ok(s) => s,
1055                        Err(e) => {
1056                            eprintln!("Error reading {}: {}", file.relative_path, e);
1057                            return None;
1058                        }
1059                    };
1060                    let hash = FileCache::compute_hash(&source);
1061
1062                    if !force {
1063                        if let Some(cached) = cache.get_cached(&hash) {
1064                            return Some((
1065                                file.relative_path.clone(),
1066                                cached.nodes,
1067                                cached.edges,
1068                                true,
1069                            ));
1070                        }
1071                    }
1072
1073                    let fragment = extractor.extract_file(&file.path, &source).ok()?;
1074                    cache.set_cached(&hash, &fragment).ok();
1075                    Some((
1076                        file.relative_path.clone(),
1077                        fragment.nodes,
1078                        fragment.edges,
1079                        false,
1080                    ))
1081                })
1082                .collect();
1083
1084            let total_cached: usize = results.iter().filter(|(_, _, _, cached)| *cached).count();
1085
1086            let mut all_fragments: Vec<(String, Vec<Node>, Vec<Edge>)> = results
1087                .into_iter()
1088                .map(|(path, nodes, edges, _)| (path, nodes, edges))
1089                .collect();
1090
1091            // Filter by code_only
1092            if code_only {
1093                all_fragments.retain(|(_, nodes, _)| nodes.iter().any(|n| n.file_type == "code"));
1094            }
1095
1096            if update {
1097                println!("Update mode — incremental graph rebuild");
1098            }
1099
1100            let use_llm =
1101                !no_llm && (llm || config.as_ref().and_then(|c| c.llm.as_ref()).is_some());
1102            if use_llm {
1103                let llm_config = config
1104                    .as_ref()
1105                    .and_then(|c| c.llm.clone())
1106                    .unwrap_or(LlmConfig {
1107                        provider: None,
1108                        model: None,
1109                        api_key: None,
1110                        base_url: None,
1111                    });
1112                match build_extractor(&llm_config) {
1113                    Ok(extractor) => {
1114                        let doc_files: Vec<_> = files
1115                            .iter()
1116                            .filter(|f| matches!(f.file_type, FileType::Document | FileType::Paper))
1117                            .collect();
1118                        let mut llm_nodes = 0usize;
1119                        let mut llm_edges = 0usize;
1120                        for file in &doc_files {
1121                            match std::fs::read(&file.path) {
1122                                Ok(source) => match extractor.extract(&source, &file.path) {
1123                                    Ok(fragment) => {
1124                                        llm_nodes += fragment.nodes.len();
1125                                        llm_edges += fragment.edges.len();
1126                                        all_fragments.push((
1127                                            file.relative_path.clone(),
1128                                            fragment.nodes,
1129                                            fragment.edges,
1130                                        ));
1131                                    }
1132                                    Err(e) => eprintln!(
1133                                        "LLM extraction error for {}: {}",
1134                                        file.relative_path, e
1135                                    ),
1136                                },
1137                                Err(e) => eprintln!("Error reading {}: {}", file.relative_path, e),
1138                            }
1139                        }
1140                        println!(
1141                            "LLM extracted {} nodes, {} edges from {} doc/paper files",
1142                            llm_nodes,
1143                            llm_edges,
1144                            doc_files.len()
1145                        );
1146                    }
1147                    Err(e) => eprintln!("LLM extractor build error: {e}"),
1148                }
1149            } else if no_llm {
1150                println!("LLM extraction disabled (--no-llm)");
1151            }
1152
1153            builder.build_from_fragments(all_fragments)?;
1154            drop(builder); // release sled lock before re-opening
1155
1156            // Re-open store to read back built graph
1157            let store = SledGraphStore::open(&store_path)?;
1158            let backend = StoreBackend::Sled(store);
1159            let stored_nodes = backend.node_count()?;
1160            let stored_edges = backend.edge_count()?;
1161            if total_cached > 0 {
1162                println!(
1163                    "Graph built: {} nodes, {} edges from {} files ({} cached)",
1164                    stored_nodes,
1165                    stored_edges,
1166                    files.len(),
1167                    total_cached
1168                );
1169            } else {
1170                println!(
1171                    "Graph built: {} nodes, {} edges from {} files",
1172                    stored_nodes,
1173                    stored_edges,
1174                    files.len()
1175                );
1176            }
1177            let all_nodes = backend.get_all_nodes()?;
1178            let all_edges = backend.get_all_edges()?;
1179
1180            // Export in requested formats
1181            let exporter = Exporter;
1182
1183            for fmt in &formats {
1184                let output_path = match fmt.as_str() {
1185                    "json" => output_dir.join("graph.json"),
1186                    "html" => output_dir.join("graph.html"),
1187                    "svg" => output_dir.join("graph.svg"),
1188                    "graphml" => output_dir.join("graph.graphml"),
1189                    "cypher" => output_dir.join("graph.cypher"),
1190                    "wiki" => output_dir.join("wiki").join("index.md"),
1191                    "obsidian" => output_dir.join("obsidian").join("index.md"),
1192                    "report" => output_dir.join("GRAPH_REPORT.md"),
1193                    other => {
1194                        eprintln!("Unknown format: {}", other);
1195                        continue;
1196                    }
1197                };
1198                std::fs::create_dir_all(output_path.parent().unwrap_or(&output_dir))?;
1199
1200                match fmt.as_str() {
1201                    "json" => {
1202                        exporter.to_json_compat_file(&all_nodes, &all_edges, &output_path)?;
1203                    }
1204                    "html" => {
1205                        exporter.to_html_file(&all_nodes, &all_edges, &output_path)?;
1206                    }
1207                    "svg" => {
1208                        exporter.to_svg_file(&all_nodes, &all_edges, &output_path)?;
1209                    }
1210                    "graphml" => {
1211                        let content = exporter.to_graphml(&all_nodes, &all_edges)?;
1212                        std::fs::write(&output_path, content)?;
1213                    }
1214                    "cypher" => {
1215                        exporter.to_cypher_file(&all_nodes, &all_edges, &output_path)?;
1216                    }
1217                    "wiki" => {
1218                        exporter.to_wiki(&all_nodes, &all_edges, &output_dir.join("wiki"))?;
1219                    }
1220                    "obsidian" => {
1221                        exporter.to_obsidian_vault(
1222                            &all_nodes,
1223                            &all_edges,
1224                            &output_dir.join("obsidian"),
1225                        )?;
1226                    }
1227                    "report" => {
1228                        let report = format!(
1229                            "# Graph Report\n\nNodes: {}\nEdges: {}\n",
1230                            all_nodes.len(),
1231                            all_edges.len()
1232                        );
1233                        std::fs::write(&output_path, report)?;
1234                    }
1235                    _ => {}
1236                }
1237                println!("  Exported {} -> {:?}", fmt, output_path);
1238            }
1239        }
1240        Command::Cluster { path } => {
1241            let store_path = path.join(".codesynapse-store");
1242            if !store_path.exists() {
1243                eprintln!(
1244                    "No graph store found at {:?}. Run `build` first.",
1245                    store_path
1246                );
1247                return Ok(());
1248            }
1249            let store = SledGraphStore::open(&store_path)?;
1250            let backend = StoreBackend::Sled(store);
1251            let nodes = backend.get_all_nodes()?;
1252            let edges = backend.get_all_edges()?;
1253
1254            if nodes.is_empty() {
1255                eprintln!("Graph store is empty. Run `build` first.");
1256                return Ok(());
1257            }
1258
1259            let detector = CommunityDetector;
1260            let communities = detector.detect(&nodes, &edges, 1.0)?;
1261
1262            println!("Detected {} communities:", communities.len());
1263            for community in &communities {
1264                println!(
1265                    "  Community {}: {} nodes, cohesion {:.3}",
1266                    community.id,
1267                    community.nodes.len(),
1268                    community.cohesion
1269                );
1270                // Assign community id to each node in the store
1271                for node_id in &community.nodes {
1272                    if let Some(mut node) = backend.get_node(node_id)? {
1273                        node.community = Some(community.id);
1274                        backend.add_node(node)?;
1275                    }
1276                }
1277            }
1278        }
1279        Command::Analyze { path } => {
1280            let store_path = path.join(".codesynapse-store");
1281            if !store_path.exists() {
1282                eprintln!(
1283                    "No graph store found at {:?}. Run `build` first.",
1284                    store_path
1285                );
1286                return Ok(());
1287            }
1288            let store = SledGraphStore::open(&store_path)?;
1289            let backend = StoreBackend::Sled(store);
1290            let nodes = backend.get_all_nodes()?;
1291            let edges = backend.get_all_edges()?;
1292
1293            if nodes.is_empty() {
1294                eprintln!("Graph store is empty. Run `build` first.");
1295                return Ok(());
1296            }
1297
1298            let analyzer = Analyzer;
1299            let result = analyzer.analyze(&nodes, &edges)?;
1300
1301            println!("Analysis Results:");
1302            println!("  God nodes ({}):", result.god_nodes.len());
1303            for node in &result.god_nodes {
1304                println!("    - {} ({})", node.label, node.id);
1305            }
1306            println!(
1307                "  Surprising connections: {}",
1308                result.surprising_connections.len()
1309            );
1310            for edge in &result.surprising_connections {
1311                println!(
1312                    "    - {} -> {} ({})",
1313                    edge.source, edge.target, edge.relation
1314                );
1315            }
1316            println!(
1317                "  Suggested questions: {}",
1318                result.suggested_questions.len()
1319            );
1320            for (i, question) in result.suggested_questions.iter().enumerate() {
1321                println!("    {}. {}", i + 1, question);
1322            }
1323        }
1324        Command::Export {
1325            path,
1326            json,
1327            html: _,
1328            graphml: _,
1329            obsidian: _,
1330        } => {
1331            if json {
1332                let exporter = Exporter;
1333                // Load from store
1334                let _store = MemoryGraphStore::new();
1335                // For now, try to load graph.json if it exists
1336                let graph_path = path.join("graph.json");
1337                if graph_path.exists() {
1338                    let data = exporter.load_json(&graph_path)?;
1339                    let json = exporter.to_json(&data.nodes, &data.edges, None)?;
1340                    println!("{}", json);
1341                }
1342            }
1343        }
1344        Command::Query {
1345            query,
1346            graph,
1347            model_path,
1348            mode,
1349            depth,
1350        } => {
1351            if let Some(graph_path) = graph {
1352                let g = codesynapse_serve::graph_query::load_graph(&graph_path)
1353                    .map_err(|e| codesynapse_core::error::CodeSynapseError::Other(e.to_string()))?;
1354
1355                // Build node label pairs for embedding
1356                let node_pairs: Vec<(String, String)> = g
1357                    .nodes_iter()
1358                    .map(|(id, n)| (id.to_string(), n.label.clone()))
1359                    .collect();
1360
1361                let (embedder_opt, node_embs) = if let Some(mp) = model_path {
1362                    let embedder = codesynapse_core::embedding::StaticEmbedder::from_path(&mp)
1363                        .map_err(codesynapse_core::error::CodeSynapseError::Other)?;
1364                    let refs: Vec<(&str, &str)> = node_pairs
1365                        .iter()
1366                        .map(|(id, lbl)| (id.as_str(), lbl.as_str()))
1367                        .collect();
1368                    let embs = embedder.embed_nodes(&refs);
1369                    (Some(embedder), embs)
1370                } else {
1371                    (None, std::collections::HashMap::new())
1372                };
1373
1374                let dense = embedder_opt.as_ref().map(|e| (e, &node_embs));
1375
1376                let result = codesynapse_serve::graph_query::query_graph_text_hybrid(
1377                    &g, &query, &mode, depth, 4000, None, dense,
1378                );
1379                println!("{}", result);
1380            } else {
1381                let global_dir = dirs::home_dir()
1382                    .unwrap_or_else(|| PathBuf::from("~"))
1383                    .join(".codesynapse");
1384                let graph_path = global_dir.join("global-graph.json");
1385                if !graph_path.exists() {
1386                    eprintln!("No graph found. Run `codesynapse module add <name> <path>` first.");
1387                    std::process::exit(1);
1388                }
1389                let g = codesynapse_serve::graph_query::load_graph(&graph_path)
1390                    .map_err(|e| codesynapse_core::error::CodeSynapseError::Other(e.to_string()))?;
1391
1392                let model_path_opt = {
1393                    let mp = global_dir.join("models").join("potion-code-16M");
1394                    if mp.exists() {
1395                        Some(mp)
1396                    } else {
1397                        None
1398                    }
1399                };
1400
1401                let node_pairs: Vec<(String, String)> = g
1402                    .nodes_iter()
1403                    .map(|(id, n)| (id.to_string(), n.label.clone()))
1404                    .collect();
1405
1406                let (embedder_opt, node_embs) = if let Some(mp) = model_path_opt {
1407                    let embedder = codesynapse_core::embedding::StaticEmbedder::from_path(&mp)
1408                        .map_err(codesynapse_core::error::CodeSynapseError::Other)?;
1409                    let refs: Vec<(&str, &str)> = node_pairs
1410                        .iter()
1411                        .map(|(id, lbl)| (id.as_str(), lbl.as_str()))
1412                        .collect();
1413                    let embs = embedder.embed_nodes(&refs);
1414                    (Some(embedder), embs)
1415                } else {
1416                    (None, std::collections::HashMap::new())
1417                };
1418
1419                let dense = embedder_opt.as_ref().map(|e| (e, &node_embs));
1420                let result = codesynapse_serve::graph_query::query_graph_text_hybrid(
1421                    &g, &query, &mode, depth, 4000, None, dense,
1422                );
1423                println!("{}", result);
1424            }
1425        }
1426        Command::Path {
1427            source,
1428            target,
1429            path,
1430        } => {
1431            let store_path = path
1432                .unwrap_or_else(|| PathBuf::from("."))
1433                .join(".codesynapse-store");
1434            if !store_path.exists() {
1435                eprintln!(
1436                    "No graph store found at {:?}. Run `codesynapse module add <name> <source>` first.",
1437                    store_path
1438                );
1439                return Ok(());
1440            }
1441            let store = SledGraphStore::open(&store_path)?;
1442            let backend = StoreBackend::Sled(store);
1443            let engine = QueryEngine::new(Box::new(backend));
1444            match engine.shortest_path(&source, &target)? {
1445                Some(path) => {
1446                    println!("Path found ({} hops):", path.len());
1447                    for node in &path {
1448                        println!("  - {}", node.label);
1449                    }
1450                }
1451                None => {
1452                    println!("No path found between '{}' and '{}'", source, target);
1453                }
1454            }
1455        }
1456        Command::Stats { path } => {
1457            let store_path = path.join(".codesynapse-store");
1458            if store_path.exists() {
1459                let store = SledGraphStore::open(&store_path)?;
1460                let backend = StoreBackend::Sled(store);
1461                println!("Nodes: {}", backend.node_count()?);
1462                println!("Edges: {}", backend.edge_count()?);
1463            } else {
1464                println!("No graph store found at {:?}", store_path);
1465            }
1466        }
1467        Command::Validate { path } => {
1468            let exporter = Exporter;
1469            let data = exporter.load_json(&path)?;
1470            let mut errors = Vec::new();
1471
1472            for (i, node) in data.nodes.iter().enumerate() {
1473                if node.id.is_empty() {
1474                    errors.push(format!("Node {} has empty id", i));
1475                }
1476                if node.label.is_empty() {
1477                    errors.push(format!("Node {} has empty label", i));
1478                }
1479            }
1480
1481            for (i, edge) in data.edges.iter().enumerate() {
1482                if edge.source.is_empty() {
1483                    errors.push(format!("Edge {} has empty source", i));
1484                }
1485                if edge.target.is_empty() {
1486                    errors.push(format!("Edge {} has empty target", i));
1487                }
1488                if edge.relation.is_empty() {
1489                    errors.push(format!("Edge {} has empty relation", i));
1490                }
1491            }
1492
1493            if errors.is_empty() {
1494                println!(
1495                    "Validation passed: {} nodes, {} edges",
1496                    data.nodes.len(),
1497                    data.edges.len()
1498                );
1499            } else {
1500                for err in &errors {
1501                    eprintln!("Validation error: {}", err);
1502                }
1503                std::process::exit(1);
1504            }
1505        }
1506        Command::Merge { a, b } => {
1507            let exporter = Exporter;
1508            let data_a = exporter.load_json(&a)?;
1509            let data_b = exporter.load_json(&b)?;
1510
1511            let mut all_nodes = data_a.nodes;
1512            all_nodes.extend(data_b.nodes);
1513
1514            let mut all_edges = data_a.edges;
1515            all_edges.extend(data_b.edges);
1516
1517            let merged = exporter.to_json(&all_nodes, &all_edges, None)?;
1518            println!("{}", merged);
1519        }
1520        Command::MergeDriver {
1521            base: _,
1522            current,
1523            other,
1524        } => {
1525            const MERGE_MAX_BYTES: u64 = 50 * 1024 * 1024;
1526            const MERGE_MAX_NODES: usize = 100_000;
1527
1528            let check_size = |path: &PathBuf| -> std::result::Result<(), String> {
1529                let size = std::fs::metadata(path)
1530                    .map_err(|e| format!("cannot stat {}: {}", path.display(), e))?
1531                    .len();
1532                if size > MERGE_MAX_BYTES {
1533                    return Err(format!(
1534                        "{} is {} bytes, exceeds {}-byte cap",
1535                        path.display(),
1536                        size,
1537                        MERGE_MAX_BYTES
1538                    ));
1539                }
1540                Ok(())
1541            };
1542            if let Err(e) = check_size(&current) {
1543                eprintln!("[codesynapse merge-driver] {}", e);
1544                std::process::exit(1);
1545            }
1546            if let Err(e) = check_size(&other) {
1547                eprintln!("[codesynapse merge-driver] {}", e);
1548                std::process::exit(1);
1549            }
1550
1551            let load_value = |path: &PathBuf| -> std::result::Result<serde_json::Value, String> {
1552                let text = std::fs::read_to_string(path)
1553                    .map_err(|e| format!("cannot read {}: {}", path.display(), e))?;
1554                serde_json::from_str(&text)
1555                    .map_err(|e| format!("invalid JSON in {}: {}", path.display(), e))
1556            };
1557            let val_cur = match load_value(&current) {
1558                Ok(v) => v,
1559                Err(e) => {
1560                    eprintln!("[codesynapse merge-driver] error loading graphs: {}", e);
1561                    std::process::exit(1);
1562                }
1563            };
1564            let val_oth = match load_value(&other) {
1565                Ok(v) => v,
1566                Err(e) => {
1567                    eprintln!("[codesynapse merge-driver] error loading graphs: {}", e);
1568                    std::process::exit(1);
1569                }
1570            };
1571
1572            let get_nodes = |v: &serde_json::Value| -> Vec<serde_json::Value> {
1573                v.get("nodes")
1574                    .and_then(|n| n.as_array())
1575                    .cloned()
1576                    .unwrap_or_default()
1577            };
1578            let get_edges = |v: &serde_json::Value| -> Vec<serde_json::Value> {
1579                v.get("edges")
1580                    .or_else(|| v.get("links"))
1581                    .and_then(|e| e.as_array())
1582                    .cloned()
1583                    .unwrap_or_default()
1584            };
1585            let edges_key = if val_cur.get("links").is_some() {
1586                "links"
1587            } else {
1588                "edges"
1589            };
1590
1591            let nodes_cur = get_nodes(&val_cur);
1592            let edges_cur = get_edges(&val_cur);
1593            let nodes_oth = get_nodes(&val_oth);
1594            let edges_oth = get_edges(&val_oth);
1595
1596            let mut seen_ids: HashSet<String> = HashSet::new();
1597            let mut merged_nodes: Vec<serde_json::Value> = Vec::new();
1598            for n in nodes_cur.iter().chain(nodes_oth.iter()) {
1599                let id = n
1600                    .get("id")
1601                    .and_then(|v| v.as_str())
1602                    .unwrap_or("")
1603                    .to_string();
1604                if id.is_empty() || seen_ids.insert(id) {
1605                    merged_nodes.push(n.clone());
1606                }
1607            }
1608
1609            let mut seen_edges: HashSet<String> = HashSet::new();
1610            let mut merged_edges: Vec<serde_json::Value> = Vec::new();
1611            for e in edges_cur.iter().chain(edges_oth.iter()) {
1612                let src = e.get("source").and_then(|v| v.as_str()).unwrap_or("");
1613                let tgt = e.get("target").and_then(|v| v.as_str()).unwrap_or("");
1614                let rel = e.get("relation").and_then(|v| v.as_str()).unwrap_or("");
1615                let key = format!("{}|{}|{}", src, tgt, rel);
1616                if seen_edges.insert(key) {
1617                    merged_edges.push(e.clone());
1618                }
1619            }
1620
1621            if merged_nodes.len() > MERGE_MAX_NODES {
1622                eprintln!(
1623                    "[codesynapse merge-driver] merged graph has {} nodes, exceeds {}-node cap; aborting merge.",
1624                    merged_nodes.len(), MERGE_MAX_NODES
1625                );
1626                std::process::exit(1);
1627            }
1628
1629            let mut output = val_cur.clone();
1630            if let Some(obj) = output.as_object_mut() {
1631                obj.insert("nodes".to_string(), serde_json::Value::Array(merged_nodes));
1632                obj.insert(
1633                    edges_key.to_string(),
1634                    serde_json::Value::Array(merged_edges),
1635                );
1636            }
1637
1638            let out_text = match serde_json::to_string_pretty(&output) {
1639                Ok(t) => t,
1640                Err(e) => {
1641                    eprintln!("[codesynapse merge-driver] serialization error: {}", e);
1642                    std::process::exit(1);
1643                }
1644            };
1645            if let Err(e) = std::fs::write(&current, out_text) {
1646                eprintln!(
1647                    "[codesynapse merge-driver] cannot write {}: {}",
1648                    current.display(),
1649                    e
1650                );
1651                std::process::exit(1);
1652            }
1653        }
1654        Command::Mcp { global_dir } => {
1655            use codesynapse_mcp::mcp::McpServer;
1656            let global_dir = global_dir.unwrap_or_else(|| {
1657                dirs::home_dir()
1658                    .unwrap_or_else(|| PathBuf::from("."))
1659                    .join(".codesynapse")
1660            });
1661            let tmp_sled =
1662                std::env::temp_dir().join(format!("codesynapse-mcp-sled-{}", std::process::id()));
1663            match McpServer::new_with_global(&tmp_sled, global_dir) {
1664                Ok(server) => {
1665                    if let Err(e) = server.run() {
1666                        eprintln!("[codesynapse mcp] error: {}", e);
1667                        std::process::exit(1);
1668                    }
1669                }
1670                Err(e) => {
1671                    eprintln!("[codesynapse mcp] failed to start: {}", e);
1672                    std::process::exit(1);
1673                }
1674            }
1675        }
1676        Command::Serve { graph, path, port } => {
1677            let _graph_path = graph.or(path).unwrap_or_else(|| PathBuf::from("."));
1678            let addr = std::net::SocketAddr::from(([0, 0, 0, 0], port));
1679            println!("Starting gRPC server on {}", addr);
1680            if let Err(e) = codesynapse_serve::server::start_grpc_blocking(addr) {
1681                eprintln!("[codesynapse serve] error: {}", e);
1682                std::process::exit(1);
1683            }
1684        }
1685        Command::Explain { id, path } => {
1686            let store_path = path
1687                .unwrap_or_else(|| PathBuf::from("."))
1688                .join(".codesynapse-store");
1689            if !store_path.exists() {
1690                eprintln!("No graph store found. Run `build` first.");
1691                return Ok(());
1692            }
1693            let store = SledGraphStore::open(&store_path)?;
1694            let backend = StoreBackend::Sled(store);
1695
1696            if let Some(node) = backend.get_node(&id)? {
1697                println!("Node: {}", node.label);
1698                println!("  ID: {}", node.id);
1699                println!("  File: {}", node.source_file);
1700                if let Some(loc) = &node.source_location {
1701                    println!("  Location: {}", loc);
1702                }
1703                if let Some(community) = node.community {
1704                    println!("  Community: {}", community);
1705                }
1706                if let Some(rationale) = &node.rationale {
1707                    println!("  Rationale: {}", rationale);
1708                }
1709
1710                let neighbors = backend.neighbors(&id, None)?;
1711                if !neighbors.is_empty() {
1712                    println!("\nRelationships:");
1713                    for (_neighbor, edge) in &neighbors {
1714                        println!("  {} --[{}]--> {}", edge.source, edge.relation, edge.target);
1715                    }
1716                }
1717            } else {
1718                println!("Node '{}' not found in graph store.", id);
1719            }
1720        }
1721        Command::Affected {
1722            id,
1723            path,
1724            graph,
1725            depth,
1726            relations,
1727        } => {
1728            if let Some(graph_path) = graph {
1729                let gdata =
1730                    codesynapse_core::affected::load_graph_json(&graph_path).map_err(|e| {
1731                        codesynapse_core::error::CodeSynapseError::Io(std::io::Error::other(
1732                            e.to_string(),
1733                        ))
1734                    })?;
1735                let rels: Vec<&str> = if relations.is_empty() {
1736                    codesynapse_core::affected::DEFAULT_AFFECTED_RELATIONS.to_vec()
1737                } else {
1738                    relations.iter().map(|s| s.as_str()).collect()
1739                };
1740                let output = codesynapse_core::affected::format_affected(&gdata, &id, &rels, depth);
1741                println!("{}", output);
1742            } else {
1743                let store_path = path
1744                    .unwrap_or_else(|| PathBuf::from("."))
1745                    .join(".codesynapse-store");
1746                if !store_path.exists() {
1747                    eprintln!("No graph store found. Run `build` first.");
1748                    return Ok(());
1749                }
1750                let store = SledGraphStore::open(&store_path)?;
1751                let backend = StoreBackend::Sled(store);
1752
1753                let engine = QueryEngine::new(Box::new(backend));
1754                if let Some(node) = engine.resolve_node(&id)? {
1755                    let result = engine.query_text(&node.id, "bfs", depth, None, None)?;
1756                    println!(
1757                        "Nodes affected by '{}': {} directly + {} in neighborhood",
1758                        node.label,
1759                        result.seed_nodes.len(),
1760                        result.neighborhood.len()
1761                    );
1762                    for n in &result.neighborhood {
1763                        if n.id != node.id {
1764                            println!("  {} ({})", n.label, n.source_file);
1765                        }
1766                    }
1767                } else {
1768                    eprintln!("Node '{}' not found.", id);
1769                }
1770            }
1771        }
1772        Command::Tree {
1773            path,
1774            root,
1775            max_depth,
1776        } => {
1777            let store_path = path.join(".codesynapse-store");
1778            if !store_path.exists() {
1779                eprintln!(
1780                    "No graph store found at {:?}. Run `build` first.",
1781                    store_path
1782                );
1783                return Ok(());
1784            }
1785            let store = SledGraphStore::open(&store_path)?;
1786            let backend = StoreBackend::Sled(store);
1787            let all_nodes = backend.get_all_nodes()?;
1788            let all_edges = backend.get_all_edges()?;
1789
1790            let root_id = root.unwrap_or_else(|| {
1791                // Use the first file node as root
1792                all_nodes
1793                    .iter()
1794                    .find(|n| n.file_type == "code")
1795                    .map(|n| n.id.clone())
1796                    .unwrap_or_else(|| all_nodes.first().map(|n| n.id.clone()).unwrap_or_default())
1797            });
1798
1799            #[allow(clippy::too_many_arguments)]
1800            fn print_tree(
1801                node_id: &str,
1802                all_nodes: &[Node],
1803                all_edges: &[Edge],
1804                prefix: &str,
1805                is_last: bool,
1806                depth: usize,
1807                max_depth: usize,
1808                visited: &mut HashSet<String>,
1809            ) {
1810                if depth > max_depth || visited.contains(node_id) {
1811                    return;
1812                }
1813                visited.insert(node_id.to_string());
1814
1815                let connector = if is_last { "└── " } else { "├── " };
1816                let node = all_nodes.iter().find(|n| n.id == node_id);
1817                if let Some(n) = node {
1818                    println!("{}{}{}", prefix, connector, n.label);
1819                } else {
1820                    println!("{}{}{}", prefix, connector, node_id);
1821                }
1822
1823                let child_prefix = if is_last { "    " } else { "│   " };
1824                let children: Vec<&str> = all_edges
1825                    .iter()
1826                    .filter(|e| e.source == node_id)
1827                    .map(|e| e.target.as_str())
1828                    .collect();
1829
1830                for (i, child) in children.iter().enumerate() {
1831                    let child_is_last = i == children.len() - 1;
1832                    print_tree(
1833                        child,
1834                        all_nodes,
1835                        all_edges,
1836                        &format!("{}{}", prefix, child_prefix),
1837                        child_is_last,
1838                        depth + 1,
1839                        max_depth,
1840                        visited,
1841                    );
1842                }
1843            }
1844
1845            let mut visited = HashSet::new();
1846            print_tree(
1847                &root_id,
1848                &all_nodes,
1849                &all_edges,
1850                "",
1851                true,
1852                0,
1853                max_depth,
1854                &mut visited,
1855            );
1856        }
1857        Command::Benchmark { path, runs } => {
1858            use std::time::Instant;
1859
1860            let detector = Detector::new(&path);
1861            let files = detector.discover(&path)?;
1862
1863            let mut extractor = Extractor::new();
1864            extractor.register("py", Box::new(TsPythonExtractor));
1865            extractor.register("js", Box::new(TsJavaScriptExtractor));
1866            extractor.register("ts", Box::new(TsTypeScriptExtractor));
1867            extractor.register("rs", Box::new(TsRustExtractor));
1868            extractor.register("go", Box::new(TsGoExtractor));
1869            extractor.register("java", Box::new(TsJavaExtractor));
1870
1871            let mut total_detect = 0u128;
1872            let mut total_extract = 0u128;
1873            let mut total_cluster = 0u128;
1874
1875            for i in 0..runs {
1876                let start = Instant::now();
1877                let _detected = detector.discover(&path)?;
1878                let detect_elapsed = start.elapsed().as_micros();
1879                total_detect += detect_elapsed;
1880
1881                let mut all_fragments = Vec::new();
1882                let ext_start = Instant::now();
1883                for file in &files {
1884                    if file.file_type.as_str() == "code" {
1885                        if let Ok(source) = std::fs::read(&file.path) {
1886                            if let Ok(fragment) = extractor.extract_file(&file.path, &source) {
1887                                all_fragments.push((
1888                                    file.relative_path.clone(),
1889                                    fragment.nodes,
1890                                    fragment.edges,
1891                                ));
1892                            }
1893                        }
1894                    }
1895                }
1896                let extract_elapsed = ext_start.elapsed().as_millis();
1897                total_extract += extract_elapsed;
1898
1899                let mut all_nodes = Vec::new();
1900                let mut all_edges = Vec::new();
1901                for (_, nodes, edges) in &all_fragments {
1902                    all_nodes.extend(nodes.iter().cloned());
1903                    all_edges.extend(edges.iter().cloned());
1904                }
1905
1906                let cluster_start = Instant::now();
1907                let detector = CommunityDetector;
1908                let _communities = detector.detect(&all_nodes, &all_edges, 1.0)?;
1909                let cluster_elapsed = cluster_start.elapsed().as_micros();
1910                total_cluster += cluster_elapsed;
1911
1912                println!(
1913                    "Run {}: detect={}ms, extract={}ms, cluster={}ms",
1914                    i + 1,
1915                    detect_elapsed / 1000,
1916                    extract_elapsed,
1917                    cluster_elapsed / 1000
1918                );
1919            }
1920
1921            println!("\n--- Benchmark Results ---");
1922            println!(
1923                "Files: {}",
1924                files
1925                    .iter()
1926                    .filter(|f| f.file_type.as_str() == "code")
1927                    .count()
1928            );
1929            println!("Average detect: {}ms", (total_detect / runs as u128) / 1000);
1930            println!("Average extract: {}ms", total_extract / runs as u128);
1931            println!(
1932                "Average cluster: {}ms",
1933                (total_cluster / runs as u128) / 1000
1934            );
1935        }
1936        Command::Risk { path, dry_run } => {
1937            let store_path = path.join(".codesynapse-store");
1938            if !store_path.exists() {
1939                eprintln!(
1940                    "No graph store found at {:?}. Run `build` first.",
1941                    store_path
1942                );
1943                return Ok(());
1944            }
1945            let store = SledGraphStore::open(&store_path)?;
1946            let backend = StoreBackend::Sled(store);
1947            let mut nodes = backend.get_all_nodes()?;
1948            let edges = backend.get_all_edges()?;
1949
1950            if nodes.is_empty() {
1951                eprintln!("Graph store is empty. Run `build` first.");
1952                return Ok(());
1953            }
1954
1955            let analyzer = Analyzer;
1956            if let Err(e) = analyzer.compute_temporal_risk(&mut nodes, &edges) {
1957                eprintln!("Failed to compute temporal risk: {}", e);
1958                return Ok(());
1959            }
1960
1961            if dry_run {
1962                // Just print the risk scores, don't store them back
1963                println!("Temporal risk analysis (dry run):");
1964                let mut risk_nodes: Vec<(&Node, f64)> = nodes
1965                    .iter()
1966                    .filter_map(|n| {
1967                        n.metadata
1968                            .get("risk_score")
1969                            .and_then(|s| s.parse::<f64>().ok())
1970                            .map(|score| (n, score))
1971                    })
1972                    .collect();
1973                risk_nodes.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
1974                for (node, score) in risk_nodes.iter().take(20) {
1975                    println!(
1976                        "  {} (file: {}): risk={:.2}",
1977                        node.label, node.source_file, score
1978                    );
1979                }
1980            } else {
1981                // Store the updated nodes back to the store
1982                for node in nodes {
1983                    backend.add_node(node)?;
1984                }
1985                println!("Temporal risk analysis complete. Risk scores stored in node metadata.");
1986            }
1987        }
1988        Command::Update { path, force: _ } => {
1989            println!("Update command — rebuilding graph incrementally");
1990            // For now, fall back to a full rebuild
1991            let store_path = path.join(".codesynapse-store");
1992            let store = SledGraphStore::open(&store_path)?;
1993            let backend = StoreBackend::Sled(store);
1994            let builder = GraphBuilder::new(Box::new(backend));
1995
1996            let detector = Detector::new(&path);
1997            let files = detector.discover(&path)?;
1998
1999            let mut extractor = Extractor::new();
2000            extractor.register("py", Box::new(TsPythonExtractor));
2001            extractor.register("js", Box::new(TsJavaScriptExtractor));
2002            extractor.register("ts", Box::new(TsTypeScriptExtractor));
2003            extractor.register("rs", Box::new(TsRustExtractor));
2004            extractor.register("go", Box::new(TsGoExtractor));
2005            extractor.register("java", Box::new(TsJavaExtractor));
2006
2007            let mut all_fragments = Vec::new();
2008            for file in &files {
2009                if file.file_type.as_str() == "code" {
2010                    if let Ok(source) = std::fs::read(&file.path) {
2011                        if let Ok(fragment) = extractor.extract_file(&file.path, &source) {
2012                            all_fragments.push((
2013                                file.relative_path.clone(),
2014                                fragment.nodes,
2015                                fragment.edges,
2016                            ));
2017                        }
2018                    }
2019                }
2020            }
2021            builder.build_from_fragments(all_fragments)?;
2022            println!("Graph rebuilt successfully.");
2023        }
2024        Command::Watch { path: _, output: _ } => {
2025            let global_dir = dirs::home_dir()
2026                .unwrap_or_else(|| PathBuf::from("~"))
2027                .join(".codesynapse");
2028            watch_all_modules(global_dir)?;
2029        }
2030        Command::Ingest { url, output } => {
2031            println!("Ingesting: {}", url);
2032            // Basic HTTP fetch
2033            match ureq::get(&url).call() {
2034                Ok(resp) => {
2035                    let body = resp.into_string().unwrap_or_default();
2036                    let size = body.len();
2037                    println!("Fetched {} bytes from {}", size, url);
2038                    if let Some(out) = output {
2039                        std::fs::write(&out, &body)?;
2040                        println!("Saved to {:?}", out);
2041                    }
2042                }
2043                Err(e) => {
2044                    eprintln!("Failed to fetch {}: {}", url, e);
2045                }
2046            }
2047        }
2048        Command::Hook { action } => match action {
2049            HookAction::Install { path } => {
2050                let repo_root = path.unwrap_or_else(|| PathBuf::from("."));
2051                let hooks_dir = repo_root.join(".git").join("hooks");
2052                let hook_path = hooks_dir.join("pre-commit");
2053                std::fs::create_dir_all(&hooks_dir)?;
2054                let hook_script = r#"#!/bin/sh
2055# codesynapse pre-commit hook — auto-build graph on commit
2056echo "Building graph via codesynapse..."
2057codesynapse build .
2058"#;
2059                std::fs::write(&hook_path, hook_script)?;
2060                // Make executable
2061                #[cfg(unix)]
2062                {
2063                    use std::os::unix::fs::PermissionsExt;
2064                    std::fs::set_permissions(&hook_path, std::fs::Permissions::from_mode(0o755))?;
2065                }
2066                println!("Hook installed at {:?}", hook_path);
2067            }
2068            HookAction::Uninstall { path } => {
2069                let repo_root = path.unwrap_or_else(|| PathBuf::from("."));
2070                let hook_path = repo_root.join(".git").join("hooks").join("pre-commit");
2071                if hook_path.exists() {
2072                    std::fs::remove_file(&hook_path)?;
2073                    println!("Hook removed from {:?}", hook_path);
2074                } else {
2075                    println!("No codesynapse hook found at {:?}", hook_path);
2076                }
2077            }
2078            HookAction::Status { path } => {
2079                let repo_root = path.unwrap_or_else(|| PathBuf::from("."));
2080                let hook_path = repo_root.join(".git").join("hooks").join("pre-commit");
2081                if hook_path.exists() {
2082                    let content = std::fs::read_to_string(&hook_path)?;
2083                    if content.contains("codesynapse") {
2084                        println!("✅ codesynapse hook is installed at {:?}", hook_path);
2085                    } else {
2086                        println!(
2087                            "⚠️  Hook exists at {:?} but is not a codesynapse hook",
2088                            hook_path
2089                        );
2090                    }
2091                } else {
2092                    println!("❌ No codesynapse hook installed at {:?}", hook_path);
2093                }
2094            }
2095        },
2096        Command::Init { path, force } => {
2097            let dir = path.unwrap_or_else(|| PathBuf::from("."));
2098            let config_path = dir.join("codesynapse.toml");
2099            if config_path.exists() && !force {
2100                eprintln!(
2101                    "codesynapse.toml already exists at {:?}. Use --force to overwrite.",
2102                    config_path
2103                );
2104                return Ok(());
2105            }
2106            let config_content = r#"# codesynapse configuration
2107output = "codesynapse-out"
2108no_llm = false
2109code_only = false
2110formats = ["json", "html", "report"]
2111
2112# [llm]
2113# provider = "anthropic"
2114# model = "claude-sonnet-4-20250514"
2115"#;
2116            std::fs::write(&config_path, config_content)?;
2117            ui::ok(&format!(
2118                "Created codesynapse.toml at {}",
2119                config_path.display()
2120            ));
2121            ui::info("Edit [modules] to configure source paths");
2122            ui::info("Then run: codesynapse module add <name> <path>");
2123        }
2124        Command::Diff { path, baseline } => {
2125            let exporter = Exporter;
2126            let data_a = exporter.load_json(&path)?;
2127            let data_b = exporter.load_json(&baseline)?;
2128
2129            let node_diff =
2130                (data_a.nodes.len() as isize - data_b.nodes.len() as isize).unsigned_abs();
2131            let edge_diff =
2132                (data_a.edges.len() as isize - data_b.edges.len() as isize).unsigned_abs();
2133
2134            let node_recall = if data_b.nodes.is_empty() {
2135                1.0
2136            } else {
2137                1.0 - node_diff as f64 / data_b.nodes.len() as f64
2138            };
2139
2140            let edge_recall = if data_b.edges.is_empty() {
2141                1.0
2142            } else {
2143                1.0 - edge_diff as f64 / data_b.edges.len() as f64
2144            };
2145
2146            println!("Comparison Results:");
2147            println!(
2148                "  Nodes: {} vs {} (recall: {:.3})",
2149                data_a.nodes.len(),
2150                data_b.nodes.len(),
2151                node_recall
2152            );
2153            println!(
2154                "  Edges: {} vs {} (recall: {:.3})",
2155                data_a.edges.len(),
2156                data_b.edges.len(),
2157                edge_recall
2158            );
2159            println!("  Harmonic mean: {:.3}", (node_recall * edge_recall).sqrt());
2160        }
2161        Command::Completions { shell } => {
2162            use clap_complete::{generate, Shell};
2163            let shell = match shell.as_str() {
2164                "bash" => Shell::Bash,
2165                "zsh" => Shell::Zsh,
2166                "fish" => Shell::Fish,
2167                "powershell" => Shell::PowerShell,
2168                "elvish" => Shell::Elvish,
2169                _ => {
2170                    return Err(codesynapse_core::error::CodeSynapseError::msg(format!(
2171                        "Unknown shell '{}'. Supported: bash, zsh, fish, powershell, elvish",
2172                        shell
2173                    )));
2174                }
2175            };
2176            let mut cmd = Cli::command();
2177            let name = cmd.get_name().to_string();
2178            generate(shell, &mut cmd, &name, &mut std::io::stdout());
2179        }
2180        Command::SaveResult { output } => {
2181            let out_path = output.unwrap_or_else(|| PathBuf::from("codesynapse-result.json"));
2182            // For now, save a placeholder — real implementation stores last query/analysis result
2183            let placeholder = serde_json::json!({
2184                "saved_at": chrono::Utc::now().to_rfc3339(),
2185                "message": "save-result placeholder"
2186            });
2187            std::fs::write(&out_path, serde_json::to_string_pretty(&placeholder)?)?;
2188            println!("Result saved to {:?}", out_path);
2189        }
2190        Command::Install { platform } => {
2191            let config_dir = dirs::config_dir()
2192                .unwrap_or_else(|| PathBuf::from("~/.config"))
2193                .join("codesynapse");
2194            std::fs::create_dir_all(&config_dir)?;
2195            let install_path = config_dir.join(format!("{}.json", platform));
2196            let config = serde_json::json!({
2197                "platform": platform,
2198                "installed_at": chrono::Utc::now().to_rfc3339(),
2199                "version": env!("CARGO_PKG_VERSION"),
2200            });
2201            std::fs::write(&install_path, serde_json::to_string_pretty(&config)?)?;
2202            println!(
2203                "Installed codesynapse for platform '{}' at {:?}",
2204                platform, install_path
2205            );
2206        }
2207        Command::Claude { ref action }
2208        | Command::Opencode { ref action }
2209        | Command::Codex { ref action }
2210        | Command::Codebuddy { ref action }
2211        | Command::Claw { ref action }
2212        | Command::Droid { ref action }
2213        | Command::Cursor { ref action }
2214        | Command::Vscode { ref action }
2215        | Command::Aider { ref action }
2216        | Command::Copilot { ref action }
2217        | Command::Trae { ref action }
2218        | Command::TraeCn { ref action }
2219        | Command::Antigravity { ref action }
2220        | Command::Hermes { ref action }
2221        | Command::Kiro { ref action }
2222        | Command::Pi { ref action }
2223        | Command::Devin { ref action } => {
2224            let cmd_name = match &cli.command {
2225                Command::Claude { .. } => "claude",
2226                Command::Opencode { .. } => "opencode",
2227                Command::Codex { .. } => "codex",
2228                Command::Codebuddy { .. } => "codebuddy",
2229                Command::Claw { .. } => "claw",
2230                Command::Droid { .. } => "droid",
2231                Command::Cursor { .. } => "cursor",
2232                Command::Vscode { .. } => "vscode",
2233                Command::Aider { .. } => "aider",
2234                Command::Copilot { .. } => "copilot",
2235                Command::Trae { .. } => "trae",
2236                Command::TraeCn { .. } => "trae-cn",
2237                Command::Antigravity { .. } => "antigravity",
2238                Command::Hermes { .. } => "hermes",
2239                Command::Kiro { .. } => "kiro",
2240                Command::Pi { .. } => "pi",
2241                Command::Devin { .. } => "devin",
2242                _ => unreachable!(),
2243            };
2244            match action {
2245                PlatformAction::Install => {
2246                    let config_dir = dirs::config_dir()
2247                        .unwrap_or_else(|| PathBuf::from("~/.config"))
2248                        .join("codesynapse")
2249                        .join("platforms");
2250                    std::fs::create_dir_all(&config_dir)?;
2251                    let path = config_dir.join(format!("{}.json", cmd_name));
2252                    let config = serde_json::json!({
2253                        "platform": cmd_name,
2254                        "installed_at": chrono::Utc::now().to_rfc3339(),
2255                        "version": env!("CARGO_PKG_VERSION"),
2256                    });
2257                    std::fs::write(&path, serde_json::to_string_pretty(&config)?)?;
2258                    println!(
2259                        "Installed codesynapse integration for '{}' at {:?}",
2260                        cmd_name, path
2261                    );
2262                }
2263                PlatformAction::Uninstall => {
2264                    let config_dir = dirs::config_dir()
2265                        .unwrap_or_else(|| PathBuf::from("~/.config"))
2266                        .join("codesynapse")
2267                        .join("platforms");
2268                    let path = config_dir.join(format!("{}.json", cmd_name));
2269                    if path.exists() {
2270                        std::fs::remove_file(&path)?;
2271                        println!("Uninstalled codesynapse integration for '{}'", cmd_name);
2272                    } else {
2273                        println!("No integration found for '{}'", cmd_name);
2274                    }
2275                }
2276            }
2277        }
2278        Command::Diagnose {
2279            graph,
2280            json,
2281            max_examples,
2282            directed,
2283            undirected,
2284        } => {
2285            let force_directed = if directed {
2286                Some(true)
2287            } else if undirected {
2288                Some(false)
2289            } else {
2290                None
2291            };
2292            match diagnose_file(&graph, force_directed, max_examples, None) {
2293                Ok(summary) => {
2294                    if json {
2295                        println!("{}", format_diagnostic_json(&summary));
2296                    } else {
2297                        println!("{}", format_diagnostic_report(&summary));
2298                    }
2299                }
2300                Err(e) => {
2301                    eprintln!("diagnose failed: {}", e);
2302                    std::process::exit(1);
2303                }
2304            }
2305        }
2306        Command::CallflowHtml {
2307            graph_path,
2308            graph,
2309            output,
2310            project,
2311            max_sections,
2312            diagram_scale,
2313            max_diagram_nodes,
2314            max_diagram_edges,
2315        } => {
2316            use codesynapse_core::callflow_html::write_callflow_html;
2317            let resolved_graph = graph.or(graph_path);
2318            match write_callflow_html(
2319                project.as_deref(),
2320                None,
2321                resolved_graph.as_deref(),
2322                None,
2323                None,
2324                None,
2325                output.as_deref(),
2326                "en",
2327                max_sections,
2328                diagram_scale,
2329                max_diagram_nodes,
2330                max_diagram_edges,
2331                true,
2332            ) {
2333                Ok(path) => println!("callflow HTML written: {}", path.display()),
2334                Err(e) => {
2335                    eprintln!("callflow-html failed: {}", e);
2336                    std::process::exit(1);
2337                }
2338            }
2339        }
2340        Command::TreeHtml {
2341            graph,
2342            output,
2343            root,
2344            max_children,
2345            label,
2346        } => {
2347            use codesynapse_core::tree_html::write_tree_html;
2348            use codesynapse_core::tree_html::DEFAULT_MAX_CHILDREN;
2349            let graph_path = graph.unwrap_or_else(|| PathBuf::from("codesynapse-out/graph.json"));
2350            let output_path = output.unwrap_or_else(|| PathBuf::from("codesynapse-out/tree.html"));
2351            let mc = if max_children == 200 {
2352                DEFAULT_MAX_CHILDREN
2353            } else {
2354                max_children
2355            };
2356            match write_tree_html(
2357                &graph_path,
2358                &output_path,
2359                root.as_deref(),
2360                mc,
2361                label.as_deref(),
2362            ) {
2363                Ok(path) => println!("tree HTML written: {}", path.display()),
2364                Err(e) => {
2365                    eprintln!("tree-html failed: {}", e);
2366                    std::process::exit(1);
2367                }
2368            }
2369        }
2370        Command::Clone { url, branch, out } => {
2371            match clone_repo(&url, branch.as_deref(), out.as_deref()) {
2372                Ok(_) => {}
2373                Err(e) => {
2374                    eprintln!("clone failed: {}", e);
2375                    std::process::exit(1);
2376                }
2377            }
2378        }
2379        Command::Prs {
2380            base,
2381            repo,
2382            limit,
2383            graph,
2384        } => {
2385            use codesynapse_core::prs::{
2386                compute_pr_impact, fetch_prs, fetch_worktrees, format_prs_text,
2387            };
2388            let mut prs = match fetch_prs(repo.as_deref(), base.as_deref(), limit) {
2389                Ok(p) => p,
2390                Err(e) => {
2391                    eprintln!("Error: {}", e);
2392                    std::process::exit(1);
2393                }
2394            };
2395            let worktrees = fetch_worktrees();
2396            for pr in &mut prs {
2397                pr.worktree_path = worktrees.get(&pr.branch).cloned();
2398            }
2399            if let Some(graph_path) = graph {
2400                if let Ok(text) = std::fs::read_to_string(&graph_path) {
2401                    if let Ok(v) = serde_json::from_str::<serde_json::Value>(&text) {
2402                        let nodes_val = v
2403                            .get("nodes")
2404                            .and_then(|n| n.as_array())
2405                            .cloned()
2406                            .unwrap_or_default();
2407                        let nodes: Vec<codesynapse_core::types::Node> = nodes_val
2408                            .iter()
2409                            .filter_map(|item| serde_json::from_value(item.clone()).ok())
2410                            .collect();
2411                        for pr in &mut prs {
2412                            let files: Vec<&str> =
2413                                pr.files_changed.iter().map(|s| s.as_str()).collect();
2414                            let (comms, affected) = compute_pr_impact(&files, &nodes);
2415                            pr.communities_touched = comms;
2416                            pr.nodes_affected = affected;
2417                        }
2418                    }
2419                }
2420            }
2421            let base_branch = prs
2422                .first()
2423                .map(|p| p.expected_base.clone())
2424                .unwrap_or_else(|| "main".to_string());
2425            println!("{}", format_prs_text(&prs, &base_branch));
2426        }
2427        Command::Version => {
2428            println!("{}", env!("CARGO_PKG_VERSION"));
2429        }
2430        Command::HookCheck => {
2431            std::process::exit(0);
2432        }
2433        Command::CheckUpdate { path } => {
2434            let flag = path.join("codesynapse-out").join("needs_update");
2435            if flag.exists() {
2436                println!(
2437                    "[codesynapse check-update] Pending non-code changes in {}.",
2438                    path.display()
2439                );
2440                println!(
2441                    "[codesynapse check-update] Run `/codesynapse --update` to apply semantic re-extraction."
2442                );
2443            }
2444        }
2445        Command::CacheCheck { files_from, root } => {
2446            use codesynapse_core::cache::check_semantic_cache;
2447            let text = std::fs::read_to_string(&files_from).unwrap_or_default();
2448            let files: Vec<String> = text
2449                .lines()
2450                .filter(|l| !l.trim().is_empty())
2451                .map(|l| l.to_string())
2452                .collect();
2453            let file_refs: Vec<&str> = files.iter().map(|s| s.as_str()).collect();
2454            let (cached_nodes, cached_edges, cached_hyperedges, uncached) =
2455                check_semantic_cache(&file_refs, &root);
2456            let out_dir = root.join("codesynapse-out");
2457            std::fs::create_dir_all(&out_dir)?;
2458            if !cached_nodes.is_empty() || !cached_edges.is_empty() || !cached_hyperedges.is_empty()
2459            {
2460                let cached_json = serde_json::json!({
2461                    "nodes": cached_nodes,
2462                    "edges": cached_edges,
2463                    "hyperedges": cached_hyperedges,
2464                });
2465                std::fs::write(
2466                    out_dir.join(".codesynapse_cached.json"),
2467                    serde_json::to_string(&cached_json)?,
2468                )?;
2469            }
2470            std::fs::write(
2471                out_dir.join(".codesynapse_uncached.txt"),
2472                uncached.join("\n"),
2473            )?;
2474            let hits = files.len() - uncached.len();
2475            println!("Cache: {} hit, {} miss", hits, uncached.len());
2476        }
2477        Command::MergeChunks { files, out } => {
2478            let mut seen_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
2479            let mut merged_nodes: Vec<serde_json::Value> = Vec::new();
2480            let mut merged_edges: Vec<serde_json::Value> = Vec::new();
2481            let mut merged_hyperedges: Vec<serde_json::Value> = Vec::new();
2482            let mut input_tokens: u64 = 0;
2483            let mut output_tokens: u64 = 0;
2484            let n_files = files.len();
2485            for cf in &files {
2486                let text = match std::fs::read_to_string(cf) {
2487                    Ok(t) => t,
2488                    Err(e) => {
2489                        eprintln!(
2490                            "[codesynapse merge-chunks] warning: skipping {:?}: {}",
2491                            cf, e
2492                        );
2493                        continue;
2494                    }
2495                };
2496                let chunk: serde_json::Value = match serde_json::from_str(&text) {
2497                    Ok(v) => v,
2498                    Err(e) => {
2499                        eprintln!(
2500                            "[codesynapse merge-chunks] warning: skipping {:?}: {}",
2501                            cf, e
2502                        );
2503                        continue;
2504                    }
2505                };
2506                if let Some(nodes) = chunk.get("nodes").and_then(|n| n.as_array()) {
2507                    for n in nodes {
2508                        let id = n
2509                            .get("id")
2510                            .and_then(|v| v.as_str())
2511                            .unwrap_or("")
2512                            .to_string();
2513                        if id.is_empty() || seen_ids.insert(id) {
2514                            merged_nodes.push(n.clone());
2515                        }
2516                    }
2517                }
2518                if let Some(edges) = chunk.get("edges").and_then(|e| e.as_array()) {
2519                    merged_edges.extend(edges.iter().cloned());
2520                }
2521                if let Some(he) = chunk.get("hyperedges").and_then(|h| h.as_array()) {
2522                    merged_hyperedges.extend(he.iter().cloned());
2523                }
2524                input_tokens += chunk
2525                    .get("input_tokens")
2526                    .and_then(|v| v.as_u64())
2527                    .unwrap_or(0);
2528                output_tokens += chunk
2529                    .get("output_tokens")
2530                    .and_then(|v| v.as_u64())
2531                    .unwrap_or(0);
2532            }
2533            let n_nodes = merged_nodes.len();
2534            let n_edges = merged_edges.len();
2535            let result = serde_json::json!({
2536                "nodes": merged_nodes,
2537                "edges": merged_edges,
2538                "hyperedges": merged_hyperedges,
2539                "input_tokens": input_tokens,
2540                "output_tokens": output_tokens,
2541            });
2542            if let Some(parent) = out.parent() {
2543                if parent != std::path::Path::new("") {
2544                    std::fs::create_dir_all(parent)?;
2545                }
2546            }
2547            std::fs::write(&out, serde_json::to_string(&result)?)?;
2548            println!(
2549                "Merged {} chunks: {} nodes, {} edges, {} in / {} out tokens",
2550                n_files, n_nodes, n_edges, input_tokens, output_tokens
2551            );
2552        }
2553        Command::MergeSemantic { cached, new, out } => {
2554            let load_json = |p: &Option<PathBuf>| -> serde_json::Value {
2555                p.as_ref()
2556                    .filter(|p| p.exists())
2557                    .and_then(|p| std::fs::read_to_string(p).ok())
2558                    .and_then(|t| serde_json::from_str::<serde_json::Value>(&t).ok())
2559                    .unwrap_or_else(
2560                        || serde_json::json!({"nodes": [], "edges": [], "hyperedges": []}),
2561                    )
2562            };
2563            let cached_data = load_json(&cached);
2564            let new_data = load_json(&new);
2565
2566            let get_arr = |v: &serde_json::Value, key: &str| -> Vec<serde_json::Value> {
2567                v.get(key)
2568                    .and_then(|x| x.as_array())
2569                    .cloned()
2570                    .unwrap_or_default()
2571            };
2572
2573            let cached_nodes = get_arr(&cached_data, "nodes");
2574            let new_nodes = get_arr(&new_data, "nodes");
2575            let mut seen_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
2576            let mut all_nodes: Vec<serde_json::Value> = Vec::new();
2577            for n in cached_nodes.iter().chain(new_nodes.iter()) {
2578                let id = n
2579                    .get("id")
2580                    .and_then(|v| v.as_str())
2581                    .unwrap_or("")
2582                    .to_string();
2583                if id.is_empty() || seen_ids.insert(id) {
2584                    all_nodes.push(n.clone());
2585                }
2586            }
2587
2588            let mut all_edges = get_arr(&cached_data, "edges");
2589            all_edges.extend(get_arr(&new_data, "edges"));
2590            let mut all_hyperedges = get_arr(&cached_data, "hyperedges");
2591            all_hyperedges.extend(get_arr(&new_data, "hyperedges"));
2592
2593            let n_nodes = all_nodes.len();
2594            let n_edges = all_edges.len();
2595            let result = serde_json::json!({
2596                "nodes": all_nodes,
2597                "edges": all_edges,
2598                "hyperedges": all_hyperedges,
2599            });
2600            if let Some(parent) = out.parent() {
2601                if parent != std::path::Path::new("") {
2602                    std::fs::create_dir_all(parent)?;
2603                }
2604            }
2605            std::fs::write(&out, serde_json::to_string(&result)?)?;
2606            println!("Merged: {} nodes, {} edges", n_nodes, n_edges);
2607        }
2608        Command::Global { action } => {
2609            let global_dir = dirs::home_dir()
2610                .unwrap_or_else(|| PathBuf::from("~"))
2611                .join(".codesynapse");
2612            match action {
2613                GlobalAction::Add { path, tag } => {
2614                    let repo_tag = tag.unwrap_or_else(|| {
2615                        path.file_stem()
2616                            .and_then(|s| s.to_str())
2617                            .unwrap_or("repo")
2618                            .to_string()
2619                    });
2620                    match global_add(&path, &repo_tag, &global_dir) {
2621                        Ok(result) if result.skipped => {
2622                            println!(
2623                                "Skipped '{}' — graph unchanged (same hash).",
2624                                result.repo_tag
2625                            );
2626                        }
2627                        Ok(result) => {
2628                            println!(
2629                                "Added '{}': +{} nodes, -{} nodes removed.",
2630                                result.repo_tag, result.nodes_added, result.nodes_removed
2631                            );
2632                        }
2633                        Err(e) => {
2634                            eprintln!("global add failed: {}", e);
2635                            std::process::exit(1);
2636                        }
2637                    }
2638                    let model_present = global_dir.join("models").join("potion-code-16M").exists();
2639                    match embed_global_graph(&global_dir) {
2640                        Ok(0) if !model_present => eprintln!("No embedding model found — run `codesynapse setup` to enable hybrid search."),
2641                        Ok(_) => {}
2642                        Err(e) => eprintln!("Embedding failed: {}", e),
2643                    }
2644                }
2645                GlobalAction::Remove { tag } => match global_remove(&tag, &global_dir) {
2646                    Ok(removed) => {
2647                        println!("Removed '{}': {} nodes pruned.", tag, removed);
2648                    }
2649                    Err(e) => {
2650                        eprintln!("global remove failed: {}", e);
2651                        std::process::exit(1);
2652                    }
2653                },
2654                GlobalAction::List => {
2655                    let repos = global_list(&global_dir);
2656                    if repos.is_empty() {
2657                        println!("No repos in global graph.");
2658                    } else {
2659                        for (tag, entry) in &repos {
2660                            println!(
2661                                "{}\t{} nodes\t{} edges\t{}",
2662                                tag, entry.node_count, entry.edge_count, entry.added_at
2663                            );
2664                        }
2665                    }
2666                }
2667                GlobalAction::Path => {
2668                    println!("{}", global_dir.display());
2669                }
2670            }
2671        }
2672        Command::Setup { client, workspace } => {
2673            let binary = std::env::current_exe()
2674                .map(|p| p.to_string_lossy().into_owned())
2675                .unwrap_or_else(|_| "codesynapse".to_string());
2676            let ws = workspace
2677                .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
2678
2679            ui::print_banner();
2680
2681            // Model check
2682            let pb = ui::spinner("Checking embedding model...");
2683            let model_status = setup_model();
2684            match &model_status {
2685                ModelStatus::AlreadyInstalled => {
2686                    ui::spin_ok(&pb, &format!("Model ready  ({})", MODEL_NAME))
2687                }
2688                ModelStatus::Copied => {
2689                    ui::spin_ok(&pb, &format!("Model installed  ({})", MODEL_NAME))
2690                }
2691                ModelStatus::Downloaded => {
2692                    ui::spin_ok(&pb, &format!("Model downloaded  ({})", MODEL_NAME))
2693                }
2694                ModelStatus::NotFound => {
2695                    ui::spin_warn(&pb, "Model not found — hybrid search disabled");
2696                    println!();
2697                    ui::info("  Download failed. Check your internet connection and try again.");
2698                    ui::info(&format!(
2699                        "  Or copy model files manually to ~/.codesynapse/models/{}",
2700                        MODEL_NAME
2701                    ));
2702                    println!();
2703                }
2704            }
2705
2706            // MCP clients
2707            let pb = ui::spinner("Registering MCP clients...");
2708            let client_results = setup_mcp_clients(&binary, client.as_deref(), &ws);
2709            pb.finish_and_clear();
2710
2711            let mut registered_clients: Vec<String> = Vec::new();
2712            let mut failed_clients: Vec<String> = Vec::new();
2713            for (name, status) in &client_results {
2714                match status {
2715                    ClientStatus::Registered(_) => {
2716                        ui::ok(name);
2717                        registered_clients.push(name.clone());
2718                    }
2719                    ClientStatus::Failed => {
2720                        ui::fail(&format!("{}  (write failed)", name));
2721                        failed_clients.push(name.clone());
2722                    }
2723                    ClientStatus::Skipped => {}
2724                }
2725            }
2726
2727            let hybrid = !matches!(model_status, ModelStatus::NotFound);
2728            ui::print_setup_summary(&registered_clients, &failed_clients, hybrid);
2729
2730            let global_dir = dirs::home_dir()
2731                .unwrap_or_else(|| PathBuf::from("~"))
2732                .join(".codesynapse");
2733
2734            // Telemetry consent — only on first run (no config file yet)
2735            let telemetry_config = global_dir.join("telemetry.json");
2736            let skip_telemetry_env = std::env::var("DO_NOT_TRACK")
2737                .map(|v| v == "1")
2738                .unwrap_or(false)
2739                || std::env::var("CODESYNAPSE_TELEMETRY")
2740                    .map(|v| v == "0")
2741                    .unwrap_or(false);
2742            if !telemetry_config.exists() && !skip_telemetry_env && ui::is_tty() {
2743                println!();
2744                println!(
2745                    "  {}  Codesynapse can collect anonymous usage stats (tool call counts,",
2746                    console::style("·").dim()
2747                );
2748                println!("     token-savings buckets). No code, paths, or queries are ever sent.");
2749                println!(
2750                    "     Details: https://github.com/sohilladhani/codesynapse/blob/master/TELEMETRY.md"
2751                );
2752                println!();
2753                print!("  Enable telemetry? [y/N]: ");
2754                let _ = std::io::Write::flush(&mut std::io::stdout());
2755                let mut input = String::new();
2756                if std::io::BufRead::read_line(&mut std::io::stdin().lock(), &mut input).is_ok() {
2757                    let answer = input.trim().to_lowercase();
2758                    let t = codesynapse_core::telemetry::Telemetry::new(global_dir.clone());
2759                    if answer == "y" || answer == "yes" {
2760                        t.set_enabled(true);
2761                        ui::ok("Telemetry enabled — run `codesynapse telemetry off` to disable.");
2762                    } else {
2763                        t.set_enabled(false);
2764                        ui::info("Telemetry disabled — run `codesynapse telemetry on` to enable.");
2765                    }
2766                }
2767            }
2768
2769            let _ = inject_agent_instructions(&ws, &global_dir);
2770        }
2771        Command::Resolve { query, limit } => {
2772            let global_dir = dirs::home_dir()
2773                .unwrap_or_else(|| PathBuf::from("~"))
2774                .join(".codesynapse");
2775            let graph_path = global_dir.join("global-graph.json");
2776            if !graph_path.exists() {
2777                eprintln!("No graph found. Run `codesynapse module add <name> <path>` first.");
2778                std::process::exit(1);
2779            }
2780            let limit = limit.max(1);
2781            let out =
2782                codesynapse_serve::graph_query::resolve_query(&query, &graph_path, limit, 24000)
2783                    .map_err(codesynapse_core::error::CodeSynapseError::Other)?;
2784            println!("{}", out);
2785        }
2786        Command::Module { action } => {
2787            let global_dir = dirs::home_dir()
2788                .unwrap_or_else(|| PathBuf::from("~"))
2789                .join(".codesynapse");
2790            match action {
2791                ModuleAction::Add {
2792                    name,
2793                    source,
2794                    modules_conf,
2795                    force,
2796                    llm,
2797                } => {
2798                    let conf_path = modules_conf.unwrap_or_else(|| global_dir.join("modules.conf"));
2799                    let module_graph_dir = global_dir.join("modules").join(&name);
2800                    std::fs::create_dir_all(&module_graph_dir)?;
2801                    let out_json = module_graph_dir.join("graph.json");
2802
2803                    let pb = ui::spinner(format!("Extracting {}...", name));
2804                    let t0 = std::time::Instant::now();
2805                    let (nodes, edges) = build_graph_to_json(&source, &out_json, force, llm)?;
2806                    let elapsed = t0.elapsed().as_secs();
2807                    ui::spin_ok(
2808                        &pb,
2809                        &format!(
2810                            "{}  {} nodes · {} edges  {}",
2811                            name,
2812                            ui::fmt_count(nodes),
2813                            ui::fmt_count(edges),
2814                            style(format!("({}s)", elapsed)).dim(),
2815                        ),
2816                    );
2817
2818                    match global_add_force(&out_json, &name, &global_dir, force) {
2819                        Ok(r) if r.skipped => ui::info("global graph unchanged (same hash)"),
2820                        Ok(_) => {}
2821                        Err(e) => {
2822                            ui::fail(&format!("global add failed: {}", e));
2823                            std::process::exit(1);
2824                        }
2825                    }
2826                    upsert_modules_conf(&conf_path, &name, &source)?;
2827                    ui::info(&format!("saved to {}", conf_path.display()));
2828                    if find_git_root(&source).is_none() {
2829                        ui::warn("no .git root found — add .codesynapse-store to .git/info/exclude manually");
2830                    }
2831                    gitignore_codesynapse_artifacts(&source);
2832
2833                    let pb = ui::spinner("Building search index...");
2834                    let t_embed = std::time::Instant::now();
2835                    let model_present = global_dir.join("models").join("potion-code-16M").exists();
2836                    match embed_global_graph(&global_dir) {
2837                        Ok(0) if !model_present => ui::spin_warn(
2838                            &pb,
2839                            "no embedding model — run `codesynapse setup` to enable hybrid search",
2840                        ),
2841                        Ok(0) => ui::spin_ok(&pb, "search index up to date"),
2842                        Ok(n) => ui::spin_ok(
2843                            &pb,
2844                            &format!(
2845                                "embedded {} nodes  {}",
2846                                ui::fmt_count(n),
2847                                style(format!("({})", ui::fmt_duration(t_embed.elapsed()))).dim(),
2848                            ),
2849                        ),
2850                        Err(e) => ui::spin_fail(&pb, &format!("embedding failed: {}", e)),
2851                    }
2852                }
2853                ModuleAction::Refresh {
2854                    name,
2855                    modules_conf,
2856                    force,
2857                    llm,
2858                } => {
2859                    let conf_path = modules_conf.unwrap_or_else(|| global_dir.join("modules.conf"));
2860                    let modules = read_modules_conf(&conf_path);
2861                    if modules.is_empty() {
2862                        ui::fail(&format!(
2863                            "no modules in {}. Run `codesynapse module add` first.",
2864                            conf_path.display()
2865                        ));
2866                        std::process::exit(1);
2867                    }
2868                    let to_refresh: Vec<_> = match &name {
2869                        Some(n) => modules.into_iter().filter(|(m, _)| m == n).collect(),
2870                        None => modules,
2871                    };
2872                    if to_refresh.is_empty() {
2873                        ui::fail(&format!(
2874                            "module '{}' not found in {}.",
2875                            name.unwrap_or_default(),
2876                            conf_path.display()
2877                        ));
2878                        std::process::exit(1);
2879                    }
2880                    let mut n_refreshed = 0usize;
2881                    let mut n_skipped = 0usize;
2882                    for (module_name, source_path) in to_refresh {
2883                        let module_graph_dir = global_dir.join("modules").join(&module_name);
2884                        std::fs::create_dir_all(&module_graph_dir)?;
2885                        let out_json = module_graph_dir.join("graph.json");
2886                        if !force
2887                            && out_json.exists()
2888                            && !has_newer_sources(&source_path, &out_json)
2889                        {
2890                            ui::info(&format!("{}  skipped (up to date)", module_name));
2891                            n_skipped += 1;
2892                            continue;
2893                        }
2894                        let pb = ui::spinner(format!("Refreshing {}...", module_name));
2895                        let t0 = std::time::Instant::now();
2896                        let (nodes, edges) =
2897                            build_graph_to_json(&source_path, &out_json, force, llm)?;
2898                        let elapsed = t0.elapsed().as_secs();
2899                        ui::spin_ok(
2900                            &pb,
2901                            &format!(
2902                                "{}  {} nodes · {} edges  {}",
2903                                module_name,
2904                                ui::fmt_count(nodes),
2905                                ui::fmt_count(edges),
2906                                style(format!("({}s)", elapsed)).dim(),
2907                            ),
2908                        );
2909                        match global_add_force(&out_json, &module_name, &global_dir, force) {
2910                            Ok(_) => {}
2911                            Err(e) => {
2912                                ui::warn(&format!("'{}': global add failed: {}", module_name, e))
2913                            }
2914                        }
2915                        n_refreshed += 1;
2916                    }
2917                    let pb = ui::spinner("Building search index...");
2918                    let t_embed = std::time::Instant::now();
2919                    let model_present = global_dir.join("models").join("potion-code-16M").exists();
2920                    match embed_global_graph(&global_dir) {
2921                        Ok(0) if !model_present => ui::spin_warn(
2922                            &pb,
2923                            "no embedding model — run `codesynapse setup` to enable hybrid search",
2924                        ),
2925                        Ok(0) => ui::spin_ok(&pb, "search index up to date"),
2926                        Ok(n) => ui::spin_ok(
2927                            &pb,
2928                            &format!(
2929                                "embedded {} nodes  {}",
2930                                ui::fmt_count(n),
2931                                style(format!("({})", ui::fmt_duration(t_embed.elapsed()))).dim(),
2932                            ),
2933                        ),
2934                        Err(e) => ui::spin_fail(&pb, &format!("embedding failed: {}", e)),
2935                    }
2936                    println!();
2937                    match (n_refreshed, n_skipped) {
2938                        (0, s) => ui::info(&format!("{} skipped (all up to date)", s)),
2939                        (r, 0) => ui::ok(&format!("{} refreshed", r)),
2940                        (r, s) => ui::ok(&format!("{} refreshed · {} skipped", r, s)),
2941                    }
2942                }
2943                ModuleAction::List { modules_conf } => {
2944                    let conf_path = modules_conf.unwrap_or_else(|| global_dir.join("modules.conf"));
2945                    let modules = read_modules_conf(&conf_path);
2946                    if modules.is_empty() {
2947                        ui::info(
2948                            "no modules registered. Run `codesynapse module add <name> <path>`.",
2949                        );
2950                        return Ok(());
2951                    }
2952                    let manifest_path = global_dir.join("global-manifest.json");
2953                    let manifest: serde_json::Value = if manifest_path.exists() {
2954                        serde_json::from_str(&std::fs::read_to_string(&manifest_path)?)
2955                            .unwrap_or(serde_json::json!({}))
2956                    } else {
2957                        serde_json::json!({})
2958                    };
2959                    if ui::is_tty() {
2960                        println!(
2961                            "  {}  {:>8}  {}",
2962                            style(format!("{:<40}", "MODULE")).bold(),
2963                            style("NODES").bold(),
2964                            style("SOURCE").bold(),
2965                        );
2966                        println!("  {}", style("─".repeat(78)).dim());
2967                        for (name, source) in &modules {
2968                            let nodes = manifest["repos"][name]["node_count"].as_u64().unwrap_or(0);
2969                            let nodes_s = if nodes > 0 {
2970                                style(format!("{:>8}", ui::fmt_count(nodes as usize)))
2971                                    .green()
2972                                    .to_string()
2973                            } else {
2974                                style(format!("{:>8}", "0")).yellow().to_string()
2975                            };
2976                            println!(
2977                                "  {:<40} {}  {}",
2978                                name,
2979                                nodes_s,
2980                                style(source.display().to_string()).dim(),
2981                            );
2982                        }
2983                    } else {
2984                        println!("{:<40} {:>8}  SOURCE", "MODULE", "NODES");
2985                        println!("{}", "-".repeat(80));
2986                        for (name, source) in &modules {
2987                            let nodes = manifest["repos"][name]["node_count"].as_u64().unwrap_or(0);
2988                            println!("{:<40} {:>8}  {}", name, nodes, source.display());
2989                        }
2990                    }
2991                }
2992                ModuleAction::Remove { name, modules_conf } => {
2993                    let conf_path = modules_conf.unwrap_or_else(|| global_dir.join("modules.conf"));
2994                    remove_from_modules_conf(&conf_path, &name)?;
2995                    match global_remove(&name, &global_dir) {
2996                        Ok(removed) => ui::ok(&format!(
2997                            "removed {}  —  {} nodes pruned",
2998                            name,
2999                            ui::fmt_count(removed)
3000                        )),
3001                        Err(e) => ui::fail(&format!("global remove failed: {}", e)),
3002                    }
3003                    let module_graph = global_dir.join("modules").join(&name).join("graph.json");
3004                    if module_graph.exists() {
3005                        std::fs::remove_file(&module_graph).ok();
3006                    }
3007                    let module_dir = global_dir.join("modules").join(&name);
3008                    if module_dir.exists() {
3009                        std::fs::remove_dir_all(&module_dir).ok();
3010                    }
3011
3012                    let remaining = read_modules_conf(&conf_path);
3013                    if remaining.is_empty() {
3014                        let ws = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
3015                        for filename in &["CLAUDE.md", "AGENTS.md", "GEMINI.md"] {
3016                            let p = ws.join(filename);
3017                            strip_marker_block(
3018                                &p,
3019                                "<!-- codesynapse:start -->",
3020                                "<!-- codesynapse:end -->",
3021                            )
3022                            .ok();
3023                        }
3024                    }
3025                }
3026            }
3027        }
3028        Command::Telemetry { action } => {
3029            let global_dir = dirs::home_dir()
3030                .unwrap_or_else(|| PathBuf::from("~"))
3031                .join(".codesynapse");
3032            let t = codesynapse_core::telemetry::Telemetry::new(global_dir);
3033            match action {
3034                TelemetryAction::On => {
3035                    t.set_enabled(true);
3036                    println!("Telemetry enabled. Anonymous usage data will be sent daily.");
3037                    println!("Run `codesynapse telemetry off` or set DO_NOT_TRACK=1 to disable.");
3038                }
3039                TelemetryAction::Off => {
3040                    t.set_enabled(false);
3041                    println!("Telemetry disabled. Local queue deleted.");
3042                }
3043                TelemetryAction::Status => {
3044                    let s = t.status();
3045                    println!(
3046                        "Telemetry: {}",
3047                        if s.enabled { "enabled" } else { "disabled" }
3048                    );
3049                    println!("Decided by: {}", s.decided_by);
3050                    if let Some(id) = s.machine_id {
3051                        println!("Machine ID: {}", id);
3052                    }
3053                    println!("Config: {}", s.config_path.display());
3054                    println!(
3055                        "Docs: https://github.com/sohilladhani/codesynapse/blob/master/TELEMETRY.md"
3056                    );
3057                }
3058            }
3059        }
3060    }
3061    Ok(())
3062}
3063
3064fn do_module_add(
3065    name: &str,
3066    path: &std::path::Path,
3067    force: bool,
3068    global_dir: &std::path::Path,
3069) -> Result<()> {
3070    let module_graph_dir = global_dir.join("modules").join(name);
3071    std::fs::create_dir_all(&module_graph_dir)?;
3072    let out_json = module_graph_dir.join("graph.json");
3073
3074    let t_start = std::time::Instant::now();
3075    build_graph_to_json(path, &out_json, force, false)?;
3076    let elapsed_ms = t_start.elapsed().as_millis() as u64;
3077
3078    match global_add_force(&out_json, name, global_dir, force) {
3079        Ok(_) => {}
3080        Err(e) => return Err(e),
3081    }
3082    embed_global_graph(global_dir)?;
3083
3084    // Emit index_complete lifecycle event (fire-and-forget, never fails the build).
3085    let _ = (|| -> Option<()> {
3086        let telemetry = codesynapse_core::telemetry::Telemetry::new(global_dir.to_path_buf());
3087        if !telemetry.is_enabled() {
3088            return None;
3089        }
3090        let text = std::fs::read_to_string(&out_json).ok()?;
3091        let graph: serde_json::Value = serde_json::from_str(&text).ok()?;
3092        let nodes = graph.get("nodes")?.as_array()?;
3093        let node_count = nodes.len();
3094
3095        let mut ext_set = std::collections::HashSet::new();
3096        for n in nodes.iter().take(2000) {
3097            if let Some(sf) = n.get("source_file").and_then(|v| v.as_str()) {
3098                if let Some(ext) = sf.rsplit('.').next() {
3099                    ext_set.insert(ext_to_lang(ext));
3100                }
3101            }
3102        }
3103        let mut languages: Vec<&'static str> =
3104            ext_set.into_iter().filter(|s| !s.is_empty()).collect();
3105        languages.sort_unstable();
3106
3107        let embeddings_enabled = global_dir.join("models").join("potion-code-16M").exists();
3108
3109        telemetry.record_lifecycle(
3110            "index_complete",
3111            serde_json::json!({
3112                "node_count_bucket": codesynapse_core::telemetry::bucket_count(node_count),
3113                "languages": languages,
3114                "index_duration_bucket": codesynapse_core::telemetry::bucket_duration_ms(elapsed_ms),
3115                "embeddings_enabled": embeddings_enabled,
3116            }),
3117        );
3118        telemetry.persist_sync();
3119        telemetry.flush_bg();
3120        Some(())
3121    })();
3122
3123    Ok(())
3124}
3125
3126fn ext_to_lang(ext: &str) -> &'static str {
3127    match ext {
3128        "rs" => "rust",
3129        "py" | "pyi" => "python",
3130        "ts" | "tsx" => "typescript",
3131        "js" | "jsx" | "mjs" | "cjs" => "javascript",
3132        "java" => "java",
3133        "kt" | "kts" => "kotlin",
3134        "go" => "go",
3135        "rb" => "ruby",
3136        "cs" => "csharp",
3137        "cpp" | "cc" | "cxx" | "c" | "h" | "hpp" => "c_cpp",
3138        "swift" => "swift",
3139        "php" => "php",
3140        "scala" => "scala",
3141        "ex" | "exs" => "elixir",
3142        "hs" => "haskell",
3143        "ml" | "mli" => "ocaml",
3144        "lua" => "lua",
3145        "r" => "r",
3146        "jl" => "julia",
3147        "f90" | "f95" | "f03" | "f08" | "for" => "fortran",
3148        "v" | "sv" => "verilog",
3149        "md" | "mdx" => "markdown",
3150        _ => "",
3151    }
3152}
3153
3154fn inject_agent_instructions(
3155    workspace: &std::path::Path,
3156    global_dir: &std::path::Path,
3157) -> std::io::Result<()> {
3158    let conf_path = global_dir.join("modules.conf");
3159    let modules = read_modules_conf(&conf_path);
3160    let module_name = modules
3161        .into_iter()
3162        .next()
3163        .map(|(n, _)| n)
3164        .unwrap_or_else(|| "your-repo".into());
3165
3166    let block = format!(
3167        "<!-- codesynapse:start -->\n\
3168         ## Codesynapse — Code Intelligence\n\n\
3169         Use these CLI commands to answer architecture questions (works in subagents without MCP):\n\
3170         - `codesynapse resolve \"how does X work\"` — hybrid search + source body\n\
3171         - `codesynapse query \"concept name\"` — find relevant symbols\n\n\
3172         Repository indexed as module `{name}`. Re-index: `codesynapse module add --force {name} <path>`.\n\
3173         <!-- codesynapse:end -->",
3174        name = module_name
3175    );
3176
3177    for filename in &["CLAUDE.md", "AGENTS.md", "GEMINI.md"] {
3178        let p = workspace.join(filename);
3179        if p.exists() {
3180            upsert_marker_block(
3181                &p,
3182                "<!-- codesynapse:start -->",
3183                "<!-- codesynapse:end -->",
3184                &block,
3185            )?;
3186            eprintln!("✓ Injected codesynapse guidance into {}", filename);
3187        }
3188    }
3189    Ok(())
3190}
3191
3192fn upsert_marker_block(
3193    path: &std::path::Path,
3194    start: &str,
3195    end: &str,
3196    block: &str,
3197) -> std::io::Result<()> {
3198    let content = std::fs::read_to_string(path)?;
3199    let new_content = if content.contains(start) {
3200        let before = content.split(start).next().unwrap_or("");
3201        let after = content.split(end).last().unwrap_or("");
3202        format!("{}{}{}", before, block, after)
3203    } else {
3204        format!("{}\n\n{}\n", content.trim_end(), block)
3205    };
3206    std::fs::write(path, new_content)
3207}
3208
3209fn strip_marker_block(path: &std::path::Path, start: &str, end: &str) -> std::io::Result<()> {
3210    if !path.exists() {
3211        return Ok(());
3212    }
3213    let content = std::fs::read_to_string(path)?;
3214    if !content.contains(start) {
3215        return Ok(());
3216    }
3217    let before = content.split(start).next().unwrap_or("").trim_end();
3218    let after_end = content.split(end).last().unwrap_or("");
3219    let after = after_end.trim_start_matches('\n');
3220    let new_content = if after.is_empty() {
3221        format!("{}\n", before)
3222    } else {
3223        format!("{}\n\n{}", before, after)
3224    };
3225    std::fs::write(path, new_content)
3226}
3227
3228fn watch_all_modules(global_dir: PathBuf) -> Result<()> {
3229    let conf_path = global_dir.join("modules.conf");
3230    let modules = read_modules_conf(&conf_path);
3231    if modules.is_empty() {
3232        return Err(codesynapse_core::error::CodeSynapseError::Validation(
3233            "no modules registered. Run `codesynapse module add <name> <path>` first".into(),
3234        ));
3235    }
3236
3237    eprintln!(
3238        "Watching {} module(s). Press Ctrl+C to stop.",
3239        modules.len()
3240    );
3241
3242    let handles: Vec<_> = modules
3243        .into_iter()
3244        .map(|(name, path)| {
3245            let gd = global_dir.clone();
3246            std::thread::spawn(move || {
3247                let cfg = WatchConfig {
3248                    root: path.clone(),
3249                    debounce_ms: 500,
3250                };
3251                Watcher::new(cfg).run(move |result| {
3252                    eprintln!(
3253                        "[{}] {} file(s) changed — rebuilding...",
3254                        name,
3255                        result.changed_files.len()
3256                    );
3257                    match do_module_add(&name, &path, true, &gd) {
3258                        Ok(_) => eprintln!("[{}] rebuilt in {}ms", name, result.elapsed_ms),
3259                        Err(e) => eprintln!("[{}] rebuild failed: {}", name, e),
3260                    }
3261                })
3262            })
3263        })
3264        .collect();
3265
3266    for h in handles {
3267        let _ = h.join();
3268    }
3269    Ok(())
3270}
3271
3272fn parse_github_owner_repo(url: &str) -> Option<(String, String)> {
3273    let url = url.trim_end_matches('/').trim_end_matches(".git");
3274    // Match https://github.com/owner/repo or git@github.com:owner/repo
3275    let after_gh = url
3276        .strip_prefix("https://github.com/")
3277        .or_else(|| url.strip_prefix("http://github.com/"))
3278        .or_else(|| url.strip_prefix("git@github.com:"))?;
3279    let mut parts = after_gh.splitn(2, '/');
3280    let owner = parts.next()?.to_string();
3281    let repo = parts.next()?.trim_end_matches(".git").to_string();
3282    if owner.is_empty() || repo.is_empty() {
3283        return None;
3284    }
3285    Some((owner, repo))
3286}
3287
3288pub fn clone_repo(
3289    url: &str,
3290    branch: Option<&str>,
3291    out: Option<&std::path::Path>,
3292) -> Result<PathBuf> {
3293    use codesynapse_core::error::CodeSynapseError;
3294    use std::process::{Command as Proc, Stdio};
3295
3296    let url = url.trim_end_matches('/');
3297    let git_url = if url.ends_with(".git") {
3298        url.to_string()
3299    } else {
3300        format!("{}.git", url)
3301    };
3302    let base_url = url.trim_end_matches(".git");
3303    let repo_slug = base_url
3304        .trim_start_matches("https://github.com/")
3305        .trim_start_matches("http://github.com/")
3306        .trim_start_matches("git@github.com:");
3307
3308    let dest: PathBuf = if let Some(out_path) = out {
3309        out_path.to_path_buf()
3310    } else {
3311        let (owner, repo) = parse_github_owner_repo(url).ok_or_else(|| {
3312            CodeSynapseError::Other(format!("not a recognised GitHub URL: {}", url))
3313        })?;
3314        dirs::home_dir()
3315            .unwrap_or_else(|| PathBuf::from("~"))
3316            .join(".codesynapse")
3317            .join("repos")
3318            .join(owner)
3319            .join(repo)
3320    };
3321
3322    if let Some(b) = branch {
3323        if b.starts_with('-') {
3324            return Err(CodeSynapseError::Other(format!(
3325                "invalid branch name: {:?}",
3326                b
3327            )));
3328        }
3329    }
3330
3331    if dest.exists() {
3332        let mut cmd = Proc::new("git");
3333        cmd.args(["-C", dest.to_str().unwrap_or("."), "pull", "--quiet"]);
3334        if let Some(b) = branch {
3335            cmd.args(["origin", "--", b]);
3336        }
3337        let status = cmd
3338            .stdout(Stdio::null())
3339            .stderr(Stdio::null())
3340            .status()
3341            .map_err(|e| CodeSynapseError::Other(format!("git pull failed: {}", e)))?;
3342        if status.success() {
3343            ui::ok(&format!("Up to date  {}", dest.display()));
3344        } else {
3345            ui::warn(&format!("pull failed — using cached  {}", dest.display()));
3346        }
3347    } else {
3348        dest.parent().map(std::fs::create_dir_all).transpose().ok();
3349        let mut cmd = Proc::new("git");
3350        cmd.args([
3351            "clone",
3352            "--quiet",
3353            "--depth",
3354            "1",
3355            "--single-branch",
3356            "--no-tags",
3357        ]);
3358        if let Some(b) = branch {
3359            cmd.args(["--branch", b]);
3360        }
3361        cmd.args(["--", &git_url, dest.to_str().unwrap_or(".")]);
3362        let status = cmd
3363            .stdout(Stdio::null())
3364            .stderr(Stdio::null())
3365            .status()
3366            .map_err(|e| CodeSynapseError::Other(format!("git clone failed: {}", e)))?;
3367        if !status.success() {
3368            ui::fail(&format!("clone failed  {}", repo_slug));
3369            return Err(CodeSynapseError::Other(
3370                "git clone failed (run with GIT_TERMINAL_PROMPT=0 git clone for details)"
3371                    .to_string(),
3372            ));
3373        }
3374        ui::ok(&format!("Cloned  {}", dest.display()));
3375    }
3376
3377    Ok(dest)
3378}
3379
3380const MODEL_NAME: &str = "potion-code-16M";
3381const MODEL_FILES: &[&str] = &["model.safetensors", "tokenizer.json", "config.json"];
3382
3383fn model_is_valid(dir: &std::path::Path) -> bool {
3384    // Require the two essential files; modules.json is optional
3385    dir.join("model.safetensors").exists() && dir.join("tokenizer.json").exists()
3386}
3387
3388fn try_copy_model(src: &std::path::Path, dst: &std::path::Path) -> bool {
3389    if !model_is_valid(src) {
3390        return false;
3391    }
3392    if std::fs::create_dir_all(dst).is_err() {
3393        return false;
3394    }
3395    for file in MODEL_FILES {
3396        let s = src.join(file);
3397        if s.exists() && std::fs::copy(&s, dst.join(file)).is_err() {
3398            return false;
3399        }
3400    }
3401    true
3402}
3403
3404fn try_download_model(dst: &std::path::Path) -> bool {
3405    if std::fs::create_dir_all(dst).is_err() {
3406        return false;
3407    }
3408    let version = env!("CARGO_PKG_VERSION");
3409    let gh_base = format!(
3410        "https://github.com/sohilladhani/codesynapse/releases/download/v{}/",
3411        version
3412    );
3413    let hf_base = "https://huggingface.co/minishlab/potion-code-16M/resolve/main/";
3414
3415    for file in MODEL_FILES {
3416        let out_path = dst.join(file);
3417        let hf_url = format!("{}{}", hf_base, file);
3418        let gh_url = format!("{}{}", gh_base, file);
3419
3420        let bytes = download_file(&hf_url).or_else(|| download_file(&gh_url));
3421        match bytes {
3422            Some(b) => {
3423                if std::fs::write(&out_path, b).is_err() {
3424                    return false;
3425                }
3426            }
3427            None => return false,
3428        }
3429    }
3430    true
3431}
3432
3433fn download_file(url: &str) -> Option<Vec<u8>> {
3434    let resp = ureq::get(url).call().ok()?;
3435    if resp.status() != 200 {
3436        return None;
3437    }
3438    let mut reader = resp.into_reader();
3439    let mut bytes = Vec::new();
3440    std::io::Read::read_to_end(&mut reader, &mut bytes).ok()?;
3441    Some(bytes)
3442}
3443
3444pub enum ModelStatus {
3445    AlreadyInstalled,
3446    Copied,
3447    Downloaded,
3448    NotFound,
3449}
3450
3451pub fn setup_model() -> ModelStatus {
3452    let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("~"));
3453    let canonical = home.join(".codesynapse").join("models").join(MODEL_NAME);
3454
3455    if model_is_valid(&canonical) {
3456        return ModelStatus::AlreadyInstalled;
3457    }
3458
3459    let search_paths: Vec<PathBuf> = vec![std::env::current_dir()
3460        .unwrap_or_else(|_| PathBuf::from("."))
3461        .join("models")
3462        .join(MODEL_NAME)];
3463
3464    for src in &search_paths {
3465        if model_is_valid(src) && try_copy_model(src, &canonical) {
3466            return ModelStatus::Copied;
3467        }
3468    }
3469
3470    if try_download_model(&canonical) {
3471        return ModelStatus::Downloaded;
3472    }
3473
3474    ModelStatus::NotFound
3475}
3476
3477pub enum ClientStatus {
3478    Registered(PathBuf),
3479    Skipped,
3480    Failed,
3481}
3482
3483/// Register `codesynapse mcp` in every detected AI client config.
3484/// `client_filter`: if Some, only register that client; otherwise register all detected.
3485/// `workspace`: used as base for VS Code `.vscode/mcp.json`.
3486pub fn setup_mcp_clients(
3487    binary: &str,
3488    client_filter: Option<&str>,
3489    workspace: &std::path::Path,
3490) -> Vec<(String, ClientStatus)> {
3491    let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("~"));
3492    let entry_stdio = serde_json::json!({
3493        "type": "stdio",
3494        "command": binary,
3495        "args": ["mcp"],
3496        "env": {}
3497    });
3498
3499    #[allow(clippy::type_complexity)]
3500    let clients: &[(&str, Box<dyn Fn() -> Option<PathBuf>>, &str)] = &[
3501        (
3502            "claude-code",
3503            Box::new({
3504                let h = home.clone();
3505                move || Some(h.join(".claude.json"))
3506            }),
3507            "mcpServers",
3508        ),
3509        (
3510            "cursor",
3511            Box::new({
3512                let h = home.clone();
3513                move || {
3514                    let p = h.join(".cursor").join("mcp.json");
3515                    if h.join(".cursor").exists() || p.exists() {
3516                        Some(p)
3517                    } else {
3518                        None
3519                    }
3520                }
3521            }),
3522            "mcpServers",
3523        ),
3524        (
3525            "windsurf",
3526            Box::new({
3527                let h = home.clone();
3528                move || {
3529                    let p = h.join(".codeium").join("windsurf").join("mcp_config.json");
3530                    if p.parent().map(|d| d.exists()).unwrap_or(false) {
3531                        Some(p)
3532                    } else {
3533                        None
3534                    }
3535                }
3536            }),
3537            "mcpServers",
3538        ),
3539        (
3540            "continue",
3541            Box::new({
3542                let h = home.clone();
3543                move || {
3544                    let p = h.join(".continue").join("config.json");
3545                    if p.parent().map(|d| d.exists()).unwrap_or(false) || p.exists() {
3546                        Some(p)
3547                    } else {
3548                        None
3549                    }
3550                }
3551            }),
3552            "mcpServers",
3553        ),
3554    ];
3555
3556    let mut results: Vec<(String, ClientStatus)> = Vec::new();
3557
3558    for (name, path_fn, key) in clients {
3559        if let Some(f) = client_filter {
3560            if f != *name {
3561                continue;
3562            }
3563        }
3564        match path_fn() {
3565            None => results.push((name.to_string(), ClientStatus::Skipped)),
3566            Some(path) => {
3567                if merge_mcp_entry(&path, key, "codesynapse", entry_stdio.clone()) {
3568                    results.push((name.to_string(), ClientStatus::Registered(path)));
3569                } else {
3570                    results.push((name.to_string(), ClientStatus::Failed));
3571                }
3572            }
3573        }
3574    }
3575
3576    // VS Code — workspace-relative
3577    let vscode_name = "vscode";
3578    if client_filter.map(|f| f == vscode_name).unwrap_or(true) {
3579        let vscode_path = workspace.join(".vscode").join("mcp.json");
3580        let vscode_entry = serde_json::json!({
3581            "type": "stdio",
3582            "command": binary,
3583            "args": ["mcp"]
3584        });
3585        if merge_mcp_entry(&vscode_path, "servers", "codesynapse", vscode_entry) {
3586            results.push((
3587                vscode_name.to_string(),
3588                ClientStatus::Registered(vscode_path),
3589            ));
3590        } else {
3591            results.push((vscode_name.to_string(), ClientStatus::Skipped));
3592        }
3593    }
3594
3595    // Zed — nested under context_servers with different schema
3596    let zed_name = "zed";
3597    if client_filter.map(|f| f == zed_name).unwrap_or(true) {
3598        let zed_path = home.join(".config").join("zed").join("settings.json");
3599        if zed_path.parent().map(|d| d.exists()).unwrap_or(false) || zed_path.exists() {
3600            let zed_entry = serde_json::json!({
3601                "command": {"path": binary, "args": ["mcp"]}
3602            });
3603            if merge_mcp_entry(&zed_path, "context_servers", "codesynapse", zed_entry) {
3604                results.push((zed_name.to_string(), ClientStatus::Registered(zed_path)));
3605            } else {
3606                results.push((zed_name.to_string(), ClientStatus::Failed));
3607            }
3608        } else {
3609            results.push((zed_name.to_string(), ClientStatus::Skipped));
3610        }
3611    }
3612
3613    // JetBrains — glob all installed IDEs
3614    let jb_name = "jetbrains";
3615    if client_filter.map(|f| f == jb_name).unwrap_or(true) {
3616        let jb_base = home.join(".config").join("JetBrains");
3617        let mut jb_found = false;
3618        if jb_base.is_dir() {
3619            if let Ok(entries) = std::fs::read_dir(&jb_base) {
3620                for entry in entries.flatten() {
3621                    if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
3622                        let mcp_path = entry.path().join("mcp.json");
3623                        if merge_mcp_entry(
3624                            &mcp_path,
3625                            "mcpServers",
3626                            "codesynapse",
3627                            entry_stdio.clone(),
3628                        ) {
3629                            results.push((jb_name.to_string(), ClientStatus::Registered(mcp_path)));
3630                            jb_found = true;
3631                        }
3632                    }
3633                }
3634            }
3635        }
3636        if !jb_found {
3637            results.push((jb_name.to_string(), ClientStatus::Skipped));
3638        }
3639    }
3640
3641    // OpenCode — ~/.config/opencode/opencode.json, key "mcp", entry format differs
3642    let opencode_name = "opencode";
3643    if client_filter.map(|f| f == opencode_name).unwrap_or(true) {
3644        let p = home.join(".config").join("opencode").join("opencode.json");
3645        if p.parent().map(|d| d.exists()).unwrap_or(false) || p.exists() {
3646            let opencode_entry = serde_json::json!({
3647                "type": "local",
3648                "command": [binary, "mcp"],
3649                "enabled": true
3650            });
3651            if merge_mcp_entry(&p, "mcp", "codesynapse", opencode_entry) {
3652                results.push((opencode_name.to_string(), ClientStatus::Registered(p)));
3653            } else {
3654                results.push((opencode_name.to_string(), ClientStatus::Failed));
3655            }
3656        } else {
3657            results.push((opencode_name.to_string(), ClientStatus::Skipped));
3658        }
3659    }
3660
3661    // Kiro (AWS) — ~/.kiro/settings/mcp.json
3662    let kiro_name = "kiro";
3663    if client_filter.map(|f| f == kiro_name).unwrap_or(true) {
3664        let kiro_dir = home.join(".kiro");
3665        let p = kiro_dir.join("settings").join("mcp.json");
3666        if kiro_dir.exists() || p.exists() {
3667            if merge_mcp_entry(&p, "mcpServers", "codesynapse", entry_stdio.clone()) {
3668                results.push((kiro_name.to_string(), ClientStatus::Registered(p)));
3669            } else {
3670                results.push((kiro_name.to_string(), ClientStatus::Failed));
3671            }
3672        } else {
3673            results.push((kiro_name.to_string(), ClientStatus::Skipped));
3674        }
3675    }
3676
3677    // Cline (VS Code extension) — Linux/macOS paths
3678    let cline_name = "cline";
3679    if client_filter.map(|f| f == cline_name).unwrap_or(true) {
3680        let cline_paths = [
3681            home.join(".config")
3682                .join("Code")
3683                .join("User")
3684                .join("globalStorage")
3685                .join("saoudrizwan.claude-dev")
3686                .join("settings")
3687                .join("cline_mcp_settings.json"),
3688            home.join("Library")
3689                .join("Application Support")
3690                .join("Code")
3691                .join("User")
3692                .join("globalStorage")
3693                .join("saoudrizwan.claude-dev")
3694                .join("settings")
3695                .join("cline_mcp_settings.json"),
3696        ];
3697        let mut cline_found = false;
3698        for p in &cline_paths {
3699            if (p.parent().map(|d| d.exists()).unwrap_or(false) || p.exists())
3700                && merge_mcp_entry(p, "mcpServers", "codesynapse", entry_stdio.clone())
3701            {
3702                results.push((cline_name.to_string(), ClientStatus::Registered(p.clone())));
3703                cline_found = true;
3704                break;
3705            }
3706        }
3707        if !cline_found {
3708            results.push((cline_name.to_string(), ClientStatus::Skipped));
3709        }
3710    }
3711
3712    results
3713}
3714
3715fn merge_mcp_entry(
3716    config_path: &std::path::Path,
3717    key: &str,
3718    server_name: &str,
3719    entry: serde_json::Value,
3720) -> bool {
3721    if let Some(parent) = config_path.parent() {
3722        if std::fs::create_dir_all(parent).is_err() {
3723            return false;
3724        }
3725    }
3726    let mut root: serde_json::Value = std::fs::read_to_string(config_path)
3727        .ok()
3728        .and_then(|s| serde_json::from_str(&s).ok())
3729        .unwrap_or_else(|| serde_json::json!({}));
3730
3731    if let Some(obj) = root.as_object_mut() {
3732        let bucket = obj.entry(key).or_insert_with(|| serde_json::json!({}));
3733        if let Some(servers) = bucket.as_object_mut() {
3734            servers.insert(server_name.to_string(), entry);
3735        }
3736    }
3737
3738    match serde_json::to_string_pretty(&root) {
3739        Ok(text) => std::fs::write(config_path, text).is_ok(),
3740        Err(_) => false,
3741    }
3742}
3743
3744#[cfg(test)]
3745mod tests {
3746    use super::*;
3747
3748    #[test]
3749    fn test_cli_diff_command() {
3750        let cli = Cli::try_parse_from(["codesynapse", "diff", "/tmp/a", "--baseline", "/tmp/b"]);
3751        assert!(cli.is_ok());
3752        let cli = cli.unwrap();
3753        match cli.command {
3754            Command::Diff { path, baseline } => {
3755                assert_eq!(path, PathBuf::from("/tmp/a"));
3756                assert_eq!(baseline, PathBuf::from("/tmp/b"));
3757            }
3758            other => panic!("Expected Diff, got {:?}", other),
3759        }
3760    }
3761
3762    #[test]
3763    fn test_cli_diff_uses_compare_alias() {
3764        let cli = Cli::try_parse_from(["codesynapse", "compare", "/tmp/a", "--baseline", "/tmp/b"]);
3765        assert!(cli.is_ok(), "compare alias should work: {:?}", cli.err());
3766        let cli = cli.unwrap();
3767        match cli.command {
3768            Command::Diff { .. } => {} // OK — alias resolves to Diff
3769            other => panic!("Expected Diff via compare alias, got {:?}", other),
3770        }
3771    }
3772
3773    #[test]
3774    fn test_cli_ingest_default() {
3775        let cli = Cli::try_parse_from(["codesynapse", "ingest", "https://example.com"]);
3776        assert!(cli.is_ok());
3777        let cli = cli.unwrap();
3778        match cli.command {
3779            Command::Ingest { url, output } => {
3780                assert_eq!(url, "https://example.com");
3781                assert!(output.is_none());
3782            }
3783            other => panic!("Expected Ingest, got {:?}", other),
3784        }
3785    }
3786
3787    #[test]
3788    fn test_cli_ingest_with_output() {
3789        let cli = Cli::try_parse_from([
3790            "codesynapse",
3791            "ingest",
3792            "https://example.com",
3793            "--output",
3794            "/tmp/out.json",
3795        ]);
3796        assert!(cli.is_ok());
3797        let cli = cli.unwrap();
3798        match cli.command {
3799            Command::Ingest { url, output } => {
3800                assert_eq!(url, "https://example.com");
3801                assert_eq!(output, Some(PathBuf::from("/tmp/out.json")));
3802            }
3803            other => panic!("Expected Ingest, got {:?}", other),
3804        }
3805    }
3806
3807    #[test]
3808    fn test_cli_hook_install() {
3809        let cli = Cli::try_parse_from(["codesynapse", "hook", "install"]);
3810        assert!(cli.is_ok());
3811        let cli = cli.unwrap();
3812        match cli.command {
3813            Command::Hook { action } => match action {
3814                HookAction::Install { path } => {
3815                    assert!(path.is_none());
3816                }
3817                other => panic!("Expected Hook::Install, got {:?}", other),
3818            },
3819            other => panic!("Expected Hook, got {:?}", other),
3820        }
3821    }
3822
3823    #[test]
3824    fn test_cli_hook_uninstall() {
3825        let cli = Cli::try_parse_from(["codesynapse", "hook", "uninstall"]);
3826        assert!(cli.is_ok());
3827        let cli = cli.unwrap();
3828        match cli.command {
3829            Command::Hook { action } => match action {
3830                HookAction::Uninstall { path } => {
3831                    assert!(path.is_none());
3832                }
3833                other => panic!("Expected Hook::Uninstall, got {:?}", other),
3834            },
3835            other => panic!("Expected Hook, got {:?}", other),
3836        }
3837    }
3838
3839    #[test]
3840    fn test_cli_hook_status() {
3841        let cli = Cli::try_parse_from(["codesynapse", "hook", "status"]);
3842        assert!(cli.is_ok());
3843        let cli = cli.unwrap();
3844        match cli.command {
3845            Command::Hook { action } => match action {
3846                HookAction::Status { path } => {
3847                    assert!(path.is_none());
3848                }
3849                other => panic!("Expected Hook::Status, got {:?}", other),
3850            },
3851            other => panic!("Expected Hook, got {:?}", other),
3852        }
3853    }
3854
3855    #[test]
3856    fn test_cli_hook_with_path() {
3857        let cli = Cli::try_parse_from(["codesynapse", "hook", "install", "--path", "/tmp/repo"]);
3858        assert!(cli.is_ok());
3859        let cli = cli.unwrap();
3860        match cli.command {
3861            Command::Hook { action } => match action {
3862                HookAction::Install { path } => {
3863                    assert_eq!(path, Some(PathBuf::from("/tmp/repo")));
3864                }
3865                other => panic!("Expected Hook::Install, got {:?}", other),
3866            },
3867            other => panic!("Expected Hook, got {:?}", other),
3868        }
3869    }
3870
3871    #[test]
3872    fn test_cli_init_default() {
3873        let cli = Cli::try_parse_from(["codesynapse", "init"]);
3874        assert!(cli.is_ok());
3875        let cli = cli.unwrap();
3876        match cli.command {
3877            Command::Init { path, force } => {
3878                assert!(path.is_none());
3879                assert!(!force);
3880            }
3881            other => panic!("Expected Init, got {:?}", other),
3882        }
3883    }
3884
3885    #[test]
3886    fn test_cli_init_with_path_and_force() {
3887        let cli = Cli::try_parse_from(["codesynapse", "init", "--path", "/tmp/proj", "--force"]);
3888        assert!(cli.is_ok());
3889        let cli = cli.unwrap();
3890        match cli.command {
3891            Command::Init { path, force } => {
3892                assert_eq!(path, Some(PathBuf::from("/tmp/proj")));
3893                assert!(force);
3894            }
3895            other => panic!("Expected Init, got {:?}", other),
3896        }
3897    }
3898
3899    #[test]
3900    fn test_cli_serve_default() {
3901        let cli = Cli::try_parse_from(["codesynapse", "serve", "/tmp/graph"]);
3902        assert!(cli.is_ok());
3903        let cli = cli.unwrap();
3904        match cli.command {
3905            Command::Serve { graph, path, port } => {
3906                assert!(graph.is_none());
3907                assert_eq!(path, Some(PathBuf::from("/tmp/graph")));
3908                assert_eq!(port, 50051);
3909            }
3910            other => panic!("Expected Serve, got {:?}", other),
3911        }
3912    }
3913
3914    #[test]
3915    fn test_cli_serve_with_graph_flag() {
3916        let cli = Cli::try_parse_from(["codesynapse", "serve", "--graph", "/tmp/store"]);
3917        assert!(cli.is_ok());
3918        let cli = cli.unwrap();
3919        match cli.command {
3920            Command::Serve { graph, path, port } => {
3921                assert_eq!(graph, Some(PathBuf::from("/tmp/store")));
3922                assert!(path.is_none());
3923                assert_eq!(port, 50051);
3924            }
3925            other => panic!("Expected Serve, got {:?}", other),
3926        }
3927    }
3928
3929    #[test]
3930    fn test_cli_build_default() {
3931        let cli = Cli::try_parse_from(["codesynapse", "build", "/tmp/proj"]);
3932        assert!(cli.is_ok());
3933        let cli = cli.unwrap();
3934        match cli.command {
3935            Command::Build {
3936                path,
3937                output,
3938                format,
3939                no_llm,
3940                llm,
3941                code_only,
3942                update,
3943                force,
3944                jobs,
3945            } => {
3946                assert_eq!(path, PathBuf::from("/tmp/proj"));
3947                assert!(output.is_none());
3948                assert!(format.is_none());
3949                assert!(!no_llm);
3950                assert!(!llm);
3951                assert!(!code_only);
3952                assert!(!update);
3953                assert!(!force);
3954                assert!(jobs.is_none());
3955            }
3956            other => panic!("Expected Build, got {:?}", other),
3957        }
3958    }
3959
3960    #[test]
3961    fn test_cli_build_all_flags() {
3962        let cli = Cli::try_parse_from([
3963            "codesynapse",
3964            "build",
3965            "/tmp/proj",
3966            "--output",
3967            "/tmp/out",
3968            "--format",
3969            "json,html,svg",
3970            "--no-llm",
3971            "--code-only",
3972            "--update",
3973            "--force",
3974            "--jobs",
3975            "4",
3976        ]);
3977        assert!(cli.is_ok());
3978        let cli = cli.unwrap();
3979        match cli.command {
3980            Command::Build {
3981                path,
3982                output,
3983                format,
3984                no_llm,
3985                llm: _,
3986                code_only,
3987                update,
3988                force,
3989                jobs,
3990            } => {
3991                assert_eq!(path, PathBuf::from("/tmp/proj"));
3992                assert_eq!(output, Some(PathBuf::from("/tmp/out")));
3993                assert_eq!(
3994                    format,
3995                    Some(vec![
3996                        "json".to_string(),
3997                        "html".to_string(),
3998                        "svg".to_string()
3999                    ])
4000                );
4001                assert!(no_llm);
4002                assert!(code_only);
4003                assert!(update);
4004                assert!(force);
4005                assert_eq!(jobs, Some(4));
4006            }
4007            other => panic!("Expected Build, got {:?}", other),
4008        }
4009    }
4010
4011    #[test]
4012    fn test_cli_watch_default() {
4013        let cli = Cli::try_parse_from(["codesynapse", "watch"]);
4014        assert!(cli.is_ok());
4015        let cli = cli.unwrap();
4016        match cli.command {
4017            Command::Watch { path, output } => {
4018                assert!(path.is_none());
4019                assert!(output.is_none());
4020            }
4021            other => panic!("Expected Watch, got {:?}", other),
4022        }
4023    }
4024
4025    #[test]
4026    fn test_cli_watch_with_flags() {
4027        let cli = Cli::try_parse_from([
4028            "codesynapse",
4029            "watch",
4030            "--path",
4031            "/tmp/proj",
4032            "--output",
4033            "/tmp/out",
4034        ]);
4035        assert!(cli.is_ok());
4036        let cli = cli.unwrap();
4037        match cli.command {
4038            Command::Watch { path, output } => {
4039                assert_eq!(path, Some(PathBuf::from("/tmp/proj")));
4040                assert_eq!(output, Some(PathBuf::from("/tmp/out")));
4041            }
4042            other => panic!("Expected Watch, got {:?}", other),
4043        }
4044    }
4045
4046    #[test]
4047    fn test_cli_build_format_single() {
4048        let cli = Cli::try_parse_from(["codesynapse", "build", "/tmp/proj", "--format", "json"]);
4049        assert!(cli.is_ok());
4050        let cli = cli.unwrap();
4051        match cli.command {
4052            Command::Build { format, .. } => {
4053                assert_eq!(format, Some(vec!["json".to_string()]));
4054            }
4055            other => panic!("Expected Build, got {:?}", other),
4056        }
4057    }
4058
4059    #[test]
4060    fn test_cli_completions_bash() {
4061        let cli = Cli::try_parse_from(["codesynapse", "completions", "bash"]);
4062        assert!(cli.is_ok());
4063        let cli = cli.unwrap();
4064        match cli.command {
4065            Command::Completions { shell } => {
4066                assert_eq!(shell, "bash");
4067            }
4068            other => panic!("Expected Completions, got {:?}", other),
4069        }
4070    }
4071
4072    #[test]
4073    fn test_cli_completions_zsh() {
4074        let cli = Cli::try_parse_from(["codesynapse", "completions", "zsh"]);
4075        assert!(cli.is_ok());
4076        let cli = cli.unwrap();
4077        match cli.command {
4078            Command::Completions { shell } => {
4079                assert_eq!(shell, "zsh");
4080            }
4081            other => panic!("Expected Completions, got {:?}", other),
4082        }
4083    }
4084
4085    #[test]
4086    fn test_cli_save_result_default() {
4087        let cli = Cli::try_parse_from(["codesynapse", "save-result"]);
4088        assert!(cli.is_ok());
4089        let cli = cli.unwrap();
4090        match cli.command {
4091            Command::SaveResult { output } => {
4092                assert!(output.is_none());
4093            }
4094            other => panic!("Expected SaveResult, got {:?}", other),
4095        }
4096    }
4097
4098    #[test]
4099    fn test_cli_save_result_with_output() {
4100        let cli = Cli::try_parse_from(["codesynapse", "save-result", "-o", "/tmp/result.json"]);
4101        assert!(cli.is_ok());
4102        let cli = cli.unwrap();
4103        match cli.command {
4104            Command::SaveResult { output } => {
4105                assert_eq!(output, Some(PathBuf::from("/tmp/result.json")));
4106            }
4107            other => panic!("Expected SaveResult, got {:?}", other),
4108        }
4109    }
4110
4111    #[test]
4112    fn test_cli_install_platform() {
4113        let cli = Cli::try_parse_from(["codesynapse", "install", "--platform", "cursor"]);
4114        assert!(cli.is_ok());
4115        let cli = cli.unwrap();
4116        match cli.command {
4117            Command::Install { platform } => {
4118                assert_eq!(platform, "cursor");
4119            }
4120            other => panic!("Expected Install, got {:?}", other),
4121        }
4122    }
4123
4124    #[test]
4125    fn test_cli_platform_commands() {
4126        for cmd in &[
4127            "claude",
4128            "opencode",
4129            "codex",
4130            "codebuddy",
4131            "claw",
4132            "droid",
4133            "cursor",
4134            "vscode",
4135            "aider",
4136            "copilot",
4137            "trae",
4138            "trae-cn",
4139            "antigravity",
4140            "hermes",
4141            "kiro",
4142            "pi",
4143            "devin",
4144        ] {
4145            for action in &["install", "uninstall"] {
4146                let args = vec![
4147                    "codesynapse".to_string(),
4148                    cmd.to_string(),
4149                    action.to_string(),
4150                ];
4151                let cli = Cli::try_parse_from(args);
4152                assert!(cli.is_ok(), "Failed: {} {}: {:?}", cmd, action, cli.err());
4153                let cli = cli.unwrap();
4154                match cli.command {
4155                    Command::Claude { .. }
4156                    | Command::Opencode { .. }
4157                    | Command::Codex { .. }
4158                    | Command::Codebuddy { .. }
4159                    | Command::Claw { .. }
4160                    | Command::Droid { .. }
4161                    | Command::Cursor { .. }
4162                    | Command::Vscode { .. }
4163                    | Command::Aider { .. }
4164                    | Command::Copilot { .. }
4165                    | Command::Trae { .. }
4166                    | Command::TraeCn { .. }
4167                    | Command::Antigravity { .. }
4168                    | Command::Hermes { .. }
4169                    | Command::Kiro { .. }
4170                    | Command::Pi { .. }
4171                    | Command::Devin { .. } => {} // OK
4172                    other => panic!(
4173                        "Expected platform command for {} {}, got {:?}",
4174                        cmd, action, other
4175                    ),
4176                }
4177            }
4178        }
4179    }
4180
4181    #[test]
4182    fn test_affected_cli_reverse_traverses_impact_edges() {
4183        use codesynapse_core::affected::{format_affected, DEFAULT_AFFECTED_RELATIONS};
4184        use codesynapse_core::types::{Edge, GraphData, Node};
4185        use std::collections::HashMap;
4186
4187        fn n(id: &str, label: &str, src: &str) -> Node {
4188            Node {
4189                id: id.into(),
4190                label: label.into(),
4191                file_type: "code".into(),
4192                source_file: src.into(),
4193                source_location: None,
4194                community: None,
4195                rationale: None,
4196                metadata: HashMap::new(),
4197                docstring: None,
4198            }
4199        }
4200        fn e(src: &str, tgt: &str, rel: &str) -> Edge {
4201            Edge {
4202                source: src.into(),
4203                target: tgt.into(),
4204                relation: rel.into(),
4205                confidence: "EXTRACTED".into(),
4206                source_file: None,
4207                weight: 1.0,
4208                context: None,
4209            }
4210        }
4211        let g = GraphData {
4212            nodes: vec![
4213                n("target", "Foo", "pkg/foo.py"),
4214                n("caller", "X()", "app.py"),
4215                n("barrel", "__init__.py", "pkg/__init__.py"),
4216                n("consumer", "app.py", "app.py"),
4217            ],
4218            edges: vec![
4219                e("caller", "target", "calls"),
4220                e("barrel", "target", "re_exports"),
4221                e("consumer", "target", "imports"),
4222            ],
4223            hyperedges: None,
4224        };
4225        let out = format_affected(&g, "Foo", DEFAULT_AFFECTED_RELATIONS, 2);
4226        assert!(
4227            out.contains("Affected nodes for Foo"),
4228            "header missing: {out}"
4229        );
4230        assert!(out.contains("X()"), "X() missing: {out}");
4231        assert!(out.contains("calls"), "calls missing: {out}");
4232        assert!(out.contains("__init__.py"), "__init__ missing: {out}");
4233        assert!(out.contains("re_exports"), "re_exports missing: {out}");
4234        assert!(out.contains("imports"), "imports missing: {out}");
4235    }
4236
4237    #[test]
4238    fn test_affected_cli_relation_filter_limits_reverse_traversal() {
4239        use codesynapse_core::affected::format_affected;
4240        use codesynapse_core::types::{Edge, GraphData, Node};
4241        use std::collections::HashMap;
4242
4243        fn n(id: &str, label: &str, src: &str) -> Node {
4244            Node {
4245                id: id.into(),
4246                label: label.into(),
4247                file_type: "code".into(),
4248                source_file: src.into(),
4249                source_location: None,
4250                community: None,
4251                rationale: None,
4252                metadata: HashMap::new(),
4253                docstring: None,
4254            }
4255        }
4256        fn e(src: &str, tgt: &str, rel: &str) -> Edge {
4257            Edge {
4258                source: src.into(),
4259                target: tgt.into(),
4260                relation: rel.into(),
4261                confidence: "EXTRACTED".into(),
4262                source_file: None,
4263                weight: 1.0,
4264                context: None,
4265            }
4266        }
4267        let g = GraphData {
4268            nodes: vec![
4269                n("target", "Foo", "pkg/foo.py"),
4270                n("caller", "X()", "app.py"),
4271                n("barrel", "__init__.py", "pkg/__init__.py"),
4272                n("consumer", "app.py", "app.py"),
4273            ],
4274            edges: vec![
4275                e("caller", "target", "calls"),
4276                e("barrel", "target", "re_exports"),
4277                e("consumer", "target", "imports"),
4278            ],
4279            hyperedges: None,
4280        };
4281        let out = format_affected(&g, "Foo", &["calls"], 2);
4282        assert!(
4283            out.contains("Relations: calls"),
4284            "relations header missing: {out}"
4285        );
4286        assert!(out.contains("X()"), "X() missing: {out}");
4287        assert!(
4288            !out.contains("__init__.py"),
4289            "__init__ should not appear: {out}"
4290        );
4291    }
4292
4293    #[test]
4294    fn test_extract_json_output() {
4295        use tempfile::tempdir;
4296        let dir = tempdir().unwrap();
4297        std::fs::write(dir.path().join("foo.py"), b"def hello():\n    pass\n").unwrap();
4298        let json = extract_path_to_json(dir.path()).unwrap();
4299        let arr: serde_json::Value = serde_json::from_str(&json).unwrap();
4300        assert!(arr.is_array(), "expected JSON array, got: {json}");
4301        let arr = arr.as_array().unwrap();
4302        assert_eq!(arr.len(), 1, "expected 1 fragment for 1 code file");
4303        let frag = &arr[0];
4304        assert!(frag["nodes"].is_array(), "nodes must be array");
4305        assert!(frag["edges"].is_array(), "edges must be array");
4306        let nodes = frag["nodes"].as_array().unwrap();
4307        assert!(
4308            nodes
4309                .iter()
4310                .any(|n| n["label"].as_str().unwrap_or("").contains("hello")),
4311            "hello fn node missing; nodes: {nodes:?}"
4312        );
4313    }
4314
4315    #[test]
4316    fn test_extract_json_empty_dir() {
4317        use tempfile::tempdir;
4318        let dir = tempdir().unwrap();
4319        let json = extract_path_to_json(dir.path()).unwrap();
4320        let arr: serde_json::Value = serde_json::from_str(&json).unwrap();
4321        assert!(arr.is_array());
4322        assert_eq!(arr.as_array().unwrap().len(), 0, "empty dir → empty array");
4323    }
4324
4325    #[test]
4326    fn test_cli_global_add_parses() {
4327        let cli = Cli::try_parse_from(["codesynapse", "global", "add", "/tmp/graph.json"]);
4328        assert!(cli.is_ok(), "parse failed: {:?}", cli.err());
4329        match cli.unwrap().command {
4330            Command::Global {
4331                action: GlobalAction::Add { path, tag },
4332            } => {
4333                assert_eq!(path, PathBuf::from("/tmp/graph.json"));
4334                assert!(tag.is_none());
4335            }
4336            other => panic!("Expected Global::Add, got {:?}", other),
4337        }
4338    }
4339
4340    #[test]
4341    fn test_cli_global_add_with_tag() {
4342        let cli = Cli::try_parse_from([
4343            "codesynapse",
4344            "global",
4345            "add",
4346            "/tmp/graph.json",
4347            "--as",
4348            "myrepo",
4349        ]);
4350        assert!(cli.is_ok(), "parse failed: {:?}", cli.err());
4351        match cli.unwrap().command {
4352            Command::Global {
4353                action: GlobalAction::Add { tag, .. },
4354            } => {
4355                assert_eq!(tag, Some("myrepo".to_string()));
4356            }
4357            other => panic!("Expected Global::Add with tag, got {:?}", other),
4358        }
4359    }
4360
4361    #[test]
4362    fn test_cli_global_remove_parses() {
4363        let cli = Cli::try_parse_from(["codesynapse", "global", "remove", "myrepo"]);
4364        assert!(cli.is_ok(), "parse failed: {:?}", cli.err());
4365        match cli.unwrap().command {
4366            Command::Global {
4367                action: GlobalAction::Remove { tag },
4368            } => {
4369                assert_eq!(tag, "myrepo");
4370            }
4371            other => panic!("Expected Global::Remove, got {:?}", other),
4372        }
4373    }
4374
4375    #[test]
4376    fn test_cli_global_list_parses() {
4377        let cli = Cli::try_parse_from(["codesynapse", "global", "list"]);
4378        assert!(cli.is_ok(), "parse failed: {:?}", cli.err());
4379        match cli.unwrap().command {
4380            Command::Global {
4381                action: GlobalAction::List,
4382            } => {}
4383            other => panic!("Expected Global::List, got {:?}", other),
4384        }
4385    }
4386
4387    #[test]
4388    fn test_cli_global_path_parses() {
4389        let cli = Cli::try_parse_from(["codesynapse", "global", "path"]);
4390        assert!(cli.is_ok(), "parse failed: {:?}", cli.err());
4391        match cli.unwrap().command {
4392            Command::Global {
4393                action: GlobalAction::Path,
4394            } => {}
4395            other => panic!("Expected Global::Path, got {:?}", other),
4396        }
4397    }
4398
4399    #[test]
4400    fn test_cli_global_add_round_trip() {
4401        use codesynapse_core::global_graph::{global_add, global_list, global_remove};
4402        use tempfile::tempdir;
4403
4404        let global_dir = tempdir().unwrap();
4405        let src_dir = tempdir().unwrap();
4406
4407        let graph = serde_json::json!({
4408            "directed": false, "multigraph": false, "graph": {},
4409            "nodes": [{"id": "n1", "label": "Main", "file_type": "code", "source_file": "main.rs",
4410                       "metadata": {}}],
4411            "links": []
4412        });
4413        let graph_path = src_dir.path().join("graph.json");
4414        std::fs::write(&graph_path, serde_json::to_string(&graph).unwrap()).unwrap();
4415
4416        let result = global_add(&graph_path, "testrepo", global_dir.path()).unwrap();
4417        assert_eq!(result.repo_tag, "testrepo");
4418        assert!(!result.skipped);
4419        assert_eq!(result.nodes_added, 1);
4420
4421        let repos = global_list(global_dir.path());
4422        assert!(repos.contains_key("testrepo"), "testrepo missing from list");
4423        assert_eq!(repos["testrepo"].node_count, 1);
4424
4425        let removed = global_remove("testrepo", global_dir.path()).unwrap();
4426        assert!(removed >= 1);
4427        let repos_after = global_list(global_dir.path());
4428        assert!(!repos_after.contains_key("testrepo"));
4429    }
4430
4431    #[test]
4432    fn test_cli_diagnose_parses() {
4433        let cli = Cli::try_parse_from(["codesynapse", "diagnose", "--graph", "/tmp/graph.json"]);
4434        assert!(cli.is_ok());
4435        let cmd = cli.unwrap().command;
4436        assert!(matches!(
4437            cmd,
4438            Command::Diagnose {
4439                json: false,
4440                directed: false,
4441                undirected: false,
4442                ..
4443            }
4444        ));
4445    }
4446
4447    #[test]
4448    fn test_cli_diagnose_json_flag() {
4449        let cli = Cli::try_parse_from([
4450            "codesynapse",
4451            "diagnose",
4452            "--graph",
4453            "/tmp/graph.json",
4454            "--json",
4455        ]);
4456        assert!(cli.is_ok());
4457        let cmd = cli.unwrap().command;
4458        assert!(matches!(cmd, Command::Diagnose { json: true, .. }));
4459    }
4460
4461    #[test]
4462    fn test_cli_diagnose_directed_flag() {
4463        let cli = Cli::try_parse_from([
4464            "codesynapse",
4465            "diagnose",
4466            "--graph",
4467            "/tmp/g.json",
4468            "--directed",
4469        ]);
4470        assert!(cli.is_ok());
4471        let cmd = cli.unwrap().command;
4472        assert!(matches!(cmd, Command::Diagnose { directed: true, .. }));
4473    }
4474
4475    #[test]
4476    fn test_cli_diagnose_integration() {
4477        use codesynapse_core::diagnostics::{diagnose_file, format_diagnostic_report};
4478        use tempfile::tempdir;
4479
4480        let dir = tempdir().unwrap();
4481        let graph = serde_json::json!({
4482            "directed": true, "multigraph": false, "graph": {},
4483            "nodes": [
4484                {"id": "a", "label": "A", "file_type": "code", "source_file": "a.rs", "metadata": {}},
4485                {"id": "b", "label": "B", "file_type": "code", "source_file": "b.rs", "metadata": {}}
4486            ],
4487            "links": [
4488                {"source": "a", "target": "b", "relation": "calls", "confidence": "EXTRACTED",
4489                 "source_file": "a.rs", "weight": 1.0}
4490            ]
4491        });
4492        let path = dir.path().join("graph.json");
4493        std::fs::write(&path, serde_json::to_string(&graph).unwrap()).unwrap();
4494
4495        let summary = diagnose_file(&path, None, 3, None).unwrap();
4496        let report = format_diagnostic_report(&summary);
4497        assert!(report.contains("MultiDiGraph"));
4498        assert_eq!(summary.raw_edge_count, 1);
4499        assert_eq!(summary.node_count, 2);
4500    }
4501
4502    #[test]
4503    fn test_cli_callflow_html_parses() {
4504        let cli = Cli::try_parse_from([
4505            "codesynapse",
4506            "callflow-html",
4507            "--output",
4508            "out.html",
4509            "--max-sections",
4510            "4",
4511        ]);
4512        assert!(cli.is_ok(), "callflow-html should parse: {:?}", cli.err());
4513        assert!(matches!(cli.unwrap().command, Command::CallflowHtml { .. }));
4514    }
4515
4516    #[test]
4517    fn test_cli_callflow_html_positional_graph() {
4518        let cli = Cli::try_parse_from([
4519            "codesynapse",
4520            "callflow-html",
4521            "/tmp/graph.json",
4522            "--output",
4523            "out.html",
4524        ]);
4525        assert!(
4526            cli.is_ok(),
4527            "callflow-html with positional graph should parse"
4528        );
4529        if let Command::CallflowHtml { graph_path, .. } = cli.unwrap().command {
4530            assert!(graph_path.is_some());
4531        } else {
4532            panic!("expected CallflowHtml");
4533        }
4534    }
4535
4536    #[test]
4537    fn test_cli_callflow_html_integration() {
4538        use codesynapse_core::callflow_html::write_callflow_html;
4539        use tempfile::tempdir;
4540
4541        let dir = tempdir().unwrap();
4542        let out = dir.path().join("codesynapse-out");
4543        std::fs::create_dir_all(&out).unwrap();
4544
4545        let graph = serde_json::json!({
4546            "directed": false, "multigraph": false, "graph": {},
4547            "nodes": [
4548                {"id": "a", "label": "ApiClient", "source_file": "src/api.py", "file_type": "code", "community": 0},
4549                {"id": "b", "label": "run()", "source_file": "src/main.py", "file_type": "code", "community": 0},
4550                {"id": "c", "label": "write_html()", "source_file": "src/export.py", "file_type": "code", "community": 1}
4551            ],
4552            "links": [
4553                {"source": "a", "target": "b", "relation": "calls", "confidence": "EXTRACTED", "confidence_score": 1.0}
4554            ],
4555            "hyperedges": []
4556        });
4557        std::fs::write(
4558            out.join("graph.json"),
4559            serde_json::to_string(&graph).unwrap(),
4560        )
4561        .unwrap();
4562        std::fs::write(
4563            out.join(".codesynapse_labels.json"),
4564            r#"{"0":"Runtime","1":"Export"}"#,
4565        )
4566        .unwrap();
4567
4568        let html_path = write_callflow_html(
4569            Some(dir.path()),
4570            None,
4571            None,
4572            None,
4573            None,
4574            None,
4575            Some(&out.join("callflow.html")),
4576            "en",
4577            4,
4578            1.0,
4579            18,
4580            24,
4581            false,
4582        )
4583        .unwrap();
4584
4585        let content = std::fs::read_to_string(&html_path).unwrap();
4586        assert!(content.contains("mermaid"), "HTML should contain mermaid");
4587        assert!(
4588            content.contains("ApiClient"),
4589            "HTML should contain node label"
4590        );
4591        assert!(html_path.exists());
4592    }
4593
4594    #[test]
4595    fn test_cli_callflow_html_max_sections_flag() {
4596        let cli = Cli::try_parse_from([
4597            "codesynapse",
4598            "callflow-html",
4599            "--max-sections",
4600            "6",
4601            "--diagram-scale",
4602            "1.2",
4603        ]);
4604        assert!(cli.is_ok());
4605        if let Command::CallflowHtml {
4606            max_sections,
4607            diagram_scale,
4608            ..
4609        } = cli.unwrap().command
4610        {
4611            assert_eq!(max_sections, 6);
4612            assert!((diagram_scale - 1.2).abs() < 0.01);
4613        }
4614    }
4615
4616    #[test]
4617    fn test_cli_callflow_html_graph_flag() {
4618        let cli = Cli::try_parse_from([
4619            "codesynapse",
4620            "callflow-html",
4621            "--graph",
4622            "/tmp/custom.json",
4623        ]);
4624        assert!(cli.is_ok());
4625        if let Command::CallflowHtml { graph, .. } = cli.unwrap().command {
4626            assert!(graph.is_some());
4627        }
4628    }
4629
4630    #[test]
4631    fn test_cli_clone_parses() {
4632        let cli = Cli::try_parse_from(["codesynapse", "clone", "https://github.com/owner/repo"]);
4633        assert!(cli.is_ok(), "parse failed: {:?}", cli.err());
4634        match cli.unwrap().command {
4635            Command::Clone { url, branch, out } => {
4636                assert_eq!(url, "https://github.com/owner/repo");
4637                assert!(branch.is_none());
4638                assert!(out.is_none());
4639            }
4640            other => panic!("Expected Clone, got {:?}", other),
4641        }
4642    }
4643
4644    #[test]
4645    fn test_cli_clone_with_branch() {
4646        let cli = Cli::try_parse_from([
4647            "codesynapse",
4648            "clone",
4649            "https://github.com/owner/repo",
4650            "--branch",
4651            "main",
4652        ]);
4653        assert!(cli.is_ok());
4654        match cli.unwrap().command {
4655            Command::Clone { branch, .. } => assert_eq!(branch.as_deref(), Some("main")),
4656            other => panic!("Expected Clone, got {:?}", other),
4657        }
4658    }
4659
4660    #[test]
4661    fn test_cli_clone_with_out() {
4662        let cli = Cli::try_parse_from([
4663            "codesynapse",
4664            "clone",
4665            "https://github.com/owner/repo",
4666            "--out",
4667            "/tmp/myrepo",
4668        ]);
4669        assert!(cli.is_ok());
4670        match cli.unwrap().command {
4671            Command::Clone { out, .. } => {
4672                assert_eq!(out, Some(PathBuf::from("/tmp/myrepo")));
4673            }
4674            other => panic!("Expected Clone, got {:?}", other),
4675        }
4676    }
4677
4678    #[test]
4679    fn test_parse_github_owner_repo_https() {
4680        let result = parse_github_owner_repo("https://github.com/owner/myrepo");
4681        assert_eq!(result, Some(("owner".into(), "myrepo".into())));
4682    }
4683
4684    #[test]
4685    fn test_parse_github_owner_repo_git_suffix() {
4686        let result = parse_github_owner_repo("https://github.com/owner/myrepo.git");
4687        assert_eq!(result, Some(("owner".into(), "myrepo".into())));
4688    }
4689
4690    #[test]
4691    fn test_parse_github_owner_repo_ssh() {
4692        let result = parse_github_owner_repo("git@github.com:owner/myrepo.git");
4693        assert_eq!(result, Some(("owner".into(), "myrepo".into())));
4694    }
4695
4696    #[test]
4697    fn test_parse_github_owner_repo_invalid() {
4698        assert!(parse_github_owner_repo("https://gitlab.com/owner/repo").is_none());
4699        assert!(parse_github_owner_repo("not-a-url").is_none());
4700    }
4701
4702    #[test]
4703    fn test_clone_repo_invalid_branch() {
4704        let result = clone_repo("https://github.com/owner/repo", Some("-bad-branch"), None);
4705        assert!(result.is_err());
4706        let msg = result.unwrap_err().to_string();
4707        assert!(msg.contains("invalid branch name"), "got: {}", msg);
4708    }
4709
4710    #[test]
4711    fn test_clone_repo_invalid_url() {
4712        let result = clone_repo("https://notgithub.com/owner/repo", None, None);
4713        assert!(result.is_err());
4714        let msg = result.unwrap_err().to_string();
4715        assert!(msg.contains("not a recognised GitHub URL"), "got: {}", msg);
4716    }
4717
4718    #[test]
4719    fn test_clone_repo_out_dir_path() {
4720        use tempfile::tempdir;
4721        let tmp = tempdir().unwrap();
4722        let dest = tmp.path().join("clone_target");
4723        std::fs::create_dir_all(&dest).unwrap();
4724        // dest exists → triggers pull path; git pull will fail gracefully (no git repo)
4725        // we just verify the function returns dest without panicking
4726        let result = clone_repo("https://github.com/owner/repo", None, Some(&dest));
4727        // pull will fail (not a git repo) but we only emit a warning — result is Ok
4728        assert!(result.is_ok(), "unexpected err: {:?}", result.err());
4729        assert_eq!(result.unwrap(), dest);
4730    }
4731
4732    #[test]
4733    fn test_cli_merge_driver_parses() {
4734        let cli = Cli::try_parse_from([
4735            "codesynapse",
4736            "merge-driver",
4737            "/tmp/base.json",
4738            "/tmp/cur.json",
4739            "/tmp/oth.json",
4740        ]);
4741        assert!(cli.is_ok(), "parse failed: {:?}", cli.err());
4742        match cli.unwrap().command {
4743            Command::MergeDriver {
4744                base,
4745                current,
4746                other,
4747            } => {
4748                assert_eq!(base, PathBuf::from("/tmp/base.json"));
4749                assert_eq!(current, PathBuf::from("/tmp/cur.json"));
4750                assert_eq!(other, PathBuf::from("/tmp/oth.json"));
4751            }
4752            other => panic!("Expected MergeDriver, got {:?}", other),
4753        }
4754    }
4755
4756    #[test]
4757    fn test_cli_merge_driver_union_nodes() {
4758        use tempfile::tempdir;
4759        let tmp = tempdir().unwrap();
4760        let base = tmp.path().join("base.json");
4761        let current = tmp.path().join("current.json");
4762        let other = tmp.path().join("other.json");
4763
4764        let cur_json = r#"{"nodes":[{"id":"a","label":"A"}],"edges":[]}"#;
4765        let oth_json = r#"{"nodes":[{"id":"b","label":"B"}],"edges":[]}"#;
4766        std::fs::write(&base, cur_json).unwrap();
4767        std::fs::write(&current, cur_json).unwrap();
4768        std::fs::write(&other, oth_json).unwrap();
4769
4770        // Run merge-driver logic directly (can't call run() — it calls process::exit)
4771        // Instead exercise the JSON merge logic inline.
4772        let val_cur: serde_json::Value = serde_json::from_str(cur_json).unwrap();
4773        let val_oth: serde_json::Value = serde_json::from_str(oth_json).unwrap();
4774
4775        let mut seen_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
4776        let mut merged_nodes: Vec<serde_json::Value> = Vec::new();
4777        for n in val_cur["nodes"]
4778            .as_array()
4779            .unwrap()
4780            .iter()
4781            .chain(val_oth["nodes"].as_array().unwrap().iter())
4782        {
4783            let id = n
4784                .get("id")
4785                .and_then(|v| v.as_str())
4786                .unwrap_or("")
4787                .to_string();
4788            if id.is_empty() || seen_ids.insert(id) {
4789                merged_nodes.push(n.clone());
4790            }
4791        }
4792        assert_eq!(merged_nodes.len(), 2);
4793        let ids: Vec<&str> = merged_nodes
4794            .iter()
4795            .map(|n| n["id"].as_str().unwrap())
4796            .collect();
4797        assert!(ids.contains(&"a"));
4798        assert!(ids.contains(&"b"));
4799        let _ = (base, current, other);
4800    }
4801
4802    #[test]
4803    fn test_cli_merge_driver_dedup_nodes() {
4804        let cur_json = r#"{"nodes":[{"id":"a","label":"A"}],"edges":[]}"#;
4805        let oth_json =
4806            r#"{"nodes":[{"id":"a","label":"A-other"},{"id":"b","label":"B"}],"edges":[]}"#;
4807
4808        let val_cur: serde_json::Value = serde_json::from_str(cur_json).unwrap();
4809        let val_oth: serde_json::Value = serde_json::from_str(oth_json).unwrap();
4810
4811        let mut seen_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
4812        let mut merged_nodes: Vec<serde_json::Value> = Vec::new();
4813        for n in val_cur["nodes"]
4814            .as_array()
4815            .unwrap()
4816            .iter()
4817            .chain(val_oth["nodes"].as_array().unwrap().iter())
4818        {
4819            let id = n
4820                .get("id")
4821                .and_then(|v| v.as_str())
4822                .unwrap_or("")
4823                .to_string();
4824            if id.is_empty() || seen_ids.insert(id) {
4825                merged_nodes.push(n.clone());
4826            }
4827        }
4828        // "a" appears in both — only current's version kept
4829        assert_eq!(merged_nodes.len(), 2);
4830        let a_node = merged_nodes
4831            .iter()
4832            .find(|n| n["id"].as_str() == Some("a"))
4833            .unwrap();
4834        assert_eq!(a_node["label"].as_str(), Some("A"));
4835    }
4836
4837    #[test]
4838    fn test_cli_merge_driver_dedup_edges() {
4839        let cur_json = r#"{"nodes":[],"edges":[{"source":"a","target":"b","relation":"calls"}]}"#;
4840        let oth_json = r#"{"nodes":[],"edges":[{"source":"a","target":"b","relation":"calls"},{"source":"b","target":"c","relation":"imports"}]}"#;
4841
4842        let val_cur: serde_json::Value = serde_json::from_str(cur_json).unwrap();
4843        let val_oth: serde_json::Value = serde_json::from_str(oth_json).unwrap();
4844
4845        let mut seen_edges: std::collections::HashSet<String> = std::collections::HashSet::new();
4846        let mut merged_edges: Vec<serde_json::Value> = Vec::new();
4847        for e in val_cur["edges"]
4848            .as_array()
4849            .unwrap()
4850            .iter()
4851            .chain(val_oth["edges"].as_array().unwrap().iter())
4852        {
4853            let src = e.get("source").and_then(|v| v.as_str()).unwrap_or("");
4854            let tgt = e.get("target").and_then(|v| v.as_str()).unwrap_or("");
4855            let rel = e.get("relation").and_then(|v| v.as_str()).unwrap_or("");
4856            let key = format!("{}|{}|{}", src, tgt, rel);
4857            if seen_edges.insert(key) {
4858                merged_edges.push(e.clone());
4859            }
4860        }
4861        assert_eq!(merged_edges.len(), 2);
4862    }
4863
4864    #[test]
4865    fn test_cli_merge_driver_links_key_preserved() {
4866        // NetworkX format uses "links" not "edges"
4867        let cur_json = r#"{"directed":true,"nodes":[{"id":"a"}],"links":[{"source":"a","target":"b","relation":"calls"}]}"#;
4868        let oth_json = r#"{"directed":true,"nodes":[{"id":"b"}],"links":[]}"#;
4869
4870        let val_cur: serde_json::Value = serde_json::from_str(cur_json).unwrap();
4871        let edges_key = if val_cur.get("links").is_some() {
4872            "links"
4873        } else {
4874            "edges"
4875        };
4876        assert_eq!(edges_key, "links");
4877
4878        let get_edges = |v: &serde_json::Value| -> Vec<serde_json::Value> {
4879            v.get("edges")
4880                .or_else(|| v.get("links"))
4881                .and_then(|e| e.as_array())
4882                .cloned()
4883                .unwrap_or_default()
4884        };
4885        let edges_cur = get_edges(&val_cur);
4886        let val_oth: serde_json::Value = serde_json::from_str(oth_json).unwrap();
4887        let edges_oth = get_edges(&val_oth);
4888        assert_eq!(edges_cur.len(), 1);
4889        assert_eq!(edges_oth.len(), 0);
4890
4891        let mut output = val_cur.clone();
4892        if let Some(obj) = output.as_object_mut() {
4893            obj.insert(
4894                "nodes".to_string(),
4895                serde_json::json!([{"id":"a"},{"id":"b"}]),
4896            );
4897            obj.insert(edges_key.to_string(), serde_json::Value::Array(edges_cur));
4898        }
4899        assert!(output.get("links").is_some(), "links key must be preserved");
4900        assert!(output.get("edges").is_none(), "edges key must not appear");
4901        let _ = oth_json;
4902    }
4903
4904    #[test]
4905    fn test_cli_prs_default_parses() {
4906        let cli = Cli::try_parse_from(["codesynapse", "prs"]).unwrap();
4907        match cli.command {
4908            Command::Prs {
4909                base,
4910                repo,
4911                limit,
4912                graph,
4913            } => {
4914                assert!(base.is_none());
4915                assert!(repo.is_none());
4916                assert_eq!(limit, 50);
4917                assert!(graph.is_none());
4918            }
4919            _ => panic!("expected Prs"),
4920        }
4921    }
4922
4923    #[test]
4924    fn test_cli_prs_with_base_parses() {
4925        let cli = Cli::try_parse_from(["codesynapse", "prs", "--base", "main"]).unwrap();
4926        match cli.command {
4927            Command::Prs { base, .. } => assert_eq!(base.as_deref(), Some("main")),
4928            _ => panic!("expected Prs"),
4929        }
4930    }
4931
4932    #[test]
4933    fn test_cli_prs_with_repo_parses() {
4934        let cli = Cli::try_parse_from(["codesynapse", "prs", "--repo", "owner/repo"]).unwrap();
4935        match cli.command {
4936            Command::Prs { repo, .. } => assert_eq!(repo.as_deref(), Some("owner/repo")),
4937            _ => panic!("expected Prs"),
4938        }
4939    }
4940
4941    #[test]
4942    fn test_cli_prs_with_limit_parses() {
4943        let cli = Cli::try_parse_from(["codesynapse", "prs", "--limit", "25"]).unwrap();
4944        match cli.command {
4945            Command::Prs { limit, .. } => assert_eq!(limit, 25),
4946            _ => panic!("expected Prs"),
4947        }
4948    }
4949
4950    #[test]
4951    fn test_cli_prs_with_graph_parses() {
4952        let cli = Cli::try_parse_from(["codesynapse", "prs", "--graph", "out/graph.json"]).unwrap();
4953        match cli.command {
4954            Command::Prs { graph, .. } => {
4955                assert_eq!(graph.unwrap().to_str().unwrap(), "out/graph.json");
4956            }
4957            _ => panic!("expected Prs"),
4958        }
4959    }
4960
4961    #[test]
4962    fn test_cli_prs_all_flags_parses() {
4963        let cli = Cli::try_parse_from([
4964            "codesynapse",
4965            "prs",
4966            "--base",
4967            "v8",
4968            "--repo",
4969            "acme/myrepo",
4970            "--limit",
4971            "100",
4972            "--graph",
4973            "graph.json",
4974        ])
4975        .unwrap();
4976        match cli.command {
4977            Command::Prs {
4978                base,
4979                repo,
4980                limit,
4981                graph,
4982            } => {
4983                assert_eq!(base.as_deref(), Some("v8"));
4984                assert_eq!(repo.as_deref(), Some("acme/myrepo"));
4985                assert_eq!(limit, 100);
4986                assert_eq!(graph.unwrap().to_str().unwrap(), "graph.json");
4987            }
4988            _ => panic!("expected Prs"),
4989        }
4990    }
4991
4992    #[test]
4993    fn test_cli_version_parses() {
4994        let cli = Cli::try_parse_from(["codesynapse", "version"]).unwrap();
4995        assert!(matches!(cli.command, Command::Version));
4996    }
4997
4998    #[test]
4999    fn test_cli_hook_check_parses() {
5000        let cli = Cli::try_parse_from(["codesynapse", "hook-check"]).unwrap();
5001        assert!(matches!(cli.command, Command::HookCheck));
5002    }
5003
5004    #[test]
5005    fn test_cli_check_update_parses() {
5006        let cli = Cli::try_parse_from(["codesynapse", "check-update", "/some/path"]).unwrap();
5007        match cli.command {
5008            Command::CheckUpdate { path } => {
5009                assert_eq!(path, PathBuf::from("/some/path"));
5010            }
5011            _ => panic!("expected CheckUpdate"),
5012        }
5013    }
5014
5015    #[test]
5016    fn test_cli_cache_check_parses() {
5017        let cli = Cli::try_parse_from(["codesynapse", "cache-check", "/tmp/files.txt"]).unwrap();
5018        match cli.command {
5019            Command::CacheCheck { files_from, root } => {
5020                assert_eq!(files_from, PathBuf::from("/tmp/files.txt"));
5021                assert_eq!(root, PathBuf::from("."));
5022            }
5023            _ => panic!("expected CacheCheck"),
5024        }
5025    }
5026
5027    #[test]
5028    fn test_cli_cache_check_with_root_parses() {
5029        let cli = Cli::try_parse_from([
5030            "codesynapse",
5031            "cache-check",
5032            "/tmp/files.txt",
5033            "--root",
5034            "/my/project",
5035        ])
5036        .unwrap();
5037        match cli.command {
5038            Command::CacheCheck { files_from, root } => {
5039                assert_eq!(files_from, PathBuf::from("/tmp/files.txt"));
5040                assert_eq!(root, PathBuf::from("/my/project"));
5041            }
5042            _ => panic!("expected CacheCheck"),
5043        }
5044    }
5045
5046    #[test]
5047    fn test_cli_merge_chunks_parses() {
5048        let cli = Cli::try_parse_from([
5049            "codesynapse",
5050            "merge-chunks",
5051            "chunk1.json",
5052            "chunk2.json",
5053            "--out",
5054            "merged.json",
5055        ])
5056        .unwrap();
5057        match cli.command {
5058            Command::MergeChunks { files, out } => {
5059                assert_eq!(files.len(), 2);
5060                assert_eq!(out, PathBuf::from("merged.json"));
5061            }
5062            _ => panic!("expected MergeChunks"),
5063        }
5064    }
5065
5066    #[test]
5067    fn test_cli_merge_semantic_parses() {
5068        let cli = Cli::try_parse_from([
5069            "codesynapse",
5070            "merge-semantic",
5071            "--cached",
5072            "/tmp/cached.json",
5073            "--new",
5074            "/tmp/new.json",
5075            "--out",
5076            "/tmp/merged.json",
5077        ])
5078        .unwrap();
5079        match cli.command {
5080            Command::MergeSemantic { cached, new, out } => {
5081                assert_eq!(cached.unwrap(), PathBuf::from("/tmp/cached.json"));
5082                assert_eq!(new.unwrap(), PathBuf::from("/tmp/new.json"));
5083                assert_eq!(out, PathBuf::from("/tmp/merged.json"));
5084            }
5085            _ => panic!("expected MergeSemantic"),
5086        }
5087    }
5088
5089    #[test]
5090    fn test_merge_chunks_dedup_nodes_by_id() {
5091        use tempfile::tempdir;
5092        let tmp = tempdir().unwrap();
5093        let chunk1 = tmp.path().join("c1.json");
5094        let chunk2 = tmp.path().join("c2.json");
5095        let out = tmp.path().join("merged.json");
5096        std::fs::write(
5097            &chunk1,
5098            r#"{"nodes":[{"id":"a","label":"A"}],"edges":[],"input_tokens":10,"output_tokens":5}"#,
5099        )
5100        .unwrap();
5101        std::fs::write(&chunk2, r#"{"nodes":[{"id":"a","label":"A-dup"},{"id":"b","label":"B"}],"edges":[],"input_tokens":8,"output_tokens":3}"#).unwrap();
5102
5103        let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
5104        let mut merged_nodes: Vec<serde_json::Value> = Vec::new();
5105        let mut input_tokens: u64 = 0;
5106        for cf in &[&chunk1, &chunk2] {
5107            let v: serde_json::Value =
5108                serde_json::from_str(&std::fs::read_to_string(cf).unwrap()).unwrap();
5109            for n in v["nodes"].as_array().unwrap() {
5110                let id = n["id"].as_str().unwrap_or("").to_string();
5111                if id.is_empty() || seen.insert(id) {
5112                    merged_nodes.push(n.clone());
5113                }
5114            }
5115            input_tokens += v.get("input_tokens").and_then(|v| v.as_u64()).unwrap_or(0);
5116        }
5117        std::fs::write(&out, serde_json::to_string(&serde_json::json!({"nodes": merged_nodes, "edges": [], "input_tokens": input_tokens})).unwrap()).unwrap();
5118
5119        let result: serde_json::Value =
5120            serde_json::from_str(&std::fs::read_to_string(&out).unwrap()).unwrap();
5121        let nodes = result["nodes"].as_array().unwrap();
5122        assert_eq!(nodes.len(), 2, "duplicate id 'a' must be deduped");
5123        assert_eq!(
5124            nodes[0]["label"].as_str().unwrap(),
5125            "A",
5126            "first writer wins"
5127        );
5128        assert_eq!(result["input_tokens"].as_u64().unwrap(), 18);
5129    }
5130
5131    #[test]
5132    fn test_merge_semantic_cached_takes_priority() {
5133        let get_arr = |v: &serde_json::Value, key: &str| -> Vec<serde_json::Value> {
5134            v.get(key)
5135                .and_then(|x| x.as_array())
5136                .cloned()
5137                .unwrap_or_default()
5138        };
5139        let cached: serde_json::Value =
5140            serde_json::json!({"nodes":[{"id":"a","label":"cached-A"}],"edges":[],"hyperedges":[]});
5141        let new: serde_json::Value = serde_json::json!({"nodes":[{"id":"a","label":"new-A"},{"id":"b","label":"B"}],"edges":[{"source":"a","target":"b","relation":"calls"}],"hyperedges":[]});
5142
5143        let cached_nodes = get_arr(&cached, "nodes");
5144        let new_nodes = get_arr(&new, "nodes");
5145        let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
5146        let mut all_nodes: Vec<serde_json::Value> = Vec::new();
5147        for n in cached_nodes.iter().chain(new_nodes.iter()) {
5148            let id = n
5149                .get("id")
5150                .and_then(|v| v.as_str())
5151                .unwrap_or("")
5152                .to_string();
5153            if id.is_empty() || seen.insert(id) {
5154                all_nodes.push(n.clone());
5155            }
5156        }
5157        let all_edges: Vec<serde_json::Value> = get_arr(&cached, "edges")
5158            .into_iter()
5159            .chain(get_arr(&new, "edges"))
5160            .collect();
5161
5162        assert_eq!(all_nodes.len(), 2);
5163        assert_eq!(
5164            all_nodes[0]["label"].as_str().unwrap(),
5165            "cached-A",
5166            "cached wins on id conflict"
5167        );
5168        assert_eq!(all_edges.len(), 1);
5169    }
5170
5171    #[test]
5172    fn test_check_update_no_flag() {
5173        use tempfile::tempdir;
5174        let tmp = tempdir().unwrap();
5175        let flag = tmp.path().join("codesynapse-out").join("needs_update");
5176        assert!(!flag.exists(), "flag should not exist");
5177    }
5178
5179    #[test]
5180    fn test_check_update_flag_exists() {
5181        use tempfile::tempdir;
5182        let tmp = tempdir().unwrap();
5183        let out_dir = tmp.path().join("codesynapse-out");
5184        std::fs::create_dir_all(&out_dir).unwrap();
5185        let flag = out_dir.join("needs_update");
5186        std::fs::write(&flag, "1").unwrap();
5187        assert!(flag.exists());
5188    }
5189
5190    #[test]
5191    fn test_cli_tree_html_parses() {
5192        let cli = Cli::try_parse_from([
5193            "codesynapse",
5194            "tree-html",
5195            "--graph",
5196            "/tmp/graph.json",
5197            "--output",
5198            "/tmp/tree.html",
5199        ]);
5200        assert!(cli.is_ok(), "tree-html should parse: {:?}", cli.err());
5201        assert!(matches!(cli.unwrap().command, Command::TreeHtml { .. }));
5202    }
5203
5204    #[test]
5205    fn test_cli_tree_html_defaults() {
5206        let cli = Cli::try_parse_from(["codesynapse", "tree-html"]);
5207        assert!(cli.is_ok());
5208        if let Command::TreeHtml {
5209            graph,
5210            output,
5211            root,
5212            max_children,
5213            label,
5214        } = cli.unwrap().command
5215        {
5216            assert!(graph.is_none());
5217            assert!(output.is_none());
5218            assert!(root.is_none());
5219            assert_eq!(max_children, 200);
5220            assert!(label.is_none());
5221        } else {
5222            panic!("expected TreeHtml");
5223        }
5224    }
5225
5226    #[test]
5227    fn test_cli_tree_html_all_flags() {
5228        let cli = Cli::try_parse_from([
5229            "codesynapse",
5230            "tree-html",
5231            "--graph",
5232            "/tmp/g.json",
5233            "--output",
5234            "/tmp/out.html",
5235            "--root",
5236            "/src",
5237            "--max-children",
5238            "50",
5239            "--label",
5240            "MyProject",
5241        ]);
5242        assert!(cli.is_ok());
5243        if let Command::TreeHtml {
5244            graph,
5245            output,
5246            root,
5247            max_children,
5248            label,
5249        } = cli.unwrap().command
5250        {
5251            assert_eq!(graph.unwrap().to_str().unwrap(), "/tmp/g.json");
5252            assert_eq!(output.unwrap().to_str().unwrap(), "/tmp/out.html");
5253            assert_eq!(root.unwrap(), "/src");
5254            assert_eq!(max_children, 50);
5255            assert_eq!(label.unwrap(), "MyProject");
5256        } else {
5257            panic!("expected TreeHtml");
5258        }
5259    }
5260
5261    #[test]
5262    fn test_cli_tree_html_integration() {
5263        use codesynapse_core::tree_html::write_tree_html;
5264        use tempfile::tempdir;
5265
5266        let dir = tempdir().unwrap();
5267        let graph = serde_json::json!({
5268            "nodes": [
5269                {"id": "a", "label": "ApiClient", "source_file": "/proj/src/api.py", "file_type": "code"},
5270                {"id": "b", "label": "run", "source_file": "/proj/src/main.py", "file_type": "code"}
5271            ]
5272        });
5273        let graph_path = dir.path().join("graph.json");
5274        std::fs::write(&graph_path, serde_json::to_string(&graph).unwrap()).unwrap();
5275        let out_path = dir.path().join("tree.html");
5276
5277        let result = write_tree_html(&graph_path, &out_path, Some("/proj"), 200, Some("TestProj"));
5278        assert!(result.is_ok(), "write_tree_html failed: {:?}", result.err());
5279        let html = std::fs::read_to_string(&out_path).unwrap();
5280        assert!(html.contains("TestProj"));
5281        assert!(html.contains("<!DOCTYPE html>"));
5282        assert!(html.contains("d3.v7"));
5283    }
5284
5285    #[test]
5286    fn test_cli_tree_html_build_tree_only() {
5287        use codesynapse_core::tree_html::build_tree;
5288        let graph = serde_json::json!({
5289            "nodes": [
5290                {"id": "x", "label": "Foo", "source_file": "/a/b.py", "file_type": "code"},
5291                {"id": "y", "label": "Bar", "source_file": "/a/c.py", "file_type": "code"}
5292            ]
5293        });
5294        let tree = build_tree(&graph, Some("/a"), 200, None);
5295        assert_eq!(tree["name"].as_str().unwrap(), "a");
5296        assert!(tree["total_count"].as_u64().unwrap() > 0);
5297    }
5298
5299    #[test]
5300    fn test_cli_setup_parses() {
5301        let cli = Cli::try_parse_from(["codesynapse", "setup"]);
5302        assert!(cli.is_ok());
5303        if let Command::Setup { client, workspace } = cli.unwrap().command {
5304            assert!(client.is_none());
5305            assert!(workspace.is_none());
5306        } else {
5307            panic!("expected Setup");
5308        }
5309    }
5310
5311    #[test]
5312    fn test_cli_setup_with_client_flag() {
5313        let cli = Cli::try_parse_from(["codesynapse", "setup", "--client", "cursor"]);
5314        assert!(cli.is_ok());
5315        if let Command::Setup { client, .. } = cli.unwrap().command {
5316            assert_eq!(client.as_deref(), Some("cursor"));
5317        } else {
5318            panic!("expected Setup");
5319        }
5320    }
5321
5322    #[test]
5323    fn test_merge_mcp_entry_creates_file() {
5324        let tmp = tempfile::tempdir().unwrap();
5325        let path = tmp.path().join("mcp.json");
5326        let entry = serde_json::json!({"type": "stdio", "command": "/usr/bin/cs", "args": ["mcp"]});
5327        let ok = merge_mcp_entry(&path, "mcpServers", "codesynapse", entry);
5328        assert!(ok);
5329        let text = std::fs::read_to_string(&path).unwrap();
5330        let v: serde_json::Value = serde_json::from_str(&text).unwrap();
5331        assert_eq!(
5332            v["mcpServers"]["codesynapse"]["command"].as_str().unwrap(),
5333            "/usr/bin/cs"
5334        );
5335    }
5336
5337    #[test]
5338    fn test_merge_mcp_entry_idempotent() {
5339        let tmp = tempfile::tempdir().unwrap();
5340        let path = tmp.path().join("mcp.json");
5341        let entry = serde_json::json!({"type": "stdio", "command": "/cs", "args": ["mcp"]});
5342        merge_mcp_entry(&path, "mcpServers", "codesynapse", entry.clone());
5343        merge_mcp_entry(&path, "mcpServers", "codesynapse", entry);
5344        let text = std::fs::read_to_string(&path).unwrap();
5345        let v: serde_json::Value = serde_json::from_str(&text).unwrap();
5346        let count = v["mcpServers"].as_object().unwrap().len();
5347        assert_eq!(count, 1);
5348    }
5349
5350    #[test]
5351    fn test_merge_mcp_entry_preserves_existing() {
5352        let tmp = tempfile::tempdir().unwrap();
5353        let path = tmp.path().join("mcp.json");
5354        let existing = serde_json::json!({"mcpServers": {"other": {"command": "other-tool"}}});
5355        std::fs::write(&path, serde_json::to_string(&existing).unwrap()).unwrap();
5356        let entry = serde_json::json!({"command": "/cs", "args": ["mcp"]});
5357        merge_mcp_entry(&path, "mcpServers", "codesynapse", entry);
5358        let text = std::fs::read_to_string(&path).unwrap();
5359        let v: serde_json::Value = serde_json::from_str(&text).unwrap();
5360        assert!(v["mcpServers"]["other"].is_object());
5361        assert!(v["mcpServers"]["codesynapse"].is_object());
5362    }
5363
5364    #[test]
5365    fn test_setup_vscode_writes_servers_key() {
5366        let tmp = tempfile::tempdir().unwrap();
5367        let _ = setup_mcp_clients("/usr/bin/codesynapse", Some("vscode"), tmp.path());
5368        let vscode_path = tmp.path().join(".vscode").join("mcp.json");
5369        assert!(vscode_path.exists());
5370        let v: serde_json::Value =
5371            serde_json::from_str(&std::fs::read_to_string(&vscode_path).unwrap()).unwrap();
5372        assert!(v["servers"]["codesynapse"].is_object());
5373        assert_eq!(
5374            v["servers"]["codesynapse"]["command"].as_str().unwrap(),
5375            "/usr/bin/codesynapse"
5376        );
5377    }
5378
5379    #[test]
5380    fn test_setup_client_filter_only_registers_target() {
5381        let tmp = tempfile::tempdir().unwrap();
5382        let _ = setup_mcp_clients("/cs", Some("vscode"), tmp.path());
5383        // cursor dir does not exist in tmp → cursor config should not be created
5384        assert!(!tmp.path().join(".cursor").join("mcp.json").exists());
5385        // vscode should exist
5386        assert!(tmp.path().join(".vscode").join("mcp.json").exists());
5387    }
5388
5389    #[test]
5390    fn test_cli_path_parses() {
5391        let cli =
5392            Cli::try_parse_from(["codesynapse", "path", "AuthService", "UserService"]).unwrap();
5393        match cli.command {
5394            Command::Path {
5395                source,
5396                target,
5397                path,
5398            } => {
5399                assert_eq!(source, "AuthService");
5400                assert_eq!(target, "UserService");
5401                assert!(path.is_none());
5402            }
5403            other => panic!("expected Path, got {:?}", other),
5404        }
5405    }
5406
5407    #[test]
5408    fn test_cli_path_parses_with_path_flag() {
5409        let cli = Cli::try_parse_from([
5410            "codesynapse",
5411            "path",
5412            "AuthService",
5413            "UserService",
5414            "--path",
5415            "/my/project",
5416        ])
5417        .unwrap();
5418        match cli.command {
5419            Command::Path {
5420                source,
5421                target,
5422                path,
5423            } => {
5424                assert_eq!(source, "AuthService");
5425                assert_eq!(target, "UserService");
5426                assert_eq!(path, Some(PathBuf::from("/my/project")));
5427            }
5428            other => panic!("expected Path, got {:?}", other),
5429        }
5430    }
5431
5432    #[test]
5433    fn test_module_add_parses() {
5434        let cli =
5435            Cli::try_parse_from(["codesynapse", "module", "add", "core/ctx", "/workspace/ctx"])
5436                .unwrap();
5437        match cli.command {
5438            Command::Module {
5439                action:
5440                    ModuleAction::Add {
5441                        name,
5442                        source,
5443                        modules_conf,
5444                        force,
5445                        ..
5446                    },
5447            } => {
5448                assert_eq!(name, "core/ctx");
5449                assert_eq!(source, PathBuf::from("/workspace/ctx"));
5450                assert!(modules_conf.is_none());
5451                assert!(!force);
5452            }
5453            other => panic!("expected Module::Add, got {:?}", other),
5454        }
5455    }
5456
5457    #[test]
5458    fn test_module_add_with_modules_conf_and_force() {
5459        let cli = Cli::try_parse_from([
5460            "codesynapse",
5461            "module",
5462            "add",
5463            "mymod",
5464            "/src",
5465            "--modules-conf",
5466            "/tmp/custom.conf",
5467            "--force",
5468        ])
5469        .unwrap();
5470        match cli.command {
5471            Command::Module {
5472                action:
5473                    ModuleAction::Add {
5474                        modules_conf,
5475                        force,
5476                        ..
5477                    },
5478            } => {
5479                assert_eq!(modules_conf, Some(PathBuf::from("/tmp/custom.conf")));
5480                assert!(force);
5481            }
5482            other => panic!("expected Module::Add, got {:?}", other),
5483        }
5484    }
5485
5486    #[test]
5487    fn test_module_add_llm_flag() {
5488        let cli = Cli::try_parse_from(["codesynapse", "module", "add", "mymod", "/src", "--llm"])
5489            .unwrap();
5490        match cli.command {
5491            Command::Module {
5492                action: ModuleAction::Add { llm, .. },
5493            } => assert!(llm),
5494            other => panic!("expected Module::Add, got {:?}", other),
5495        }
5496    }
5497
5498    #[test]
5499    fn test_module_refresh_llm_flag() {
5500        let cli = Cli::try_parse_from(["codesynapse", "module", "refresh", "--llm"]).unwrap();
5501        match cli.command {
5502            Command::Module {
5503                action: ModuleAction::Refresh { llm, .. },
5504            } => assert!(llm),
5505            other => panic!("expected Module::Refresh, got {:?}", other),
5506        }
5507    }
5508
5509    #[test]
5510    fn test_module_refresh_parses_all() {
5511        let cli = Cli::try_parse_from(["codesynapse", "module", "refresh"]).unwrap();
5512        match cli.command {
5513            Command::Module {
5514                action: ModuleAction::Refresh { name, .. },
5515            } => {
5516                assert!(name.is_none());
5517            }
5518            other => panic!("expected Module::Refresh, got {:?}", other),
5519        }
5520    }
5521
5522    #[test]
5523    fn test_module_refresh_parses_named() {
5524        let cli = Cli::try_parse_from(["codesynapse", "module", "refresh", "core/ctx"]).unwrap();
5525        match cli.command {
5526            Command::Module {
5527                action: ModuleAction::Refresh { name, .. },
5528            } => {
5529                assert_eq!(name.as_deref(), Some("core/ctx"));
5530            }
5531            other => panic!("expected Module::Refresh, got {:?}", other),
5532        }
5533    }
5534
5535    #[test]
5536    fn test_module_list_parses() {
5537        let cli = Cli::try_parse_from(["codesynapse", "module", "list"]).unwrap();
5538        assert!(matches!(
5539            cli.command,
5540            Command::Module {
5541                action: ModuleAction::List { .. }
5542            }
5543        ));
5544    }
5545
5546    #[test]
5547    fn test_module_remove_parses() {
5548        let cli = Cli::try_parse_from(["codesynapse", "module", "remove", "core/ctx"]).unwrap();
5549        match cli.command {
5550            Command::Module {
5551                action: ModuleAction::Remove { name, .. },
5552            } => {
5553                assert_eq!(name, "core/ctx");
5554            }
5555            other => panic!("expected Module::Remove, got {:?}", other),
5556        }
5557    }
5558
5559    #[test]
5560    fn test_read_modules_conf_basic() {
5561        let tmp = tempfile::tempdir().unwrap();
5562        let path = tmp.path().join("modules.conf");
5563        std::fs::write(
5564            &path,
5565            "core/api|/workspace/api\ncore/impl|/workspace/impl\n",
5566        )
5567        .unwrap();
5568        let modules = read_modules_conf(&path);
5569        assert_eq!(modules.len(), 2);
5570        assert_eq!(modules[0].0, "core/api");
5571        assert_eq!(modules[0].1, PathBuf::from("/workspace/api"));
5572        assert_eq!(modules[1].0, "core/impl");
5573        assert_eq!(modules[1].1, PathBuf::from("/workspace/impl"));
5574    }
5575
5576    #[test]
5577    fn test_read_modules_conf_skips_comments_and_blanks() {
5578        let tmp = tempfile::tempdir().unwrap();
5579        let path = tmp.path().join("modules.conf");
5580        std::fs::write(
5581            &path,
5582            "# comment\n\ncore/api|/workspace/api\n  # indented\n",
5583        )
5584        .unwrap();
5585        let modules = read_modules_conf(&path);
5586        assert_eq!(modules.len(), 1);
5587        assert_eq!(modules[0].0, "core/api");
5588    }
5589
5590    #[test]
5591    fn test_read_modules_conf_missing_file() {
5592        let modules = read_modules_conf(std::path::Path::new("/nonexistent/modules.conf"));
5593        assert!(modules.is_empty());
5594    }
5595
5596    #[test]
5597    fn test_upsert_modules_conf_new_entry() {
5598        let tmp = tempfile::tempdir().unwrap();
5599        let path = tmp.path().join("modules.conf");
5600        upsert_modules_conf(&path, "core/api", std::path::Path::new("/workspace/api")).unwrap();
5601        let modules = read_modules_conf(&path);
5602        assert_eq!(modules.len(), 1);
5603        assert_eq!(modules[0].0, "core/api");
5604        assert_eq!(modules[0].1, PathBuf::from("/workspace/api"));
5605    }
5606
5607    #[test]
5608    fn test_upsert_modules_conf_updates_existing() {
5609        let tmp = tempfile::tempdir().unwrap();
5610        let path = tmp.path().join("modules.conf");
5611        std::fs::write(&path, "core/api|/old/path\n").unwrap();
5612        upsert_modules_conf(&path, "core/api", std::path::Path::new("/new/path")).unwrap();
5613        let modules = read_modules_conf(&path);
5614        assert_eq!(modules.len(), 1);
5615        assert_eq!(modules[0].1, PathBuf::from("/new/path"));
5616    }
5617
5618    #[test]
5619    fn test_upsert_modules_conf_preserves_comments() {
5620        let tmp = tempfile::tempdir().unwrap();
5621        let path = tmp.path().join("modules.conf");
5622        std::fs::write(&path, "# my modules\ncore/api|/workspace/api\n").unwrap();
5623        upsert_modules_conf(&path, "core/impl", std::path::Path::new("/workspace/impl")).unwrap();
5624        let text = std::fs::read_to_string(&path).unwrap();
5625        assert!(text.contains("# my modules"));
5626        assert!(text.contains("core/api|/workspace/api"));
5627        assert!(text.contains("core/impl|/workspace/impl"));
5628    }
5629
5630    #[test]
5631    fn test_remove_from_modules_conf() {
5632        let tmp = tempfile::tempdir().unwrap();
5633        let path = tmp.path().join("modules.conf");
5634        std::fs::write(
5635            &path,
5636            "# comment\ncore/api|/workspace/api\ncore/impl|/workspace/impl\n",
5637        )
5638        .unwrap();
5639        remove_from_modules_conf(&path, "core/api").unwrap();
5640        let modules = read_modules_conf(&path);
5641        assert_eq!(modules.len(), 1);
5642        assert_eq!(modules[0].0, "core/impl");
5643        let text = std::fs::read_to_string(&path).unwrap();
5644        assert!(text.contains("# comment"));
5645    }
5646
5647    #[test]
5648    fn test_find_git_root_finds_parent() {
5649        let tmp = tempfile::tempdir().unwrap();
5650        std::fs::create_dir_all(tmp.path().join(".git")).unwrap();
5651        let subdir = tmp.path().join("src").join("main");
5652        std::fs::create_dir_all(&subdir).unwrap();
5653        let root = find_git_root(&subdir).unwrap();
5654        assert_eq!(root, tmp.path());
5655    }
5656
5657    #[test]
5658    fn test_find_git_root_none_when_no_git() {
5659        let tmp = tempfile::tempdir().unwrap();
5660        assert!(find_git_root(tmp.path()).is_none());
5661    }
5662
5663    #[test]
5664    fn test_gitignore_codesynapse_artifacts_creates_exclude() {
5665        let tmp = tempfile::tempdir().unwrap();
5666        std::fs::create_dir_all(tmp.path().join(".git").join("info")).unwrap();
5667        let src = tmp.path().join("src");
5668        std::fs::create_dir_all(&src).unwrap();
5669        gitignore_codesynapse_artifacts(&src);
5670        let exclude = tmp.path().join(".git").join("info").join("exclude");
5671        let content = std::fs::read_to_string(&exclude).unwrap();
5672        assert!(content.contains(".codesynapse-store"));
5673        assert!(content.contains("codesynapse-out"));
5674    }
5675
5676    #[test]
5677    fn test_gitignore_codesynapse_artifacts_idempotent() {
5678        let tmp = tempfile::tempdir().unwrap();
5679        std::fs::create_dir_all(tmp.path().join(".git").join("info")).unwrap();
5680        let exclude = tmp.path().join(".git").join("info").join("exclude");
5681        std::fs::write(&exclude, ".codesynapse-store\ncodesynapse-out\n").unwrap();
5682        gitignore_codesynapse_artifacts(tmp.path());
5683        let content = std::fs::read_to_string(&exclude).unwrap();
5684        assert_eq!(content.matches(".codesynapse-store").count(), 1);
5685        assert_eq!(content.matches("codesynapse-out").count(), 1);
5686    }
5687
5688    #[test]
5689    fn test_has_newer_sources_no_source_files() {
5690        let tmp = tempfile::tempdir().unwrap();
5691        let graph = tmp.path().join("graph.json");
5692        std::fs::write(&graph, "{}").unwrap();
5693        // empty dir — no code files → not newer
5694        assert!(!has_newer_sources(tmp.path(), &graph));
5695    }
5696
5697    // Fix 1: build_graph_to_json produces graph.json with code nodes (llm=false)
5698    #[test]
5699    fn test_build_graph_to_json_without_llm() {
5700        let src = tempfile::tempdir().unwrap();
5701        std::fs::write(
5702            src.path().join("main.py"),
5703            b"class Foo:\n    def bar(self): pass\n",
5704        )
5705        .unwrap();
5706        let out = tempfile::tempdir().unwrap();
5707        let out_json = out.path().join("graph.json");
5708        let (nodes, _edges) = build_graph_to_json(src.path(), &out_json, false, false).unwrap();
5709        assert!(nodes > 0, "expected nodes from code extraction");
5710        assert!(out_json.exists(), "graph.json should be written");
5711        let content = std::fs::read_to_string(&out_json).unwrap();
5712        let v: serde_json::Value = serde_json::from_str(&content).unwrap();
5713        assert!(v["nodes"].as_array().map(|a| a.len()).unwrap_or(0) > 0);
5714    }
5715
5716    #[test]
5717    fn test_watch_all_modules_empty_returns_err() {
5718        let tmp = tempfile::tempdir().unwrap();
5719        let global_dir = tmp.path().to_path_buf();
5720        // No modules.conf created → read_modules_conf returns empty vec
5721        let result = watch_all_modules(global_dir);
5722        assert!(result.is_err(), "expected Err when no modules registered");
5723        let msg = result.unwrap_err().to_string();
5724        assert!(
5725            msg.contains("no modules"),
5726            "expected 'no modules' in error message, got: {msg}"
5727        );
5728    }
5729
5730    // Fix 1: build_graph_to_json with llm=true but no LLM config still succeeds
5731    // (extractor build fails gracefully; code nodes still present in output)
5732    #[test]
5733    fn test_build_graph_to_json_llm_flag_graceful_failure() {
5734        let src = tempfile::tempdir().unwrap();
5735        std::fs::write(src.path().join("main.rs"), b"pub struct Foo;\n").unwrap();
5736        std::fs::write(src.path().join("README.md"), b"# docs\nsome content\n").unwrap();
5737        let out = tempfile::tempdir().unwrap();
5738        let out_json = out.path().join("graph.json");
5739        // llm=true but no OPENAI_API_KEY or config → build_extractor fails → graceful fallback
5740        let result = build_graph_to_json(src.path(), &out_json, false, true);
5741        let (nodes, _) = result
5742            .unwrap_or_else(|e| panic!("should not error when LLM extractor fails to build: {e}"));
5743        assert!(nodes > 0, "code nodes should still be extracted");
5744    }
5745
5746    #[test]
5747    fn test_cli_resolve_parses() {
5748        let cli = Cli::try_parse_from(["codesynapse", "resolve", "how does auth work"]);
5749        assert!(cli.is_ok(), "resolve subcommand should parse");
5750        match cli.unwrap().command {
5751            Command::Resolve { query, limit } => {
5752                assert_eq!(query, "how does auth work");
5753                assert_eq!(limit, 5);
5754            }
5755            other => panic!("Expected Resolve, got {:?}", other),
5756        }
5757    }
5758
5759    #[test]
5760    fn test_inject_creates_block_in_existing_claude_md() {
5761        let dir = tempfile::tempdir().unwrap();
5762        let claude_md = dir.path().join("CLAUDE.md");
5763        std::fs::write(&claude_md, "# My project\n\nSome existing content.\n").unwrap();
5764
5765        let global_dir = dir.path().join(".codesynapse");
5766        std::fs::create_dir_all(&global_dir).unwrap();
5767
5768        inject_agent_instructions(dir.path(), &global_dir).unwrap();
5769
5770        let content = std::fs::read_to_string(&claude_md).unwrap();
5771        assert!(content.contains("<!-- codesynapse:start -->"));
5772        assert!(content.contains("codesynapse resolve"));
5773        assert!(content.contains("<!-- codesynapse:end -->"));
5774        assert!(
5775            content.contains("# My project"),
5776            "original content preserved"
5777        );
5778    }
5779
5780    #[test]
5781    fn test_inject_upserts_existing_block() {
5782        let dir = tempfile::tempdir().unwrap();
5783        let claude_md = dir.path().join("CLAUDE.md");
5784        std::fs::write(
5785            &claude_md,
5786            "# My project\n\n<!-- codesynapse:start -->\nOLD BLOCK\n<!-- codesynapse:end -->\n",
5787        )
5788        .unwrap();
5789
5790        let global_dir = dir.path().join(".codesynapse");
5791        std::fs::create_dir_all(&global_dir).unwrap();
5792
5793        inject_agent_instructions(dir.path(), &global_dir).unwrap();
5794
5795        let content = std::fs::read_to_string(&claude_md).unwrap();
5796        assert!(
5797            !content.contains("OLD BLOCK"),
5798            "old block should be replaced"
5799        );
5800        assert!(content.contains("codesynapse resolve"), "new block present");
5801        let count = content.matches("<!-- codesynapse:start -->").count();
5802        assert_eq!(count, 1, "only one block should exist");
5803    }
5804
5805    #[test]
5806    fn test_strip_marker_block() {
5807        let dir = tempfile::tempdir().unwrap();
5808        let p = dir.path().join("CLAUDE.md");
5809        std::fs::write(
5810            &p,
5811            "# My project\n\n<!-- codesynapse:start -->\nBLOCK CONTENT\n<!-- codesynapse:end -->\n",
5812        )
5813        .unwrap();
5814
5815        strip_marker_block(&p, "<!-- codesynapse:start -->", "<!-- codesynapse:end -->").unwrap();
5816
5817        let content = std::fs::read_to_string(&p).unwrap();
5818        assert!(!content.contains("<!-- codesynapse:start -->"));
5819        assert!(!content.contains("BLOCK CONTENT"));
5820        assert!(content.contains("# My project"));
5821    }
5822}