1use std::collections::hash_map::DefaultHasher;
2use std::hash::{Hash, Hasher};
3use std::path::Path;
4
5pub(crate) fn hash_project_root(root: &str) -> String {
11 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
29pub(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
38pub(crate) fn project_identity(root: &str) -> Option<String> {
53 let root = Path::new(root);
54
55 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
89pub(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
116pub(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
145fn 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
256fn 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
307fn 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#[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 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 assert!(legacy_unnormalized_hashes("/home/user/project").is_empty());
388 }
389
390 #[test]
391 fn legacy_unnormalized_hashes_present_for_backslash_path() {
392 let legacy = legacy_unnormalized_hashes(r"D:\repos\oref-examples");
395 assert_eq!(legacy.len(), 2, "composite + path-only raw hashes");
396 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}