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