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 std::collections::BTreeMap;
14use std::path::{Path, PathBuf};
15
16/// Directory containing pending intent files.
17pub const INTENTS_PATH: &str = ".intentional/intents";
18
19const ADJECTIVES: &[&str] = &[
20    "amber", "brisk", "calm", "clear", "gentle", "lucky", "quiet", "swift",
21];
22const NOUNS: &[&str] = &[
23    "badger", "comet", "falcon", "lantern", "otter", "panda", "river", "willow",
24];
25
26/// Parsed change intent.
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct Intent {
29    /// Stable identifier derived from the filename stem.
30    pub id: String,
31    /// Release-unit bump claims.
32    pub release_units: BTreeMap<String, Bump>,
33    /// Markdown changelog prose.
34    pub message: String,
35    /// Source file.
36    pub path: PathBuf,
37}
38
39/// Inputs used to author an intent.
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct IntentDraft {
42    /// Release-unit bump claims.
43    pub release_units: BTreeMap<String, Bump>,
44    /// Markdown changelog prose.
45    pub message: String,
46}
47
48/// Planned intent file write.
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct IntentWrite {
51    /// Target path relative to the workspace.
52    pub path: PathBuf,
53    /// Complete file contents.
54    pub contents: String,
55}
56
57impl Intent {
58    /// Load every pending intent in stable filename order.
59    pub fn load_all(root: &Path, config: &Config) -> Result<Vec<Self>> {
60        let directory = root.join(INTENTS_PATH);
61        if !directory.exists() {
62            return Ok(Vec::new());
63        }
64        let mut paths = std::fs::read_dir(&directory)
65            .map_err(|error| Error::io(&directory, error))?
66            .filter_map(std::result::Result::ok)
67            .map(|entry| entry.path())
68            .filter(|path| path.extension().is_some_and(|extension| extension == "md"))
69            .collect::<Vec<_>>();
70        paths.sort();
71        paths
72            .into_iter()
73            .map(|path| Self::load(&path, config))
74            .collect()
75    }
76
77    /// Load and validate one intent file.
78    pub fn load(path: &Path, config: &Config) -> Result<Self> {
79        let text = std::fs::read_to_string(path).map_err(|error| Error::io(path, error))?;
80        Self::parse(path, &text, config)
81    }
82
83    /// Parse and validate one intent from supplied contents.
84    pub fn parse(path: &Path, text: &str, config: &Config) -> Result<Self> {
85        let (frontmatter, message) = split_frontmatter(text)?;
86        let release_units: BTreeMap<String, Bump> = serde_yaml::from_str(frontmatter)?;
87        validate_draft(&release_units, message, config)?;
88        let id = path
89            .file_stem()
90            .and_then(|stem| stem.to_str())
91            .ok_or_else(|| {
92                Error::Validation(format!("invalid intent filename {}", path.display()))
93            })?
94            .to_owned();
95        Ok(Self {
96            id,
97            release_units,
98            message: message.trim().to_owned(),
99            path: path.to_owned(),
100        })
101    }
102}
103
104impl IntentDraft {
105    /// Validate and render a new intent with a generated memorable slug.
106    pub fn plan(self, root: &Path, config: &Config) -> Result<IntentWrite> {
107        validate_draft(&self.release_units, &self.message, config)?;
108        let mut rng = rand::rng();
109        let adjective = ADJECTIVES
110            .choose(&mut rng)
111            .expect("adjectives are non-empty");
112        let noun = NOUNS.choose(&mut rng).expect("nouns are non-empty");
113        let suffix: u32 = rng.random_range(0..=0xffff);
114        let slug = format!("{adjective}-{noun}-{suffix:04x}");
115        let relative = PathBuf::from(INTENTS_PATH).join(format!("{slug}.md"));
116        let path = root.join(&relative);
117        if path.exists() {
118            return Err(Error::Validation(format!(
119                "generated intent path already exists: {}",
120                relative.display()
121            )));
122        }
123        let yaml = serde_yaml::to_string(&self.release_units)?;
124        let contents = format!("---\n{yaml}---\n\n{}\n", self.message.trim());
125        Ok(IntentWrite {
126            path: relative,
127            contents,
128        })
129    }
130}
131
132impl IntentWrite {
133    /// Materialize this write unless `dry_run` is set.
134    pub fn apply(&self, root: &Path, dry_run: bool) -> Result<()> {
135        if dry_run {
136            return Ok(());
137        }
138        let path = root.join(&self.path);
139        let parent = path.parent().expect("intent path has a parent");
140        std::fs::create_dir_all(parent).map_err(|error| Error::io(parent, error))?;
141        std::fs::write(&path, &self.contents).map_err(|error| Error::io(&path, error))
142    }
143}
144
145fn validate_draft(
146    release_units: &BTreeMap<String, Bump>,
147    message: &str,
148    config: &Config,
149) -> Result<()> {
150    if release_units.is_empty() {
151        return Err(Error::Validation(
152            "intent must reference at least one release unit".to_owned(),
153        ));
154    }
155    if message.trim().is_empty() {
156        return Err(Error::Validation(
157            "intent changelog message must not be empty".to_owned(),
158        ));
159    }
160    for (release_unit, bump) in release_units {
161        if !config.release_units.contains_key(release_unit) {
162            return Err(Error::Validation(format!(
163                "intent references unknown release unit {release_unit}"
164            )));
165        }
166        if *bump == Bump::None {
167            return Err(Error::Validation(format!(
168                "intent release unit {release_unit} must declare major, minor, or patch"
169            )));
170        }
171    }
172    Ok(())
173}
174
175fn split_frontmatter(text: &str) -> Result<(&str, &str)> {
176    let rest = text.strip_prefix("---\n").ok_or_else(|| {
177        Error::Validation("intent must start with YAML frontmatter delimiter".to_owned())
178    })?;
179    let (frontmatter, message) = rest.split_once("\n---\n").ok_or_else(|| {
180        Error::Validation("intent frontmatter is missing its closing delimiter".to_owned())
181    })?;
182    Ok((frontmatter, message))
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188
189    fn config() -> Config {
190        Config::from_yaml(
191            r#"
192contract: contract-1
193release-units:
194  library:
195    path: .
196    projections:
197      - { adapter: npm, file: package.json, mode: committed }
198    tags:
199      primary: { role: primary, template: '{id}@{version}' }
200"#,
201        )
202        .expect("valid config")
203    }
204
205    #[test]
206    fn plans_direct_frontmatter_mapping() {
207        let draft = IntentDraft {
208            release_units: BTreeMap::from([("library".to_owned(), Bump::Minor)]),
209            message: "Add a useful capability.".to_owned(),
210        };
211        let write = draft.plan(Path::new("."), &config()).expect("planned");
212        assert!(write.path.to_string_lossy().starts_with(INTENTS_PATH));
213        assert!(write.contents.starts_with("---\nlibrary: minor\n---"));
214        assert!(write.contents.contains("Add a useful capability."));
215    }
216
217    #[test]
218    fn rejects_unknown_release_unit() {
219        let draft = IntentDraft {
220            release_units: BTreeMap::from([("missing".to_owned(), Bump::Patch)]),
221            message: "Correct a defect.".to_owned(),
222        };
223        let error = draft
224            .plan(Path::new("."), &config())
225            .expect_err("unknown release unit rejected");
226        assert!(error.to_string().contains("unknown release unit missing"));
227    }
228}