Skip to main content

khive_vcs/
sync.rs

1// Copyright 2026 khive contributors. Licensed under Apache-2.0.
2//
3//! NDJSON-to-SQLite sync library boundary (ADR-010/ADR-020, finding F106).
4//!
5//! Reads `<repo>/.khive/kg/entities.ndjson` and `<repo>/.khive/kg/edges.ndjson`,
6//! parses each record per the ADR-020 §2 canonical schema, and writes them into
7//! a fresh SQLite database using the runtime's upsert APIs. The resulting DB
8//! has the full khive schema (entities + graph_edges + FTS5 indexes + vector
9//! tables) — the same schema the MCP server uses.
10//!
11//! ## Atomicity
12//!
13//! Builds into `<target>.tmp` then renames over `<target>`. A crash mid-build
14//! leaves the previous DB intact.
15//!
16//! ## F201: Remote archive fetch + hash-pin verification (ADR-037)
17//!
18//! [`run_sync_remote`] fetches NDJSON files from a git remote URL into a local
19//! cache directory, computes the canonical SHA-256 content hash, and compares
20//! it against an optional `sha256:` pin in the caller-supplied [`RemoteConfig`].
21//! A pin mismatch aborts before any cache file is modified (fail-closed). If
22//! no pin is present the hash is still computed and written to `meta.json`.
23//! Pass `repin: true` to skip the pin comparison and have the computed hash
24//! returned for the caller to write back to `schema.yaml`.
25//!
26//! ## Consumers
27//!
28//! `kkernel sync` is the primary consumer. It calls [`run_sync`] and prints the
29//! resulting [`SyncReport`] as JSON. Other callers (e.g. git post-checkout hooks)
30//! can use this library directly.
31
32use std::path::{Path, PathBuf};
33use std::process::Command;
34
35use anyhow::{anyhow, Context, Result};
36use chrono::Utc;
37use khive_runtime::portability::{ExportedEdge, ExportedEntity, KgArchive};
38use khive_runtime::{KhiveRuntime, RuntimeConfig};
39use khive_storage::types::{Edge, TextDocument};
40use khive_storage::{LinkId, SubstrateKind};
41use khive_types::EdgeRelation;
42use serde::{Deserialize, Serialize};
43use uuid::Uuid;
44
45use crate::error::VcsError;
46use crate::hash::snapshot_id_for_archive;
47use crate::types::SnapshotId;
48
49/// Per-record entity shape in NDJSON sources (ADR-020 §2).
50#[derive(Debug, Serialize, Deserialize)]
51struct NdjsonEntity {
52    id: Uuid,
53    kind: String,
54    name: String,
55    #[serde(default)]
56    description: Option<String>,
57    #[serde(default)]
58    properties: Option<serde_json::Value>,
59    #[serde(default)]
60    tags: Vec<String>,
61    #[serde(default)]
62    created_at: Option<String>,
63    #[serde(default)]
64    updated_at: Option<String>,
65}
66
67/// Per-record edge shape in NDJSON sources (ADR-020 §2).
68#[derive(Debug, Serialize, Deserialize)]
69struct NdjsonEdge {
70    edge_id: Uuid,
71    source: Uuid,
72    target: Uuid,
73    relation: String,
74    #[serde(default = "default_weight")]
75    weight: f64,
76    // properties: accepted but not yet persisted to the storage-layer Edge
77    // struct. Parsed here so existing NDJSON files round-trip without warning.
78    #[serde(default)]
79    #[allow(dead_code)]
80    properties: Option<serde_json::Value>,
81    #[serde(default)]
82    created_at: Option<String>,
83    #[serde(default)]
84    #[allow(dead_code)]
85    updated_at: Option<String>,
86}
87
88fn default_weight() -> f64 {
89    1.0
90}
91
92/// Parse an ISO-8601 timestamp string into microseconds since epoch.
93/// Returns `now` if the string is `None` or unparseable.
94fn parse_ts_micros(s: Option<&str>) -> i64 {
95    s.and_then(|t| chrono::DateTime::parse_from_rfc3339(t).ok())
96        .map(|dt| dt.timestamp_micros())
97        .unwrap_or_else(|| chrono::Utc::now().timestamp_micros())
98}
99
100/// Summary of a completed sync run.
101#[derive(Debug, Serialize)]
102pub struct SyncReport {
103    pub entities: usize,
104    pub edges: usize,
105    pub db_path: String,
106}
107
108// ── F201: Remote archive fetch (ADR-037) ──────────────────────────────────────
109
110/// Configuration for a remote KG archive (maps to one entry in `schema.yaml`
111/// `remotes:` list per ADR-037 §schema-yaml-remotes-section).
112#[derive(Debug, Clone)]
113pub struct RemoteConfig {
114    /// Human-readable name for this remote (used in error messages and cache
115    /// directory paths).
116    pub name: String,
117    /// Git remote URL (e.g. `https://github.com/org/kg-data.git`).
118    pub url: String,
119    /// Git ref to check out (branch or tag, e.g. `main`).
120    pub git_ref: String,
121    /// Namespace to assign to imported records.
122    pub namespace: String,
123    /// Optional SHA-256 content-hash pin. When present, a mismatch between the
124    /// fetched archive hash and this value aborts the sync (fail-closed).
125    pub pin: Option<SnapshotId>,
126}
127
128/// Summary of a completed remote sync run (F201).
129#[derive(Debug, Serialize)]
130pub struct RemoteSyncReport {
131    pub entities: usize,
132    pub edges: usize,
133    /// Path to the populated cache directory (`.khive/kg/remotes/<name>/`).
134    pub cache_dir: String,
135    /// Path to the written `meta.json` file.
136    pub meta_path: String,
137    /// Canonical SHA-256 content hash of the fetched archive (`sha256:<hex>`).
138    pub content_hash: String,
139    /// `true` when `repin` was requested — the caller should write
140    /// `content_hash` back to `schema.yaml` as the new `pin` value.
141    pub repinned: bool,
142}
143
144/// Metadata written to `.khive/kg/remotes/<name>/meta.json` (ADR-037
145/// §remote-cache-layout).
146#[derive(Debug, Serialize)]
147struct MetaJson {
148    /// ISO-8601 timestamp of when the fetch completed.
149    fetched_at: String,
150    /// Git ref that was resolved.
151    git_ref: String,
152    /// Git commit SHA resolved from `git_ref` at fetch time.
153    commit_sha: String,
154    /// Canonical content hash of the fetched archive.
155    content_hash: String,
156}
157
158/// Fetch a remote KG archive, verify its SHA-256 content hash, populate the
159/// local cache, and write `meta.json`.
160///
161/// # Arguments
162///
163/// * `repo_root` — Repository root that owns the `.khive/` tree. The cache
164///   directory `<repo_root>/.khive/kg/remotes/<remote.name>/` is populated on
165///   success.
166/// * `remote` — Remote configuration (URL, git ref, namespace, optional pin).
167/// * `repin` — When `true`, skip pin comparison and return the computed hash
168///   for the caller to write back to `schema.yaml`. When `false` and a pin is
169///   present, a hash mismatch returns [`VcsError::HashMismatch`] via `anyhow`.
170///
171/// # Failure behaviour (ADR-037 §failure-behaviour-fail-closed)
172///
173/// On hash mismatch the function returns an error *before* any cache file is
174/// modified. The staging directory is cleaned up. The error message includes the
175/// remote name, expected hash, and actual hash so the operator can copy the
176/// actual hash into `schema.yaml` to accept the new content, or use `--repin`.
177///
178/// If no pin is present the hash is still computed and written to `meta.json`,
179/// but the sync proceeds regardless.
180pub async fn run_sync_remote(
181    repo_root: &Path,
182    remote: &RemoteConfig,
183    repin: bool,
184) -> Result<RemoteSyncReport> {
185    // ── 1. Create staging directory ──────────────────────────────────────────
186    let state_dir = repo_root.join(".khive/state/remote-staging");
187    std::fs::create_dir_all(&state_dir)
188        .with_context(|| format!("creating staging dir {}", state_dir.display()))?;
189    let staging = tempfile::TempDir::new_in(&state_dir).context("creating staging temp dir")?;
190    let staging_path = staging.path().to_path_buf();
191
192    // ── 2. Git clone (sparse, depth=1) ───────────────────────────────────────
193    let entities_ndjson: Vec<NdjsonEntity>;
194    let edges_ndjson: Vec<NdjsonEdge>;
195    let commit_sha: String;
196
197    {
198        // Clone only the objects needed — no blobs, just tree metadata, then
199        // sparse-checkout the two NDJSON files we need.
200        let clone_out = Command::new("git")
201            .args([
202                "clone",
203                "--depth=1",
204                "--filter=blob:none",
205                "--no-checkout",
206                "--branch",
207                &remote.git_ref,
208                &remote.url,
209                staging_path.to_str().unwrap(),
210            ])
211            .output()
212            .context("running git clone")?;
213
214        if !clone_out.status.success() {
215            let stderr = String::from_utf8_lossy(&clone_out.stderr);
216            return Err(anyhow!(
217                "git clone failed for remote {:?} (url: {}): {}",
218                remote.name,
219                remote.url,
220                stderr.trim()
221            ));
222        }
223
224        // Resolve commit SHA from HEAD.
225        let rev_out = Command::new("git")
226            .args(["rev-parse", "HEAD"])
227            .current_dir(&staging_path)
228            .output()
229            .context("running git rev-parse HEAD")?;
230        commit_sha = String::from_utf8_lossy(&rev_out.stdout).trim().to_string();
231
232        // Sparse checkout: enable and limit to the two NDJSON files.
233        run_git_in(&staging_path, &["sparse-checkout", "init", "--cone"])
234            .context("git sparse-checkout init")?;
235        run_git_in(
236            &staging_path,
237            &[
238                "sparse-checkout",
239                "set",
240                ".khive/kg/entities.ndjson",
241                ".khive/kg/edges.ndjson",
242            ],
243        )
244        .context("git sparse-checkout set")?;
245        run_git_in(&staging_path, &["checkout"]).context("git checkout")?;
246
247        // Parse the staged NDJSON files.
248        let entities_path = staging_path.join(".khive/kg/entities.ndjson");
249        let edges_path = staging_path.join(".khive/kg/edges.ndjson");
250
251        entities_ndjson = read_entities(&entities_path)
252            .with_context(|| format!("reading staged {}", entities_path.display()))?;
253        edges_ndjson = read_edges(&edges_path)
254            .with_context(|| format!("reading staged {}", edges_path.display()))?;
255    }
256    // `staging` tempdir is still alive here — we drop it after moving files.
257
258    // ── 3. Build KgArchive and compute canonical hash ─────────────────────────
259    let archive = build_kg_archive(&remote.namespace, &entities_ndjson, &edges_ndjson);
260    let actual_hash = snapshot_id_for_archive(&archive)
261        .map_err(|e| anyhow!("hashing archive for remote {:?}: {}", remote.name, e))?;
262
263    // ── 4. Pin verification (fail-closed) ────────────────────────────────────
264    if let Some(expected) = &remote.pin {
265        if !repin && actual_hash != *expected {
266            return Err(anyhow!(VcsError::HashMismatch {
267                expected: expected.clone(),
268                actual: actual_hash.clone(),
269            })
270            .context(format!(
271                "remote {:?}: hash mismatch — use `--repin` to accept the new content \
272                 after independently verifying it (actual hash: {})",
273                remote.name,
274                actual_hash.as_str()
275            )));
276        }
277    }
278
279    // ── 5. Atomically publish to cache ────────────────────────────────────────
280    let cache_dir = repo_root.join(".khive/kg/remotes").join(&remote.name);
281    std::fs::create_dir_all(&cache_dir)
282        .with_context(|| format!("creating cache dir {}", cache_dir.display()))?;
283
284    // Write files into staging first, then rename into place atomically.
285    let tmp_entities = cache_dir.with_extension("entities.tmp");
286    let tmp_edges = cache_dir.with_extension("edges.tmp");
287
288    write_sorted_entities(&tmp_entities, &entities_ndjson)
289        .context("writing staged entities.ndjson")?;
290    write_sorted_edges(&tmp_edges, &edges_ndjson).context("writing staged edges.ndjson")?;
291
292    std::fs::rename(&tmp_entities, cache_dir.join("entities.ndjson"))
293        .context("renaming entities.ndjson into cache")?;
294    std::fs::rename(&tmp_edges, cache_dir.join("edges.ndjson"))
295        .context("renaming edges.ndjson into cache")?;
296
297    // ── 6. Write meta.json ────────────────────────────────────────────────────
298    let meta = MetaJson {
299        fetched_at: Utc::now().to_rfc3339(),
300        git_ref: remote.git_ref.clone(),
301        commit_sha,
302        content_hash: actual_hash.as_str().to_string(),
303    };
304    let meta_path = cache_dir.join("meta.json");
305    let meta_json = serde_json::to_string_pretty(&meta).context("serializing meta.json")?;
306    std::fs::write(&meta_path, meta_json.as_bytes()).context("writing meta.json")?;
307
308    // staging tempdir is dropped here, cleaning up the clone.
309    drop(staging);
310
311    Ok(RemoteSyncReport {
312        entities: entities_ndjson.len(),
313        edges: edges_ndjson.len(),
314        cache_dir: cache_dir.to_string_lossy().into_owned(),
315        meta_path: meta_path.to_string_lossy().into_owned(),
316        content_hash: actual_hash.as_str().to_string(),
317        repinned: repin,
318    })
319}
320
321/// Run a git command inside `dir`, returning an error if it fails.
322fn run_git_in(dir: &Path, args: &[&str]) -> Result<()> {
323    let out = Command::new("git")
324        .args(args)
325        .current_dir(dir)
326        .output()
327        .with_context(|| format!("running git {}", args.join(" ")))?;
328    if !out.status.success() {
329        let stderr = String::from_utf8_lossy(&out.stderr);
330        return Err(anyhow!("git {} failed: {}", args.join(" "), stderr.trim()));
331    }
332    Ok(())
333}
334
335/// Convert the NDJSON record slices into a [`KgArchive`] for hashing.
336fn build_kg_archive(namespace: &str, entities: &[NdjsonEntity], edges: &[NdjsonEdge]) -> KgArchive {
337    let now = Utc::now();
338    let exported_entities: Vec<ExportedEntity> = entities
339        .iter()
340        .map(|e| ExportedEntity {
341            id: e.id,
342            kind: e.kind.clone(),
343            entity_type: None,
344            name: e.name.clone(),
345            description: e.description.clone(),
346            properties: e.properties.clone(),
347            tags: e.tags.clone(),
348            created_at: e
349                .created_at
350                .as_deref()
351                .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
352                .map(|dt| dt.with_timezone(&Utc))
353                .unwrap_or(now),
354            updated_at: e
355                .updated_at
356                .as_deref()
357                .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
358                .map(|dt| dt.with_timezone(&Utc))
359                .unwrap_or(now),
360        })
361        .collect();
362
363    let exported_edges: Vec<ExportedEdge> = edges
364        .iter()
365        .filter_map(|e| {
366            let relation: EdgeRelation = e.relation.parse().ok()?;
367            Some(ExportedEdge {
368                edge_id: e.edge_id,
369                source: e.source,
370                target: e.target,
371                relation,
372                weight: e.weight,
373            })
374        })
375        .collect();
376
377    KgArchive {
378        format: "khive-kg".into(),
379        version: "0.1".into(),
380        namespace: namespace.to_string(),
381        exported_at: now,
382        entities: exported_entities,
383        edges: exported_edges,
384    }
385}
386
387/// Write entities to a file as sorted NDJSON (one JSON object per line).
388///
389/// Entities are sorted by UUID string (case-insensitive ascending) to match
390/// the canonical sort order used by `snapshot_id_for_archive`.
391fn write_sorted_entities(path: &Path, records: &[NdjsonEntity]) -> Result<()> {
392    let mut sorted: Vec<&NdjsonEntity> = records.iter().collect();
393    sorted.sort_by(|a, b| {
394        a.id.to_string()
395            .to_ascii_lowercase()
396            .cmp(&b.id.to_string().to_ascii_lowercase())
397    });
398    let mut lines = Vec::with_capacity(sorted.len());
399    for r in sorted {
400        let line = serde_json::to_string(r).context("serializing entity")?;
401        lines.push(line);
402    }
403    std::fs::write(path, lines.join("\n")).context("writing entities file")?;
404    Ok(())
405}
406
407/// Write edges to a file as sorted NDJSON (one JSON object per line).
408///
409/// Edges are sorted by (source, target, relation) to match the canonical sort
410/// order used by `snapshot_id_for_archive`.
411fn write_sorted_edges(path: &Path, records: &[NdjsonEdge]) -> Result<()> {
412    let mut sorted: Vec<&NdjsonEdge> = records.iter().collect();
413    sorted.sort_by(|a, b| {
414        let ak = (
415            a.source.to_string(),
416            a.target.to_string(),
417            a.relation.clone(),
418        );
419        let bk = (
420            b.source.to_string(),
421            b.target.to_string(),
422            b.relation.clone(),
423        );
424        ak.cmp(&bk)
425    });
426    let mut lines = Vec::with_capacity(sorted.len());
427    for r in sorted {
428        let line = serde_json::to_string(r).context("serializing edge")?;
429        lines.push(line);
430    }
431    std::fs::write(path, lines.join("\n")).context("writing edges file")?;
432    Ok(())
433}
434
435/// Rebuild `db_path` from `.khive/kg/{entities,edges}.ndjson` under `repo_root`.
436///
437/// The operation is atomic: the database is built in a `.tmp` sibling file and
438/// renamed over `db_path` only on success. A crash or error leaves the previous
439/// `db_path` intact.
440///
441/// `namespace` is applied to all imported records.
442///
443/// Returns a [`SyncReport`] on success, or an error if NDJSON parsing or SQLite
444/// upserts fail.
445pub async fn run_sync(repo_root: &Path, db_path: &Path, namespace: &str) -> Result<SyncReport> {
446    let entities_path = repo_root.join(".khive/kg/entities.ndjson");
447    let edges_path = repo_root.join(".khive/kg/edges.ndjson");
448
449    let entity_records = read_entities(&entities_path)
450        .with_context(|| format!("reading {}", entities_path.display()))?;
451    let edge_records =
452        read_edges(&edges_path).with_context(|| format!("reading {}", edges_path.display()))?;
453
454    let tmp_path = with_extension_suffix(db_path, ".tmp");
455    let _ = std::fs::remove_file(&tmp_path);
456
457    // Build the runtime against the tmp file. Vector embedding is disabled
458    // because sync runs without an embedding model loaded — vectors are
459    // computed lazily on access via the MCP server if needed.
460    let ns = khive_types::Namespace::parse(namespace)
461        .map_err(|e| anyhow!("invalid namespace {namespace:?}: {e}"))?;
462    let config = RuntimeConfig {
463        db_path: Some(tmp_path.clone()),
464        default_namespace: ns,
465        embedding_model: None,
466        ..RuntimeConfig::default()
467    };
468    let runtime = KhiveRuntime::new(config)
469        .with_context(|| format!("building runtime for {}", tmp_path.display()))?;
470
471    let entity_count = upsert_entities(&runtime, namespace, entity_records).await?;
472    let edge_count = upsert_edges(&runtime, namespace, edge_records).await?;
473
474    // Checkpoint the WAL so all committed writes land in the main DB file.
475    // Without this, `rename(tmp, target)` moves only the main file and leaves
476    // the -wal alongside it; opening `target` later would see only the data
477    // through the last auto-checkpoint (every 4000 pages). For small graphs no
478    // auto-checkpoint fires, so the data would silently disappear.
479    checkpoint_wal(&runtime)
480        .await
481        .context("checkpoint WAL before rename")?;
482
483    // Drop the runtime so SQLite releases its file handles before rename.
484    drop(runtime);
485
486    if let Some(parent) = db_path.parent() {
487        std::fs::create_dir_all(parent)
488            .with_context(|| format!("creating {}", parent.display()))?;
489    }
490    std::fs::rename(&tmp_path, db_path)
491        .with_context(|| format!("renaming {} -> {}", tmp_path.display(), db_path.display()))?;
492
493    Ok(SyncReport {
494        entities: entity_count,
495        edges: edge_count,
496        db_path: db_path.to_string_lossy().into_owned(),
497    })
498}
499
500fn with_extension_suffix(p: &Path, suffix: &str) -> PathBuf {
501    let mut s = p.as_os_str().to_owned();
502    s.push(suffix);
503    PathBuf::from(s)
504}
505
506fn read_entities(path: &Path) -> Result<Vec<NdjsonEntity>> {
507    if !path.exists() {
508        return Ok(Vec::new());
509    }
510    let text = std::fs::read_to_string(path)?;
511    let mut out = Vec::new();
512    for (i, line) in text.lines().enumerate() {
513        let trimmed = line.trim();
514        if trimmed.is_empty() {
515            continue;
516        }
517        let e: NdjsonEntity = serde_json::from_str(trimmed)
518            .with_context(|| format!("parsing entity at line {}", i + 1))?;
519        out.push(e);
520    }
521    Ok(out)
522}
523
524fn read_edges(path: &Path) -> Result<Vec<NdjsonEdge>> {
525    if !path.exists() {
526        return Ok(Vec::new());
527    }
528    let text = std::fs::read_to_string(path)?;
529    let mut out = Vec::new();
530    for (i, line) in text.lines().enumerate() {
531        let trimmed = line.trim();
532        if trimmed.is_empty() {
533            continue;
534        }
535        let e: NdjsonEdge = serde_json::from_str(trimmed)
536            .with_context(|| format!("parsing edge at line {}", i + 1))?;
537        out.push(e);
538    }
539    Ok(out)
540}
541
542async fn checkpoint_wal(runtime: &KhiveRuntime) -> Result<()> {
543    let mut writer = runtime.backend().sql().writer().await?;
544    writer
545        .execute_script("PRAGMA wal_checkpoint(TRUNCATE);".to_string())
546        .await?;
547    Ok(())
548}
549
550async fn upsert_entities(
551    runtime: &KhiveRuntime,
552    namespace: &str,
553    records: Vec<NdjsonEntity>,
554) -> Result<usize> {
555    let ns = khive_types::Namespace::parse(namespace)
556        .map_err(|e| anyhow!("invalid namespace {namespace:?}: {e}"))?;
557    let token = runtime.authorize(ns)?;
558    let store = runtime.entities(&token).context("opening entity store")?;
559    let text = runtime.text(&token).context("opening text store")?;
560    let mut count = 0;
561    for r in records {
562        let created_at = parse_ts_micros(r.created_at.as_deref());
563        let updated_at = parse_ts_micros(r.updated_at.as_deref());
564        // Build the FTS body from name + description (same as create_entity in operations.rs).
565        let body = match &r.description {
566            Some(d) if !d.is_empty() => format!("{} {}", r.name, d),
567            _ => r.name.clone(),
568        };
569        let entity = khive_storage::entity::Entity {
570            id: r.id,
571            namespace: namespace.to_string(),
572            kind: r.kind.clone(),
573            entity_type: None,
574            name: r.name.clone(),
575            description: r.description.clone(),
576            properties: r.properties.clone(),
577            tags: r.tags.clone(),
578            created_at,
579            updated_at,
580            deleted_at: None,
581            merge_event_id: None,
582            merged_into: None,
583        };
584        store
585            .upsert_entity(entity)
586            .await
587            .with_context(|| format!("upsert entity {}", r.id))?;
588        // Populate FTS5 index so text search works after sync.
589        // Vectors are intentionally skipped: they are local-only derived state
590        // (ADR-035 §6) and will be computed by `kkernel kg embed` when needed.
591        text.upsert_document(TextDocument {
592            subject_id: r.id,
593            kind: SubstrateKind::Entity,
594            title: Some(r.name.clone()),
595            body,
596            tags: r.tags.clone(),
597            namespace: namespace.to_string(),
598            metadata: r.properties.clone(),
599            updated_at: chrono::DateTime::from_timestamp_micros(updated_at)
600                .unwrap_or_else(chrono::Utc::now),
601        })
602        .await
603        .with_context(|| format!("fts index entity {}", r.id))?;
604        count += 1;
605    }
606    Ok(count)
607}
608
609async fn upsert_edges(
610    runtime: &KhiveRuntime,
611    namespace: &str,
612    records: Vec<NdjsonEdge>,
613) -> Result<usize> {
614    let ns = khive_types::Namespace::parse(namespace)
615        .map_err(|e| anyhow!("invalid namespace {namespace:?}: {e}"))?;
616    let token = runtime.authorize(ns)?;
617    let graph = runtime.graph(&token).context("opening graph store")?;
618    let mut count = 0;
619    for r in records {
620        let relation: EdgeRelation = r
621            .relation
622            .parse()
623            .map_err(|e| anyhow!("invalid relation {:?}: {}", r.relation, e))?;
624        let created_at =
625            chrono::DateTime::from_timestamp_micros(parse_ts_micros(r.created_at.as_deref()))
626                .unwrap_or_else(chrono::Utc::now);
627        let edge = Edge {
628            id: LinkId::from(r.edge_id),
629            namespace: namespace.to_string(),
630            source_id: r.source,
631            target_id: r.target,
632            relation,
633            weight: r.weight,
634            created_at,
635            updated_at: created_at,
636            deleted_at: None,
637            metadata: None,
638            target_backend: None,
639        };
640        graph
641            .upsert_edge(edge)
642            .await
643            .with_context(|| format!("upsert edge {}", r.edge_id))?;
644        count += 1;
645    }
646    Ok(count)
647}
648
649// ── Tests ─────────────────────────────────────────────────────────────────────
650
651#[cfg(test)]
652mod tests {
653    use super::*;
654    use tempfile::TempDir;
655
656    // ── F201 test helpers ─────────────────────────────────────────────────────
657
658    /// Create a minimal git repository under `dir` with the given NDJSON content
659    /// inside `.khive/kg/`. Returns the URL-style path suitable for `git clone`.
660    fn make_git_remote(dir: &Path, entities_ndjson: &str, edges_ndjson: &str) -> String {
661        let kg_dir = dir.join(".khive/kg");
662        std::fs::create_dir_all(&kg_dir).unwrap();
663        std::fs::write(kg_dir.join("entities.ndjson"), entities_ndjson).unwrap();
664        std::fs::write(kg_dir.join("edges.ndjson"), edges_ndjson).unwrap();
665
666        // Initialise git repo with a single commit on `main`.
667        run_git(dir, &["init", "-b", "main"]);
668        run_git(dir, &["config", "user.email", "test@example.com"]);
669        run_git(dir, &["config", "user.name", "Test"]);
670        run_git(dir, &["add", "-A"]);
671        run_git(dir, &["commit", "-m", "init"]);
672
673        dir.to_string_lossy().into_owned()
674    }
675
676    fn run_git(dir: &Path, args: &[&str]) {
677        let status = Command::new("git")
678            .args(args)
679            .current_dir(dir)
680            .status()
681            .unwrap_or_else(|e| panic!("git {} failed to spawn: {e}", args.join(" ")));
682        assert!(
683            status.success(),
684            "git {} exited with {}",
685            args.join(" "),
686            status
687        );
688    }
689
690    /// Compute the canonical `SnapshotId` for entity/edge NDJSON strings without
691    /// touching the filesystem, so we can build expected pins from in-memory data.
692    fn compute_pin(entities_ndjson: &str, edges_ndjson: &str, namespace: &str) -> SnapshotId {
693        let tmp = TempDir::new().unwrap();
694        let kg = tmp.path().join(".khive/kg");
695        std::fs::create_dir_all(&kg).unwrap();
696        std::fs::write(kg.join("entities.ndjson"), entities_ndjson).unwrap();
697        std::fs::write(kg.join("edges.ndjson"), edges_ndjson).unwrap();
698
699        let entities = read_entities(&kg.join("entities.ndjson")).unwrap();
700        let edges = read_edges(&kg.join("edges.ndjson")).unwrap();
701        let archive = build_kg_archive(namespace, &entities, &edges);
702        snapshot_id_for_archive(&archive).unwrap()
703    }
704
705    // ── test_run_sync_local_path_unchanged_behavior ───────────────────────────
706
707    fn write_repo(dir: &Path, entities_ndjson: &str, edges_ndjson: &str) {
708        let kg_dir = dir.join(".khive/kg");
709        std::fs::create_dir_all(&kg_dir).unwrap();
710        std::fs::write(kg_dir.join("entities.ndjson"), entities_ndjson).unwrap();
711        std::fs::write(kg_dir.join("edges.ndjson"), edges_ndjson).unwrap();
712    }
713
714    #[tokio::test]
715    async fn sync_empty_ndjson_produces_real_sqlite_file() {
716        let tmp = TempDir::new().unwrap();
717        let repo = tmp.path();
718        let db_path = repo.join(".khive/state/working.db");
719        write_repo(repo, "", "");
720
721        let report = run_sync(repo, &db_path, "test-ns").await.unwrap();
722        assert_eq!(report.entities, 0);
723        assert_eq!(report.edges, 0);
724
725        let bytes = std::fs::read(&db_path).unwrap();
726        assert!(!bytes.is_empty(), "DB file must be non-empty after sync");
727        assert!(
728            bytes.starts_with(b"SQLite format 3\0"),
729            "DB file must start with SQLite magic header, got {:?}",
730            &bytes[..bytes.len().min(20)]
731        );
732    }
733
734    #[tokio::test]
735    async fn sync_imports_entities_and_edges_into_real_db() {
736        let tmp = TempDir::new().unwrap();
737        let repo = tmp.path();
738        let db_path = repo.join(".khive/state/working.db");
739
740        let id_a = "11111111-1111-1111-1111-111111111111";
741        let id_b = "22222222-2222-2222-2222-222222222222";
742        let edge_id = "33333333-3333-3333-3333-333333333333";
743
744        let line_a = format!(
745            r#"{{"id":"{id_a}","kind":"concept","name":"Alpha","properties":{{}},"tags":[]}}"#
746        );
747        let line_b = format!(
748            r#"{{"id":"{id_b}","kind":"concept","name":"Beta","properties":{{}},"tags":[]}}"#
749        );
750        let entities = format!("{line_a}\n{line_b}\n");
751        let edges = format!(
752            r#"{{"edge_id":"{edge_id}","source":"{id_a}","target":"{id_b}","relation":"extends","weight":1.0,"properties":{{}}}}"#
753        );
754        write_repo(repo, &entities, &edges);
755
756        let report = run_sync(repo, &db_path, "test-ns").await.unwrap();
757        assert_eq!(report.entities, 2);
758        assert_eq!(report.edges, 1);
759
760        let ns = khive_types::Namespace::parse("test-ns").unwrap();
761        let config = RuntimeConfig {
762            db_path: Some(db_path.clone()),
763            default_namespace: ns.clone(),
764            embedding_model: None,
765            ..RuntimeConfig::default()
766        };
767        let rt = KhiveRuntime::new(config).unwrap();
768        let token = rt.authorize(ns).unwrap();
769        let alpha = rt
770            .entities(&token)
771            .unwrap()
772            .get_entity(id_a.parse().unwrap())
773            .await
774            .unwrap()
775            .expect("entity Alpha must be retrievable after sync");
776        assert_eq!(alpha.name, "Alpha");
777        assert_eq!(alpha.kind, "concept");
778    }
779
780    #[tokio::test]
781    async fn sync_is_atomic_via_tmp_rename() {
782        let tmp = TempDir::new().unwrap();
783        let repo = tmp.path();
784        let db_path = repo.join(".khive/state/working.db");
785        std::fs::create_dir_all(db_path.parent().unwrap()).unwrap();
786        std::fs::write(&db_path, b"SENTINEL").unwrap();
787
788        write_repo(repo, "not json\n", "");
789        let err = run_sync(repo, &db_path, "test-ns").await.unwrap_err();
790        assert!(
791            err.to_string().to_lowercase().contains("parsing entity")
792                || err.chain().any(|e| e.to_string().contains("expected")),
793            "expected parse error, got: {err}"
794        );
795
796        let after = std::fs::read(&db_path).unwrap();
797        assert_eq!(
798            after, b"SENTINEL",
799            "atomic guarantee: failed sync must not replace existing DB"
800        );
801    }
802
803    #[tokio::test]
804    async fn sync_missing_ndjson_files_succeeds_with_zero_counts() {
805        let tmp = TempDir::new().unwrap();
806        let repo = tmp.path();
807        let db_path = repo.join(".khive/state/working.db");
808
809        let report = run_sync(repo, &db_path, "test-ns").await.unwrap();
810        assert_eq!(report.entities, 0);
811        assert_eq!(report.edges, 0);
812    }
813
814    /// F195: verify that FTS5 is populated during sync so text search works
815    /// after sync without a separate `kkernel kg embed` pass (ADR-035 §5).
816    #[tokio::test]
817    async fn sync_populates_fts_for_text_search() {
818        use khive_runtime::RuntimeConfig;
819        use khive_storage::types::{TextFilter, TextQueryMode, TextSearchRequest};
820
821        let tmp = TempDir::new().unwrap();
822        let repo = tmp.path();
823        let db_path = repo.join(".khive/state/working.db");
824
825        let id_a = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa";
826        let line_a = format!(
827            r#"{{"id":"{id_a}","kind":"concept","name":"FlashAttention","description":"Fast attention algorithm","properties":{{}},"tags":[]}}"#
828        );
829        write_repo(repo, &line_a, "");
830
831        run_sync(repo, &db_path, "test-ns").await.unwrap();
832
833        let ns = khive_types::Namespace::parse("test-ns").unwrap();
834        let config = RuntimeConfig {
835            db_path: Some(db_path.clone()),
836            default_namespace: ns.clone(),
837            embedding_model: None,
838            ..RuntimeConfig::default()
839        };
840        let rt = KhiveRuntime::new(config).unwrap();
841        let token = rt.authorize(ns).unwrap();
842
843        let hits = rt
844            .text(&token)
845            .expect("text store must be available")
846            .search(TextSearchRequest {
847                query: "FlashAttention".to_string(),
848                filter: Some(TextFilter {
849                    namespaces: vec!["test-ns".to_string()],
850                    ..Default::default()
851                }),
852                mode: TextQueryMode::Phrase,
853                top_k: 10,
854                snippet_chars: 128,
855            })
856            .await
857            .expect("text search must succeed after sync");
858
859        assert!(
860            !hits.is_empty(),
861            "FTS search for 'FlashAttention' must return results after sync (F195)"
862        );
863        assert_eq!(
864            hits[0].subject_id.to_string(),
865            id_a,
866            "FTS hit must reference the synced entity UUID"
867        );
868    }
869
870    // ── F201 tests ────────────────────────────────────────────────────────────
871
872    /// F201-1: `run_sync_remote` with a correct pin succeeds and writes the
873    /// expected cache files and `meta.json`.
874    #[tokio::test]
875    async fn run_sync_remote_fetches_and_verifies_hash_match() {
876        let remote_dir = TempDir::new().unwrap();
877        let repo_dir = TempDir::new().unwrap();
878
879        let id_a = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa";
880        let entities = format!(
881            r#"{{"id":"{id_a}","kind":"concept","name":"RemoteEntity","properties":{{}},"tags":[]}}"#
882        );
883        let edges = "";
884
885        let remote_url = make_git_remote(remote_dir.path(), &entities, edges);
886        let expected_pin = compute_pin(&entities, edges, "remote-ns");
887
888        let remote = RemoteConfig {
889            name: "upstream".to_string(),
890            url: remote_url,
891            git_ref: "main".to_string(),
892            namespace: "remote-ns".to_string(),
893            pin: Some(expected_pin.clone()),
894        };
895
896        let report = run_sync_remote(repo_dir.path(), &remote, false)
897            .await
898            .expect("run_sync_remote must succeed with correct pin");
899
900        assert_eq!(report.entities, 1, "must report 1 entity");
901        assert_eq!(report.edges, 0, "must report 0 edges");
902        assert_eq!(
903            report.content_hash,
904            expected_pin.as_str(),
905            "content_hash must match the pin"
906        );
907        assert!(!report.repinned, "repin was not requested");
908
909        // Cache files must exist.
910        let cache = repo_dir.path().join(".khive/kg/remotes/upstream");
911        assert!(
912            cache.join("entities.ndjson").exists(),
913            "entities.ndjson must exist in cache"
914        );
915        assert!(
916            cache.join("edges.ndjson").exists(),
917            "edges.ndjson must exist in cache"
918        );
919        assert!(
920            cache.join("meta.json").exists(),
921            "meta.json must exist in cache"
922        );
923
924        // meta.json must be valid JSON with the expected fields.
925        let meta_bytes = std::fs::read(cache.join("meta.json")).unwrap();
926        let meta: serde_json::Value = serde_json::from_slice(&meta_bytes).unwrap();
927        assert_eq!(
928            meta["content_hash"].as_str().unwrap(),
929            expected_pin.as_str(),
930            "meta.json content_hash must match"
931        );
932        assert!(
933            meta["fetched_at"].as_str().is_some(),
934            "meta.json must have fetched_at"
935        );
936        assert!(
937            meta["commit_sha"].as_str().is_some(),
938            "meta.json must have commit_sha"
939        );
940    }
941
942    /// F201-2: `run_sync_remote` with a wrong pin fails before touching the
943    /// cache (fail-closed guarantee from ADR-037 §failure-behaviour).
944    #[tokio::test]
945    async fn run_sync_remote_rejects_hash_mismatch() {
946        let remote_dir = TempDir::new().unwrap();
947        let repo_dir = TempDir::new().unwrap();
948
949        let id_b = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb";
950        let entities = format!(
951            r#"{{"id":"{id_b}","kind":"concept","name":"AnotherEntity","properties":{{}},"tags":[]}}"#
952        );
953        let edges = "";
954
955        let remote_url = make_git_remote(remote_dir.path(), &entities, edges);
956
957        // Deliberate wrong pin: 64 zero hex chars.
958        let wrong_pin = SnapshotId::from_hash(&"0".repeat(64)).unwrap();
959
960        let remote = RemoteConfig {
961            name: "upstream".to_string(),
962            url: remote_url,
963            git_ref: "main".to_string(),
964            namespace: "remote-ns".to_string(),
965            pin: Some(wrong_pin.clone()),
966        };
967
968        let err = run_sync_remote(repo_dir.path(), &remote, false)
969            .await
970            .expect_err("run_sync_remote must fail on hash mismatch");
971
972        let err_msg = err.to_string();
973        assert!(
974            err_msg.contains("hash mismatch") || err_msg.contains("sha256:"),
975            "error must mention hash mismatch, got: {err_msg}"
976        );
977
978        // Cache must NOT have been written (fail-closed).
979        let cache = repo_dir.path().join(".khive/kg/remotes/upstream");
980        assert!(
981            !cache.join("entities.ndjson").exists(),
982            "entities.ndjson must NOT exist after mismatch"
983        );
984        assert!(
985            !cache.join("meta.json").exists(),
986            "meta.json must NOT exist after mismatch"
987        );
988    }
989
990    /// F201-3: `run_sync_remote` with no pin still proceeds and writes `meta.json`
991    /// (auditability even without a pin — ADR-037 §hash-requirement).
992    #[tokio::test]
993    async fn run_sync_remote_no_pin_proceeds_and_writes_meta() {
994        let remote_dir = TempDir::new().unwrap();
995        let repo_dir = TempDir::new().unwrap();
996
997        let id_c = "cccccccc-cccc-cccc-cccc-cccccccccccc";
998        let entities = format!(
999            r#"{{"id":"{id_c}","kind":"concept","name":"Pinless","properties":{{}},"tags":[]}}"#
1000        );
1001
1002        let remote_url = make_git_remote(remote_dir.path(), &entities, "");
1003
1004        let remote = RemoteConfig {
1005            name: "no-pin-remote".to_string(),
1006            url: remote_url,
1007            git_ref: "main".to_string(),
1008            namespace: "remote-ns".to_string(),
1009            pin: None,
1010        };
1011
1012        let report = run_sync_remote(repo_dir.path(), &remote, false)
1013            .await
1014            .expect("run_sync_remote must succeed with no pin");
1015
1016        assert_eq!(report.entities, 1);
1017        assert!(
1018            report.content_hash.starts_with("sha256:"),
1019            "content_hash must have sha256: prefix even without pin"
1020        );
1021
1022        let cache = repo_dir.path().join(".khive/kg/remotes/no-pin-remote");
1023        assert!(
1024            cache.join("meta.json").exists(),
1025            "meta.json must be written even when pin is absent"
1026        );
1027    }
1028
1029    /// F201-4: `--repin` skips pin comparison and returns the actual hash,
1030    /// allowing the caller to update `schema.yaml`.
1031    #[tokio::test]
1032    async fn run_sync_remote_repin_updates_hash_ignoring_old_pin() {
1033        let remote_dir = TempDir::new().unwrap();
1034        let repo_dir = TempDir::new().unwrap();
1035
1036        let id_d = "dddddddd-dddd-dddd-dddd-dddddddddddd";
1037        let entities = format!(
1038            r#"{{"id":"{id_d}","kind":"concept","name":"RepinTarget","properties":{{}},"tags":[]}}"#
1039        );
1040
1041        let remote_url = make_git_remote(remote_dir.path(), &entities, "");
1042        let actual_hash = compute_pin(&entities, "", "repin-ns");
1043
1044        // Deliberately stale/wrong pin — repin must ignore it.
1045        let stale_pin = SnapshotId::from_hash(&"f".repeat(64)).unwrap();
1046
1047        let remote = RemoteConfig {
1048            name: "repinned".to_string(),
1049            url: remote_url,
1050            git_ref: "main".to_string(),
1051            namespace: "repin-ns".to_string(),
1052            pin: Some(stale_pin),
1053        };
1054
1055        let report = run_sync_remote(repo_dir.path(), &remote, true)
1056            .await
1057            .expect("repin must succeed even with wrong existing pin");
1058
1059        assert!(report.repinned, "repinned flag must be true");
1060        assert_eq!(
1061            report.content_hash,
1062            actual_hash.as_str(),
1063            "repinned hash must be the actual fetched archive hash"
1064        );
1065
1066        // Cache must be populated.
1067        let cache = repo_dir.path().join(".khive/kg/remotes/repinned");
1068        assert!(cache.join("entities.ndjson").exists());
1069        assert!(cache.join("meta.json").exists());
1070    }
1071}