Skip to main content

bynk_project/
workspace.rs

1//! Packaging slice 1 (#2207, decision C): workspaces.
2//!
3//! A workspace root is a directory whose `bynk.toml` holds `[workspace]` (and
4//! may hold `[registries]`) and nothing else: it is never a project itself, the
5//! Cargo "virtual manifest". Its members are the directories `members` matches,
6//! minus those `exclude` matches, each holding its own `bynk.toml`.
7//!
8//! A pattern is a relative path whose components may use `*` (any run of
9//! characters) and `?` (one character); each `*` matches within one directory
10//! level. A hidden directory is matched only by a component that itself starts
11//! with `.`.
12//!
13//! A project finds the workspace it belongs to by walking up for a `[workspace]`
14//! whose members include it ([`find_parent_workspace`]). A parent `bynk.toml`
15//! that doesn't parse, has an unknown table, or doesn't list the project counts
16//! as absent, so one broken unrelated manifest can't break every project nested
17//! under it.
18
19use std::collections::{BTreeSet, HashMap};
20use std::fs;
21use std::path::{Component, Path, PathBuf};
22
23use crate::manifest::{WorkspaceConfig, workspace_config_from};
24use crate::paths::{ProjectPathsError, check_manifest_str};
25
26/// A loaded workspace root and its members.
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct Workspace {
29    /// The root directory, as the caller named it.
30    pub root: PathBuf,
31    pub config: WorkspaceConfig,
32    /// Each member's directory relative to `root`, sorted, so every command
33    /// runs over them in the same order.
34    pub members: Vec<PathBuf>,
35}
36
37/// Whether `dir`'s `bynk.toml` parses and holds a `[workspace]` table. A
38/// manifest that doesn't parse isn't a workspace root: the command that reads
39/// it next reports it as malformed.
40pub fn is_workspace_root(dir: &Path) -> bool {
41    fs::read_to_string(dir.join("bynk.toml"))
42        .ok()
43        .and_then(|text| text.parse::<toml::Table>().ok())
44        .is_some_and(|doc| doc.contains_key("workspace"))
45}
46
47/// Load the workspace rooted at `root`: check its manifest, refuse sources at
48/// the root, and resolve its members. A workspace whose patterns match no
49/// member is an error, as an unknown table is (#1665).
50pub fn load_workspace(root: &Path) -> Result<Workspace, ProjectPathsError> {
51    let manifest = root.join("bynk.toml");
52    let text = fs::read_to_string(&manifest).map_err(|_| ProjectPathsError::Malformed)?;
53    // Path-aware, so a member of an enclosing workspace that declares its own
54    // `[workspace]` is refused as that, before anything else.
55    crate::paths::check_manifest(root, &HashMap::from([(manifest, text.clone())]))?;
56    let doc = text
57        .parse::<toml::Table>()
58        .map_err(|_| ProjectPathsError::Malformed)?;
59    let Some(table) = doc.get("workspace").and_then(|v| v.as_table()) else {
60        return Err(ProjectPathsError::BadWorkspace {
61            reason: "is missing".to_string(),
62        });
63    };
64    let config = workspace_config_from(table)?;
65    if root.join("src").is_dir() || has_bynk_file(root) {
66        return Err(ProjectPathsError::WorkspaceRootSources);
67    }
68    let members = expand_members(root, &config)?;
69    if members.is_empty() {
70        return Err(ProjectPathsError::BadWorkspace {
71            reason: format!(
72                "matches no member: no directory under the root matches {}",
73                quoted_list(&config.members)
74            ),
75        });
76    }
77    Ok(Workspace {
78        root: root.to_path_buf(),
79        config,
80        members,
81    })
82}
83
84/// The workspace root above `project_root` whose members include it, if any.
85pub fn find_parent_workspace(project_root: &Path) -> Option<PathBuf> {
86    let me = project_root.canonicalize().ok()?;
87    let mut dir = me.parent();
88    while let Some(candidate) = dir {
89        if lists(candidate, &me) {
90            return Some(candidate.to_path_buf());
91        }
92        dir = candidate.parent();
93    }
94    None
95}
96
97/// Whether `root`'s manifest is a well-formed workspace that lists `member`
98/// (canonical). Anything short of that is "not listed".
99fn lists(root: &Path, member: &Path) -> bool {
100    let Ok(text) = fs::read_to_string(root.join("bynk.toml")) else {
101        return false;
102    };
103    if check_manifest_str(&text).is_err() {
104        return false;
105    }
106    let Some(config) = text
107        .parse::<toml::Table>()
108        .ok()
109        .and_then(|doc| doc.get("workspace")?.as_table().cloned())
110        .and_then(|t| workspace_config_from(&t).ok())
111    else {
112        return false;
113    };
114    let Ok(members) = expand_members(root, &config) else {
115        return false;
116    };
117    members
118        .iter()
119        .any(|m| root.join(m).canonicalize().is_ok_and(|c| c == member))
120}
121
122/// The members `config` resolves to under `root`: every directory a `members`
123/// pattern matches that no `exclude` pattern matches, relative and sorted. A
124/// literal entry that doesn't exist, or a member without a `bynk.toml`, is an
125/// error; a wildcard entry that matches nothing contributes nothing.
126fn expand_members(
127    root: &Path,
128    config: &WorkspaceConfig,
129) -> Result<Vec<PathBuf>, ProjectPathsError> {
130    let bad = |reason: String| ProjectPathsError::BadWorkspace { reason };
131    let mut found: BTreeSet<PathBuf> = BTreeSet::new();
132    for pattern in &config.members {
133        let parts = components(pattern);
134        let matches = matching_dirs(root, &parts);
135        if matches.is_empty() && !parts.iter().any(|p| is_wildcard(p)) {
136            return Err(bad(format!(
137                "`members` lists `{pattern}`, which isn't a directory under the workspace root"
138            )));
139        }
140        found.extend(matches);
141    }
142    let mut members = Vec::new();
143    for rel in found {
144        let excluded = config
145            .exclude
146            .iter()
147            .any(|ex| excludes(&components(ex), &rel));
148        if excluded {
149            continue;
150        }
151        let shown = rel.to_string_lossy().replace('\\', "/");
152        if rel.as_os_str().is_empty() {
153            return Err(bad(
154                "`members` matches the workspace root itself, which is never a member".to_string(),
155            ));
156        }
157        if !root.join(&rel).join("bynk.toml").is_file() {
158            return Err(bad(format!(
159                "member `{shown}` has no `bynk.toml` — add one, or `exclude` it"
160            )));
161        }
162        members.push(rel);
163    }
164    Ok(members)
165}
166
167/// A pattern's normal components (`./` and empty segments dropped).
168fn components(pattern: &str) -> Vec<String> {
169    Path::new(pattern)
170        .components()
171        .filter_map(|c| match c {
172            Component::Normal(s) => Some(s.to_string_lossy().into_owned()),
173            _ => None,
174        })
175        .collect()
176}
177
178fn is_wildcard(part: &str) -> bool {
179    part.contains(['*', '?'])
180}
181
182/// The directories under `root` that `parts` match, relative to `root`.
183fn matching_dirs(root: &Path, parts: &[String]) -> Vec<PathBuf> {
184    let mut current = vec![PathBuf::new()];
185    for part in parts {
186        let mut next = Vec::new();
187        for base in &current {
188            if is_wildcard(part) {
189                let Ok(entries) = fs::read_dir(root.join(base)) else {
190                    continue;
191                };
192                let mut names: Vec<String> = entries
193                    .filter_map(Result::ok)
194                    .filter(|e| e.file_type().is_ok_and(|t| t.is_dir()))
195                    .map(|e| e.file_name().to_string_lossy().into_owned())
196                    .filter(|n| part.starts_with('.') || !n.starts_with('.'))
197                    .filter(|n| wildcard_match(part, n))
198                    .collect();
199                names.sort();
200                next.extend(names.into_iter().map(|n| base.join(n)));
201            } else if root.join(base).join(part).is_dir() {
202                next.push(base.join(part));
203            }
204        }
205        current = next;
206    }
207    current
208}
209
210/// Whether the exclude pattern `parts` matches `member` or a directory above it.
211fn excludes(parts: &[String], member: &Path) -> bool {
212    let names: Vec<String> = member
213        .components()
214        .map(|c| c.as_os_str().to_string_lossy().into_owned())
215        .collect();
216    parts.len() <= names.len() && parts.iter().zip(&names).all(|(p, n)| wildcard_match(p, n))
217}
218
219/// `*` and `?` matching over one path component.
220fn wildcard_match(pattern: &str, name: &str) -> bool {
221    let p: Vec<char> = pattern.chars().collect();
222    let n: Vec<char> = name.chars().collect();
223    let (mut pi, mut ni) = (0, 0);
224    let (mut star, mut mark) = (None, 0);
225    while ni < n.len() {
226        if pi < p.len() && (p[pi] == '?' || p[pi] == n[ni]) {
227            pi += 1;
228            ni += 1;
229        } else if pi < p.len() && p[pi] == '*' {
230            star = Some(pi);
231            mark = ni;
232            pi += 1;
233        } else if let Some(s) = star {
234            pi = s + 1;
235            mark += 1;
236            ni = mark;
237        } else {
238            return false;
239        }
240    }
241    p[pi..].iter().all(|c| *c == '*')
242}
243
244/// Whether `dir` holds a `.bynk` file directly.
245fn has_bynk_file(dir: &Path) -> bool {
246    fs::read_dir(dir).is_ok_and(|entries| {
247        entries
248            .filter_map(Result::ok)
249            .any(|e| e.path().extension().is_some_and(|x| x == "bynk") && e.path().is_file())
250    })
251}
252
253fn quoted_list(items: &[String]) -> String {
254    if items.is_empty() {
255        return "nothing (`members` is empty)".to_string();
256    }
257    items
258        .iter()
259        .map(|i| format!("`{i}`"))
260        .collect::<Vec<_>>()
261        .join(", ")
262}
263
264#[cfg(test)]
265mod tests {
266    use super::*;
267
268    #[test]
269    fn wildcards_match_within_a_component() {
270        assert!(wildcard_match("*", "money"));
271        assert!(wildcard_match("m*y", "money"));
272        assert!(wildcard_match("mon?y", "money"));
273        assert!(!wildcard_match("m*x", "money"));
274        assert!(wildcard_match("money", "money"));
275        assert!(!wildcard_match("mone", "money"));
276    }
277
278    #[test]
279    fn an_exclude_covers_a_member_and_everything_under_it() {
280        let ex = components("packages/scratch");
281        assert!(excludes(&ex, Path::new("packages/scratch")));
282        assert!(excludes(&ex, Path::new("packages/scratch/inner")));
283        assert!(!excludes(&ex, Path::new("packages/money")));
284        assert!(excludes(
285            &components("packages/s*"),
286            Path::new("packages/scratch")
287        ));
288    }
289}