1use 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
17pub 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#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct Intent {
30 pub id: String,
32 pub release_units: BTreeMap<String, Bump>,
34 pub graduates: BTreeSet<String>,
36 pub message: String,
38 pub path: PathBuf,
40}
41
42#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct IntentDraft {
45 pub release_units: BTreeMap<String, Bump>,
47 pub graduates: BTreeSet<String>,
49 pub message: String,
51}
52
53#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct IntentWrite {
56 pub path: PathBuf,
58 pub contents: String,
60}
61
62impl Intent {
63 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 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 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 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 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
150pub(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
182fn 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}