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