1mod access_log;
2mod analysis;
3mod app;
4mod cache_paths;
5mod cli;
6mod config;
7mod event_log;
8mod export_html;
9pub mod graph;
10mod graph_lock;
11mod import_csv;
12mod import_markdown;
13mod index;
14mod init;
15mod kg_sidecar;
16mod kql;
17mod ops;
18pub mod output;
19mod schema;
20mod scoring;
21mod storage;
22mod text_norm;
23mod validate;
24mod vectors;
25
26pub use cache_paths::cache_root_for_cwd;
28pub use graph::{Edge, EdgeProperties, GraphFile, Metadata, Node, NodeProperties, Note};
29pub use graph_lock::acquire_for_graph as acquire_graph_write_lock;
30pub use output::FindMode;
31
32pub use validate::{
34 EDGE_TYPE_RULES, TYPE_TO_PREFIX, VALID_RELATIONS, VALID_TYPES, canonicalize_node_id_for_type,
35 edge_type_rule, format_edge_source_type_error, format_edge_target_type_error,
36 is_valid_node_type, is_valid_relation, normalize_node_id,
37};
38
39pub use index::Bm25Index;
41
42use std::ffi::OsString;
43use std::fmt::Write as _;
44use std::io::IsTerminal;
45use std::path::{Path, PathBuf};
46use std::sync::atomic::{AtomicU8, Ordering};
47
48use anyhow::{Context, Result, anyhow, bail};
49use clap::Parser;
50use cli::{
51 AsOfArgs, AuditArgs, BaselineArgs, CheckArgs, Cli, ClusterSkill, ClustersArgs, Command,
52 DiffAsOfArgs, EdgeCommand, ExportDotArgs, ExportGraphmlArgs, ExportMdArgs, ExportMermaidArgs,
53 FeedbackLogArgs, FeedbackSummaryArgs, FindMode as CliFindMode, GraphCommand, HistoryArgs,
54 ImportCsvArgs, ImportMarkdownArgs, MergeStrategy, NodeCommand, NoteAddArgs, NoteCommand,
55 NoteListArgs, ScoreAllArgs, SplitArgs, TemporalSource, TimelineArgs, VectorCommand,
56};
57use serde::{Deserialize, Serialize};
58use serde_json::Value;
59use storage::{GraphStore, graph_store};
61
62use app::graph_node_edge::{GraphCommandContext, execute_edge, execute_node};
63use app::graph_note::{GraphNoteContext, execute_note};
64use app::graph_query_quality::{
65 execute_audit, execute_baseline, execute_check, execute_duplicates, execute_edge_gaps,
66 execute_feedback_log, execute_feedback_summary, execute_kql, execute_missing_descriptions,
67 execute_missing_facts, execute_quality, execute_stats,
68};
69use app::graph_transfer_temporal::{
70 GraphTransferContext, execute_access_log, execute_access_stats, execute_as_of,
71 execute_diff_as_of, execute_export_dot, execute_export_graphml, execute_export_html,
72 execute_export_json, execute_export_md, execute_export_mermaid, execute_history,
73 execute_import_csv, execute_import_json, execute_import_markdown, execute_split,
74 execute_timeline, execute_vector,
75};
76
77use schema::{GraphSchema, SchemaViolation};
78use validate::validate_graph;
79
80static EVENT_LOG_MODE: AtomicU8 = AtomicU8::new(0);
81
82fn format_schema_violations(violations: &[SchemaViolation]) -> String {
87 let mut lines = Vec::new();
88 lines.push("schema violations:".to_owned());
89 for v in violations {
90 lines.push(format!(" - {}", v.message));
91 }
92 lines.join("\n")
93}
94
95pub(crate) fn bail_on_schema_violations(violations: &[SchemaViolation]) -> Result<()> {
96 if !violations.is_empty() {
97 anyhow::bail!("{}", format_schema_violations(violations));
98 }
99 Ok(())
100}
101
102pub fn validate_node_add_with_schema(cwd: &Path, node: &Node) -> Result<()> {
103 let schema = GraphSchema::discover(cwd)
104 .with_context(|| format!("failed to discover schema from {}", cwd.display()))?
105 .map(|(_, schema)| schema);
106 if let Some(schema) = schema.as_ref() {
107 let violations = schema.validate_node_add(node);
108 bail_on_schema_violations(&violations)?;
109 }
110 Ok(())
111}
112
113fn validate_graph_with_schema(graph: &GraphFile, schema: &GraphSchema) -> Vec<SchemaViolation> {
114 let mut all_violations = Vec::new();
115 for node in &graph.nodes {
116 all_violations.extend(schema.validate_node_add(node));
117 }
118 let node_type_map: std::collections::HashMap<&str, &str> = graph
119 .nodes
120 .iter()
121 .map(|n| (n.id.as_str(), n.r#type.as_str()))
122 .collect();
123 for edge in &graph.edges {
124 if let (Some(src_type), Some(tgt_type)) = (
125 node_type_map.get(edge.source_id.as_str()),
126 node_type_map.get(edge.target_id.as_str()),
127 ) {
128 all_violations.extend(schema.validate_edge_add(
129 &edge.source_id,
130 src_type,
131 &edge.relation,
132 &edge.target_id,
133 tgt_type,
134 ));
135 }
136 }
137 all_violations.extend(schema.validate_uniqueness(&graph.nodes));
138 all_violations
139}
140
141pub fn run<I>(args: I, cwd: &Path) -> Result<()>
149where
150 I: IntoIterator<Item = OsString>,
151{
152 let rendered = run_args(args, cwd)?;
153 if should_colorize_stdout() {
154 print!("{}", colorize_cli_output(&rendered));
155 } else {
156 print!("{rendered}");
157 }
158 Ok(())
159}
160
161fn should_colorize_stdout() -> bool {
162 let force = std::env::var("CLICOLOR_FORCE")
163 .map(|value| value != "0")
164 .unwrap_or(false);
165 if force {
166 return true;
167 }
168 if !std::io::stdout().is_terminal() {
169 return false;
170 }
171 if std::env::var_os("NO_COLOR").is_some() {
172 return false;
173 }
174 std::env::var("CLICOLOR")
175 .map(|value| value != "0")
176 .unwrap_or(true)
177}
178
179fn colorize_cli_output(rendered: &str) -> String {
180 if looks_like_json(rendered) {
181 return rendered.to_owned();
182 }
183 rendered
184 .lines()
185 .map(colorize_line)
186 .collect::<Vec<_>>()
187 .join("\n")
188}
189
190fn looks_like_json(rendered: &str) -> bool {
191 let trimmed = rendered.trim_start();
192 trimmed.starts_with('{') || trimmed.starts_with('[')
193}
194
195fn colorize_line(line: &str) -> String {
196 const RESET: &str = "\x1b[0m";
197 const BOLD_CYAN: &str = "\x1b[1;36m";
198 const BOLD_YELLOW: &str = "\x1b[1;33m";
199 const BOLD_GREEN: &str = "\x1b[1;32m";
200 const BOLD_MAGENTA: &str = "\x1b[1;35m";
201 const BLUE: &str = "\x1b[34m";
202
203 if line.starts_with("# ") {
204 return format!("{BOLD_CYAN}{line}{RESET}");
205 }
206 if line.starts_with("? ") {
207 return format!("{BOLD_YELLOW}{line}{RESET}");
208 }
209 if line.starts_with("= ") || line.starts_with("+ ") {
210 return format!("{BOLD_GREEN}{line}{RESET}");
211 }
212 if line.starts_with("score:") {
213 return format!("{BOLD_MAGENTA}{line}{RESET}");
214 }
215 if line.starts_with("-> ") || line.starts_with("<- ") {
216 return format!("{BLUE}{line}{RESET}");
217 }
218 line.to_owned()
219}
220
221pub fn format_error_chain(err: &anyhow::Error) -> String {
222 let mut rendered = err.to_string();
223 let mut causes = err.chain().skip(1).peekable();
224 if causes.peek().is_some() {
225 rendered.push_str("\ncaused by:");
226 for cause in causes {
227 let _ = write!(rendered, "\n - {cause}");
228 }
229 }
230 rendered
231}
232
233pub fn run_args<I>(args: I, cwd: &Path) -> Result<String>
237where
238 I: IntoIterator<Item = OsString>,
239{
240 let cli = Cli::parse_from(normalize_args(args));
241 let graph_root = default_graph_root(cwd);
242 execute(cli, cwd, &graph_root)
243}
244
245pub fn run_args_safe<I>(args: I, cwd: &Path) -> Result<String>
250where
251 I: IntoIterator<Item = OsString>,
252{
253 let cli = Cli::try_parse_from(normalize_args(args)).map_err(|err| anyhow!(err.to_string()))?;
254 let graph_root = default_graph_root(cwd);
255 execute(cli, cwd, &graph_root)
256}
257
258fn normalize_args<I>(args: I) -> Vec<OsString>
263where
264 I: IntoIterator<Item = OsString>,
265{
266 let collected: Vec<OsString> = args.into_iter().collect();
267 if collected.len() <= 1 {
268 return collected;
269 }
270 let first = collected[1].to_string_lossy();
271 if first.starts_with('-')
272 || first == "init"
273 || first == "create"
274 || first == "diff"
275 || first == "merge"
276 || first == "graph"
277 || first == "list"
278 || first == "feedback-log"
279 || first == "feedback-summary"
280 {
281 return collected;
282 }
283 let mut normalized = Vec::with_capacity(collected.len() + 1);
284 normalized.push(collected[0].clone());
285 normalized.push(OsString::from("graph"));
286 normalized.extend(collected.into_iter().skip(1));
287 normalized
288}
289
290fn execute(cli: Cli, cwd: &Path, graph_root: &Path) -> Result<String> {
295 configure_event_log_mode(cli.event_log);
296 match cli.command {
297 Command::Init(args) => Ok(init::render_init(&args)),
298 Command::Create { graph_name } => {
299 let store = graph_store(cwd, graph_root, false)?;
300 let path = store.create_graph(&graph_name)?;
301 let graph_file = store.load_graph(&path)?;
302 append_event_snapshot(&path, "graph.create", Some(graph_name.clone()), &graph_file)?;
303 Ok(format!("+ created {}\n", path.display()))
304 }
305 Command::Diff { left, right, json } => {
306 let store = graph_store(cwd, graph_root, false)?;
307 if json {
308 render_graph_diff_json(store.as_ref(), &left, &right)
309 } else {
310 render_graph_diff(store.as_ref(), &left, &right)
311 }
312 }
313 Command::Merge {
314 target,
315 source,
316 strategy,
317 } => {
318 let store = graph_store(cwd, graph_root, false)?;
319 merge_graphs(store.as_ref(), &target, &source, strategy)
320 }
321 Command::List(args) => {
322 let store = graph_store(cwd, graph_root, false)?;
323 if args.json {
324 render_graph_list_json(store.as_ref())
325 } else {
326 render_graph_list(store.as_ref(), args.full)
327 }
328 }
329 Command::FeedbackLog(args) => execute_feedback_log(cwd, &args),
330 Command::Graph {
331 graph,
332 legacy,
333 command,
334 } => {
335 let store = graph_store(cwd, graph_root, legacy)?;
336 let path = store.resolve_graph_path(&graph)?;
337 let _graph_write_lock = if graph_command_mutates(&command) {
338 Some(graph_lock::acquire_for_graph(&path)?)
339 } else {
340 None
341 };
342 let mut graph_file = store.load_graph(&path)?;
343 let schema = GraphSchema::discover(cwd).ok().flatten().map(|(_, s)| s);
344 let user_short_uid = config::ensure_user_short_uid(cwd);
345
346 match command {
347 GraphCommand::Node { command } => execute_node(
348 command,
349 GraphCommandContext {
350 graph_name: &graph,
351 path: &path,
352 user_short_uid: &user_short_uid,
353 graph_file: &mut graph_file,
354 schema: schema.as_ref(),
355 store: store.as_ref(),
356 },
357 ),
358
359 GraphCommand::Edge { command } => execute_edge(
360 command,
361 GraphCommandContext {
362 graph_name: &graph,
363 path: &path,
364 user_short_uid: &user_short_uid,
365 graph_file: &mut graph_file,
366 schema: schema.as_ref(),
367 store: store.as_ref(),
368 },
369 ),
370
371 GraphCommand::Note { command } => execute_note(
372 command,
373 GraphNoteContext {
374 path: &path,
375 graph_file: &mut graph_file,
376 store: store.as_ref(),
377 _schema: schema.as_ref(),
378 },
379 ),
380
381 GraphCommand::Stats(args) => Ok(execute_stats(&graph_file, &args)),
382 GraphCommand::Check(args) => Ok(execute_check(&graph_file, cwd, &args)),
383 GraphCommand::Audit(args) => Ok(execute_audit(&graph_file, cwd, &args)),
384
385 GraphCommand::Quality { command } => Ok(execute_quality(command, &graph_file)),
386
387 GraphCommand::MissingDescriptions(args) => {
389 Ok(execute_missing_descriptions(&graph_file, &args))
390 }
391 GraphCommand::MissingFacts(args) => Ok(execute_missing_facts(&graph_file, &args)),
392 GraphCommand::Duplicates(args) => Ok(execute_duplicates(&graph_file, &args)),
393 GraphCommand::EdgeGaps(args) => Ok(execute_edge_gaps(&graph_file, &args)),
394 GraphCommand::Clusters(args) => execute_clusters(&graph_file, &path, &args),
395
396 GraphCommand::ExportHtml(args) => execute_export_html(&graph, &graph_file, args),
397
398 GraphCommand::AccessLog(args) => execute_access_log(&path, args),
399
400 GraphCommand::AccessStats(_) => execute_access_stats(&path),
401 GraphCommand::ImportCsv(args) => execute_import_csv(
402 GraphTransferContext {
403 cwd,
404 graph_name: &graph,
405 path: &path,
406 graph_file: &mut graph_file,
407 schema: schema.as_ref(),
408 store: store.as_ref(),
409 },
410 args,
411 ),
412 GraphCommand::ImportMarkdown(args) => execute_import_markdown(
413 GraphTransferContext {
414 cwd,
415 graph_name: &graph,
416 path: &path,
417 graph_file: &mut graph_file,
418 schema: schema.as_ref(),
419 store: store.as_ref(),
420 },
421 args,
422 ),
423 GraphCommand::Kql(args) => execute_kql(&graph_file, args),
424 GraphCommand::ExportJson(args) => execute_export_json(&graph, &graph_file, args),
425 GraphCommand::ImportJson(args) => {
426 execute_import_json(&path, &graph, store.as_ref(), args)
427 }
428 GraphCommand::ExportDot(args) => execute_export_dot(&graph, &graph_file, args),
429 GraphCommand::ExportMermaid(args) => {
430 execute_export_mermaid(&graph, &graph_file, args)
431 }
432 GraphCommand::ExportGraphml(args) => {
433 execute_export_graphml(&graph, &graph_file, args)
434 }
435 GraphCommand::ExportMd(args) => execute_export_md(
436 GraphTransferContext {
437 cwd,
438 graph_name: &graph,
439 path: &path,
440 graph_file: &mut graph_file,
441 schema: schema.as_ref(),
442 store: store.as_ref(),
443 },
444 args,
445 ),
446 GraphCommand::Split(args) => execute_split(&graph, &graph_file, args),
447 GraphCommand::Vector { command } => execute_vector(
448 GraphTransferContext {
449 cwd,
450 graph_name: &graph,
451 path: &path,
452 graph_file: &mut graph_file,
453 schema: schema.as_ref(),
454 store: store.as_ref(),
455 },
456 command,
457 ),
458 GraphCommand::AsOf(args) => execute_as_of(&path, &graph, args),
459 GraphCommand::History(args) => execute_history(&path, &graph, args),
460 GraphCommand::Timeline(args) => execute_timeline(&path, &graph, args),
461 GraphCommand::DiffAsOf(args) => execute_diff_as_of(&path, &graph, args),
462 GraphCommand::FeedbackSummary(args) => {
463 Ok(execute_feedback_summary(cwd, &graph, &args)?)
464 }
465 GraphCommand::Baseline(args) => {
466 Ok(execute_baseline(cwd, &graph, &graph_file, &args)?)
467 }
468 GraphCommand::ScoreAll(args) => execute_score_all(&graph_file, &path, &args),
469 }
470 }
471 }
472}
473
474fn render_graph_list(store: &dyn GraphStore, full: bool) -> Result<String> {
475 let graphs = store.list_graphs()?;
476
477 let mut lines = vec![format!("= graphs ({})", graphs.len())];
478 for (name, path) in graphs {
479 if full {
480 lines.push(format!("- {name} | {}", path.display()));
481 } else {
482 lines.push(format!("- {name}"));
483 }
484 }
485 Ok(format!("{}\n", lines.join("\n")))
486}
487
488fn graph_command_mutates(command: &GraphCommand) -> bool {
489 match command {
490 GraphCommand::Node { command } => node_command_mutates(command),
491 GraphCommand::Edge { command } => edge_command_mutates(command),
492 GraphCommand::Note { command } => note_command_mutates(command),
493 GraphCommand::ImportCsv(_)
494 | GraphCommand::ImportMarkdown(_)
495 | GraphCommand::ImportJson(_)
496 | GraphCommand::Vector {
497 command: VectorCommand::Import(_),
498 } => true,
499 GraphCommand::Stats(_)
500 | GraphCommand::Check(_)
501 | GraphCommand::Audit(_)
502 | GraphCommand::Quality { .. }
503 | GraphCommand::MissingDescriptions(_)
504 | GraphCommand::MissingFacts(_)
505 | GraphCommand::Duplicates(_)
506 | GraphCommand::EdgeGaps(_)
507 | GraphCommand::Clusters(_)
508 | GraphCommand::ExportHtml(_)
509 | GraphCommand::AccessLog(_)
510 | GraphCommand::AccessStats(_)
511 | GraphCommand::Kql(_)
512 | GraphCommand::ExportJson(_)
513 | GraphCommand::ExportDot(_)
514 | GraphCommand::ExportMermaid(_)
515 | GraphCommand::ExportGraphml(_)
516 | GraphCommand::ExportMd(_)
517 | GraphCommand::Split(_)
518 | GraphCommand::Vector {
519 command: VectorCommand::Stats(_),
520 }
521 | GraphCommand::AsOf(_)
522 | GraphCommand::History(_)
523 | GraphCommand::Timeline(_)
524 | GraphCommand::DiffAsOf(_)
525 | GraphCommand::FeedbackSummary(_)
526 | GraphCommand::Baseline(_)
527 | GraphCommand::ScoreAll(_) => false,
528 }
529}
530
531fn execute_score_all(graph: &GraphFile, path: &Path, args: &ScoreAllArgs) -> Result<String> {
532 let outcome = scoring::compute_all_pair_scores_to_cache(
533 graph,
534 path,
535 &scoring::ScoreAllConfig {
536 min_desc_len: args.min_desc_len,
537 desc_weight: args.desc_weight,
538 bundle_weight: args.bundle_weight,
539 cluster_seed: args.cluster_seed,
540 cluster_resolution: args.cluster_resolution,
541 membership_top_k: args.membership_top_k,
542 },
543 )?;
544
545 Ok(format!(
546 "= score-all\n- pairs: {}\n- edges: {}\n- clusters: {}\n- output: {}\n",
547 outcome.pairs,
548 outcome.edges,
549 outcome.clusters,
550 outcome.path.display()
551 ))
552}
553
554fn execute_clusters(graph: &GraphFile, path: &Path, args: &ClustersArgs) -> Result<String> {
555 let source_graph = resolve_cluster_source_graph(graph, path)?;
556 Ok(render_clusters(&source_graph, args))
557}
558
559fn resolve_cluster_source_graph(graph: &GraphFile, path: &Path) -> Result<GraphFile> {
560 let filename = path
561 .file_name()
562 .and_then(|value| value.to_str())
563 .unwrap_or_default();
564 if filename.contains(".score.") {
565 return Ok(graph.clone());
566 }
567
568 let latest = find_latest_score_snapshot(path)?.ok_or_else(|| {
569 anyhow!(
570 "no score cache found for '{}'; run `kg graph {} score-all` first",
571 path.display(),
572 graph.metadata.name
573 )
574 })?;
575 GraphFile::load(&latest)
576}
577
578fn find_latest_score_snapshot(path: &Path) -> Result<Option<PathBuf>> {
579 let stem = path
580 .file_stem()
581 .and_then(|value| value.to_str())
582 .ok_or_else(|| anyhow!("invalid graph filename"))?;
583 let prefix = format!("{stem}.score.");
584 let suffix = ".kg";
585 let mut latest: Option<(u128, PathBuf)> = None;
586
587 let cache_dir = cache_paths::cache_root_for_graph(path);
588 let Ok(entries) = std::fs::read_dir(&cache_dir) else {
589 return Ok(None);
590 };
591
592 for entry in entries.flatten() {
593 let name = entry.file_name();
594 let name = name.to_string_lossy();
595 if !name.starts_with(&prefix) || !name.ends_with(suffix) {
596 continue;
597 }
598 let ts_part = &name[prefix.len()..name.len() - suffix.len()];
599 let Ok(ts) = ts_part.parse::<u128>() else {
600 continue;
601 };
602 if latest.as_ref().map(|(curr, _)| ts > *curr).unwrap_or(true) {
603 latest = Some((ts, entry.path()));
604 }
605 }
606
607 Ok(latest.map(|(_, path)| path))
608}
609
610#[derive(Debug, Serialize)]
611struct ClusterView {
612 id: String,
613 size: usize,
614 relevance: f64,
615 members: Vec<(String, f64)>,
616}
617
618fn render_clusters(graph: &GraphFile, args: &ClustersArgs) -> String {
619 let mut clusters: Vec<ClusterView> = graph
620 .nodes
621 .iter()
622 .filter(|node| node.r#type == "@" && node.id.starts_with("@:cluster_"))
623 .map(|cluster| {
624 let mut members: Vec<(String, f64)> = graph
625 .edges
626 .iter()
627 .filter(|edge| edge.source_id == cluster.id && edge.relation == "HAS")
628 .map(|edge| {
629 (
630 edge.target_id.clone(),
631 edge.properties.detail.parse::<f64>().unwrap_or(0.0),
632 )
633 })
634 .collect();
635 members.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
636 let relevance = if members.is_empty() {
637 0.0
638 } else {
639 members.iter().map(|(_, v)| *v).sum::<f64>() / members.len() as f64
640 };
641 ClusterView {
642 id: cluster.id.clone(),
643 size: members.len(),
644 relevance,
645 members,
646 }
647 })
648 .collect();
649
650 clusters.sort_by(|a, b| {
651 b.relevance
652 .partial_cmp(&a.relevance)
653 .unwrap_or(std::cmp::Ordering::Equal)
654 .then_with(|| b.size.cmp(&a.size))
655 .then_with(|| a.id.cmp(&b.id))
656 });
657 clusters.truncate(args.limit);
658
659 if args.json {
660 return serde_json::to_string_pretty(&clusters).unwrap_or_else(|_| "[]".to_owned());
661 }
662
663 if matches!(args.skill, Some(ClusterSkill::Gardener)) {
664 let mut lines = vec![format!("= gardener clusters ({})", clusters.len())];
665 for cluster in &clusters {
666 let top = cluster
667 .members
668 .iter()
669 .take(3)
670 .map(|(id, score)| format!("{id} ({score:.3})"))
671 .collect::<Vec<_>>()
672 .join(", ");
673 lines.push(format!(
674 "- {} | relevance {:.3} | size {} | top: {}",
675 cluster.id, cluster.relevance, cluster.size, top
676 ));
677 lines.push(format!(
678 "- action: review cluster {}, merge aliases/facts, then keep strongest node as canonical",
679 cluster.id
680 ));
681 }
682 return format!("{}\n", lines.join("\n"));
683 }
684
685 let mut lines = vec![format!("= clusters ({})", clusters.len())];
686 for cluster in &clusters {
687 let top = cluster
688 .members
689 .iter()
690 .take(5)
691 .map(|(id, score)| format!("{id}:{score:.3}"))
692 .collect::<Vec<_>>()
693 .join(", ");
694 lines.push(format!(
695 "- {} | relevance {:.3} | size {} | top {}",
696 cluster.id, cluster.relevance, cluster.size, top
697 ));
698 }
699 format!("{}\n", lines.join("\n"))
700}
701
702fn node_command_mutates(command: &NodeCommand) -> bool {
703 matches!(
704 command,
705 NodeCommand::Add(_)
706 | NodeCommand::Modify(_)
707 | NodeCommand::Rename { .. }
708 | NodeCommand::Remove { .. }
709 )
710}
711
712fn edge_command_mutates(command: &EdgeCommand) -> bool {
713 matches!(
714 command,
715 EdgeCommand::Add(_) | EdgeCommand::AddBatch(_) | EdgeCommand::Remove(_)
716 )
717}
718
719fn note_command_mutates(command: &NoteCommand) -> bool {
720 matches!(command, NoteCommand::Add(_) | NoteCommand::Remove { .. })
721}
722
723#[derive(Debug, Serialize)]
724struct GraphListEntry {
725 name: String,
726 path: String,
727}
728
729#[derive(Debug, Serialize)]
730struct GraphListResponse {
731 graphs: Vec<GraphListEntry>,
732}
733
734fn render_graph_list_json(store: &dyn GraphStore) -> Result<String> {
735 let graphs = store.list_graphs()?;
736 let entries = graphs
737 .into_iter()
738 .map(|(name, path)| GraphListEntry {
739 name,
740 path: path.display().to_string(),
741 })
742 .collect();
743 let payload = GraphListResponse { graphs: entries };
744 Ok(serde_json::to_string_pretty(&payload).unwrap_or_else(|_| "{}".to_owned()))
745}
746
747#[derive(Debug, Serialize)]
748struct FindQueryResult {
749 query: String,
750 count: usize,
751 nodes: Vec<ScoredFindNode>,
752}
753
754#[derive(Debug, Serialize)]
755struct ScoredFindNode {
756 score: i64,
757 node: Node,
758 #[serde(skip_serializing_if = "Option::is_none")]
759 score_breakdown: Option<ScoredFindBreakdown>,
760}
761
762#[derive(Debug, Serialize)]
763struct ScoredFindBreakdown {
764 raw_relevance: f64,
765 normalized_relevance: i64,
766 lexical_boost: i64,
767 feedback_boost: i64,
768 importance_boost: i64,
769 authority_raw: i64,
770 authority_applied: i64,
771 authority_cap: i64,
772}
773
774#[derive(Debug, Serialize)]
775struct FindResponse {
776 total: usize,
777 queries: Vec<FindQueryResult>,
778}
779
780pub(crate) fn render_find_json_with_index(
781 graph: &GraphFile,
782 queries: &[String],
783 limit: usize,
784 include_metadata: bool,
785 mode: output::FindMode,
786 debug_score: bool,
787 index: Option<&Bm25Index>,
788 tune: Option<&output::FindTune>,
789) -> String {
790 let mut total = 0usize;
791 let mut results = Vec::new();
792 for query in queries {
793 let (count, scored_nodes) = output::find_scored_nodes_and_total_with_index_tuned(
794 graph,
795 query,
796 limit,
797 true,
798 include_metadata,
799 mode,
800 index,
801 tune,
802 );
803 total += count;
804 let nodes = scored_nodes
805 .into_iter()
806 .map(|entry| ScoredFindNode {
807 score: entry.score,
808 node: entry.node,
809 score_breakdown: debug_score.then_some(ScoredFindBreakdown {
810 raw_relevance: entry.breakdown.raw_relevance,
811 normalized_relevance: entry.breakdown.normalized_relevance,
812 lexical_boost: entry.breakdown.lexical_boost,
813 feedback_boost: entry.breakdown.feedback_boost,
814 importance_boost: entry.breakdown.importance_boost,
815 authority_raw: entry.breakdown.authority_raw,
816 authority_applied: entry.breakdown.authority_applied,
817 authority_cap: entry.breakdown.authority_cap,
818 }),
819 })
820 .collect();
821 results.push(FindQueryResult {
822 query: query.clone(),
823 count,
824 nodes,
825 });
826 }
827 let payload = FindResponse {
828 total,
829 queries: results,
830 };
831 serde_json::to_string_pretty(&payload).unwrap_or_else(|_| "{}".to_owned())
832}
833
834#[derive(Debug, Serialize)]
835struct NodeGetResponse {
836 node: Node,
837}
838
839pub(crate) fn render_node_json(node: &Node) -> String {
840 let payload = NodeGetResponse { node: node.clone() };
841 serde_json::to_string_pretty(&payload).unwrap_or_else(|_| "{}".to_owned())
842}
843
844fn render_graph_diff(store: &dyn GraphStore, left: &str, right: &str) -> Result<String> {
845 let left_path = store.resolve_graph_path(left)?;
846 let right_path = store.resolve_graph_path(right)?;
847 let left_graph = store.load_graph(&left_path)?;
848 let right_graph = store.load_graph(&right_path)?;
849 Ok(render_graph_diff_from_files(
850 left,
851 right,
852 &left_graph,
853 &right_graph,
854 ))
855}
856
857fn render_graph_diff_json(store: &dyn GraphStore, left: &str, right: &str) -> Result<String> {
858 let left_path = store.resolve_graph_path(left)?;
859 let right_path = store.resolve_graph_path(right)?;
860 let left_graph = store.load_graph(&left_path)?;
861 let right_graph = store.load_graph(&right_path)?;
862 Ok(render_graph_diff_json_from_files(
863 left,
864 right,
865 &left_graph,
866 &right_graph,
867 ))
868}
869
870#[derive(Debug, Serialize)]
871struct DiffEntry {
872 path: String,
873 left: Value,
874 right: Value,
875}
876
877#[derive(Debug, Serialize)]
878struct EntityDiff {
879 id: String,
880 diffs: Vec<DiffEntry>,
881}
882
883#[derive(Debug, Serialize)]
884struct GraphDiffResponse {
885 left: String,
886 right: String,
887 added_nodes: Vec<String>,
888 removed_nodes: Vec<String>,
889 changed_nodes: Vec<EntityDiff>,
890 added_edges: Vec<String>,
891 removed_edges: Vec<String>,
892 changed_edges: Vec<EntityDiff>,
893 added_notes: Vec<String>,
894 removed_notes: Vec<String>,
895 changed_notes: Vec<EntityDiff>,
896}
897
898fn render_graph_diff_json_from_files(
899 left: &str,
900 right: &str,
901 left_graph: &GraphFile,
902 right_graph: &GraphFile,
903) -> String {
904 use std::collections::{HashMap, HashSet};
905
906 let left_nodes: HashSet<String> = left_graph.nodes.iter().map(|n| n.id.clone()).collect();
907 let right_nodes: HashSet<String> = right_graph.nodes.iter().map(|n| n.id.clone()).collect();
908
909 let left_node_map: HashMap<String, &Node> =
910 left_graph.nodes.iter().map(|n| (n.id.clone(), n)).collect();
911 let right_node_map: HashMap<String, &Node> = right_graph
912 .nodes
913 .iter()
914 .map(|n| (n.id.clone(), n))
915 .collect();
916
917 let left_edges: HashSet<String> = left_graph
918 .edges
919 .iter()
920 .map(|e| format!("{} {} {}", e.source_id, e.relation, e.target_id))
921 .collect();
922 let right_edges: HashSet<String> = right_graph
923 .edges
924 .iter()
925 .map(|e| format!("{} {} {}", e.source_id, e.relation, e.target_id))
926 .collect();
927
928 let left_edge_map: HashMap<String, &Edge> = left_graph
929 .edges
930 .iter()
931 .map(|e| (format!("{} {} {}", e.source_id, e.relation, e.target_id), e))
932 .collect();
933 let right_edge_map: HashMap<String, &Edge> = right_graph
934 .edges
935 .iter()
936 .map(|e| (format!("{} {} {}", e.source_id, e.relation, e.target_id), e))
937 .collect();
938
939 let left_notes: HashSet<String> = left_graph.notes.iter().map(|n| n.id.clone()).collect();
940 let right_notes: HashSet<String> = right_graph.notes.iter().map(|n| n.id.clone()).collect();
941
942 let left_note_map: HashMap<String, &Note> =
943 left_graph.notes.iter().map(|n| (n.id.clone(), n)).collect();
944 let right_note_map: HashMap<String, &Note> = right_graph
945 .notes
946 .iter()
947 .map(|n| (n.id.clone(), n))
948 .collect();
949
950 let mut added_nodes: Vec<String> = right_nodes.difference(&left_nodes).cloned().collect();
951 let mut removed_nodes: Vec<String> = left_nodes.difference(&right_nodes).cloned().collect();
952 let mut added_edges: Vec<String> = right_edges.difference(&left_edges).cloned().collect();
953 let mut removed_edges: Vec<String> = left_edges.difference(&right_edges).cloned().collect();
954 let mut added_notes: Vec<String> = right_notes.difference(&left_notes).cloned().collect();
955 let mut removed_notes: Vec<String> = left_notes.difference(&right_notes).cloned().collect();
956
957 let mut changed_nodes: Vec<String> = left_nodes
958 .intersection(&right_nodes)
959 .filter_map(|id| {
960 let left_node = left_node_map.get(id.as_str())?;
961 let right_node = right_node_map.get(id.as_str())?;
962 if eq_serialized(*left_node, *right_node) {
963 None
964 } else {
965 Some(id.clone())
966 }
967 })
968 .collect();
969 let mut changed_edges: Vec<String> = left_edges
970 .intersection(&right_edges)
971 .filter_map(|key| {
972 let left_edge = left_edge_map.get(key.as_str())?;
973 let right_edge = right_edge_map.get(key.as_str())?;
974 if eq_serialized(*left_edge, *right_edge) {
975 None
976 } else {
977 Some(key.clone())
978 }
979 })
980 .collect();
981 let mut changed_notes: Vec<String> = left_notes
982 .intersection(&right_notes)
983 .filter_map(|id| {
984 let left_note = left_note_map.get(id.as_str())?;
985 let right_note = right_note_map.get(id.as_str())?;
986 if eq_serialized(*left_note, *right_note) {
987 None
988 } else {
989 Some(id.clone())
990 }
991 })
992 .collect();
993
994 added_nodes.sort();
995 removed_nodes.sort();
996 added_edges.sort();
997 removed_edges.sort();
998 added_notes.sort();
999 removed_notes.sort();
1000 changed_nodes.sort();
1001 changed_edges.sort();
1002 changed_notes.sort();
1003
1004 let changed_nodes = changed_nodes
1005 .into_iter()
1006 .map(|id| EntityDiff {
1007 diffs: left_node_map
1008 .get(id.as_str())
1009 .zip(right_node_map.get(id.as_str()))
1010 .map(|(left_node, right_node)| diff_serialized_values_json(*left_node, *right_node))
1011 .unwrap_or_default(),
1012 id,
1013 })
1014 .collect();
1015 let changed_edges = changed_edges
1016 .into_iter()
1017 .map(|id| EntityDiff {
1018 diffs: left_edge_map
1019 .get(id.as_str())
1020 .zip(right_edge_map.get(id.as_str()))
1021 .map(|(left_edge, right_edge)| diff_serialized_values_json(*left_edge, *right_edge))
1022 .unwrap_or_default(),
1023 id,
1024 })
1025 .collect();
1026 let changed_notes = changed_notes
1027 .into_iter()
1028 .map(|id| EntityDiff {
1029 diffs: left_note_map
1030 .get(id.as_str())
1031 .zip(right_note_map.get(id.as_str()))
1032 .map(|(left_note, right_note)| diff_serialized_values_json(*left_note, *right_note))
1033 .unwrap_or_default(),
1034 id,
1035 })
1036 .collect();
1037
1038 let payload = GraphDiffResponse {
1039 left: left.to_owned(),
1040 right: right.to_owned(),
1041 added_nodes,
1042 removed_nodes,
1043 changed_nodes,
1044 added_edges,
1045 removed_edges,
1046 changed_edges,
1047 added_notes,
1048 removed_notes,
1049 changed_notes,
1050 };
1051 serde_json::to_string_pretty(&payload).unwrap_or_else(|_| "{}".to_owned())
1052}
1053
1054fn render_graph_diff_from_files(
1055 left: &str,
1056 right: &str,
1057 left_graph: &GraphFile,
1058 right_graph: &GraphFile,
1059) -> String {
1060 use std::collections::{HashMap, HashSet};
1061
1062 let left_nodes: HashSet<String> = left_graph.nodes.iter().map(|n| n.id.clone()).collect();
1063 let right_nodes: HashSet<String> = right_graph.nodes.iter().map(|n| n.id.clone()).collect();
1064
1065 let left_node_map: HashMap<String, &Node> =
1066 left_graph.nodes.iter().map(|n| (n.id.clone(), n)).collect();
1067 let right_node_map: HashMap<String, &Node> = right_graph
1068 .nodes
1069 .iter()
1070 .map(|n| (n.id.clone(), n))
1071 .collect();
1072
1073 let left_edges: HashSet<String> = left_graph
1074 .edges
1075 .iter()
1076 .map(|e| format!("{} {} {}", e.source_id, e.relation, e.target_id))
1077 .collect();
1078 let right_edges: HashSet<String> = right_graph
1079 .edges
1080 .iter()
1081 .map(|e| format!("{} {} {}", e.source_id, e.relation, e.target_id))
1082 .collect();
1083
1084 let left_edge_map: HashMap<String, &Edge> = left_graph
1085 .edges
1086 .iter()
1087 .map(|e| (format!("{} {} {}", e.source_id, e.relation, e.target_id), e))
1088 .collect();
1089 let right_edge_map: HashMap<String, &Edge> = right_graph
1090 .edges
1091 .iter()
1092 .map(|e| (format!("{} {} {}", e.source_id, e.relation, e.target_id), e))
1093 .collect();
1094
1095 let left_notes: HashSet<String> = left_graph.notes.iter().map(|n| n.id.clone()).collect();
1096 let right_notes: HashSet<String> = right_graph.notes.iter().map(|n| n.id.clone()).collect();
1097
1098 let left_note_map: HashMap<String, &Note> =
1099 left_graph.notes.iter().map(|n| (n.id.clone(), n)).collect();
1100 let right_note_map: HashMap<String, &Note> = right_graph
1101 .notes
1102 .iter()
1103 .map(|n| (n.id.clone(), n))
1104 .collect();
1105
1106 let mut added_nodes: Vec<String> = right_nodes.difference(&left_nodes).cloned().collect();
1107 let mut removed_nodes: Vec<String> = left_nodes.difference(&right_nodes).cloned().collect();
1108 let mut added_edges: Vec<String> = right_edges.difference(&left_edges).cloned().collect();
1109 let mut removed_edges: Vec<String> = left_edges.difference(&right_edges).cloned().collect();
1110 let mut added_notes: Vec<String> = right_notes.difference(&left_notes).cloned().collect();
1111 let mut removed_notes: Vec<String> = left_notes.difference(&right_notes).cloned().collect();
1112
1113 let mut changed_nodes: Vec<String> = left_nodes
1114 .intersection(&right_nodes)
1115 .filter_map(|id| {
1116 let left_node = left_node_map.get(id.as_str())?;
1117 let right_node = right_node_map.get(id.as_str())?;
1118 if eq_serialized(*left_node, *right_node) {
1119 None
1120 } else {
1121 Some(id.clone())
1122 }
1123 })
1124 .collect();
1125
1126 let mut changed_edges: Vec<String> = left_edges
1127 .intersection(&right_edges)
1128 .filter_map(|key| {
1129 let left_edge = left_edge_map.get(key.as_str())?;
1130 let right_edge = right_edge_map.get(key.as_str())?;
1131 if eq_serialized(*left_edge, *right_edge) {
1132 None
1133 } else {
1134 Some(key.clone())
1135 }
1136 })
1137 .collect();
1138
1139 let mut changed_notes: Vec<String> = left_notes
1140 .intersection(&right_notes)
1141 .filter_map(|id| {
1142 let left_note = left_note_map.get(id.as_str())?;
1143 let right_note = right_note_map.get(id.as_str())?;
1144 if eq_serialized(*left_note, *right_note) {
1145 None
1146 } else {
1147 Some(id.clone())
1148 }
1149 })
1150 .collect();
1151
1152 added_nodes.sort();
1153 removed_nodes.sort();
1154 added_edges.sort();
1155 removed_edges.sort();
1156 added_notes.sort();
1157 removed_notes.sort();
1158 changed_nodes.sort();
1159 changed_edges.sort();
1160 changed_notes.sort();
1161
1162 let mut lines = vec![format!("= diff {left} -> {right}")];
1163 lines.push(format!("+ nodes ({})", added_nodes.len()));
1164 for id in added_nodes {
1165 lines.push(format!("+ node {id}"));
1166 }
1167 lines.push(format!("- nodes ({})", removed_nodes.len()));
1168 for id in removed_nodes {
1169 lines.push(format!("- node {id}"));
1170 }
1171 lines.push(format!("~ nodes ({})", changed_nodes.len()));
1172 for id in changed_nodes {
1173 if let (Some(left_node), Some(right_node)) = (
1174 left_node_map.get(id.as_str()),
1175 right_node_map.get(id.as_str()),
1176 ) {
1177 lines.extend(render_entity_diff_lines("node", &id, left_node, right_node));
1178 } else {
1179 lines.push(format!("~ node {id}"));
1180 }
1181 }
1182 lines.push(format!("+ edges ({})", added_edges.len()));
1183 for edge in added_edges {
1184 lines.push(format!("+ edge {edge}"));
1185 }
1186 lines.push(format!("- edges ({})", removed_edges.len()));
1187 for edge in removed_edges {
1188 lines.push(format!("- edge {edge}"));
1189 }
1190 lines.push(format!("~ edges ({})", changed_edges.len()));
1191 for edge in changed_edges {
1192 if let (Some(left_edge), Some(right_edge)) = (
1193 left_edge_map.get(edge.as_str()),
1194 right_edge_map.get(edge.as_str()),
1195 ) {
1196 lines.extend(render_entity_diff_lines(
1197 "edge", &edge, left_edge, right_edge,
1198 ));
1199 } else {
1200 lines.push(format!("~ edge {edge}"));
1201 }
1202 }
1203 lines.push(format!("+ notes ({})", added_notes.len()));
1204 for note_id in added_notes {
1205 lines.push(format!("+ note {note_id}"));
1206 }
1207 lines.push(format!("- notes ({})", removed_notes.len()));
1208 for note_id in removed_notes {
1209 lines.push(format!("- note {note_id}"));
1210 }
1211 lines.push(format!("~ notes ({})", changed_notes.len()));
1212 for note_id in changed_notes {
1213 if let (Some(left_note), Some(right_note)) = (
1214 left_note_map.get(note_id.as_str()),
1215 right_note_map.get(note_id.as_str()),
1216 ) {
1217 lines.extend(render_entity_diff_lines(
1218 "note", ¬e_id, left_note, right_note,
1219 ));
1220 } else {
1221 lines.push(format!("~ note {note_id}"));
1222 }
1223 }
1224
1225 format!("{}\n", lines.join("\n"))
1226}
1227
1228fn eq_serialized<T: Serialize>(left: &T, right: &T) -> bool {
1229 match (serde_json::to_value(left), serde_json::to_value(right)) {
1230 (Ok(left_value), Ok(right_value)) => left_value == right_value,
1231 _ => false,
1232 }
1233}
1234
1235fn render_entity_diff_lines<T: Serialize>(
1236 kind: &str,
1237 id: &str,
1238 left: &T,
1239 right: &T,
1240) -> Vec<String> {
1241 let mut lines = Vec::new();
1242 lines.push(format!("~ {kind} {id}"));
1243 for diff in diff_serialized_values(left, right) {
1244 lines.push(format!(" ~ {diff}"));
1245 }
1246 lines
1247}
1248
1249fn diff_serialized_values<T: Serialize>(left: &T, right: &T) -> Vec<String> {
1250 match (serde_json::to_value(left), serde_json::to_value(right)) {
1251 (Ok(left_value), Ok(right_value)) => {
1252 let mut diffs = Vec::new();
1253 collect_value_diffs("", &left_value, &right_value, &mut diffs);
1254 diffs
1255 }
1256 _ => vec!["<serialization failed>".to_owned()],
1257 }
1258}
1259
1260fn diff_serialized_values_json<T: Serialize>(left: &T, right: &T) -> Vec<DiffEntry> {
1261 match (serde_json::to_value(left), serde_json::to_value(right)) {
1262 (Ok(left_value), Ok(right_value)) => {
1263 let mut diffs = Vec::new();
1264 collect_value_diffs_json("", &left_value, &right_value, &mut diffs);
1265 diffs
1266 }
1267 _ => Vec::new(),
1268 }
1269}
1270
1271fn collect_value_diffs_json(path: &str, left: &Value, right: &Value, out: &mut Vec<DiffEntry>) {
1272 if left == right {
1273 return;
1274 }
1275 match (left, right) {
1276 (Value::Object(left_obj), Value::Object(right_obj)) => {
1277 use std::collections::BTreeSet;
1278
1279 let mut keys: BTreeSet<&str> = BTreeSet::new();
1280 for key in left_obj.keys() {
1281 keys.insert(key.as_str());
1282 }
1283 for key in right_obj.keys() {
1284 keys.insert(key.as_str());
1285 }
1286 for key in keys {
1287 let left_value = left_obj.get(key).unwrap_or(&Value::Null);
1288 let right_value = right_obj.get(key).unwrap_or(&Value::Null);
1289 let next_path = if path.is_empty() {
1290 key.to_owned()
1291 } else {
1292 format!("{path}.{key}")
1293 };
1294 collect_value_diffs_json(&next_path, left_value, right_value, out);
1295 }
1296 }
1297 (Value::Array(_), Value::Array(_)) => {
1298 let label = if path.is_empty() {
1299 "<root>[]".to_owned()
1300 } else {
1301 format!("{path}[]")
1302 };
1303 out.push(DiffEntry {
1304 path: label,
1305 left: left.clone(),
1306 right: right.clone(),
1307 });
1308 }
1309 _ => {
1310 let label = if path.is_empty() { "<root>" } else { path };
1311 out.push(DiffEntry {
1312 path: label.to_owned(),
1313 left: left.clone(),
1314 right: right.clone(),
1315 });
1316 }
1317 }
1318}
1319
1320fn collect_value_diffs(path: &str, left: &Value, right: &Value, out: &mut Vec<String>) {
1321 if left == right {
1322 return;
1323 }
1324 match (left, right) {
1325 (Value::Object(left_obj), Value::Object(right_obj)) => {
1326 use std::collections::BTreeSet;
1327
1328 let mut keys: BTreeSet<&str> = BTreeSet::new();
1329 for key in left_obj.keys() {
1330 keys.insert(key.as_str());
1331 }
1332 for key in right_obj.keys() {
1333 keys.insert(key.as_str());
1334 }
1335 for key in keys {
1336 let left_value = left_obj.get(key).unwrap_or(&Value::Null);
1337 let right_value = right_obj.get(key).unwrap_or(&Value::Null);
1338 let next_path = if path.is_empty() {
1339 key.to_owned()
1340 } else {
1341 format!("{path}.{key}")
1342 };
1343 collect_value_diffs(&next_path, left_value, right_value, out);
1344 }
1345 }
1346 (Value::Array(_), Value::Array(_)) => {
1347 let label = if path.is_empty() {
1348 "<root>[]".to_owned()
1349 } else {
1350 format!("{path}[]")
1351 };
1352 out.push(format!(
1353 "{label}: {} -> {}",
1354 format_value(left),
1355 format_value(right)
1356 ));
1357 }
1358 _ => {
1359 let label = if path.is_empty() { "<root>" } else { path };
1360 out.push(format!(
1361 "{label}: {} -> {}",
1362 format_value(left),
1363 format_value(right)
1364 ));
1365 }
1366 }
1367}
1368
1369fn format_value(value: &Value) -> String {
1370 let mut rendered =
1371 serde_json::to_string(value).unwrap_or_else(|_| "<unserializable>".to_owned());
1372 rendered = rendered.replace('\n', "\\n");
1373 truncate_value(rendered, 160)
1374}
1375
1376fn truncate_value(mut value: String, limit: usize) -> String {
1377 if value.len() <= limit {
1378 return value;
1379 }
1380 value.truncate(limit.saturating_sub(3));
1381 value.push_str("...");
1382 value
1383}
1384
1385fn merge_graphs(
1386 store: &dyn GraphStore,
1387 target: &str,
1388 source: &str,
1389 strategy: MergeStrategy,
1390) -> Result<String> {
1391 use std::collections::HashMap;
1392
1393 let target_path = store.resolve_graph_path(target)?;
1394 let _target_write_lock = graph_lock::acquire_for_graph(&target_path)?;
1395 let source_path = store.resolve_graph_path(source)?;
1396 let mut target_graph = store.load_graph(&target_path)?;
1397 let source_graph = store.load_graph(&source_path)?;
1398
1399 let mut node_index: HashMap<String, usize> = HashMap::new();
1400 for (idx, node) in target_graph.nodes.iter().enumerate() {
1401 node_index.insert(node.id.clone(), idx);
1402 }
1403
1404 let mut node_added = 0usize;
1405 let mut node_updated = 0usize;
1406 for node in &source_graph.nodes {
1407 if let Some(&idx) = node_index.get(&node.id) {
1408 if matches!(strategy, MergeStrategy::PreferNew) {
1409 target_graph.nodes[idx] = node.clone();
1410 node_updated += 1;
1411 }
1412 } else {
1413 target_graph.nodes.push(node.clone());
1414 node_index.insert(node.id.clone(), target_graph.nodes.len() - 1);
1415 node_added += 1;
1416 }
1417 }
1418
1419 let mut edge_index: HashMap<String, usize> = HashMap::new();
1420 for (idx, edge) in target_graph.edges.iter().enumerate() {
1421 let key = format!("{} {} {}", edge.source_id, edge.relation, edge.target_id);
1422 edge_index.insert(key, idx);
1423 }
1424
1425 let mut edge_added = 0usize;
1426 let mut edge_updated = 0usize;
1427 for edge in &source_graph.edges {
1428 let key = format!("{} {} {}", edge.source_id, edge.relation, edge.target_id);
1429 if let Some(&idx) = edge_index.get(&key) {
1430 if matches!(strategy, MergeStrategy::PreferNew) {
1431 target_graph.edges[idx] = edge.clone();
1432 edge_updated += 1;
1433 }
1434 } else {
1435 target_graph.edges.push(edge.clone());
1436 edge_index.insert(key, target_graph.edges.len() - 1);
1437 edge_added += 1;
1438 }
1439 }
1440
1441 let mut note_index: HashMap<String, usize> = HashMap::new();
1442 for (idx, note) in target_graph.notes.iter().enumerate() {
1443 note_index.insert(note.id.clone(), idx);
1444 }
1445
1446 let mut note_added = 0usize;
1447 let mut note_updated = 0usize;
1448 for note in &source_graph.notes {
1449 if let Some(&idx) = note_index.get(¬e.id) {
1450 if matches!(strategy, MergeStrategy::PreferNew) {
1451 target_graph.notes[idx] = note.clone();
1452 note_updated += 1;
1453 }
1454 } else {
1455 target_graph.notes.push(note.clone());
1456 note_index.insert(note.id.clone(), target_graph.notes.len() - 1);
1457 note_added += 1;
1458 }
1459 }
1460
1461 store.save_graph(&target_path, &target_graph)?;
1462 append_event_snapshot(
1463 &target_path,
1464 "graph.merge",
1465 Some(format!("{source} -> {target} ({strategy:?})")),
1466 &target_graph,
1467 )?;
1468
1469 let mut lines = vec![format!("+ merged {source} -> {target}")];
1470 lines.push(format!("nodes: +{node_added} ~{node_updated}"));
1471 lines.push(format!("edges: +{edge_added} ~{edge_updated}"));
1472 lines.push(format!("notes: +{note_added} ~{note_updated}"));
1473
1474 Ok(format!("{}\n", lines.join("\n")))
1475}
1476
1477pub(crate) fn export_graph_as_of(path: &Path, graph: &str, args: &AsOfArgs) -> Result<String> {
1478 match resolve_temporal_source(path, args.source)? {
1479 TemporalSource::EventLog => export_graph_as_of_event_log(path, graph, args),
1480 _ => export_graph_as_of_backups(path, graph, args),
1481 }
1482}
1483
1484fn export_graph_as_of_backups(path: &Path, graph: &str, args: &AsOfArgs) -> Result<String> {
1485 let backups = list_graph_backups(path)?;
1486 if backups.is_empty() {
1487 bail!("no backups found for graph: {graph}");
1488 }
1489 let target_ts = args.ts_ms / 1000;
1490 let mut selected = None;
1491 for (ts, backup_path) in backups {
1492 if ts <= target_ts {
1493 selected = Some((ts, backup_path));
1494 }
1495 }
1496 let Some((ts, backup_path)) = selected else {
1497 bail!("no backup at or before ts_ms={}", args.ts_ms);
1498 };
1499
1500 let output_path = args
1501 .output
1502 .clone()
1503 .unwrap_or_else(|| format!("{graph}.asof.{}.json", args.ts_ms));
1504 let raw = read_gz_to_string(&backup_path)?;
1505 std::fs::write(&output_path, raw)?;
1506 Ok(format!("+ exported {output_path} (as-of {ts})\n"))
1507}
1508
1509fn export_graph_as_of_event_log(path: &Path, graph: &str, args: &AsOfArgs) -> Result<String> {
1510 let entries = event_log::read_log(path)?;
1511 if entries.is_empty() {
1512 bail!("no event log entries found for graph: {graph}");
1513 }
1514 let selected = select_event_at_or_before(&entries, args.ts_ms)
1515 .ok_or_else(|| anyhow!("no event log entry at or before ts_ms={}", args.ts_ms))?;
1516 let output_path = args
1517 .output
1518 .clone()
1519 .unwrap_or_else(|| format!("{graph}.asof.{}.json", args.ts_ms));
1520 let mut snapshot = selected.graph.clone();
1521 snapshot.refresh_counts();
1522 let raw = serde_json::to_string_pretty(&snapshot).context("failed to serialize graph")?;
1523 std::fs::write(&output_path, raw)?;
1524 Ok(format!(
1525 "+ exported {output_path} (as-of {})\n",
1526 selected.ts_ms
1527 ))
1528}
1529
1530fn list_graph_backups(path: &Path) -> Result<Vec<(u64, PathBuf)>> {
1531 let stem = path
1532 .file_stem()
1533 .and_then(|s| s.to_str())
1534 .ok_or_else(|| anyhow!("invalid graph filename"))?;
1535 let ext = path.extension().and_then(|s| s.to_str()).unwrap_or("json");
1536 let prefixes = [format!("{stem}.{ext}.bck."), format!("{stem}.bck.")];
1537 let suffix = ".gz";
1538
1539 let mut backups = Vec::new();
1540 let mut dirs = vec![cache_paths::cache_root_for_graph(path)];
1541 if let Some(parent) = path.parent() {
1542 dirs.push(parent.to_path_buf());
1543 }
1544
1545 for dir in dirs {
1546 let Ok(entries) = std::fs::read_dir(dir) else {
1547 continue;
1548 };
1549 for entry in entries {
1550 let entry = entry?;
1551 let name = entry.file_name();
1552 let name = name.to_string_lossy();
1553 if !name.ends_with(suffix) {
1554 continue;
1555 }
1556 for prefix in &prefixes {
1557 if !name.starts_with(prefix) {
1558 continue;
1559 }
1560 let ts_part = &name[prefix.len()..name.len() - suffix.len()];
1561 if let Ok(ts) = ts_part.parse::<u64>() {
1562 backups.push((ts, entry.path()));
1563 }
1564 }
1565 }
1566 }
1567 backups.sort_by_key(|(ts, _)| *ts);
1568 Ok(backups)
1569}
1570
1571fn read_gz_to_string(path: &Path) -> Result<String> {
1572 use flate2::read::GzDecoder;
1573 use std::io::Read;
1574
1575 let data = std::fs::read(path)?;
1576 let mut decoder = GzDecoder::new(&data[..]);
1577 let mut out = String::new();
1578 decoder.read_to_string(&mut out)?;
1579 Ok(out)
1580}
1581
1582pub(crate) fn append_event_snapshot(
1583 path: &Path,
1584 action: &str,
1585 detail: Option<String>,
1586 graph: &GraphFile,
1587) -> Result<()> {
1588 if !event_log_enabled() {
1589 return Ok(());
1590 }
1591 event_log::append_snapshot(path, action, detail, graph)
1592}
1593
1594fn configure_event_log_mode(cli_switch_enabled: bool) {
1595 if cli_switch_enabled {
1596 EVENT_LOG_MODE.store(2, Ordering::Relaxed);
1597 return;
1598 }
1599 EVENT_LOG_MODE.store(0, Ordering::Relaxed);
1600}
1601
1602fn event_log_enabled() -> bool {
1603 match EVENT_LOG_MODE.load(Ordering::Relaxed) {
1604 2 => true,
1605 1 => false,
1606 _ => {
1607 let raw = std::env::var("KG_EVENT_LOG").unwrap_or_default();
1608 matches!(raw.as_str(), "1" | "true" | "TRUE" | "yes" | "on")
1609 }
1610 }
1611}
1612
1613pub(crate) fn export_graph_json(
1614 graph: &str,
1615 graph_file: &GraphFile,
1616 output: Option<&str>,
1617) -> Result<String> {
1618 let output_path = output
1619 .map(|value| value.to_owned())
1620 .unwrap_or_else(|| format!("{graph}.export.json"));
1621 let raw = serde_json::to_string_pretty(graph_file).context("failed to serialize graph")?;
1622 std::fs::write(&output_path, raw)?;
1623 Ok(format!("+ exported {output_path}\n"))
1624}
1625
1626pub(crate) fn import_graph_json(
1627 path: &Path,
1628 graph: &str,
1629 input: &str,
1630 store: &dyn GraphStore,
1631) -> Result<String> {
1632 let raw = std::fs::read_to_string(input)
1633 .with_context(|| format!("failed to read import file: {input}"))?;
1634 let mut imported: GraphFile =
1635 serde_json::from_str(&raw).with_context(|| format!("invalid JSON: {input}"))?;
1636 imported.metadata.name = graph.to_owned();
1637 imported.refresh_counts();
1638 store.save_graph(path, &imported)?;
1639 append_event_snapshot(path, "graph.import", Some(input.to_owned()), &imported)?;
1640 Ok(format!("+ imported {input} -> {graph}\n"))
1641}
1642
1643pub(crate) fn import_graph_csv(
1644 path: &Path,
1645 graph: &str,
1646 graph_file: &mut GraphFile,
1647 store: &dyn GraphStore,
1648 args: &ImportCsvArgs,
1649 schema: Option<&GraphSchema>,
1650) -> Result<String> {
1651 if args.nodes.is_none() && args.edges.is_none() && args.notes.is_none() {
1652 bail!("expected at least one of --nodes/--edges/--notes");
1653 }
1654 let strategy = match args.strategy {
1655 MergeStrategy::PreferNew => import_csv::CsvStrategy::PreferNew,
1656 MergeStrategy::PreferOld => import_csv::CsvStrategy::PreferOld,
1657 };
1658 let summary = import_csv::import_csv_into_graph(
1659 graph_file,
1660 import_csv::CsvImportArgs {
1661 nodes_path: args.nodes.as_deref(),
1662 edges_path: args.edges.as_deref(),
1663 notes_path: args.notes.as_deref(),
1664 strategy,
1665 },
1666 )?;
1667 if let Some(schema) = schema {
1668 let all_violations = validate_graph_with_schema(graph_file, schema);
1669 bail_on_schema_violations(&all_violations)?;
1670 }
1671 store.save_graph(path, graph_file)?;
1672 append_event_snapshot(path, "graph.import-csv", None, graph_file)?;
1673 let mut lines = vec![format!("+ imported csv into {graph}")];
1674 lines.extend(import_csv::merge_summary_lines(&summary));
1675 Ok(format!("{}\n", lines.join("\n")))
1676}
1677
1678pub(crate) fn import_graph_markdown(
1679 path: &Path,
1680 graph: &str,
1681 graph_file: &mut GraphFile,
1682 store: &dyn GraphStore,
1683 args: &ImportMarkdownArgs,
1684 schema: Option<&GraphSchema>,
1685) -> Result<String> {
1686 let strategy = match args.strategy {
1687 MergeStrategy::PreferNew => import_markdown::MarkdownStrategy::PreferNew,
1688 MergeStrategy::PreferOld => import_markdown::MarkdownStrategy::PreferOld,
1689 };
1690 let summary = import_markdown::import_markdown_into_graph(
1691 graph_file,
1692 import_markdown::MarkdownImportArgs {
1693 path: &args.path,
1694 notes_as_nodes: args.notes_as_nodes,
1695 strategy,
1696 },
1697 )?;
1698 if let Some(schema) = schema {
1699 let all_violations = validate_graph_with_schema(graph_file, schema);
1700 bail_on_schema_violations(&all_violations)?;
1701 }
1702 store.save_graph(path, graph_file)?;
1703 append_event_snapshot(path, "graph.import-md", Some(args.path.clone()), graph_file)?;
1704 let mut lines = vec![format!("+ imported markdown into {graph}")];
1705 lines.extend(import_csv::merge_summary_lines(&summary));
1706 Ok(format!("{}\n", lines.join("\n")))
1707}
1708
1709pub(crate) fn export_graph_dot(
1710 graph: &str,
1711 graph_file: &GraphFile,
1712 args: &ExportDotArgs,
1713) -> Result<String> {
1714 let output_path = args
1715 .output
1716 .clone()
1717 .unwrap_or_else(|| format!("{graph}.dot"));
1718 let (nodes, edges) = select_subgraph(
1719 graph_file,
1720 args.focus.as_deref(),
1721 args.depth,
1722 &args.node_types,
1723 )?;
1724 let mut lines = Vec::new();
1725 lines.push("digraph kg {".to_owned());
1726 for node in &nodes {
1727 let label = format!("{}\\n{}", node.id, node.name);
1728 lines.push(format!(
1729 " \"{}\" [label=\"{}\"];",
1730 escape_dot(&node.id),
1731 escape_dot(&label)
1732 ));
1733 }
1734 for edge in &edges {
1735 lines.push(format!(
1736 " \"{}\" -> \"{}\" [label=\"{}\"];",
1737 escape_dot(&edge.source_id),
1738 escape_dot(&edge.target_id),
1739 escape_dot(&edge.relation)
1740 ));
1741 }
1742 lines.push("}".to_owned());
1743 std::fs::write(&output_path, format!("{}\n", lines.join("\n")))?;
1744 Ok(format!("+ exported {output_path}\n"))
1745}
1746
1747pub(crate) fn export_graph_mermaid(
1748 graph: &str,
1749 graph_file: &GraphFile,
1750 args: &ExportMermaidArgs,
1751) -> Result<String> {
1752 let output_path = args
1753 .output
1754 .clone()
1755 .unwrap_or_else(|| format!("{graph}.mmd"));
1756 let (nodes, edges) = select_subgraph(
1757 graph_file,
1758 args.focus.as_deref(),
1759 args.depth,
1760 &args.node_types,
1761 )?;
1762 let mut lines = Vec::new();
1763 lines.push("graph TD".to_owned());
1764 for node in &nodes {
1765 let label = format!("{}\\n{}", node.id, node.name);
1766 lines.push(format!(
1767 " {}[\"{}\"]",
1768 sanitize_mermaid_id(&node.id),
1769 escape_mermaid(&label)
1770 ));
1771 }
1772 for edge in &edges {
1773 lines.push(format!(
1774 " {} -- \"{}\" --> {}",
1775 sanitize_mermaid_id(&edge.source_id),
1776 escape_mermaid(&edge.relation),
1777 sanitize_mermaid_id(&edge.target_id)
1778 ));
1779 }
1780 std::fs::write(&output_path, format!("{}\n", lines.join("\n")))?;
1781 Ok(format!("+ exported {output_path}\n"))
1782}
1783
1784pub(crate) fn export_graph_graphml(
1785 graph: &str,
1786 graph_file: &GraphFile,
1787 args: &ExportGraphmlArgs,
1788) -> Result<String> {
1789 let output_path = args
1790 .output
1791 .clone()
1792 .unwrap_or_else(|| format!("{graph}.graphml"));
1793 let (nodes, edges) = select_subgraph(
1794 graph_file,
1795 args.focus.as_deref(),
1796 args.depth,
1797 &args.node_types,
1798 )?;
1799
1800 let mut lines = Vec::new();
1801 lines.push(r#"<?xml version="1.0" encoding="UTF-8"?>"#.to_string());
1802 lines.push(r#"<graphml xmlns="http://graphml.graphdrawing.org/xmlns" "#.to_string());
1803 lines.push(r#" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance""#.to_string());
1804 lines.push(r#" xsi:schemaLocation="http://graphml.graphdrawing.org/xmlns"#.to_string());
1805 lines.push(r#" http://graphml.graphdrawing.org/xmlns/1.0/graphml.xsd">"#.to_string());
1806 lines.push(r#" <key id="d0" for="node" attr.name="name" attr.type="string"/>"#.to_string());
1807 lines.push(r#" <key id="d1" for="node" attr.name="type" attr.type="string"/>"#.to_string());
1808 lines.push(
1809 r#" <key id="d2" for="node" attr.name="description" attr.type="string"/>"#.to_string(),
1810 );
1811 lines
1812 .push(r#" <key id="d3" for="edge" attr.name="relation" attr.type="string"/>"#.to_string());
1813 lines.push(r#" <key id="d4" for="edge" attr.name="detail" attr.type="string"/>"#.to_string());
1814 lines.push(format!(
1815 r#" <graph id="{}" edgedefault="directed">"#,
1816 escape_xml(graph)
1817 ));
1818
1819 for node in &nodes {
1820 lines.push(format!(r#" <node id="{}">"#, escape_xml(&node.id)));
1821 lines.push(format!(
1822 r#" <data key="d0">{}</data>"#,
1823 escape_xml(&node.name)
1824 ));
1825 lines.push(format!(
1826 r#" <data key="d1">{}</data>"#,
1827 escape_xml(&node.r#type)
1828 ));
1829 lines.push(format!(
1830 r#" <data key="d2">{}</data>"#,
1831 escape_xml(&node.properties.description)
1832 ));
1833 lines.push(" </node>".to_string());
1834 }
1835
1836 for edge in &edges {
1837 lines.push(format!(
1838 r#" <edge source="{}" target="{}">"#,
1839 escape_xml(&edge.source_id),
1840 escape_xml(&edge.target_id)
1841 ));
1842 lines.push(format!(
1843 r#" <data key="d3">{}</data>"#,
1844 escape_xml(&edge.relation)
1845 ));
1846 lines.push(format!(
1847 r#" <data key="d4">{}</data>"#,
1848 escape_xml(&edge.properties.detail)
1849 ));
1850 lines.push(" </edge>".to_string());
1851 }
1852
1853 lines.push(" </graph>".to_string());
1854 lines.push("</graphml>".to_string());
1855
1856 std::fs::write(&output_path, lines.join("\n"))?;
1857 Ok(format!("+ exported {output_path}\n"))
1858}
1859
1860fn escape_xml(s: &str) -> String {
1861 s.replace('&', "&")
1862 .replace('<', "<")
1863 .replace('>', ">")
1864 .replace('"', """)
1865 .replace('\'', "'")
1866}
1867
1868pub(crate) fn export_graph_md(
1869 graph: &str,
1870 graph_file: &GraphFile,
1871 args: &ExportMdArgs,
1872 _cwd: &Path,
1873) -> Result<String> {
1874 let output_dir = args
1875 .output
1876 .clone()
1877 .unwrap_or_else(|| format!("{}-md", graph));
1878
1879 let (nodes, edges) = select_subgraph(
1880 graph_file,
1881 args.focus.as_deref(),
1882 args.depth,
1883 &args.node_types,
1884 )?;
1885
1886 std::fs::create_dir_all(&output_dir)?;
1887
1888 let mut index_lines = format!("# {}\n\nNodes: {}\n\n## Index\n", graph, nodes.len());
1889
1890 for node in &nodes {
1891 let safe_name = sanitize_filename(&node.id);
1892 let filename = format!("{}.md", safe_name);
1893 let filepath = Path::new(&output_dir).join(&filename);
1894
1895 let mut content = String::new();
1896 content.push_str(&format!("# {}\n\n", node.name));
1897 content.push_str(&format!("**ID:** `{}`\n\n", node.id));
1898 content.push_str(&format!("**Type:** {}\n\n", node.r#type));
1899
1900 if !node.properties.description.is_empty() {
1901 content.push_str(&format!(
1902 "## Description\n\n{}\n\n",
1903 node.properties.description
1904 ));
1905 }
1906
1907 if !node.properties.key_facts.is_empty() {
1908 content.push_str("## Facts\n\n");
1909 for fact in &node.properties.key_facts {
1910 content.push_str(&format!("- {}\n", fact));
1911 }
1912 content.push('\n');
1913 }
1914
1915 if !node.properties.alias.is_empty() {
1916 content.push_str(&format!(
1917 "**Aliases:** {}\n\n",
1918 node.properties.alias.join(", ")
1919 ));
1920 }
1921
1922 content.push_str("## Relations\n\n");
1923 for edge in &edges {
1924 if edge.source_id == node.id {
1925 content.push_str(&format!(
1926 "- [[{}]] --({})--> [[{}]]\n",
1927 node.id, edge.relation, edge.target_id
1928 ));
1929 } else if edge.target_id == node.id {
1930 content.push_str(&format!(
1931 "- [[{}]] <--({})-- [[{}]]\n",
1932 edge.source_id, edge.relation, node.id
1933 ));
1934 }
1935 }
1936 content.push('\n');
1937
1938 content.push_str("## Backlinks\n\n");
1939 let backlinks: Vec<_> = edges.iter().filter(|e| e.target_id == node.id).collect();
1940 if backlinks.is_empty() {
1941 content.push_str("_No backlinks_\n");
1942 } else {
1943 for edge in backlinks {
1944 content.push_str(&format!("- [[{}]] ({})\n", edge.source_id, edge.relation));
1945 }
1946 }
1947
1948 std::fs::write(&filepath, content)?;
1949
1950 index_lines.push_str(&format!(
1951 "- [[{}]] - {} [{}]\n",
1952 node.id, node.name, node.r#type
1953 ));
1954 }
1955
1956 std::fs::write(Path::new(&output_dir).join("index.md"), index_lines)?;
1957
1958 Ok(format!(
1959 "+ exported {}/ ({} nodes)\n",
1960 output_dir,
1961 nodes.len()
1962 ))
1963}
1964
1965fn sanitize_filename(name: &str) -> String {
1966 name.replace([':', '/', '\\', ' '], "_").replace('&', "and")
1967}
1968
1969pub(crate) fn split_graph(graph: &str, graph_file: &GraphFile, args: &SplitArgs) -> Result<String> {
1970 let output_dir = args
1971 .output
1972 .clone()
1973 .unwrap_or_else(|| format!("{}-split", graph));
1974
1975 let nodes_dir = Path::new(&output_dir).join("nodes");
1976 let edges_dir = Path::new(&output_dir).join("edges");
1977 let notes_dir = Path::new(&output_dir).join("notes");
1978 let meta_dir = Path::new(&output_dir).join("metadata");
1979
1980 std::fs::create_dir_all(&nodes_dir)?;
1981 std::fs::create_dir_all(&edges_dir)?;
1982 std::fs::create_dir_all(¬es_dir)?;
1983 std::fs::create_dir_all(&meta_dir)?;
1984
1985 let meta_json = serde_json::to_string_pretty(&graph_file.metadata)?;
1986 std::fs::write(meta_dir.join("metadata.json"), meta_json)?;
1987
1988 let mut node_count = 0;
1989 for node in &graph_file.nodes {
1990 let safe_id = sanitize_filename(&node.id);
1991 let filepath = nodes_dir.join(format!("{}.json", safe_id));
1992 let node_json = serde_json::to_string_pretty(node)?;
1993 std::fs::write(filepath, node_json)?;
1994 node_count += 1;
1995 }
1996
1997 let mut edge_count = 0;
1998 for edge in &graph_file.edges {
1999 let edge_key = format!(
2000 "{}___{}___{}",
2001 sanitize_filename(&edge.source_id),
2002 sanitize_filename(&edge.relation),
2003 sanitize_filename(&edge.target_id)
2004 );
2005 let filepath = edges_dir.join(format!("{}.json", edge_key));
2006 let edge_json = serde_json::to_string_pretty(edge)?;
2007 std::fs::write(filepath, edge_json)?;
2008 edge_count += 1;
2009 }
2010
2011 let mut note_count = 0;
2012 for note in &graph_file.notes {
2013 let safe_id = sanitize_filename(¬e.id);
2014 let filepath = notes_dir.join(format!("{}.json", safe_id));
2015 let note_json = serde_json::to_string_pretty(note)?;
2016 std::fs::write(filepath, note_json)?;
2017 note_count += 1;
2018 }
2019
2020 let manifest = format!(
2021 r#"# {} Split Manifest
2022
2023This directory contains a git-friendly split representation of the graph.
2024
2025## Structure
2026
2027- `metadata/metadata.json` - Graph metadata
2028- `nodes/` - One JSON file per node (filename = sanitized node id)
2029- `edges/` - One JSON file per edge (filename = source___relation___target)
2030- `notes/` - One JSON file per note
2031
2032## Stats
2033
2034- Nodes: {}
2035- Edges: {}
2036- Notes: {}
2037
2038## Usage
2039
2040To reassemble into a single JSON file, use `kg {} import-json`.
2041"#,
2042 graph, node_count, edge_count, note_count, graph
2043 );
2044 std::fs::write(Path::new(&output_dir).join("MANIFEST.md"), manifest)?;
2045
2046 Ok(format!(
2047 "+ split {} into {}/ (nodes: {}, edges: {}, notes: {})\n",
2048 graph, output_dir, node_count, edge_count, note_count
2049 ))
2050}
2051
2052fn select_subgraph<'a>(
2053 graph_file: &'a GraphFile,
2054 focus: Option<&'a str>,
2055 depth: usize,
2056 node_types: &'a [String],
2057) -> Result<(Vec<&'a Node>, Vec<&'a Edge>)> {
2058 use std::collections::{HashSet, VecDeque};
2059
2060 let mut selected: HashSet<String> = HashSet::new();
2061 if let Some(focus_id) = focus {
2062 if graph_file.node_by_id(focus_id).is_none() {
2063 bail!("focus node not found: {focus_id}");
2064 }
2065 selected.insert(focus_id.to_owned());
2066 let mut frontier = VecDeque::new();
2067 frontier.push_back((focus_id.to_owned(), 0usize));
2068 while let Some((current, dist)) = frontier.pop_front() {
2069 if dist >= depth {
2070 continue;
2071 }
2072 for edge in &graph_file.edges {
2073 let next = if edge.source_id == current {
2074 Some(edge.target_id.clone())
2075 } else if edge.target_id == current {
2076 Some(edge.source_id.clone())
2077 } else {
2078 None
2079 };
2080 if let Some(next_id) = next {
2081 if selected.insert(next_id.clone()) {
2082 frontier.push_back((next_id, dist + 1));
2083 }
2084 }
2085 }
2086 }
2087 } else {
2088 for node in &graph_file.nodes {
2089 selected.insert(node.id.clone());
2090 }
2091 }
2092
2093 let type_filter: Vec<String> = node_types.iter().map(|t| t.to_lowercase()).collect();
2094 let has_filter = !type_filter.is_empty();
2095 let mut nodes: Vec<&Node> = graph_file
2096 .nodes
2097 .iter()
2098 .filter(|node| selected.contains(&node.id))
2099 .filter(|node| {
2100 if let Some(focus_id) = focus {
2101 if node.id == focus_id {
2102 return true;
2103 }
2104 }
2105 !has_filter || type_filter.contains(&node.r#type.to_lowercase())
2106 })
2107 .collect();
2108 nodes.sort_by(|a, b| a.id.cmp(&b.id));
2109
2110 let node_set: HashSet<String> = nodes.iter().map(|node| node.id.clone()).collect();
2111 let mut edges: Vec<&Edge> = graph_file
2112 .edges
2113 .iter()
2114 .filter(|edge| node_set.contains(&edge.source_id) && node_set.contains(&edge.target_id))
2115 .collect();
2116 edges.sort_by(|a, b| {
2117 a.source_id
2118 .cmp(&b.source_id)
2119 .then_with(|| a.relation.cmp(&b.relation))
2120 .then_with(|| a.target_id.cmp(&b.target_id))
2121 });
2122
2123 Ok((nodes, edges))
2124}
2125
2126fn escape_dot(value: &str) -> String {
2127 value.replace('"', "\\\"").replace('\n', "\\n")
2128}
2129
2130fn escape_mermaid(value: &str) -> String {
2131 value.replace('"', "\\\"").replace('\n', "\\n")
2132}
2133
2134fn sanitize_mermaid_id(value: &str) -> String {
2135 let mut out = String::new();
2136 for ch in value.chars() {
2137 if ch.is_ascii_alphanumeric() || ch == '_' {
2138 out.push(ch);
2139 } else {
2140 out.push('_');
2141 }
2142 }
2143 if out.is_empty() {
2144 "node".to_owned()
2145 } else {
2146 out
2147 }
2148}
2149
2150pub(crate) fn render_graph_history(path: &Path, graph: &str, args: &HistoryArgs) -> Result<String> {
2151 let backups = list_graph_backups(path)?;
2152 let total = backups.len();
2153 let snapshots: Vec<(u64, PathBuf)> = backups.into_iter().rev().take(args.limit).collect();
2154
2155 if args.json {
2156 let payload = GraphHistoryResponse {
2157 graph: graph.to_owned(),
2158 total,
2159 snapshots: snapshots
2160 .iter()
2161 .map(|(ts, backup_path)| GraphHistorySnapshot {
2162 ts: *ts,
2163 path: backup_path.display().to_string(),
2164 })
2165 .collect(),
2166 };
2167 let rendered =
2168 serde_json::to_string_pretty(&payload).context("failed to render history as JSON")?;
2169 return Ok(format!("{rendered}\n"));
2170 }
2171
2172 let mut lines = vec![format!("= history {graph} ({total})")];
2173 for (ts, backup_path) in snapshots {
2174 lines.push(format!("- {ts} | {}", backup_path.display()));
2175 }
2176 Ok(format!("{}\n", lines.join("\n")))
2177}
2178
2179pub(crate) fn render_graph_timeline(
2180 path: &Path,
2181 graph: &str,
2182 args: &TimelineArgs,
2183) -> Result<String> {
2184 let entries = event_log::read_log(path)?;
2185 let total = entries.len();
2186 let filtered: Vec<&event_log::EventLogEntry> = entries
2187 .iter()
2188 .filter(|entry| {
2189 let after_since = args
2190 .since_ts_ms
2191 .map(|since| entry.ts_ms >= since)
2192 .unwrap_or(true);
2193 let before_until = args
2194 .until_ts_ms
2195 .map(|until| entry.ts_ms <= until)
2196 .unwrap_or(true);
2197 after_since && before_until
2198 })
2199 .collect();
2200 let recent: Vec<&event_log::EventLogEntry> =
2201 filtered.into_iter().rev().take(args.limit).collect();
2202
2203 if args.json {
2204 let payload = GraphTimelineResponse {
2205 graph: graph.to_owned(),
2206 total,
2207 filtered: recent.len(),
2208 since_ts_ms: args.since_ts_ms,
2209 until_ts_ms: args.until_ts_ms,
2210 entries: recent
2211 .iter()
2212 .map(|entry| GraphTimelineEntry {
2213 ts_ms: entry.ts_ms,
2214 action: entry.action.clone(),
2215 detail: entry.detail.clone(),
2216 node_count: entry.graph.nodes.len(),
2217 edge_count: entry.graph.edges.len(),
2218 note_count: entry.graph.notes.len(),
2219 })
2220 .collect(),
2221 };
2222 let rendered =
2223 serde_json::to_string_pretty(&payload).context("failed to render timeline as JSON")?;
2224 return Ok(format!("{rendered}\n"));
2225 }
2226
2227 let mut lines = vec![format!("= timeline {graph} ({total})")];
2228 if args.since_ts_ms.is_some() || args.until_ts_ms.is_some() {
2229 lines.push(format!(
2230 "range: {} -> {}",
2231 args.since_ts_ms
2232 .map(|value| value.to_string())
2233 .unwrap_or_else(|| "-inf".to_owned()),
2234 args.until_ts_ms
2235 .map(|value| value.to_string())
2236 .unwrap_or_else(|| "+inf".to_owned())
2237 ));
2238 lines.push(format!("showing: {}", recent.len()));
2239 }
2240 for entry in recent {
2241 let detail = entry
2242 .detail
2243 .as_deref()
2244 .map(|value| format!(" | {value}"))
2245 .unwrap_or_default();
2246 lines.push(format!(
2247 "- {} | {}{} | nodes: {} | edges: {} | notes: {}",
2248 entry.ts_ms,
2249 entry.action,
2250 detail,
2251 entry.graph.nodes.len(),
2252 entry.graph.edges.len(),
2253 entry.graph.notes.len()
2254 ));
2255 }
2256 Ok(format!("{}\n", lines.join("\n")))
2257}
2258
2259#[derive(Debug, Serialize)]
2260struct GraphHistorySnapshot {
2261 ts: u64,
2262 path: String,
2263}
2264
2265#[derive(Debug, Serialize)]
2266struct GraphHistoryResponse {
2267 graph: String,
2268 total: usize,
2269 snapshots: Vec<GraphHistorySnapshot>,
2270}
2271
2272#[derive(Debug, Serialize)]
2273struct GraphTimelineEntry {
2274 ts_ms: u64,
2275 action: String,
2276 detail: Option<String>,
2277 node_count: usize,
2278 edge_count: usize,
2279 note_count: usize,
2280}
2281
2282#[derive(Debug, Serialize)]
2283struct GraphTimelineResponse {
2284 graph: String,
2285 total: usize,
2286 filtered: usize,
2287 since_ts_ms: Option<u64>,
2288 until_ts_ms: Option<u64>,
2289 entries: Vec<GraphTimelineEntry>,
2290}
2291
2292pub(crate) fn render_graph_diff_as_of(
2293 path: &Path,
2294 graph: &str,
2295 args: &DiffAsOfArgs,
2296) -> Result<String> {
2297 match resolve_temporal_source(path, args.source)? {
2298 TemporalSource::EventLog => render_graph_diff_as_of_event_log(path, graph, args),
2299 _ => render_graph_diff_as_of_backups(path, graph, args),
2300 }
2301}
2302
2303pub(crate) fn render_graph_diff_as_of_json(
2304 path: &Path,
2305 graph: &str,
2306 args: &DiffAsOfArgs,
2307) -> Result<String> {
2308 match resolve_temporal_source(path, args.source)? {
2309 TemporalSource::EventLog => render_graph_diff_as_of_event_log_json(path, graph, args),
2310 _ => render_graph_diff_as_of_backups_json(path, graph, args),
2311 }
2312}
2313
2314fn render_graph_diff_as_of_backups(
2315 path: &Path,
2316 graph: &str,
2317 args: &DiffAsOfArgs,
2318) -> Result<String> {
2319 let backups = list_graph_backups(path)?;
2320 if backups.is_empty() {
2321 bail!("no backups found for graph: {graph}");
2322 }
2323 let from_ts = args.from_ts_ms / 1000;
2324 let to_ts = args.to_ts_ms / 1000;
2325 let from_backup = select_backup_at_or_before(&backups, from_ts)
2326 .ok_or_else(|| anyhow!("no backup at or before from_ts_ms={}", args.from_ts_ms))?;
2327 let to_backup = select_backup_at_or_before(&backups, to_ts)
2328 .ok_or_else(|| anyhow!("no backup at or before to_ts_ms={}", args.to_ts_ms))?;
2329
2330 let from_graph = load_graph_from_backup(&from_backup.1)?;
2331 let to_graph = load_graph_from_backup(&to_backup.1)?;
2332 let left_label = format!("{graph}@{}", args.from_ts_ms);
2333 let right_label = format!("{graph}@{}", args.to_ts_ms);
2334 Ok(render_graph_diff_from_files(
2335 &left_label,
2336 &right_label,
2337 &from_graph,
2338 &to_graph,
2339 ))
2340}
2341
2342fn render_graph_diff_as_of_backups_json(
2343 path: &Path,
2344 graph: &str,
2345 args: &DiffAsOfArgs,
2346) -> Result<String> {
2347 let backups = list_graph_backups(path)?;
2348 if backups.is_empty() {
2349 bail!("no backups found for graph: {graph}");
2350 }
2351 let from_ts = args.from_ts_ms / 1000;
2352 let to_ts = args.to_ts_ms / 1000;
2353 let from_backup = select_backup_at_or_before(&backups, from_ts)
2354 .ok_or_else(|| anyhow!("no backup at or before from_ts_ms={}", args.from_ts_ms))?;
2355 let to_backup = select_backup_at_or_before(&backups, to_ts)
2356 .ok_or_else(|| anyhow!("no backup at or before to_ts_ms={}", args.to_ts_ms))?;
2357
2358 let from_graph = load_graph_from_backup(&from_backup.1)?;
2359 let to_graph = load_graph_from_backup(&to_backup.1)?;
2360 let left_label = format!("{graph}@{}", args.from_ts_ms);
2361 let right_label = format!("{graph}@{}", args.to_ts_ms);
2362 Ok(render_graph_diff_json_from_files(
2363 &left_label,
2364 &right_label,
2365 &from_graph,
2366 &to_graph,
2367 ))
2368}
2369
2370fn render_graph_diff_as_of_event_log(
2371 path: &Path,
2372 graph: &str,
2373 args: &DiffAsOfArgs,
2374) -> Result<String> {
2375 let entries = event_log::read_log(path)?;
2376 if entries.is_empty() {
2377 bail!("no event log entries found for graph: {graph}");
2378 }
2379 let from_entry = select_event_at_or_before(&entries, args.from_ts_ms).ok_or_else(|| {
2380 anyhow!(
2381 "no event log entry at or before from_ts_ms={}",
2382 args.from_ts_ms
2383 )
2384 })?;
2385 let to_entry = select_event_at_or_before(&entries, args.to_ts_ms)
2386 .ok_or_else(|| anyhow!("no event log entry at or before to_ts_ms={}", args.to_ts_ms))?;
2387
2388 let left_label = format!("{graph}@{}", args.from_ts_ms);
2389 let right_label = format!("{graph}@{}", args.to_ts_ms);
2390 Ok(render_graph_diff_from_files(
2391 &left_label,
2392 &right_label,
2393 &from_entry.graph,
2394 &to_entry.graph,
2395 ))
2396}
2397
2398fn render_graph_diff_as_of_event_log_json(
2399 path: &Path,
2400 graph: &str,
2401 args: &DiffAsOfArgs,
2402) -> Result<String> {
2403 let entries = event_log::read_log(path)?;
2404 if entries.is_empty() {
2405 bail!("no event log entries found for graph: {graph}");
2406 }
2407 let from_entry = select_event_at_or_before(&entries, args.from_ts_ms).ok_or_else(|| {
2408 anyhow!(
2409 "no event log entry at or before from_ts_ms={}",
2410 args.from_ts_ms
2411 )
2412 })?;
2413 let to_entry = select_event_at_or_before(&entries, args.to_ts_ms)
2414 .ok_or_else(|| anyhow!("no event log entry at or before to_ts_ms={}", args.to_ts_ms))?;
2415
2416 let left_label = format!("{graph}@{}", args.from_ts_ms);
2417 let right_label = format!("{graph}@{}", args.to_ts_ms);
2418 Ok(render_graph_diff_json_from_files(
2419 &left_label,
2420 &right_label,
2421 &from_entry.graph,
2422 &to_entry.graph,
2423 ))
2424}
2425
2426fn resolve_temporal_source(path: &Path, source: TemporalSource) -> Result<TemporalSource> {
2427 if matches!(source, TemporalSource::Auto) {
2428 let has_events = event_log::has_log(path);
2429 return Ok(if has_events {
2430 TemporalSource::EventLog
2431 } else {
2432 TemporalSource::Backups
2433 });
2434 }
2435 Ok(source)
2436}
2437
2438fn select_event_at_or_before(
2439 entries: &[event_log::EventLogEntry],
2440 target_ts_ms: u64,
2441) -> Option<&event_log::EventLogEntry> {
2442 let mut selected = None;
2443 for entry in entries {
2444 if entry.ts_ms <= target_ts_ms {
2445 selected = Some(entry);
2446 }
2447 }
2448 selected
2449}
2450
2451fn select_backup_at_or_before(
2452 backups: &[(u64, PathBuf)],
2453 target_ts: u64,
2454) -> Option<(u64, PathBuf)> {
2455 let mut selected = None;
2456 for (ts, path) in backups {
2457 if *ts <= target_ts {
2458 selected = Some((*ts, path.clone()));
2459 }
2460 }
2461 selected
2462}
2463
2464fn load_graph_from_backup(path: &Path) -> Result<GraphFile> {
2465 let raw = read_gz_to_string(path)?;
2466 let graph: GraphFile = serde_json::from_str(&raw)
2467 .with_context(|| format!("failed to parse backup: {}", path.display()))?;
2468 Ok(graph)
2469}
2470
2471pub(crate) fn render_note_list(graph: &GraphFile, args: &NoteListArgs) -> String {
2472 let mut notes: Vec<&Note> = graph
2473 .notes
2474 .iter()
2475 .filter(|note| args.node.as_ref().is_none_or(|node| note.node_id == *node))
2476 .collect();
2477
2478 notes.sort_by(|a, b| {
2479 a.created_at
2480 .cmp(&b.created_at)
2481 .then_with(|| a.id.cmp(&b.id))
2482 });
2483
2484 let total = notes.len();
2485 let visible: Vec<&Note> = notes.into_iter().take(args.limit).collect();
2486
2487 let mut lines = vec![format!("= notes ({total})")];
2488 for note in &visible {
2489 let mut line = format!(
2490 "- {} | {} | {} | {}",
2491 note.id,
2492 note.node_id,
2493 note.created_at,
2494 truncate_note(&escape_cli_text(¬e.body), 80)
2495 );
2496 if !note.tags.is_empty() {
2497 line.push_str(" | tags: ");
2498 line.push_str(
2499 ¬e
2500 .tags
2501 .iter()
2502 .map(|tag| escape_cli_text(tag))
2503 .collect::<Vec<_>>()
2504 .join(", "),
2505 );
2506 }
2507 if !note.author.is_empty() {
2508 line.push_str(" | by: ");
2509 line.push_str(&escape_cli_text(¬e.author));
2510 }
2511 lines.push(line);
2512 }
2513 let omitted = total.saturating_sub(visible.len());
2514 if omitted > 0 {
2515 lines.push(format!("... {omitted} more notes omitted"));
2516 }
2517
2518 format!("{}\n", lines.join("\n"))
2519}
2520
2521pub(crate) fn build_note(graph: &GraphFile, args: NoteAddArgs) -> Result<Note> {
2522 if graph.node_by_id(&args.node_id).is_none() {
2523 bail!("node not found: {}", args.node_id);
2524 }
2525 let ts = now_ms();
2526 let id = args.id.unwrap_or_else(|| format!("note:{ts}"));
2527 let created_at = args.created_at.unwrap_or_else(|| ts.to_string());
2528 Ok(Note {
2529 id,
2530 node_id: args.node_id,
2531 body: args.text,
2532 tags: args.tag,
2533 author: args.author.unwrap_or_default(),
2534 created_at,
2535 provenance: args.provenance.unwrap_or_default(),
2536 source_files: args.source,
2537 })
2538}
2539
2540fn truncate_note(value: &str, max_len: usize) -> String {
2541 let char_count = value.chars().count();
2542 if char_count <= max_len {
2543 return value.to_owned();
2544 }
2545 let truncated: String = value.chars().take(max_len.saturating_sub(3)).collect();
2546 format!("{truncated}...")
2547}
2548
2549fn escape_cli_text(value: &str) -> String {
2550 let mut out = String::new();
2551 for ch in value.chars() {
2552 match ch {
2553 '\\' => out.push_str("\\\\"),
2554 '\n' => out.push_str("\\n"),
2555 '\r' => out.push_str("\\r"),
2556 '\t' => out.push_str("\\t"),
2557 _ => out.push(ch),
2558 }
2559 }
2560 out
2561}
2562
2563fn now_ms() -> u128 {
2564 use std::time::{SystemTime, UNIX_EPOCH};
2565
2566 SystemTime::now()
2567 .duration_since(UNIX_EPOCH)
2568 .unwrap_or_default()
2569 .as_millis()
2570}
2571
2572pub(crate) fn map_find_mode(mode: CliFindMode) -> output::FindMode {
2573 match mode {
2574 CliFindMode::Fuzzy => output::FindMode::Fuzzy,
2575 CliFindMode::Bm25 => output::FindMode::Bm25,
2576 CliFindMode::Hybrid => output::FindMode::Hybrid,
2577 CliFindMode::Vector => output::FindMode::Fuzzy,
2578 }
2579}
2580
2581pub(crate) fn render_feedback_log(cwd: &Path, args: &FeedbackLogArgs) -> Result<String> {
2582 let path = first_existing_feedback_log_path(cwd);
2583 if !path.exists() {
2584 return Ok(String::from("= feedback-log\nempty: no entries yet\n"));
2585 }
2586
2587 let content = std::fs::read_to_string(&path)?;
2588 let mut entries: Vec<FeedbackLogEntry> = Vec::new();
2589 for line in content.lines() {
2590 if let Some(entry) = FeedbackLogEntry::parse(line) {
2591 if let Some(ref uid) = args.uid {
2592 if &entry.uid != uid {
2593 continue;
2594 }
2595 }
2596 if let Some(ref graph) = args.graph {
2597 if &entry.graph != graph {
2598 continue;
2599 }
2600 }
2601 entries.push(entry);
2602 }
2603 }
2604
2605 entries.reverse();
2606 let shown: Vec<&FeedbackLogEntry> = entries.iter().take(args.limit).collect();
2607
2608 let mut output = vec![String::from("= feedback-log")];
2609 output.push(format!("total_entries: {}", entries.len()));
2610 output.push(format!("showing: {}", shown.len()));
2611 output.push(String::from("recent_entries:"));
2612 for e in shown {
2613 let pick = e.pick.as_deref().unwrap_or("-");
2614 let selected = e.selected.as_deref().unwrap_or("-");
2615 let graph = if e.graph.is_empty() { "-" } else { &e.graph };
2616 let queries = if e.queries.is_empty() {
2617 "-"
2618 } else {
2619 &e.queries
2620 };
2621 output.push(format!(
2622 "- {} | {} | {} | pick={} | selected={} | graph={} | {}",
2623 e.ts_ms, e.uid, e.action, pick, selected, graph, queries
2624 ));
2625 }
2626
2627 Ok(format!("{}\n", output.join("\n")))
2628}
2629
2630pub(crate) fn handle_vector_command(
2631 path: &Path,
2632 _graph: &str,
2633 graph_file: &GraphFile,
2634 command: &VectorCommand,
2635 _cwd: &Path,
2636) -> Result<String> {
2637 match command {
2638 VectorCommand::Import(args) => {
2639 let vector_path = path
2640 .parent()
2641 .map(|p| p.join(".kg.vectors.json"))
2642 .unwrap_or_else(|| PathBuf::from(".kg.vectors.json"));
2643 let store =
2644 vectors::VectorStore::import_jsonl(std::path::Path::new(&args.input), graph_file)?;
2645 store.save(&vector_path)?;
2646 Ok(format!(
2647 "+ imported {} vectors (dim={}) to {}\n",
2648 store.vectors.len(),
2649 store.dimension,
2650 vector_path.display()
2651 ))
2652 }
2653 VectorCommand::Stats(_args) => {
2654 let vector_path = path
2655 .parent()
2656 .map(|p| p.join(".kg.vectors.json"))
2657 .unwrap_or_else(|| PathBuf::from(".kg.vectors.json"));
2658 if !vector_path.exists() {
2659 return Ok(String::from("= vectors\nnot initialized\n"));
2660 }
2661 let store = vectors::VectorStore::load(&vector_path)?;
2662 let node_ids: Vec<_> = store.vectors.keys().cloned().collect();
2663 let in_graph = node_ids
2664 .iter()
2665 .filter(|id| graph_file.node_by_id(id).is_some())
2666 .count();
2667 Ok(format!(
2668 "= vectors\ndimension: {}\ntotal: {}\nin_graph: {}\n",
2669 store.dimension,
2670 store.vectors.len(),
2671 in_graph
2672 ))
2673 }
2674 }
2675}
2676
2677fn render_feedback_summary(cwd: &Path, args: &FeedbackSummaryArgs) -> Result<String> {
2678 use std::collections::HashMap;
2679
2680 let path = first_existing_feedback_log_path(cwd);
2681 if !path.exists() {
2682 return Ok(String::from("= feedback-summary\nNo feedback yet.\n"));
2683 }
2684
2685 let content = std::fs::read_to_string(&path)?;
2686 let mut entries: Vec<FeedbackLogEntry> = Vec::new();
2687 for line in content.lines() {
2688 if let Some(entry) = FeedbackLogEntry::parse(line) {
2689 if let Some(ref graph) = args.graph {
2690 if &entry.graph != graph {
2691 continue;
2692 }
2693 }
2694 entries.push(entry);
2695 }
2696 }
2697
2698 entries.reverse();
2699 let _shown = entries.iter().take(args.limit).collect::<Vec<_>>();
2700
2701 let mut lines = vec![String::from("= feedback-summary")];
2702 lines.push(format!("Total entries: {}", entries.len()));
2703
2704 let mut by_action: HashMap<&str, usize> = HashMap::new();
2705 let mut nil_queries: Vec<&str> = Vec::new();
2706 let mut yes_count = 0;
2707 let mut no_count = 0;
2708 let mut pick_map: HashMap<&str, usize> = HashMap::new();
2709 let mut query_counts: HashMap<&str, usize> = HashMap::new();
2710
2711 for e in &entries {
2712 *by_action.entry(&e.action).or_insert(0) += 1;
2713
2714 match e.action.as_str() {
2715 "NIL" => {
2716 if !e.queries.is_empty() {
2717 nil_queries.push(&e.queries);
2718 }
2719 }
2720 "YES" => yes_count += 1,
2721 "NO" => no_count += 1,
2722 "PICK" => {
2723 if let Some(ref sel) = e.selected {
2724 *pick_map.entry(sel).or_insert(0) += 1;
2725 }
2726 }
2727 _ => {}
2728 }
2729
2730 if !e.queries.is_empty() {
2731 *query_counts.entry(&e.queries).or_insert(0) += 1;
2732 }
2733 }
2734
2735 lines.push(String::from("\n### By response"));
2736 lines.push(format!(
2737 "YES: {} ({:.0}%)",
2738 yes_count,
2739 if !entries.is_empty() {
2740 (yes_count as f64 / entries.len() as f64) * 100.0
2741 } else {
2742 0.0
2743 }
2744 ));
2745 lines.push(format!("NO: {}", no_count));
2746 lines.push(format!("PICK: {}", by_action.get("PICK").unwrap_or(&0)));
2747 lines.push(format!("NIL: {} (no results)", nil_queries.len()));
2748
2749 if !nil_queries.is_empty() {
2750 lines.push(String::from("\n### Brakujące node'y (NIL queries)"));
2751 for q in nil_queries.iter().take(10) {
2752 lines.push(format!("- \"{}\"", q));
2753 }
2754 if nil_queries.len() > 10 {
2755 lines.push(format!(" ... i {} więcej", nil_queries.len() - 10));
2756 }
2757 }
2758
2759 if !pick_map.is_empty() {
2760 lines.push(String::from("\n### Najczęściej wybierane node'y (PICK)"));
2761 let mut sorted: Vec<_> = pick_map.iter().collect();
2762 sorted.sort_by(|a, b| b.1.cmp(a.1));
2763 for (node, count) in sorted.iter().take(10) {
2764 lines.push(format!("- {} ({}x)", node, count));
2765 }
2766 }
2767
2768 if !query_counts.is_empty() {
2769 lines.push(String::from("\n### Top wyszukiwane terminy"));
2770 let mut sorted: Vec<_> = query_counts.iter().collect();
2771 sorted.sort_by(|a, b| b.1.cmp(a.1));
2772 for (query, count) in sorted.iter().take(10) {
2773 lines.push(format!("- \"{}\" ({})", query, count));
2774 }
2775 }
2776
2777 if yes_count == 0 && no_count == 0 && nil_queries.is_empty() {
2778 lines.push(String::from(
2779 "\n(Wpływy za mało na wnioski - potrzeba więcej feedbacku)",
2780 ));
2781 } else if yes_count > no_count * 3 {
2782 lines.push(String::from(
2783 "\n✓ Feedback pozytywny - wyszukiwania działają dobrze.",
2784 ));
2785 } else if no_count > yes_count {
2786 lines.push(String::from(
2787 "\n⚠ Dużo NO - sprawdź jakość aliasów i dopasowań.",
2788 ));
2789 }
2790
2791 Ok(format!("{}\n", lines.join("\n")))
2792}
2793
2794pub fn feedback_log_path(cwd: &Path) -> PathBuf {
2795 cache_paths::cache_root_for_cwd(cwd).join("kg-mcp.feedback.log")
2796}
2797
2798fn legacy_feedback_log_path(cwd: &Path) -> PathBuf {
2799 cwd.join("kg-mcp.feedback.log")
2800}
2801
2802pub fn first_existing_feedback_log_path(cwd: &Path) -> PathBuf {
2803 let preferred = feedback_log_path(cwd);
2804 if preferred.exists() {
2805 return preferred;
2806 }
2807 let legacy = legacy_feedback_log_path(cwd);
2808 if legacy.exists() {
2809 return legacy;
2810 }
2811 preferred
2812}
2813
2814pub(crate) fn render_feedback_summary_for_graph(
2815 cwd: &Path,
2816 graph: &str,
2817 args: &FeedbackSummaryArgs,
2818) -> Result<String> {
2819 let mut args = args.clone();
2820 args.graph = Some(graph.to_string());
2821 render_feedback_summary(cwd, &args)
2822}
2823
2824#[derive(Debug, Serialize)]
2825struct BaselineFeedbackMetrics {
2826 entries: usize,
2827 yes: usize,
2828 no: usize,
2829 pick: usize,
2830 nil: usize,
2831 yes_rate: f64,
2832 no_rate: f64,
2833 nil_rate: f64,
2834}
2835
2836#[derive(Debug, Serialize)]
2837struct BaselineCostMetrics {
2838 find_operations: usize,
2839 feedback_events: usize,
2840 feedback_events_per_1000_find_ops: f64,
2841 token_cost_estimate: Option<f64>,
2842 token_cost_note: &'static str,
2843}
2844
2845#[derive(Debug, Serialize)]
2846struct GoldenSetMetrics {
2847 cases: usize,
2848 hits_any: usize,
2849 top1_hits: usize,
2850 hit_rate: f64,
2851 top1_rate: f64,
2852 mrr: f64,
2853 ndcg_at_k: f64,
2854}
2855
2856#[derive(Debug, Serialize)]
2857struct BaselineQualityScore {
2858 description_coverage: f64,
2859 facts_coverage: f64,
2860 duplicate_penalty: f64,
2861 edge_gap_penalty: f64,
2862 score_0_100: f64,
2863}
2864
2865#[derive(Debug, Serialize)]
2866struct BaselineReport {
2867 graph: String,
2868 quality: crate::analysis::QualitySnapshot,
2869 quality_score: BaselineQualityScore,
2870 feedback: BaselineFeedbackMetrics,
2871 cost: BaselineCostMetrics,
2872 golden: Option<GoldenSetMetrics>,
2873}
2874
2875#[derive(Debug, Deserialize)]
2876struct GoldenSetCase {
2877 query: String,
2878 expected: Vec<String>,
2879}
2880
2881fn parse_feedback_entries(cwd: &Path, graph_name: &str) -> Result<Vec<FeedbackLogEntry>> {
2882 let path = first_existing_feedback_log_path(cwd);
2883 if !path.exists() {
2884 return Ok(Vec::new());
2885 }
2886
2887 let content = std::fs::read_to_string(path)?;
2888 let mut entries = Vec::new();
2889 for line in content.lines() {
2890 if let Some(entry) = FeedbackLogEntry::parse(line) {
2891 if entry.graph == graph_name {
2892 entries.push(entry);
2893 }
2894 }
2895 }
2896 Ok(entries)
2897}
2898
2899fn parse_find_operations(graph_path: &Path) -> Result<usize> {
2900 let Some(path) = access_log::first_existing_access_log_path(graph_path) else {
2901 return Ok(0);
2902 };
2903
2904 let content = std::fs::read_to_string(path)?;
2905 let mut find_ops = 0usize;
2906 for line in content.lines() {
2907 let mut parts = line.split('\t');
2908 let _ts = parts.next();
2909 if let Some(op) = parts.next() {
2910 if op == "FIND" {
2911 find_ops += 1;
2912 }
2913 }
2914 }
2915 Ok(find_ops)
2916}
2917
2918fn compute_feedback_metrics(entries: &[FeedbackLogEntry]) -> BaselineFeedbackMetrics {
2919 let mut yes = 0usize;
2920 let mut no = 0usize;
2921 let mut pick = 0usize;
2922 let mut nil = 0usize;
2923 for entry in entries {
2924 match entry.action.as_str() {
2925 "YES" => yes += 1,
2926 "NO" => no += 1,
2927 "PICK" => pick += 1,
2928 "NIL" => nil += 1,
2929 _ => {}
2930 }
2931 }
2932 let total = entries.len() as f64;
2933 BaselineFeedbackMetrics {
2934 entries: entries.len(),
2935 yes,
2936 no,
2937 pick,
2938 nil,
2939 yes_rate: if total > 0.0 { yes as f64 / total } else { 0.0 },
2940 no_rate: if total > 0.0 { no as f64 / total } else { 0.0 },
2941 nil_rate: if total > 0.0 { nil as f64 / total } else { 0.0 },
2942 }
2943}
2944
2945fn compute_quality_score(snapshot: &crate::analysis::QualitySnapshot) -> BaselineQualityScore {
2946 let total_nodes = snapshot.total_nodes as f64;
2947 let description_coverage = if total_nodes > 0.0 {
2948 (snapshot
2949 .total_nodes
2950 .saturating_sub(snapshot.missing_descriptions)) as f64
2951 / total_nodes
2952 } else {
2953 1.0
2954 };
2955 let facts_coverage = if total_nodes > 0.0 {
2956 (snapshot.total_nodes.saturating_sub(snapshot.missing_facts)) as f64 / total_nodes
2957 } else {
2958 1.0
2959 };
2960
2961 let duplicate_penalty = if snapshot.total_nodes > 1 {
2962 let max_pairs = (snapshot.total_nodes * (snapshot.total_nodes - 1) / 2) as f64;
2963 (snapshot.duplicate_pairs as f64 / max_pairs).clamp(0.0, 1.0)
2964 } else {
2965 0.0
2966 };
2967
2968 let edge_candidates = snapshot.edge_gaps.total_candidates();
2969 let edge_gap_penalty = if edge_candidates > 0 {
2970 (snapshot.edge_gaps.total_missing() as f64 / edge_candidates as f64).clamp(0.0, 1.0)
2971 } else {
2972 0.0
2973 };
2974
2975 let score = 100.0
2976 * (0.35 * description_coverage
2977 + 0.35 * facts_coverage
2978 + 0.15 * (1.0 - duplicate_penalty)
2979 + 0.15 * (1.0 - edge_gap_penalty));
2980
2981 BaselineQualityScore {
2982 description_coverage,
2983 facts_coverage,
2984 duplicate_penalty,
2985 edge_gap_penalty,
2986 score_0_100: score,
2987 }
2988}
2989
2990fn eval_golden_set(graph: &GraphFile, args: &BaselineArgs) -> Result<Option<GoldenSetMetrics>> {
2991 if matches!(args.mode, CliFindMode::Vector) {
2992 anyhow::bail!("baseline does not support --mode vector");
2993 }
2994
2995 let Some(path) = args.golden.as_ref() else {
2996 return Ok(None);
2997 };
2998
2999 let raw = std::fs::read_to_string(path)
3000 .with_context(|| format!("failed to read golden set: {path}"))?;
3001 let cases: Vec<GoldenSetCase> =
3002 serde_json::from_str(&raw).with_context(|| format!("invalid golden set JSON: {path}"))?;
3003
3004 if cases.is_empty() {
3005 return Ok(Some(GoldenSetMetrics {
3006 cases: 0,
3007 hits_any: 0,
3008 top1_hits: 0,
3009 hit_rate: 0.0,
3010 top1_rate: 0.0,
3011 mrr: 0.0,
3012 ndcg_at_k: 0.0,
3013 }));
3014 }
3015
3016 let mode = map_find_mode(args.mode);
3017 let mut hits_any = 0usize;
3018 let mut top1_hits = 0usize;
3019 let mut mrr_sum = 0.0;
3020 let mut ndcg_sum = 0.0;
3021
3022 for case in &cases {
3023 let results = output::find_nodes(
3024 graph,
3025 &case.query,
3026 args.find_limit,
3027 args.include_features,
3028 false,
3029 mode,
3030 );
3031
3032 let mut first_rank: Option<usize> = None;
3033 for (idx, node) in results.iter().enumerate() {
3034 if case.expected.iter().any(|id| id == &node.id) {
3035 first_rank = Some(idx + 1);
3036 break;
3037 }
3038 }
3039
3040 if let Some(rank) = first_rank {
3041 hits_any += 1;
3042 if rank == 1 {
3043 top1_hits += 1;
3044 }
3045 mrr_sum += 1.0 / rank as f64;
3046 }
3047
3048 let mut dcg = 0.0;
3049 for (idx, node) in results.iter().enumerate() {
3050 if case.expected.iter().any(|id| id == &node.id) {
3051 let denom = (idx as f64 + 2.0).log2();
3052 dcg += 1.0 / denom;
3053 }
3054 }
3055 let ideal_hits = case.expected.len().min(results.len());
3056 let mut idcg = 0.0;
3057 for rank in 0..ideal_hits {
3058 let denom = (rank as f64 + 2.0).log2();
3059 idcg += 1.0 / denom;
3060 }
3061 if idcg > 0.0 {
3062 ndcg_sum += dcg / idcg;
3063 }
3064 }
3065
3066 let total = cases.len() as f64;
3067 Ok(Some(GoldenSetMetrics {
3068 cases: cases.len(),
3069 hits_any,
3070 top1_hits,
3071 hit_rate: hits_any as f64 / total,
3072 top1_rate: top1_hits as f64 / total,
3073 mrr: mrr_sum / total,
3074 ndcg_at_k: ndcg_sum / total,
3075 }))
3076}
3077
3078pub(crate) fn render_baseline_report(
3079 cwd: &Path,
3080 graph_name: &str,
3081 graph: &GraphFile,
3082 quality: &crate::analysis::QualitySnapshot,
3083 args: &BaselineArgs,
3084) -> Result<String> {
3085 let feedback_entries = parse_feedback_entries(cwd, graph_name)?;
3086 let feedback = compute_feedback_metrics(&feedback_entries);
3087
3088 let graph_root = default_graph_root(cwd);
3089 let graph_path = resolve_graph_path(cwd, &graph_root, graph_name)?;
3090 let find_operations = parse_find_operations(&graph_path)?;
3091
3092 let cost = BaselineCostMetrics {
3093 find_operations,
3094 feedback_events: feedback.entries,
3095 feedback_events_per_1000_find_ops: if find_operations > 0 {
3096 (feedback.entries as f64 / find_operations as f64) * 1000.0
3097 } else {
3098 0.0
3099 },
3100 token_cost_estimate: None,
3101 token_cost_note: "token cost unavailable in current logs (instrumentation pending)",
3102 };
3103
3104 let quality_score = compute_quality_score(quality);
3105 let golden = eval_golden_set(graph, args)?;
3106
3107 let report = BaselineReport {
3108 graph: graph_name.to_owned(),
3109 quality: crate::analysis::QualitySnapshot {
3110 total_nodes: quality.total_nodes,
3111 missing_descriptions: quality.missing_descriptions,
3112 missing_facts: quality.missing_facts,
3113 duplicate_pairs: quality.duplicate_pairs,
3114 edge_gaps: crate::analysis::EdgeGapSnapshot {
3115 datastore_candidates: quality.edge_gaps.datastore_candidates,
3116 datastore_missing_stored_in: quality.edge_gaps.datastore_missing_stored_in,
3117 process_candidates: quality.edge_gaps.process_candidates,
3118 process_missing_incoming: quality.edge_gaps.process_missing_incoming,
3119 },
3120 },
3121 quality_score,
3122 feedback,
3123 cost,
3124 golden,
3125 };
3126
3127 if args.json {
3128 let rendered = serde_json::to_string_pretty(&report).unwrap_or_else(|_| "{}".to_owned());
3129 return Ok(format!("{rendered}\n"));
3130 }
3131
3132 let mut lines = vec![String::from("= baseline")];
3133 lines.push(format!("graph: {}", report.graph));
3134 lines.push(format!(
3135 "quality_score_0_100: {:.1}",
3136 report.quality_score.score_0_100
3137 ));
3138 lines.push(String::from("quality:"));
3139 lines.push(format!("- total_nodes: {}", report.quality.total_nodes));
3140 lines.push(format!(
3141 "- missing_descriptions: {} ({:.1}%)",
3142 report.quality.missing_descriptions,
3143 report
3144 .quality_score
3145 .description_coverage
3146 .mul_add(-100.0, 100.0)
3147 ));
3148 lines.push(format!(
3149 "- missing_facts: {} ({:.1}%)",
3150 report.quality.missing_facts,
3151 report.quality_score.facts_coverage.mul_add(-100.0, 100.0)
3152 ));
3153 lines.push(format!(
3154 "- duplicate_pairs: {}",
3155 report.quality.duplicate_pairs
3156 ));
3157 lines.push(format!(
3158 "- edge_gaps: {} / {}",
3159 report.quality.edge_gaps.total_missing(),
3160 report.quality.edge_gaps.total_candidates()
3161 ));
3162
3163 lines.push(String::from("feedback:"));
3164 lines.push(format!("- entries: {}", report.feedback.entries));
3165 lines.push(format!(
3166 "- YES/NO/NIL/PICK: {}/{}/{}/{}",
3167 report.feedback.yes, report.feedback.no, report.feedback.nil, report.feedback.pick
3168 ));
3169 lines.push(format!(
3170 "- yes_rate: {:.1}%",
3171 report.feedback.yes_rate * 100.0
3172 ));
3173 lines.push(format!(
3174 "- no_rate: {:.1}%",
3175 report.feedback.no_rate * 100.0
3176 ));
3177
3178 lines.push(String::from("cost:"));
3179 lines.push(format!(
3180 "- find_operations: {}",
3181 report.cost.find_operations
3182 ));
3183 lines.push(format!(
3184 "- feedback_events: {}",
3185 report.cost.feedback_events
3186 ));
3187 lines.push(format!(
3188 "- feedback_events_per_1000_find_ops: {:.1}",
3189 report.cost.feedback_events_per_1000_find_ops
3190 ));
3191 lines.push(format!("- token_cost: {}", report.cost.token_cost_note));
3192
3193 if let Some(golden) = report.golden {
3194 lines.push(String::from("golden_set:"));
3195 lines.push(format!("- cases: {}", golden.cases));
3196 lines.push(format!("- hit_rate: {:.1}%", golden.hit_rate * 100.0));
3197 lines.push(format!("- top1_rate: {:.1}%", golden.top1_rate * 100.0));
3198 lines.push(format!("- mrr: {:.3}", golden.mrr));
3199 lines.push(format!("- ndcg@k: {:.3}", golden.ndcg_at_k));
3200 }
3201
3202 Ok(format!("{}\n", lines.join("\n")))
3203}
3204
3205#[derive(Debug, Clone)]
3206struct FeedbackLogEntry {
3207 ts_ms: String,
3208 uid: String,
3209 action: String,
3210 pick: Option<String>,
3211 selected: Option<String>,
3212 graph: String,
3213 queries: String,
3214}
3215
3216impl FeedbackLogEntry {
3217 fn parse(line: &str) -> Option<Self> {
3218 let mut ts_ms: Option<String> = None;
3221 let mut uid: Option<String> = None;
3222 let mut action: Option<String> = None;
3223 let mut pick: Option<String> = None;
3224 let mut selected: Option<String> = None;
3225 let mut graph: Option<String> = None;
3226 let mut queries: Option<String> = None;
3227
3228 for part in line.split('\t') {
3229 let (k, v) = part.split_once('=')?;
3230 let v = v.trim();
3231 match k {
3232 "ts_ms" => ts_ms = Some(v.to_owned()),
3233 "uid" => uid = Some(v.to_owned()),
3234 "action" => action = Some(v.to_owned()),
3235 "pick" => {
3236 if v != "-" {
3237 pick = Some(v.to_owned());
3238 }
3239 }
3240 "selected" => {
3241 if v != "-" {
3242 selected = Some(v.to_owned());
3243 }
3244 }
3245 "graph" => {
3246 if v != "-" {
3247 graph = Some(v.to_owned());
3248 }
3249 }
3250 "queries" => {
3251 if v != "-" {
3252 queries = Some(v.to_owned());
3253 }
3254 }
3255 _ => {}
3256 }
3257 }
3258
3259 Some(Self {
3260 ts_ms: ts_ms?,
3261 uid: uid?,
3262 action: action?,
3263 pick,
3264 selected,
3265 graph: graph.unwrap_or_default(),
3266 queries: queries.unwrap_or_default(),
3267 })
3268 }
3269}
3270
3271pub fn default_graph_root(cwd: &Path) -> PathBuf {
3280 let home = std::env::var_os("HOME")
3281 .map(PathBuf::from)
3282 .or_else(|| std::env::var_os("USERPROFILE").map(PathBuf::from));
3283 graph_root_from(home.as_deref(), cwd)
3284}
3285
3286fn graph_root_from(home: Option<&Path>, cwd: &Path) -> PathBuf {
3287 match home {
3288 Some(home) => home.join(".kg").join("graphs"),
3289 None => cwd.join(".kg").join("graphs"),
3290 }
3291}
3292
3293pub fn resolve_graph_path(cwd: &Path, graph_root: &Path, graph: &str) -> Result<PathBuf> {
3298 let store = graph_store(cwd, graph_root, false)?;
3299 store.resolve_graph_path(graph)
3300}
3301
3302pub fn feedback_nudge_percent(cwd: &Path) -> Result<u8> {
3304 Ok(config::KgConfig::discover(cwd)?
3305 .map(|(_, config)| config.nudge_percent())
3306 .unwrap_or(config::DEFAULT_NUDGE_PERCENT))
3307}
3308
3309pub fn sidecar_user_short_uid(cwd: &Path) -> String {
3311 config::ensure_user_short_uid(cwd)
3312}
3313
3314pub fn append_kg_feedback(graph_path: &Path, user_short_uid: &str, node_id: &str, feedback: &str) {
3316 let _ = kg_sidecar::append_feedback_with_uid(graph_path, user_short_uid, node_id, feedback);
3317}
3318
3319pub(crate) fn render_check(graph: &GraphFile, cwd: &Path, args: &CheckArgs) -> String {
3324 let report = validate_graph(graph, cwd, args.deep, args.base_dir.as_deref());
3325 format_validation_report(
3326 "check",
3327 &report.errors,
3328 &report.warnings,
3329 args.errors_only,
3330 args.warnings_only,
3331 args.limit,
3332 )
3333}
3334
3335pub(crate) fn render_audit(graph: &GraphFile, cwd: &Path, args: &AuditArgs) -> String {
3336 let report = validate_graph(graph, cwd, args.deep, args.base_dir.as_deref());
3337 format_validation_report(
3338 "audit",
3339 &report.errors,
3340 &report.warnings,
3341 args.errors_only,
3342 args.warnings_only,
3343 args.limit,
3344 )
3345}
3346
3347fn format_validation_report(
3348 header: &str,
3349 errors: &[String],
3350 warnings: &[String],
3351 errors_only: bool,
3352 warnings_only: bool,
3353 limit: usize,
3354) -> String {
3355 let mut lines = vec![format!("= {header}")];
3356 lines.push(format!(
3357 "status: {}",
3358 if errors.is_empty() {
3359 "VALID"
3360 } else {
3361 "INVALID"
3362 }
3363 ));
3364 lines.push(format!("errors: {}", errors.len()));
3365 lines.push(format!("warnings: {}", warnings.len()));
3366 if !warnings_only {
3367 lines.push("error-list:".to_owned());
3368 for error in errors.iter().take(limit) {
3369 lines.push(format!("- {error}"));
3370 }
3371 }
3372 if !errors_only {
3373 lines.push("warning-list:".to_owned());
3374 for warning in warnings.iter().take(limit) {
3375 lines.push(format!("- {warning}"));
3376 }
3377 }
3378 format!("{}\n", lines.join("\n"))
3379}
3380
3381#[cfg(test)]
3386mod tests {
3387 use super::*;
3388 use tempfile::tempdir;
3389
3390 fn fixture_graph() -> GraphFile {
3391 serde_json::from_str(include_str!("../graph-example-fridge.json")).expect("fixture graph")
3392 }
3393
3394 fn exec_safe(args: &[&str], cwd: &Path) -> Result<String> {
3395 run_args_safe(args.iter().map(OsString::from), cwd)
3396 }
3397
3398 #[test]
3399 fn graph_root_prefers_home_directory() {
3400 let cwd = Path::new("/tmp/workspace");
3401 let home = Path::new("/tmp/home");
3402 assert_eq!(
3403 graph_root_from(Some(home), cwd),
3404 PathBuf::from("/tmp/home/.kg/graphs")
3405 );
3406 assert_eq!(
3407 graph_root_from(None, cwd),
3408 PathBuf::from("/tmp/workspace/.kg/graphs")
3409 );
3410 }
3411
3412 #[test]
3413 fn get_renders_compact_symbolic_view() {
3414 let graph = fixture_graph();
3415 let node = graph.node_by_id("concept:refrigerator").expect("node");
3416 let rendered = output::render_node(&graph, node, false);
3417 assert!(rendered.contains("# concept:refrigerator | Lodowka"));
3418 assert!(rendered.contains("aka: Chlodziarka, Fridge"));
3419 assert!(rendered.contains("-> HAS | concept:cooling_chamber | Komora Chlodzenia"));
3420 assert!(rendered.contains("-> HAS | concept:temperature | Temperatura"));
3421 }
3422
3423 #[test]
3424 fn help_lists_mvp_commands() {
3425 let help = Cli::try_parse_from(["kg", "--help"]).expect_err("help exits");
3426 let rendered = help.to_string();
3427 assert!(!rendered.contains("▓ ▄▄"));
3428 assert!(rendered.contains("create"));
3429 assert!(rendered.contains("list"));
3430 assert!(rendered.contains("feedback-log"));
3431 assert!(rendered.contains("fridge node"));
3432 assert!(rendered.contains("edge"));
3433 assert!(rendered.contains("quality"));
3434 assert!(rendered.contains("kg graph fridge stats"));
3435 }
3436
3437 #[test]
3438 fn run_args_safe_returns_error_instead_of_exiting() {
3439 let dir = tempdir().expect("tempdir");
3440 let err = exec_safe(&["kg", "create"], dir.path()).expect_err("parse error");
3441 let rendered = err.to_string();
3442 assert!(rendered.contains("required arguments were not provided"));
3443 assert!(rendered.contains("<GRAPH_NAME>"));
3444 }
3445
3446 #[test]
3447 fn colorize_cli_output_styles_key_lines() {
3448 let rendered = "? weather (1)\nscore: 1000\n# concept:rain | Rain [Concept]\n-> DEPENDS_ON | process:forecast | Forecast\n";
3449 let colored = colorize_cli_output(rendered);
3450 assert!(colored.contains("\x1b[1;33m? weather (1)\x1b[0m"));
3451 assert!(colored.contains("\x1b[1;35mscore: 1000\x1b[0m"));
3452 assert!(colored.contains("\x1b[1;36m# concept:rain | Rain [Concept]\x1b[0m"));
3453 assert!(colored.contains("\x1b[34m-> DEPENDS_ON | process:forecast | Forecast\x1b[0m"));
3454 }
3455
3456 #[test]
3457 fn colorize_cli_output_leaves_json_unchanged() {
3458 let rendered = "{\n \"nodes\": []\n}\n";
3459 assert_eq!(colorize_cli_output(rendered), rendered);
3460 }
3461
3462 #[test]
3463 fn execute_clusters_sorts_by_relevance_then_size() {
3464 let mut graph = GraphFile::new("score");
3465 graph.nodes.push(Node {
3466 id: "@:cluster_0001".to_owned(),
3467 r#type: "@".to_owned(),
3468 name: "Cluster 1".to_owned(),
3469 properties: NodeProperties::default(),
3470 source_files: vec![],
3471 });
3472 graph.nodes.push(Node {
3473 id: "@:cluster_0002".to_owned(),
3474 r#type: "@".to_owned(),
3475 name: "Cluster 2".to_owned(),
3476 properties: NodeProperties::default(),
3477 source_files: vec![],
3478 });
3479 for id in ["concept:a", "concept:b", "concept:c", "concept:d"] {
3480 graph.nodes.push(Node {
3481 id: id.to_owned(),
3482 r#type: "Concept".to_owned(),
3483 name: id.to_owned(),
3484 properties: NodeProperties::default(),
3485 source_files: vec![],
3486 });
3487 }
3488 graph.edges.push(Edge {
3489 source_id: "@:cluster_0001".to_owned(),
3490 relation: "HAS".to_owned(),
3491 target_id: "concept:a".to_owned(),
3492 properties: EdgeProperties {
3493 detail: "0.95".to_owned(),
3494 ..Default::default()
3495 },
3496 });
3497 graph.edges.push(Edge {
3498 source_id: "@:cluster_0001".to_owned(),
3499 relation: "HAS".to_owned(),
3500 target_id: "concept:b".to_owned(),
3501 properties: EdgeProperties {
3502 detail: "0.85".to_owned(),
3503 ..Default::default()
3504 },
3505 });
3506 graph.edges.push(Edge {
3507 source_id: "@:cluster_0002".to_owned(),
3508 relation: "HAS".to_owned(),
3509 target_id: "concept:c".to_owned(),
3510 properties: EdgeProperties {
3511 detail: "0.70".to_owned(),
3512 ..Default::default()
3513 },
3514 });
3515 graph.edges.push(Edge {
3516 source_id: "@:cluster_0002".to_owned(),
3517 relation: "HAS".to_owned(),
3518 target_id: "concept:d".to_owned(),
3519 properties: EdgeProperties {
3520 detail: "0.65".to_owned(),
3521 ..Default::default()
3522 },
3523 });
3524
3525 let rendered = render_clusters(
3526 &graph,
3527 &ClustersArgs {
3528 limit: 10,
3529 json: false,
3530 skill: None,
3531 },
3532 );
3533 let first = rendered.find("@:cluster_0001").expect("cluster 1 present");
3534 let second = rendered.find("@:cluster_0002").expect("cluster 2 present");
3535 assert!(first < second);
3536 }
3537
3538 #[test]
3539 fn execute_clusters_gardener_mode_emits_actions() {
3540 let mut graph = GraphFile::new("score");
3541 graph.nodes.push(Node {
3542 id: "@:cluster_0001".to_owned(),
3543 r#type: "@".to_owned(),
3544 name: "Cluster 1".to_owned(),
3545 properties: NodeProperties::default(),
3546 source_files: vec![],
3547 });
3548 graph.nodes.push(Node {
3549 id: "concept:a".to_owned(),
3550 r#type: "Concept".to_owned(),
3551 name: "A".to_owned(),
3552 properties: NodeProperties::default(),
3553 source_files: vec![],
3554 });
3555 graph.edges.push(Edge {
3556 source_id: "@:cluster_0001".to_owned(),
3557 relation: "HAS".to_owned(),
3558 target_id: "concept:a".to_owned(),
3559 properties: EdgeProperties {
3560 detail: "0.9".to_owned(),
3561 ..Default::default()
3562 },
3563 });
3564
3565 let rendered = render_clusters(
3566 &graph,
3567 &ClustersArgs {
3568 limit: 5,
3569 json: false,
3570 skill: Some(ClusterSkill::Gardener),
3571 },
3572 );
3573 assert!(rendered.contains("= gardener clusters"));
3574 assert!(rendered.contains("action: review cluster"));
3575 }
3576
3577 #[test]
3578 fn find_latest_score_snapshot_picks_newest_timestamp() {
3579 let dir = tempdir().expect("tempdir");
3580 let graph_path = dir.path().join("fridge.kg");
3581 std::fs::write(&graph_path, "").expect("graph file");
3582 let cache_dir = crate::cache_paths::cache_root_for_graph(&graph_path);
3583 std::fs::create_dir_all(&cache_dir).expect("cache dir");
3584 let older = cache_dir.join("fridge.score.100.kg");
3585 let newer = cache_dir.join("fridge.score.200.kg");
3586 std::fs::write(&older, "").expect("older");
3587 std::fs::write(&newer, "").expect("newer");
3588
3589 let latest = find_latest_score_snapshot(&graph_path)
3590 .expect("latest")
3591 .expect("some path");
3592 assert_eq!(latest, newer);
3593 }
3594
3595 #[test]
3596 fn baseline_rejects_vector_mode() {
3597 let graph = fixture_graph();
3598 let err = eval_golden_set(
3599 &graph,
3600 &BaselineArgs {
3601 find_limit: 5,
3602 include_features: true,
3603 mode: CliFindMode::Vector,
3604 golden: None,
3605 json: false,
3606 },
3607 )
3608 .expect_err("vector mode should be rejected for baseline");
3609 assert!(
3610 err.to_string()
3611 .contains("baseline does not support --mode vector")
3612 );
3613 }
3614}