omni_dev/data/
amendments.rs1use std::fs;
4use std::path::Path;
5
6use anyhow::{Context, Result};
7use schemars::JsonSchema;
8use serde::{Deserialize, Serialize};
9
10#[derive(Debug, Serialize, Deserialize, JsonSchema)]
12#[schemars(deny_unknown_fields)]
13pub struct AmendmentFile {
14 pub amendments: Vec<Amendment>,
16}
17
18#[derive(Debug, Serialize, Deserialize, JsonSchema)]
26#[schemars(deny_unknown_fields)]
27#[schemars(extend("required" = ["commit", "message", "summary"]))]
28pub struct Amendment {
29 pub commit: String,
31 pub message: String,
33 #[serde(default)]
36 pub summary: String,
37}
38
39impl AmendmentFile {
40 pub fn load_from_file<P: AsRef<Path>>(path: P) -> Result<Self> {
42 let content = fs::read_to_string(&path).with_context(|| {
43 format!("Failed to read amendment file: {}", path.as_ref().display())
44 })?;
45
46 Self::from_yaml_str(&content)
47 }
48
49 pub fn from_yaml_str(content: &str) -> Result<Self> {
55 let amendment_file: Self =
56 crate::data::from_yaml(content).context("Failed to parse YAML amendment file")?;
57
58 amendment_file.validate()?;
59
60 Ok(amendment_file)
61 }
62
63 pub fn validate(&self) -> Result<()> {
65 for (i, amendment) in self.amendments.iter().enumerate() {
67 amendment
68 .validate()
69 .with_context(|| format!("Invalid amendment at index {i}"))?;
70 }
71
72 Ok(())
73 }
74
75 pub fn save_to_file<P: AsRef<Path>>(&self, path: P) -> Result<()> {
77 let yaml_content =
78 serde_yaml::to_string(self).context("Failed to serialize amendments to YAML")?;
79
80 let formatted_yaml = self.format_multiline_yaml(&yaml_content);
82
83 fs::write(&path, formatted_yaml).with_context(|| {
84 format!(
85 "Failed to write amendment file: {}",
86 path.as_ref().display()
87 )
88 })?;
89
90 Ok(())
91 }
92
93 fn format_multiline_yaml(&self, yaml: &str) -> String {
95 let mut result = String::new();
96 let lines: Vec<&str> = yaml.lines().collect();
97 let mut i = 0;
98
99 while i < lines.len() {
100 let line = lines[i];
101
102 if line.trim_start().starts_with("message:") && line.contains('"') {
104 let indent = line.len() - line.trim_start().len();
105 let indent_str = " ".repeat(indent);
106
107 if let Some(start_quote) = line.find('"') {
109 if let Some(end_quote) = line.rfind('"') {
110 if start_quote != end_quote {
111 let quoted_content = &line[start_quote + 1..end_quote];
112
113 if quoted_content.contains("\\n") {
115 result.push_str(&format!("{indent_str}message: |\n"));
117
118 let unescaped = quoted_content.replace("\\n", "\n");
120 for (line_idx, content_line) in unescaped.lines().enumerate() {
121 if line_idx == 0 && content_line.trim().is_empty() {
122 continue;
124 }
125 result.push_str(&format!("{indent_str} {content_line}\n"));
126 }
127 i += 1;
128 continue;
129 }
130 }
131 }
132 }
133 }
134
135 result.push_str(line);
137 result.push('\n');
138 i += 1;
139 }
140
141 result
142 }
143}
144
145impl Amendment {
146 pub fn new(commit: String, message: String) -> Self {
148 Self {
149 commit,
150 message,
151 summary: String::new(),
152 }
153 }
154
155 pub fn validate(&self) -> Result<()> {
157 if self.commit.len() != crate::git::FULL_HASH_LEN {
159 anyhow::bail!(
160 "Commit hash must be exactly {} characters long, got: {}",
161 crate::git::FULL_HASH_LEN,
162 self.commit.len()
163 );
164 }
165
166 if !self.commit.chars().all(|c| c.is_ascii_hexdigit()) {
167 anyhow::bail!("Commit hash must contain only hexadecimal characters");
168 }
169
170 if !self
171 .commit
172 .chars()
173 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
174 {
175 anyhow::bail!("Commit hash must be lowercase");
176 }
177
178 if self.message.trim().is_empty() {
180 anyhow::bail!("Commit message cannot be empty");
181 }
182
183 Ok(())
184 }
185}
186
187#[cfg(test)]
188#[allow(clippy::unwrap_used, clippy::expect_used)]
189mod tests {
190 use super::*;
191 use tempfile::TempDir;
192
193 #[test]
196 fn valid_amendment() {
197 let amendment = Amendment::new("a".repeat(40), "feat: add feature".to_string());
198 assert!(amendment.validate().is_ok());
199 }
200
201 #[test]
202 fn short_hash_rejected() {
203 let amendment = Amendment::new("abc1234".to_string(), "feat: add feature".to_string());
204 let err = amendment.validate().unwrap_err();
205 assert!(err.to_string().contains("exactly"));
206 }
207
208 #[test]
209 fn uppercase_hash_rejected() {
210 let amendment = Amendment::new("A".repeat(40), "feat: add feature".to_string());
211 let err = amendment.validate().unwrap_err();
212 assert!(err.to_string().contains("lowercase"));
213 }
214
215 #[test]
216 fn non_hex_hash_rejected() {
217 let amendment = Amendment::new("g".repeat(40), "feat: add feature".to_string());
218 let err = amendment.validate().unwrap_err();
219 assert!(err.to_string().contains("hexadecimal"));
220 }
221
222 #[test]
223 fn empty_message_rejected() {
224 let amendment = Amendment::new("a".repeat(40), " ".to_string());
225 let err = amendment.validate().unwrap_err();
226 assert!(err.to_string().contains("empty"));
227 }
228
229 #[test]
230 fn valid_hex_digits() {
231 let hash = "0123456789abcdef0123456789abcdef01234567";
233 let amendment = Amendment::new(hash.to_string(), "fix: something".to_string());
234 assert!(amendment.validate().is_ok());
235 }
236
237 #[test]
240 fn validate_empty_amendments_ok() {
241 let file = AmendmentFile { amendments: vec![] };
242 assert!(file.validate().is_ok());
243 }
244
245 #[test]
246 fn validate_propagates_amendment_errors() {
247 let file = AmendmentFile {
248 amendments: vec![Amendment::new("short".to_string(), "msg".to_string())],
249 };
250 let err = file.validate().unwrap_err();
251 assert!(err.to_string().contains("index 0"));
252 }
253
254 #[test]
257 fn save_and_load_roundtrip() -> Result<()> {
258 let dir = {
259 std::fs::create_dir_all("tmp")?;
260 TempDir::new_in("tmp")?
261 };
262 let path = dir.path().join("amendments.yaml");
263
264 let original = AmendmentFile {
265 amendments: vec![
266 Amendment {
267 commit: "a".repeat(40),
268 message: "feat(cli): add new command".to_string(),
269 summary: "Adds the twiddle command".to_string(),
270 },
271 Amendment {
272 commit: "b".repeat(40),
273 message: "fix(git): resolve rebase issue\n\nDetailed body here.".to_string(),
274 summary: String::new(),
275 },
276 ],
277 };
278
279 original.save_to_file(&path)?;
280 let loaded = AmendmentFile::load_from_file(&path)?;
281
282 assert_eq!(loaded.amendments.len(), 2);
283 assert_eq!(loaded.amendments[0].commit, "a".repeat(40));
284 assert_eq!(loaded.amendments[0].message, "feat(cli): add new command");
285 assert_eq!(loaded.amendments[1].commit, "b".repeat(40));
286 assert!(loaded.amendments[1]
287 .message
288 .contains("resolve rebase issue"));
289 Ok(())
290 }
291
292 #[test]
293 fn load_invalid_yaml_fails() -> Result<()> {
294 let dir = {
295 std::fs::create_dir_all("tmp")?;
296 TempDir::new_in("tmp")?
297 };
298 let path = dir.path().join("bad.yaml");
299 fs::write(&path, "not: valid: yaml: [{{")?;
300 assert!(AmendmentFile::load_from_file(&path).is_err());
301 Ok(())
302 }
303
304 #[test]
305 fn load_nonexistent_file_fails() {
306 assert!(AmendmentFile::load_from_file("/nonexistent/path.yaml").is_err());
307 }
308
309 #[test]
312 fn from_yaml_str_parses_and_validates() {
313 let yaml = format!(
314 "amendments:\n - commit: {}\n message: \"feat: inline amend\"\n summary: \"\"\n",
315 "a".repeat(40)
316 );
317 let file = AmendmentFile::from_yaml_str(&yaml).unwrap();
318 assert_eq!(file.amendments.len(), 1);
319 assert_eq!(file.amendments[0].message, "feat: inline amend");
320 }
321
322 #[test]
323 fn from_yaml_str_rejects_invalid_hash() {
324 let yaml = "amendments:\n - commit: short\n message: \"m\"\n summary: \"\"\n";
325 assert!(AmendmentFile::from_yaml_str(yaml).is_err());
326 }
327
328 #[test]
329 fn from_yaml_str_empty_list_ok() {
330 let file = AmendmentFile::from_yaml_str("amendments: []\n").unwrap();
331 assert!(file.amendments.is_empty());
332 }
333
334 mod prop {
337 use super::*;
338 use proptest::prelude::*;
339
340 proptest! {
341 #[test]
342 fn valid_hex_hash_nonempty_msg_validates(
343 hash in "[0-9a-f]{40}",
344 msg in "[a-zA-Z0-9].{0,200}",
345 ) {
346 let amendment = Amendment::new(hash, msg);
347 prop_assert!(amendment.validate().is_ok());
348 }
349
350 #[test]
351 fn wrong_length_hash_rejects(
352 len in (1_usize..80).prop_filter("not 40", |l| *l != 40),
353 ) {
354 let hash: String = "a".repeat(len);
355 let amendment = Amendment::new(hash, "valid message".to_string());
356 prop_assert!(amendment.validate().is_err());
357 }
358
359 #[test]
360 fn non_hex_char_in_hash_rejects(
361 pos in 0_usize..40,
362 bad_idx in 0_usize..20,
363 ) {
364 let bad_chars = "ghijklmnopqrstuvwxyz";
365 let bad_char = bad_chars.as_bytes()[bad_idx % bad_chars.len()] as char;
366 let mut chars: Vec<char> = "a".repeat(40).chars().collect();
367 chars[pos] = bad_char;
368 let hash: String = chars.into_iter().collect();
369 let amendment = Amendment::new(hash, "valid message".to_string());
370 prop_assert!(amendment.validate().is_err());
371 }
372
373 #[test]
374 fn whitespace_only_message_rejects(
375 hash in "[0-9a-f]{40}",
376 ws in "[ \t\n]{1,20}",
377 ) {
378 let amendment = Amendment::new(hash, ws);
379 prop_assert!(amendment.validate().is_err());
380 }
381
382 #[test]
383 fn roundtrip_save_load(
384 count in 1_usize..5,
385 ) {
386 let tmp_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tmp");
387 let dir = { std::fs::create_dir_all(&tmp_root).ok(); tempfile::TempDir::new_in(&tmp_root).unwrap() };
388 let path = dir.path().join("amendments.yaml");
389 let amendments: Vec<Amendment> = (0..count)
390 .map(|i| {
391 let hash = format!("{i:0>40x}");
392 Amendment::new(hash, format!("feat: message {i}"))
393 })
394 .collect();
395 let original = AmendmentFile { amendments };
396 original.save_to_file(&path).unwrap();
397 let loaded = AmendmentFile::load_from_file(&path).unwrap();
398 prop_assert_eq!(loaded.amendments.len(), original.amendments.len());
399 for (orig, load) in original.amendments.iter().zip(loaded.amendments.iter()) {
400 prop_assert_eq!(&orig.commit, &load.commit);
401 prop_assert!(load.message.contains(orig.message.lines().next().unwrap()));
403 }
404 }
405 }
406 }
407}