Skip to main content

pedant_core/
workspace.rs

1use std::fs;
2use std::path::{Path, PathBuf};
3
4/// Failure encountered while expanding workspace member patterns.
5#[derive(Debug, thiserror::Error)]
6pub enum WorkspaceMemberError {
7    /// A directory listed in (or scanned for) a member pattern could not be read.
8    #[error("failed to read directory {path}: {source}")]
9    ReadDir {
10        /// The directory that could not be read.
11        path: Box<str>,
12        /// The underlying I/O failure.
13        #[source]
14        source: std::io::Error,
15    },
16}
17
18/// Expand workspace `members` patterns into sorted, deduplicated member directories.
19///
20/// Literal entries resolve directly against `workspace_root`; entries containing
21/// `*` are expanded by scanning the filesystem. Only directories containing a
22/// `Cargo.toml` are returned.
23pub fn resolve_workspace_members(
24    workspace_root: &Path,
25    members: &[Box<str>],
26) -> Result<Vec<PathBuf>, WorkspaceMemberError> {
27    let mut dirs: Vec<PathBuf> = members
28        .iter()
29        .map(|member| expand_member(workspace_root, member))
30        .collect::<Result<Vec<_>, _>>()?
31        .into_iter()
32        .flatten()
33        .filter(|path| path.join("Cargo.toml").is_file())
34        .collect();
35    dirs.sort();
36    dirs.dedup();
37    Ok(dirs)
38}
39
40fn expand_member(
41    workspace_root: &Path,
42    member: &str,
43) -> Result<Vec<PathBuf>, WorkspaceMemberError> {
44    match member.contains('*') {
45        true => expand_glob_member(workspace_root, member),
46        false => Ok(vec![workspace_root.join(member)]),
47    }
48}
49
50fn expand_glob_member(
51    workspace_root: &Path,
52    member: &str,
53) -> Result<Vec<PathBuf>, WorkspaceMemberError> {
54    let (scan_root, pattern) = scan_root_for_member(workspace_root, member);
55    if !scan_root.is_dir() {
56        return Ok(Vec::new());
57    }
58
59    let max_depth = member_path_segments(pattern.as_ref()).len();
60    let mut matches = Vec::new();
61    collect_matching_dirs(
62        &scan_root,
63        &scan_root,
64        pattern.as_ref(),
65        max_depth,
66        &mut matches,
67    )?;
68    Ok(matches)
69}
70
71fn scan_root_for_member(workspace_root: &Path, member: &str) -> (PathBuf, Box<str>) {
72    let member_segments = member_path_segments(member);
73    let split_index = member_segments
74        .iter()
75        .position(|segment| segment.contains('*'))
76        .unwrap_or(member_segments.len());
77    let mut scan_root = workspace_root.to_path_buf();
78    for segment in &member_segments[..split_index] {
79        scan_root.push(segment);
80    }
81    let pattern = member_segments[split_index..].join("/").into_boxed_str();
82    (scan_root, pattern)
83}
84
85fn collect_matching_dirs(
86    pattern_root: &Path,
87    current_dir: &Path,
88    member: &str,
89    max_depth: usize,
90    matches: &mut Vec<PathBuf>,
91) -> Result<(), WorkspaceMemberError> {
92    for entry in read_directory(current_dir)? {
93        let path = entry.path();
94        if !path.is_dir() {
95            continue;
96        }
97        if !matches_member_prefix(pattern_root, &path, member) {
98            continue;
99        }
100
101        add_matching_dir(pattern_root, &path, member, matches);
102        if relative_depth(pattern_root, &path) < max_depth {
103            collect_matching_dirs(pattern_root, &path, member, max_depth, matches)?;
104        }
105    }
106    Ok(())
107}
108
109fn add_matching_dir(workspace_root: &Path, path: &Path, member: &str, matches: &mut Vec<PathBuf>) {
110    if matches_member_pattern(workspace_root, path, member) {
111        matches.push(path.to_path_buf());
112    }
113}
114
115fn read_directory(path: &Path) -> Result<Vec<fs::DirEntry>, WorkspaceMemberError> {
116    fs::read_dir(path)
117        .map_err(|source| WorkspaceMemberError::ReadDir {
118            path: path.to_string_lossy().into(),
119            source,
120        })?
121        .collect::<Result<Vec<_>, _>>()
122        .map_err(|source| WorkspaceMemberError::ReadDir {
123            path: path.to_string_lossy().into(),
124            source,
125        })
126}
127
128fn relative_depth(workspace_root: &Path, path: &Path) -> usize {
129    path.strip_prefix(workspace_root)
130        .ok()
131        .map(path_component_count)
132        .unwrap_or(0)
133}
134
135fn matches_member_pattern(workspace_root: &Path, path: &Path, member: &str) -> bool {
136    let relative = match path.strip_prefix(workspace_root) {
137        Ok(relative) => relative,
138        Err(_) => return false,
139    };
140    let path_segments = path_segments(relative);
141    let member_segments = member_path_segments(member);
142    match path_segments.len() == member_segments.len() {
143        true => path_segments
144            .iter()
145            .zip(member_segments.iter())
146            .all(|(path_segment, member_segment)| segment_matches(path_segment, member_segment)),
147        false => false,
148    }
149}
150
151fn matches_member_prefix(workspace_root: &Path, path: &Path, member: &str) -> bool {
152    let relative = match path.strip_prefix(workspace_root) {
153        Ok(relative) => relative,
154        Err(_) => return false,
155    };
156    let path_segments = path_segments(relative);
157    let member_segments = member_path_segments(member);
158    match path_segments.len() <= member_segments.len() {
159        true => path_segments
160            .iter()
161            .zip(member_segments.iter())
162            .all(|(path_segment, member_segment)| segment_matches(path_segment, member_segment)),
163        false => false,
164    }
165}
166
167fn path_component_count(path: &Path) -> usize {
168    path.components().count()
169}
170
171fn path_segments(path: &Path) -> Vec<Box<str>> {
172    path.iter()
173        .map(|segment| segment.to_string_lossy().into_owned().into_boxed_str())
174        .collect()
175}
176
177fn member_path_segments(member: &str) -> Vec<&str> {
178    member
179        .split('/')
180        .filter(|segment| !segment.is_empty())
181        .collect()
182}
183
184fn segment_matches(path_segment: &str, pattern_segment: &str) -> bool {
185    let parts = pattern_segment.split('*').collect::<Vec<_>>();
186    match parts.len() {
187        1 => path_segment == pattern_segment,
188        _ => wildcard_segment_matches(path_segment, &parts, pattern_segment.starts_with('*')),
189    }
190}
191
192fn wildcard_segment_matches(
193    path_segment: &str,
194    parts: &[&str],
195    starts_with_wildcard: bool,
196) -> bool {
197    let mut remaining = path_segment;
198    for (index, part) in parts.iter().enumerate() {
199        if part.is_empty() {
200            continue;
201        }
202        let found = match (index == 0, starts_with_wildcard) {
203            (true, false) => remaining.strip_prefix(part),
204            _ => remaining
205                .find(part)
206                .map(|offset| &remaining[offset + part.len()..]),
207        };
208        match found {
209            Some(next) => remaining = next,
210            None => return false,
211        }
212    }
213    pattern_segment_ends_with_wildcard(parts, remaining)
214}
215
216fn pattern_segment_ends_with_wildcard(parts: &[&str], remaining: &str) -> bool {
217    match parts.last() {
218        Some(&"") => true,
219        Some(_) => remaining.is_empty(),
220        None => false,
221    }
222}