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 {
48 path: PathBuf,
49 #[arg(long)]
50 format: Option<String>,
51 },
52 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 #[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 Cluster { path: PathBuf },
75 Analyze { path: PathBuf },
77 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 {
91 query: String,
92 #[arg(long)]
94 graph: Option<PathBuf>,
95 #[arg(long)]
97 model_path: Option<PathBuf>,
98 #[arg(long, default_value = "bfs")]
100 mode: String,
101 #[arg(long, default_value_t = 2)]
103 depth: usize,
104 },
105 Path {
107 source: String,
108 target: String,
109 #[arg(long)]
110 path: Option<PathBuf>,
111 },
112 Stats { path: PathBuf },
114 Validate { path: PathBuf },
116 Merge { a: PathBuf, b: PathBuf },
118 #[command(name = "merge-driver")]
120 MergeDriver {
121 base: PathBuf,
123 current: PathBuf,
125 other: PathBuf,
127 },
128 Mcp {
130 #[arg(long)]
132 global_dir: Option<PathBuf>,
133 },
134 Serve {
136 #[arg(long)]
137 graph: Option<PathBuf>,
138 path: Option<PathBuf>,
139 #[arg(long, default_value = "50051")]
141 port: u16,
142 },
143 #[command(alias = "compare")]
145 Diff {
146 path: PathBuf,
147 #[arg(long)]
148 baseline: PathBuf,
149 },
150 Explain {
152 id: String,
153 #[arg(long)]
154 path: Option<PathBuf>,
155 },
156 Affected {
158 id: String,
159 #[arg(long)]
160 path: Option<PathBuf>,
161 #[arg(long)]
163 graph: Option<PathBuf>,
164 #[arg(long, default_value = "2")]
165 depth: usize,
166 #[arg(long = "relation", value_name = "RELATION")]
168 relations: Vec<String>,
169 },
170 Tree {
172 path: PathBuf,
173 #[arg(long)]
174 root: Option<String>,
175 #[arg(long, default_value = "3")]
176 max_depth: usize,
177 },
178 Benchmark {
180 path: PathBuf,
181 #[arg(long, default_value = "3")]
182 runs: usize,
183 },
184 Risk {
186 path: PathBuf,
187 #[arg(long)]
188 dry_run: bool,
189 },
190 Update {
192 path: PathBuf,
193 #[arg(long)]
194 force: bool,
195 },
196 Watch {
198 #[arg(long, short = 'p')]
199 path: Option<PathBuf>,
200 #[arg(long, short = 'o')]
201 output: Option<PathBuf>,
202 },
203 Ingest {
205 url: String,
206 #[arg(long)]
207 output: Option<PathBuf>,
208 },
209 Hook {
211 #[command(subcommand)]
212 action: HookAction,
213 },
214 Init {
216 #[arg(long)]
217 path: Option<PathBuf>,
218 #[arg(long)]
219 force: bool,
220 },
221 Completions { shell: String },
223 SaveResult {
225 #[arg(long, short = 'o')]
226 output: Option<PathBuf>,
227 },
228 Install {
230 #[arg(long)]
231 platform: String,
232 },
233 Claude {
235 #[command(subcommand)]
236 action: PlatformAction,
237 },
238 Opencode {
240 #[command(subcommand)]
241 action: PlatformAction,
242 },
243 Codex {
245 #[command(subcommand)]
246 action: PlatformAction,
247 },
248 Codebuddy {
250 #[command(subcommand)]
251 action: PlatformAction,
252 },
253 Claw {
255 #[command(subcommand)]
256 action: PlatformAction,
257 },
258 Droid {
260 #[command(subcommand)]
261 action: PlatformAction,
262 },
263 Cursor {
265 #[command(subcommand)]
266 action: PlatformAction,
267 },
268 Vscode {
270 #[command(subcommand)]
271 action: PlatformAction,
272 },
273 Aider {
275 #[command(subcommand)]
276 action: PlatformAction,
277 },
278 Copilot {
280 #[command(subcommand)]
281 action: PlatformAction,
282 },
283 Trae {
285 #[command(subcommand)]
286 action: PlatformAction,
287 },
288 #[command(name = "trae-cn")]
290 TraeCn {
291 #[command(subcommand)]
292 action: PlatformAction,
293 },
294 Antigravity {
296 #[command(subcommand)]
297 action: PlatformAction,
298 },
299 Hermes {
301 #[command(subcommand)]
302 action: PlatformAction,
303 },
304 Kiro {
306 #[command(subcommand)]
307 action: PlatformAction,
308 },
309 Pi {
311 #[command(subcommand)]
312 action: PlatformAction,
313 },
314 Devin {
316 #[command(subcommand)]
317 action: PlatformAction,
318 },
319 Global {
321 #[command(subcommand)]
322 action: GlobalAction,
323 },
324 Diagnose {
326 #[arg(long)]
328 graph: PathBuf,
329 #[arg(long)]
331 json: bool,
332 #[arg(long, default_value_t = 3)]
334 max_examples: usize,
335 #[arg(long)]
337 directed: bool,
338 #[arg(long)]
340 undirected: bool,
341 },
342 #[command(name = "callflow-html")]
344 CallflowHtml {
345 graph_path: Option<PathBuf>,
347 #[arg(long)]
349 graph: Option<PathBuf>,
350 #[arg(long, short = 'o')]
352 output: Option<PathBuf>,
353 #[arg(long)]
355 project: Option<PathBuf>,
356 #[arg(long, default_value_t = 15)]
358 max_sections: usize,
359 #[arg(long, default_value_t = 1.0)]
361 diagram_scale: f64,
362 #[arg(long, default_value_t = 18)]
364 max_diagram_nodes: usize,
365 #[arg(long, default_value_t = 24)]
367 max_diagram_edges: usize,
368 },
369 #[command(name = "tree-html")]
371 TreeHtml {
372 #[arg(long)]
374 graph: Option<PathBuf>,
375 #[arg(long, short = 'o')]
377 output: Option<PathBuf>,
378 #[arg(long)]
380 root: Option<String>,
381 #[arg(long, default_value_t = 200)]
383 max_children: usize,
384 #[arg(long)]
386 label: Option<String>,
387 },
388 Clone {
390 url: String,
392 #[arg(long)]
394 branch: Option<String>,
395 #[arg(long)]
397 out: Option<PathBuf>,
398 },
399 Prs {
401 #[arg(long)]
403 base: Option<String>,
404 #[arg(long)]
406 repo: Option<String>,
407 #[arg(long, default_value_t = 50)]
409 limit: usize,
410 #[arg(long)]
412 graph: Option<PathBuf>,
413 },
414 Version,
416 #[command(name = "hook-check")]
418 HookCheck,
419 #[command(name = "check-update")]
421 CheckUpdate { path: PathBuf },
422 #[command(name = "cache-check")]
424 CacheCheck {
425 files_from: PathBuf,
427 #[arg(long, default_value = ".")]
429 root: PathBuf,
430 },
431 #[command(name = "merge-chunks")]
433 MergeChunks {
434 files: Vec<PathBuf>,
436 #[arg(long)]
438 out: PathBuf,
439 },
440 #[command(name = "merge-semantic")]
442 MergeSemantic {
443 #[arg(long)]
445 cached: Option<PathBuf>,
446 #[arg(long)]
448 new: Option<PathBuf>,
449 #[arg(long)]
451 out: PathBuf,
452 },
453 Setup {
455 #[arg(long)]
457 client: Option<String>,
458 #[arg(long)]
460 workspace: Option<PathBuf>,
461 },
462 Resolve {
464 query: String,
466 #[arg(long, default_value_t = 5)]
468 limit: usize,
469 },
470 Module {
472 #[command(subcommand)]
473 action: ModuleAction,
474 },
475 Telemetry {
477 #[command(subcommand)]
478 action: TelemetryAction,
479 },
480}
481
482#[derive(Debug, Subcommand)]
483pub enum TelemetryAction {
484 On,
486 Off,
488 Status,
490}
491
492#[derive(Debug, Subcommand)]
493pub enum PlatformAction {
494 Install,
496 Uninstall,
498}
499
500#[derive(Debug, Subcommand)]
501pub enum HookAction {
502 Install {
504 #[arg(long)]
505 path: Option<PathBuf>,
506 },
507 Uninstall {
509 #[arg(long)]
510 path: Option<PathBuf>,
511 },
512 Status {
514 #[arg(long)]
515 path: Option<PathBuf>,
516 },
517}
518
519#[derive(Debug, Subcommand)]
520pub enum GlobalAction {
521 Add {
523 path: PathBuf,
525 #[arg(long = "as")]
527 tag: Option<String>,
528 },
529 Remove {
531 tag: String,
533 },
534 List,
536 Path,
538}
539
540#[derive(Debug, Subcommand)]
541pub enum ModuleAction {
542 Add {
544 name: String,
546 source: PathBuf,
548 #[arg(long)]
550 modules_conf: Option<PathBuf>,
551 #[arg(long)]
553 force: bool,
554 #[arg(long)]
556 llm: bool,
557 },
558 Refresh {
560 name: Option<String>,
562 #[arg(long)]
564 modules_conf: Option<PathBuf>,
565 #[arg(long)]
567 force: bool,
568 #[arg(long)]
570 llm: bool,
571 },
572 List {
574 #[arg(long)]
576 modules_conf: Option<PathBuf>,
577 },
578 Remove {
580 name: String,
582 #[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); 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 let config = CodeSynapseConfig::discover(&path).ok();
963
964 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 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); 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 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 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 let _store = MemoryGraphStore::new();
1335 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 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(¤t) {
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(¤t) {
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(¤t, 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 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 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 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 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 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 #[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 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 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 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(®istered_clients, &failed_clients, hybrid);
2729
2730 let global_dir = dirs::home_dir()
2731 .unwrap_or_else(|| PathBuf::from("~"))
2732 .join(".codesynapse");
2733
2734 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 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 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 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
3483pub 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 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 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 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 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 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 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 { .. } => {} 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 { .. } => {} 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 let result = clone_repo("https://github.com/owner/repo", None, Some(&dest));
4727 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(¤t, cur_json).unwrap();
4768 std::fs::write(&other, oth_json).unwrap();
4769
4770 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 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 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 assert!(!tmp.path().join(".cursor").join("mcp.json").exists());
5385 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 assert!(!has_newer_sources(tmp.path(), &graph));
5695 }
5696
5697 #[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 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 #[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 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}