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 schemars::JsonSchema;
8use serde::{Deserialize, Serialize};
9
10/// Amendment file structure.
11#[derive(Debug, Serialize, Deserialize, JsonSchema)]
12#[schemars(deny_unknown_fields)]
13pub struct AmendmentFile {
14    /// List of commit amendments to apply.
15    pub amendments: Vec<Amendment>,
16}
17
18/// Individual commit amendment.
19#[derive(Debug, Serialize, Deserialize, JsonSchema)]
20#[schemars(deny_unknown_fields)]
21pub struct Amendment {
22    /// Full 40-character SHA-1 commit hash.
23    pub commit: String,
24    /// New commit message.
25    pub message: String,
26    /// Brief summary of what this commit changes (for cross-commit coherence).
27    #[serde(default, skip_serializing_if = "Option::is_none")]
28    pub summary: Option<String>,
29}
30
31impl AmendmentFile {
32    /// Loads amendments from a YAML file.
33    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    /// Validates amendment file structure and content.
47    pub fn validate(&self) -> Result<()> {
48        // Empty amendments are allowed - they indicate no changes are needed
49        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    /// Saves amendments to a YAML file with proper multiline formatting.
59    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        // Post-process YAML to use literal block scalars for multiline messages
64        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    /// Formats YAML to use literal block scalars for multiline messages.
77    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            // Check if this is a message field with a quoted multiline string
86            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                // Extract the quoted content
91                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                            // Check if it contains newlines (multiline content)
97                            if quoted_content.contains("\\n") {
98                                // Convert to literal block scalar format
99                                result.push_str(&format!("{indent_str}message: |\n"));
100
101                                // Process the content, converting \n to actual newlines
102                                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                                        // Skip leading empty line
106                                        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            // Default: just copy the line as-is
119            result.push_str(line);
120            result.push('\n');
121            i += 1;
122        }
123
124        result
125    }
126}
127
128impl Amendment {
129    /// Creates a new amendment.
130    pub fn new(commit: String, message: String) -> Self {
131        Self {
132            commit,
133            message,
134            summary: None,
135        }
136    }
137
138    /// Validates amendment structure.
139    pub fn validate(&self) -> Result<()> {
140        // Validate commit hash format
141        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        // Validate message content
162        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    // ── Amendment::validate ──────────────────────────────────────────
177
178    #[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        // All valid hex chars: 0-9, a-f
215        let hash = "0123456789abcdef0123456789abcdef01234567";
216        let amendment = Amendment::new(hash.to_string(), "fix: something".to_string());
217        assert!(amendment.validate().is_ok());
218    }
219
220    // ── AmendmentFile::validate ──────────────────────────────────────
221
222    #[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    // ── AmendmentFile round-trip ─────────────────────────────────────
238
239    #[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    // ── property tests ────────────────────────────────────────────
293
294    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                    // Messages may differ slightly due to YAML block scalar formatting
360                    prop_assert!(load.message.contains(orig.message.lines().next().unwrap()));
361                }
362            }
363        }
364    }
365}