Skip to main content

canic_host/workspace_discovery/
mod.rs

1//! Workspace and ICP CLI root discovery helpers for downstream install tooling.
2
3use 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/// Typed failure while locating a Cargo workspace or ICP project root.
13#[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
53// Resolve the nearest Cargo workspace root from a starting file or directory path.
54pub 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
90// Resolve the nearest ICP CLI root from a starting file or directory path.
91pub 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
162// Normalize a workspace-relative path against the chosen workspace root.
163pub 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}