1use std::path::{Path, PathBuf};
23
24use thiserror::Error;
25
26#[derive(Debug, Error)]
27pub enum RootError {
28 #[error("launch root must be an absolute path; got `{path}` (set --root or SHELL_MCP_ROOT)")]
29 NotAbsolute { path: String },
30
31 #[error("launch root does not exist: `{path}`")]
32 DoesNotExist { path: String },
33
34 #[error("launch root is not a directory: `{path}`")]
35 NotDirectory { path: String },
36
37 #[error("could not canonicalize launch root `{path}`: {source}")]
38 Canonicalize {
39 path: String,
40 #[source]
41 source: std::io::Error,
42 },
43
44 #[error("could not read launch root `{path}`: {source}")]
45 Stat {
46 path: String,
47 #[source]
48 source: std::io::Error,
49 },
50}
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55pub enum RootSource {
56 Flag,
57 Env,
58 LaunchCwd,
59}
60
61impl RootSource {
62 pub fn as_str(&self) -> &'static str {
63 match self {
64 RootSource::Flag => "--root flag",
65 RootSource::Env => "SHELL_MCP_ROOT env var",
66 RootSource::LaunchCwd => "launch cwd",
67 }
68 }
69}
70
71#[derive(Debug, Clone)]
73pub struct ResolvedRoot {
74 pub path: PathBuf,
75 pub source: RootSource,
76}
77
78pub fn resolve_root(
91 cli: Option<&Path>,
92 env: Option<&str>,
93 fallback_cwd: &Path,
94) -> Result<ResolvedRoot, RootError> {
95 let (raw, source) = if let Some(p) = cli {
96 (p.to_path_buf(), RootSource::Flag)
97 } else if let Some(s) = env.filter(|s| !s.is_empty()) {
98 (PathBuf::from(s), RootSource::Env)
99 } else {
100 (fallback_cwd.to_path_buf(), RootSource::LaunchCwd)
101 };
102
103 if matches!(source, RootSource::Flag | RootSource::Env) && !raw.is_absolute() {
105 return Err(RootError::NotAbsolute {
106 path: raw.display().to_string(),
107 });
108 }
109
110 let metadata = match std::fs::metadata(&raw) {
111 Ok(m) => m,
112 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
113 return Err(RootError::DoesNotExist {
114 path: raw.display().to_string(),
115 });
116 }
117 Err(e) => {
118 return Err(RootError::Stat {
119 path: raw.display().to_string(),
120 source: e,
121 });
122 }
123 };
124 if !metadata.is_dir() {
125 return Err(RootError::NotDirectory {
126 path: raw.display().to_string(),
127 });
128 }
129
130 let path = raw.canonicalize().map_err(|e| RootError::Canonicalize {
131 path: raw.display().to_string(),
132 source: e,
133 })?;
134
135 Ok(ResolvedRoot { path, source })
136}
137
138#[cfg(test)]
139mod tests {
140 use super::*;
141 use std::fs;
142 use tempfile::tempdir;
143
144 #[test]
145 fn flag_overrides_env() {
146 let flag_dir = tempdir().unwrap();
147 let env_dir = tempdir().unwrap();
148 let cwd_dir = tempdir().unwrap();
149 let r = resolve_root(
150 Some(flag_dir.path()),
151 Some(env_dir.path().to_str().unwrap()),
152 cwd_dir.path(),
153 )
154 .unwrap();
155 assert_eq!(r.source, RootSource::Flag);
156 assert_eq!(r.path, flag_dir.path().canonicalize().unwrap());
157 }
158
159 #[test]
160 fn env_overrides_launch_cwd() {
161 let env_dir = tempdir().unwrap();
162 let cwd_dir = tempdir().unwrap();
163 let r = resolve_root(None, Some(env_dir.path().to_str().unwrap()), cwd_dir.path()).unwrap();
164 assert_eq!(r.source, RootSource::Env);
165 assert_eq!(r.path, env_dir.path().canonicalize().unwrap());
166 }
167
168 #[test]
169 fn empty_env_falls_through_to_launch_cwd() {
170 let cwd_dir = tempdir().unwrap();
171 let r = resolve_root(None, Some(""), cwd_dir.path()).unwrap();
172 assert_eq!(r.source, RootSource::LaunchCwd);
173 }
174
175 #[test]
176 fn launch_cwd_used_when_nothing_else_set() {
177 let cwd_dir = tempdir().unwrap();
178 let r = resolve_root(None, None, cwd_dir.path()).unwrap();
179 assert_eq!(r.source, RootSource::LaunchCwd);
180 assert_eq!(r.path, cwd_dir.path().canonicalize().unwrap());
181 }
182
183 #[test]
184 fn nonexistent_path_rejected() {
185 let parent = tempdir().unwrap();
186 let missing = parent.path().join("does-not-exist");
187 let cwd_dir = tempdir().unwrap();
188 let err = resolve_root(Some(&missing), None, cwd_dir.path()).unwrap_err();
189 assert!(matches!(err, RootError::DoesNotExist { .. }), "got {err:?}");
190 }
191
192 #[test]
193 fn file_not_directory_rejected() {
194 let dir = tempdir().unwrap();
195 let file = dir.path().join("a.txt");
196 fs::write(&file, b"hi").unwrap();
197 let cwd_dir = tempdir().unwrap();
198 let err = resolve_root(Some(&file), None, cwd_dir.path()).unwrap_err();
199 assert!(matches!(err, RootError::NotDirectory { .. }), "got {err:?}");
200 }
201
202 #[test]
203 fn relative_flag_path_rejected() {
204 let cwd_dir = tempdir().unwrap();
205 let rel = Path::new("relative/path");
206 let err = resolve_root(Some(rel), None, cwd_dir.path()).unwrap_err();
207 assert!(matches!(err, RootError::NotAbsolute { .. }), "got {err:?}");
208 }
209
210 #[test]
211 fn relative_env_path_rejected() {
212 let cwd_dir = tempdir().unwrap();
213 let err = resolve_root(None, Some("also/relative"), cwd_dir.path()).unwrap_err();
214 assert!(matches!(err, RootError::NotAbsolute { .. }), "got {err:?}");
215 }
216
217 #[cfg(unix)]
218 #[test]
219 fn symlinks_are_resolved() {
220 use std::os::unix::fs::symlink;
221 let real = tempdir().unwrap();
222 let link_parent = tempdir().unwrap();
223 let link = link_parent.path().join("alias");
224 symlink(real.path(), &link).unwrap();
225
226 let cwd_dir = tempdir().unwrap();
227 let r = resolve_root(Some(&link), None, cwd_dir.path()).unwrap();
228 assert_eq!(r.source, RootSource::Flag);
229 assert_eq!(r.path, real.path().canonicalize().unwrap());
231 assert_ne!(r.path, link);
232 }
233}