1use std::path::{Component, Path, PathBuf};
2
3use anyhow::Context as _;
4use serde::Deserialize;
5
6const CONFIG_FILE: &str = ".code-moniker.toml";
7
8#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
12#[serde(deny_unknown_fields)]
13pub struct SourceGroupConfig {
14 pub roots: Vec<SourceGroupRootConfig>,
15}
16
17#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
18#[serde(untagged)]
19pub enum SourceGroupRootConfig {
20 Path(String),
21 Mapped(SourceGroupMappedRootConfig),
22}
23
24#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
25#[serde(deny_unknown_fields)]
26pub struct SourceGroupMappedRootConfig {
27 pub path: String,
28 pub srcset: String,
29}
30
31#[derive(Clone, Copy, Debug, Eq, PartialEq)]
32pub(crate) struct SourceGroupMembership<'a> {
33 pub group: usize,
34 pub srcset: Option<&'a str>,
35}
36
37#[doc(hidden)]
38#[derive(Clone, Debug, Default)]
39pub struct DeclaredSourceGroups {
40 roots: Vec<DeclaredSourceRoot>,
41}
42
43#[derive(Clone, Debug)]
44struct DeclaredSourceRoot {
45 group: usize,
46 path: PathBuf,
47 alternate_path: Option<PathBuf>,
48 srcset: Option<String>,
49}
50
51#[derive(Clone, Debug)]
52struct SourceGroupWorkspace {
53 lexical_root: PathBuf,
54 absolute_root: PathBuf,
55}
56
57impl DeclaredSourceGroups {
58 pub(crate) fn load(workspace_root: &Path) -> anyhow::Result<Self> {
59 let config_path = workspace_root.join(CONFIG_FILE);
60 let text = match std::fs::read_to_string(&config_path) {
61 Ok(text) => text,
62 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
63 return Ok(Self::default());
64 }
65 Err(error) => {
66 return Err(error)
67 .with_context(|| format!("cannot read {}", config_path.display()));
68 }
69 };
70 let file: ConfigFile = toml::from_str(&text)
71 .with_context(|| format!("invalid source groups in {}", config_path.display()))?;
72 Self::from_config(workspace_root, file.workspace.source_groups)
73 .with_context(|| format!("invalid source groups in {}", config_path.display()))
74 }
75
76 fn from_config(workspace_root: &Path, groups: Vec<SourceGroupConfig>) -> anyhow::Result<Self> {
77 let workspace = SourceGroupWorkspace::new(workspace_root);
78 let mut roots = Vec::new();
79 for (group, entry) in groups.into_iter().enumerate() {
80 roots.extend(entry.into_declared_roots(group, &workspace)?);
81 }
82 validate_unique_roots(&roots)?;
83 validate_group_boundaries(&roots)?;
84 roots.sort_by_key(|root| std::cmp::Reverse(root.path.components().count()));
85 Ok(Self { roots })
86 }
87
88 pub(crate) fn membership(&self, file_path: &Path) -> Option<SourceGroupMembership<'_>> {
89 self.roots
90 .iter()
91 .find(|root| {
92 file_path.starts_with(&root.path)
93 || root
94 .alternate_path
95 .as_ref()
96 .is_some_and(|alternate| file_path.starts_with(alternate))
97 })
98 .map(|root| SourceGroupMembership {
99 group: root.group,
100 srcset: root.srcset.as_deref(),
101 })
102 }
103}
104
105impl SourceGroupConfig {
106 fn into_declared_roots(
107 self,
108 group: usize,
109 workspace: &SourceGroupWorkspace,
110 ) -> anyhow::Result<Vec<DeclaredSourceRoot>> {
111 if self.roots.is_empty() {
112 anyhow::bail!("workspace.source_group[{group}].roots must not be empty");
113 }
114 self.roots
115 .into_iter()
116 .map(|root| root.into_declared_root(group, workspace))
117 .collect()
118 }
119}
120
121impl SourceGroupRootConfig {
122 fn into_declared_root(
123 self,
124 group: usize,
125 workspace: &SourceGroupWorkspace,
126 ) -> anyhow::Result<DeclaredSourceRoot> {
127 let (path, srcset) = match self {
128 Self::Path(path) => (path, None),
129 Self::Mapped(mapped) => mapped.into_path_and_srcset(group)?,
130 };
131 let (path, alternate_path) = workspace.resolve(group, &path)?;
132 Ok(DeclaredSourceRoot {
133 group,
134 path,
135 alternate_path,
136 srcset,
137 })
138 }
139}
140
141impl SourceGroupMappedRootConfig {
142 fn into_path_and_srcset(self, group: usize) -> anyhow::Result<(String, Option<String>)> {
143 if self.srcset.trim().is_empty() {
144 anyhow::bail!("workspace.source_group[{group}] contains an empty srcset");
145 }
146 Ok((self.path, Some(self.srcset)))
147 }
148}
149
150impl SourceGroupWorkspace {
151 fn new(workspace_root: &Path) -> Self {
152 Self {
153 lexical_root: lexical_absolute(workspace_root),
154 absolute_root: crate::path_util::absolute_path(workspace_root),
155 }
156 }
157
158 fn resolve(&self, group: usize, relative: &str) -> anyhow::Result<(PathBuf, Option<PathBuf>)> {
159 validate_relative_root(group, relative)?;
160 let lexical_path = crate::path_util::lexical_path(&self.lexical_root.join(relative));
161 let path = crate::path_util::absolute_path(&self.absolute_root.join(relative));
162 if !path.starts_with(&self.absolute_root) {
163 anyhow::bail!(
164 "workspace.source_group[{group}] root {} escapes workspace root {}",
165 path.display(),
166 self.absolute_root.display()
167 );
168 }
169 let alternate_path = (lexical_path != path).then_some(lexical_path);
170 Ok((path, alternate_path))
171 }
172}
173
174fn lexical_absolute(path: &Path) -> PathBuf {
175 if path.is_absolute() {
176 return crate::path_util::lexical_path(path);
177 }
178 std::env::current_dir()
179 .map(|current| crate::path_util::lexical_path(¤t.join(path)))
180 .unwrap_or_else(|_| crate::path_util::lexical_path(path))
181}
182
183fn validate_relative_root(group: usize, path: &str) -> anyhow::Result<()> {
184 if path.trim().is_empty() {
185 anyhow::bail!("workspace.source_group[{group}] contains an empty root");
186 }
187 let path = Path::new(path);
188 if path.is_absolute()
189 || path
190 .components()
191 .any(|component| matches!(component, Component::ParentDir | Component::RootDir))
192 {
193 anyhow::bail!(
194 "workspace.source_group[{group}] root {} must stay relative to the workspace",
195 path.display()
196 );
197 }
198 Ok(())
199}
200
201fn validate_unique_roots(roots: &[DeclaredSourceRoot]) -> anyhow::Result<()> {
202 for (idx, root) in roots.iter().enumerate() {
203 if let Some(existing) = roots[..idx]
204 .iter()
205 .find(|existing| existing.path == root.path)
206 {
207 anyhow::bail!(
208 "source root {} is declared more than once (groups {} and {})",
209 root.path.display(),
210 existing.group,
211 root.group
212 );
213 }
214 }
215 Ok(())
216}
217
218fn validate_group_boundaries(roots: &[DeclaredSourceRoot]) -> anyhow::Result<()> {
219 for (idx, left) in roots.iter().enumerate() {
220 for right in &roots[idx + 1..] {
221 if left.group != right.group
222 && (left.path.starts_with(&right.path) || right.path.starts_with(&left.path))
223 {
224 anyhow::bail!(
225 "source roots {} (group {}) and {} (group {}) overlap",
226 left.path.display(),
227 left.group,
228 right.path.display(),
229 right.group
230 );
231 }
232 }
233 }
234 Ok(())
235}
236
237#[derive(Debug, Default, Deserialize)]
238struct ConfigFile {
239 #[serde(default)]
240 workspace: WorkspaceSection,
241}
242
243#[derive(Debug, Default, Deserialize)]
244struct WorkspaceSection {
245 #[serde(default, rename = "source_group")]
246 source_groups: Vec<SourceGroupConfig>,
247}
248
249#[cfg(test)]
250mod tests {
251 use super::*;
252
253 fn write_config(root: &Path, text: &str) {
254 std::fs::write(root.join(CONFIG_FILE), text).expect("write config");
255 }
256
257 #[test]
258 fn legacy_roots_keep_connectivity_without_assigning_srcset() {
259 let dir = tempfile::tempdir().expect("tempdir");
260 write_config(
261 dir.path(),
262 r#"
263[[workspace.source_group]]
264roots = ["module-a", "module-b"]
265
266[[workspace.source_group]]
267roots = ["module-c"]
268"#,
269 );
270
271 let groups = DeclaredSourceGroups::load(dir.path()).expect("groups load");
272 let a = groups
273 .membership(&dir.path().join("module-a/src/main/java/A.java"))
274 .expect("module-a membership");
275 let b = groups
276 .membership(&dir.path().join("module-b/src/test/java/B.java"))
277 .expect("module-b membership");
278 let c = groups
279 .membership(&dir.path().join("module-c/src/main/java/C.java"))
280 .expect("module-c membership");
281
282 assert_eq!(
283 a,
284 SourceGroupMembership {
285 group: 0,
286 srcset: None
287 }
288 );
289 assert_eq!(
290 b,
291 SourceGroupMembership {
292 group: 0,
293 srcset: None
294 }
295 );
296 assert_eq!(
297 c,
298 SourceGroupMembership {
299 group: 1,
300 srcset: None
301 }
302 );
303 }
304
305 #[test]
306 fn mapped_roots_assign_existing_srcset_identity_inside_one_group() {
307 let dir = tempfile::tempdir().expect("tempdir");
308 write_config(
309 dir.path(),
310 r#"
311[[workspace.source_group]]
312roots = [
313 { path = "src/java", srcset = "main" },
314 { path = "test", srcset = "test" },
315]
316"#,
317 );
318
319 let groups = DeclaredSourceGroups::load(dir.path()).expect("groups load");
320 let production = groups
321 .membership(&dir.path().join("src/java/org/acme/Service.java"))
322 .expect("production membership");
323 let test = groups
324 .membership(&dir.path().join("test/unit/org/acme/ServiceTest.java"))
325 .expect("test membership");
326
327 assert_eq!(production.group, 0);
328 assert_eq!(production.srcset, Some("main"));
329 assert_eq!(test.group, 0);
330 assert_eq!(test.srcset, Some("test"));
331 }
332
333 #[test]
334 fn deepest_root_supplies_the_srcset_within_one_group() {
335 let dir = tempfile::tempdir().expect("tempdir");
336 write_config(
337 dir.path(),
338 r#"
339[[workspace.source_group]]
340roots = [
341 { path = ".", srcset = "main" },
342 { path = "test", srcset = "test" },
343]
344"#,
345 );
346
347 let groups = DeclaredSourceGroups::load(dir.path()).expect("groups load");
348 assert_eq!(
349 groups
350 .membership(&dir.path().join("test/unit/ThingTest.java"))
351 .and_then(|membership| membership.srcset),
352 Some("test")
353 );
354 }
355
356 #[test]
357 fn overlapping_roots_across_groups_are_rejected() {
358 let dir = tempfile::tempdir().expect("tempdir");
359 write_config(
360 dir.path(),
361 r#"
362[[workspace.source_group]]
363roots = ["src"]
364
365[[workspace.source_group]]
366roots = ["src/generated"]
367"#,
368 );
369
370 let error = DeclaredSourceGroups::load(dir.path()).expect_err("overlap must fail");
371 assert!(error.to_string().contains("invalid source groups"));
372 assert!(format!("{error:#}").contains("overlap"));
373 }
374
375 #[test]
376 fn missing_config_yields_no_groups() {
377 let dir = tempfile::tempdir().expect("tempdir");
378 let groups = DeclaredSourceGroups::load(dir.path()).expect("missing config is valid");
379 assert!(groups.membership(&dir.path().join("src/A.java")).is_none());
380 }
381}