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: AmendmentFile =
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!("{}message: |\n", indent_str));
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!("{}  {}\n", indent_str, content_line));
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}