1use crate::config::Config;
9use crate::error::{Error, Result};
10use crate::model::Bump;
11use rand::seq::SliceRandom;
12use rand::Rng;
13use std::collections::BTreeMap;
14use std::path::{Path, PathBuf};
15
16pub 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#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct Intent {
29 pub id: String,
31 pub packages: BTreeMap<String, Bump>,
33 pub message: String,
35 pub path: PathBuf,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct IntentDraft {
42 pub packages: BTreeMap<String, Bump>,
44 pub message: String,
46}
47
48#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct IntentWrite {
51 pub path: PathBuf,
53 pub contents: String,
55}
56
57impl Intent {
58 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 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 let (frontmatter, message) = split_frontmatter(&text)?;
81 let packages: BTreeMap<String, Bump> = serde_yaml::from_str(frontmatter)?;
82 validate_draft(&packages, message, config)?;
83 let id = path
84 .file_stem()
85 .and_then(|stem| stem.to_str())
86 .ok_or_else(|| {
87 Error::Validation(format!("invalid intent filename {}", path.display()))
88 })?
89 .to_owned();
90 Ok(Self {
91 id,
92 packages,
93 message: message.trim().to_owned(),
94 path: path.to_owned(),
95 })
96 }
97}
98
99impl IntentDraft {
100 pub fn plan(self, root: &Path, config: &Config) -> Result<IntentWrite> {
102 validate_draft(&self.packages, &self.message, config)?;
103 let mut rng = rand::thread_rng();
104 let adjective = ADJECTIVES
105 .choose(&mut rng)
106 .expect("adjectives are non-empty");
107 let noun = NOUNS.choose(&mut rng).expect("nouns are non-empty");
108 let suffix: u32 = rng.gen_range(0..=0xffff);
109 let slug = format!("{adjective}-{noun}-{suffix:04x}");
110 let relative = PathBuf::from(INTENTS_PATH).join(format!("{slug}.md"));
111 let path = root.join(&relative);
112 if path.exists() {
113 return Err(Error::Validation(format!(
114 "generated intent path already exists: {}",
115 relative.display()
116 )));
117 }
118 let yaml = serde_yaml::to_string(&self.packages)?;
119 let contents = format!("---\n{yaml}---\n\n{}\n", self.message.trim());
120 Ok(IntentWrite {
121 path: relative,
122 contents,
123 })
124 }
125}
126
127impl IntentWrite {
128 pub fn apply(&self, root: &Path, dry_run: bool) -> Result<()> {
130 if dry_run {
131 return Ok(());
132 }
133 let path = root.join(&self.path);
134 let parent = path.parent().expect("intent path has a parent");
135 std::fs::create_dir_all(parent).map_err(|error| Error::io(parent, error))?;
136 std::fs::write(&path, &self.contents).map_err(|error| Error::io(&path, error))
137 }
138}
139
140fn validate_draft(packages: &BTreeMap<String, Bump>, message: &str, config: &Config) -> Result<()> {
141 if packages.is_empty() {
142 return Err(Error::Validation(
143 "intent must reference at least one package".to_owned(),
144 ));
145 }
146 if message.trim().is_empty() {
147 return Err(Error::Validation(
148 "intent changelog message must not be empty".to_owned(),
149 ));
150 }
151 for (package, bump) in packages {
152 if !config.packages.contains_key(package) {
153 return Err(Error::Validation(format!(
154 "intent references unknown package {package}"
155 )));
156 }
157 if *bump == Bump::None {
158 return Err(Error::Validation(format!(
159 "intent package {package} must declare major, minor, or patch"
160 )));
161 }
162 }
163 Ok(())
164}
165
166fn split_frontmatter(text: &str) -> Result<(&str, &str)> {
167 let rest = text.strip_prefix("---\n").ok_or_else(|| {
168 Error::Validation("intent must start with YAML frontmatter delimiter".to_owned())
169 })?;
170 let (frontmatter, message) = rest.split_once("\n---\n").ok_or_else(|| {
171 Error::Validation("intent frontmatter is missing its closing delimiter".to_owned())
172 })?;
173 Ok((frontmatter, message))
174}
175
176#[cfg(test)]
177mod tests {
178 use super::*;
179
180 fn config() -> Config {
181 Config::from_yaml(
182 r#"
183packages:
184 library:
185 path: .
186 projections:
187 - { adapter: npm, file: package.json, mode: committed }
188"#,
189 )
190 .expect("valid config")
191 }
192
193 #[test]
194 fn plans_direct_frontmatter_mapping() {
195 let draft = IntentDraft {
196 packages: BTreeMap::from([("library".to_owned(), Bump::Minor)]),
197 message: "Add a useful capability.".to_owned(),
198 };
199 let write = draft.plan(Path::new("."), &config()).expect("planned");
200 assert!(write.path.to_string_lossy().starts_with(INTENTS_PATH));
201 assert!(write.contents.starts_with("---\nlibrary: minor\n---"));
202 assert!(write.contents.contains("Add a useful capability."));
203 }
204
205 #[test]
206 fn rejects_unknown_package() {
207 let draft = IntentDraft {
208 packages: BTreeMap::from([("missing".to_owned(), Bump::Patch)]),
209 message: "Correct a defect.".to_owned(),
210 };
211 let error = draft
212 .plan(Path::new("."), &config())
213 .expect_err("unknown package rejected");
214 assert!(error.to_string().contains("unknown package missing"));
215 }
216}