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