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