canic_host/workspace_discovery/
mod.rs1use serde_json::Value as JsonValue;
4use std::{
5 fs, io,
6 path::{Path, PathBuf},
7};
8use thiserror::Error as ThisError;
9
10use crate::cargo_metadata::{CargoMetadataPackage, cargo_metadata_no_deps_cached};
11
12const WORKSPACE_MANIFEST_RELATIVE: &str = "Cargo.toml";
13const ICP_CONFIG_FILE: &str = "icp.yaml";
14
15#[derive(Debug, ThisError)]
17pub enum WorkspaceDiscoveryError {
18 #[error("failed to resolve current directory: {0}")]
19 CurrentDirectory(#[source] io::Error),
20
21 #[error("failed to inspect discovery path {}: {source}", path.display())]
22 Inspect {
23 path: PathBuf,
24 #[source]
25 source: io::Error,
26 },
27
28 #[error("discovery path must be a regular file or directory: {}", path.display())]
29 UnsupportedPath { path: PathBuf },
30
31 #[error("expected a regular project file at {}", path.display())]
32 ExpectedFile { path: PathBuf },
33
34 #[error("failed to read Cargo manifest {}: {source}", path.display())]
35 ReadManifest {
36 path: PathBuf,
37 #[source]
38 source: io::Error,
39 },
40
41 #[error("failed to parse Cargo manifest {}: {source}", path.display())]
42 ParseManifest {
43 path: PathBuf,
44 #[source]
45 source: Box<toml::de::Error>,
46 },
47
48 #[error("failed to canonicalize project root {}: {source}", path.display())]
49 Canonicalize {
50 path: PathBuf,
51 #[source]
52 source: io::Error,
53 },
54}
55
56#[derive(Debug, ThisError)]
58pub enum CanisterManifestError {
59 #[error("failed to canonicalize Cargo workspace {}: {source}", path.display())]
60 WorkspaceRoot {
61 path: PathBuf,
62 #[source]
63 source: io::Error,
64 },
65
66 #[error("failed to canonicalize selected canister root {}: {source}", path.display())]
67 CanisterRoot {
68 path: PathBuf,
69 #[source]
70 source: io::Error,
71 },
72
73 #[error(
74 "cargo metadata failed while resolving role {role:?} from {}: {source}",
75 workspace_root.display()
76 )]
77 CargoMetadata {
78 role: String,
79 workspace_root: PathBuf,
80 #[source]
81 source: Box<dyn std::error::Error>,
82 },
83
84 #[error(
85 "no canister package under {} declares [package.metadata.canic] role = {role:?}",
86 canister_root.display()
87 )]
88 RoleNotFound {
89 role: String,
90 canister_root: PathBuf,
91 },
92
93 #[error(
94 "multiple canister packages under {} declare [package.metadata.canic] role = {role:?}: {manifests:?}",
95 canister_root.display()
96 )]
97 RoleAmbiguous {
98 role: String,
99 canister_root: PathBuf,
100 manifests: Vec<PathBuf>,
101 },
102}
103
104pub fn discover_workspace_root_from(
106 path: &Path,
107) -> Result<Option<PathBuf>, WorkspaceDiscoveryError> {
108 let start = discovery_start(path)?;
109
110 for candidate in start.ancestors() {
111 let manifest_path = candidate.join(WORKSPACE_MANIFEST_RELATIVE);
112 if !project_file_exists(&manifest_path)? {
113 continue;
114 }
115
116 let manifest = fs::read_to_string(&manifest_path).map_err(|source| {
117 WorkspaceDiscoveryError::ReadManifest {
118 path: manifest_path.clone(),
119 source,
120 }
121 })?;
122 if manifest_declares_workspace(&manifest).map_err(|source| {
123 WorkspaceDiscoveryError::ParseManifest {
124 path: manifest_path,
125 source: Box::new(source),
126 }
127 })? {
128 return Ok(Some(candidate.to_path_buf()));
129 }
130 }
131
132 Ok(None)
133}
134
135fn manifest_declares_workspace(source: &str) -> Result<bool, toml::de::Error> {
136 Ok(toml::from_str::<toml::Value>(source)?
137 .get("workspace")
138 .is_some())
139}
140
141pub fn discover_icp_root_from(path: &Path) -> Result<Option<PathBuf>, WorkspaceDiscoveryError> {
143 let start = discovery_start(path)?;
144
145 for candidate in start.ancestors() {
146 let icp_config = candidate.join(ICP_CONFIG_FILE);
147 if project_file_exists(&icp_config)? {
148 return Ok(Some(candidate.to_path_buf()));
149 }
150 }
151
152 Ok(None)
153}
154
155fn discovery_start(path: &Path) -> Result<PathBuf, WorkspaceDiscoveryError> {
156 let metadata = fs::metadata(path).map_err(|source| WorkspaceDiscoveryError::Inspect {
157 path: path.to_path_buf(),
158 source,
159 })?;
160 let canonical =
161 path.canonicalize()
162 .map_err(|source| WorkspaceDiscoveryError::Canonicalize {
163 path: path.to_path_buf(),
164 source,
165 })?;
166 if metadata.is_dir() {
167 return Ok(canonical);
168 }
169 if metadata.is_file() {
170 return canonical.parent().map(Path::to_path_buf).ok_or_else(|| {
171 WorkspaceDiscoveryError::UnsupportedPath {
172 path: path.to_path_buf(),
173 }
174 });
175 }
176 Err(WorkspaceDiscoveryError::UnsupportedPath {
177 path: path.to_path_buf(),
178 })
179}
180
181fn project_file_exists(path: &Path) -> Result<bool, WorkspaceDiscoveryError> {
182 let metadata = match fs::symlink_metadata(path) {
183 Ok(metadata) => metadata,
184 Err(source) if source.kind() == io::ErrorKind::NotFound => return Ok(false),
185 Err(source) => {
186 return Err(WorkspaceDiscoveryError::Inspect {
187 path: path.to_path_buf(),
188 source,
189 });
190 }
191 };
192 if metadata.file_type().is_symlink() {
193 return match fs::metadata(path) {
194 Ok(metadata) if metadata.is_file() => Ok(true),
195 Ok(_) => Err(WorkspaceDiscoveryError::ExpectedFile {
196 path: path.to_path_buf(),
197 }),
198 Err(source) => Err(WorkspaceDiscoveryError::Inspect {
199 path: path.to_path_buf(),
200 source,
201 }),
202 };
203 }
204 if metadata.is_file() {
205 Ok(true)
206 } else {
207 Err(WorkspaceDiscoveryError::ExpectedFile {
208 path: path.to_path_buf(),
209 })
210 }
211}
212
213pub fn normalize_workspace_path(workspace_root: &Path, path: PathBuf) -> PathBuf {
215 if path.is_absolute() {
216 path
217 } else {
218 workspace_root.join(path)
219 }
220}
221
222pub fn resolve_canister_manifest_from_metadata_under(
225 workspace_root: &Path,
226 role_name: &str,
227 search_root: &Path,
228) -> Result<PathBuf, CanisterManifestError> {
229 let workspace_root =
230 workspace_root
231 .canonicalize()
232 .map_err(|source| CanisterManifestError::WorkspaceRoot {
233 path: workspace_root.to_path_buf(),
234 source,
235 })?;
236 let search_root =
237 search_root
238 .canonicalize()
239 .map_err(|source| CanisterManifestError::CanisterRoot {
240 path: search_root.to_path_buf(),
241 source,
242 })?;
243 let metadata = cargo_metadata_no_deps_cached(&workspace_root).map_err(|source| {
244 CanisterManifestError::CargoMetadata {
245 role: role_name.to_string(),
246 workspace_root: workspace_root.clone(),
247 source,
248 }
249 })?;
250
251 let mut matches = metadata
252 .packages
253 .into_iter()
254 .filter(|package| package.manifest_path.starts_with(&search_root))
255 .filter(|package| package_declares_role(package, role_name))
256 .map(|package| package.manifest_path)
257 .collect::<Vec<_>>();
258 matches.sort();
259
260 match matches.as_slice() {
261 [manifest_path] => Ok(manifest_path.clone()),
262 [] => Err(CanisterManifestError::RoleNotFound {
263 role: role_name.to_string(),
264 canister_root: search_root,
265 }),
266 paths => Err(CanisterManifestError::RoleAmbiguous {
267 role: role_name.to_string(),
268 canister_root: search_root,
269 manifests: paths.to_vec(),
270 }),
271 }
272}
273
274fn package_declares_role(package: &CargoMetadataPackage, role_name: &str) -> bool {
276 package
277 .metadata
278 .as_ref()
279 .and_then(|metadata| metadata.get("canic"))
280 .and_then(|canic| canic.get("role"))
281 .and_then(JsonValue::as_str)
282 == Some(role_name)
283}
284
285#[cfg(test)]
286mod tests {
287 use super::*;
288 use crate::test_support::temp_dir;
289
290 struct TempProject {
291 path: PathBuf,
292 }
293
294 impl TempProject {
295 fn new(name: &str) -> Self {
296 let path = temp_dir(name);
297 fs::create_dir_all(&path).expect("create temp project");
298 Self { path }
299 }
300 }
301
302 impl Drop for TempProject {
303 fn drop(&mut self) {
304 let _ = fs::remove_dir_all(&self.path);
305 }
306 }
307
308 #[test]
309 fn workspace_detection_parses_toml_structure() {
310 assert!(manifest_declares_workspace("[workspace]\nmembers = []\n").expect("manifest"));
311 assert!(manifest_declares_workspace("[ workspace ]\nmembers = []\n").expect("manifest"));
312 assert!(
313 !manifest_declares_workspace("description = \"example containing [workspace] text\"\n")
314 .expect("manifest")
315 );
316 assert!(manifest_declares_workspace("[workspace\n").is_err());
317 }
318
319 #[test]
320 fn root_discovery_accepts_a_file_start_and_returns_canonical_roots() {
321 let project = TempProject::new("canic-workspace-root-discovery");
322 let nested = project.path.join("backend/src/lib.rs");
323 fs::create_dir_all(nested.parent().expect("nested parent")).expect("create nested dir");
324 fs::write(&nested, "").expect("write nested file");
325 fs::write(
326 project.path.join(WORKSPACE_MANIFEST_RELATIVE),
327 "[workspace]\nmembers = []\n",
328 )
329 .expect("write workspace manifest");
330 fs::write(project.path.join(ICP_CONFIG_FILE), "").expect("write ICP config");
331 let expected = project.path.canonicalize().expect("canonical project");
332
333 assert_eq!(
334 discover_workspace_root_from(&nested).expect("discover workspace"),
335 Some(expected.clone())
336 );
337 assert_eq!(
338 discover_icp_root_from(&nested).expect("discover ICP root"),
339 Some(expected)
340 );
341 }
342
343 #[test]
344 fn workspace_discovery_preserves_manifest_parse_cause() {
345 let project = TempProject::new("canic-workspace-invalid-manifest");
346 fs::write(
347 project.path.join(WORKSPACE_MANIFEST_RELATIVE),
348 "[workspace\n",
349 )
350 .expect("write invalid manifest");
351
352 let error =
353 discover_workspace_root_from(&project.path).expect_err("invalid manifest must fail");
354 let WorkspaceDiscoveryError::ParseManifest { path, .. } = error else {
355 panic!("expected typed manifest parse error");
356 };
357
358 assert_eq!(path, project.path.join(WORKSPACE_MANIFEST_RELATIVE));
359 }
360
361 #[test]
362 fn icp_discovery_rejects_non_file_project_config() {
363 let project = TempProject::new("canic-workspace-invalid-icp-config");
364 let config = project.path.join(ICP_CONFIG_FILE);
365 fs::create_dir_all(&config).expect("create invalid ICP config directory");
366
367 let error = discover_icp_root_from(&project.path)
368 .expect_err("non-file ICP config must fail discovery");
369
370 std::assert_matches!(
371 error,
372 WorkspaceDiscoveryError::ExpectedFile { path } if path == config
373 );
374 }
375
376 #[cfg(unix)]
377 #[test]
378 fn icp_discovery_accepts_file_symlinks_and_rejects_dangling_markers() {
379 use std::os::unix::fs::symlink;
380
381 let project = TempProject::new("canic-workspace-symlinked-icp-config");
382 let target = project.path.join("project-icp.yaml");
383 let config = project.path.join(ICP_CONFIG_FILE);
384 fs::write(&target, "").expect("write ICP config target");
385 symlink(&target, &config).expect("link ICP config");
386
387 assert_eq!(
388 discover_icp_root_from(&project.path).expect("discover symlinked ICP config"),
389 Some(project.path.canonicalize().expect("canonical project"))
390 );
391
392 fs::remove_file(&target).expect("remove ICP config target");
393 let error = discover_icp_root_from(&project.path)
394 .expect_err("dangling ICP config marker must fail discovery");
395 std::assert_matches!(
396 error,
397 WorkspaceDiscoveryError::Inspect { path, source }
398 if path == config && source.kind() == io::ErrorKind::NotFound
399 );
400 }
401
402 #[test]
403 fn manifest_resolution_rejects_uncanonicalizable_canister_root() {
404 let project = TempProject::new("canic-workspace-missing-canister-root");
405 let canister_root = project.path.join("fleets");
406
407 let error =
408 resolve_canister_manifest_from_metadata_under(&project.path, "root", &canister_root)
409 .expect_err("missing canister root must fail");
410
411 std::assert_matches!(
412 error,
413 CanisterManifestError::CanisterRoot { path, .. } if path == canister_root
414 );
415 }
416}