Skip to main content

canic_host/workspace_discovery/
mod.rs

1//! Workspace and ICP CLI root discovery helpers for downstream install tooling.
2
3use 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/// Typed failure while locating a Cargo workspace or ICP project root.
16#[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/// Typed failure while resolving one role's Cargo package manifest.
57#[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
104// Resolve the nearest Cargo workspace root from a starting file or directory path.
105pub 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
141// Resolve the nearest ICP CLI root from a starting file or directory path.
142pub 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
213// Normalize a workspace-relative path against the chosen workspace root.
214pub 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
222// Resolve exactly one canister manifest for a role, restricted to packages below
223// the selected canister root.
224pub 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
274// Check whether a package declares the requested Canic role in Cargo metadata.
275fn 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}