Skip to main content

intentional_core/
intent.rs

1// ---
2// relationships:
3//   implements: intent-driven-polyglot-release
4// ---
5
6//! Intent file parsing, validation, and authoring.
7
8use crate::config::Config;
9use crate::error::{Error, Result};
10use crate::model::Bump;
11use rand::seq::IndexedRandom;
12use rand::RngExt;
13use serde::Deserialize;
14use std::collections::{BTreeMap, BTreeSet};
15use std::path::{Path, PathBuf};
16
17/// Directory containing pending intent files.
18pub const INTENTS_PATH: &str = ".intentional/intents";
19
20const ADJECTIVES: &[&str] = &[
21    "amber", "brisk", "calm", "clear", "gentle", "lucky", "quiet", "swift",
22];
23const NOUNS: &[&str] = &[
24    "badger", "comet", "falcon", "lantern", "otter", "panda", "river", "willow",
25];
26
27/// Parsed change intent.
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct Intent {
30    /// Stable identifier derived from the filename stem.
31    pub id: String,
32    /// Release-unit bump claims.
33    pub release_units: BTreeMap<String, Bump>,
34    /// Release units this intent graduates to `1.0.0`.
35    pub graduates: BTreeSet<String>,
36    /// Markdown changelog prose.
37    pub message: String,
38    /// Source file.
39    pub path: PathBuf,
40}
41
42/// Inputs used to author an intent.
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct IntentDraft {
45    /// Release-unit bump claims.
46    pub release_units: BTreeMap<String, Bump>,
47    /// Release units this intent graduates to `1.0.0`.
48    pub graduates: BTreeSet<String>,
49    /// Markdown changelog prose.
50    pub message: String,
51}
52
53/// Planned intent file write.
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct IntentWrite {
56    /// Target path relative to the workspace.
57    pub path: PathBuf,
58    /// Complete file contents.
59    pub contents: String,
60}
61
62impl Intent {
63    /// Load every pending intent in stable filename order.
64    pub fn load_all(root: &Path, config: &Config) -> Result<Vec<Self>> {
65        let directory = root.join(INTENTS_PATH);
66        if !directory.exists() {
67            return Ok(Vec::new());
68        }
69        let mut paths = std::fs::read_dir(&directory)
70            .map_err(|error| Error::io(&directory, error))?
71            .filter_map(std::result::Result::ok)
72            .map(|entry| entry.path())
73            .filter(|path| path.extension().is_some_and(|extension| extension == "md"))
74            .collect::<Vec<_>>();
75        paths.sort();
76        paths
77            .into_iter()
78            .map(|path| Self::load(&path, config))
79            .collect()
80    }
81
82    /// Load and validate one intent file.
83    pub fn load(path: &Path, config: &Config) -> Result<Self> {
84        let text = std::fs::read_to_string(path).map_err(|error| Error::io(path, error))?;
85        Self::parse(path, &text, config)
86    }
87
88    /// Parse and validate one intent from supplied contents.
89    pub fn parse(path: &Path, text: &str, config: &Config) -> Result<Self> {
90        let (frontmatter, message) = split_frontmatter(text)?;
91        let (release_units, graduates) = parse_claims(frontmatter)?;
92        validate_draft(&release_units, &graduates, message, config)?;
93        let id = path
94            .file_stem()
95            .and_then(|stem| stem.to_str())
96            .ok_or_else(|| {
97                Error::Validation(format!("invalid intent filename {}", path.display()))
98            })?
99            .to_owned();
100        Ok(Self {
101            id,
102            release_units,
103            graduates,
104            message: message.trim().to_owned(),
105            path: path.to_owned(),
106        })
107    }
108}
109
110impl IntentDraft {
111    /// Validate and render a new intent with a generated memorable slug.
112    pub fn plan(self, root: &Path, config: &Config) -> Result<IntentWrite> {
113        validate_draft(&self.release_units, &self.graduates, &self.message, config)?;
114        let mut rng = rand::rng();
115        let adjective = ADJECTIVES
116            .choose(&mut rng)
117            .expect("adjectives are non-empty");
118        let noun = NOUNS.choose(&mut rng).expect("nouns are non-empty");
119        let suffix: u32 = rng.random_range(0..=0xffff);
120        let slug = format!("{adjective}-{noun}-{suffix:04x}");
121        let relative = PathBuf::from(INTENTS_PATH).join(format!("{slug}.md"));
122        let path = root.join(&relative);
123        if path.exists() {
124            return Err(Error::Validation(format!(
125                "generated intent path already exists: {}",
126                relative.display()
127            )));
128        }
129        let contents = render_intent(&self.release_units, &self.graduates, &self.message)?;
130        Ok(IntentWrite {
131            path: relative,
132            contents,
133        })
134    }
135}
136
137impl IntentWrite {
138    /// Materialize this write unless `dry_run` is set.
139    pub fn apply(&self, root: &Path, dry_run: bool) -> Result<()> {
140        if dry_run {
141            return Ok(());
142        }
143        let path = root.join(&self.path);
144        let parent = path.parent().expect("intent path has a parent");
145        std::fs::create_dir_all(parent).map_err(|error| Error::io(parent, error))?;
146        std::fs::write(&path, &self.contents).map_err(|error| Error::io(&path, error))
147    }
148}
149
150/// Render intent file contents. A graduating release unit uses the long
151/// form `{ bump: major, graduate: true }`; every other one uses the short
152/// form `id: bump`.
153pub(crate) fn render_intent(
154    release_units: &BTreeMap<String, Bump>,
155    graduates: &BTreeSet<String>,
156    message: &str,
157) -> Result<String> {
158    let mut frontmatter = serde_yaml::Mapping::new();
159    for (id, bump) in release_units {
160        let value = if graduates.contains(id) {
161            let mut claim = serde_yaml::Mapping::new();
162            claim.insert("bump".into(), serde_yaml::to_value(bump)?);
163            claim.insert("graduate".into(), true.into());
164            serde_yaml::Value::Mapping(claim)
165        } else {
166            serde_yaml::to_value(bump)?
167        };
168        frontmatter.insert(id.as_str().into(), value);
169    }
170    let yaml = serde_yaml::to_string(&frontmatter)?;
171    Ok(format!("---\n{yaml}---\n\n{}\n", message.trim()))
172}
173
174#[derive(Deserialize)]
175#[serde(deny_unknown_fields)]
176struct LongClaim {
177    bump: Bump,
178    #[serde(default)]
179    graduate: bool,
180}
181
182/// Parse frontmatter claims. Each value is a bump name, or a mapping with
183/// `bump` and an optional `graduate`.
184fn parse_claims(frontmatter: &str) -> Result<(BTreeMap<String, Bump>, BTreeSet<String>)> {
185    let claims: BTreeMap<String, serde_yaml::Value> = serde_yaml::from_str(frontmatter)?;
186    let mut release_units = BTreeMap::new();
187    let mut graduates = BTreeSet::new();
188    for (id, value) in claims {
189        let bump = match value {
190            serde_yaml::Value::String(_) => serde_yaml::from_value(value)?,
191            serde_yaml::Value::Mapping(_) => {
192                let claim: LongClaim = serde_yaml::from_value(value)?;
193                if claim.graduate {
194                    graduates.insert(id.clone());
195                }
196                claim.bump
197            }
198            _ => {
199                return Err(Error::Validation(format!(
200                    "intent release unit {id} must declare a bump name or a mapping with bump and graduate"
201                )))
202            }
203        };
204        release_units.insert(id, bump);
205    }
206    Ok((release_units, graduates))
207}
208
209fn validate_draft(
210    release_units: &BTreeMap<String, Bump>,
211    graduates: &BTreeSet<String>,
212    message: &str,
213    config: &Config,
214) -> Result<()> {
215    if release_units.is_empty() {
216        return Err(Error::Validation(
217            "intent must reference at least one release unit".to_owned(),
218        ));
219    }
220    if message.trim().is_empty() {
221        return Err(Error::Validation(
222            "intent changelog message must not be empty".to_owned(),
223        ));
224    }
225    for (release_unit, bump) in release_units {
226        if !config.release_units.contains_key(release_unit) {
227            return Err(Error::Validation(format!(
228                "intent references unknown release unit {release_unit}"
229            )));
230        }
231        if *bump == Bump::None {
232            return Err(Error::Validation(format!(
233                "intent release unit {release_unit} must declare major, minor, or patch"
234            )));
235        }
236    }
237    for release_unit in graduates {
238        match release_units.get(release_unit) {
239            Some(Bump::Major) => {}
240            Some(_) => {
241                return Err(Error::Validation(format!(
242                    "intent release unit {release_unit} sets graduate, which requires bump major"
243                )))
244            }
245            None => {
246                return Err(Error::Validation(format!(
247                    "intent graduates release unit {release_unit}, which it does not bump"
248                )))
249            }
250        }
251    }
252    Ok(())
253}
254
255fn split_frontmatter(text: &str) -> Result<(&str, &str)> {
256    let rest = text.strip_prefix("---\n").ok_or_else(|| {
257        Error::Validation("intent must start with YAML frontmatter delimiter".to_owned())
258    })?;
259    let (frontmatter, message) = rest.split_once("\n---\n").ok_or_else(|| {
260        Error::Validation("intent frontmatter is missing its closing delimiter".to_owned())
261    })?;
262    Ok((frontmatter, message))
263}
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268
269    fn config() -> Config {
270        Config::from_yaml(
271            r#"
272contract: contract-2
273release-units:
274  library:
275    path: .
276    projections:
277      - { adapter: npm, file: package.json, mode: committed }
278    tags:
279      primary: { role: primary, template: '{id}@{version}' }
280"#,
281        )
282        .expect("valid config")
283    }
284
285    #[test]
286    fn plans_direct_frontmatter_mapping() {
287        let draft = IntentDraft {
288            release_units: BTreeMap::from([("library".to_owned(), Bump::Minor)]),
289            graduates: BTreeSet::new(),
290            message: "Add a useful capability.".to_owned(),
291        };
292        let write = draft.plan(Path::new("."), &config()).expect("planned");
293        assert!(write.path.to_string_lossy().starts_with(INTENTS_PATH));
294        assert!(write.contents.starts_with("---\nlibrary: minor\n---"));
295        assert!(write.contents.contains("Add a useful capability."));
296    }
297
298    #[test]
299    fn rejects_unknown_release_unit() {
300        let draft = IntentDraft {
301            release_units: BTreeMap::from([("missing".to_owned(), Bump::Patch)]),
302            graduates: BTreeSet::new(),
303            message: "Correct a defect.".to_owned(),
304        };
305        let error = draft
306            .plan(Path::new("."), &config())
307            .expect_err("unknown release unit rejected");
308        assert!(error.to_string().contains("unknown release unit missing"));
309    }
310
311    #[test]
312    fn graduating_release_unit_round_trips_in_long_form() {
313        let draft = IntentDraft {
314            release_units: BTreeMap::from([("library".to_owned(), Bump::Major)]),
315            graduates: BTreeSet::from(["library".to_owned()]),
316            message: "Declare the stable interface.".to_owned(),
317        };
318        let write = draft.plan(Path::new("."), &config()).expect("planned");
319        assert!(write
320            .contents
321            .starts_with("---\nlibrary:\n  bump: major\n  graduate: true\n---"));
322        let intent =
323            Intent::parse(Path::new("x.md"), &write.contents, &config()).expect("parsed back");
324        assert_eq!(intent.release_units["library"], Bump::Major);
325        assert!(intent.graduates.contains("library"));
326    }
327
328    #[test]
329    fn long_form_without_graduate_is_an_ordinary_bump() {
330        let intent = Intent::parse(
331            Path::new("x.md"),
332            "---\nlibrary: { bump: minor }\n---\n\nAdd a capability.\n",
333            &config(),
334        )
335        .expect("parsed");
336        assert_eq!(intent.release_units["library"], Bump::Minor);
337        assert!(intent.graduates.is_empty());
338    }
339
340    #[test]
341    fn graduate_requires_major() {
342        let error = Intent::parse(
343            Path::new("x.md"),
344            "---\nlibrary: { bump: minor, graduate: true }\n---\n\nAdd a capability.\n",
345            &config(),
346        )
347        .expect_err("graduate with minor rejected");
348        assert!(error.to_string().contains("requires bump major"));
349    }
350}