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///
20/// `summary` is force-included in `required` via the
21/// `#[schemars(extend(...))]` attribute even though `#[serde(default)]`
22/// would normally exclude it: OpenAI's strict-subset rule requires every
23/// property in `properties` to appear in `required`, while we still want
24/// graceful YAML loading for files written before the field existed.
25#[derive(Debug, Serialize, Deserialize, JsonSchema)]
26#[schemars(deny_unknown_fields)]
27#[schemars(extend("required" = ["commit", "message", "summary"]))]
28pub struct Amendment {
29    /// Full 40-character SHA-1 commit hash.
30    pub commit: String,
31    /// New commit message.
32    pub message: String,
33    /// Brief summary of what this commit changes (for cross-commit coherence).
34    /// Empty string when the model has nothing to add.
35    #[serde(default)]
36    pub summary: String,
37}
38
39impl AmendmentFile {
40    /// Loads amendments from a YAML file.
41    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    /// Parses and validates amendments from an in-memory YAML string.
50    ///
51    /// Shared by [`Self::load_from_file`] (which reads the file first) and the
52    /// MCP `git_amend_commits` tool, which receives the amendments YAML inline
53    /// rather than as a path on disk.
54    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    /// Validates amendment file structure and content.
64    pub fn validate(&self) -> Result<()> {
65        // Empty amendments are allowed - they indicate no changes are needed
66        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    /// Saves amendments to a YAML file with proper multiline formatting.
76    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        // Post-process YAML to use literal block scalars for multiline messages
81        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    /// Formats YAML to use literal block scalars for multiline messages.
94    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            // Check if this is a message field with a quoted multiline string
103            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                // Extract the quoted content
108                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                            // Check if it contains newlines (multiline content)
114                            if quoted_content.contains("\\n") {
115                                // Convert to literal block scalar format
116                                result.push_str(&format!("{indent_str}message: |\n"));
117
118                                // Process the content, converting \n to actual newlines
119                                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                                        // Skip leading empty line
123                                        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            // Default: just copy the line as-is
136            result.push_str(line);
137            result.push('\n');
138            i += 1;
139        }
140
141        result
142    }
143}
144
145impl Amendment {
146    /// Creates a new amendment.
147    pub fn new(commit: String, message: String) -> Self {
148        Self {
149            commit,
150            message,
151            summary: String::new(),
152        }
153    }
154
155    /// Validates amendment structure.
156    pub fn validate(&self) -> Result<()> {
157        // Validate commit hash format
158        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        // Validate message content
179        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    // ── Amendment::validate ──────────────────────────────────────────
194
195    #[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        // All valid hex chars: 0-9, a-f
232        let hash = "0123456789abcdef0123456789abcdef01234567";
233        let amendment = Amendment::new(hash.to_string(), "fix: something".to_string());
234        assert!(amendment.validate().is_ok());
235    }
236
237    // ── AmendmentFile::validate ──────────────────────────────────────
238
239    #[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    // ── AmendmentFile round-trip ─────────────────────────────────────
255
256    #[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    // ── AmendmentFile::from_yaml_str ─────────────────────────────────
310
311    #[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    // ── property tests ────────────────────────────────────────────
335
336    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                    // Messages may differ slightly due to YAML block scalar formatting
402                    prop_assert!(load.message.contains(orig.message.lines().next().unwrap()));
403                }
404            }
405        }
406    }
407}