1use crate::error::{Error, Result};
9use crate::model::{Adapter, Bump, ProjectionMode};
10use serde::{Deserialize, Serialize};
11use std::collections::{BTreeMap, BTreeSet};
12use std::path::{Component, Path, PathBuf};
13
14pub const CONFIG_PATH: &str = ".intentional/config.yml";
16
17pub const CONFIG_SCHEMA: &str = "https://intentional.foo/schemas/config.yml";
19
20#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
22#[serde(rename_all = "kebab-case", deny_unknown_fields)]
23pub struct Config {
24 #[serde(rename = "$schema", skip_serializing_if = "Option::is_none")]
26 pub schema: Option<String>,
27 #[serde(default)]
29 pub settings: Settings,
30 pub packages: BTreeMap<String, PackageConfig>,
32}
33
34#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
36#[serde(rename_all = "kebab-case", deny_unknown_fields)]
37pub struct Settings {
38 #[serde(default)]
40 pub global_tag: bool,
41 #[serde(default = "default_dependency_bump")]
43 pub internal_dependency_bump: Bump,
44}
45
46impl Default for Settings {
47 fn default() -> Self {
48 Self {
49 global_tag: false,
50 internal_dependency_bump: default_dependency_bump(),
51 }
52 }
53}
54
55const fn default_dependency_bump() -> Bump {
56 Bump::Patch
57}
58
59#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
61#[serde(rename_all = "kebab-case", deny_unknown_fields)]
62pub struct PackageConfig {
63 pub path: PathBuf,
65 pub projections: Vec<Projection>,
67 #[serde(default = "default_tag_template")]
69 pub tag: String,
70 #[serde(default, skip_serializing_if = "Vec::is_empty")]
72 pub depends_on: Vec<String>,
73}
74
75fn default_tag_template() -> String {
76 "{id}@{version}".to_owned()
77}
78
79#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
81#[serde(rename_all = "kebab-case", deny_unknown_fields)]
82pub struct Projection {
83 pub adapter: Adapter,
85 pub file: PathBuf,
87 pub mode: ProjectionMode,
89 #[serde(default, skip_serializing_if = "Option::is_none")]
91 pub pointer: Option<String>,
92}
93
94impl Config {
95 pub fn load(root: &Path) -> Result<Self> {
97 let path = root.join(CONFIG_PATH);
98 let text = std::fs::read_to_string(&path).map_err(|error| Error::io(&path, error))?;
99 Self::from_yaml(&text)
100 }
101
102 pub fn from_yaml(text: &str) -> Result<Self> {
104 let config: Self = serde_yaml::from_str(text)?;
105 config.validate()?;
106 Ok(config)
107 }
108
109 pub fn to_yaml(&self) -> Result<String> {
111 self.validate()?;
112 Ok(serde_yaml::to_string(self)?)
113 }
114
115 pub fn validate(&self) -> Result<()> {
117 if self.packages.is_empty() {
118 return Err(Error::Validation(
119 "config must declare at least one package".to_owned(),
120 ));
121 }
122 if self.settings.internal_dependency_bump == Bump::None {
123 return Err(Error::Validation(
124 "internal-dependency-bump must be major, minor, or patch".to_owned(),
125 ));
126 }
127
128 for (id, package) in &self.packages {
129 validate_id(id)?;
130 validate_relative_path(&package.path, &format!("package {id} path"))?;
131 if package.projections.is_empty() {
132 return Err(Error::Validation(format!(
133 "package {id} must declare at least one projection"
134 )));
135 }
136 validate_tag_template(id, &package.tag)?;
137
138 let mut projection_keys = BTreeSet::new();
139 for projection in &package.projections {
140 validate_relative_path(&projection.file, &format!("package {id} projection file"))?;
141 if projection.adapter.requires_pointer()
142 && projection.pointer.as_deref().is_none_or(str::is_empty)
143 {
144 return Err(Error::Validation(format!(
145 "package {id} generic {:?} projection requires a pointer",
146 projection.adapter
147 )));
148 }
149 let key = (&projection.file, projection.pointer.as_deref());
150 if !projection_keys.insert(key) {
151 return Err(Error::Validation(format!(
152 "package {id} repeats projection {}",
153 projection.file.display()
154 )));
155 }
156 }
157
158 let mut dependencies = BTreeSet::new();
159 for dependency in &package.depends_on {
160 if dependency == id {
161 return Err(Error::Validation(format!(
162 "package {id} cannot depend on itself"
163 )));
164 }
165 if !self.packages.contains_key(dependency) {
166 return Err(Error::Validation(format!(
167 "package {id} depends on unknown package {dependency}"
168 )));
169 }
170 if !dependencies.insert(dependency) {
171 return Err(Error::Validation(format!(
172 "package {id} repeats dependency {dependency}"
173 )));
174 }
175 }
176 }
177
178 let mut resolved_tag_patterns = BTreeMap::new();
179 if self.settings.global_tag {
180 resolved_tag_patterns.insert("{version}".to_owned(), "the global tag".to_owned());
181 }
182 for (id, package) in &self.packages {
183 let pattern = package.tag.replace("{id}", id);
184 if let Some(other) = resolved_tag_patterns.insert(pattern, format!("package {id}")) {
185 return Err(Error::Validation(format!(
186 "package {id} tag template collides with {other}"
187 )));
188 }
189 }
190
191 self.validate_acyclic()
192 }
193
194 fn validate_acyclic(&self) -> Result<()> {
195 fn visit<'a>(
196 id: &'a str,
197 packages: &'a BTreeMap<String, PackageConfig>,
198 visiting: &mut BTreeSet<&'a str>,
199 visited: &mut BTreeSet<&'a str>,
200 ) -> Result<()> {
201 if visited.contains(id) {
202 return Ok(());
203 }
204 if !visiting.insert(id) {
205 return Err(Error::Validation(format!(
206 "dependency cycle includes package {id}"
207 )));
208 }
209 for dependency in &packages[id].depends_on {
210 visit(dependency, packages, visiting, visited)?;
211 }
212 visiting.remove(id);
213 visited.insert(id);
214 Ok(())
215 }
216
217 let mut visiting = BTreeSet::new();
218 let mut visited = BTreeSet::new();
219 for id in self.packages.keys() {
220 visit(id, &self.packages, &mut visiting, &mut visited)?;
221 }
222 Ok(())
223 }
224}
225
226fn validate_id(id: &str) -> Result<()> {
227 if id.is_empty()
228 || !id
229 .chars()
230 .all(|character| character.is_ascii_alphanumeric() || "-_.@/".contains(character))
231 {
232 return Err(Error::Validation(format!("invalid package id {id:?}")));
233 }
234 Ok(())
235}
236
237fn validate_relative_path(path: &Path, description: &str) -> Result<()> {
238 if path.as_os_str().is_empty()
239 || path.is_absolute()
240 || path
241 .components()
242 .any(|component| matches!(component, Component::ParentDir))
243 {
244 return Err(Error::Validation(format!(
245 "{description} must be a non-empty relative path without .."
246 )));
247 }
248 Ok(())
249}
250
251fn validate_tag_template(id: &str, template: &str) -> Result<()> {
252 if template.matches("{version}").count() != 1 || template.matches("{id}").count() > 1 {
253 return Err(Error::Validation(format!(
254 "package {id} tag template must contain exactly one {{version}} and at most one {{id}}"
255 )));
256 }
257 if template.contains("v{version}") {
258 return Err(Error::Validation(format!(
259 "package {id} tag template must not prefix versions with v"
260 )));
261 }
262 Ok(())
263}
264
265#[cfg(test)]
266mod tests {
267 use super::*;
268
269 const VALID: &str = r#"
270$schema: https://intentional.foo/schemas/config.yml
271settings:
272 global-tag: true
273 internal-dependency-bump: patch
274packages:
275 library:
276 path: packages/library
277 projections:
278 - adapter: npm
279 file: package.json
280 mode: committed
281 application:
282 path: packages/application
283 depends-on: [library]
284 projections:
285 - adapter: json
286 file: metadata.json
287 pointer: /version
288 mode: injected
289"#;
290
291 #[test]
292 fn parses_and_defaults_tag_templates() {
293 let config = Config::from_yaml(VALID).expect("valid config");
294 assert_eq!(config.packages["library"].tag, "{id}@{version}");
295 assert!(config.settings.global_tag);
296 }
297
298 #[test]
299 fn rejects_unknown_dependencies() {
300 let invalid = VALID.replace("depends-on: [library]", "depends-on: [missing]");
301 let error = Config::from_yaml(&invalid).expect_err("unknown package rejected");
302 assert!(error.to_string().contains("unknown package missing"));
303 }
304
305 #[test]
306 fn rejects_dependency_cycles() {
307 let invalid = VALID.replace(
308 "path: packages/library",
309 "path: packages/library\n depends-on: [application]",
310 );
311 let error = Config::from_yaml(&invalid).expect_err("cycle rejected");
312 assert!(error.to_string().contains("dependency cycle"));
313 }
314
315 #[test]
316 fn accepts_plain_version_tag_template() {
317 let valid = VALID
318 .replace("global-tag: true", "global-tag: false")
319 .replace(
320 "path: packages/library",
321 "path: packages/library\n tag: '{version}'",
322 );
323 let config = Config::from_yaml(&valid).expect("plain version template accepted");
324 assert_eq!(config.packages["library"].tag, "{version}");
325 }
326
327 #[test]
328 fn rejects_colliding_tag_templates() {
329 let invalid = VALID
330 .replace("global-tag: true", "global-tag: false")
331 .replace(
332 "path: packages/library",
333 "path: packages/library\n tag: 'shared@{version}'",
334 )
335 .replace(
336 "path: packages/application",
337 "path: packages/application\n tag: 'shared@{version}'",
338 );
339 let error = Config::from_yaml(&invalid).expect_err("collision rejected");
340 assert!(error.to_string().contains("collides"));
341 }
342
343 #[test]
344 fn rejects_plain_version_template_colliding_with_global_tag() {
345 let invalid = VALID.replace(
346 "path: packages/library",
347 "path: packages/library\n tag: '{version}'",
348 );
349 let error = Config::from_yaml(&invalid).expect_err("global tag collision rejected");
350 assert!(error.to_string().contains("collides with the global tag"));
351 }
352
353 #[test]
354 fn rejects_v_prefixed_tag_templates() {
355 let invalid = VALID.replace(
356 "path: packages/library",
357 "path: packages/library\n tag: '{id}@v{version}'",
358 );
359 let error = Config::from_yaml(&invalid).expect_err("v prefix rejected");
360 assert!(error
361 .to_string()
362 .contains("must not prefix versions with v"));
363 }
364
365 #[test]
366 fn generic_formats_require_pointers() {
367 let invalid = VALID.replace(" pointer: /version\n", "");
368 let error = Config::from_yaml(&invalid).expect_err("pointer required");
369 assert!(error.to_string().contains("requires a pointer"));
370 }
371}