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