Skip to main content

lean_ctx/core/
project_hash.rs

1use std::collections::hash_map::DefaultHasher;
2use std::hash::{Hash, Hasher};
3use std::path::Path;
4
5/// Computes a composite hash from the project root path and any detected
6/// project identity markers (git remote, manifest file, etc.).
7///
8/// This prevents hash collisions when different projects share the same
9/// mount path (e.g. Docker volumes at `/workspace`).
10pub(crate) fn hash_project_root(root: &str) -> String {
11    // Normalize the path separator/casing first so the SAME directory always
12    // produces the SAME hash regardless of which interface resolved it. On
13    // Windows the MCP server reports forward slashes (`D:/repo`) while the CLI
14    // reports backslashes (`D:\repo`); without normalization these hash to two
15    // different project stores and facts written via one are invisible to the
16    // other (issue #325). `normalize_tool_path` is a no-op for clean POSIX
17    // paths, so non-Windows hashes are unaffected.
18    let root = crate::core::pathutil::normalize_tool_path(root);
19    let mut hasher = DefaultHasher::new();
20    root.hash(&mut hasher);
21
22    if let Some(identity) = project_identity(&root) {
23        identity.hash(&mut hasher);
24    }
25
26    format!("{:016x}", hasher.finish())
27}
28
29/// Legacy path-only hash used before v3.3.2.
30/// Kept for auto-migration from old knowledge directories.
31pub(crate) fn hash_path_only(root: &str) -> String {
32    let root = crate::core::pathutil::normalize_tool_path(root);
33    let mut hasher = DefaultHasher::new();
34    root.hash(&mut hasher);
35    format!("{:016x}", hasher.finish())
36}
37
38/// Extracts a stable project identity string from well-known config files.
39///
40/// Checks (in priority order):
41///   1. `.git/config`   → remote "origin" URL
42///   2. `Cargo.toml`    → `[package] name`
43///   3. `package.json`  → `"name"` field
44///   4. `pyproject.toml`→ `[project] name`
45///   5. `go.mod`        → `module` path
46///   6. `composer.json` → `"name"` field
47///   7. `build.gradle`  / `build.gradle.kts` → existence as a marker
48///   8. `*.sln`         → first `.sln` filename
49///
50/// Returns `None` when no identity marker is found, in which case
51/// the hash falls back to path-only (same behaviour as pre-3.3.2).
52pub(crate) fn project_identity(root: &str) -> Option<String> {
53    let root = Path::new(root);
54
55    // Explicit identity file — highest priority. Ideal for Docker containers
56    // where the mount path (/workspace) is reused across different projects.
57    // Users create `.lean-ctx-id` with a unique name to disambiguate.
58    if let Some(id) = explicit_identity_file(root) {
59        return Some(format!("explicit:{id}"));
60    }
61    if let Some(url) = git_remote_url(root) {
62        return Some(format!("git:{url}"));
63    }
64    if let Some(name) = cargo_package_name(root) {
65        return Some(format!("cargo:{name}"));
66    }
67    if let Some(name) = npm_package_name(root) {
68        return Some(format!("npm:{name}"));
69    }
70    if let Some(name) = pyproject_name(root) {
71        return Some(format!("python:{name}"));
72    }
73    if let Some(module) = go_module(root) {
74        return Some(format!("go:{module}"));
75    }
76    if let Some(name) = composer_name(root) {
77        return Some(format!("composer:{name}"));
78    }
79    if let Some(name) = gradle_project(root) {
80        return Some(format!("gradle:{name}"));
81    }
82    if let Some(name) = dotnet_solution(root) {
83        return Some(format!("dotnet:{name}"));
84    }
85
86    None
87}
88
89/// Hashes computed from the *raw* (un-normalized) project root, as produced
90/// before issue #325 was fixed. Used purely to detect and migrate stores that
91/// were keyed by a platform-specific path separator (most importantly Windows
92/// backslash paths written by the CLI). Returns both the composite and the
93/// path-only variant. Empty when the raw path already normalizes to itself, so
94/// callers can skip migration on POSIX where no split ever occurred.
95pub(crate) fn legacy_unnormalized_hashes(root: &str) -> Vec<String> {
96    let normalized = crate::core::pathutil::normalize_tool_path(root);
97    if normalized == root {
98        return Vec::new();
99    }
100
101    let mut composite = DefaultHasher::new();
102    root.hash(&mut composite);
103    if let Some(identity) = project_identity(root) {
104        identity.hash(&mut composite);
105    }
106
107    let mut path_only = DefaultHasher::new();
108    root.hash(&mut path_only);
109
110    vec![
111        format!("{:016x}", composite.finish()),
112        format!("{:016x}", path_only.finish()),
113    ]
114}
115
116/// Copies all files from `old_hash` dir to `new_hash` dir when the composite
117/// hash differs from the legacy path-only hash.  Leaves the old directory
118/// intact so sibling projects sharing the same mount path can still migrate
119/// their own data independently.
120pub(crate) fn migrate_if_needed(old_hash: &str, new_hash: &str, project_root: &str) {
121    if old_hash == new_hash {
122        return;
123    }
124
125    let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() else {
126        return;
127    };
128
129    let old_dir = data_dir.join("knowledge").join(old_hash);
130    let new_dir = data_dir.join("knowledge").join(new_hash);
131
132    if !old_dir.exists() || new_dir.exists() {
133        return;
134    }
135
136    if !verify_ownership(&old_dir, project_root) {
137        return;
138    }
139
140    if let Err(e) = copy_dir_contents(&old_dir, &new_dir) {
141        tracing::error!("lean-ctx: knowledge migration failed: {e}");
142    }
143}
144
145// ---------------------------------------------------------------------------
146// Identity detectors
147// ---------------------------------------------------------------------------
148
149fn explicit_identity_file(root: &Path) -> Option<String> {
150    let path = root.join(".lean-ctx-id");
151    let content = std::fs::read_to_string(path).ok()?;
152    let id = content.trim().to_string();
153    if id.is_empty() || id.len() > 256 {
154        return None;
155    }
156    Some(id)
157}
158
159fn git_remote_url(root: &Path) -> Option<String> {
160    let config = root.join(".git").join("config");
161    let content = std::fs::read_to_string(config).ok()?;
162
163    let mut in_origin = false;
164    for line in content.lines() {
165        let trimmed = line.trim();
166        if trimmed.starts_with('[') {
167            in_origin = trimmed == r#"[remote "origin"]"#;
168            continue;
169        }
170        if in_origin && let Some(url) = trimmed.strip_prefix("url") {
171            let url = url.trim_start_matches([' ', '=']);
172            let url = url.trim();
173            if !url.is_empty() {
174                return Some(normalize_git_url(url));
175            }
176        }
177    }
178    None
179}
180
181fn normalize_git_url(url: &str) -> String {
182    let url = url.trim_end_matches(".git");
183    let url = url
184        .strip_prefix("git@")
185        .map_or_else(|| url.to_string(), |s| s.replacen(':', "/", 1));
186    url.to_lowercase()
187}
188
189fn cargo_package_name(root: &Path) -> Option<String> {
190    extract_toml_value(&root.join("Cargo.toml"), "name", Some("[package]"))
191}
192
193fn npm_package_name(root: &Path) -> Option<String> {
194    extract_json_string_field(&root.join("package.json"), "name")
195}
196
197fn pyproject_name(root: &Path) -> Option<String> {
198    extract_toml_value(&root.join("pyproject.toml"), "name", Some("[project]"))
199        .or_else(|| extract_toml_value(&root.join("pyproject.toml"), "name", Some("[tool.poetry]")))
200}
201
202fn go_module(root: &Path) -> Option<String> {
203    let content = std::fs::read_to_string(root.join("go.mod")).ok()?;
204    let first = content.lines().next()?;
205    first.strip_prefix("module").map(|s| s.trim().to_string())
206}
207
208fn composer_name(root: &Path) -> Option<String> {
209    extract_json_string_field(&root.join("composer.json"), "name")
210}
211
212fn gradle_project(root: &Path) -> Option<String> {
213    let settings = root.join("settings.gradle");
214    let settings_kts = root.join("settings.gradle.kts");
215
216    let path = if settings.exists() {
217        settings
218    } else if settings_kts.exists() {
219        settings_kts
220    } else {
221        return None;
222    };
223
224    let content = std::fs::read_to_string(path).ok()?;
225    for line in content.lines() {
226        let trimmed = line.trim();
227        if let Some(rest) = trimmed.strip_prefix("rootProject.name") {
228            let rest = rest.trim_start_matches([' ', '=']);
229            let name = rest.trim().trim_matches(['\'', '"']);
230            if !name.is_empty() {
231                return Some(name.to_string());
232            }
233        }
234    }
235    None
236}
237
238fn dotnet_solution(root: &Path) -> Option<String> {
239    let entries = std::fs::read_dir(root).ok()?;
240    for entry in entries.flatten() {
241        if let Some(ext) = entry.path().extension()
242            && ext == "sln"
243        {
244            return entry
245                .path()
246                .file_stem()
247                .and_then(|s| s.to_str())
248                .map(String::from);
249        }
250    }
251    None
252}
253
254// ---------------------------------------------------------------------------
255// TOML / JSON helpers (lightweight, no extra deps)
256// ---------------------------------------------------------------------------
257
258fn extract_toml_value(path: &Path, key: &str, section: Option<&str>) -> Option<String> {
259    let content = std::fs::read_to_string(path).ok()?;
260    let mut in_section = section.is_none();
261    let target_section = section.unwrap_or("");
262
263    for line in content.lines() {
264        let trimmed = line.trim();
265
266        if trimmed.starts_with('[') {
267            in_section = trimmed == target_section;
268            continue;
269        }
270
271        if in_section && let Some(rest) = trimmed.strip_prefix(key) {
272            let rest = rest.trim_start();
273            if let Some(rest) = rest.strip_prefix('=') {
274                let val = rest.trim().trim_matches('"');
275                if !val.is_empty() {
276                    return Some(val.to_string());
277                }
278            }
279        }
280    }
281    None
282}
283
284fn extract_json_string_field(path: &Path, field: &str) -> Option<String> {
285    let content = std::fs::read_to_string(path).ok()?;
286    let needle = format!("\"{field}\"");
287    for line in content.lines() {
288        let trimmed = line.trim();
289        if let Some(rest) = trimmed.strip_prefix(&needle) {
290            let rest = rest.trim_start_matches([' ', ':']);
291            let val = rest.trim().trim_start_matches('"');
292            if let Some(end) = val.find('"') {
293                let name = &val[..end];
294                if !name.is_empty() {
295                    return Some(name.to_string());
296                }
297            }
298        }
299    }
300    None
301}
302
303// ---------------------------------------------------------------------------
304// Migration helpers
305// ---------------------------------------------------------------------------
306
307fn verify_ownership(old_dir: &Path, project_root: &str) -> bool {
308    let knowledge_path = old_dir.join("knowledge.json");
309    let Ok(content) = std::fs::read_to_string(&knowledge_path) else {
310        return true;
311    };
312
313    let stored_root: Option<String> = serde_json::from_str::<serde_json::Value>(&content)
314        .ok()
315        .and_then(|v| v.get("project_root")?.as_str().map(String::from));
316
317    match stored_root {
318        Some(stored) if !stored.is_empty() => stored == project_root,
319        _ => true,
320    }
321}
322
323fn copy_dir_contents(src: &Path, dst: &Path) -> Result<(), String> {
324    std::fs::create_dir_all(dst).map_err(|e| e.to_string())?;
325
326    for entry in std::fs::read_dir(src).map_err(|e| e.to_string())?.flatten() {
327        let src_path = entry.path();
328        let dst_path = dst.join(entry.file_name());
329
330        if src_path.is_dir() {
331            copy_dir_contents(&src_path, &dst_path)?;
332        } else {
333            std::fs::copy(&src_path, &dst_path).map_err(|e| e.to_string())?;
334        }
335    }
336    Ok(())
337}
338
339// ---------------------------------------------------------------------------
340// Tests
341// ---------------------------------------------------------------------------
342
343#[cfg(test)]
344mod tests {
345    use super::*;
346    use std::fs;
347
348    #[test]
349    fn path_only_matches_legacy_behaviour() {
350        let h = hash_path_only("/workspace");
351        assert_eq!(h.len(), 16);
352        let h2 = hash_path_only("/workspace");
353        assert_eq!(h, h2);
354    }
355
356    #[test]
357    fn windows_slash_and_backslash_hash_identically() {
358        // Issue #325: the MCP server reports forward slashes while the CLI
359        // reports backslashes for the same Windows directory. Both must resolve
360        // to the same project hash so the knowledge store is not split.
361        assert_eq!(
362            hash_project_root(r"D:\repos\oref-examples"),
363            hash_project_root("D:/repos/oref-examples"),
364        );
365        assert_eq!(
366            hash_path_only(r"D:\repos\oref-examples"),
367            hash_path_only("D:/repos/oref-examples"),
368        );
369    }
370
371    #[test]
372    fn trailing_slash_does_not_split_hash() {
373        assert_eq!(
374            hash_project_root("/home/user/project/"),
375            hash_project_root("/home/user/project"),
376        );
377    }
378
379    #[test]
380    fn legacy_unnormalized_hashes_empty_for_clean_posix() {
381        // POSIX paths already normalize to themselves: no split ever occurred,
382        // so there is nothing to migrate.
383        assert!(legacy_unnormalized_hashes("/home/user/project").is_empty());
384    }
385
386    #[test]
387    fn legacy_unnormalized_hashes_present_for_backslash_path() {
388        // A backslash path normalizes to a different string, so the pre-fix
389        // (raw) hashes are offered for migration.
390        let legacy = legacy_unnormalized_hashes(r"D:\repos\oref-examples");
391        assert_eq!(legacy.len(), 2, "composite + path-only raw hashes");
392        // The raw path-only hash must differ from the normalized hash.
393        assert!(!legacy.contains(&hash_project_root(r"D:\repos\oref-examples")));
394    }
395
396    #[test]
397    fn composite_differs_when_identity_present() {
398        let dir = tempfile::tempdir().unwrap();
399        let root = dir.path().to_str().unwrap();
400
401        let old = hash_path_only(root);
402        let no_identity = hash_project_root(root);
403        assert_eq!(old, no_identity, "without identity, hashes must match");
404
405        fs::create_dir_all(dir.path().join(".git")).unwrap();
406        fs::write(
407            dir.path().join(".git").join("config"),
408            "[remote \"origin\"]\n\turl = git@github.com:user/my-repo.git\n",
409        )
410        .unwrap();
411
412        let with_identity = hash_project_root(root);
413        assert_ne!(old, with_identity, "identity must change hash");
414    }
415
416    #[test]
417    fn docker_collision_avoided() {
418        let dir_a = tempfile::tempdir().unwrap();
419        let dir_b = tempfile::tempdir().unwrap();
420
421        let shared_path = "/workspace";
422
423        fs::create_dir_all(dir_a.path().join(".git")).unwrap();
424        fs::write(
425            dir_a.path().join(".git").join("config"),
426            "[remote \"origin\"]\n\turl = git@github.com:user/repo-a.git\n",
427        )
428        .unwrap();
429
430        fs::create_dir_all(dir_b.path().join(".git")).unwrap();
431        fs::write(
432            dir_b.path().join(".git").join("config"),
433            "[remote \"origin\"]\n\turl = git@github.com:user/repo-b.git\n",
434        )
435        .unwrap();
436
437        let hash_a = {
438            let mut hasher = DefaultHasher::new();
439            shared_path.hash(&mut hasher);
440            let id = project_identity(dir_a.path().to_str().unwrap()).unwrap();
441            id.hash(&mut hasher);
442            format!("{:016x}", hasher.finish())
443        };
444        let hash_b = {
445            let mut hasher = DefaultHasher::new();
446            shared_path.hash(&mut hasher);
447            let id = project_identity(dir_b.path().to_str().unwrap()).unwrap();
448            id.hash(&mut hasher);
449            format!("{:016x}", hasher.finish())
450        };
451
452        assert_ne!(
453            hash_a, hash_b,
454            "different repos at same path must produce different hashes"
455        );
456    }
457
458    #[test]
459    fn git_url_normalization() {
460        assert_eq!(
461            normalize_git_url("git@github.com:User/Repo.git"),
462            "github.com/user/repo"
463        );
464        assert_eq!(
465            normalize_git_url("https://github.com/User/Repo.git"),
466            "https://github.com/user/repo"
467        );
468        assert_eq!(
469            normalize_git_url("git@gitlab.com:org/sub/project.git"),
470            "gitlab.com/org/sub/project"
471        );
472    }
473
474    #[test]
475    fn identity_from_cargo_toml() {
476        let dir = tempfile::tempdir().unwrap();
477        fs::write(
478            dir.path().join("Cargo.toml"),
479            "[package]\nname = \"my-crate\"\nversion = \"0.1.0\"\n",
480        )
481        .unwrap();
482
483        let id = project_identity(dir.path().to_str().unwrap());
484        assert_eq!(id, Some("cargo:my-crate".into()));
485    }
486
487    #[test]
488    fn identity_from_package_json() {
489        let dir = tempfile::tempdir().unwrap();
490        fs::write(
491            dir.path().join("package.json"),
492            "{\n  \"name\": \"@scope/my-app\",\n  \"version\": \"1.0.0\"\n}\n",
493        )
494        .unwrap();
495
496        let id = project_identity(dir.path().to_str().unwrap());
497        assert_eq!(id, Some("npm:@scope/my-app".into()));
498    }
499
500    #[test]
501    fn identity_from_pyproject() {
502        let dir = tempfile::tempdir().unwrap();
503        fs::write(
504            dir.path().join("pyproject.toml"),
505            "[project]\nname = \"my-python-lib\"\nversion = \"2.0\"\n",
506        )
507        .unwrap();
508
509        let id = project_identity(dir.path().to_str().unwrap());
510        assert_eq!(id, Some("python:my-python-lib".into()));
511    }
512
513    #[test]
514    fn identity_from_poetry_pyproject() {
515        let dir = tempfile::tempdir().unwrap();
516        fs::write(
517            dir.path().join("pyproject.toml"),
518            "[tool.poetry]\nname = \"poetry-app\"\nversion = \"1.0\"\n",
519        )
520        .unwrap();
521
522        let id = project_identity(dir.path().to_str().unwrap());
523        assert_eq!(id, Some("python:poetry-app".into()));
524    }
525
526    #[test]
527    fn identity_from_go_mod() {
528        let dir = tempfile::tempdir().unwrap();
529        fs::write(
530            dir.path().join("go.mod"),
531            "module github.com/user/myservice\n\ngo 1.21\n",
532        )
533        .unwrap();
534
535        let id = project_identity(dir.path().to_str().unwrap());
536        assert_eq!(id, Some("go:github.com/user/myservice".into()));
537    }
538
539    #[test]
540    fn identity_from_composer() {
541        let dir = tempfile::tempdir().unwrap();
542        fs::write(
543            dir.path().join("composer.json"),
544            "{\n  \"name\": \"vendor/my-php-lib\"\n}\n",
545        )
546        .unwrap();
547
548        let id = project_identity(dir.path().to_str().unwrap());
549        assert_eq!(id, Some("composer:vendor/my-php-lib".into()));
550    }
551
552    #[test]
553    fn identity_from_gradle() {
554        let dir = tempfile::tempdir().unwrap();
555        fs::write(
556            dir.path().join("settings.gradle"),
557            "rootProject.name = 'my-java-app'\n",
558        )
559        .unwrap();
560
561        let id = project_identity(dir.path().to_str().unwrap());
562        assert_eq!(id, Some("gradle:my-java-app".into()));
563    }
564
565    #[test]
566    fn identity_from_dotnet_sln() {
567        let dir = tempfile::tempdir().unwrap();
568        fs::write(dir.path().join("MyApp.sln"), "").unwrap();
569
570        let id = project_identity(dir.path().to_str().unwrap());
571        assert_eq!(id, Some("dotnet:MyApp".into()));
572    }
573
574    #[test]
575    fn identity_git_takes_priority_over_cargo() {
576        let dir = tempfile::tempdir().unwrap();
577        fs::create_dir_all(dir.path().join(".git")).unwrap();
578        fs::write(
579            dir.path().join(".git").join("config"),
580            "[remote \"origin\"]\n\turl = git@github.com:user/repo.git\n",
581        )
582        .unwrap();
583        fs::write(
584            dir.path().join("Cargo.toml"),
585            "[package]\nname = \"my-crate\"\n",
586        )
587        .unwrap();
588
589        let id = project_identity(dir.path().to_str().unwrap());
590        assert_eq!(id, Some("git:github.com/user/repo".into()));
591    }
592
593    #[test]
594    fn no_identity_for_empty_dir() {
595        let dir = tempfile::tempdir().unwrap();
596        let id = project_identity(dir.path().to_str().unwrap());
597        assert!(id.is_none());
598    }
599
600    #[test]
601    fn identity_from_lean_ctx_id() {
602        let dir = tempfile::tempdir().unwrap();
603        fs::write(dir.path().join(".lean-ctx-id"), "my-docker-project\n").unwrap();
604
605        let id = project_identity(dir.path().to_str().unwrap());
606        assert_eq!(id, Some("explicit:my-docker-project".into()));
607    }
608
609    #[test]
610    fn lean_ctx_id_takes_priority_over_git() {
611        let dir = tempfile::tempdir().unwrap();
612        fs::write(dir.path().join(".lean-ctx-id"), "override-name").unwrap();
613        fs::create_dir_all(dir.path().join(".git")).unwrap();
614        fs::write(
615            dir.path().join(".git").join("config"),
616            "[remote \"origin\"]\n\turl = git@github.com:user/repo.git\n",
617        )
618        .unwrap();
619
620        let id = project_identity(dir.path().to_str().unwrap());
621        assert_eq!(id, Some("explicit:override-name".into()));
622    }
623
624    #[test]
625    fn docker_different_projects_same_path_with_lean_ctx_id() {
626        let dir_a = tempfile::tempdir().unwrap();
627        let dir_b = tempfile::tempdir().unwrap();
628
629        fs::write(dir_a.path().join(".lean-ctx-id"), "project-alpha").unwrap();
630        fs::write(dir_b.path().join(".lean-ctx-id"), "project-beta").unwrap();
631
632        let id_a = project_identity(dir_a.path().to_str().unwrap());
633        let id_b = project_identity(dir_b.path().to_str().unwrap());
634        assert_ne!(id_a, id_b);
635    }
636
637    #[test]
638    fn fallback_hash_equals_legacy_when_no_identity() {
639        let h_new = hash_project_root("/some/path/without/project");
640        let h_old = hash_path_only("/some/path/without/project");
641        assert_eq!(
642            h_new, h_old,
643            "must be backward-compatible when no identity is found"
644        );
645    }
646
647    #[test]
648    fn migration_copies_files() {
649        let tmp = tempfile::tempdir().unwrap();
650        let knowledge_base = tmp.path().join("knowledge");
651        let old_hash = "aaaa000000000000";
652        let new_hash = "bbbb111111111111";
653
654        let old_dir = knowledge_base.join(old_hash);
655        let new_dir = knowledge_base.join(new_hash);
656        fs::create_dir_all(&old_dir).unwrap();
657        fs::write(
658            old_dir.join("knowledge.json"),
659            r#"{"project_root":"/workspace"}"#,
660        )
661        .unwrap();
662        fs::write(old_dir.join("gotchas.json"), "{}").unwrap();
663
664        copy_dir_contents(&old_dir, &new_dir).unwrap();
665
666        assert!(new_dir.join("knowledge.json").exists());
667        assert!(new_dir.join("gotchas.json").exists());
668        assert!(
669            old_dir.join("knowledge.json").exists(),
670            "old dir must remain intact"
671        );
672    }
673
674    #[test]
675    fn ownership_check_rejects_foreign_data() {
676        let tmp = tempfile::tempdir().unwrap();
677        let dir = tmp.path().join("knowledge").join("hash123");
678        fs::create_dir_all(&dir).unwrap();
679        fs::write(
680            dir.join("knowledge.json"),
681            r#"{"project_root":"/other/project"}"#,
682        )
683        .unwrap();
684
685        assert!(!verify_ownership(&dir, "/workspace"));
686    }
687
688    #[test]
689    fn ownership_check_accepts_matching_root() {
690        let tmp = tempfile::tempdir().unwrap();
691        let dir = tmp.path().join("knowledge").join("hash123");
692        fs::create_dir_all(&dir).unwrap();
693        fs::write(
694            dir.join("knowledge.json"),
695            r#"{"project_root":"/workspace"}"#,
696        )
697        .unwrap();
698
699        assert!(verify_ownership(&dir, "/workspace"));
700    }
701
702    #[test]
703    fn ownership_check_accepts_empty_stored_root() {
704        let tmp = tempfile::tempdir().unwrap();
705        let dir = tmp.path().join("knowledge").join("hash123");
706        fs::create_dir_all(&dir).unwrap();
707        fs::write(dir.join("knowledge.json"), r#"{"project_root":""}"#).unwrap();
708
709        assert!(verify_ownership(&dir, "/workspace"));
710    }
711
712    #[test]
713    fn ownership_check_accepts_missing_knowledge_json() {
714        let tmp = tempfile::tempdir().unwrap();
715        let dir = tmp.path().join("knowledge").join("hash123");
716        fs::create_dir_all(&dir).unwrap();
717
718        assert!(verify_ownership(&dir, "/workspace"));
719    }
720}