1use std::path::{Path, PathBuf};
9
10use anyhow::{bail, Context, Result};
11use clap::Subcommand;
12use serde::Serialize;
13
14#[derive(Subcommand, Debug)]
17pub enum KgCommand {
18 Validate(ValidateArgs),
20
21 Init(InitArgs),
23
24 #[command(subcommand)]
26 Hook(HookCommand),
27}
28
29#[derive(clap::Parser, Debug)]
30pub struct ValidateArgs {
31 #[arg(long, default_value = ".")]
33 pub repo: PathBuf,
34
35 #[arg(long)]
37 pub fix: bool,
38
39 #[arg(long)]
41 pub strict: bool,
42
43 #[arg(long, default_value = "text")]
45 pub format: OutputFormat,
46
47 #[arg(long)]
49 pub verbose: bool,
50
51 #[arg(long)]
53 pub quiet: bool,
54
55 #[arg(long)]
57 pub rules: Option<PathBuf>,
58
59 #[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 #[arg(long, default_value = ".")]
85 pub repo: PathBuf,
86
87 #[arg(long)]
89 pub ci: bool,
90
91 #[arg(long)]
93 pub add_hooks: bool,
94}
95
96#[derive(Subcommand, Debug)]
97pub enum HookCommand {
98 Install {
100 #[arg(long, default_value = ".")]
101 repo: PathBuf,
102 },
103 Uninstall {
105 #[arg(long, default_value = ".")]
106 repo: PathBuf,
107 },
108 Status {
110 #[arg(long, default_value = ".")]
111 repo: PathBuf,
112 },
113}
114
115#[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
152pub 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
162fn 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 let mut rule_results: Vec<RuleResult> = structural_checks(&entities_path, &edges_path);
183
184 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 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 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 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
534const 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 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 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 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 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 #[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#[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 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#[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_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}