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 {
171            if let Some(url) = trimmed.strip_prefix("url") {
172                let url = url.trim_start_matches([' ', '=']);
173                let url = url.trim();
174                if !url.is_empty() {
175                    return Some(normalize_git_url(url));
176                }
177            }
178        }
179    }
180    None
181}
182
183fn normalize_git_url(url: &str) -> String {
184    let url = url.trim_end_matches(".git");
185    let url = url
186        .strip_prefix("git@")
187        .map_or_else(|| url.to_string(), |s| s.replacen(':', "/", 1));
188    url.to_lowercase()
189}
190
191fn cargo_package_name(root: &Path) -> Option<String> {
192    extract_toml_value(&root.join("Cargo.toml"), "name", Some("[package]"))
193}
194
195fn npm_package_name(root: &Path) -> Option<String> {
196    extract_json_string_field(&root.join("package.json"), "name")
197}
198
199fn pyproject_name(root: &Path) -> Option<String> {
200    extract_toml_value(&root.join("pyproject.toml"), "name", Some("[project]"))
201        .or_else(|| extract_toml_value(&root.join("pyproject.toml"), "name", Some("[tool.poetry]")))
202}
203
204fn go_module(root: &Path) -> Option<String> {
205    let content = std::fs::read_to_string(root.join("go.mod")).ok()?;
206    let first = content.lines().next()?;
207    first.strip_prefix("module").map(|s| s.trim().to_string())
208}
209
210fn composer_name(root: &Path) -> Option<String> {
211    extract_json_string_field(&root.join("composer.json"), "name")
212}
213
214fn gradle_project(root: &Path) -> Option<String> {
215    let settings = root.join("settings.gradle");
216    let settings_kts = root.join("settings.gradle.kts");
217
218    let path = if settings.exists() {
219        settings
220    } else if settings_kts.exists() {
221        settings_kts
222    } else {
223        return None;
224    };
225
226    let content = std::fs::read_to_string(path).ok()?;
227    for line in content.lines() {
228        let trimmed = line.trim();
229        if let Some(rest) = trimmed.strip_prefix("rootProject.name") {
230            let rest = rest.trim_start_matches([' ', '=']);
231            let name = rest.trim().trim_matches(['\'', '"']);
232            if !name.is_empty() {
233                return Some(name.to_string());
234            }
235        }
236    }
237    None
238}
239
240fn dotnet_solution(root: &Path) -> Option<String> {
241    let entries = std::fs::read_dir(root).ok()?;
242    for entry in entries.flatten() {
243        if let Some(ext) = entry.path().extension() {
244            if ext == "sln" {
245                return entry
246                    .path()
247                    .file_stem()
248                    .and_then(|s| s.to_str())
249                    .map(String::from);
250            }
251        }
252    }
253    None
254}
255
256// ---------------------------------------------------------------------------
257// TOML / JSON helpers (lightweight, no extra deps)
258// ---------------------------------------------------------------------------
259
260fn extract_toml_value(path: &Path, key: &str, section: Option<&str>) -> Option<String> {
261    let content = std::fs::read_to_string(path).ok()?;
262    let mut in_section = section.is_none();
263    let target_section = section.unwrap_or("");
264
265    for line in content.lines() {
266        let trimmed = line.trim();
267
268        if trimmed.starts_with('[') {
269            in_section = trimmed == target_section;
270            continue;
271        }
272
273        if in_section {
274            if let Some(rest) = trimmed.strip_prefix(key) {
275                let rest = rest.trim_start();
276                if let Some(rest) = rest.strip_prefix('=') {
277                    let val = rest.trim().trim_matches('"');
278                    if !val.is_empty() {
279                        return Some(val.to_string());
280                    }
281                }
282            }
283        }
284    }
285    None
286}
287
288fn extract_json_string_field(path: &Path, field: &str) -> Option<String> {
289    let content = std::fs::read_to_string(path).ok()?;
290    let needle = format!("\"{field}\"");
291    for line in content.lines() {
292        let trimmed = line.trim();
293        if let Some(rest) = trimmed.strip_prefix(&needle) {
294            let rest = rest.trim_start_matches([' ', ':']);
295            let val = rest.trim().trim_start_matches('"');
296            if let Some(end) = val.find('"') {
297                let name = &val[..end];
298                if !name.is_empty() {
299                    return Some(name.to_string());
300                }
301            }
302        }
303    }
304    None
305}
306
307// ---------------------------------------------------------------------------
308// Migration helpers
309// ---------------------------------------------------------------------------
310
311fn verify_ownership(old_dir: &Path, project_root: &str) -> bool {
312    let knowledge_path = old_dir.join("knowledge.json");
313    let Ok(content) = std::fs::read_to_string(&knowledge_path) else {
314        return true;
315    };
316
317    let stored_root: Option<String> = serde_json::from_str::<serde_json::Value>(&content)
318        .ok()
319        .and_then(|v| v.get("project_root")?.as_str().map(String::from));
320
321    match stored_root {
322        Some(stored) if !stored.is_empty() => stored == project_root,
323        _ => true,
324    }
325}
326
327fn copy_dir_contents(src: &Path, dst: &Path) -> Result<(), String> {
328    std::fs::create_dir_all(dst).map_err(|e| e.to_string())?;
329
330    for entry in std::fs::read_dir(src).map_err(|e| e.to_string())?.flatten() {
331        let src_path = entry.path();
332        let dst_path = dst.join(entry.file_name());
333
334        if src_path.is_dir() {
335            copy_dir_contents(&src_path, &dst_path)?;
336        } else {
337            std::fs::copy(&src_path, &dst_path).map_err(|e| e.to_string())?;
338        }
339    }
340    Ok(())
341}
342
343// ---------------------------------------------------------------------------
344// Tests
345// ---------------------------------------------------------------------------
346
347#[cfg(test)]
348mod tests {
349    use super::*;
350    use std::fs;
351
352    #[test]
353    fn path_only_matches_legacy_behaviour() {
354        let h = hash_path_only("/workspace");
355        assert_eq!(h.len(), 16);
356        let h2 = hash_path_only("/workspace");
357        assert_eq!(h, h2);
358    }
359
360    #[test]
361    fn windows_slash_and_backslash_hash_identically() {
362        // Issue #325: the MCP server reports forward slashes while the CLI
363        // reports backslashes for the same Windows directory. Both must resolve
364        // to the same project hash so the knowledge store is not split.
365        assert_eq!(
366            hash_project_root(r"D:\repos\oref-examples"),
367            hash_project_root("D:/repos/oref-examples"),
368        );
369        assert_eq!(
370            hash_path_only(r"D:\repos\oref-examples"),
371            hash_path_only("D:/repos/oref-examples"),
372        );
373    }
374
375    #[test]
376    fn trailing_slash_does_not_split_hash() {
377        assert_eq!(
378            hash_project_root("/home/user/project/"),
379            hash_project_root("/home/user/project"),
380        );
381    }
382
383    #[test]
384    fn legacy_unnormalized_hashes_empty_for_clean_posix() {
385        // POSIX paths already normalize to themselves: no split ever occurred,
386        // so there is nothing to migrate.
387        assert!(legacy_unnormalized_hashes("/home/user/project").is_empty());
388    }
389
390    #[test]
391    fn legacy_unnormalized_hashes_present_for_backslash_path() {
392        // A backslash path normalizes to a different string, so the pre-fix
393        // (raw) hashes are offered for migration.
394        let legacy = legacy_unnormalized_hashes(r"D:\repos\oref-examples");
395        assert_eq!(legacy.len(), 2, "composite + path-only raw hashes");
396        // The raw path-only hash must differ from the normalized hash.
397        assert!(!legacy.contains(&hash_project_root(r"D:\repos\oref-examples")));
398    }
399
400    #[test]
401    fn composite_differs_when_identity_present() {
402        let dir = tempfile::tempdir().unwrap();
403        let root = dir.path().to_str().unwrap();
404
405        let old = hash_path_only(root);
406        let no_identity = hash_project_root(root);
407        assert_eq!(old, no_identity, "without identity, hashes must match");
408
409        fs::create_dir_all(dir.path().join(".git")).unwrap();
410        fs::write(
411            dir.path().join(".git").join("config"),
412            "[remote \"origin\"]\n\turl = git@github.com:user/my-repo.git\n",
413        )
414        .unwrap();
415
416        let with_identity = hash_project_root(root);
417        assert_ne!(old, with_identity, "identity must change hash");
418    }
419
420    #[test]
421    fn docker_collision_avoided() {
422        let dir_a = tempfile::tempdir().unwrap();
423        let dir_b = tempfile::tempdir().unwrap();
424
425        let shared_path = "/workspace";
426
427        fs::create_dir_all(dir_a.path().join(".git")).unwrap();
428        fs::write(
429            dir_a.path().join(".git").join("config"),
430            "[remote \"origin\"]\n\turl = git@github.com:user/repo-a.git\n",
431        )
432        .unwrap();
433
434        fs::create_dir_all(dir_b.path().join(".git")).unwrap();
435        fs::write(
436            dir_b.path().join(".git").join("config"),
437            "[remote \"origin\"]\n\turl = git@github.com:user/repo-b.git\n",
438        )
439        .unwrap();
440
441        let hash_a = {
442            let mut hasher = DefaultHasher::new();
443            shared_path.hash(&mut hasher);
444            let id = project_identity(dir_a.path().to_str().unwrap()).unwrap();
445            id.hash(&mut hasher);
446            format!("{:016x}", hasher.finish())
447        };
448        let hash_b = {
449            let mut hasher = DefaultHasher::new();
450            shared_path.hash(&mut hasher);
451            let id = project_identity(dir_b.path().to_str().unwrap()).unwrap();
452            id.hash(&mut hasher);
453            format!("{:016x}", hasher.finish())
454        };
455
456        assert_ne!(
457            hash_a, hash_b,
458            "different repos at same path must produce different hashes"
459        );
460    }
461
462    #[test]
463    fn git_url_normalization() {
464        assert_eq!(
465            normalize_git_url("git@github.com:User/Repo.git"),
466            "github.com/user/repo"
467        );
468        assert_eq!(
469            normalize_git_url("https://github.com/User/Repo.git"),
470            "https://github.com/user/repo"
471        );
472        assert_eq!(
473            normalize_git_url("git@gitlab.com:org/sub/project.git"),
474            "gitlab.com/org/sub/project"
475        );
476    }
477
478    #[test]
479    fn identity_from_cargo_toml() {
480        let dir = tempfile::tempdir().unwrap();
481        fs::write(
482            dir.path().join("Cargo.toml"),
483            "[package]\nname = \"my-crate\"\nversion = \"0.1.0\"\n",
484        )
485        .unwrap();
486
487        let id = project_identity(dir.path().to_str().unwrap());
488        assert_eq!(id, Some("cargo:my-crate".into()));
489    }
490
491    #[test]
492    fn identity_from_package_json() {
493        let dir = tempfile::tempdir().unwrap();
494        fs::write(
495            dir.path().join("package.json"),
496            "{\n  \"name\": \"@scope/my-app\",\n  \"version\": \"1.0.0\"\n}\n",
497        )
498        .unwrap();
499
500        let id = project_identity(dir.path().to_str().unwrap());
501        assert_eq!(id, Some("npm:@scope/my-app".into()));
502    }
503
504    #[test]
505    fn identity_from_pyproject() {
506        let dir = tempfile::tempdir().unwrap();
507        fs::write(
508            dir.path().join("pyproject.toml"),
509            "[project]\nname = \"my-python-lib\"\nversion = \"2.0\"\n",
510        )
511        .unwrap();
512
513        let id = project_identity(dir.path().to_str().unwrap());
514        assert_eq!(id, Some("python:my-python-lib".into()));
515    }
516
517    #[test]
518    fn identity_from_poetry_pyproject() {
519        let dir = tempfile::tempdir().unwrap();
520        fs::write(
521            dir.path().join("pyproject.toml"),
522            "[tool.poetry]\nname = \"poetry-app\"\nversion = \"1.0\"\n",
523        )
524        .unwrap();
525
526        let id = project_identity(dir.path().to_str().unwrap());
527        assert_eq!(id, Some("python:poetry-app".into()));
528    }
529
530    #[test]
531    fn identity_from_go_mod() {
532        let dir = tempfile::tempdir().unwrap();
533        fs::write(
534            dir.path().join("go.mod"),
535            "module github.com/user/myservice\n\ngo 1.21\n",
536        )
537        .unwrap();
538
539        let id = project_identity(dir.path().to_str().unwrap());
540        assert_eq!(id, Some("go:github.com/user/myservice".into()));
541    }
542
543    #[test]
544    fn identity_from_composer() {
545        let dir = tempfile::tempdir().unwrap();
546        fs::write(
547            dir.path().join("composer.json"),
548            "{\n  \"name\": \"vendor/my-php-lib\"\n}\n",
549        )
550        .unwrap();
551
552        let id = project_identity(dir.path().to_str().unwrap());
553        assert_eq!(id, Some("composer:vendor/my-php-lib".into()));
554    }
555
556    #[test]
557    fn identity_from_gradle() {
558        let dir = tempfile::tempdir().unwrap();
559        fs::write(
560            dir.path().join("settings.gradle"),
561            "rootProject.name = 'my-java-app'\n",
562        )
563        .unwrap();
564
565        let id = project_identity(dir.path().to_str().unwrap());
566        assert_eq!(id, Some("gradle:my-java-app".into()));
567    }
568
569    #[test]
570    fn identity_from_dotnet_sln() {
571        let dir = tempfile::tempdir().unwrap();
572        fs::write(dir.path().join("MyApp.sln"), "").unwrap();
573
574        let id = project_identity(dir.path().to_str().unwrap());
575        assert_eq!(id, Some("dotnet:MyApp".into()));
576    }
577
578    #[test]
579    fn identity_git_takes_priority_over_cargo() {
580        let dir = tempfile::tempdir().unwrap();
581        fs::create_dir_all(dir.path().join(".git")).unwrap();
582        fs::write(
583            dir.path().join(".git").join("config"),
584            "[remote \"origin\"]\n\turl = git@github.com:user/repo.git\n",
585        )
586        .unwrap();
587        fs::write(
588            dir.path().join("Cargo.toml"),
589            "[package]\nname = \"my-crate\"\n",
590        )
591        .unwrap();
592
593        let id = project_identity(dir.path().to_str().unwrap());
594        assert_eq!(id, Some("git:github.com/user/repo".into()));
595    }
596
597    #[test]
598    fn no_identity_for_empty_dir() {
599        let dir = tempfile::tempdir().unwrap();
600        let id = project_identity(dir.path().to_str().unwrap());
601        assert!(id.is_none());
602    }
603
604    #[test]
605    fn identity_from_lean_ctx_id() {
606        let dir = tempfile::tempdir().unwrap();
607        fs::write(dir.path().join(".lean-ctx-id"), "my-docker-project\n").unwrap();
608
609        let id = project_identity(dir.path().to_str().unwrap());
610        assert_eq!(id, Some("explicit:my-docker-project".into()));
611    }
612
613    #[test]
614    fn lean_ctx_id_takes_priority_over_git() {
615        let dir = tempfile::tempdir().unwrap();
616        fs::write(dir.path().join(".lean-ctx-id"), "override-name").unwrap();
617        fs::create_dir_all(dir.path().join(".git")).unwrap();
618        fs::write(
619            dir.path().join(".git").join("config"),
620            "[remote \"origin\"]\n\turl = git@github.com:user/repo.git\n",
621        )
622        .unwrap();
623
624        let id = project_identity(dir.path().to_str().unwrap());
625        assert_eq!(id, Some("explicit:override-name".into()));
626    }
627
628    #[test]
629    fn docker_different_projects_same_path_with_lean_ctx_id() {
630        let dir_a = tempfile::tempdir().unwrap();
631        let dir_b = tempfile::tempdir().unwrap();
632
633        fs::write(dir_a.path().join(".lean-ctx-id"), "project-alpha").unwrap();
634        fs::write(dir_b.path().join(".lean-ctx-id"), "project-beta").unwrap();
635
636        let id_a = project_identity(dir_a.path().to_str().unwrap());
637        let id_b = project_identity(dir_b.path().to_str().unwrap());
638        assert_ne!(id_a, id_b);
639    }
640
641    #[test]
642    fn fallback_hash_equals_legacy_when_no_identity() {
643        let h_new = hash_project_root("/some/path/without/project");
644        let h_old = hash_path_only("/some/path/without/project");
645        assert_eq!(
646            h_new, h_old,
647            "must be backward-compatible when no identity is found"
648        );
649    }
650
651    #[test]
652    fn migration_copies_files() {
653        let tmp = tempfile::tempdir().unwrap();
654        let knowledge_base = tmp.path().join("knowledge");
655        let old_hash = "aaaa000000000000";
656        let new_hash = "bbbb111111111111";
657
658        let old_dir = knowledge_base.join(old_hash);
659        let new_dir = knowledge_base.join(new_hash);
660        fs::create_dir_all(&old_dir).unwrap();
661        fs::write(
662            old_dir.join("knowledge.json"),
663            r#"{"project_root":"/workspace"}"#,
664        )
665        .unwrap();
666        fs::write(old_dir.join("gotchas.json"), "{}").unwrap();
667
668        copy_dir_contents(&old_dir, &new_dir).unwrap();
669
670        assert!(new_dir.join("knowledge.json").exists());
671        assert!(new_dir.join("gotchas.json").exists());
672        assert!(
673            old_dir.join("knowledge.json").exists(),
674            "old dir must remain intact"
675        );
676    }
677
678    #[test]
679    fn ownership_check_rejects_foreign_data() {
680        let tmp = tempfile::tempdir().unwrap();
681        let dir = tmp.path().join("knowledge").join("hash123");
682        fs::create_dir_all(&dir).unwrap();
683        fs::write(
684            dir.join("knowledge.json"),
685            r#"{"project_root":"/other/project"}"#,
686        )
687        .unwrap();
688
689        assert!(!verify_ownership(&dir, "/workspace"));
690    }
691
692    #[test]
693    fn ownership_check_accepts_matching_root() {
694        let tmp = tempfile::tempdir().unwrap();
695        let dir = tmp.path().join("knowledge").join("hash123");
696        fs::create_dir_all(&dir).unwrap();
697        fs::write(
698            dir.join("knowledge.json"),
699            r#"{"project_root":"/workspace"}"#,
700        )
701        .unwrap();
702
703        assert!(verify_ownership(&dir, "/workspace"));
704    }
705
706    #[test]
707    fn ownership_check_accepts_empty_stored_root() {
708        let tmp = tempfile::tempdir().unwrap();
709        let dir = tmp.path().join("knowledge").join("hash123");
710        fs::create_dir_all(&dir).unwrap();
711        fs::write(dir.join("knowledge.json"), r#"{"project_root":""}"#).unwrap();
712
713        assert!(verify_ownership(&dir, "/workspace"));
714    }
715
716    #[test]
717    fn ownership_check_accepts_missing_knowledge_json() {
718        let tmp = tempfile::tempdir().unwrap();
719        let dir = tmp.path().join("knowledge").join("hash123");
720        fs::create_dir_all(&dir).unwrap();
721
722        assert!(verify_ownership(&dir, "/workspace"));
723    }
724}