use std::collections::HashSet;
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use chrono::Utc;
use clap::Subcommand;
use khive_runtime::portability::{ExportedEdge, ExportedEntity, KgArchive};
use khive_runtime::{KhiveRuntime, Namespace, RuntimeConfig};
use khive_storage::EdgeRelation;
use khive_types::EntityKind;
use khive_vcs_adapters::{EdgeRecord, EntityRecord, FormatAdapter, JsonFormatAdapter};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Subcommand, Debug)]
pub enum KgCommand {
Validate(ValidateArgs),
Init(InitArgs),
#[command(visible_alias = "sync")]
Fetch(FetchArgs),
Export(ExportArgs),
Import(ImportArgs),
Status(StatusArgs),
#[command(subcommand)]
Hook(HookCommand),
}
#[derive(clap::Parser, Debug)]
pub struct ValidateArgs {
#[arg(long, default_value = ".")]
pub repo: PathBuf,
#[arg(long)]
pub fix: bool,
#[arg(long)]
pub strict: bool,
#[arg(long, default_value = "text")]
pub format: OutputFormat,
#[arg(long)]
pub verbose: bool,
#[arg(long)]
pub quiet: bool,
#[arg(long)]
pub rules: Option<PathBuf>,
#[arg(long)]
pub no_rules: bool,
}
#[derive(clap::ValueEnum, Debug, Clone, Copy)]
pub enum OutputFormat {
Text,
Json,
Github,
}
impl std::fmt::Display for OutputFormat {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
OutputFormat::Text => write!(f, "text"),
OutputFormat::Json => write!(f, "json"),
OutputFormat::Github => write!(f, "github"),
}
}
}
#[derive(clap::Parser, Debug)]
pub struct InitArgs {
#[arg(long, default_value = ".")]
pub repo: PathBuf,
#[arg(long)]
pub ci: bool,
#[arg(long)]
pub add_hooks: bool,
}
#[derive(clap::Parser, Debug)]
pub struct FetchArgs {
pub remote: String,
#[arg(long, default_value = ".")]
pub repo: PathBuf,
#[arg(long)]
pub url: String,
#[arg(long = "ref", default_value = "main")]
pub git_ref: String,
#[arg(long, default_value = "local")]
pub namespace: String,
#[arg(long)]
pub pin: Option<String>,
#[arg(long)]
pub repin: bool,
}
#[derive(clap::Parser, Debug)]
pub struct ExportArgs {
pub output: PathBuf,
#[arg(long)]
pub db: PathBuf,
#[arg(long, default_value = "local")]
pub namespace: String,
}
#[derive(clap::Parser, Debug)]
pub struct ImportArgs {
pub source: PathBuf,
#[arg(long)]
pub db: PathBuf,
#[arg(long, default_value = "local")]
pub namespace: String,
#[arg(long, value_enum, default_value_t = ImportFormat::Archive)]
pub format: ImportFormat,
#[arg(long)]
pub verbose: bool,
}
#[derive(clap::ValueEnum, Debug, Clone, Copy, PartialEq, Eq)]
pub enum ImportFormat {
Archive,
Json,
Ndjson,
}
#[derive(clap::Parser, Debug)]
pub struct StatusArgs {
#[arg(long, default_value = ".")]
pub repo: PathBuf,
#[arg(long)]
pub db: PathBuf,
#[arg(long, default_value = "local")]
pub namespace: String,
}
#[derive(Subcommand, Debug)]
pub enum HookCommand {
Install {
#[arg(long, default_value = ".")]
repo: PathBuf,
},
Uninstall {
#[arg(long, default_value = ".")]
repo: PathBuf,
},
Status {
#[arg(long, default_value = ".")]
repo: PathBuf,
},
}
#[derive(Debug, Serialize)]
pub struct KgStatusReport {
pub db_hash: String,
pub ndjson_hash: String,
pub clean: bool,
}
#[derive(Debug, Serialize)]
pub struct ValidationReport {
pub rules: Vec<RuleResult>,
pub summary: ValidationSummary,
}
#[derive(Debug, Serialize)]
pub struct RuleResult {
pub id: String,
pub severity: &'static str,
pub passed: bool,
pub violations: Vec<Violation>,
}
#[derive(Debug, Serialize)]
pub struct Violation {
pub entity_id: Option<String>,
pub entity_name: Option<String>,
pub entity_kind: Option<String>,
pub rule_id: String,
pub severity: &'static str,
pub message: String,
pub fixable: bool,
}
#[derive(Debug, Serialize)]
pub struct ValidationSummary {
pub errors: usize,
pub warnings: usize,
pub info: usize,
pub entities: usize,
pub edges: usize,
pub passed: bool,
}
pub async fn run_kg(cmd: KgCommand) -> Result<()> {
match cmd {
KgCommand::Validate(args) => cmd_validate(args),
KgCommand::Init(args) => cmd_init(args),
KgCommand::Fetch(args) => cmd_fetch(args).await,
KgCommand::Export(args) => cmd_export(args).await,
KgCommand::Import(args) => cmd_import(args).await,
KgCommand::Status(args) => cmd_status(args).await,
KgCommand::Hook(h) => cmd_hook(h),
}
}
fn is_safe_remote_name(s: &str) -> bool {
!s.is_empty()
&& s != "."
&& s != ".."
&& s.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
}
async fn cmd_fetch(args: FetchArgs) -> Result<()> {
if !is_safe_remote_name(&args.remote) {
bail!(
"invalid remote name {:?}: must be [A-Za-z0-9._-]+ and not . or ..",
args.remote
);
}
let pin = args
.pin
.as_deref()
.map(khive_vcs::SnapshotId::from_prefixed)
.transpose()
.context("invalid --pin")?;
let remote = crate::sync::RemoteConfig {
name: args.remote,
url: args.url,
git_ref: args.git_ref,
namespace: args.namespace,
pin,
};
let report = crate::sync::run_sync_remote(&args.repo, &remote, args.repin)
.await
.with_context(|| format!("fetch remote {:?}", remote.name))?;
let json = serde_json::to_string(&report).expect("serialize RemoteSyncReport");
println!("{json}");
Ok(())
}
async fn cmd_export(args: ExportArgs) -> Result<()> {
let ns = Namespace::parse(&args.namespace)?;
let db_canon = std::fs::canonicalize(&args.db).ok();
let out_canon = std::fs::canonicalize(&args.output).ok().or_else(|| {
args.output
.parent()
.and_then(|p| std::fs::canonicalize(p).ok())
.map(|p| p.join(args.output.file_name().unwrap_or_default()))
});
if args.output == args.db || (db_canon.is_some() && db_canon == out_canon) {
anyhow::bail!(
"refusing to export: --output {} resolves to the --db path {} (would overwrite the database)",
args.output.display(),
args.db.display(),
);
}
let config = RuntimeConfig {
db_path: Some(args.db.clone()),
default_namespace: ns.clone(),
embedding_model: None,
..Default::default()
};
let runtime = KhiveRuntime::new(config)?;
let token = runtime.authorize(ns)?;
let json = runtime
.export_kg_json(&token)
.await
.with_context(|| format!("export namespace {:?}", args.namespace))?;
if let Some(parent) = args.output.parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent)
.with_context(|| format!("create {}", parent.display()))?;
}
}
use std::io::Write as _;
let mut tmp_name = args.output.file_name().unwrap_or_default().to_os_string();
tmp_name.push(format!(".{}.inprogress", std::process::id()));
let tmp = args.output.with_file_name(tmp_name);
let mut f = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&tmp)
.with_context(|| format!("create temp {}", tmp.display()))?;
f.write_all(json.as_bytes())
.with_context(|| format!("write {}", tmp.display()))?;
f.sync_all().ok();
drop(f);
std::fs::rename(&tmp, &args.output)
.with_context(|| format!("finalize {}", args.output.display()))?;
Ok(())
}
async fn cmd_import(args: ImportArgs) -> Result<()> {
let ns = Namespace::parse(&args.namespace)?;
let config = RuntimeConfig {
db_path: Some(args.db.clone()),
default_namespace: ns.clone(),
embedding_model: None,
..Default::default()
};
let runtime = KhiveRuntime::new(config)?;
let token = runtime.authorize(ns)?;
let source = std::fs::read_to_string(&args.source)
.with_context(|| format!("read {}", args.source.display()))?;
let summary = match args.format {
ImportFormat::Archive => {
let archive: KgArchive = serde_json::from_str(&source)
.with_context(|| format!("parse archive {}", args.source.display()))?;
validate_archive_entity_kinds(&archive)?;
runtime
.import_kg(&archive, &token)
.await
.with_context(|| format!("import archive {}", args.source.display()))?
}
ImportFormat::Json | ImportFormat::Ndjson => {
let input = match args.format {
ImportFormat::Json => source,
ImportFormat::Ndjson => ndjson_to_json_array(&source)?,
ImportFormat::Archive => unreachable!(),
};
let mut adapter = JsonFormatAdapter::new(&input)
.with_context(|| format!("parse adapter input {}", args.source.display()))?;
if args.verbose {
for warning in adapter.warnings() {
eprintln!("warning: {warning}");
}
}
let entities: Vec<EntityRecord> = adapter
.entities()
.collect::<std::result::Result<Vec<_>, _>>()?;
let edges: Vec<EdgeRecord> = adapter
.edges()
.collect::<std::result::Result<Vec<_>, _>>()?;
let archive = adapter_records_to_archive(&args.namespace, entities, edges)?;
runtime
.import_kg(&archive, &token)
.await
.with_context(|| format!("import adapter records {}", args.source.display()))?
}
};
let json = serde_json::to_string(&summary).expect("serialize ImportSummary");
println!("{json}");
Ok(())
}
fn ndjson_to_json_array(source: &str) -> Result<String> {
let mut values = Vec::new();
for (idx, line) in source.lines().enumerate() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let value: serde_json::Value = serde_json::from_str(trimmed)
.with_context(|| format!("parse NDJSON line {}", idx + 1))?;
values.push(value);
}
serde_json::to_string(&values).context("serialize NDJSON records as JSON array")
}
fn adapter_records_to_archive(
namespace: &str,
entities: Vec<EntityRecord>,
edges: Vec<EdgeRecord>,
) -> Result<KgArchive> {
let now = Utc::now();
let entity_ids: HashSet<Uuid> = entities.iter().map(|e| e.id).collect();
let exported_entities: Vec<ExportedEntity> = entities
.into_iter()
.map(|e| {
let _: EntityKind = e.kind.parse().map_err(|_| {
anyhow::anyhow!("unknown entity kind {:?} on entity {}", e.kind, e.id)
})?;
Ok(ExportedEntity {
id: e.id,
kind: e.kind,
entity_type: None,
name: e.name,
description: e.description,
properties: if e.properties.is_null() {
None
} else {
Some(e.properties)
},
tags: e.tags,
created_at: now,
updated_at: now,
})
})
.collect::<Result<Vec<_>>>()?;
let exported_edges = edges
.into_iter()
.map(|edge| adapter_edge_to_exported(edge, &entity_ids))
.collect::<Result<Vec<_>>>()?;
Ok(KgArchive {
format: "khive-kg".to_string(),
version: "0.1".to_string(),
namespace: namespace.to_string(),
exported_at: now,
entities: exported_entities,
edges: exported_edges,
})
}
fn validate_archive_entity_kinds(archive: &KgArchive) -> Result<()> {
for e in &archive.entities {
let _: EntityKind = e
.kind
.parse()
.map_err(|_| anyhow::anyhow!("unknown entity kind {:?} on entity {}", e.kind, e.id))?;
}
Ok(())
}
fn adapter_edge_to_exported(edge: EdgeRecord, entity_ids: &HashSet<Uuid>) -> Result<ExportedEdge> {
let source = edge
.source
.parse::<Uuid>()
.with_context(|| format!("edge {} source must be a UUID", edge.edge_id))?;
let target = edge
.target
.parse::<Uuid>()
.with_context(|| format!("edge {} target must be a UUID", edge.edge_id))?;
if !entity_ids.contains(&source) {
bail!(
"edge {} source {} is not present in adapter entities",
edge.edge_id,
source
);
}
if !entity_ids.contains(&target) {
bail!(
"edge {} target {} is not present in adapter entities",
edge.edge_id,
target
);
}
let relation: EdgeRelation = edge
.relation
.parse()
.with_context(|| format!("edge {} invalid relation {:?}", edge.edge_id, edge.relation))?;
Ok(ExportedEdge {
edge_id: edge.edge_id,
source,
target,
relation,
weight: edge.weight,
})
}
async fn cmd_status(args: StatusArgs) -> Result<()> {
let ns = Namespace::parse(&args.namespace)?;
let config = RuntimeConfig {
db_path: Some(args.db.clone()),
default_namespace: ns.clone(),
embedding_model: None,
..Default::default()
};
let runtime = KhiveRuntime::new(config)?;
let token = runtime.authorize(ns)?;
let db_archive = runtime.export_kg(&token).await?;
let db_hash = khive_vcs::hash::snapshot_id_for_archive(&db_archive)
.context("hash DB archive")?
.as_str()
.to_string();
let ndjson_archive = archive_from_ndjson_repo(&args.repo, &args.namespace)?;
let ndjson_hash = khive_vcs::hash::snapshot_id_for_archive(&ndjson_archive)
.context("hash NDJSON archive")?
.as_str()
.to_string();
let report = KgStatusReport {
clean: db_hash == ndjson_hash,
db_hash,
ndjson_hash,
};
let json = serde_json::to_string(&report).expect("serialize KgStatusReport");
println!("{json}");
Ok(())
}
#[derive(Debug, Deserialize)]
struct StatusNdjsonEntity {
id: Uuid,
kind: String,
name: String,
#[serde(default)]
description: Option<String>,
#[serde(default)]
properties: Option<serde_json::Value>,
#[serde(default)]
tags: Vec<String>,
#[serde(default)]
created_at: Option<String>,
#[serde(default)]
updated_at: Option<String>,
}
#[derive(Debug, Deserialize)]
struct StatusNdjsonEdge {
edge_id: Uuid,
source: Uuid,
target: Uuid,
relation: String,
#[serde(default = "default_status_edge_weight")]
weight: f64,
}
fn default_status_edge_weight() -> f64 {
1.0
}
fn archive_from_ndjson_repo(repo: &Path, namespace: &str) -> Result<KgArchive> {
let kg_dir = repo.join(".khive/kg");
let entities =
read_ndjson_records::<StatusNdjsonEntity>(&kg_dir.join("entities.ndjson"), "entity")?;
let edges = read_ndjson_records::<StatusNdjsonEdge>(&kg_dir.join("edges.ndjson"), "edge")?;
let now = Utc::now();
let exported_entities = entities
.into_iter()
.map(|e| ExportedEntity {
id: e.id,
kind: e.kind,
entity_type: None,
name: e.name,
description: e.description,
properties: e.properties,
tags: e.tags,
created_at: parse_status_dt(e.created_at.as_deref(), now),
updated_at: parse_status_dt(e.updated_at.as_deref(), now),
})
.collect();
let exported_edges = edges
.into_iter()
.map(|edge| {
let relation: EdgeRelation = edge
.relation
.parse()
.with_context(|| format!("invalid relation {:?}", edge.relation))?;
Ok(ExportedEdge {
edge_id: edge.edge_id,
source: edge.source,
target: edge.target,
relation,
weight: edge.weight,
})
})
.collect::<Result<Vec<_>>>()?;
Ok(KgArchive {
format: "khive-kg".to_string(),
version: "0.1".to_string(),
namespace: namespace.to_string(),
exported_at: now,
entities: exported_entities,
edges: exported_edges,
})
}
fn read_ndjson_records<T>(path: &Path, label: &str) -> Result<Vec<T>>
where
T: for<'de> Deserialize<'de>,
{
if !path.exists() {
return Ok(Vec::new());
}
let text = std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
let mut records = Vec::new();
for (idx, line) in text.lines().enumerate() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let record = serde_json::from_str(trimmed)
.with_context(|| format!("parse {label} at {}:{}", path.display(), idx + 1))?;
records.push(record);
}
Ok(records)
}
fn parse_status_dt(value: Option<&str>, fallback: chrono::DateTime<Utc>) -> chrono::DateTime<Utc> {
value
.and_then(|raw| chrono::DateTime::parse_from_rfc3339(raw).ok())
.map(|dt| dt.with_timezone(&Utc))
.unwrap_or(fallback)
}
fn cmd_validate(args: ValidateArgs) -> Result<()> {
let kg_dir = args.repo.join(".khive/kg");
if !kg_dir.exists() {
bail!(
"KG directory not found: {}. Run `kkernel kg init` first.",
kg_dir.display()
);
}
let entities_path = kg_dir.join("entities.ndjson");
let edges_path = kg_dir.join("edges.ndjson");
let entities = count_ndjson_lines(&entities_path).unwrap_or(0);
let edges = count_ndjson_lines(&edges_path).unwrap_or(0);
let rules_path = args.rules.unwrap_or_else(|| kg_dir.join("rules.toml"));
let mut rule_results: Vec<RuleResult> = structural_checks(&entities_path, &edges_path);
if !args.no_rules && rules_path.exists() {
let configurable = configurable_rule_checks(&entities_path, &edges_path, &rules_path)?;
rule_results.extend(configurable);
}
let errors: usize = rule_results
.iter()
.filter(|r| r.severity == "error" && !r.passed)
.count();
let warnings: usize = rule_results
.iter()
.filter(|r| r.severity == "warning" && !r.passed)
.count();
let info: usize = rule_results
.iter()
.filter(|r| r.severity == "info" && !r.passed)
.count();
let passed = if args.strict {
errors == 0 && warnings == 0
} else {
errors == 0
};
let summary = ValidationSummary {
errors,
warnings,
info,
entities,
edges,
passed,
};
let report = ValidationReport {
rules: rule_results,
summary,
};
match args.format {
OutputFormat::Json => {
let json = serde_json::to_string_pretty(&report).expect("serialize ValidationReport");
println!("{json}");
}
OutputFormat::Github => print_github_format(&report),
OutputFormat::Text => print_text_format(&report, args.verbose, args.quiet),
}
if args.fix {
apply_fixes(&args.repo)?;
}
if !report.summary.passed {
std::process::exit(1);
}
Ok(())
}
fn count_ndjson_lines(path: &Path) -> Option<usize> {
let content = std::fs::read_to_string(path).ok()?;
Some(content.lines().filter(|l| !l.trim().is_empty()).count())
}
fn structural_checks(entities_path: &Path, edges_path: &Path) -> Vec<RuleResult> {
vec![
check_no_duplicate_uuids(entities_path),
check_sort_order(entities_path, edges_path),
check_referential_integrity(entities_path, edges_path),
]
}
fn check_no_duplicate_uuids(entities_path: &Path) -> RuleResult {
let mut seen = std::collections::HashSet::new();
let mut violations = Vec::new();
if let Ok(content) = std::fs::read_to_string(entities_path) {
for line in content.lines().filter(|l| !l.trim().is_empty()) {
if let Ok(v) = serde_json::from_str::<serde_json::Value>(line) {
if let Some(id) = v.get("id").and_then(|i| i.as_str()) {
if !seen.insert(id.to_string()) {
violations.push(Violation {
entity_id: Some(id.to_string()),
entity_name: v.get("name").and_then(|n| n.as_str()).map(str::to_string),
entity_kind: v.get("kind").and_then(|k| k.as_str()).map(str::to_string),
rule_id: "no-duplicate-uuids".into(),
severity: "error",
message: format!("Duplicate UUID: {id}"),
fixable: false,
});
}
}
}
}
}
RuleResult {
id: "no-duplicate-uuids".into(),
severity: "error",
passed: violations.is_empty(),
violations,
}
}
fn check_sort_order(entities_path: &Path, edges_path: &Path) -> RuleResult {
let mut violations = Vec::new();
if let Ok(content) = std::fs::read_to_string(entities_path) {
let ids: Vec<String> = content
.lines()
.filter(|l| !l.trim().is_empty())
.filter_map(|l| {
serde_json::from_str::<serde_json::Value>(l)
.ok()
.and_then(|v| v.get("id")?.as_str().map(str::to_string))
})
.collect();
let mut sorted = ids.clone();
sorted.sort();
if ids != sorted {
violations.push(Violation {
entity_id: None,
entity_name: None,
entity_kind: None,
rule_id: "sort-order".into(),
severity: "warning",
message: "entities.ndjson is not sorted by UUID; run `kkernel kg validate --fix`"
.into(),
fixable: true,
});
}
}
if let Ok(content) = std::fs::read_to_string(edges_path) {
let keys: Vec<(String, String, String)> = content
.lines()
.filter(|l| !l.trim().is_empty())
.filter_map(|l| {
let v: serde_json::Value = serde_json::from_str(l).ok()?;
let s = v.get("source_id")?.as_str()?.to_string();
let t = v.get("target_id")?.as_str()?.to_string();
let r = v.get("relation")?.as_str()?.to_string();
Some((s, t, r))
})
.collect();
let mut sorted = keys.clone();
sorted.sort();
if keys != sorted {
violations.push(Violation {
entity_id: None,
entity_name: None,
entity_kind: None,
rule_id: "sort-order".into(),
severity: "warning",
message:
"edges.ndjson is not sorted by (source, target, relation); run `kkernel kg validate --fix`"
.into(),
fixable: true,
});
}
}
RuleResult {
id: "sort-order".into(),
severity: "warning",
passed: violations.is_empty(),
violations,
}
}
fn check_referential_integrity(entities_path: &Path, edges_path: &Path) -> RuleResult {
let mut violations = Vec::new();
let entity_ids: std::collections::HashSet<String> =
if let Ok(content) = std::fs::read_to_string(entities_path) {
content
.lines()
.filter(|l| !l.trim().is_empty())
.filter_map(|l| {
serde_json::from_str::<serde_json::Value>(l)
.ok()
.and_then(|v| v.get("id")?.as_str().map(str::to_string))
})
.collect()
} else {
std::collections::HashSet::new()
};
if let Ok(content) = std::fs::read_to_string(edges_path) {
for line in content.lines().filter(|l| !l.trim().is_empty()) {
if let Ok(v) = serde_json::from_str::<serde_json::Value>(line) {
for field in &["source_id", "target_id"] {
if let Some(id) = v.get(field).and_then(|i| i.as_str()) {
if !entity_ids.contains(id) {
violations.push(Violation {
entity_id: Some(id.to_string()),
entity_name: None,
entity_kind: None,
rule_id: "referential-integrity".into(),
severity: "error",
message: format!(
"Edge {} references unknown entity: {id}",
if *field == "source_id" {
"source"
} else {
"target"
}
),
fixable: false,
});
}
}
}
}
}
}
RuleResult {
id: "referential-integrity".into(),
severity: "error",
passed: violations.is_empty(),
violations,
}
}
#[derive(Debug, Deserialize)]
struct RuleConfig {
id: String,
#[serde(default = "default_severity")]
severity: String,
kind: String,
condition: Option<String>,
require_field: Option<String>,
#[serde(default)]
message: String,
}
fn default_severity() -> String {
"warning".to_owned()
}
#[derive(Debug, Deserialize)]
struct RulesFile {
#[serde(default)]
rules: Vec<RuleConfig>,
}
fn severity_static(s: &str) -> &'static str {
match s {
"error" => "error",
"info" => "info",
_ => "warning",
}
}
fn configurable_rule_checks(
entities_path: &Path,
edges_path: &Path,
rules_path: &Path,
) -> Result<Vec<RuleResult>> {
let ext = rules_path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("");
if matches!(ext, "yaml" | "yml") {
bail!(
"rules file {:?} uses YAML format which is not supported in this build. \
Rename it to {}.toml and use TOML format instead.",
rules_path,
rules_path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("rules")
);
}
let content = std::fs::read_to_string(rules_path)
.with_context(|| format!("read rules file {}", rules_path.display()))?;
let rules_file: RulesFile = toml::from_str(&content)
.with_context(|| format!("parse rules TOML {}", rules_path.display()))?;
let mut results = Vec::with_capacity(rules_file.rules.len());
for rule in &rules_file.rules {
let path = match rule.kind.as_str() {
"entity" => entities_path,
"edge" => edges_path,
other => {
results.push(RuleResult {
id: rule.id.clone(),
severity: "error",
passed: false,
violations: vec![Violation {
entity_id: None,
entity_name: None,
entity_kind: None,
rule_id: rule.id.clone(),
severity: "error",
message: format!(
"Rule {:?}: unknown kind {other:?}; must be \"entity\" or \"edge\"",
rule.id
),
fixable: false,
}],
});
continue;
}
};
let violations = evaluate_rule(rule, path);
let sev = severity_static(&rule.severity);
results.push(RuleResult {
id: rule.id.clone(),
severity: sev,
passed: violations.is_empty(),
violations,
});
}
Ok(results)
}
fn evaluate_rule(rule: &RuleConfig, path: &Path) -> Vec<Violation> {
let content = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(_) => return vec![],
};
let condition: Option<(&str, &str)> = rule.condition.as_deref().and_then(|c| c.split_once('='));
let sev = severity_static(&rule.severity);
let mut violations = Vec::new();
for line in content.lines().filter(|l| !l.trim().is_empty()) {
let v: serde_json::Value = match serde_json::from_str(line) {
Ok(val) => val,
Err(_) => continue,
};
if let Some((field, expected)) = condition {
if field == "source_id" && expected == "target_id" {
let src = v.get("source_id").and_then(|s| s.as_str()).unwrap_or("");
let tgt = v.get("target_id").and_then(|s| s.as_str()).unwrap_or("");
if src == tgt {
violations.push(Violation {
entity_id: Some(src.to_owned()),
entity_name: None,
entity_kind: v
.get("relation")
.and_then(|r| r.as_str())
.map(str::to_owned),
rule_id: rule.id.clone(),
severity: sev,
message: rule.message.replace("{id}", src),
fixable: false,
});
}
continue;
}
let actual = v.get(field).and_then(|f| f.as_str()).unwrap_or("");
if actual != expected {
continue;
}
}
if let Some(req) = rule.require_field.as_deref() {
let val = v.get(req).and_then(|f| f.as_str()).unwrap_or("");
if val.is_empty() {
let id = v.get("id").and_then(|i| i.as_str()).unwrap_or("");
violations.push(Violation {
entity_id: if id.is_empty() {
None
} else {
Some(id.to_owned())
},
entity_name: v.get("name").and_then(|n| n.as_str()).map(str::to_owned),
entity_kind: v.get("kind").and_then(|k| k.as_str()).map(str::to_owned),
rule_id: rule.id.clone(),
severity: sev,
message: rule.message.replace("{id}", id),
fixable: false,
});
}
}
}
violations
}
fn apply_fixes(repo: &Path) -> Result<()> {
let kg_dir = repo.join(".khive/kg");
fix_sort_order(&kg_dir.join("entities.ndjson"), "id")?;
fix_sort_order_edges(&kg_dir.join("edges.ndjson"))?;
eprintln!("~ sort-order: applied fix to entities.ndjson and edges.ndjson");
Ok(())
}
fn fix_sort_order(path: &Path, sort_key: &str) -> Result<()> {
if !path.exists() {
return Ok(());
}
let content =
std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
let mut lines: Vec<serde_json::Value> = content
.lines()
.filter(|l| !l.trim().is_empty())
.filter_map(|l| serde_json::from_str(l).ok())
.collect();
lines.sort_by(|a, b| {
let ak = a.get(sort_key).and_then(|v| v.as_str()).unwrap_or("");
let bk = b.get(sort_key).and_then(|v| v.as_str()).unwrap_or("");
ak.cmp(bk)
});
let out: String = lines
.iter()
.map(|v| serde_json::to_string(v).unwrap())
.collect::<Vec<_>>()
.join("\n");
std::fs::write(path, out + "\n").with_context(|| format!("write {}", path.display()))
}
fn fix_sort_order_edges(path: &Path) -> Result<()> {
if !path.exists() {
return Ok(());
}
let content =
std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
let mut lines: Vec<serde_json::Value> = content
.lines()
.filter(|l| !l.trim().is_empty())
.filter_map(|l| serde_json::from_str(l).ok())
.collect();
lines.sort_by(|a, b| {
let ak = (
a.get("source_id").and_then(|v| v.as_str()).unwrap_or(""),
a.get("target_id").and_then(|v| v.as_str()).unwrap_or(""),
a.get("relation").and_then(|v| v.as_str()).unwrap_or(""),
);
let bk = (
b.get("source_id").and_then(|v| v.as_str()).unwrap_or(""),
b.get("target_id").and_then(|v| v.as_str()).unwrap_or(""),
b.get("relation").and_then(|v| v.as_str()).unwrap_or(""),
);
ak.cmp(&bk)
});
let out: String = lines
.iter()
.map(|v| serde_json::to_string(v).unwrap())
.collect::<Vec<_>>()
.join("\n");
std::fs::write(path, out + "\n").with_context(|| format!("write {}", path.display()))
}
fn print_text_format(report: &ValidationReport, verbose: bool, quiet: bool) {
if !quiet {
for r in &report.rules {
let symbol = if r.passed {
"\u{2713}"
} else if r.severity == "error" {
"\u{2717}"
} else {
"\u{26a0}"
};
if r.violations.is_empty() {
println!(" {symbol} {}", r.id);
} else {
println!(" {symbol} {}: {} violation(s)", r.id, r.violations.len());
let shown = if verbose {
r.violations.len()
} else {
2.min(r.violations.len())
};
for v in &r.violations[..shown] {
println!(" - {}", v.message);
}
if !verbose && r.violations.len() > 2 {
println!(" + {} more (run with --verbose)", r.violations.len() - 2);
}
}
}
}
let s = &report.summary;
println!(
"\nSummary: {} error(s), {} warning(s), {} entities, {} edges",
s.errors, s.warnings, s.entities, s.edges
);
}
fn print_github_format(report: &ValidationReport) {
for r in &report.rules {
for v in &r.violations {
let level = if r.severity == "error" {
"error"
} else {
"warning"
};
println!("::{level} ::{}", v.message);
}
}
}
const DEFAULT_KHIVE_TOML: &str = r#"# .khive/khive.toml — project KG configuration (ADR-035)
# Committed to git. All collaborators use these settings.
[[backends]]
name = "main"
path = "~/.khive/khive.db"
cache_mb = 256
journal_mode = "wal"
[[engines]]
name = "mE5-small"
dim = 384
weight = 1.0
[packs.kg]
backend = "main"
engines = ["mE5-small"]
[packs.memory]
backend = "main"
engines = ["mE5-small"]
[packs.gtd]
backend = "main"
engines = []
[embed]
model = "mE5-small"
dimensions = 384
auto_embed = true
batch_size = 64
[embed.fields]
include = ["name", "description"]
[schema]
strict = true
"#;
const GITIGNORE_CONTENT: &str = "*\n!.gitignore\n!kg/\n!kg/**\n!khive.toml\n";
const PRE_COMMIT_HOOK: &str = r#"#!/usr/bin/env bash
# .khive/kg/hooks/pre-commit
# Generated by kkernel kg init.
# Runs KG validation on staged NDJSON files.
# Bypass with: git commit --no-verify
set -euo pipefail
staged=$(git diff --cached --name-only \
| grep -E '^\.khive/kg/(entities|edges)\.ndjson$' || true)
if [ -z "$staged" ]; then
exit 0
fi
kkernel kg validate
"#;
const CI_WORKFLOW: &str = r#"name: KG Validate
on:
push:
paths: [".khive/kg/**"]
pull_request:
paths: [".khive/kg/**"]
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Validate KG
run: kkernel kg validate --format github
"#;
fn cmd_init(args: InitArgs) -> Result<()> {
if args.add_hooks {
return hook_install(&args.repo);
}
let khive_dir = args.repo.join(".khive");
let kg_dir = khive_dir.join("kg");
let hooks_dir = kg_dir.join("hooks");
std::fs::create_dir_all(&kg_dir).with_context(|| format!("create {}", kg_dir.display()))?;
std::fs::create_dir_all(&hooks_dir)
.with_context(|| format!("create {}", hooks_dir.display()))?;
for name in &["entities.ndjson", "edges.ndjson"] {
let path = kg_dir.join(name);
if !path.exists() {
std::fs::write(&path, "").with_context(|| format!("create {}", path.display()))?;
}
}
let gitignore = khive_dir.join(".gitignore");
if !gitignore.exists() {
std::fs::write(&gitignore, GITIGNORE_CONTENT)
.with_context(|| format!("write {}", gitignore.display()))?;
}
let toml_path = khive_dir.join("khive.toml");
if !toml_path.exists() {
std::fs::write(&toml_path, DEFAULT_KHIVE_TOML)
.with_context(|| format!("write {}", toml_path.display()))?;
println!(" Initialized {}", toml_path.display());
} else {
println!(" Skipped {} (already exists)", toml_path.display());
}
let hook_script = hooks_dir.join("pre-commit");
if !hook_script.exists() {
std::fs::write(&hook_script, PRE_COMMIT_HOOK)
.with_context(|| format!("write {}", hook_script.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(&hook_script)?.permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&hook_script, perms)?;
}
}
println!(" Initialized .khive/kg/ (entities.ndjson, edges.ndjson, hooks/pre-commit)");
if args.ci {
let workflow_dir = args.repo.join(".github/workflows");
std::fs::create_dir_all(&workflow_dir)
.with_context(|| format!("create {}", workflow_dir.display()))?;
let workflow_path = workflow_dir.join("kg-validate.yml");
if !workflow_path.exists() {
std::fs::write(&workflow_path, CI_WORKFLOW)
.with_context(|| format!("write {}", workflow_path.display()))?;
println!(" Generated {}", workflow_path.display());
}
}
Ok(())
}
#[derive(Debug, Serialize)]
pub struct HookStatus {
pub symlink_exists: bool,
pub symlink_target: Option<String>,
pub target_valid: bool,
}
fn cmd_hook(cmd: HookCommand) -> Result<()> {
match cmd {
HookCommand::Install { repo } => hook_install(&repo),
HookCommand::Uninstall { repo } => hook_uninstall(&repo),
HookCommand::Status { repo } => hook_status(&repo),
}
}
fn hook_install(repo: &Path) -> Result<()> {
let hook_script = repo.join(".khive/kg/hooks/pre-commit");
let git_hook = repo.join(".git/hooks/pre-commit");
if !hook_script.exists() {
bail!(
"Hook script not found: {}. Run `kkernel kg init` first.",
hook_script.display()
);
}
if let Some(parent) = git_hook.parent() {
std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
}
if git_hook.exists() || git_hook.is_symlink() {
std::fs::remove_file(&git_hook)
.with_context(|| format!("remove existing {}", git_hook.display()))?;
}
#[cfg(unix)]
{
use std::os::unix::fs::symlink;
let absolute_script = hook_script
.canonicalize()
.unwrap_or_else(|_| hook_script.clone());
symlink(&absolute_script, &git_hook)
.with_context(|| format!("create symlink {}", git_hook.display()))?;
}
#[cfg(not(unix))]
{
std::fs::copy(&hook_script, &git_hook)
.with_context(|| format!("copy hook to {}", git_hook.display()))?;
}
println!(
" Installed: {} -> {}",
git_hook.display(),
hook_script.display()
);
Ok(())
}
fn hook_uninstall(repo: &Path) -> Result<()> {
let git_hook = repo.join(".git/hooks/pre-commit");
if git_hook.exists() || git_hook.is_symlink() {
std::fs::remove_file(&git_hook)
.with_context(|| format!("remove {}", git_hook.display()))?;
println!(" Uninstalled: {}", git_hook.display());
} else {
println!(" No hook installed at {}", git_hook.display());
}
Ok(())
}
fn hook_status(repo: &Path) -> Result<()> {
let git_hook = repo.join(".git/hooks/pre-commit");
let symlink_exists = git_hook.exists() || git_hook.is_symlink();
let symlink_target = if symlink_exists {
std::fs::read_link(&git_hook)
.ok()
.map(|p| p.display().to_string())
} else {
None
};
let target_valid = symlink_target
.as_deref()
.map(|t| Path::new(t).exists())
.unwrap_or(false);
let status = HookStatus {
symlink_exists,
symlink_target,
target_valid,
};
let json = serde_json::to_string(&status).expect("serialize HookStatus");
println!("{json}");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn make_kg_dir(tmp: &TempDir) -> PathBuf {
let kg_dir = tmp.path().join(".khive/kg");
std::fs::create_dir_all(&kg_dir).unwrap();
kg_dir
}
fn write_entities(kg_dir: &Path, entities: &[(&str, &str, &str)]) {
let content: String = entities
.iter()
.map(|(id, kind, name)| format!(r#"{{"id":"{id}","kind":"{kind}","name":"{name}"}}"#))
.collect::<Vec<_>>()
.join("\n");
std::fs::write(kg_dir.join("entities.ndjson"), content + "\n").unwrap();
}
fn write_edges(kg_dir: &Path, edges: &[(&str, &str, &str)]) {
let content: String = edges
.iter()
.map(|(src, tgt, rel)| {
format!(r#"{{"source_id":"{src}","target_id":"{tgt}","relation":"{rel}"}}"#)
})
.collect::<Vec<_>>()
.join("\n");
std::fs::write(kg_dir.join("edges.ndjson"), content + "\n").unwrap();
}
#[test]
fn duplicate_uuid_detected() {
let tmp = TempDir::new().unwrap();
let kg_dir = make_kg_dir(&tmp);
write_entities(
&kg_dir,
&[
("aaaaaaaa-0000-0000-0000-000000000001", "concept", "A"),
("aaaaaaaa-0000-0000-0000-000000000001", "concept", "A-dup"),
],
);
let result = check_no_duplicate_uuids(&kg_dir.join("entities.ndjson"));
assert!(!result.passed, "duplicate UUID should fail");
assert_eq!(result.violations.len(), 1);
}
#[test]
fn no_duplicates_passes() {
let tmp = TempDir::new().unwrap();
let kg_dir = make_kg_dir(&tmp);
write_entities(
&kg_dir,
&[
("aaaaaaaa-0000-0000-0000-000000000001", "concept", "A"),
("bbbbbbbb-0000-0000-0000-000000000002", "concept", "B"),
],
);
let result = check_no_duplicate_uuids(&kg_dir.join("entities.ndjson"));
assert!(result.passed);
}
#[test]
fn referential_integrity_catches_missing_target() {
let tmp = TempDir::new().unwrap();
let kg_dir = make_kg_dir(&tmp);
write_entities(
&kg_dir,
&[("aaaaaaaa-0000-0000-0000-000000000001", "concept", "A")],
);
write_edges(
&kg_dir,
&[(
"aaaaaaaa-0000-0000-0000-000000000001",
"bbbbbbbb-0000-0000-0000-000000000002",
"extends",
)],
);
let result = check_referential_integrity(
&kg_dir.join("entities.ndjson"),
&kg_dir.join("edges.ndjson"),
);
assert!(!result.passed);
assert_eq!(result.violations.len(), 1);
}
#[test]
fn init_creates_expected_files() {
let tmp = TempDir::new().unwrap();
let args = InitArgs {
repo: tmp.path().to_path_buf(),
ci: false,
add_hooks: false,
};
cmd_init(args).unwrap();
assert!(tmp.path().join(".khive/kg/entities.ndjson").exists());
assert!(tmp.path().join(".khive/kg/edges.ndjson").exists());
assert!(tmp.path().join(".khive/khive.toml").exists());
assert!(tmp.path().join(".khive/kg/hooks/pre-commit").exists());
}
#[test]
fn init_does_not_overwrite_existing_toml() {
let tmp = TempDir::new().unwrap();
std::fs::create_dir_all(tmp.path().join(".khive")).unwrap();
let toml_path = tmp.path().join(".khive/khive.toml");
std::fs::write(&toml_path, "# custom\n").unwrap();
let args = InitArgs {
repo: tmp.path().to_path_buf(),
ci: false,
add_hooks: false,
};
cmd_init(args).unwrap();
let content = std::fs::read_to_string(&toml_path).unwrap();
assert_eq!(content, "# custom\n", "should not overwrite existing toml");
}
#[test]
fn configurable_rule_checks_empty_rules_file_returns_no_results() {
let tmp = TempDir::new().unwrap();
let kg_dir = make_kg_dir(&tmp);
write_entities(
&kg_dir,
&[("aaaaaaaa-0000-0000-0000-000000000001", "concept", "A")],
);
std::fs::write(kg_dir.join("edges.ndjson"), "").unwrap();
let rules_path = tmp.path().join("rules.toml");
std::fs::write(&rules_path, "rules = []\n").unwrap();
let results = configurable_rule_checks(
&kg_dir.join("entities.ndjson"),
&kg_dir.join("edges.ndjson"),
&rules_path,
)
.unwrap();
assert!(results.is_empty(), "no rules → no results");
}
#[test]
fn configurable_rule_checks_require_field_detects_missing_description() {
let tmp = TempDir::new().unwrap();
let kg_dir = make_kg_dir(&tmp);
let entities = r#"{"id":"aaa1","kind":"concept","name":"A","description":"has one"}
{"id":"aaa2","kind":"concept","name":"B"}
"#;
std::fs::write(kg_dir.join("entities.ndjson"), entities).unwrap();
std::fs::write(kg_dir.join("edges.ndjson"), "").unwrap();
let rules_toml = r#"
[[rules]]
id = "concept-must-have-description"
severity = "warning"
kind = "entity"
condition = "kind=concept"
require_field = "description"
message = "Concept {id} missing description"
"#;
let rules_path = tmp.path().join("rules.toml");
std::fs::write(&rules_path, rules_toml).unwrap();
let results = configurable_rule_checks(
&kg_dir.join("entities.ndjson"),
&kg_dir.join("edges.ndjson"),
&rules_path,
)
.unwrap();
assert_eq!(results.len(), 1);
let r = &results[0];
assert_eq!(r.id, "concept-must-have-description");
assert!(
!r.passed,
"rule should fail when a concept lacks description"
);
assert_eq!(r.violations.len(), 1);
assert_eq!(r.violations[0].entity_id.as_deref(), Some("aaa2"));
}
#[test]
fn configurable_rule_checks_self_loop_sentinel_detects_loop() {
let tmp = TempDir::new().unwrap();
let kg_dir = make_kg_dir(&tmp);
write_entities(
&kg_dir,
&[
("aaaaaaaa-0000-0000-0000-000000000001", "concept", "A"),
("bbbbbbbb-0000-0000-0000-000000000002", "concept", "B"),
],
);
let edges = r#"{"source_id":"aaaaaaaa-0000-0000-0000-000000000001","target_id":"aaaaaaaa-0000-0000-0000-000000000001","relation":"extends"}
{"source_id":"aaaaaaaa-0000-0000-0000-000000000001","target_id":"bbbbbbbb-0000-0000-0000-000000000002","relation":"extends"}
"#;
std::fs::write(kg_dir.join("edges.ndjson"), edges).unwrap();
let rules_toml = r#"
[[rules]]
id = "no-self-loops"
severity = "error"
kind = "edge"
condition = "source_id=target_id"
message = "Self-loop detected on {id}"
"#;
let rules_path = tmp.path().join("rules.toml");
std::fs::write(&rules_path, rules_toml).unwrap();
let results = configurable_rule_checks(
&kg_dir.join("entities.ndjson"),
&kg_dir.join("edges.ndjson"),
&rules_path,
)
.unwrap();
assert_eq!(results.len(), 1);
let r = &results[0];
assert!(!r.passed);
assert_eq!(r.violations.len(), 1, "exactly one self-loop");
}
#[test]
fn configurable_rule_checks_yaml_extension_returns_error() {
let tmp = TempDir::new().unwrap();
let kg_dir = make_kg_dir(&tmp);
write_entities(
&kg_dir,
&[("aaaaaaaa-0000-0000-0000-000000000001", "concept", "A")],
);
std::fs::write(kg_dir.join("edges.ndjson"), "").unwrap();
let rules_path = tmp.path().join("rules.yaml");
std::fs::write(&rules_path, "rules: []\n").unwrap();
let result = configurable_rule_checks(
&kg_dir.join("entities.ndjson"),
&kg_dir.join("edges.ndjson"),
&rules_path,
);
assert!(result.is_err(), "YAML extension must return an error");
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("YAML") || msg.contains("toml"),
"error message should mention TOML: {msg}"
);
}
#[test]
fn configurable_rule_checks_unknown_kind_produces_error_result() {
let tmp = TempDir::new().unwrap();
let kg_dir = make_kg_dir(&tmp);
write_entities(
&kg_dir,
&[("aaaaaaaa-0000-0000-0000-000000000001", "concept", "A")],
);
std::fs::write(kg_dir.join("edges.ndjson"), "").unwrap();
let rules_toml = r#"
[[rules]]
id = "bad-kind"
severity = "error"
kind = "note"
condition = "kind=concept"
require_field = "description"
message = "bad"
"#;
let rules_path = tmp.path().join("rules.toml");
std::fs::write(&rules_path, rules_toml).unwrap();
let results = configurable_rule_checks(
&kg_dir.join("entities.ndjson"),
&kg_dir.join("edges.ndjson"),
&rules_path,
)
.unwrap();
assert_eq!(results.len(), 1);
assert!(!results[0].passed);
assert_eq!(results[0].severity, "error");
}
#[test]
fn sort_order_fix_sorts_entities() {
let tmp = TempDir::new().unwrap();
let kg_dir = make_kg_dir(&tmp);
write_entities(
&kg_dir,
&[
("cccccccc-0000-0000-0000-000000000003", "concept", "C"),
("aaaaaaaa-0000-0000-0000-000000000001", "concept", "A"),
("bbbbbbbb-0000-0000-0000-000000000002", "concept", "B"),
],
);
std::fs::write(kg_dir.join("edges.ndjson"), "").unwrap();
fix_sort_order(&kg_dir.join("entities.ndjson"), "id").unwrap();
let result = check_sort_order(
&kg_dir.join("entities.ndjson"),
&kg_dir.join("edges.ndjson"),
);
assert!(result.passed, "sort-order should pass after fix");
}
fn run_git(dir: &std::path::Path, args: &[&str]) {
let status = std::process::Command::new("git")
.args(args)
.current_dir(dir)
.status()
.unwrap_or_else(|e| panic!("git {} failed to spawn: {e}", args.join(" ")));
assert!(
status.success(),
"git {} exited with {}",
args.join(" "),
status
);
}
fn make_git_remote_for_kg(dir: &std::path::Path) -> String {
let kg_dir = dir.join(".khive/kg");
std::fs::create_dir_all(&kg_dir).unwrap();
let entity_id = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa";
let entities = format!(
r#"{{"id":"{entity_id}","kind":"concept","name":"RemoteEntity","properties":{{}},"tags":[]}}"#
);
std::fs::write(kg_dir.join("entities.ndjson"), &entities).unwrap();
std::fs::write(kg_dir.join("edges.ndjson"), "").unwrap();
run_git(dir, &["init", "-b", "main"]);
run_git(dir, &["config", "user.email", "test@example.com"]);
run_git(dir, &["config", "user.name", "Test"]);
run_git(dir, &["add", "-A"]);
run_git(dir, &["commit", "-m", "init"]);
dir.to_string_lossy().into_owned()
}
#[tokio::test]
async fn fetch_populates_temp_remote_cache() {
let remote_dir = TempDir::new().unwrap();
let repo_dir = TempDir::new().unwrap();
let remote_url = make_git_remote_for_kg(remote_dir.path());
let args = FetchArgs {
remote: "upstream".to_string(),
repo: repo_dir.path().to_path_buf(),
url: remote_url,
git_ref: "main".to_string(),
namespace: "remote-ns".to_string(),
pin: None,
repin: false,
};
cmd_fetch(args).await.unwrap();
let cache = repo_dir.path().join(".khive/kg/remotes/upstream");
assert!(
cache.join("entities.ndjson").exists(),
"entities.ndjson in cache"
);
assert!(cache.join("edges.ndjson").exists(), "edges.ndjson in cache");
assert!(cache.join("meta.json").exists(), "meta.json in cache");
}
#[tokio::test]
async fn export_creates_archive_json() {
let tmp = TempDir::new().unwrap();
let db_path = tmp.path().join("test.db");
let output_path = tmp.path().join("archive.json");
let ns = Namespace::parse("test-ns").unwrap();
let config = RuntimeConfig {
db_path: Some(db_path.clone()),
default_namespace: ns.clone(),
embedding_model: None,
..Default::default()
};
let runtime = KhiveRuntime::new(config).unwrap();
let token = runtime.authorize(ns).unwrap();
runtime
.create_entity(&token, "concept", None, "TestEntity", None, None, vec![])
.await
.unwrap();
let args = ExportArgs {
output: output_path.clone(),
db: db_path,
namespace: "test-ns".to_string(),
};
cmd_export(args).await.unwrap();
assert!(output_path.exists(), "output archive must exist");
let content = std::fs::read_to_string(&output_path).unwrap();
let archive: serde_json::Value = serde_json::from_str(&content).unwrap();
assert_eq!(archive["format"].as_str().unwrap(), "khive-kg");
let entities = archive["entities"].as_array().unwrap();
assert_eq!(entities.len(), 1, "one entity exported");
assert_eq!(entities[0]["name"].as_str().unwrap(), "TestEntity");
}
#[tokio::test]
#[cfg(unix)]
async fn export_refuses_symlinked_output_to_db() {
let tmp = TempDir::new().unwrap();
let db_path = tmp.path().join("working.db");
let ns = Namespace::parse("test-ns").unwrap();
let config = RuntimeConfig {
db_path: Some(db_path.clone()),
default_namespace: ns.clone(),
embedding_model: None,
..Default::default()
};
let runtime = KhiveRuntime::new(config).unwrap();
let token = runtime.authorize(ns).unwrap();
runtime
.create_entity(&token, "concept", None, "Keep", None, None, vec![])
.await
.unwrap();
drop(runtime);
let before = std::fs::read(&db_path).unwrap();
let link = tmp.path().join("archive.json");
std::os::unix::fs::symlink(&db_path, &link).unwrap();
let args = ExportArgs {
output: link,
db: db_path.clone(),
namespace: "test-ns".to_string(),
};
assert!(
cmd_export(args).await.is_err(),
"export through a symlink to the DB must be refused"
);
let after = std::fs::read(&db_path).unwrap();
assert_eq!(before, after, "source DB must be byte-for-byte unchanged");
}
#[tokio::test]
#[cfg(unix)]
async fn export_refuses_symlinked_temp_to_db() {
let tmp = TempDir::new().unwrap();
let db_path = tmp.path().join("working.db");
let ns = Namespace::parse("test-ns").unwrap();
let config = RuntimeConfig {
db_path: Some(db_path.clone()),
default_namespace: ns.clone(),
embedding_model: None,
..Default::default()
};
let runtime = KhiveRuntime::new(config).unwrap();
let token = runtime.authorize(ns).unwrap();
runtime
.create_entity(&token, "concept", None, "Keep", None, None, vec![])
.await
.unwrap();
drop(runtime);
let before = std::fs::read(&db_path).unwrap();
let out = tmp.path().join("archive.json");
let mut tmp_name = out.file_name().unwrap().to_os_string();
tmp_name.push(format!(".{}.inprogress", std::process::id()));
let temp_path = out.with_file_name(tmp_name);
std::os::unix::fs::symlink(&db_path, &temp_path).unwrap();
let args = ExportArgs {
output: out,
db: db_path.clone(),
namespace: "test-ns".to_string(),
};
assert!(
cmd_export(args).await.is_err(),
"export must refuse when the temp path is a symlink to the DB"
);
let after = std::fs::read(&db_path).unwrap();
assert_eq!(before, after, "source DB must be byte-for-byte unchanged");
}
#[tokio::test]
async fn import_archive_roundtrip() {
let tmp = TempDir::new().unwrap();
let db_path = tmp.path().join("import-test.db");
let entity_id = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb";
let archive_json = format!(
r#"{{"format":"khive-kg","version":"0.1","namespace":"test-ns","exported_at":"2026-01-01T00:00:00Z","entities":[{{"id":"{entity_id}","kind":"concept","name":"Imported","tags":[],"created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-01T00:00:00Z"}}],"edges":[]}}"#
);
let source_path = tmp.path().join("archive.json");
std::fs::write(&source_path, &archive_json).unwrap();
let args = ImportArgs {
source: source_path,
db: db_path.clone(),
namespace: "test-ns".to_string(),
format: ImportFormat::Archive,
verbose: false,
};
cmd_import(args).await.unwrap();
let ns = Namespace::parse("test-ns").unwrap();
let config = RuntimeConfig {
db_path: Some(db_path),
default_namespace: ns.clone(),
embedding_model: None,
..Default::default()
};
let rt2 = KhiveRuntime::new(config).unwrap();
let tok2 = rt2.authorize(ns).unwrap();
let entity_uuid: Uuid = entity_id.parse().unwrap();
let entity = rt2.get_entity(&tok2, entity_uuid).await.unwrap();
assert_eq!(entity.name, "Imported");
}
#[tokio::test]
async fn import_json_adapter_imports_entities() {
let tmp = TempDir::new().unwrap();
let db_path = tmp.path().join("adapter-json.db");
let e1_id = "cccccccc-cccc-cccc-cccc-cccccccccccc";
let e2_id = "dddddddd-dddd-dddd-dddd-dddddddddddd";
let json_input = format!(
r#"[{{"id":"{e1_id}","kind":"concept","name":"Entity1"}},{{"id":"{e2_id}","kind":"concept","name":"Entity2"}}]"#
);
let source_path = tmp.path().join("records.json");
std::fs::write(&source_path, &json_input).unwrap();
let args = ImportArgs {
source: source_path,
db: db_path.clone(),
namespace: "test-ns".to_string(),
format: ImportFormat::Json,
verbose: false,
};
cmd_import(args).await.unwrap();
let ns = Namespace::parse("test-ns").unwrap();
let config = RuntimeConfig {
db_path: Some(db_path),
default_namespace: ns.clone(),
embedding_model: None,
..Default::default()
};
let rt2 = KhiveRuntime::new(config).unwrap();
let tok2 = rt2.authorize(ns).unwrap();
let e1_uuid: Uuid = e1_id.parse().unwrap();
let entity = rt2.get_entity(&tok2, e1_uuid).await.unwrap();
assert_eq!(entity.name, "Entity1");
}
#[tokio::test]
async fn import_ndjson_adapter_imports_entity() {
let tmp = TempDir::new().unwrap();
let db_path = tmp.path().join("adapter-ndjson.db");
let entity_id = "eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee";
let ndjson_input =
format!(r#"{{"id":"{entity_id}","kind":"concept","name":"NdjsonEntity"}}"#);
let source_path = tmp.path().join("records.ndjson");
std::fs::write(&source_path, &ndjson_input).unwrap();
let args = ImportArgs {
source: source_path,
db: db_path.clone(),
namespace: "test-ns".to_string(),
format: ImportFormat::Ndjson,
verbose: false,
};
cmd_import(args).await.unwrap();
let ns = Namespace::parse("test-ns").unwrap();
let config = RuntimeConfig {
db_path: Some(db_path),
default_namespace: ns.clone(),
embedding_model: None,
..Default::default()
};
let rt2 = KhiveRuntime::new(config).unwrap();
let tok2 = rt2.authorize(ns).unwrap();
let entity_uuid: Uuid = entity_id.parse().unwrap();
let entity = rt2.get_entity(&tok2, entity_uuid).await.unwrap();
assert_eq!(entity.name, "NdjsonEntity");
}
#[tokio::test]
async fn status_hashes_clean_after_sync() {
let tmp = TempDir::new().unwrap();
let repo = tmp.path();
let entity_id = "ffffffff-ffff-ffff-ffff-ffffffffffff";
let entity_ndjson = format!(
r#"{{"id":"{entity_id}","kind":"concept","name":"StatusEntity","properties":{{}},"tags":[]}}"#
);
let kg_dir = repo.join(".khive/kg");
std::fs::create_dir_all(&kg_dir).unwrap();
std::fs::write(kg_dir.join("entities.ndjson"), &entity_ndjson).unwrap();
std::fs::write(kg_dir.join("edges.ndjson"), "").unwrap();
let db = repo.join(".khive/state/working.db");
crate::sync::run_sync(repo, &db, "test-ns").await.unwrap();
let ns = Namespace::parse("test-ns").unwrap();
let config = RuntimeConfig {
db_path: Some(db),
default_namespace: ns.clone(),
embedding_model: None,
..Default::default()
};
let runtime = KhiveRuntime::new(config).unwrap();
let token = runtime.authorize(ns).unwrap();
let db_archive = runtime.export_kg(&token).await.unwrap();
let ndjson_archive = archive_from_ndjson_repo(repo, "test-ns").unwrap();
let db_hash = khive_vcs::hash::snapshot_id_for_archive(&db_archive).unwrap();
let ndjson_hash = khive_vcs::hash::snapshot_id_for_archive(&ndjson_archive).unwrap();
assert_eq!(db_hash, ndjson_hash, "hashes must match after sync");
}
}