1use 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#[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#[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 #[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
92fn 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#[derive(Debug, Serialize)]
102pub struct SyncReport {
103 pub entities: usize,
104 pub edges: usize,
105 pub db_path: String,
106}
107
108#[derive(Debug, Clone)]
113pub struct RemoteConfig {
114 pub name: String,
117 pub url: String,
119 pub git_ref: String,
121 pub namespace: String,
123 pub pin: Option<SnapshotId>,
126}
127
128#[derive(Debug, Serialize)]
130pub struct RemoteSyncReport {
131 pub entities: usize,
132 pub edges: usize,
133 pub cache_dir: String,
135 pub meta_path: String,
137 pub content_hash: String,
139 pub repinned: bool,
142}
143
144#[derive(Debug, Serialize)]
147struct MetaJson {
148 fetched_at: String,
150 git_ref: String,
152 commit_sha: String,
154 content_hash: String,
156}
157
158pub async fn run_sync_remote(
181 repo_root: &Path,
182 remote: &RemoteConfig,
183 repin: bool,
184) -> Result<RemoteSyncReport> {
185 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 let entities_ndjson: Vec<NdjsonEntity>;
194 let edges_ndjson: Vec<NdjsonEdge>;
195 let commit_sha: String;
196
197 {
198 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 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 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 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 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 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 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 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 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 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
321fn 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
335fn 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
387fn 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
407fn 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
435pub 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 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_wal(&runtime)
480 .await
481 .context("checkpoint WAL before rename")?;
482
483 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 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 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#[cfg(test)]
652mod tests {
653 use super::*;
654 use tempfile::TempDir;
655
656 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 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 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 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 #[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 #[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 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 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 #[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 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 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 #[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 #[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 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 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}