Skip to main content

intentional_core/
config.rs

1// ---
2// relationships:
3//   implements: intent-driven-polyglot-release
4// ---
5
6//! Workspace configuration and validation.
7
8use 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
14/// Location of the workspace configuration.
15pub const CONFIG_PATH: &str = ".intentional/config.yml";
16
17/// Published configuration schema identifier.
18pub const CONFIG_SCHEMA: &str = "https://intentional.foo/schemas/config.yml";
19
20/// Complete workspace configuration.
21#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
22#[serde(rename_all = "kebab-case", deny_unknown_fields)]
23pub struct Config {
24    /// Optional schema URL for editor and validation tooling.
25    #[serde(rename = "$schema", skip_serializing_if = "Option::is_none")]
26    pub schema: Option<String>,
27    /// Workspace-wide release settings.
28    #[serde(default)]
29    pub settings: Settings,
30    /// Logical package inventory keyed by stable package id.
31    pub packages: BTreeMap<String, PackageConfig>,
32}
33
34/// Workspace-wide release behavior.
35#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
36#[serde(rename_all = "kebab-case", deny_unknown_fields)]
37pub struct Settings {
38    /// Create an additional plain `X.Y.Z` tag.
39    #[serde(default)]
40    pub global_tag: bool,
41    /// Minimum bump propagated to internal dependents.
42    #[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/// One logical package, potentially projected into several ecosystems.
60#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
61#[serde(rename_all = "kebab-case", deny_unknown_fields)]
62pub struct PackageConfig {
63    /// Package directory relative to the workspace root.
64    pub path: PathBuf,
65    /// Version-bearing ecosystem and format projections.
66    pub projections: Vec<Projection>,
67    /// Tag template. Defaults to `{id}@{version}`.
68    #[serde(default = "default_tag_template")]
69    pub tag: String,
70    /// Authored internal dependency edges.
71    #[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/// A version projection into a manifest or arbitrary file.
80#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
81#[serde(rename_all = "kebab-case", deny_unknown_fields)]
82pub struct Projection {
83    /// Adapter specialization.
84    pub adapter: Adapter,
85    /// File relative to the logical package path.
86    pub file: PathBuf,
87    /// Projection materialization mode.
88    pub mode: ProjectionMode,
89    /// JSON Pointer or dotted TOML/YAML key path for generic formats.
90    #[serde(default, skip_serializing_if = "Option::is_none")]
91    pub pointer: Option<String>,
92}
93
94impl Config {
95    /// Load and validate configuration rooted at `root`.
96    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    /// Parse and validate configuration YAML.
103    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    /// Serialize configuration deterministically.
110    pub fn to_yaml(&self) -> Result<String> {
111        self.validate()?;
112        Ok(serde_yaml::to_string(self)?)
113    }
114
115    /// Validate package ids, paths, projections, tag templates, and dependency graph.
116    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}