use std::path::Path;
use super::types::{HookCommand, HookStatus, InitArgs};
use anyhow::{bail, Context, Result};
const DEFAULT_KHIVE_TOML: &str = r#"# .khive/khive.toml — project KG configuration
# 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
"#;
pub(super) 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(())
}
pub(super) 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),
}
}
pub(super) 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 tempfile::TempDir;
use super::*;
#[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");
}
}