Skip to main content

code_moniker_workspace/
source_group.rs

1use std::path::{Component, Path, PathBuf};
2
3use anyhow::Context as _;
4use serde::Deserialize;
5
6const CONFIG_FILE: &str = ".code-moniker.toml";
7
8/// One connected workspace source space. String roots preserve the original
9/// connectivity-only declaration. Mapped roots additionally assign the
10/// existing `srcset` identity to non-standard source layouts.
11#[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(&current.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}