Skip to main content

kg/
lib.rs

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