Skip to main content

kg/
lib.rs

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
22// Re-export the core graph types for embedding (e.g. kg-mcp).
23pub use graph::{Edge, EdgeProperties, GraphFile, Metadata, Node, NodeProperties, Note};
24pub use output::FindMode;
25
26// Re-export validation constants for schema tools.
27pub use validate::{EDGE_TYPE_RULES, TYPE_TO_PREFIX, VALID_RELATIONS, VALID_TYPES};
28
29// Re-export BM25 index for embedding and benchmarks.
30pub 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;
46// (graph types are re-exported above)
47use 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
67// ---------------------------------------------------------------------------
68// Schema validation helpers
69// ---------------------------------------------------------------------------
70
71fn 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
115// ---------------------------------------------------------------------------
116// Public entry point
117// ---------------------------------------------------------------------------
118
119/// Run kg with CLI arguments, printing the result to stdout.
120///
121/// This is the main entry point for the kg binary.
122pub 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
131/// Run kg with CLI arguments, returning the rendered output as a string.
132///
133/// This is useful for embedding kg in other applications.
134pub 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
143/// Run kg with CLI arguments, returning errors as Result instead of exiting.
144///
145/// Unlike `run_args`, this does not exit on parse errors - it returns them
146/// as `Err` results. Useful for testing and embedding scenarios.
147pub 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
156// ---------------------------------------------------------------------------
157// Arg normalisation: `kg fridge ...` -> `kg graph fridge ...`
158// ---------------------------------------------------------------------------
159
160fn 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
188// ---------------------------------------------------------------------------
189// Command dispatch
190// ---------------------------------------------------------------------------
191
192fn 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                // Short aliases (e.g. `kg graph fridge missing-descriptions`)
280                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", &note_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(&note.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('&', "&amp;")
1449        .replace('<', "&lt;")
1450        .replace('>', "&gt;")
1451        .replace('"', "&quot;")
1452        .replace('\'', "&apos;")
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(&notes_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(&note.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(&note.body, 80)
2115        );
2116        if !note.tags.is_empty() {
2117            line.push_str(" | tags: ");
2118            line.push_str(&note.tags.join(", "));
2119        }
2120        if !note.author.is_empty() {
2121            line.push_str(" | by: ");
2122            line.push_str(&note.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        // Expected (tab-separated):
2766        // ts_ms=...\tuid=...\taction=...\tpick=...\tselected=...\tgraph=...\tqueries=...
2767        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
2818// ---------------------------------------------------------------------------
2819// Graph lifecycle helpers
2820// ---------------------------------------------------------------------------
2821
2822/// Returns the default graph root directory for this environment.
2823///
2824/// This is primarily exposed for embedding use-cases (e.g. kg-mcp), so they
2825/// can resolve graph paths consistently with the CLI.
2826pub 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
2840/// Resolve a graph identifier/path to an on-disk JSON file.
2841///
2842/// This is primarily exposed for embedding use-cases (e.g. kg-mcp), so they
2843/// can resolve graph paths consistently with the CLI.
2844pub 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
2849/// Load the MCP nudge probability from `.kg.toml`, defaulting to 20.
2850pub 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
2856/// Resolve and (if needed) persist `user_short_uid` for sidecar logging.
2857pub fn sidecar_user_short_uid(cwd: &Path) -> String {
2858    config::ensure_user_short_uid(cwd)
2859}
2860
2861/// Best-effort append of an `F` feedback record to `<graph>.kglog`.
2862pub 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
2866// ---------------------------------------------------------------------------
2867// Validation renderers (check vs audit differ in header only)
2868// ---------------------------------------------------------------------------
2869
2870pub(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// ---------------------------------------------------------------------------
2929// Tests
2930// ---------------------------------------------------------------------------
2931
2932#[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}