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