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 && 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
254fn 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
303fn 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#[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 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 assert!(legacy_unnormalized_hashes("/home/user/project").is_empty());
384 }
385
386 #[test]
387 fn legacy_unnormalized_hashes_present_for_backslash_path() {
388 let legacy = legacy_unnormalized_hashes(r"D:\repos\oref-examples");
391 assert_eq!(legacy.len(), 2, "composite + path-only raw hashes");
392 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}