Skip to main content

aion_context/
export.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Export/Import Module
3//!
4//! Provides export and import functionality for AION files in various formats:
5//! - JSON: Full file metadata export
6//! - YAML: Human-readable configuration export  
7//! - CSV: Audit trail export for spreadsheet analysis
8
9use crate::operations::{show_file_info, FileInfo};
10use crate::{AionError, Result};
11use std::path::Path;
12
13/// Export format options
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum ExportFormat {
16    /// JSON format (full metadata)
17    Json,
18    /// YAML format (human-readable)
19    Yaml,
20    /// CSV format (audit trail only)
21    Csv,
22}
23
24/// Exportable file data structure
25#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
26pub struct ExportData {
27    /// Export format version
28    pub export_version: String,
29    /// Original file path
30    pub source_file: String,
31    /// File metadata
32    pub file_info: ExportFileInfo,
33    /// Version history
34    pub versions: Vec<ExportVersion>,
35    /// Signature information
36    pub signatures: Vec<ExportSignature>,
37}
38
39/// Exported file information
40#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
41pub struct ExportFileInfo {
42    /// File ID (hex string)
43    pub file_id: String,
44    /// Total version count
45    pub version_count: u64,
46    /// Current (latest) version number
47    pub current_version: u64,
48}
49
50/// Exported version entry
51#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
52pub struct ExportVersion {
53    /// Version number
54    pub version: u64,
55    /// Author ID
56    pub author_id: u64,
57    /// ISO 8601 timestamp
58    pub timestamp: String,
59    /// Commit message
60    pub message: String,
61    /// Rules content hash (hex)
62    pub rules_hash: String,
63    /// Parent version hash (hex, null for genesis)
64    pub parent_hash: Option<String>,
65}
66
67/// Exported signature entry
68#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
69pub struct ExportSignature {
70    /// Version number
71    pub version: u64,
72    /// Author ID
73    pub author_id: u64,
74    /// Public key (hex)
75    pub public_key: String,
76    /// Verification status
77    pub verified: bool,
78}
79
80/// Export an AION file to the specified format
81///
82/// # Arguments
83///
84/// * `path` - Path to the AION file
85/// * `format` - Export format (JSON, YAML, CSV)
86///
87/// # Returns
88///
89/// Exported data as a string in the requested format
90pub fn export_file(
91    path: &Path,
92    format: ExportFormat,
93    registry: &crate::key_registry::KeyRegistry,
94) -> Result<String> {
95    let file_info = show_file_info(path, registry)?;
96
97    match format {
98        ExportFormat::Json => export_json(path, &file_info),
99        ExportFormat::Yaml => export_yaml(path, &file_info),
100        ExportFormat::Csv => export_csv(&file_info),
101    }
102}
103
104/// Export to JSON format
105fn export_json(path: &Path, file_info: &FileInfo) -> Result<String> {
106    let export_data = build_export_data(path, file_info);
107    serde_json::to_string_pretty(&export_data).map_err(|e| AionError::InvalidFormat {
108        reason: format!("JSON serialization failed: {e}"),
109    })
110}
111
112/// Export to YAML format
113fn export_yaml(path: &Path, file_info: &FileInfo) -> Result<String> {
114    let export_data = build_export_data(path, file_info);
115    serde_yaml::to_string(&export_data).map_err(|e| AionError::InvalidFormat {
116        reason: format!("YAML serialization failed: {e}"),
117    })
118}
119
120/// Export audit trail to CSV format
121fn export_csv(file_info: &FileInfo) -> Result<String> {
122    let mut output = String::new();
123
124    // Header row
125    output.push_str("version,author_id,timestamp,message,rules_hash,parent_hash\n");
126
127    // Data rows
128    for version in &file_info.versions {
129        let timestamp = format_timestamp_nanos(version.timestamp);
130        let rules_hash = hex::encode(version.rules_hash);
131        let parent_hash = version.parent_hash.map(hex::encode).unwrap_or_default();
132
133        // Escape message for CSV (handle commas and quotes)
134        let escaped_message = escape_csv_field(&version.message);
135
136        output.push_str(&format!(
137            "{},{},{},{},{},{}\n",
138            version.version_number,
139            version.author_id,
140            timestamp,
141            escaped_message,
142            rules_hash,
143            parent_hash
144        ));
145    }
146
147    Ok(output)
148}
149
150/// Build export data structure from file info
151fn build_export_data(path: &Path, file_info: &FileInfo) -> ExportData {
152    ExportData {
153        export_version: "1.0".to_string(),
154        source_file: path.display().to_string(),
155        file_info: ExportFileInfo {
156            file_id: format!("0x{:016x}", file_info.file_id),
157            version_count: file_info.version_count,
158            current_version: file_info.current_version,
159        },
160        versions: file_info
161            .versions
162            .iter()
163            .map(|v| ExportVersion {
164                version: v.version_number,
165                author_id: v.author_id,
166                timestamp: format_timestamp_nanos(v.timestamp),
167                message: v.message.clone(),
168                rules_hash: hex::encode(v.rules_hash),
169                parent_hash: v.parent_hash.map(hex::encode),
170            })
171            .collect(),
172        signatures: file_info
173            .signatures
174            .iter()
175            .map(|s| ExportSignature {
176                version: s.version_number,
177                author_id: s.author_id,
178                public_key: hex::encode(s.public_key),
179                verified: s.verified,
180            })
181            .collect(),
182    }
183}
184
185/// Escape a field for CSV output
186fn escape_csv_field(field: &str) -> String {
187    if field.contains(',') || field.contains('"') || field.contains('\n') {
188        format!("\"{}\"", field.replace('"', "\"\""))
189    } else {
190        field.to_string()
191    }
192}
193
194/// Format nanosecond timestamp to ISO 8601
195fn format_timestamp_nanos(nanos: u64) -> String {
196    let secs = nanos / 1_000_000_000;
197    let days = secs / 86400;
198    let time_of_day = secs % 86400;
199    let hours = time_of_day / 3600;
200    let minutes = (time_of_day % 3600) / 60;
201    let seconds = time_of_day % 60;
202    let (year, month, day) = days_to_ymd(days);
203    format!("{year:04}-{month:02}-{day:02}T{hours:02}:{minutes:02}:{seconds:02}Z")
204}
205
206/// Convert days since epoch to year/month/day
207fn days_to_ymd(days: u64) -> (u64, u64, u64) {
208    let mut remaining_days = days as i64;
209    let mut year = 1970u64;
210    loop {
211        let days_in_year = if is_leap_year(year) { 366 } else { 365 };
212        if remaining_days < days_in_year {
213            break;
214        }
215        remaining_days = remaining_days.saturating_sub(days_in_year);
216        year = year.saturating_add(1);
217    }
218    let days_in_months: [i64; 12] = if is_leap_year(year) {
219        [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
220    } else {
221        [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
222    };
223    let mut month = 1u64;
224    for days_in_month in days_in_months {
225        if remaining_days < days_in_month {
226            break;
227        }
228        remaining_days = remaining_days.saturating_sub(days_in_month);
229        month = month.saturating_add(1);
230    }
231    let day = (remaining_days as u64).saturating_add(1);
232    (year, month, day)
233}
234
235const fn is_leap_year(year: u64) -> bool {
236    (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
237}
238
239// ============================================================================
240// Import functionality
241// ============================================================================
242
243/// Import data from JSON format
244///
245/// Note: This imports metadata only. The actual AION file must be
246/// recreated using the init/commit operations with the original rules.
247pub fn import_json(json_data: &str) -> Result<ExportData> {
248    serde_json::from_str(json_data).map_err(|e| AionError::InvalidFormat {
249        reason: format!("JSON parse failed: {e}"),
250    })
251}
252
253/// Import data from YAML format
254pub fn import_yaml(yaml_data: &str) -> Result<ExportData> {
255    serde_yaml::from_str(yaml_data).map_err(|e| AionError::InvalidFormat {
256        reason: format!("YAML parse failed: {e}"),
257    })
258}
259
260#[cfg(test)]
261mod tests {
262    use super::*;
263
264    #[test]
265    fn test_escape_csv_field_simple() {
266        assert_eq!(escape_csv_field("hello"), "hello");
267    }
268
269    #[test]
270    fn test_escape_csv_field_with_comma() {
271        assert_eq!(escape_csv_field("hello, world"), "\"hello, world\"");
272    }
273
274    #[test]
275    fn test_escape_csv_field_with_quotes() {
276        assert_eq!(escape_csv_field("say \"hi\""), "\"say \"\"hi\"\"\"");
277    }
278
279    #[test]
280    fn test_format_timestamp() {
281        // 2024-01-01 00:00:00 UTC
282        let ts = 1704067200_000_000_000u64;
283        let formatted = format_timestamp_nanos(ts);
284        assert!(formatted.starts_with("2024-01-01"));
285    }
286}