Skip to main content

kg/
lib.rs

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