Skip to main content

omni_dev/data/
amendments.rs

1//! Amendment data structures and validation.
2
3use std::fs;
4use std::path::Path;
5
6use anyhow::{Context, Result};
7use serde::{Deserialize, Serialize};
8
9/// Amendment file structure.
10#[derive(Debug, Serialize, Deserialize)]
11pub struct AmendmentFile {
12    /// List of commit amendments to apply.
13    pub amendments: Vec<Amendment>,
14}
15
16/// Individual commit amendment.
17#[derive(Debug, Serialize, Deserialize)]
18pub struct Amendment {
19    /// Full 40-character SHA-1 commit hash.
20    pub commit: String,
21    /// New commit message.
22    pub message: String,
23    /// Brief summary of what this commit changes (for cross-commit coherence).
24    #[serde(default, skip_serializing_if = "Option::is_none")]
25    pub summary: Option<String>,
26}
27
28impl AmendmentFile {
29    /// Loads amendments from a YAML file.
30    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    /// Validates amendment file structure and content.
44    pub fn validate(&self) -> Result<()> {
45        // Empty amendments are allowed - they indicate no changes are needed
46        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    /// Saves amendments to a YAML file with proper multiline formatting.
56    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        // Post-process YAML to use literal block scalars for multiline messages
61        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    /// Formats YAML to use literal block scalars for multiline messages.
74    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            // Check if this is a message field with a quoted multiline string
83            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                // Extract the quoted content
88                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                            // Check if it contains newlines (multiline content)
94                            if quoted_content.contains("\\n") {
95                                // Convert to literal block scalar format
96                                result.push_str(&format!("{indent_str}message: |\n"));
97
98                                // Process the content, converting \n to actual newlines
99                                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                                        // Skip leading empty line
103                                        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            // Default: just copy the line as-is
116            result.push_str(line);
117            result.push('\n');
118            i += 1;
119        }
120
121        result
122    }
123}
124
125impl Amendment {
126    /// Creates a new amendment.
127    pub fn new(commit: String, message: String) -> Self {
128        Self {
129            commit,
130            message,
131            summary: None,
132        }
133    }
134
135    /// Validates amendment structure.
136    pub fn validate(&self) -> Result<()> {
137        // Validate commit hash format
138        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        // Validate message content
159        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    // ── Amendment::validate ──────────────────────────────────────────
174
175    #[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        // All valid hex chars: 0-9, a-f
212        let hash = "0123456789abcdef0123456789abcdef01234567";
213        let amendment = Amendment::new(hash.to_string(), "fix: something".to_string());
214        assert!(amendment.validate().is_ok());
215    }
216
217    // ── AmendmentFile::validate ──────────────────────────────────────
218
219    #[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    // ── AmendmentFile round-trip ─────────────────────────────────────
235
236    #[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    // ── property tests ────────────────────────────────────────────
290
291    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                    // Messages may differ slightly due to YAML block scalar formatting
356                    prop_assert!(load.message.contains(orig.message.lines().next().unwrap()));
357                }
358            }
359        }
360    }
361}