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