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