bynk_project/
workspace.rs1use 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#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct Workspace {
29 pub root: PathBuf,
31 pub config: WorkspaceConfig,
32 pub members: Vec<PathBuf>,
35}
36
37pub 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
47pub 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 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
84pub 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
97fn 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
122fn 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
167fn 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
182fn 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 ¤t {
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
210fn 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
219fn 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
244fn 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}