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