Skip to main content

sqry_core/project/
resolver.rs

1//! Root resolution logic for determining Project boundaries
2//!
3//! Implements the root resolution algorithms from `PROJECT_ROOT_SPEC.md` and `02_DESIGN.md` \[H2\]:
4//!
5//! - **`GitRoot` mode**: Each git repository gets its own Project
6//! - **`WorkspaceFolder` mode**: Each workspace folder is a Project
7//! - **`WorkspaceRoot` mode**: Single Project covering all folders
8//!
9//! # Fallback Chain (gitRoot mode, per C1)
10//!
11//! ```text
12//! 1. Walk ancestors of file looking for .git
13//! 2. IF .git found → use git root
14//! 3. ELSE IF workspace folders exist → use containing folder or first folder
15//! 4. ELSE (single-file mode) → use parent directory
16//! ```
17
18use super::path_utils::canonicalize_path;
19use super::types::{ProjectError, ProjectRootMode};
20use std::path::{Path, PathBuf};
21
22/// Find the git root for a file path by walking ancestors.
23///
24/// Returns `Some(git_root_path)` if a `.git` directory is found in an ancestor,
25/// or `None` if no git root exists.
26///
27/// The path must already be canonicalized for consistent results.
28#[must_use]
29pub fn find_git_root(file_path: &Path) -> Option<PathBuf> {
30    let mut current = file_path;
31
32    // Walk up the directory tree
33    loop {
34        let git_dir = current.join(".git");
35
36        // Check if .git exists and is a directory (or file for worktrees)
37        if git_dir.exists() {
38            // Found git root - return the parent of .git
39            return Some(current.to_path_buf());
40        }
41
42        // Move to parent directory
43        match current.parent() {
44            Some(parent) => current = parent,
45            None => break, // Reached filesystem root
46        }
47    }
48
49    None
50}
51
52/// Resolve the index root for a file path based on mode and workspace context.
53///
54/// This implements the full resolution algorithm from `02_DESIGN.md` H2:
55///
56/// # Arguments
57///
58/// * `file_path` - The canonical path to resolve (must be pre-canonicalized)
59/// * `mode` - The `ProjectRootMode` determining resolution strategy
60/// * `workspace_folders` - Ordered list of workspace folders (LSP order, not alphabetical)
61///
62/// # Returns
63///
64/// The canonical path to use as `index_root` for the Project.
65///
66/// # Errors
67///
68/// Returns an error if resolution fails (extremely rare - only if all fallbacks exhausted).
69pub fn resolve_index_root(
70    file_path: &Path,
71    mode: ProjectRootMode,
72    workspace_folders: &[PathBuf],
73) -> Result<PathBuf, ProjectError> {
74    match mode {
75        ProjectRootMode::GitRoot => resolve_git_root_mode(file_path, workspace_folders),
76        ProjectRootMode::WorkspaceFolder => {
77            resolve_workspace_folder_mode(file_path, workspace_folders)
78        }
79        ProjectRootMode::WorkspaceRoot => resolve_workspace_root_mode(file_path, workspace_folders),
80    }
81}
82
83/// Resolve `index_root` for `gitRoot` mode (default).
84///
85/// Per `02_DESIGN.md` C1, the fallback chain is:
86/// 1. Walk ancestors looking for .git
87/// 2. If found → use git root
88/// 3. Else if workspace folders exist → use containing folder or first folder
89/// 4. Else (single-file mode) → use parent directory
90fn resolve_git_root_mode(
91    file_path: &Path,
92    workspace_folders: &[PathBuf],
93) -> Result<PathBuf, ProjectError> {
94    // Step 1 & 2: Look for git root
95    if let Some(git_root) = find_git_root(file_path) {
96        log::debug!(
97            "Found git root for '{}': '{}'",
98            file_path.display(),
99            git_root.display()
100        );
101        return Ok(git_root);
102    }
103
104    // Step 3: No git root - check workspace folders
105    if !workspace_folders.is_empty() {
106        // Try to find containing workspace folder
107        if let Some(folder) = find_containing_workspace_folder(file_path, workspace_folders) {
108            log::info!(
109                "No git root for '{}', using workspace folder '{}'",
110                file_path.display(),
111                folder.display()
112            );
113            return Ok(folder);
114        }
115
116        // File outside all workspace folders - use first folder
117        let first_folder = &workspace_folders[0];
118        log::warn!(
119            "File '{}' outside all workspace folders, using first folder '{}' as root",
120            file_path.display(),
121            first_folder.display()
122        );
123        return Ok(first_folder.clone());
124    }
125
126    // Step 4: Single-file mode (no workspace folders) - use parent directory
127    if let Some(parent) = file_path.parent() {
128        if parent.as_os_str().is_empty() {
129            // Handle root directory case
130            log::info!(
131                "No workspace folders, using current directory as root for '{}'",
132                file_path.display()
133            );
134            return Ok(PathBuf::from("."));
135        }
136        log::info!(
137            "No workspace folders, using parent directory '{}' as root",
138            parent.display()
139        );
140        return Ok(parent.to_path_buf());
141    }
142
143    // Edge case: file_path is root itself
144    Err(ProjectError::no_git_root(file_path))
145}
146
147/// Resolve `index_root` for `workspaceFolder` mode.
148///
149/// Each workspace folder becomes a Project root, ignoring git boundaries.
150fn resolve_workspace_folder_mode(
151    file_path: &Path,
152    workspace_folders: &[PathBuf],
153) -> Result<PathBuf, ProjectError> {
154    if workspace_folders.is_empty() {
155        // Single-file mode - use parent directory
156        return file_path
157            .parent()
158            .map(Path::to_path_buf)
159            .ok_or_else(|| ProjectError::no_git_root(file_path));
160    }
161
162    // Find containing workspace folder
163    if let Some(folder) = find_containing_workspace_folder(file_path, workspace_folders) {
164        return Ok(folder);
165    }
166
167    // File outside all workspace folders - use first folder with warning
168    let first_folder = &workspace_folders[0];
169    log::warn!(
170        "File '{}' outside all workspace folders, using first folder '{}' as root",
171        file_path.display(),
172        first_folder.display()
173    );
174    Ok(first_folder.clone())
175}
176
177/// Resolve `index_root` for `workspaceRoot` mode.
178///
179/// Single Project covering all workspace folders.
180/// Index root is always the first workspace folder.
181fn resolve_workspace_root_mode(
182    file_path: &Path,
183    workspace_folders: &[PathBuf],
184) -> Result<PathBuf, ProjectError> {
185    if workspace_folders.is_empty() {
186        // Single-file mode - use parent directory
187        return file_path
188            .parent()
189            .map(Path::to_path_buf)
190            .ok_or_else(|| ProjectError::no_git_root(file_path));
191    }
192
193    // Always use first workspace folder
194    Ok(workspace_folders[0].clone())
195}
196
197/// Find the workspace folder that contains a file path.
198///
199/// Returns the first workspace folder (in order) that is an ancestor of `file_path`,
200/// or `None` if the file is outside all workspace folders.
201fn find_containing_workspace_folder(
202    file_path: &Path,
203    workspace_folders: &[PathBuf],
204) -> Option<PathBuf> {
205    for folder in workspace_folders {
206        if file_path.starts_with(folder) {
207            return Some(folder.clone());
208        }
209    }
210    None
211}
212
213/// Canonicalize a path and resolve its index root.
214///
215/// This is the high-level entry point combining canonicalization with resolution.
216///
217/// # Arguments
218///
219/// * `file_path` - Raw file path (may be relative or contain symlinks)
220/// * `mode` - The `ProjectRootMode` determining resolution strategy
221/// * `workspace_folders` - Ordered list of workspace folders (already canonicalized)
222///
223/// # Returns
224///
225/// The canonical `index_root` path for the Project.
226///
227/// # Errors
228///
229/// Returns an error if canonicalization fails or resolution fails.
230pub fn canonicalize_and_resolve(
231    file_path: &Path,
232    mode: ProjectRootMode,
233    workspace_folders: &[PathBuf],
234) -> Result<PathBuf, ProjectError> {
235    // Canonicalize first (per H1)
236    let canonical = canonicalize_path(file_path)
237        .map_err(|e| ProjectError::canonicalization_failed(file_path, e))?;
238
239    // Then resolve index root
240    resolve_index_root(&canonical, mode, workspace_folders)
241}
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246    use tempfile::TempDir;
247
248    fn tempdir_outside_git_repo() -> TempDir {
249        #[cfg(unix)]
250        fn is_in_git_repo(path: &Path) -> bool {
251            path.ancestors()
252                .any(|ancestor| ancestor.join(".git").is_dir())
253        }
254
255        #[cfg(unix)]
256        {
257            for base in [Path::new("/var/tmp"), Path::new("/dev/shm")] {
258                if base.is_dir()
259                    && !is_in_git_repo(base)
260                    && let Ok(tmp) = TempDir::new_in(base)
261                {
262                    return tmp;
263                }
264            }
265        }
266
267        TempDir::new().expect("create temp dir")
268    }
269
270    fn setup_git_repo(temp: &TempDir) -> PathBuf {
271        let git_dir = temp.path().join(".git");
272        std::fs::create_dir(&git_dir).unwrap();
273        temp.path().to_path_buf()
274    }
275
276    #[test]
277    fn test_find_git_root_exists() {
278        let temp = TempDir::new().unwrap();
279        let repo_root = setup_git_repo(&temp);
280
281        // Create nested file
282        let subdir = repo_root.join("src");
283        std::fs::create_dir(&subdir).unwrap();
284        let file = subdir.join("main.rs");
285        std::fs::write(&file, "fn main() {}").unwrap();
286
287        // Should find git root
288        let git_root = find_git_root(&file);
289        assert!(git_root.is_some());
290        assert_eq!(git_root.unwrap(), repo_root);
291    }
292
293    #[test]
294    fn test_find_git_root_not_exists() {
295        let temp = tempdir_outside_git_repo();
296        let file = temp.path().join("loose_file.rs");
297        std::fs::write(&file, "fn main() {}").unwrap();
298
299        // No .git - should return None
300        let git_root = find_git_root(&file);
301        assert!(git_root.is_none());
302    }
303
304    #[test]
305    fn test_find_git_root_nested_repos() {
306        let temp = TempDir::new().unwrap();
307
308        // Outer repo
309        let outer_git = temp.path().join(".git");
310        std::fs::create_dir(&outer_git).unwrap();
311
312        // Inner repo (e.g., submodule)
313        let inner = temp.path().join("inner");
314        std::fs::create_dir(&inner).unwrap();
315        let inner_git = inner.join(".git");
316        std::fs::create_dir(&inner_git).unwrap();
317
318        // File in inner repo
319        let file = inner.join("lib.rs");
320        std::fs::write(&file, "pub fn foo() {}").unwrap();
321
322        // Should find inner repo (nearest .git wins)
323        let git_root = find_git_root(&file);
324        assert!(git_root.is_some());
325        assert_eq!(git_root.unwrap(), inner);
326    }
327
328    #[test]
329    fn test_resolve_git_root_mode_with_git() {
330        let temp = TempDir::new().unwrap();
331        let repo_root = setup_git_repo(&temp);
332
333        let file = repo_root.join("file.rs");
334        std::fs::write(&file, "").unwrap();
335
336        let result = resolve_git_root_mode(&file, &[]).unwrap();
337        assert_eq!(result, repo_root);
338    }
339
340    #[test]
341    fn test_resolve_git_root_mode_no_git_with_workspace() {
342        let temp = tempdir_outside_git_repo();
343        let file = temp.path().join("file.rs");
344        std::fs::write(&file, "").unwrap();
345
346        let workspace_folders = vec![temp.path().to_path_buf()];
347        let result = resolve_git_root_mode(&file, &workspace_folders).unwrap();
348        assert_eq!(result, temp.path());
349    }
350
351    #[test]
352    fn test_resolve_git_root_mode_file_outside_workspace() {
353        let temp1 = tempdir_outside_git_repo();
354        let temp2 = tempdir_outside_git_repo();
355
356        let file = temp1.path().join("file.rs");
357        std::fs::write(&file, "").unwrap();
358
359        // Workspace folder is in different temp dir
360        let workspace_folders = vec![temp2.path().to_path_buf()];
361
362        // Should use first workspace folder
363        let result = resolve_git_root_mode(&file, &workspace_folders).unwrap();
364        assert_eq!(result, temp2.path());
365    }
366
367    #[test]
368    fn test_resolve_git_root_mode_single_file_mode() {
369        let temp = tempdir_outside_git_repo();
370        let file = temp.path().join("file.rs");
371        std::fs::write(&file, "").unwrap();
372
373        // No workspace folders - single file mode
374        let result = resolve_git_root_mode(&file, &[]).unwrap();
375        assert_eq!(result, temp.path());
376    }
377
378    #[test]
379    fn test_resolve_workspace_folder_mode() {
380        let temp = TempDir::new().unwrap();
381        let folder1 = temp.path().join("proj1");
382        let folder2 = temp.path().join("proj2");
383        std::fs::create_dir(&folder1).unwrap();
384        std::fs::create_dir(&folder2).unwrap();
385
386        let file = folder1.join("src").join("main.rs");
387        std::fs::create_dir_all(file.parent().unwrap()).unwrap();
388        std::fs::write(&file, "").unwrap();
389
390        let workspace_folders = vec![folder1.clone(), folder2];
391        let result = resolve_workspace_folder_mode(&file, &workspace_folders).unwrap();
392        assert_eq!(result, folder1);
393    }
394
395    #[test]
396    fn test_resolve_workspace_root_mode() {
397        let temp = TempDir::new().unwrap();
398        let folder1 = temp.path().join("proj1");
399        let folder2 = temp.path().join("proj2");
400        std::fs::create_dir(&folder1).unwrap();
401        std::fs::create_dir(&folder2).unwrap();
402
403        let file = folder2.join("file.rs");
404        std::fs::write(&file, "").unwrap();
405
406        // Even though file is in folder2, workspaceRoot uses folder1 (first folder)
407        let workspace_folders = vec![folder1.clone(), folder2];
408        let result = resolve_workspace_root_mode(&file, &workspace_folders).unwrap();
409        assert_eq!(result, folder1);
410    }
411
412    #[test]
413    fn test_resolve_index_root_delegates_correctly() {
414        let temp = TempDir::new().unwrap();
415        let repo_root = setup_git_repo(&temp);
416        let file = repo_root.join("file.rs");
417        std::fs::write(&file, "").unwrap();
418
419        // gitRoot mode
420        let result = resolve_index_root(&file, ProjectRootMode::GitRoot, &[]).unwrap();
421        assert_eq!(result, repo_root);
422
423        // workspaceFolder mode with no folders - falls back to parent
424        let result = resolve_index_root(&file, ProjectRootMode::WorkspaceFolder, &[]).unwrap();
425        assert_eq!(result, repo_root);
426
427        // workspaceRoot mode with no folders - falls back to parent
428        let result = resolve_index_root(&file, ProjectRootMode::WorkspaceRoot, &[]).unwrap();
429        assert_eq!(result, repo_root);
430    }
431
432    #[test]
433    fn test_find_containing_workspace_folder() {
434        let temp = TempDir::new().unwrap();
435        let folder1 = temp.path().join("a");
436        let folder2 = temp.path().join("b");
437        std::fs::create_dir(&folder1).unwrap();
438        std::fs::create_dir(&folder2).unwrap();
439
440        let file_in_a = folder1.join("file.rs");
441        let file_in_b = folder2.join("file.rs");
442        let file_outside = temp.path().join("file.rs");
443
444        let workspace_folders = vec![folder1.clone(), folder2.clone()];
445
446        assert_eq!(
447            find_containing_workspace_folder(&file_in_a, &workspace_folders),
448            Some(folder1)
449        );
450        assert_eq!(
451            find_containing_workspace_folder(&file_in_b, &workspace_folders),
452            Some(folder2)
453        );
454        assert_eq!(
455            find_containing_workspace_folder(&file_outside, &workspace_folders),
456            None
457        );
458    }
459
460    #[test]
461    fn test_workspace_folder_order_preserved() {
462        let temp = TempDir::new().unwrap();
463        let folder_z = temp.path().join("z_folder");
464        let folder_a = temp.path().join("a_folder");
465        std::fs::create_dir(&folder_z).unwrap();
466        std::fs::create_dir(&folder_a).unwrap();
467
468        let file_outside = temp.path().join("file.rs");
469        std::fs::write(&file_outside, "").unwrap();
470
471        // Order is z_folder first, a_folder second (NOT alphabetical)
472        let folders = vec![folder_z.clone(), folder_a];
473
474        // File outside workspace should use FIRST folder (z_folder), not alphabetically first (a_folder)
475        let result = resolve_workspace_folder_mode(&file_outside, &folders).unwrap();
476        assert_eq!(result, folder_z);
477    }
478
479    #[test]
480    fn test_canonicalize_and_resolve() {
481        let temp = TempDir::new().unwrap();
482        let repo_root = setup_git_repo(&temp);
483        let file = repo_root.join("file.rs");
484        std::fs::write(&file, "").unwrap();
485
486        // Use non-canonical path (with . component)
487        let non_canonical = repo_root.join(".").join("file.rs");
488
489        let result = canonicalize_and_resolve(&non_canonical, ProjectRootMode::GitRoot, &[]);
490        assert!(result.is_ok());
491        // Result should be canonicalized repo root
492        let resolved = result.unwrap();
493        // Compare canonical forms
494        assert_eq!(
495            canonicalize_path(&resolved).unwrap(),
496            canonicalize_path(&repo_root).unwrap()
497        );
498    }
499}