canic_host/workspace_discovery/
mod.rs1use std::{
4 fs, io,
5 path::{Path, PathBuf},
6};
7use thiserror::Error as ThisError;
8
9const WORKSPACE_MANIFEST_RELATIVE: &str = "Cargo.toml";
10const ICP_CONFIG_FILE: &str = "icp.yaml";
11
12#[derive(Debug, ThisError)]
14pub enum WorkspaceDiscoveryError {
15 #[error("failed to resolve current directory: {0}")]
16 CurrentDirectory(#[source] io::Error),
17
18 #[error("failed to inspect discovery path {}: {source}", path.display())]
19 Inspect {
20 path: PathBuf,
21 #[source]
22 source: io::Error,
23 },
24
25 #[error("discovery path must be a regular file or directory: {}", path.display())]
26 UnsupportedPath { path: PathBuf },
27
28 #[error("expected a regular project file at {}", path.display())]
29 ExpectedFile { path: PathBuf },
30
31 #[error("failed to read Cargo manifest {}: {source}", path.display())]
32 ReadManifest {
33 path: PathBuf,
34 #[source]
35 source: io::Error,
36 },
37
38 #[error("failed to parse Cargo manifest {}: {source}", path.display())]
39 ParseManifest {
40 path: PathBuf,
41 #[source]
42 source: Box<toml::de::Error>,
43 },
44
45 #[error("failed to canonicalize project root {}: {source}", path.display())]
46 Canonicalize {
47 path: PathBuf,
48 #[source]
49 source: io::Error,
50 },
51}
52
53pub fn discover_workspace_root_from(
55 path: &Path,
56) -> Result<Option<PathBuf>, WorkspaceDiscoveryError> {
57 let start = discovery_start(path)?;
58
59 for candidate in start.ancestors() {
60 let manifest_path = candidate.join(WORKSPACE_MANIFEST_RELATIVE);
61 if !project_file_exists(&manifest_path)? {
62 continue;
63 }
64
65 let manifest = fs::read_to_string(&manifest_path).map_err(|source| {
66 WorkspaceDiscoveryError::ReadManifest {
67 path: manifest_path.clone(),
68 source,
69 }
70 })?;
71 if manifest_declares_workspace(&manifest).map_err(|source| {
72 WorkspaceDiscoveryError::ParseManifest {
73 path: manifest_path,
74 source: Box::new(source),
75 }
76 })? {
77 return Ok(Some(candidate.to_path_buf()));
78 }
79 }
80
81 Ok(None)
82}
83
84fn manifest_declares_workspace(source: &str) -> Result<bool, toml::de::Error> {
85 Ok(toml::from_str::<toml::Value>(source)?
86 .get("workspace")
87 .is_some())
88}
89
90pub fn discover_icp_root_from(path: &Path) -> Result<Option<PathBuf>, WorkspaceDiscoveryError> {
92 let start = discovery_start(path)?;
93
94 for candidate in start.ancestors() {
95 let icp_config = candidate.join(ICP_CONFIG_FILE);
96 if project_file_exists(&icp_config)? {
97 return Ok(Some(candidate.to_path_buf()));
98 }
99 }
100
101 Ok(None)
102}
103
104fn discovery_start(path: &Path) -> Result<PathBuf, WorkspaceDiscoveryError> {
105 let metadata = fs::metadata(path).map_err(|source| WorkspaceDiscoveryError::Inspect {
106 path: path.to_path_buf(),
107 source,
108 })?;
109 let canonical =
110 path.canonicalize()
111 .map_err(|source| WorkspaceDiscoveryError::Canonicalize {
112 path: path.to_path_buf(),
113 source,
114 })?;
115 if metadata.is_dir() {
116 return Ok(canonical);
117 }
118 if metadata.is_file() {
119 return canonical.parent().map(Path::to_path_buf).ok_or_else(|| {
120 WorkspaceDiscoveryError::UnsupportedPath {
121 path: path.to_path_buf(),
122 }
123 });
124 }
125 Err(WorkspaceDiscoveryError::UnsupportedPath {
126 path: path.to_path_buf(),
127 })
128}
129
130fn project_file_exists(path: &Path) -> Result<bool, WorkspaceDiscoveryError> {
131 let metadata = match fs::symlink_metadata(path) {
132 Ok(metadata) => metadata,
133 Err(source) if source.kind() == io::ErrorKind::NotFound => return Ok(false),
134 Err(source) => {
135 return Err(WorkspaceDiscoveryError::Inspect {
136 path: path.to_path_buf(),
137 source,
138 });
139 }
140 };
141 if metadata.file_type().is_symlink() {
142 return match fs::metadata(path) {
143 Ok(metadata) if metadata.is_file() => Ok(true),
144 Ok(_) => Err(WorkspaceDiscoveryError::ExpectedFile {
145 path: path.to_path_buf(),
146 }),
147 Err(source) => Err(WorkspaceDiscoveryError::Inspect {
148 path: path.to_path_buf(),
149 source,
150 }),
151 };
152 }
153 if metadata.is_file() {
154 Ok(true)
155 } else {
156 Err(WorkspaceDiscoveryError::ExpectedFile {
157 path: path.to_path_buf(),
158 })
159 }
160}
161
162pub fn normalize_workspace_path(workspace_root: &Path, path: PathBuf) -> PathBuf {
164 if path.is_absolute() {
165 path
166 } else {
167 workspace_root.join(path)
168 }
169}
170
171#[cfg(test)]
172mod tests {
173 use super::*;
174 use crate::test_support::temp_dir;
175
176 struct TempProject {
177 path: PathBuf,
178 }
179
180 impl TempProject {
181 fn new(name: &str) -> Self {
182 let path = temp_dir(name);
183 fs::create_dir_all(&path).expect("create temp project");
184 Self { path }
185 }
186 }
187
188 impl Drop for TempProject {
189 fn drop(&mut self) {
190 let _ = fs::remove_dir_all(&self.path);
191 }
192 }
193
194 #[test]
195 fn workspace_detection_parses_toml_structure() {
196 assert!(manifest_declares_workspace("[workspace]\nmembers = []\n").expect("manifest"));
197 assert!(manifest_declares_workspace("[ workspace ]\nmembers = []\n").expect("manifest"));
198 assert!(
199 !manifest_declares_workspace("description = \"example containing [workspace] text\"\n")
200 .expect("manifest")
201 );
202 assert!(manifest_declares_workspace("[workspace\n").is_err());
203 }
204
205 #[test]
206 fn root_discovery_accepts_a_file_start_and_returns_canonical_roots() {
207 let project = TempProject::new("canic-workspace-root-discovery");
208 let nested = project.path.join("backend/src/lib.rs");
209 fs::create_dir_all(nested.parent().expect("nested parent")).expect("create nested dir");
210 fs::write(&nested, "").expect("write nested file");
211 fs::write(
212 project.path.join(WORKSPACE_MANIFEST_RELATIVE),
213 "[workspace]\nmembers = []\n",
214 )
215 .expect("write workspace manifest");
216 fs::write(project.path.join(ICP_CONFIG_FILE), "").expect("write ICP config");
217 let expected = project.path.canonicalize().expect("canonical project");
218
219 assert_eq!(
220 discover_workspace_root_from(&nested).expect("discover workspace"),
221 Some(expected.clone())
222 );
223 assert_eq!(
224 discover_icp_root_from(&nested).expect("discover ICP root"),
225 Some(expected)
226 );
227 }
228
229 #[test]
230 fn workspace_discovery_preserves_manifest_parse_cause() {
231 let project = TempProject::new("canic-workspace-invalid-manifest");
232 fs::write(
233 project.path.join(WORKSPACE_MANIFEST_RELATIVE),
234 "[workspace\n",
235 )
236 .expect("write invalid manifest");
237
238 let error =
239 discover_workspace_root_from(&project.path).expect_err("invalid manifest must fail");
240 let WorkspaceDiscoveryError::ParseManifest { path, .. } = error else {
241 panic!("expected typed manifest parse error");
242 };
243
244 assert_eq!(path, project.path.join(WORKSPACE_MANIFEST_RELATIVE));
245 }
246
247 #[test]
248 fn icp_discovery_rejects_non_file_project_config() {
249 let project = TempProject::new("canic-workspace-invalid-icp-config");
250 let config = project.path.join(ICP_CONFIG_FILE);
251 fs::create_dir_all(&config).expect("create invalid ICP config directory");
252
253 let error = discover_icp_root_from(&project.path)
254 .expect_err("non-file ICP config must fail discovery");
255
256 std::assert_matches!(
257 error,
258 WorkspaceDiscoveryError::ExpectedFile { path } if path == config
259 );
260 }
261
262 #[cfg(unix)]
263 #[test]
264 fn icp_discovery_accepts_file_symlinks_and_rejects_dangling_markers() {
265 use std::os::unix::fs::symlink;
266
267 let project = TempProject::new("canic-workspace-symlinked-icp-config");
268 let target = project.path.join("project-icp.yaml");
269 let config = project.path.join(ICP_CONFIG_FILE);
270 fs::write(&target, "").expect("write ICP config target");
271 symlink(&target, &config).expect("link ICP config");
272
273 assert_eq!(
274 discover_icp_root_from(&project.path).expect("discover symlinked ICP config"),
275 Some(project.path.canonicalize().expect("canonical project"))
276 );
277
278 fs::remove_file(&target).expect("remove ICP config target");
279 let error = discover_icp_root_from(&project.path)
280 .expect_err("dangling ICP config marker must fail discovery");
281 std::assert_matches!(
282 error,
283 WorkspaceDiscoveryError::Inspect { path, source }
284 if path == config && source.kind() == io::ErrorKind::NotFound
285 );
286 }
287}