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