Skip to main content

kkernel/
kg.rs

1//! `kkernel kg` — KG validation, init, and hook management (ADR-034, ADR-035).
2//!
3//! Implements:
4//! - `kkernel kg validate` — structural + rule-pass validation
5//! - `kkernel kg init`     — initialize `.khive/kg/` directory and `khive.toml`
6//! - `kkernel kg hook`     — install / uninstall / status of the pre-commit hook
7
8use std::path::{Path, PathBuf};
9
10use anyhow::{bail, Context, Result};
11use clap::Subcommand;
12use serde::Serialize;
13
14// ── Subcommand tree ────────────────────────────────────────────────────────────
15
16#[derive(Subcommand, Debug)]
17pub enum KgCommand {
18    /// Validate the KG in `.khive/kg/` against structural and rule-pass checks.
19    Validate(ValidateArgs),
20
21    /// Initialize `.khive/kg/` and write `.khive/khive.toml` with defaults.
22    Init(InitArgs),
23
24    /// Manage the pre-commit hook for KG validation.
25    #[command(subcommand)]
26    Hook(HookCommand),
27}
28
29#[derive(clap::Parser, Debug)]
30pub struct ValidateArgs {
31    /// Repository root containing `.khive/kg/`.
32    #[arg(long, default_value = ".")]
33    pub repo: PathBuf,
34
35    /// Apply fixable rules and report what changed.
36    #[arg(long)]
37    pub fix: bool,
38
39    /// Treat warnings as errors; exit 1 when warnings > 0.
40    #[arg(long)]
41    pub strict: bool,
42
43    /// Output format.
44    #[arg(long, default_value = "text")]
45    pub format: OutputFormat,
46
47    /// Show all violations (default: cap at 2 then `+ N more`).
48    #[arg(long)]
49    pub verbose: bool,
50
51    /// Print summary line only.
52    #[arg(long)]
53    pub quiet: bool,
54
55    /// Override the default `.khive/kg/rules.yaml` path.
56    #[arg(long)]
57    pub rules: Option<PathBuf>,
58
59    /// Run ADR-020 built-in structural checks only; skip `rules.yaml`.
60    #[arg(long)]
61    pub no_rules: bool,
62}
63
64#[derive(clap::ValueEnum, Debug, Clone, Copy)]
65pub enum OutputFormat {
66    Text,
67    Json,
68    Github,
69}
70
71impl std::fmt::Display for OutputFormat {
72    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
73        match self {
74            OutputFormat::Text => write!(f, "text"),
75            OutputFormat::Json => write!(f, "json"),
76            OutputFormat::Github => write!(f, "github"),
77        }
78    }
79}
80
81#[derive(clap::Parser, Debug)]
82pub struct InitArgs {
83    /// Repository root to initialize.
84    #[arg(long, default_value = ".")]
85    pub repo: PathBuf,
86
87    /// Also generate `.github/workflows/kg-validate.yml`.
88    #[arg(long)]
89    pub ci: bool,
90
91    /// Install the pre-commit hook without reinitializing.
92    #[arg(long)]
93    pub add_hooks: bool,
94}
95
96#[derive(Subcommand, Debug)]
97pub enum HookCommand {
98    /// Create `.git/hooks/pre-commit` symlink pointing to the tracked hook.
99    Install {
100        #[arg(long, default_value = ".")]
101        repo: PathBuf,
102    },
103    /// Remove the `.git/hooks/pre-commit` symlink.
104    Uninstall {
105        #[arg(long, default_value = ".")]
106        repo: PathBuf,
107    },
108    /// Show whether the hook symlink exists and points to a valid target.
109    Status {
110        #[arg(long, default_value = ".")]
111        repo: PathBuf,
112    },
113}
114
115// ── Output types ───────────────────────────────────────────────────────────────
116
117#[derive(Debug, Serialize)]
118pub struct ValidationReport {
119    pub rules: Vec<RuleResult>,
120    pub summary: ValidationSummary,
121}
122
123#[derive(Debug, Serialize)]
124pub struct RuleResult {
125    pub id: String,
126    pub severity: &'static str,
127    pub passed: bool,
128    pub violations: Vec<Violation>,
129}
130
131#[derive(Debug, Serialize)]
132pub struct Violation {
133    pub entity_id: Option<String>,
134    pub entity_name: Option<String>,
135    pub entity_kind: Option<String>,
136    pub rule_id: String,
137    pub severity: &'static str,
138    pub message: String,
139    pub fixable: bool,
140}
141
142#[derive(Debug, Serialize)]
143pub struct ValidationSummary {
144    pub errors: usize,
145    pub warnings: usize,
146    pub info: usize,
147    pub entities: usize,
148    pub edges: usize,
149    pub passed: bool,
150}
151
152// ── Entry points ───────────────────────────────────────────────────────────────
153
154pub fn run_kg(cmd: KgCommand) -> Result<()> {
155    match cmd {
156        KgCommand::Validate(args) => cmd_validate(args),
157        KgCommand::Init(args) => cmd_init(args),
158        KgCommand::Hook(h) => cmd_hook(h),
159    }
160}
161
162// ── validate ──────────────────────────────────────────────────────────────────
163
164fn cmd_validate(args: ValidateArgs) -> Result<()> {
165    let kg_dir = args.repo.join(".khive/kg");
166    if !kg_dir.exists() {
167        bail!(
168            "KG directory not found: {}. Run `kkernel kg init` first.",
169            kg_dir.display()
170        );
171    }
172
173    let entities_path = kg_dir.join("entities.ndjson");
174    let edges_path = kg_dir.join("edges.ndjson");
175
176    let entities = count_ndjson_lines(&entities_path).unwrap_or(0);
177    let edges = count_ndjson_lines(&edges_path).unwrap_or(0);
178
179    let rules_path = args.rules.unwrap_or_else(|| kg_dir.join("rules.yaml"));
180
181    // Run structural checks (ADR-020 built-ins).
182    let mut rule_results: Vec<RuleResult> = structural_checks(&entities_path, &edges_path);
183
184    // Run configurable rule pass unless --no-rules.
185    if !args.no_rules && rules_path.exists() {
186        let configurable = configurable_rule_checks(&entities_path, &edges_path, &rules_path)?;
187        rule_results.extend(configurable);
188    }
189
190    let errors: usize = rule_results
191        .iter()
192        .filter(|r| r.severity == "error" && !r.passed)
193        .count();
194    let warnings: usize = rule_results
195        .iter()
196        .filter(|r| r.severity == "warning" && !r.passed)
197        .count();
198    let info: usize = rule_results
199        .iter()
200        .filter(|r| r.severity == "info" && !r.passed)
201        .count();
202
203    let passed = if args.strict {
204        errors == 0 && warnings == 0
205    } else {
206        errors == 0
207    };
208
209    let summary = ValidationSummary {
210        errors,
211        warnings,
212        info,
213        entities,
214        edges,
215        passed,
216    };
217
218    let report = ValidationReport {
219        rules: rule_results,
220        summary,
221    };
222
223    match args.format {
224        OutputFormat::Json => {
225            let json = serde_json::to_string_pretty(&report).expect("serialize ValidationReport");
226            println!("{json}");
227        }
228        OutputFormat::Github => print_github_format(&report),
229        OutputFormat::Text => print_text_format(&report, args.verbose, args.quiet),
230    }
231
232    if args.fix {
233        apply_fixes(&args.repo)?;
234    }
235
236    if !report.summary.passed {
237        std::process::exit(1);
238    }
239    Ok(())
240}
241
242fn count_ndjson_lines(path: &Path) -> Option<usize> {
243    let content = std::fs::read_to_string(path).ok()?;
244    Some(content.lines().filter(|l| !l.trim().is_empty()).count())
245}
246
247fn structural_checks(entities_path: &Path, edges_path: &Path) -> Vec<RuleResult> {
248    vec![
249        check_no_duplicate_uuids(entities_path),
250        check_sort_order(entities_path, edges_path),
251        check_referential_integrity(entities_path, edges_path),
252    ]
253}
254
255fn check_no_duplicate_uuids(entities_path: &Path) -> RuleResult {
256    let mut seen = std::collections::HashSet::new();
257    let mut violations = Vec::new();
258
259    if let Ok(content) = std::fs::read_to_string(entities_path) {
260        for line in content.lines().filter(|l| !l.trim().is_empty()) {
261            if let Ok(v) = serde_json::from_str::<serde_json::Value>(line) {
262                if let Some(id) = v.get("id").and_then(|i| i.as_str()) {
263                    if !seen.insert(id.to_string()) {
264                        violations.push(Violation {
265                            entity_id: Some(id.to_string()),
266                            entity_name: v.get("name").and_then(|n| n.as_str()).map(str::to_string),
267                            entity_kind: v.get("kind").and_then(|k| k.as_str()).map(str::to_string),
268                            rule_id: "no-duplicate-uuids".into(),
269                            severity: "error",
270                            message: format!("Duplicate UUID: {id}"),
271                            fixable: false,
272                        });
273                    }
274                }
275            }
276        }
277    }
278
279    RuleResult {
280        id: "no-duplicate-uuids".into(),
281        severity: "error",
282        passed: violations.is_empty(),
283        violations,
284    }
285}
286
287fn check_sort_order(entities_path: &Path, edges_path: &Path) -> RuleResult {
288    let mut violations = Vec::new();
289
290    // Check entities.ndjson sorted by UUID.
291    if let Ok(content) = std::fs::read_to_string(entities_path) {
292        let ids: Vec<String> = content
293            .lines()
294            .filter(|l| !l.trim().is_empty())
295            .filter_map(|l| {
296                serde_json::from_str::<serde_json::Value>(l)
297                    .ok()
298                    .and_then(|v| v.get("id")?.as_str().map(str::to_string))
299            })
300            .collect();
301        let mut sorted = ids.clone();
302        sorted.sort();
303        if ids != sorted {
304            violations.push(Violation {
305                entity_id: None,
306                entity_name: None,
307                entity_kind: None,
308                rule_id: "sort-order".into(),
309                severity: "warning",
310                message: "entities.ndjson is not sorted by UUID; run `kkernel kg validate --fix`"
311                    .into(),
312                fixable: true,
313            });
314        }
315    }
316
317    // Check edges.ndjson sorted by (source, target, relation).
318    if let Ok(content) = std::fs::read_to_string(edges_path) {
319        let keys: Vec<(String, String, String)> = content
320            .lines()
321            .filter(|l| !l.trim().is_empty())
322            .filter_map(|l| {
323                let v: serde_json::Value = serde_json::from_str(l).ok()?;
324                let s = v.get("source_id")?.as_str()?.to_string();
325                let t = v.get("target_id")?.as_str()?.to_string();
326                let r = v.get("relation")?.as_str()?.to_string();
327                Some((s, t, r))
328            })
329            .collect();
330        let mut sorted = keys.clone();
331        sorted.sort();
332        if keys != sorted {
333            violations.push(Violation {
334                entity_id: None,
335                entity_name: None,
336                entity_kind: None,
337                rule_id: "sort-order".into(),
338                severity: "warning",
339                message:
340                    "edges.ndjson is not sorted by (source, target, relation); run `kkernel kg validate --fix`"
341                        .into(),
342                fixable: true,
343            });
344        }
345    }
346
347    RuleResult {
348        id: "sort-order".into(),
349        severity: "warning",
350        passed: violations.is_empty(),
351        violations,
352    }
353}
354
355fn check_referential_integrity(entities_path: &Path, edges_path: &Path) -> RuleResult {
356    let mut violations = Vec::new();
357
358    let entity_ids: std::collections::HashSet<String> =
359        if let Ok(content) = std::fs::read_to_string(entities_path) {
360            content
361                .lines()
362                .filter(|l| !l.trim().is_empty())
363                .filter_map(|l| {
364                    serde_json::from_str::<serde_json::Value>(l)
365                        .ok()
366                        .and_then(|v| v.get("id")?.as_str().map(str::to_string))
367                })
368                .collect()
369        } else {
370            std::collections::HashSet::new()
371        };
372
373    if let Ok(content) = std::fs::read_to_string(edges_path) {
374        for line in content.lines().filter(|l| !l.trim().is_empty()) {
375            if let Ok(v) = serde_json::from_str::<serde_json::Value>(line) {
376                for field in &["source_id", "target_id"] {
377                    if let Some(id) = v.get(field).and_then(|i| i.as_str()) {
378                        if !entity_ids.contains(id) {
379                            violations.push(Violation {
380                                entity_id: Some(id.to_string()),
381                                entity_name: None,
382                                entity_kind: None,
383                                rule_id: "referential-integrity".into(),
384                                severity: "error",
385                                message: format!(
386                                    "Edge {} references unknown entity: {id}",
387                                    if *field == "source_id" {
388                                        "source"
389                                    } else {
390                                        "target"
391                                    }
392                                ),
393                                fixable: false,
394                            });
395                        }
396                    }
397                }
398            }
399        }
400    }
401
402    RuleResult {
403        id: "referential-integrity".into(),
404        severity: "error",
405        passed: violations.is_empty(),
406        violations,
407    }
408}
409
410fn configurable_rule_checks(
411    _entities_path: &Path,
412    _edges_path: &Path,
413    _rules_path: &Path,
414) -> Result<Vec<RuleResult>> {
415    // Rules.yaml loading and evaluation is deferred to the runtime library
416    // (ADR-034 §10 specifies schema validation with exit code 2). This stub
417    // returns no additional results when the rules file is present but the
418    // rule-evaluation runtime hasn't loaded it yet.
419    Ok(Vec::new())
420}
421
422fn apply_fixes(repo: &Path) -> Result<()> {
423    let kg_dir = repo.join(".khive/kg");
424    fix_sort_order(&kg_dir.join("entities.ndjson"), "id")?;
425    fix_sort_order_edges(&kg_dir.join("edges.ndjson"))?;
426    eprintln!("~ sort-order: applied fix to entities.ndjson and edges.ndjson");
427    Ok(())
428}
429
430fn fix_sort_order(path: &Path, sort_key: &str) -> Result<()> {
431    if !path.exists() {
432        return Ok(());
433    }
434    let content =
435        std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
436    let mut lines: Vec<serde_json::Value> = content
437        .lines()
438        .filter(|l| !l.trim().is_empty())
439        .filter_map(|l| serde_json::from_str(l).ok())
440        .collect();
441    lines.sort_by(|a, b| {
442        let ak = a.get(sort_key).and_then(|v| v.as_str()).unwrap_or("");
443        let bk = b.get(sort_key).and_then(|v| v.as_str()).unwrap_or("");
444        ak.cmp(bk)
445    });
446    let out: String = lines
447        .iter()
448        .map(|v| serde_json::to_string(v).unwrap())
449        .collect::<Vec<_>>()
450        .join("\n");
451    std::fs::write(path, out + "\n").with_context(|| format!("write {}", path.display()))
452}
453
454fn fix_sort_order_edges(path: &Path) -> Result<()> {
455    if !path.exists() {
456        return Ok(());
457    }
458    let content =
459        std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
460    let mut lines: Vec<serde_json::Value> = content
461        .lines()
462        .filter(|l| !l.trim().is_empty())
463        .filter_map(|l| serde_json::from_str(l).ok())
464        .collect();
465    lines.sort_by(|a, b| {
466        let ak = (
467            a.get("source_id").and_then(|v| v.as_str()).unwrap_or(""),
468            a.get("target_id").and_then(|v| v.as_str()).unwrap_or(""),
469            a.get("relation").and_then(|v| v.as_str()).unwrap_or(""),
470        );
471        let bk = (
472            b.get("source_id").and_then(|v| v.as_str()).unwrap_or(""),
473            b.get("target_id").and_then(|v| v.as_str()).unwrap_or(""),
474            b.get("relation").and_then(|v| v.as_str()).unwrap_or(""),
475        );
476        ak.cmp(&bk)
477    });
478    let out: String = lines
479        .iter()
480        .map(|v| serde_json::to_string(v).unwrap())
481        .collect::<Vec<_>>()
482        .join("\n");
483    std::fs::write(path, out + "\n").with_context(|| format!("write {}", path.display()))
484}
485
486fn print_text_format(report: &ValidationReport, verbose: bool, quiet: bool) {
487    if !quiet {
488        for r in &report.rules {
489            let symbol = if r.passed {
490                "\u{2713}"
491            } else if r.severity == "error" {
492                "\u{2717}"
493            } else {
494                "\u{26a0}"
495            };
496            if r.violations.is_empty() {
497                println!("  {symbol} {}", r.id);
498            } else {
499                println!("  {symbol} {}: {} violation(s)", r.id, r.violations.len());
500                let shown = if verbose {
501                    r.violations.len()
502                } else {
503                    2.min(r.violations.len())
504                };
505                for v in &r.violations[..shown] {
506                    println!("    - {}", v.message);
507                }
508                if !verbose && r.violations.len() > 2 {
509                    println!("    + {} more (run with --verbose)", r.violations.len() - 2);
510                }
511            }
512        }
513    }
514    let s = &report.summary;
515    println!(
516        "\nSummary: {} error(s), {} warning(s), {} entities, {} edges",
517        s.errors, s.warnings, s.entities, s.edges
518    );
519}
520
521fn print_github_format(report: &ValidationReport) {
522    for r in &report.rules {
523        for v in &r.violations {
524            let level = if r.severity == "error" {
525                "error"
526            } else {
527                "warning"
528            };
529            println!("::{level} ::{}", v.message);
530        }
531    }
532}
533
534// ── init ──────────────────────────────────────────────────────────────────────
535
536const DEFAULT_KHIVE_TOML: &str = r#"# .khive/khive.toml — project KG configuration (ADR-035)
537# Committed to git. All collaborators use these settings.
538
539[[backends]]
540name = "main"
541path = "~/.khive/khive.db"
542cache_mb = 256
543journal_mode = "wal"
544
545[[engines]]
546name = "mE5-small"
547dim = 384
548weight = 1.0
549
550[packs.kg]
551backend = "main"
552engines = ["mE5-small"]
553
554[packs.memory]
555backend = "main"
556engines = ["mE5-small"]
557
558[packs.gtd]
559backend = "main"
560engines = []
561
562[embed]
563model = "mE5-small"
564dimensions = 384
565auto_embed = true
566batch_size = 64
567
568[embed.fields]
569include = ["name", "description"]
570
571[schema]
572strict = true
573"#;
574
575const GITIGNORE_CONTENT: &str = "*\n!.gitignore\n!kg/\n!kg/**\n!khive.toml\n";
576
577const PRE_COMMIT_HOOK: &str = r#"#!/usr/bin/env bash
578# .khive/kg/hooks/pre-commit
579# Generated by kkernel kg init.
580# Runs KG validation on staged NDJSON files.
581# Bypass with: git commit --no-verify
582
583set -euo pipefail
584
585staged=$(git diff --cached --name-only \
586  | grep -E '^\.khive/kg/(entities|edges)\.ndjson$' || true)
587if [ -z "$staged" ]; then
588  exit 0
589fi
590
591kkernel kg validate
592"#;
593
594const CI_WORKFLOW: &str = r#"name: KG Validate
595on:
596  push:
597    paths: [".khive/kg/**"]
598  pull_request:
599    paths: [".khive/kg/**"]
600
601jobs:
602  validate:
603    runs-on: ubuntu-latest
604    steps:
605      - uses: actions/checkout@v4
606      - name: Validate KG
607        run: kkernel kg validate --format github
608"#;
609
610fn cmd_init(args: InitArgs) -> Result<()> {
611    if args.add_hooks {
612        return hook_install(&args.repo);
613    }
614
615    let khive_dir = args.repo.join(".khive");
616    let kg_dir = khive_dir.join("kg");
617    let hooks_dir = kg_dir.join("hooks");
618
619    std::fs::create_dir_all(&kg_dir).with_context(|| format!("create {}", kg_dir.display()))?;
620    std::fs::create_dir_all(&hooks_dir)
621        .with_context(|| format!("create {}", hooks_dir.display()))?;
622
623    // Write entities.ndjson and edges.ndjson if absent.
624    for name in &["entities.ndjson", "edges.ndjson"] {
625        let path = kg_dir.join(name);
626        if !path.exists() {
627            std::fs::write(&path, "").with_context(|| format!("create {}", path.display()))?;
628        }
629    }
630
631    // Write .khive/.gitignore.
632    let gitignore = khive_dir.join(".gitignore");
633    if !gitignore.exists() {
634        std::fs::write(&gitignore, GITIGNORE_CONTENT)
635            .with_context(|| format!("write {}", gitignore.display()))?;
636    }
637
638    // Write .khive/khive.toml (do not overwrite).
639    let toml_path = khive_dir.join("khive.toml");
640    if !toml_path.exists() {
641        std::fs::write(&toml_path, DEFAULT_KHIVE_TOML)
642            .with_context(|| format!("write {}", toml_path.display()))?;
643        println!("  Initialized {}", toml_path.display());
644    } else {
645        println!("  Skipped {} (already exists)", toml_path.display());
646    }
647
648    // Write pre-commit hook script.
649    let hook_script = hooks_dir.join("pre-commit");
650    if !hook_script.exists() {
651        std::fs::write(&hook_script, PRE_COMMIT_HOOK)
652            .with_context(|| format!("write {}", hook_script.display()))?;
653        // Make hook script executable.
654        #[cfg(unix)]
655        {
656            use std::os::unix::fs::PermissionsExt;
657            let mut perms = std::fs::metadata(&hook_script)?.permissions();
658            perms.set_mode(0o755);
659            std::fs::set_permissions(&hook_script, perms)?;
660        }
661    }
662
663    println!("  Initialized .khive/kg/ (entities.ndjson, edges.ndjson, hooks/pre-commit)");
664
665    if args.ci {
666        let workflow_dir = args.repo.join(".github/workflows");
667        std::fs::create_dir_all(&workflow_dir)
668            .with_context(|| format!("create {}", workflow_dir.display()))?;
669        let workflow_path = workflow_dir.join("kg-validate.yml");
670        if !workflow_path.exists() {
671            std::fs::write(&workflow_path, CI_WORKFLOW)
672                .with_context(|| format!("write {}", workflow_path.display()))?;
673            println!("  Generated {}", workflow_path.display());
674        }
675    }
676
677    Ok(())
678}
679
680// ── hook ──────────────────────────────────────────────────────────────────────
681
682#[derive(Debug, Serialize)]
683pub struct HookStatus {
684    pub symlink_exists: bool,
685    pub symlink_target: Option<String>,
686    pub target_valid: bool,
687}
688
689fn cmd_hook(cmd: HookCommand) -> Result<()> {
690    match cmd {
691        HookCommand::Install { repo } => hook_install(&repo),
692        HookCommand::Uninstall { repo } => hook_uninstall(&repo),
693        HookCommand::Status { repo } => hook_status(&repo),
694    }
695}
696
697fn hook_install(repo: &Path) -> Result<()> {
698    let hook_script = repo.join(".khive/kg/hooks/pre-commit");
699    let git_hook = repo.join(".git/hooks/pre-commit");
700
701    if !hook_script.exists() {
702        bail!(
703            "Hook script not found: {}. Run `kkernel kg init` first.",
704            hook_script.display()
705        );
706    }
707
708    if let Some(parent) = git_hook.parent() {
709        std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
710    }
711
712    if git_hook.exists() || git_hook.is_symlink() {
713        std::fs::remove_file(&git_hook)
714            .with_context(|| format!("remove existing {}", git_hook.display()))?;
715    }
716
717    #[cfg(unix)]
718    {
719        use std::os::unix::fs::symlink;
720        // Use the absolute path for the symlink target.
721        let absolute_script = hook_script
722            .canonicalize()
723            .unwrap_or_else(|_| hook_script.clone());
724        symlink(&absolute_script, &git_hook)
725            .with_context(|| format!("create symlink {}", git_hook.display()))?;
726    }
727
728    #[cfg(not(unix))]
729    {
730        std::fs::copy(&hook_script, &git_hook)
731            .with_context(|| format!("copy hook to {}", git_hook.display()))?;
732    }
733
734    println!(
735        "  Installed: {} -> {}",
736        git_hook.display(),
737        hook_script.display()
738    );
739    Ok(())
740}
741
742fn hook_uninstall(repo: &Path) -> Result<()> {
743    let git_hook = repo.join(".git/hooks/pre-commit");
744    if git_hook.exists() || git_hook.is_symlink() {
745        std::fs::remove_file(&git_hook)
746            .with_context(|| format!("remove {}", git_hook.display()))?;
747        println!("  Uninstalled: {}", git_hook.display());
748    } else {
749        println!("  No hook installed at {}", git_hook.display());
750    }
751    Ok(())
752}
753
754fn hook_status(repo: &Path) -> Result<()> {
755    let git_hook = repo.join(".git/hooks/pre-commit");
756    let symlink_exists = git_hook.exists() || git_hook.is_symlink();
757    let symlink_target = if symlink_exists {
758        std::fs::read_link(&git_hook)
759            .ok()
760            .map(|p| p.display().to_string())
761    } else {
762        None
763    };
764    let target_valid = symlink_target
765        .as_deref()
766        .map(|t| Path::new(t).exists())
767        .unwrap_or(false);
768
769    let status = HookStatus {
770        symlink_exists,
771        symlink_target,
772        target_valid,
773    };
774    let json = serde_json::to_string(&status).expect("serialize HookStatus");
775    println!("{json}");
776    Ok(())
777}
778
779// ── Tests ─────────────────────────────────────────────────────────────────────
780
781#[cfg(test)]
782mod tests {
783    use super::*;
784    use tempfile::TempDir;
785
786    fn make_kg_dir(tmp: &TempDir) -> PathBuf {
787        let kg_dir = tmp.path().join(".khive/kg");
788        std::fs::create_dir_all(&kg_dir).unwrap();
789        kg_dir
790    }
791
792    fn write_entities(kg_dir: &Path, entities: &[(&str, &str, &str)]) {
793        let content: String = entities
794            .iter()
795            .map(|(id, kind, name)| format!(r#"{{"id":"{id}","kind":"{kind}","name":"{name}"}}"#))
796            .collect::<Vec<_>>()
797            .join("\n");
798        std::fs::write(kg_dir.join("entities.ndjson"), content + "\n").unwrap();
799    }
800
801    fn write_edges(kg_dir: &Path, edges: &[(&str, &str, &str)]) {
802        let content: String = edges
803            .iter()
804            .map(|(src, tgt, rel)| {
805                format!(r#"{{"source_id":"{src}","target_id":"{tgt}","relation":"{rel}"}}"#)
806            })
807            .collect::<Vec<_>>()
808            .join("\n");
809        std::fs::write(kg_dir.join("edges.ndjson"), content + "\n").unwrap();
810    }
811
812    #[test]
813    fn duplicate_uuid_detected() {
814        let tmp = TempDir::new().unwrap();
815        let kg_dir = make_kg_dir(&tmp);
816        write_entities(
817            &kg_dir,
818            &[
819                ("aaaaaaaa-0000-0000-0000-000000000001", "concept", "A"),
820                ("aaaaaaaa-0000-0000-0000-000000000001", "concept", "A-dup"),
821            ],
822        );
823        let result = check_no_duplicate_uuids(&kg_dir.join("entities.ndjson"));
824        assert!(!result.passed, "duplicate UUID should fail");
825        assert_eq!(result.violations.len(), 1);
826    }
827
828    #[test]
829    fn no_duplicates_passes() {
830        let tmp = TempDir::new().unwrap();
831        let kg_dir = make_kg_dir(&tmp);
832        write_entities(
833            &kg_dir,
834            &[
835                ("aaaaaaaa-0000-0000-0000-000000000001", "concept", "A"),
836                ("bbbbbbbb-0000-0000-0000-000000000002", "concept", "B"),
837            ],
838        );
839        let result = check_no_duplicate_uuids(&kg_dir.join("entities.ndjson"));
840        assert!(result.passed);
841    }
842
843    #[test]
844    fn referential_integrity_catches_missing_target() {
845        let tmp = TempDir::new().unwrap();
846        let kg_dir = make_kg_dir(&tmp);
847        write_entities(
848            &kg_dir,
849            &[("aaaaaaaa-0000-0000-0000-000000000001", "concept", "A")],
850        );
851        write_edges(
852            &kg_dir,
853            &[(
854                "aaaaaaaa-0000-0000-0000-000000000001",
855                "bbbbbbbb-0000-0000-0000-000000000002",
856                "extends",
857            )],
858        );
859        let result = check_referential_integrity(
860            &kg_dir.join("entities.ndjson"),
861            &kg_dir.join("edges.ndjson"),
862        );
863        assert!(!result.passed);
864        assert_eq!(result.violations.len(), 1);
865    }
866
867    #[test]
868    fn init_creates_expected_files() {
869        let tmp = TempDir::new().unwrap();
870        let args = InitArgs {
871            repo: tmp.path().to_path_buf(),
872            ci: false,
873            add_hooks: false,
874        };
875        cmd_init(args).unwrap();
876
877        assert!(tmp.path().join(".khive/kg/entities.ndjson").exists());
878        assert!(tmp.path().join(".khive/kg/edges.ndjson").exists());
879        assert!(tmp.path().join(".khive/khive.toml").exists());
880        assert!(tmp.path().join(".khive/kg/hooks/pre-commit").exists());
881    }
882
883    #[test]
884    fn init_does_not_overwrite_existing_toml() {
885        let tmp = TempDir::new().unwrap();
886        std::fs::create_dir_all(tmp.path().join(".khive")).unwrap();
887        let toml_path = tmp.path().join(".khive/khive.toml");
888        std::fs::write(&toml_path, "# custom\n").unwrap();
889
890        let args = InitArgs {
891            repo: tmp.path().to_path_buf(),
892            ci: false,
893            add_hooks: false,
894        };
895        cmd_init(args).unwrap();
896
897        let content = std::fs::read_to_string(&toml_path).unwrap();
898        assert_eq!(content, "# custom\n", "should not overwrite existing toml");
899    }
900
901    #[test]
902    fn sort_order_fix_sorts_entities() {
903        let tmp = TempDir::new().unwrap();
904        let kg_dir = make_kg_dir(&tmp);
905        // Write out-of-order entities.
906        write_entities(
907            &kg_dir,
908            &[
909                ("cccccccc-0000-0000-0000-000000000003", "concept", "C"),
910                ("aaaaaaaa-0000-0000-0000-000000000001", "concept", "A"),
911                ("bbbbbbbb-0000-0000-0000-000000000002", "concept", "B"),
912            ],
913        );
914        std::fs::write(kg_dir.join("edges.ndjson"), "").unwrap();
915        fix_sort_order(&kg_dir.join("entities.ndjson"), "id").unwrap();
916        let result = check_sort_order(
917            &kg_dir.join("entities.ndjson"),
918            &kg_dir.join("edges.ndjson"),
919        );
920        assert!(result.passed, "sort-order should pass after fix");
921    }
922}