1use super::path_utils::canonicalize_path;
15use super::persistence::{
16 ProjectPersistence, build_persisted_state, compute_config_fingerprint, restore_file_table,
17 restore_repo_index,
18};
19use super::repo_detection::{detect_repos_under, lookup_repo_id};
20use super::resolver::resolve_index_root;
21use super::types::{FileEntry, ProjectError, ProjectId, ProjectRootMode, RepoId, StringId};
22use crate::config::ProjectConfig;
23use crate::graph::unified::concurrent::CodeGraph;
24use crate::graph::unified::persistence::{GraphStorage, load_from_path};
25use parking_lot::RwLock;
26use std::collections::HashMap;
27use std::path::{Path, PathBuf};
28use std::sync::Arc;
29use std::sync::atomic::{AtomicBool, Ordering};
30
31#[allow(missing_debug_implementations)]
41impl std::fmt::Debug for Project {
42 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
43 f.debug_struct("Project")
44 .field("id", &self.id)
45 .field("index_root", &self.index_root)
46 .field("config", &self.config)
47 .field("repo_index", &"<repo_index>")
48 .field("file_table", &"<file_table>")
49 .field(
50 "graph_cache",
51 &self.graph_cache.read().as_ref().map(|_| "<cached>"),
52 )
53 .field("initialized", &self.initialized.load(Ordering::Relaxed))
54 .field("cancelled", &self.cancelled.load(Ordering::Relaxed))
55 .finish()
56 }
57}
58
59pub struct Project {
61 pub id: ProjectId,
63
64 pub index_root: PathBuf,
66
67 config: ProjectConfig,
71
72 repo_index: RwLock<HashMap<PathBuf, RepoId>>,
74
75 file_table: RwLock<HashMap<StringId, FileEntry>>,
77
78 graph_cache: RwLock<Option<Arc<CodeGraph>>>,
83
84 initialized: AtomicBool,
86
87 cancelled: AtomicBool,
89}
90
91impl Project {
92 pub fn new(index_root: PathBuf) -> Result<Self, ProjectError> {
104 let id = ProjectId::from_index_root(&index_root);
105
106 let config = ProjectConfig::load_from_index_root(&index_root);
108
109 log::debug!(
110 "Created Project {} for root '{}' (config: max_depth={}, cache={})",
111 id,
112 index_root.display(),
113 config.indexing.max_depth,
114 config.cache.directory
115 );
116
117 Ok(Self {
118 id,
119 index_root,
120 config,
121 repo_index: RwLock::new(HashMap::new()),
122 file_table: RwLock::new(HashMap::new()),
123 graph_cache: RwLock::new(None),
124 initialized: AtomicBool::new(false),
125 cancelled: AtomicBool::new(false),
126 })
127 }
128
129 pub fn initialize(&self) -> Result<(), ProjectError> {
144 if self.initialized.load(Ordering::Acquire) {
145 return Ok(()); }
147
148 log::info!(
149 "Initializing Project {} at '{}'",
150 self.id,
151 self.index_root.display()
152 );
153
154 let mut preloaded = false;
156 if self.config.cache.persistent {
157 preloaded = self.try_preload_state();
158 }
159
160 if !preloaded {
162 self.detect_repositories();
163 }
164
165 log::debug!(
168 "Project {} graph cache ready (lazy-loaded on first access)",
169 self.id
170 );
171
172 self.initialized.store(true, Ordering::Release);
173 Ok(())
174 }
175
176 fn try_preload_state(&self) -> bool {
181 let persistence = ProjectPersistence::new(&self.index_root, &self.config.cache.directory);
182
183 let state = match persistence.read_metadata(self.id) {
185 Ok(Some(s)) => s,
186 Ok(None) => {
187 log::debug!("No persisted state found for Project {}", self.id);
188 return false;
189 }
190 Err(e) => {
191 log::warn!(
192 "Failed to read persisted state for Project {}: {}",
193 self.id,
194 e
195 );
196 return false;
197 }
198 };
199
200 if state.version != 1 {
202 log::warn!(
203 "Persisted state version mismatch for Project {}: expected 1, got {}",
204 self.id,
205 state.version
206 );
207 return false;
208 }
209
210 if state.project_id != self.id.as_u64() {
212 log::warn!(
213 "Persisted state project_id mismatch for Project {}",
214 self.id
215 );
216 return false;
217 }
218
219 let current_fingerprint =
221 compute_config_fingerprint(&self.config.cache, &self.config.indexing);
222 if state.config_fingerprint != current_fingerprint {
223 log::warn!(
224 "Persisted state config fingerprint mismatch for Project {} \
225 (config changed since last persist)",
226 self.id
227 );
228 return false;
229 }
230
231 let repo_index = restore_repo_index(&state);
233 *self.repo_index.write() = repo_index;
234
235 let file_table = restore_file_table(&state);
237 *self.file_table.write() = file_table;
238
239 log::info!(
240 "Preloaded persisted state for Project {} ({} repos, {} files)",
241 self.id,
242 self.repo_index.read().len(),
243 self.file_table.read().len()
244 );
245
246 true
247 }
248
249 fn detect_repositories(&self) {
256 let repos = detect_repos_under(&self.index_root);
257
258 let mut index = self.repo_index.write();
259 for (git_root, repo_id) in repos {
260 index.insert(git_root, repo_id);
261 }
262
263 log::debug!(
264 "Project {} detected {} repositor{}",
265 self.id,
266 index.len(),
267 if index.len() == 1 { "y" } else { "ies" }
268 );
269 }
270
271 pub fn repo_id_for_file(&self, file_path: &Path) -> Result<RepoId, ProjectError> {
285 let canonical = canonicalize_path(file_path)
286 .map_err(|e| ProjectError::canonicalization_failed(file_path, e))?;
287
288 let repo_index = self.repo_index.read();
289 Ok(lookup_repo_id(&canonical, &repo_index))
290 }
291
292 #[must_use]
296 pub fn repo_index(&self) -> HashMap<PathBuf, RepoId> {
297 self.repo_index.read().clone()
298 }
299
300 #[must_use]
302 pub fn is_initialized(&self) -> bool {
303 self.initialized.load(Ordering::Acquire)
304 }
305
306 pub fn cancel_operations(&self) {
308 self.cancelled.store(true, Ordering::Release);
309 log::debug!("Cancelled operations for Project {}", self.id);
310 }
311
312 pub fn graph(&self) -> Result<Option<Arc<CodeGraph>>, ProjectError> {
321 if let Some(graph) = self.graph_cache.read().as_ref() {
323 return Ok(Some(graph.clone()));
324 }
325
326 let storage = GraphStorage::new(&self.index_root);
328 if !storage.exists() {
329 return Ok(None);
330 }
331
332 let graph =
333 load_from_path(storage.snapshot_path(), None).map_err(|e| ProjectError::GraphLoad {
334 path: self.index_root.clone(),
335 source: e.into(),
336 })?;
337
338 let graph = Arc::new(graph);
339
340 let mut cache = self.graph_cache.write();
342 *cache = Some(graph.clone());
343
344 Ok(Some(graph))
345 }
346
347 pub fn clear_graph_cache(&self) {
351 let mut cache = self.graph_cache.write();
352 *cache = None;
353 log::debug!("Cleared graph cache for Project {}", self.id);
354 }
355
356 #[must_use]
358 pub fn is_cancelled(&self) -> bool {
359 self.cancelled.load(Ordering::Acquire)
360 }
361
362 pub fn register_repo(&self, git_root: PathBuf, repo_id: RepoId) {
364 let mut index = self.repo_index.write();
365 index.insert(git_root, repo_id);
366 }
367
368 #[must_use]
370 pub fn get_repo_id(&self, git_root: &Path) -> Option<RepoId> {
371 let index = self.repo_index.read();
372 index.get(git_root).copied()
373 }
374
375 pub fn register_file(&self, entry: FileEntry) {
377 let mut table = self.file_table.write();
378 table.insert(Arc::clone(&entry.path), entry);
379 }
380
381 #[must_use]
383 pub fn get_file(&self, path: &str) -> Option<FileEntry> {
384 let table = self.file_table.read();
385 table.get(path).cloned()
386 }
387
388 #[must_use]
390 pub fn file_count(&self) -> usize {
391 self.file_table.read().len()
392 }
393
394 #[must_use]
396 pub fn repo_count(&self) -> usize {
397 self.repo_index.read().len()
398 }
399
400 pub fn persist_if_configured(&self) {
412 if !self.config.cache.persistent {
414 log::debug!(
415 "Persistence disabled for Project {} (cache.persistent=false)",
416 self.id
417 );
418 return;
419 }
420
421 log::info!("Persisting state for Project {}", self.id);
422
423 let fingerprint = compute_config_fingerprint(&self.config.cache, &self.config.indexing);
425
426 let persistence = ProjectPersistence::new(&self.index_root, &self.config.cache.directory);
428
429 let repo_index = self.repo_index.read().clone();
431 let file_table = self.file_table.read().clone();
432
433 let state = build_persisted_state(
435 self.id,
436 &self.index_root,
437 fingerprint,
438 &repo_index,
439 &file_table,
440 );
441
442 if let Err(e) = persistence.write_metadata(&state) {
444 log::warn!("Failed to persist metadata for Project {}: {}", self.id, e);
445 }
446 }
447
448 #[must_use]
452 pub fn config(&self) -> &ProjectConfig {
453 &self.config
454 }
455
456 #[must_use]
461 pub fn effective_ignored_dirs(&self) -> Vec<&str> {
462 self.config.effective_ignored_dirs()
463 }
464
465 #[must_use]
470 pub fn is_path_ignored(&self, path: &Path) -> bool {
471 self.config.is_ignored(path)
472 }
473
474 #[must_use]
478 pub fn language_for_path(&self, path: &Path) -> Option<&str> {
479 self.config.language_for_path(path)
480 }
481}
482
483#[derive(Debug)]
491pub struct ProjectManager {
492 mode: RwLock<ProjectRootMode>,
494
495 projects: RwLock<HashMap<PathBuf, Arc<Project>>>,
497
498 workspace_folders: RwLock<Vec<PathBuf>>,
500}
501
502impl ProjectManager {
503 #[must_use]
505 pub fn new(mode: ProjectRootMode) -> Self {
506 log::info!("Created ProjectManager with mode {mode:?}");
507 Self {
508 mode: RwLock::new(mode),
509 projects: RwLock::new(HashMap::new()),
510 workspace_folders: RwLock::new(Vec::new()),
511 }
512 }
513
514 #[must_use]
516 pub fn with_default_mode() -> Self {
517 Self::new(ProjectRootMode::default())
518 }
519
520 #[must_use]
522 pub fn mode(&self) -> ProjectRootMode {
523 *self.mode.read()
524 }
525
526 pub fn set_workspace_folders(&self, folders: Vec<PathBuf>) {
533 log::info!("Setting {} workspace folder(s)", folders.len());
534 let canonicalized: Vec<PathBuf> = folders
535 .into_iter()
536 .filter_map(|f| {
537 canonicalize_path(&f)
538 .map_err(|e| {
539 log::warn!(
540 "Failed to canonicalize workspace folder '{}': {}",
541 f.display(),
542 e
543 );
544 })
545 .ok()
546 })
547 .collect();
548 *self.workspace_folders.write() = canonicalized;
549 }
550
551 #[must_use]
553 pub fn workspace_folders(&self) -> Vec<PathBuf> {
554 self.workspace_folders.read().clone()
555 }
556
557 pub fn project_for_path(&self, file_path: &Path) -> Result<Arc<Project>, ProjectError> {
573 let canonical_path = canonicalize_path(file_path)
575 .map_err(|e| ProjectError::canonicalization_failed(file_path, e))?;
576
577 let workspace_folders = self.workspace_folders.read().clone();
579 let mode = *self.mode.read();
580 let index_root = resolve_index_root(&canonical_path, mode, &workspace_folders)?;
581
582 {
584 let projects = self.projects.read();
585 if let Some(project) = projects.get(&index_root) {
586 return Ok(Arc::clone(project));
587 }
588 } {
592 let mut projects = self.projects.write();
593
594 if let Some(project) = projects.get(&index_root) {
596 return Ok(Arc::clone(project));
597 }
598
599 let project = Arc::new(Project::new(index_root.clone())?);
601 projects.insert(index_root, Arc::clone(&project));
602
603 if let Err(e) = project.initialize() {
607 log::warn!("Project {} initialization failed: {}", project.id, e);
608 }
610
611 log::info!("Created new Project {} via project_for_path", project.id);
612 Ok(project)
613 } }
615
616 #[must_use]
620 pub fn get_project(&self, index_root: &Path) -> Option<Arc<Project>> {
621 let projects = self.projects.read();
622 projects.get(index_root).cloned()
623 }
624
625 #[must_use]
627 pub fn all_projects(&self) -> Vec<Arc<Project>> {
628 self.projects.read().values().cloned().collect()
629 }
630
631 #[must_use]
633 pub fn project_count(&self) -> usize {
634 self.projects.read().len()
635 }
636
637 pub fn handle_config_change(&self, new_mode: ProjectRootMode) {
645 let old_mode = *self.mode.read();
646 if old_mode == new_mode {
647 return; }
649
650 log::warn!(
651 "projectRootMode changed from {old_mode:?} to {new_mode:?}, rebuilding Projects"
652 );
653
654 let mut projects = self.projects.write();
656 for (_root, project) in projects.drain() {
657 project.cancel_operations();
658 project.persist_if_configured();
659 }
661 drop(projects);
662
663 *self.mode.write() = new_mode;
665
666 log::info!("Mode change complete. New Projects will be created lazily.");
667 }
668
669 pub fn remove_project(&self, index_root: &Path) -> Option<Arc<Project>> {
673 let mut projects = self.projects.write();
674 let removed = projects.remove(index_root);
675 if let Some(ref project) = removed {
676 log::info!(
677 "Removed Project {} at '{}'",
678 project.id,
679 index_root.display()
680 );
681 project.cancel_operations();
682 }
683 removed
684 }
685
686 pub fn shutdown(&self) {
688 log::info!("Shutting down ProjectManager");
689 let mut projects = self.projects.write();
690 for (_root, project) in projects.drain() {
691 project.cancel_operations();
692 project.persist_if_configured();
693 }
694 }
695}
696
697impl Default for ProjectManager {
698 fn default() -> Self {
699 Self::with_default_mode()
700 }
701}
702
703#[cfg(test)]
704mod tests {
705 use super::*;
706 use tempfile::TempDir;
707
708 fn setup_git_repo(temp: &TempDir) -> PathBuf {
709 let git_dir = temp.path().join(".git");
710 std::fs::create_dir(&git_dir).unwrap();
711 temp.path().to_path_buf()
712 }
713
714 #[test]
715 fn test_project_creation() {
716 let temp = TempDir::new().unwrap();
717 let project = Project::new(temp.path().to_path_buf()).unwrap();
718
719 assert!(!project.is_initialized());
720 assert!(!project.is_cancelled());
721 assert_eq!(project.file_count(), 0);
722 assert_eq!(project.repo_count(), 0);
723 }
724
725 #[test]
726 fn test_project_initialization() {
727 let temp = TempDir::new().unwrap();
728 let project = Project::new(temp.path().to_path_buf()).unwrap();
729
730 project.initialize().unwrap();
731 assert!(project.is_initialized());
732
733 project.initialize().unwrap();
735 assert!(project.is_initialized());
736 }
737
738 #[test]
739 fn test_project_cancellation() {
740 let temp = TempDir::new().unwrap();
741 let project = Project::new(temp.path().to_path_buf()).unwrap();
742
743 assert!(!project.is_cancelled());
744 project.cancel_operations();
745 assert!(project.is_cancelled());
746 }
747
748 #[test]
749 fn test_project_repo_registration() {
750 let temp = TempDir::new().unwrap();
751 let project = Project::new(temp.path().to_path_buf()).unwrap();
752
753 let git_root = temp.path().join("repo");
754 let repo_id = RepoId::from_git_root(&git_root);
755
756 project.register_repo(git_root.clone(), repo_id);
757
758 assert_eq!(project.repo_count(), 1);
759 assert_eq!(project.get_repo_id(&git_root), Some(repo_id));
760 }
761
762 #[test]
763 fn test_project_file_registration() {
764 let temp = TempDir::new().unwrap();
765 let project = Project::new(temp.path().to_path_buf()).unwrap();
766
767 let entry = FileEntry::new(Arc::from("src/main.rs"), RepoId::NONE);
768 project.register_file(entry);
769
770 assert_eq!(project.file_count(), 1);
771 assert!(project.get_file("src/main.rs").is_some());
772 }
773
774 #[test]
775 fn test_manager_creation() {
776 let manager = ProjectManager::new(ProjectRootMode::GitRoot);
777 assert_eq!(manager.mode(), ProjectRootMode::GitRoot);
778 assert_eq!(manager.project_count(), 0);
779 }
780
781 #[test]
782 fn test_manager_default() {
783 let manager = ProjectManager::default();
784 assert_eq!(manager.mode(), ProjectRootMode::GitRoot);
785 }
786
787 #[test]
788 fn test_manager_workspace_folders() {
789 let manager = ProjectManager::new(ProjectRootMode::WorkspaceFolder);
790
791 let temp = TempDir::new().unwrap();
792 let folder1 = temp.path().join("proj1");
793 let folder2 = temp.path().join("proj2");
794 std::fs::create_dir(&folder1).unwrap();
795 std::fs::create_dir(&folder2).unwrap();
796
797 manager.set_workspace_folders(vec![folder1.clone(), folder2.clone()]);
798
799 let canon = |p: &Path| -> PathBuf { canonicalize_path(p).unwrap() };
801 let workspace_folder_list = manager.workspace_folders();
802 assert_eq!(workspace_folder_list.len(), 2);
803 assert_eq!(workspace_folder_list[0], canon(&folder1));
804 assert_eq!(workspace_folder_list[1], canon(&folder2));
805 }
806
807 #[test]
808 fn test_manager_project_for_path() {
809 let temp = TempDir::new().unwrap();
810 let repo_root = setup_git_repo(&temp);
811
812 let file = repo_root.join("src/main.rs");
813 std::fs::create_dir_all(file.parent().unwrap()).unwrap();
814 std::fs::write(&file, "fn main() {}").unwrap();
815
816 let manager = ProjectManager::new(ProjectRootMode::GitRoot);
817 let project = manager.project_for_path(&file).unwrap();
818
819 assert_eq!(manager.project_count(), 1);
821
822 let project2 = manager.project_for_path(&file).unwrap();
824 assert_eq!(project.id, project2.id);
825 assert_eq!(manager.project_count(), 1);
826 }
827
828 #[test]
829 fn test_manager_project_for_path_different_repos() {
830 let temp = TempDir::new().unwrap();
831
832 let repo1 = temp.path().join("repo1");
834 let repo2 = temp.path().join("repo2");
835 std::fs::create_dir(&repo1).unwrap();
836 std::fs::create_dir(&repo2).unwrap();
837 std::fs::create_dir(repo1.join(".git")).unwrap();
838 std::fs::create_dir(repo2.join(".git")).unwrap();
839
840 let file1 = repo1.join("file.rs");
841 let file2 = repo2.join("file.rs");
842 std::fs::write(&file1, "").unwrap();
843 std::fs::write(&file2, "").unwrap();
844
845 let manager = ProjectManager::new(ProjectRootMode::GitRoot);
846
847 let project1 = manager.project_for_path(&file1).unwrap();
848 let project2 = manager.project_for_path(&file2).unwrap();
849
850 assert_eq!(manager.project_count(), 2);
852 assert_ne!(project1.id, project2.id);
853 }
854
855 #[test]
856 fn test_manager_config_change() {
857 let temp = TempDir::new().unwrap();
858 let repo_root = setup_git_repo(&temp);
859 let file = repo_root.join("file.rs");
860 std::fs::write(&file, "").unwrap();
861
862 let manager = ProjectManager::new(ProjectRootMode::GitRoot);
863
864 let _project = manager.project_for_path(&file).unwrap();
866 assert_eq!(manager.project_count(), 1);
867
868 manager.handle_config_change(ProjectRootMode::WorkspaceFolder);
870 assert_eq!(manager.mode(), ProjectRootMode::WorkspaceFolder);
871 assert_eq!(manager.project_count(), 0);
872 }
873
874 #[test]
875 fn test_manager_remove_project() {
876 let temp = TempDir::new().unwrap();
877 let repo_root = setup_git_repo(&temp);
878 let file = repo_root.join("file.rs");
879 std::fs::write(&file, "").unwrap();
880
881 let manager = ProjectManager::new(ProjectRootMode::GitRoot);
882 let project = manager.project_for_path(&file).unwrap();
883 assert_eq!(manager.project_count(), 1);
884
885 let index_root = canonicalize_path(&repo_root).unwrap();
887
888 let removed = manager.remove_project(&index_root);
890 assert!(removed.is_some());
891 assert_eq!(removed.unwrap().id, project.id);
892 assert_eq!(manager.project_count(), 0);
893 }
894
895 #[test]
896 fn test_manager_get_project() {
897 let temp = TempDir::new().unwrap();
898 let repo_root = setup_git_repo(&temp);
899 let file = repo_root.join("file.rs");
900 std::fs::write(&file, "").unwrap();
901
902 let manager = ProjectManager::new(ProjectRootMode::GitRoot);
903
904 let canonical_root = canonicalize_path(&repo_root).unwrap();
906 assert!(manager.get_project(&canonical_root).is_none());
907
908 let _project = manager.project_for_path(&file).unwrap();
910 assert!(manager.get_project(&canonical_root).is_some());
911 }
912
913 #[test]
914 fn test_manager_all_projects() {
915 let temp = TempDir::new().unwrap();
916
917 let repo1 = temp.path().join("repo1");
918 let repo2 = temp.path().join("repo2");
919 std::fs::create_dir(&repo1).unwrap();
920 std::fs::create_dir(&repo2).unwrap();
921 std::fs::create_dir(repo1.join(".git")).unwrap();
922 std::fs::create_dir(repo2.join(".git")).unwrap();
923
924 let manager = ProjectManager::new(ProjectRootMode::GitRoot);
925
926 let file1 = repo1.join("file.rs");
927 let file2 = repo2.join("file.rs");
928 std::fs::write(&file1, "").unwrap();
929 std::fs::write(&file2, "").unwrap();
930
931 let _project1 = manager.project_for_path(&file1).unwrap();
932 let _project2 = manager.project_for_path(&file2).unwrap();
933
934 let all = manager.all_projects();
935 assert_eq!(all.len(), 2);
936 }
937
938 #[test]
939 fn test_manager_shutdown() {
940 let temp = TempDir::new().unwrap();
941 let repo_root = setup_git_repo(&temp);
942 let file = repo_root.join("file.rs");
943 std::fs::write(&file, "").unwrap();
944
945 let manager = ProjectManager::new(ProjectRootMode::GitRoot);
946 let project = manager.project_for_path(&file).unwrap();
947 assert_eq!(manager.project_count(), 1);
948
949 manager.shutdown();
950 assert_eq!(manager.project_count(), 0);
951 assert!(project.is_cancelled());
953 }
954
955 #[test]
956 fn test_manager_concurrent_access() {
957 use std::thread;
958
959 let temp = TempDir::new().unwrap();
960 let repo_root = setup_git_repo(&temp);
961
962 for i in 0..10 {
964 let file = repo_root.join(format!("file{i}.rs"));
965 std::fs::write(&file, "").unwrap();
966 }
967
968 let manager = Arc::new(ProjectManager::new(ProjectRootMode::GitRoot));
969 let mut handles = vec![];
970
971 for i in 0..10 {
973 let manager = Arc::clone(&manager);
974 let file = repo_root.join(format!("file{i}.rs"));
975 handles.push(thread::spawn(move || {
976 manager.project_for_path(&file).unwrap()
977 }));
978 }
979
980 let projects: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect();
982
983 assert_eq!(manager.project_count(), 1);
985 let first_id = projects[0].id;
986 for project in projects {
987 assert_eq!(project.id, first_id);
988 }
989 }
990
991 #[test]
994 fn test_project_initialize_detects_repos() {
995 let temp = TempDir::new().unwrap();
996 let project_root = temp.path().join("project");
997 std::fs::create_dir(&project_root).unwrap();
998 std::fs::create_dir(project_root.join(".git")).unwrap();
999
1000 let project = Project::new(project_root.clone()).unwrap();
1001
1002 assert_eq!(project.repo_count(), 0);
1004
1005 project.initialize().unwrap();
1007
1008 assert_eq!(project.repo_count(), 1);
1010 }
1011
1012 #[test]
1013 fn test_project_initialize_detects_nested_repos() {
1014 let temp = TempDir::new().unwrap();
1015 let project_root = temp.path().join("monorepo");
1016 std::fs::create_dir(&project_root).unwrap();
1017 std::fs::create_dir(project_root.join(".git")).unwrap();
1018
1019 let inner1 = project_root.join("packages/app1");
1021 let inner2 = project_root.join("packages/app2");
1022 std::fs::create_dir_all(&inner1).unwrap();
1023 std::fs::create_dir_all(&inner2).unwrap();
1024 std::fs::create_dir(inner1.join(".git")).unwrap();
1025 std::fs::create_dir(inner2.join(".git")).unwrap();
1026
1027 let project = Project::new(project_root).unwrap();
1028 project.initialize().unwrap();
1029
1030 assert_eq!(project.repo_count(), 3);
1032 }
1033
1034 #[test]
1035 fn test_project_repo_id_for_file_simple() {
1036 let temp = TempDir::new().unwrap();
1037 let repo_root = setup_git_repo(&temp);
1038
1039 let file = repo_root.join("src/main.rs");
1041 std::fs::create_dir_all(file.parent().unwrap()).unwrap();
1042 std::fs::write(&file, "fn main() {}").unwrap();
1043
1044 let project = Project::new(repo_root).unwrap();
1045 project.initialize().unwrap();
1046
1047 let repo_id = project.repo_id_for_file(&file).unwrap();
1048 assert!(repo_id.is_some());
1049 }
1050
1051 #[test]
1052 fn test_project_repo_id_for_file_nested_nearest_wins() {
1053 let temp = TempDir::new().unwrap();
1054 let outer = temp.path().join("outer");
1055 std::fs::create_dir(&outer).unwrap();
1056 std::fs::create_dir(outer.join(".git")).unwrap();
1057
1058 let inner = outer.join("packages/inner");
1060 std::fs::create_dir_all(&inner).unwrap();
1061 std::fs::create_dir(inner.join(".git")).unwrap();
1062
1063 let file = inner.join("src/lib.rs");
1065 std::fs::create_dir_all(file.parent().unwrap()).unwrap();
1066 std::fs::write(&file, "").unwrap();
1067
1068 let project = Project::new(outer.clone()).unwrap();
1069 project.initialize().unwrap();
1070
1071 let inner_canonical = canonicalize_path(&inner).unwrap();
1073 let outer_canonical = canonicalize_path(&outer).unwrap();
1074
1075 let repo_index = project.repo_index();
1076 let inner_id = repo_index.get(&inner_canonical).unwrap();
1077 let outer_id = repo_index.get(&outer_canonical).unwrap();
1078
1079 let file_repo_id = project.repo_id_for_file(&file).unwrap();
1081 assert_eq!(file_repo_id, *inner_id);
1082 assert_ne!(file_repo_id, *outer_id);
1083 }
1084
1085 #[test]
1086 fn test_project_repo_id_for_file_outer_repo() {
1087 let temp = TempDir::new().unwrap();
1088 let outer = temp.path().join("outer");
1089 std::fs::create_dir(&outer).unwrap();
1090 std::fs::create_dir(outer.join(".git")).unwrap();
1091
1092 let inner = outer.join("packages/inner");
1094 std::fs::create_dir_all(&inner).unwrap();
1095 std::fs::create_dir(inner.join(".git")).unwrap();
1096
1097 let file = outer.join("src/main.rs");
1099 std::fs::create_dir_all(file.parent().unwrap()).unwrap();
1100 std::fs::write(&file, "").unwrap();
1101
1102 let project = Project::new(outer.clone()).unwrap();
1103 project.initialize().unwrap();
1104
1105 let outer_canonical = canonicalize_path(&outer).unwrap();
1107 let repo_index = project.repo_index();
1108 let outer_id = repo_index.get(&outer_canonical).unwrap();
1109
1110 let file_repo_id = project.repo_id_for_file(&file).unwrap();
1112 assert_eq!(file_repo_id, *outer_id);
1113 }
1114
1115 #[test]
1116 fn test_project_repo_id_for_file_no_repo() {
1117 let temp = TempDir::new().unwrap();
1118 let project_root = temp.path().join("norepro");
1119 std::fs::create_dir(&project_root).unwrap();
1120
1121 let file = project_root.join("file.rs");
1125 std::fs::write(&file, "").unwrap();
1126
1127 let project = Project::new(project_root).unwrap();
1128 project.initialize().unwrap();
1129
1130 assert_eq!(project.repo_count(), 0);
1132
1133 let repo_id = project.repo_id_for_file(&file).unwrap();
1135 assert!(repo_id.is_none());
1136 assert_eq!(repo_id, RepoId::NONE);
1137 }
1138
1139 #[test]
1140 fn test_project_repo_index_returns_all_repos() {
1141 let temp = TempDir::new().unwrap();
1142 let project_root = temp.path().join("multi");
1143 std::fs::create_dir(&project_root).unwrap();
1144
1145 for name in &["repo1", "repo2", "repo3"] {
1147 let repo = project_root.join(name);
1148 std::fs::create_dir(&repo).unwrap();
1149 std::fs::create_dir(repo.join(".git")).unwrap();
1150 }
1151
1152 let project = Project::new(project_root).unwrap();
1153 project.initialize().unwrap();
1154
1155 let repo_index = project.repo_index();
1156 assert_eq!(repo_index.len(), 3);
1157 }
1158
1159 #[test]
1160 fn test_project_initialize_submodule() {
1161 let temp = TempDir::new().unwrap();
1162 let main_repo = temp.path().join("main");
1163 std::fs::create_dir(&main_repo).unwrap();
1164 std::fs::create_dir(main_repo.join(".git")).unwrap();
1165
1166 let submodule = main_repo.join("deps/lib");
1169 std::fs::create_dir_all(&submodule).unwrap();
1170
1171 let gitdir_target = main_repo.join(".git/modules/deps/lib");
1173 std::fs::create_dir_all(&gitdir_target).unwrap();
1174 std::fs::write(gitdir_target.join("HEAD"), "ref: refs/heads/main\n").unwrap();
1175
1176 std::fs::write(
1177 submodule.join(".git"),
1178 "gitdir: ../../.git/modules/deps/lib\n",
1179 )
1180 .unwrap();
1181
1182 let project = Project::new(main_repo).unwrap();
1183 project.initialize().unwrap();
1184
1185 assert_eq!(project.repo_count(), 2);
1187 }
1188
1189 #[test]
1190 fn test_project_initialize_skips_ignored_dirs() {
1191 let temp = TempDir::new().unwrap();
1192 let project_root = temp.path().join("project");
1193 std::fs::create_dir(&project_root).unwrap();
1194 std::fs::create_dir(project_root.join(".git")).unwrap();
1195
1196 let ignored_repo = project_root.join("node_modules/pkg");
1198 std::fs::create_dir_all(&ignored_repo).unwrap();
1199 std::fs::create_dir(ignored_repo.join(".git")).unwrap();
1200
1201 let project = Project::new(project_root).unwrap();
1202 project.initialize().unwrap();
1203
1204 assert_eq!(project.repo_count(), 1);
1206 }
1207
1208 #[test]
1214 fn test_project_manager_routes_path_to_correct_project() {
1215 let temp = TempDir::new().unwrap();
1216
1217 let project_a = temp.path().join("project-a");
1219 let project_b = temp.path().join("project-b");
1220 std::fs::create_dir_all(&project_a).unwrap();
1221 std::fs::create_dir_all(&project_b).unwrap();
1222 std::fs::create_dir(project_a.join(".git")).unwrap();
1223 std::fs::create_dir(project_b.join(".git")).unwrap();
1224
1225 std::fs::write(project_a.join("file_a.rs"), "fn a() {}").unwrap();
1227 std::fs::write(project_b.join("file_b.rs"), "fn b() {}").unwrap();
1228
1229 let manager = ProjectManager::new(ProjectRootMode::GitRoot);
1231
1232 let resolved_a = manager.project_for_path(&project_a.join("file_a.rs"));
1234 assert!(resolved_a.is_ok());
1235 let canonical_a = canonicalize_path(&project_a).unwrap();
1236 assert_eq!(resolved_a.unwrap().index_root, canonical_a);
1237
1238 let resolved_b = manager.project_for_path(&project_b.join("file_b.rs"));
1240 assert!(resolved_b.is_ok());
1241 let canonical_b = canonicalize_path(&project_b).unwrap();
1242 assert_eq!(resolved_b.unwrap().index_root, canonical_b);
1243
1244 assert_eq!(manager.project_count(), 2);
1246 }
1247
1248 #[test]
1249 fn test_project_manager_nested_path_routes_to_containing_project() {
1250 let temp = TempDir::new().unwrap();
1251
1252 let project = temp.path().join("workspace");
1254 std::fs::create_dir_all(&project).unwrap();
1255 std::fs::create_dir(project.join(".git")).unwrap();
1256
1257 let nested_path = project.join("src/components/deeply/nested");
1258 std::fs::create_dir_all(&nested_path).unwrap();
1259 std::fs::write(nested_path.join("component.rs"), "struct C;").unwrap();
1260
1261 let manager = ProjectManager::new(ProjectRootMode::GitRoot);
1262
1263 let resolved = manager.project_for_path(&nested_path.join("component.rs"));
1265 assert!(resolved.is_ok());
1266 let canonical_project = canonicalize_path(&project).unwrap();
1267 assert_eq!(resolved.unwrap().index_root, canonical_project);
1268 }
1269
1270 #[test]
1271 #[cfg_attr(target_os = "macos", ignore = "FSEvents timing flaky in CI")]
1272 fn test_project_manager_multi_workspace_folder_isolation() {
1273 let temp = TempDir::new().unwrap();
1274
1275 let frontend = temp.path().join("frontend");
1277 let backend = temp.path().join("backend");
1278 let shared = temp.path().join("shared");
1279
1280 for p in [&frontend, &backend, &shared] {
1281 std::fs::create_dir_all(p).unwrap();
1282 std::fs::create_dir(p.join(".git")).unwrap();
1283 std::fs::write(p.join("file.rs"), "").unwrap();
1285 }
1286
1287 let manager = ProjectManager::new(ProjectRootMode::WorkspaceFolder);
1288 manager.set_workspace_folders(vec![frontend.clone(), backend.clone(), shared.clone()]);
1289
1290 let canon = |p: &Path| -> PathBuf { canonicalize_path(p).unwrap() };
1292 let folders = manager.workspace_folders();
1293 assert_eq!(folders.len(), 3);
1294 assert!(folders.contains(&canon(&frontend)));
1295 assert!(folders.contains(&canon(&backend)));
1296 assert!(folders.contains(&canon(&shared)));
1297
1298 let frontend_proj = manager.project_for_path(&frontend.join("file.rs")).unwrap();
1300 let backend_proj = manager.project_for_path(&backend.join("file.rs")).unwrap();
1301 let shared_proj = manager.project_for_path(&shared.join("file.rs")).unwrap();
1302
1303 assert_eq!(frontend_proj.index_root, canon(&frontend));
1304 assert_eq!(backend_proj.index_root, canon(&backend));
1305 assert_eq!(shared_proj.index_root, canon(&shared));
1306 }
1307
1308 #[test]
1309 fn test_project_manager_workspace_folder_update() {
1310 let temp = TempDir::new().unwrap();
1311
1312 let project_a = temp.path().join("project-a");
1313 let project_b = temp.path().join("project-b");
1314 std::fs::create_dir_all(&project_a).unwrap();
1315 std::fs::create_dir_all(&project_b).unwrap();
1316 std::fs::create_dir(project_a.join(".git")).unwrap();
1317 std::fs::create_dir(project_b.join(".git")).unwrap();
1318 std::fs::write(project_a.join("file.rs"), "").unwrap();
1319 std::fs::write(project_b.join("file.rs"), "").unwrap();
1320
1321 let manager = ProjectManager::new(ProjectRootMode::WorkspaceFolder);
1322 manager.set_workspace_folders(vec![project_a.clone(), project_b.clone()]);
1323
1324 let canon = |p: &Path| -> PathBuf { canonicalize_path(p).unwrap() };
1326 assert_eq!(manager.workspace_folders().len(), 2);
1327
1328 manager.set_workspace_folders(vec![project_a.clone()]);
1330
1331 let folders = manager.workspace_folders();
1333 assert_eq!(folders.len(), 1);
1334 assert!(folders.contains(&canon(&project_a)));
1335 assert!(!folders.contains(&canon(&project_b)));
1336 }
1337
1338 #[test]
1339 fn test_project_index_routing_per_project() {
1340 let temp = TempDir::new().unwrap();
1341
1342 let lib_project = temp.path().join("lib");
1344 let app_project = temp.path().join("app");
1345 std::fs::create_dir_all(&lib_project).unwrap();
1346 std::fs::create_dir_all(&app_project).unwrap();
1347 std::fs::create_dir(lib_project.join(".git")).unwrap();
1348 std::fs::create_dir(app_project.join(".git")).unwrap();
1349
1350 std::fs::create_dir_all(lib_project.join("src")).unwrap();
1352 std::fs::create_dir_all(app_project.join("src")).unwrap();
1353 std::fs::write(lib_project.join("src/lib.rs"), "").unwrap();
1354 std::fs::write(app_project.join("src/main.rs"), "").unwrap();
1355
1356 let manager = ProjectManager::new(ProjectRootMode::GitRoot);
1357
1358 let lib_proj = manager.project_for_path(&lib_project.join("src/lib.rs"));
1360 let app_proj = manager.project_for_path(&app_project.join("src/main.rs"));
1361
1362 assert!(lib_proj.is_ok());
1364 assert!(app_proj.is_ok());
1365
1366 let lib_root = lib_proj.unwrap().index_root.clone();
1368 let app_root = app_proj.unwrap().index_root.clone();
1369 assert_ne!(lib_root, app_root);
1370
1371 let lib_canonical = canonicalize_path(&lib_project).unwrap();
1373 let app_canonical = canonicalize_path(&app_project).unwrap();
1374 assert_eq!(lib_root, lib_canonical);
1375 assert_eq!(app_root, app_canonical);
1376 }
1377}