Skip to main content

sqry_core/project/
manager.rs

1//! Project lifecycle management with thread-safe access
2//!
3//! Implements the `ProjectManager` from `02_DESIGN.md`:
4//! - Routes file paths to Projects based on `ProjectRootMode`
5//! - Manages Project creation/destruction lifecycle
6//! - Thread-safe with `RwLock` + double-check pattern (C5)
7//!
8//! # Thread Safety
9//!
10//! `ProjectManager` uses `parking_lot::RwLock` with double-check pattern:
11//! 1. Read lock: Fast path for existing Projects
12//! 2. Write lock: Slow path with double-check for creation
13
14use 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/// A Project represents a single code base with its own `CodeGraph` and caches.
32///
33/// Per `PROJECT_ROOT_SPEC` and `02_DESIGN.md`, each Project:
34/// - Has a unique canonical `index_root`
35/// - Owns a `CodeGraph` (future enhancement), symbol/index caches
36/// - Tracks repository metadata via `RepoIndex`
37/// - Manages file metadata via `FileTable`
38/// - Loads configuration from `.sqry-config.toml`
39// Manual Debug impl because CodeGraph doesn't implement Debug
40#[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
59/// Represents a single code base with its own `CodeGraph` and caches.
60pub struct Project {
61    /// Unique identifier for this Project.
62    pub id: ProjectId,
63
64    /// Canonical path to the project root.
65    pub index_root: PathBuf,
66
67    /// Project configuration loaded from `.sqry-config.toml`.
68    ///
69    /// Loaded from `index_root` ancestor walk per `02_DESIGN.md` [M3].
70    config: ProjectConfig,
71
72    /// Repository index: maps git root paths to `RepoIds`.
73    repo_index: RwLock<HashMap<PathBuf, RepoId>>,
74
75    /// File table: maps relative paths to `FileEntry` metadata.
76    file_table: RwLock<HashMap<StringId, FileEntry>>,
77
78    /// Cached unified graph for this project.
79    ///
80    /// Each Project caches its own `CodeGraph` to avoid disk reloads on every
81    /// handler request.
82    graph_cache: RwLock<Option<Arc<CodeGraph>>>,
83
84    /// Whether this Project has been initialized.
85    initialized: AtomicBool,
86
87    /// Cancellation flag for background operations.
88    cancelled: AtomicBool,
89}
90
91impl Project {
92    /// Create a new Project shell.
93    ///
94    /// The Project is created but not initialized - call `initialize()` to set up
95    /// the `CodeGraph` and caches.
96    ///
97    /// Configuration is loaded from `.sqry-config.toml` by walking up from
98    /// `index_root` per `02_DESIGN.md` \[M3\].
99    ///
100    /// # Errors
101    ///
102    /// Returns an error if project configuration fails to load or validate.
103    pub fn new(index_root: PathBuf) -> Result<Self, ProjectError> {
104        let id = ProjectId::from_index_root(&index_root);
105
106        // Load configuration from .sqry-config.toml (ancestor walk)
107        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    /// Initialize the Project (detect repos, prepare graph caching).
130    ///
131    /// This is separate from `new()` to allow lazy initialization.
132    /// Per `PROJECT_ROOT_SPEC.md` Section 6.1-6.3, initialization includes:
133    /// 1. Attempts to load persisted state (if `cache.persistent = true`)
134    /// 2. Falls back to detecting all git repositories under the `index_root`
135    /// 3. Populates the `repo_index` with `RepoIds`
136    /// 4. Graph caching is lazy-loaded via `graph()` method
137    ///
138    /// Note: File watchers are handled by the LSP layer, not the Project.
139    ///
140    /// # Errors
141    ///
142    /// Returns an error if repository discovery or persistence initialization fails.
143    pub fn initialize(&self) -> Result<(), ProjectError> {
144        if self.initialized.load(Ordering::Acquire) {
145            return Ok(()); // Already initialized (idempotent)
146        }
147
148        log::info!(
149            "Initializing Project {} at '{}'",
150            self.id,
151            self.index_root.display()
152        );
153
154        // Step 1: Try to load persisted state (if enabled)
155        let mut preloaded = false;
156        if self.config.cache.persistent {
157            preloaded = self.try_preload_state();
158        }
159
160        // Step 2: Fall back to repository detection if preload failed/skipped
161        if !preloaded {
162            self.detect_repositories();
163        }
164
165        // Step 3: Graph caching is set up but lazy-loaded via graph() method
166        // This avoids loading graphs for projects that may never be queried
167        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    /// Try to preload state from persisted metadata.
177    ///
178    /// Returns `true` if state was successfully loaded, `false` if preload
179    /// was skipped or failed (caller should fall back to detection).
180    fn try_preload_state(&self) -> bool {
181        let persistence = ProjectPersistence::new(&self.index_root, &self.config.cache.directory);
182
183        // Try to read persisted state
184        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        // Validate version
201        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        // Validate project_id
211        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        // Validate config fingerprint
220        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        // Restore repo_index
232        let repo_index = restore_repo_index(&state);
233        *self.repo_index.write() = repo_index;
234
235        // Restore file_table
236        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    /// Detect all git repositories under the `index_root` and populate `repo_index`.
250    ///
251    /// Per `02_DESIGN.md`:
252    /// - Walks the `index_root` looking for .git directories
253    /// - Assigns `RepoIds` to each detected repository
254    /// - Handles nested repos (all are tracked independently)
255    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    /// Get the `RepoId` for a file path using the cached repo index.
272    ///
273    /// Implements "nearest .git wins" rule per `02_DESIGN.md` M2:
274    /// - Uses the `repo_index` populated during `initialize()`
275    /// - Returns `RepoId::NONE` if no git root found in ancestry
276    ///
277    /// # Arguments
278    ///
279    /// * `file_path` - Path to the file (will be canonicalized)
280    ///
281    /// # Errors
282    ///
283    /// Returns an error if path canonicalization fails.
284    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    /// Get the cached repo index (for advanced use cases).
293    ///
294    /// Returns a copy of the repo index mapping git root paths to `RepoIds`.
295    #[must_use]
296    pub fn repo_index(&self) -> HashMap<PathBuf, RepoId> {
297        self.repo_index.read().clone()
298    }
299
300    /// Check if the Project has been initialized.
301    #[must_use]
302    pub fn is_initialized(&self) -> bool {
303        self.initialized.load(Ordering::Acquire)
304    }
305
306    /// Cancel any in-progress background operations.
307    pub fn cancel_operations(&self) {
308        self.cancelled.store(true, Ordering::Release);
309        log::debug!("Cancelled operations for Project {}", self.id);
310    }
311
312    /// Load (or reuse) the cached unified graph for this project.
313    ///
314    /// Each Project caches its own `CodeGraph` to avoid repeated disk I/O
315    /// on every handler request.
316    ///
317    /// # Errors
318    ///
319    /// Returns an error when the graph cannot be loaded from disk.
320    pub fn graph(&self) -> Result<Option<Arc<CodeGraph>>, ProjectError> {
321        // Fast path: return cached graph if available
322        if let Some(graph) = self.graph_cache.read().as_ref() {
323            return Ok(Some(graph.clone()));
324        }
325
326        // Slow path: load from disk
327        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        // Cache the loaded graph
341        let mut cache = self.graph_cache.write();
342        *cache = Some(graph.clone());
343
344        Ok(Some(graph))
345    }
346
347    /// Clear the cached unified graph.
348    ///
349    /// Call this when the graph is rebuilt or invalidated.
350    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    /// Check if operations have been cancelled.
357    #[must_use]
358    pub fn is_cancelled(&self) -> bool {
359        self.cancelled.load(Ordering::Acquire)
360    }
361
362    /// Register a repository in this Project's repo index.
363    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    /// Get the `RepoId` for a git root path.
369    #[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    /// Register a file in this Project's file table.
376    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    /// Get a file entry by path.
382    #[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    /// Get the number of registered files.
389    #[must_use]
390    pub fn file_count(&self) -> usize {
391        self.file_table.read().len()
392    }
393
394    /// Get the number of registered repositories.
395    #[must_use]
396    pub fn repo_count(&self) -> usize {
397        self.repo_index.read().len()
398    }
399
400    /// Persist state if configured.
401    ///
402    /// When `cache.persistent = true`:
403    /// 1. Persists `repo_index` + `file_table` as versioned JSON
404    ///
405    /// Per `02_DESIGN.md`, uses atomic temp + rename for safety.
406    ///
407    /// # Errors
408    ///
409    /// Logs warnings on failure but does not panic. Returns quietly
410    /// to allow shutdown to proceed.
411    pub fn persist_if_configured(&self) {
412        // Early exit if persistence disabled
413        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        // Compute config fingerprint for invalidation detection
424        let fingerprint = compute_config_fingerprint(&self.config.cache, &self.config.indexing);
425
426        // Build persistence helper
427        let persistence = ProjectPersistence::new(&self.index_root, &self.config.cache.directory);
428
429        // Snapshot repo_index and file_table under read locks
430        let repo_index = self.repo_index.read().clone();
431        let file_table = self.file_table.read().clone();
432
433        // Build persisted state
434        let state = build_persisted_state(
435            self.id,
436            &self.index_root,
437            fingerprint,
438            &repo_index,
439            &file_table,
440        );
441
442        // Write metadata
443        if let Err(e) = persistence.write_metadata(&state) {
444            log::warn!("Failed to persist metadata for Project {}: {}", self.id, e);
445        }
446    }
447
448    /// Get the project configuration.
449    ///
450    /// Returns a reference to the loaded `.sqry-config.toml` configuration.
451    #[must_use]
452    pub fn config(&self) -> &ProjectConfig {
453        &self.config
454    }
455
456    /// Get the effective ignored directories for this project.
457    ///
458    /// Combines default ignored directories with any additional directories
459    /// specified in the project configuration.
460    #[must_use]
461    pub fn effective_ignored_dirs(&self) -> Vec<&str> {
462        self.config.effective_ignored_dirs()
463    }
464
465    /// Check if a path should be ignored during indexing.
466    ///
467    /// Returns true if the path matches any ignore pattern (and doesn't match
468    /// an override include pattern).
469    #[must_use]
470    pub fn is_path_ignored(&self, path: &Path) -> bool {
471        self.config.is_ignored(path)
472    }
473
474    /// Get the language ID for a path based on configuration hints.
475    ///
476    /// Returns the configured language if found, or None for default detection.
477    #[must_use]
478    pub fn language_for_path(&self, path: &Path) -> Option<&str> {
479        self.config.language_for_path(path)
480    }
481}
482
483/// Manages multiple Projects and routes file paths to the appropriate Project.
484///
485/// Per `02_DESIGN.md`, `ProjectManager`:
486/// - Resolves file paths to Projects based on `ProjectRootMode`
487/// - Creates Projects on demand (lazy)
488/// - Handles configuration changes (mode switch = teardown + rebuild)
489/// - Thread-safe with `RwLock` + double-check pattern
490#[derive(Debug)]
491pub struct ProjectManager {
492    /// Current resolution mode.
493    mode: RwLock<ProjectRootMode>,
494
495    /// Map from `index_root` to Project.
496    projects: RwLock<HashMap<PathBuf, Arc<Project>>>,
497
498    /// Workspace folders (LSP order, not alphabetical).
499    workspace_folders: RwLock<Vec<PathBuf>>,
500}
501
502impl ProjectManager {
503    /// Create a new `ProjectManager` with the given mode.
504    #[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    /// Create a `ProjectManager` with default mode (`GitRoot`).
515    #[must_use]
516    pub fn with_default_mode() -> Self {
517        Self::new(ProjectRootMode::default())
518    }
519
520    /// Get the current mode.
521    #[must_use]
522    pub fn mode(&self) -> ProjectRootMode {
523        *self.mode.read()
524    }
525
526    /// Set workspace folders.
527    ///
528    /// Folders are canonicalized internally to ensure `starts_with` comparisons
529    /// work correctly when `project_for_path` canonicalizes file paths.
530    /// On Windows, this resolves 8.3 short names and adds the `\\?\` prefix.
531    /// Folders are stored in the order provided (typically LSP order).
532    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    /// Get current workspace folders.
552    #[must_use]
553    pub fn workspace_folders(&self) -> Vec<PathBuf> {
554        self.workspace_folders.read().clone()
555    }
556
557    /// Get or create Project for a file path.
558    ///
559    /// Uses double-check pattern per `02_DESIGN.md` C5:
560    /// 1. Canonicalize path (outside lock)
561    /// 2. Resolve `index_root` (outside lock)
562    /// 3. Read lock: check if Project exists (fast path)
563    /// 4. Write lock: double-check + create if needed (slow path)
564    ///
565    /// # Arguments
566    ///
567    /// * `file_path` - Path to file (may be relative or contain symlinks)
568    ///
569    /// # Errors
570    ///
571    /// Returns an error if path canonicalization fails.
572    pub fn project_for_path(&self, file_path: &Path) -> Result<Arc<Project>, ProjectError> {
573        // 1. Canonicalize path first (outside any lock)
574        let canonical_path = canonicalize_path(file_path)
575            .map_err(|e| ProjectError::canonicalization_failed(file_path, e))?;
576
577        // 2. Resolve index_root (outside any lock)
578        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        // 3. First check: read lock (fast path)
583        {
584            let projects = self.projects.read();
585            if let Some(project) = projects.get(&index_root) {
586                return Ok(Arc::clone(project));
587            }
588        } // Read lock released here
589
590        // 4. Second check + creation: write lock (slow path)
591        {
592            let mut projects = self.projects.write();
593
594            // Double-check: another thread may have created it
595            if let Some(project) = projects.get(&index_root) {
596                return Ok(Arc::clone(project));
597            }
598
599            // Create new Project while holding write lock
600            let project = Arc::new(Project::new(index_root.clone())?);
601            projects.insert(index_root, Arc::clone(&project));
602
603            // Initialize the project (detect repos, etc.) - idempotent
604            // This is done inside the write lock to ensure consistent state
605            // before returning the project to the caller.
606            if let Err(e) = project.initialize() {
607                log::warn!("Project {} initialization failed: {}", project.id, e);
608                // Continue anyway - the project is usable, just without repo detection
609            }
610
611            log::info!("Created new Project {} via project_for_path", project.id);
612            Ok(project)
613        } // Write lock released here
614    }
615
616    /// Get a Project by its `index_root` if it exists.
617    ///
618    /// Does not create the Project if it doesn't exist.
619    #[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    /// Get all Projects.
626    #[must_use]
627    pub fn all_projects(&self) -> Vec<Arc<Project>> {
628        self.projects.read().values().cloned().collect()
629    }
630
631    /// Get the number of active Projects.
632    #[must_use]
633    pub fn project_count(&self) -> usize {
634        self.projects.read().len()
635    }
636
637    /// Handle configuration change (mode switch).
638    ///
639    /// Per `02_DESIGN.md` M1, uses full teardown strategy:
640    /// 1. Cancel all in-progress operations
641    /// 2. Optionally persist state
642    /// 3. Drop all Projects
643    /// 4. New Projects created lazily on next access
644    pub fn handle_config_change(&self, new_mode: ProjectRootMode) {
645        let old_mode = *self.mode.read();
646        if old_mode == new_mode {
647            return; // No change
648        }
649
650        log::warn!(
651            "projectRootMode changed from {old_mode:?} to {new_mode:?}, rebuilding Projects"
652        );
653
654        // Full teardown
655        let mut projects = self.projects.write();
656        for (_root, project) in projects.drain() {
657            project.cancel_operations();
658            project.persist_if_configured();
659            // Project dropped here
660        }
661        drop(projects);
662
663        // Update mode
664        *self.mode.write() = new_mode;
665
666        log::info!("Mode change complete. New Projects will be created lazily.");
667    }
668
669    /// Remove a Project by `index_root`.
670    ///
671    /// Used when a workspace folder is removed.
672    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    /// Shutdown: remove all Projects and clean up.
687    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        // Second call should be no-op
734        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        // set_workspace_folders canonicalizes internally, so compare canonicalized forms
800        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        // Should have created one project
820        assert_eq!(manager.project_count(), 1);
821
822        // Same file should return same project
823        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        // Create two separate git repos
833        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        // Should have two different projects
851        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        // Create a project
865        let _project = manager.project_for_path(&file).unwrap();
866        assert_eq!(manager.project_count(), 1);
867
868        // Change mode - should clear projects
869        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        // Get the canonical index_root
886        let index_root = canonicalize_path(&repo_root).unwrap();
887
888        // Remove the project
889        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        // Before creation
905        let canonical_root = canonicalize_path(&repo_root).unwrap();
906        assert!(manager.get_project(&canonical_root).is_none());
907
908        // After creation
909        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        // Project should have been cancelled
952        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        // Create multiple files in the same repo
963        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        // Spawn threads that all access the same repo concurrently
972        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        // Collect results
981        let projects: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect();
982
983        // All threads should get the same project (only one created)
984        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    // ===== Phase 4: Repository Detection Tests =====
992
993    #[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        // Before initialize, repo_index should be empty
1003        assert_eq!(project.repo_count(), 0);
1004
1005        // Initialize should detect the repo
1006        project.initialize().unwrap();
1007
1008        // After initialize, repo_index should have one entry
1009        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        // Create nested repos
1020        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        // Should detect all three repos
1031        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        // Create a file in the repo
1040        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        // Create nested inner repo
1059        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        // Create file in inner repo
1064        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        // Get RepoIds
1072        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        // File in inner repo should get inner repo's ID
1080        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        // Create nested inner repo
1093        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        // Create file in outer repo (outside inner)
1098        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        // Get RepoIds
1106        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        // File in outer repo should get outer repo's ID
1111        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        // No .git directory
1122
1123        // Create a file
1124        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        // No repos detected
1131        assert_eq!(project.repo_count(), 0);
1132
1133        // File should get NONE
1134        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        // Create three separate repos
1146        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        // Create submodule with .git file
1167        // Note: use "deps" not "vendor" since vendor is in IGNORED_DIRS
1168        let submodule = main_repo.join("deps/lib");
1169        std::fs::create_dir_all(&submodule).unwrap();
1170
1171        // Create the gitdir target (Phase 4 validation requires this to exist with HEAD)
1172        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        // Should detect both main repo and submodule
1186        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        // Create repo in node_modules (should be skipped)
1197        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        // Should only find the main project repo
1205        assert_eq!(project.repo_count(), 1);
1206    }
1207
1208    // ============================================================
1209    // Integration tests for project root mode routing (Codex LOW)
1210    // Per PROJECT_ROOT_SPEC.md Section 9.2 path-aware index routing
1211    // ============================================================
1212
1213    #[test]
1214    fn test_project_manager_routes_path_to_correct_project() {
1215        let temp = TempDir::new().unwrap();
1216
1217        // Create two separate workspace folders (projects) with git repos
1218        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        // Create files in each project
1226        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        // Use GitRoot mode - each git repo becomes its own project
1230        let manager = ProjectManager::new(ProjectRootMode::GitRoot);
1231
1232        // Route path to project_a - should succeed
1233        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        // Route path to project_b - should succeed
1239        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        // Should have created two distinct projects
1245        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        // Create project with nested directories
1253        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        // Deeply nested path should route to containing project (git root)
1264        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        // Create three workspace folders with git repos
1276        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            // Create a file so path resolution works
1284            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        // Verify workspace folders are tracked (set_workspace_folders canonicalizes internally)
1291        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        // Each path should route to its own project
1299        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        // Both should be present (set_workspace_folders canonicalizes internally)
1325        let canon = |p: &Path| -> PathBuf { canonicalize_path(p).unwrap() };
1326        assert_eq!(manager.workspace_folders().len(), 2);
1327
1328        // Update to only include project_a (simulates removing project_b)
1329        manager.set_workspace_folders(vec![project_a.clone()]);
1330
1331        // Only project_a should remain
1332        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        // Create two projects with different git repos
1343        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        // Create files for path resolution
1351        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        // Get project for each path
1359        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        // Projects should be resolved successfully
1363        assert!(lib_proj.is_ok());
1364        assert!(app_proj.is_ok());
1365
1366        // Projects should be distinct
1367        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        // Verify they map to correct canonical paths
1372        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}