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