Skip to main content

whisker_dev_server/
workspace.rs

1//! Workspace path-dep walker.
2//!
3//! Hot-reload coverage (#103): the file watcher and the patcher both
4//! need to know which sub-crates the user app depends on so an edit
5//! to e.g. `examples/podcast/crates/podcast-ui-kit/src/top_nav.rs`
6//! produces a tier-1 patch instead of being silently ignored.
7//!
8//! [`discover_path_deps`] runs `cargo metadata` against the user
9//! crate's manifest and returns every path-dep reachable from it,
10//! including the user crate itself. "Path dep" here means a
11//! workspace member (or `path = "..."` dep) — anything whose
12//! `Package.source` is `None`. Registry deps (crates.io) and git
13//! deps are excluded; their sources are out-of-tree and not worth
14//! watching.
15//!
16//! The returned tuples carry the **rustc-form crate name** (hyphens
17//! replaced with underscores) so callers can look them up in the
18//! captured rustc-args map directly. The src dir is the absolute
19//! path to `<manifest_dir>/src/` (the conventional location; binary
20//! / examples-only crates that don't have `src/` are skipped at
21//! watch time when notify refuses to attach).
22
23use anyhow::{Context, Result};
24use cargo_metadata::MetadataCommand;
25use std::collections::HashSet;
26use std::path::{Path, PathBuf};
27
28/// One path-dep crate as the dev loop sees it.
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct PathDepCrate {
31    /// Rustc-style crate name (hyphens → underscores). This matches
32    /// the key the `whisker-rustc-shim` writes into its capture cache.
33    pub crate_name: String,
34    /// Absolute path to the crate's `src/` directory. Used as a
35    /// watch root and as a prefix for "which crate did this path
36    /// come from?" lookups.
37    pub src_dir: PathBuf,
38}
39
40/// Walk `cargo metadata` for the user crate at `manifest_path` and
41/// return every path-dep reachable from it, including the root
42/// package itself. Topologically ordered (parents before deps).
43///
44/// "Path dep" = `Package.source.is_none()`. That covers workspace
45/// members and explicit `path = "..."` deps. Registry / git deps
46/// are skipped — their sources live outside the workspace and
47/// changes there would imply a `Cargo.lock` bump, which Tier 2
48/// rebuild already covers.
49pub fn discover_path_deps(manifest_path: &Path, app_package: &str) -> Result<Vec<PathDepCrate>> {
50    let metadata = MetadataCommand::new()
51        .manifest_path(manifest_path)
52        .exec()
53        .with_context(|| {
54            format!(
55                "cargo metadata failed for {} (package: {app_package})",
56                manifest_path.display(),
57            )
58        })?;
59
60    let resolve = metadata
61        .resolve
62        .as_ref()
63        .context("cargo metadata returned no resolve graph")?;
64    let root_id = resolve
65        .root
66        .as_ref()
67        .cloned()
68        .or_else(|| {
69            metadata
70                .packages
71                .iter()
72                .find(|p| p.name == app_package)
73                .map(|p| p.id.clone())
74        })
75        .with_context(|| format!("cargo package `{app_package}` not found in the workspace"))?;
76
77    let mut out: Vec<PathDepCrate> = Vec::new();
78    let mut visit: Vec<&cargo_metadata::PackageId> = vec![&root_id];
79    let mut seen: HashSet<&cargo_metadata::PackageId> = HashSet::new();
80
81    while let Some(pkg_id) = visit.pop() {
82        if !seen.insert(pkg_id) {
83            continue;
84        }
85        let Some(pkg) = metadata.packages.iter().find(|p| &p.id == pkg_id) else {
86            continue;
87        };
88        // Registry / git deps have a `source`; we only watch deps
89        // whose source is None (= workspace path-dep).
90        if pkg.source.is_some() {
91            continue;
92        }
93        if let Some(manifest_dir) = pkg.manifest_path.parent() {
94            let src_dir = manifest_dir.join("src");
95            out.push(PathDepCrate {
96                crate_name: pkg.name.replace('-', "_"),
97                src_dir: src_dir.into(),
98            });
99        }
100        if let Some(node) = resolve.nodes.iter().find(|n| &n.id == pkg_id) {
101            for dep in &node.deps {
102                visit.push(&dep.pkg);
103            }
104        }
105    }
106    Ok(out)
107}
108
109/// Given a debounced batch of changed paths, return the rustc-form
110/// crate name they all map to via longest-prefix match against
111/// `crates`. Returns `None` if the batch spans multiple crates or
112/// any path is outside every known src dir — the dev loop falls back
113/// to a Tier 2 cold rebuild in that case (we can patch one crate per
114/// debounced batch, not two).
115pub fn identify_crate_for_paths(paths: &[PathBuf], crates: &[PathDepCrate]) -> Option<String> {
116    let mut found: Option<&str> = None;
117    for p in paths {
118        let hit = best_crate_for(p, crates)?;
119        match found {
120            None => found = Some(hit),
121            Some(prev) if prev != hit => return None,
122            _ => {}
123        }
124    }
125    found.map(str::to_owned)
126}
127
128/// Longest-prefix match: pick the crate whose `src_dir` is the most
129/// specific prefix of `path`. Handles the (rare) case where one
130/// crate's manifest dir nests inside another — the inner crate wins.
131fn best_crate_for<'a>(path: &Path, crates: &'a [PathDepCrate]) -> Option<&'a str> {
132    let mut best: Option<(&str, usize)> = None;
133    for c in crates {
134        if path.starts_with(&c.src_dir) {
135            let depth = c.src_dir.components().count();
136            if best.map(|(_, d)| depth > d).unwrap_or(true) {
137                best = Some((&c.crate_name, depth));
138            }
139        }
140    }
141    best.map(|(n, _)| n)
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147
148    fn cr(name: &str, dir: &str) -> PathDepCrate {
149        PathDepCrate {
150            crate_name: name.into(),
151            src_dir: PathBuf::from(dir),
152        }
153    }
154
155    #[test]
156    fn identify_returns_the_matching_crate() {
157        let crates = vec![
158            cr("podcast", "/ws/examples/podcast/src"),
159            cr(
160                "podcast_ui_kit",
161                "/ws/examples/podcast/crates/podcast-ui-kit/src",
162            ),
163        ];
164        let paths = vec![PathBuf::from(
165            "/ws/examples/podcast/crates/podcast-ui-kit/src/top_nav.rs",
166        )];
167        assert_eq!(
168            identify_crate_for_paths(&paths, &crates),
169            Some("podcast_ui_kit".into())
170        );
171    }
172
173    #[test]
174    fn identify_returns_none_when_paths_span_multiple_crates() {
175        let crates = vec![
176            cr("podcast", "/ws/examples/podcast/src"),
177            cr(
178                "podcast_ui_kit",
179                "/ws/examples/podcast/crates/podcast-ui-kit/src",
180            ),
181        ];
182        let paths = vec![
183            PathBuf::from("/ws/examples/podcast/src/lib.rs"),
184            PathBuf::from("/ws/examples/podcast/crates/podcast-ui-kit/src/top_nav.rs"),
185        ];
186        assert_eq!(identify_crate_for_paths(&paths, &crates), None);
187    }
188
189    #[test]
190    fn identify_returns_none_when_no_crate_matches() {
191        let crates = vec![cr("podcast", "/ws/examples/podcast/src")];
192        let paths = vec![PathBuf::from("/some/unrelated/path/foo.rs")];
193        assert_eq!(identify_crate_for_paths(&paths, &crates), None);
194    }
195
196    #[test]
197    fn identify_picks_the_deeper_match_when_src_dirs_nest() {
198        // Defensive: if a sub-crate's src_dir lives inside another
199        // crate's src_dir (unusual but possible with custom layouts),
200        // the inner crate wins.
201        let crates = vec![
202            cr("outer", "/ws/foo/src"),
203            cr("inner", "/ws/foo/src/inner_pkg/src"),
204        ];
205        let paths = vec![PathBuf::from("/ws/foo/src/inner_pkg/src/lib.rs")];
206        assert_eq!(
207            identify_crate_for_paths(&paths, &crates),
208            Some("inner".into())
209        );
210    }
211
212    #[test]
213    fn identify_handles_single_crate_batch() {
214        let crates = vec![cr("podcast", "/ws/podcast/src")];
215        let paths = vec![
216            PathBuf::from("/ws/podcast/src/lib.rs"),
217            PathBuf::from("/ws/podcast/src/main.rs"),
218        ];
219        assert_eq!(
220            identify_crate_for_paths(&paths, &crates),
221            Some("podcast".into())
222        );
223    }
224}