1 //! The directories the current session treats as project roots.
2 //!
3 //! An editor can open more than one directory in a single project — Zed calls
4 //! it a multi-root project — and ACP carries the extra ones as
5 //! `additional_directories` on every session request. The process still has a
6 //! single working directory (the primary `cwd` it `set_current_dir`s to), so
7 //! the extra roots live here, in a process-global that project-local discovery
8 //! reads alongside `std::env::current_dir()`: skills (`skills.rs`), slash
9 //! commands (`commands.rs`), subagent types (`subagents.rs`) and instruction
10 //! files (`instructions.rs`).
11 //!
12 //! MCP servers are deliberately not on that list. Discovery runs once at
13 //! startup (`mcp::init`), before any session exists, so a second root's
14 //! `.sigit/mcp.toml` has nobody to tell.
15
16 use std::path::{Component, Path, PathBuf};
17 use std::sync::RwLock;
18
19 /// The session's roots beyond `cwd`. Empty for an ordinary single-root project.
20 static ADDITIONAL_ROOTS: RwLock<Vec<PathBuf>> = RwLock::new(Vec::new());
21
22 /// Record the session's extra roots, returning the ones that were kept.
23 ///
24 /// Entries that aren't directories, repeat, or just name `cwd` again are
25 /// dropped — an editor is free to send any of those, and every consumer here
26 /// would either scan nothing or do the same work twice.
27 pub fn set_additional_roots(cwd: &Path, roots: &[PathBuf]) -> Vec<PathBuf> {
28 let mut seen: Vec<PathBuf> = vec![canonical_key(cwd)];
29 let mut kept: Vec<PathBuf> = Vec::new();
30
31 for root in roots {
32 if !root.is_dir() {
33 log::warn!(
34 "ignoring workspace root {} (not a directory)",
35 root.display()
36 );
37 continue;
38 }
39 let key = canonical_key(root);
40 if seen.contains(&key) {
41 continue;
42 }
43 seen.push(key);
44 kept.push(root.clone());
45 }
46
47 if let Ok(mut guard) = ADDITIONAL_ROOTS.write() {
48 *guard = kept.clone();
49 }
50 kept
51 }
52
53 /// The extra roots recorded for the session.
54 pub fn additional_roots() -> Vec<PathBuf> {
55 ADDITIONAL_ROOTS
56 .read()
57 .map(|guard| guard.clone())
58 .unwrap_or_default()
59 }
60
61 /// Every directory the session treats as a project root: the process working
62 /// directory first, then the extra roots. Project-local discovery scans these
63 /// in order, so the primary root keeps winning name collisions.
64 pub fn project_dirs() -> Vec<PathBuf> {
65 let mut dirs = Vec::new();
66 if let Ok(cwd) = std::env::current_dir() {
67 dirs.push(cwd);
68 }
69 dirs.extend(additional_roots());
70 dirs
71 }
72
73 /// A comparison key for a path: its canonical form when the filesystem can
74 /// give one, and otherwise the path made absolute and lexically normalized.
75 ///
76 /// The fallback matters. `canonicalize` fails on a path the process cannot
77 /// resolve (an unreadable parent, a root that vanished between the client
78 /// sending it and us reading it), and returning the path untouched there would
79 /// let `.` and `..` segments or a relative spelling of a root that is already
80 /// in the list read as a new one.
81 pub fn canonical_key(path: &Path) -> PathBuf {
82 path.canonicalize().unwrap_or_else(|_| normalize(path))
83 }
84
85 /// Absolute + lexically normalized: `.` dropped, `..` folded into the previous
86 /// segment. Purely textual, so it never touches the filesystem.
87 fn normalize(path: &Path) -> PathBuf {
88 let absolute = if path.is_absolute() {
89 path.to_path_buf()
90 } else {
91 std::env::current_dir()
92 .map(|cwd| cwd.join(path))
93 .unwrap_or_else(|_| path.to_path_buf())
94 };
95
96 let mut out = PathBuf::new();
97 for component in absolute.components() {
98 match component {
99 Component::CurDir => {}
100 Component::ParentDir => {
101 if !out.pop() {
102 out.push(component);
103 }
104 }
105 other => out.push(other),
106 }
107 }
108 out
109 }
110
111 #[cfg(test)]
112 mod tests {
113 use super::*;
114
115 /// One process-global under test, so the cases share a lock and a cleanup.
116 fn with_roots<T>(cwd: &Path, roots: &[PathBuf], test: impl FnOnce(Vec<PathBuf>) -> T) -> T {
117 static GUARD: std::sync::Mutex<()> = std::sync::Mutex::new(());
118 let _lock = GUARD.lock().unwrap_or_else(|poison| poison.into_inner());
119 let kept = set_additional_roots(cwd, roots);
120 let out = test(kept);
121 set_additional_roots(cwd, &[]);
122 out
123 }
124
125 #[test]
126 fn keeps_real_directories_and_drops_the_rest() {
127 let temp = std::env::temp_dir().join(format!("sigit-ws-{}", uuid::Uuid::new_v4()));
128 let cwd = temp.join("primary");
129 let extra = temp.join("extra");
130 std::fs::create_dir_all(&cwd).unwrap();
131 std::fs::create_dir_all(&extra).unwrap();
132 let missing = temp.join("gone");
133
134 with_roots(
135 &cwd,
136 &[extra.clone(), extra.clone(), cwd.clone(), missing],
137 |kept| {
138 assert_eq!(kept, vec![extra.clone()]);
139 assert_eq!(additional_roots(), vec![extra.clone()]);
140 },
141 );
142
143 assert!(additional_roots().is_empty());
144 std::fs::remove_dir_all(&temp).ok();
145 }
146
147 #[test]
148 fn keys_a_path_the_filesystem_cannot_resolve() {
149 // Nothing here exists, so `canonicalize` fails on every one of them and
150 // the lexical fallback is what has to make the three agree.
151 let base = std::env::temp_dir().join("sigit-ws-missing");
152 let direct = canonical_key(&base.join("root"));
153 let dotted = canonical_key(&base.join(".").join("root"));
154 let backtracked = canonical_key(&base.join("other").join("..").join("root"));
155
156 assert_eq!(direct, dotted);
157 assert_eq!(direct, backtracked);
158 assert!(direct.is_absolute());
159 }
160 }