Skip to main content

omni_dev/data/
amendments.rs

1//! Amendment data structures and validation
2
3use anyhow::{Context, Result};
4use serde::{Deserialize, Serialize};
5use std::fs;
6use std::path::Path;
7
8/// Amendment file structure
9#[derive(Debug, Serialize, Deserialize)]
10pub struct AmendmentFile {
11    /// List of commit amendments to apply
12    pub amendments: Vec<Amendment>,
13}
14
15/// Individual commit amendment
16#[derive(Debug, Serialize, Deserialize)]
17pub struct Amendment {
18    /// Full 40-character SHA-1 commit hash
19    pub commit: String,
20    /// New commit message
21    pub message: String,
22}
23
24impl AmendmentFile {
25    /// Load amendments from YAML file
26    pub fn load_from_file<P: AsRef<Path>>(path: P) -> Result<Self> {
27        let content = fs::read_to_string(&path).with_context(|| {
28            format!("Failed to read amendment file: {}", path.as_ref().display())
29        })?;
30
31        let amendment_file: AmendmentFile =
32            crate::data::from_yaml(&content).context("Failed to parse YAML amendment file")?;
33
34        amendment_file.validate()?;
35
36        Ok(amendment_file)
37    }
38
39    /// Validate amendment file structure and content
40    pub fn validate(&self) -> Result<()> {
41        // Empty amendments are allowed - they indicate no changes are needed
42        for (i, amendment) in self.amendments.iter().enumerate() {
43            amendment
44                .validate()
45                .with_context(|| format!("Invalid amendment at index {}", i))?;
46        }
47
48        Ok(())
49    }
50
51    /// Save amendments to YAML file with proper multiline formatting
52    pub fn save_to_file<P: AsRef<Path>>(&self, path: P) -> Result<()> {
53        let yaml_content =
54            serde_yaml::to_string(self).context("Failed to serialize amendments to YAML")?;
55
56        // Post-process YAML to use literal block scalars for multiline messages
57        let formatted_yaml = self.format_multiline_yaml(&yaml_content);
58
59        fs::write(&path, formatted_yaml).with_context(|| {
60            format!(
61                "Failed to write amendment file: {}",
62                path.as_ref().display()
63            )
64        })?;
65
66        Ok(())
67    }
68
69    /// Format YAML to use literal block scalars for multiline messages
70    fn format_multiline_yaml(&self, yaml: &str) -> String {
71        let mut result = String::new();
72        let lines: Vec<&str> = yaml.lines().collect();
73        let mut i = 0;
74
75        while i < lines.len() {
76            let line = lines[i];
77
78            // Check if this is a message field with a quoted multiline string
79            if line.trim_start().starts_with("message:") && line.contains('"') {
80                let indent = line.len() - line.trim_start().len();
81                let indent_str = " ".repeat(indent);
82
83                // Extract the quoted content
84                if let Some(start_quote) = line.find('"') {
85                    if let Some(end_quote) = line.rfind('"') {
86                        if start_quote != end_quote {
87                            let quoted_content = &line[start_quote + 1..end_quote];
88
89                            // Check if it contains newlines (multiline content)
90                            if quoted_content.contains("\\n") {
91                                // Convert to literal block scalar format
92                                result.push_str(&format!("{}message: |\n", indent_str));
93
94                                // Process the content, converting \n to actual newlines
95                                let unescaped = quoted_content.replace("\\n", "\n");
96                                for (line_idx, content_line) in unescaped.lines().enumerate() {
97                                    if line_idx == 0 && content_line.trim().is_empty() {
98                                        // Skip leading empty line
99                                        continue;
100                                    }
101                                    result.push_str(&format!("{}  {}\n", indent_str, content_line));
102                                }
103                                i += 1;
104                                continue;
105                            }
106                        }
107                    }
108                }
109            }
110
111            // Default: just copy the line as-is
112            result.push_str(line);
113            result.push('\n');
114            i += 1;
115        }
116
117        result
118    }
119}
120
121impl Amendment {
122    /// Create a new amendment
123    pub fn new(commit: String, message: String) -> Self {
124        Self { commit, message }
125    }
126
127    /// Validate amendment structure
128    pub fn validate(&self) -> Result<()> {
129        // Validate commit hash format
130        if self.commit.len() != 40 {
131            anyhow::bail!(
132                "Commit hash must be exactly 40 characters long, got: {}",
133                self.commit.len()
134            );
135        }
136
137        if !self.commit.chars().all(|c| c.is_ascii_hexdigit()) {
138            anyhow::bail!("Commit hash must contain only hexadecimal characters");
139        }
140
141        if !self
142            .commit
143            .chars()
144            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
145        {
146            anyhow::bail!("Commit hash must be lowercase");
147        }
148
149        // Validate message content
150        if self.message.trim().is_empty() {
151            anyhow::bail!("Commit message cannot be empty");
152        }
153
154        Ok(())
155    }
156}