1use std::hash::{Hash, Hasher};
2use std::path::Path;
3
4use lean_ctx_fs::stable_hash::StableHasher;
5
6pub fn hash_project_root(root: &str) -> String {
17 let root = crate::core::pathutil::normalize_tool_path(root);
25 let mut hasher = StableHasher::new();
26 root.hash(&mut hasher);
27
28 if let Some(identity) = identity_at(&root, Worktrees::Keep) {
29 identity.hash(&mut hasher);
30 }
31
32 format!("{:016x}", hasher.finish())
33}
34
35pub(crate) fn hash_path_only(root: &str) -> String {
38 let root = crate::core::pathutil::normalize_tool_path(root);
39 let mut hasher = StableHasher::new();
40 root.hash(&mut hasher);
41 format!("{:016x}", hasher.finish())
42}
43
44pub fn project_identity(root: &str) -> Option<String> {
62 identity_at(root, Worktrees::Follow)
63}
64
65pub fn origin_identity(root: &str) -> Option<String> {
69 git_remote_url(Path::new(root), Worktrees::Follow).map(|url| format!("git:{url}"))
70}
71
72#[derive(Clone, Copy, PartialEq, Eq)]
74enum Worktrees {
75 Follow,
76 Keep,
79}
80
81fn identity_at(root: &str, worktrees: Worktrees) -> Option<String> {
82 let root = Path::new(root);
83
84 if let Some(id) = explicit_identity_file(root) {
88 return Some(format!("explicit:{id}"));
89 }
90 if let Some(url) = git_remote_url(root, worktrees) {
91 return Some(format!("git:{url}"));
92 }
93 if let Some(name) = cargo_package_name(root) {
94 return Some(format!("cargo:{name}"));
95 }
96 if let Some(name) = npm_package_name(root) {
97 return Some(format!("npm:{name}"));
98 }
99 if let Some(name) = pyproject_name(root) {
100 return Some(format!("python:{name}"));
101 }
102 if let Some(module) = go_module(root) {
103 return Some(format!("go:{module}"));
104 }
105 if let Some(name) = composer_name(root) {
106 return Some(format!("composer:{name}"));
107 }
108 if let Some(name) = gradle_project(root) {
109 return Some(format!("gradle:{name}"));
110 }
111 if let Some(name) = dotnet_solution(root) {
112 return Some(format!("dotnet:{name}"));
113 }
114
115 None
116}
117
118pub(crate) fn legacy_unnormalized_hashes(root: &str) -> Vec<String> {
125 let normalized = crate::core::pathutil::normalize_tool_path(root);
126 if normalized == root {
127 return Vec::new();
128 }
129
130 let mut composite = StableHasher::new();
131 root.hash(&mut composite);
132 if let Some(identity) = identity_at(root, Worktrees::Keep) {
133 identity.hash(&mut composite);
134 }
135
136 let mut path_only = StableHasher::new();
137 root.hash(&mut path_only);
138
139 vec![
140 format!("{:016x}", composite.finish()),
141 format!("{:016x}", path_only.finish()),
142 ]
143}
144
145pub(crate) fn migrate_if_needed(old_hash: &str, new_hash: &str, project_root: &str) {
150 if old_hash == new_hash {
151 return;
152 }
153
154 let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() else {
155 return;
156 };
157
158 let old_dir = data_dir.join("knowledge").join(old_hash);
159 let new_dir = data_dir.join("knowledge").join(new_hash);
160
161 if !old_dir.exists() || new_dir.exists() {
162 return;
163 }
164
165 if !verify_ownership(&old_dir, project_root) {
166 return;
167 }
168
169 let staging = data_dir.join("migrations").join("knowledge-staging");
173 let migrated = std::fs::create_dir_all(&staging)
174 .and_then(|()| {
175 tempfile::Builder::new()
176 .prefix(new_hash)
177 .tempdir_in(&staging)
178 })
179 .map_err(|e| e.to_string())
180 .and_then(|staged| {
181 copy_dir_contents(&old_dir, staged.path())?;
182 std::fs::rename(staged.path(), &new_dir).map_err(|e| e.to_string())?;
183 let _ = staged.keep();
184 Ok(())
185 });
186 if let Err(e) = migrated {
187 tracing::error!("lean-ctx: knowledge migration failed: {e}");
188 }
189}
190
191#[cfg(test)]
192thread_local! {
193 static COPY_FAILS_AFTER_FIRST_FILE: std::cell::Cell<bool> =
195 const { std::cell::Cell::new(false) };
196}
197
198fn explicit_identity_file(root: &Path) -> Option<String> {
203 let path = root.join(".lean-ctx-id");
204 let content = std::fs::read_to_string(path).ok()?;
205 let id = content.trim().to_string();
206 if id.is_empty() || id.len() > 256 {
207 return None;
208 }
209 Some(id)
210}
211
212fn git_remote_url(root: &Path, worktrees: Worktrees) -> Option<String> {
213 let config = match worktrees {
214 Worktrees::Follow => git_config_path(root)?,
215 Worktrees::Keep => root.join(".git").join("config"),
216 };
217 let content = std::fs::read_to_string(config).ok()?;
218
219 let mut in_origin = false;
220 for line in content.lines() {
221 let trimmed = line.trim();
222 if trimmed.starts_with('[') {
223 in_origin = trimmed == r#"[remote "origin"]"#;
224 continue;
225 }
226 if in_origin && let Some(url) = trimmed.strip_prefix("url") {
227 let url = url.trim_start_matches([' ', '=']);
228 let url = url.trim();
229 if !url.is_empty() {
230 return Some(normalize_git_url(url));
231 }
232 }
233 }
234 None
235}
236
237fn git_config_path(root: &Path) -> Option<std::path::PathBuf> {
241 let dot_git = root.join(".git");
242 if dot_git.is_dir() {
243 return Some(dot_git.join("config"));
244 }
245 let pointer = std::fs::read_to_string(&dot_git).ok()?;
246 let gitdir = pointer
247 .lines()
248 .find_map(|line| line.strip_prefix("gitdir:"))?;
249 let gitdir = root.join(gitdir.trim());
250 let common = match std::fs::read_to_string(gitdir.join("commondir")) {
251 Ok(common) => gitdir.join(common.trim()),
252 Err(_) => gitdir,
253 };
254 Some(common.join("config"))
255}
256
257fn normalize_git_url(url: &str) -> String {
258 let url = url.trim_end_matches(".git");
259 let url = url
260 .strip_prefix("git@")
261 .map_or_else(|| url.to_string(), |s| s.replacen(':', "/", 1));
262 url.to_lowercase()
263}
264
265fn cargo_package_name(root: &Path) -> Option<String> {
266 extract_toml_value(&root.join("Cargo.toml"), "name", Some("[package]"))
267}
268
269fn npm_package_name(root: &Path) -> Option<String> {
270 extract_json_string_field(&root.join("package.json"), "name")
271}
272
273fn pyproject_name(root: &Path) -> Option<String> {
274 extract_toml_value(&root.join("pyproject.toml"), "name", Some("[project]"))
275 .or_else(|| extract_toml_value(&root.join("pyproject.toml"), "name", Some("[tool.poetry]")))
276}
277
278fn go_module(root: &Path) -> Option<String> {
279 let content = std::fs::read_to_string(root.join("go.mod")).ok()?;
280 let first = content.lines().next()?;
281 first.strip_prefix("module").map(|s| s.trim().to_string())
282}
283
284fn composer_name(root: &Path) -> Option<String> {
285 extract_json_string_field(&root.join("composer.json"), "name")
286}
287
288fn gradle_project(root: &Path) -> Option<String> {
289 let settings = root.join("settings.gradle");
290 let settings_kts = root.join("settings.gradle.kts");
291
292 let path = if settings.exists() {
293 settings
294 } else if settings_kts.exists() {
295 settings_kts
296 } else {
297 return None;
298 };
299
300 let content = std::fs::read_to_string(path).ok()?;
301 for line in content.lines() {
302 let trimmed = line.trim();
303 if let Some(rest) = trimmed.strip_prefix("rootProject.name") {
304 let rest = rest.trim_start_matches([' ', '=']);
305 let name = rest.trim().trim_matches(['\'', '"']);
306 if !name.is_empty() {
307 return Some(name.to_string());
308 }
309 }
310 }
311 None
312}
313
314fn dotnet_solution(root: &Path) -> Option<String> {
315 let entries = std::fs::read_dir(root).ok()?;
316 for entry in entries.flatten() {
317 if let Some(ext) = entry.path().extension()
318 && ext == "sln"
319 {
320 return entry
321 .path()
322 .file_stem()
323 .and_then(|s| s.to_str())
324 .map(String::from);
325 }
326 }
327 None
328}
329
330fn extract_toml_value(path: &Path, key: &str, section: Option<&str>) -> Option<String> {
335 let content = std::fs::read_to_string(path).ok()?;
336 let mut in_section = section.is_none();
337 let target_section = section.unwrap_or("");
338
339 for line in content.lines() {
340 let trimmed = line.trim();
341
342 if trimmed.starts_with('[') {
343 in_section = trimmed == target_section;
344 continue;
345 }
346
347 if in_section && let Some(rest) = trimmed.strip_prefix(key) {
348 let rest = rest.trim_start();
349 if let Some(rest) = rest.strip_prefix('=') {
350 let val = rest.trim().trim_matches('"');
351 if !val.is_empty() {
352 return Some(val.to_string());
353 }
354 }
355 }
356 }
357 None
358}
359
360fn extract_json_string_field(path: &Path, field: &str) -> Option<String> {
361 let content = std::fs::read_to_string(path).ok()?;
362 let needle = format!("\"{field}\"");
363 for line in content.lines() {
364 let trimmed = line.trim();
365 if let Some(rest) = trimmed.strip_prefix(&needle) {
366 let rest = rest.trim_start_matches([' ', ':']);
367 let val = rest.trim().trim_start_matches('"');
368 if let Some(end) = val.find('"') {
369 let name = &val[..end];
370 if !name.is_empty() {
371 return Some(name.to_string());
372 }
373 }
374 }
375 }
376 None
377}
378
379fn verify_ownership(old_dir: &Path, project_root: &str) -> bool {
384 let knowledge_path = old_dir.join("knowledge.json");
385 let Ok(content) = std::fs::read_to_string(&knowledge_path) else {
386 return true;
387 };
388
389 let stored_root: Option<String> = serde_json::from_str::<serde_json::Value>(&content)
390 .ok()
391 .and_then(|v| v.get("project_root")?.as_str().map(String::from));
392
393 match stored_root {
394 Some(stored) if !stored.is_empty() => stored == project_root,
395 _ => true,
396 }
397}
398
399fn copy_dir_contents(src: &Path, dst: &Path) -> Result<(), String> {
400 std::fs::create_dir_all(dst).map_err(|e| e.to_string())?;
401
402 for entry in std::fs::read_dir(src).map_err(|e| e.to_string())?.flatten() {
403 let src_path = entry.path();
404 let dst_path = dst.join(entry.file_name());
405
406 if src_path.is_dir() {
407 copy_dir_contents(&src_path, &dst_path)?;
408 } else {
409 std::fs::copy(&src_path, &dst_path).map_err(|e| e.to_string())?;
410 #[cfg(test)]
411 if COPY_FAILS_AFTER_FIRST_FILE.with(|armed| armed.replace(false)) {
412 return Err("injected crash during the copy".into());
413 }
414 }
415 }
416 Ok(())
417}
418
419#[cfg(test)]
424mod tests {
425 use super::*;
426 use std::fs;
427
428 #[test]
429 fn path_only_matches_legacy_behaviour() {
430 let h = hash_path_only("/workspace");
431 assert_eq!(h.len(), 16);
432 let h2 = hash_path_only("/workspace");
433 assert_eq!(h, h2);
434 }
435
436 #[test]
437 fn windows_slash_and_backslash_hash_identically() {
438 assert_eq!(
442 hash_project_root(r"D:\repos\oref-examples"),
443 hash_project_root("D:/repos/oref-examples"),
444 );
445 assert_eq!(
446 hash_path_only(r"D:\repos\oref-examples"),
447 hash_path_only("D:/repos/oref-examples"),
448 );
449 }
450
451 #[test]
452 fn trailing_slash_does_not_split_hash() {
453 assert_eq!(
454 hash_project_root("/home/user/project/"),
455 hash_project_root("/home/user/project"),
456 );
457 }
458
459 #[test]
463 fn store_names_are_pinned() {
464 let root = "/nonexistent/lean-ctx-pin";
465 assert_eq!(hash_path_only(root), "828234413450efd8");
466 assert_eq!(hash_project_root(root), "828234413450efd8");
467 let mut composite = StableHasher::new();
468 root.hash(&mut composite);
469 "cargo:pinned".hash(&mut composite);
470 assert_eq!(format!("{:016x}", composite.finish()), "24e8039aa75448a6");
471 }
472
473 #[test]
474 fn legacy_unnormalized_hashes_empty_for_clean_posix() {
475 {
478 let found = &legacy_unnormalized_hashes("/home/user/project");
479 assert!(found.is_empty(), "{found:?}");
480 }
481 }
482
483 #[test]
484 fn legacy_unnormalized_hashes_present_for_backslash_path() {
485 let legacy = legacy_unnormalized_hashes(r"D:\repos\oref-examples");
488 assert_eq!(legacy.len(), 2, "composite + path-only raw hashes");
489 assert!(!legacy.contains(&hash_project_root(r"D:\repos\oref-examples")));
491 }
492
493 #[test]
494 fn composite_differs_when_identity_present() {
495 let dir = tempfile::tempdir().unwrap();
496 let root = dir.path().to_str().unwrap();
497
498 let old = hash_path_only(root);
499 let no_identity = hash_project_root(root);
500 assert_eq!(old, no_identity, "without identity, hashes must match");
501
502 fs::create_dir_all(dir.path().join(".git")).unwrap();
503 fs::write(
504 dir.path().join(".git").join("config"),
505 "[remote \"origin\"]\n\turl = git@github.com:user/my-repo.git\n",
506 )
507 .unwrap();
508
509 let with_identity = hash_project_root(root);
510 assert_ne!(old, with_identity, "identity must change hash");
511 }
512
513 #[test]
514 fn docker_collision_avoided() {
515 let dir_a = tempfile::tempdir().unwrap();
516 let dir_b = tempfile::tempdir().unwrap();
517
518 let shared_path = "/workspace";
519
520 fs::create_dir_all(dir_a.path().join(".git")).unwrap();
521 fs::write(
522 dir_a.path().join(".git").join("config"),
523 "[remote \"origin\"]\n\turl = git@github.com:user/repo-a.git\n",
524 )
525 .unwrap();
526
527 fs::create_dir_all(dir_b.path().join(".git")).unwrap();
528 fs::write(
529 dir_b.path().join(".git").join("config"),
530 "[remote \"origin\"]\n\turl = git@github.com:user/repo-b.git\n",
531 )
532 .unwrap();
533
534 let hash_a = {
535 let mut hasher = StableHasher::new();
536 shared_path.hash(&mut hasher);
537 let id = project_identity(dir_a.path().to_str().unwrap()).unwrap();
538 id.hash(&mut hasher);
539 format!("{:016x}", hasher.finish())
540 };
541 let hash_b = {
542 let mut hasher = StableHasher::new();
543 shared_path.hash(&mut hasher);
544 let id = project_identity(dir_b.path().to_str().unwrap()).unwrap();
545 id.hash(&mut hasher);
546 format!("{:016x}", hasher.finish())
547 };
548
549 assert_ne!(
550 hash_a, hash_b,
551 "different repos at same path must produce different hashes"
552 );
553 }
554
555 #[test]
556 fn git_url_normalization() {
557 assert_eq!(
558 normalize_git_url("git@github.com:User/Repo.git"),
559 "github.com/user/repo"
560 );
561 assert_eq!(
562 normalize_git_url("https://github.com/User/Repo.git"),
563 "https://github.com/user/repo"
564 );
565 assert_eq!(
566 normalize_git_url("git@gitlab.com:org/sub/project.git"),
567 "gitlab.com/org/sub/project"
568 );
569 }
570
571 #[test]
572 fn identity_from_cargo_toml() {
573 let dir = tempfile::tempdir().unwrap();
574 fs::write(
575 dir.path().join("Cargo.toml"),
576 "[package]\nname = \"my-crate\"\nversion = \"0.1.0\"\n",
577 )
578 .unwrap();
579
580 let id = project_identity(dir.path().to_str().unwrap());
581 assert_eq!(id, Some("cargo:my-crate".into()));
582 }
583
584 #[test]
585 fn identity_from_package_json() {
586 let dir = tempfile::tempdir().unwrap();
587 fs::write(
588 dir.path().join("package.json"),
589 "{\n \"name\": \"@scope/my-app\",\n \"version\": \"1.0.0\"\n}\n",
590 )
591 .unwrap();
592
593 let id = project_identity(dir.path().to_str().unwrap());
594 assert_eq!(id, Some("npm:@scope/my-app".into()));
595 }
596
597 #[test]
598 fn identity_from_pyproject() {
599 let dir = tempfile::tempdir().unwrap();
600 fs::write(
601 dir.path().join("pyproject.toml"),
602 "[project]\nname = \"my-python-lib\"\nversion = \"2.0\"\n",
603 )
604 .unwrap();
605
606 let id = project_identity(dir.path().to_str().unwrap());
607 assert_eq!(id, Some("python:my-python-lib".into()));
608 }
609
610 #[test]
611 fn identity_from_poetry_pyproject() {
612 let dir = tempfile::tempdir().unwrap();
613 fs::write(
614 dir.path().join("pyproject.toml"),
615 "[tool.poetry]\nname = \"poetry-app\"\nversion = \"1.0\"\n",
616 )
617 .unwrap();
618
619 let id = project_identity(dir.path().to_str().unwrap());
620 assert_eq!(id, Some("python:poetry-app".into()));
621 }
622
623 #[test]
624 fn identity_from_go_mod() {
625 let dir = tempfile::tempdir().unwrap();
626 fs::write(
627 dir.path().join("go.mod"),
628 "module github.com/user/myservice\n\ngo 1.21\n",
629 )
630 .unwrap();
631
632 let id = project_identity(dir.path().to_str().unwrap());
633 assert_eq!(id, Some("go:github.com/user/myservice".into()));
634 }
635
636 #[test]
637 fn identity_from_composer() {
638 let dir = tempfile::tempdir().unwrap();
639 fs::write(
640 dir.path().join("composer.json"),
641 "{\n \"name\": \"vendor/my-php-lib\"\n}\n",
642 )
643 .unwrap();
644
645 let id = project_identity(dir.path().to_str().unwrap());
646 assert_eq!(id, Some("composer:vendor/my-php-lib".into()));
647 }
648
649 #[test]
650 fn identity_from_gradle() {
651 let dir = tempfile::tempdir().unwrap();
652 fs::write(
653 dir.path().join("settings.gradle"),
654 "rootProject.name = 'my-java-app'\n",
655 )
656 .unwrap();
657
658 let id = project_identity(dir.path().to_str().unwrap());
659 assert_eq!(id, Some("gradle:my-java-app".into()));
660 }
661
662 #[test]
663 fn identity_from_dotnet_sln() {
664 let dir = tempfile::tempdir().unwrap();
665 fs::write(dir.path().join("MyApp.sln"), "").unwrap();
666
667 let id = project_identity(dir.path().to_str().unwrap());
668 assert_eq!(id, Some("dotnet:MyApp".into()));
669 }
670
671 #[test]
672 fn identity_git_takes_priority_over_cargo() {
673 let dir = tempfile::tempdir().unwrap();
674 fs::create_dir_all(dir.path().join(".git")).unwrap();
675 fs::write(
676 dir.path().join(".git").join("config"),
677 "[remote \"origin\"]\n\turl = git@github.com:user/repo.git\n",
678 )
679 .unwrap();
680 fs::write(
681 dir.path().join("Cargo.toml"),
682 "[package]\nname = \"my-crate\"\n",
683 )
684 .unwrap();
685
686 let id = project_identity(dir.path().to_str().unwrap());
687 assert_eq!(id, Some("git:github.com/user/repo".into()));
688 }
689
690 #[test]
693 fn a_linked_worktree_has_the_origin_of_its_main_checkout() {
694 let tmp = tempfile::tempdir().unwrap();
695 let main = tmp.path().join("main");
696 let admin = main.join(".git").join("worktrees").join("wt");
697 fs::create_dir_all(&admin).unwrap();
698 fs::write(
699 main.join(".git").join("config"),
700 "[core]\n\tbare = false\n[remote \"origin\"]\n\turl = git@github.com:yves/hive.git\n",
701 )
702 .unwrap();
703 fs::write(admin.join("commondir"), "../..\n").unwrap();
704 let worktree = tmp.path().join("wt");
705 fs::create_dir_all(&worktree).unwrap();
706 fs::write(
707 worktree.join(".git"),
708 format!("gitdir: {}\n", admin.display()),
709 )
710 .unwrap();
711 fs::write(worktree.join("Cargo.toml"), "[package]\nname = \"hive\"\n").unwrap();
712 let worktree = worktree.to_str().unwrap();
713
714 assert_eq!(
715 project_identity(worktree),
716 project_identity(main.to_str().unwrap())
717 );
718 assert_eq!(
719 project_identity(worktree),
720 Some("git:github.com/yves/hive".into())
721 );
722 assert_eq!(
724 identity_at(worktree, Worktrees::Keep),
725 Some("cargo:hive".into())
726 );
727 let mut before = StableHasher::new();
729 crate::core::pathutil::normalize_tool_path(worktree).hash(&mut before);
730 "cargo:hive".hash(&mut before);
731 assert_eq!(
732 hash_project_root(worktree),
733 format!("{:016x}", before.finish())
734 );
735 }
736
737 #[test]
739 fn a_relative_gitdir_without_commondir_reads_its_own_config() {
740 let tmp = tempfile::tempdir().unwrap();
741 let admin = tmp.path().join("modules").join("sub");
742 fs::create_dir_all(&admin).unwrap();
743 fs::write(
744 admin.join("config"),
745 "[remote \"origin\"]\n\turl = https://gitlab.example.com/team/sub.git\n",
746 )
747 .unwrap();
748 let checkout = tmp.path().join("sub");
749 fs::create_dir_all(&checkout).unwrap();
750 fs::write(checkout.join(".git"), "gitdir: ../modules/sub\n").unwrap();
751 assert_eq!(
752 project_identity(checkout.to_str().unwrap()),
753 Some("git:https://gitlab.example.com/team/sub".into())
754 );
755 }
756
757 #[test]
758 fn no_identity_for_empty_dir() {
759 let dir = tempfile::tempdir().unwrap();
760 let id = project_identity(dir.path().to_str().unwrap());
761 assert!(id.is_none());
762 }
763
764 #[test]
765 fn identity_from_lean_ctx_id() {
766 let dir = tempfile::tempdir().unwrap();
767 fs::write(dir.path().join(".lean-ctx-id"), "my-docker-project\n").unwrap();
768
769 let id = project_identity(dir.path().to_str().unwrap());
770 assert_eq!(id, Some("explicit:my-docker-project".into()));
771 }
772
773 #[test]
774 fn lean_ctx_id_takes_priority_over_git() {
775 let dir = tempfile::tempdir().unwrap();
776 fs::write(dir.path().join(".lean-ctx-id"), "override-name").unwrap();
777 fs::create_dir_all(dir.path().join(".git")).unwrap();
778 fs::write(
779 dir.path().join(".git").join("config"),
780 "[remote \"origin\"]\n\turl = git@github.com:user/repo.git\n",
781 )
782 .unwrap();
783
784 let id = project_identity(dir.path().to_str().unwrap());
785 assert_eq!(id, Some("explicit:override-name".into()));
786 }
787
788 #[test]
789 fn docker_different_projects_same_path_with_lean_ctx_id() {
790 let dir_a = tempfile::tempdir().unwrap();
791 let dir_b = tempfile::tempdir().unwrap();
792
793 fs::write(dir_a.path().join(".lean-ctx-id"), "project-alpha").unwrap();
794 fs::write(dir_b.path().join(".lean-ctx-id"), "project-beta").unwrap();
795
796 let id_a = project_identity(dir_a.path().to_str().unwrap());
797 let id_b = project_identity(dir_b.path().to_str().unwrap());
798 assert_ne!(id_a, id_b);
799 }
800
801 #[test]
802 fn fallback_hash_equals_legacy_when_no_identity() {
803 let h_new = hash_project_root("/some/path/without/project");
804 let h_old = hash_path_only("/some/path/without/project");
805 assert_eq!(
806 h_new, h_old,
807 "must be backward-compatible when no identity is found"
808 );
809 }
810
811 #[test]
812 fn migration_copies_files() {
813 let tmp = tempfile::tempdir().unwrap();
814 let knowledge_base = tmp.path().join("knowledge");
815 let old_hash = "aaaa000000000000";
816 let new_hash = "bbbb111111111111";
817
818 let old_dir = knowledge_base.join(old_hash);
819 let new_dir = knowledge_base.join(new_hash);
820 fs::create_dir_all(&old_dir).unwrap();
821 fs::write(
822 old_dir.join("knowledge.json"),
823 r#"{"project_root":"/workspace"}"#,
824 )
825 .unwrap();
826 fs::write(old_dir.join("gotchas.json"), "{}").unwrap();
827
828 copy_dir_contents(&old_dir, &new_dir).unwrap();
829
830 assert!(new_dir.join("knowledge.json").exists());
831 assert!(new_dir.join("gotchas.json").exists());
832 assert!(
833 old_dir.join("knowledge.json").exists(),
834 "old dir must remain intact"
835 );
836 }
837
838 #[test]
842 fn an_interrupted_migration_leaves_no_partial_partition_and_resumes() {
843 let _data = crate::core::data_dir::isolated_data_dir();
844 let knowledge_base = crate::core::data_dir::lean_ctx_data_dir()
845 .unwrap()
846 .join("knowledge");
847 let (old_hash, new_hash) = ("aaaa000000000000", "bbbb111111111111");
848 let old_dir = knowledge_base.join(old_hash);
849 let new_dir = knowledge_base.join(new_hash);
850 fs::create_dir_all(&old_dir).unwrap();
851 fs::write(
852 old_dir.join("knowledge.json"),
853 r#"{"project_root":"/workspace"}"#,
854 )
855 .unwrap();
856 fs::write(old_dir.join("gotchas.json"), "{}").unwrap();
857
858 COPY_FAILS_AFTER_FIRST_FILE.with(|armed| armed.set(true));
859 migrate_if_needed(old_hash, new_hash, "/workspace");
860 assert!(!new_dir.exists(), "no partial partition is left behind");
861
862 migrate_if_needed(old_hash, new_hash, "/workspace");
863 let mut copied: Vec<_> = fs::read_dir(&new_dir)
864 .unwrap()
865 .map(|entry| entry.unwrap().file_name())
866 .collect();
867 copied.sort();
868 assert_eq!(copied, ["gotchas.json", "knowledge.json"]);
869 assert!(old_dir.join("knowledge.json").exists());
870 assert!(old_dir.join("gotchas.json").exists());
871 }
872
873 #[test]
874 fn ownership_check_rejects_foreign_data() {
875 let tmp = tempfile::tempdir().unwrap();
876 let dir = tmp.path().join("knowledge").join("hash123");
877 fs::create_dir_all(&dir).unwrap();
878 fs::write(
879 dir.join("knowledge.json"),
880 r#"{"project_root":"/other/project"}"#,
881 )
882 .unwrap();
883
884 assert!(!verify_ownership(&dir, "/workspace"));
885 }
886
887 #[test]
888 fn ownership_check_accepts_matching_root() {
889 let tmp = tempfile::tempdir().unwrap();
890 let dir = tmp.path().join("knowledge").join("hash123");
891 fs::create_dir_all(&dir).unwrap();
892 fs::write(
893 dir.join("knowledge.json"),
894 r#"{"project_root":"/workspace"}"#,
895 )
896 .unwrap();
897
898 assert!(verify_ownership(&dir, "/workspace"));
899 }
900
901 #[test]
902 fn ownership_check_accepts_empty_stored_root() {
903 let tmp = tempfile::tempdir().unwrap();
904 let dir = tmp.path().join("knowledge").join("hash123");
905 fs::create_dir_all(&dir).unwrap();
906 fs::write(dir.join("knowledge.json"), r#"{"project_root":""}"#).unwrap();
907
908 assert!(verify_ownership(&dir, "/workspace"));
909 }
910
911 #[test]
912 fn ownership_check_accepts_missing_knowledge_json() {
913 let tmp = tempfile::tempdir().unwrap();
914 let dir = tmp.path().join("knowledge").join("hash123");
915 fs::create_dir_all(&dir).unwrap();
916
917 assert!(verify_ownership(&dir, "/workspace"));
918 }
919}