aimx/aim/
journal.rs

1//! Version journaling and persistence for AIM files
2//!
3//! This module provides functionality for managing journal files that track
4//! version history in AIM workflows. Journal files (`.jnl`) map version numbers
5//! to byte offsets in corresponding AIM files, enabling efficient random access
6//! to specific versions without parsing the entire file.
7//!
8//! # Journal File Format
9//!
10//! Journal files store entries in the format `version,position` with one entry
11//! per line:
12//!
13//! ```text
14//! 1,0
15//! 2,150
16//! 3,320
17//! ```
18//!
19//! # Workflow
20//!
21//! 1. When an AIM file is modified, version headers are parsed to create journal entries
22//! 2. Journal entries are saved to a corresponding `.jnl` file
23//! 3. When reading specific versions, the journal is used to seek directly to the offset
24//!
25//! # Examples
26//!
27//! ```no_run
28//! use std::path::Path;
29//! use aimx::aim::{Journal, journal_file, journal_load};
30//!
31//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
32//! let mut journals = Vec::new();
33//! let path = Path::new("workflow.aim");
34//!
35//! // Create or update journal file
36//! journal_file(&mut journals, path)?;
37//!
38//! // Load existing journal entries
39//! journal_load(&mut journals, path)?;
40//! # Ok(())
41//! # }
42//! ```
43
44use anyhow::{anyhow, Result};
45use std::{
46    fs::File,
47    io::{BufRead, BufReader, Read, Write},
48    path::{Path, PathBuf},
49};
50use crate::aim::parse_version;
51use nom::{
52    IResult, Parser,
53    bytes::complete::tag,
54    character::complete::digit1,
55    combinator::map_res,
56    sequence::separated_pair,
57};
58
59/// Represents a journal entry with version and file position.
60///
61/// A journal entry maps a specific version of an AIM file to its byte offset position
62/// within that file. This enables efficient random access to specific versions without
63/// having to parse the entire file.
64///
65/// # Examples
66///
67/// ```
68/// use aimx::aim::Journal;
69///
70/// let journal = Journal::new(1, 100);
71/// assert_eq!(journal.version(), 1);
72/// assert_eq!(journal.position(), 100);
73/// ```
74#[derive(Debug, PartialEq, Copy, Clone)]
75pub struct Journal {
76    version: u32,
77    position: u64,
78}
79
80impl Journal {
81    /// Creates a new journal entry with the specified version and position.
82    ///
83    /// # Arguments
84    ///
85    /// * `version` - The version number this entry represents
86    /// * `position` - The byte offset position in the file where this version starts
87    ///
88    /// # Returns
89    ///
90    /// A new `Journal` instance.
91    ///
92    /// # Examples
93    ///
94    /// ```
95    /// use aimx::aim::Journal;
96    ///
97    /// let journal = Journal::new(2, 150);
98    /// assert_eq!(journal.version(), 2);
99    /// assert_eq!(journal.position(), 150);
100    /// ```
101    pub fn new(version: u32, position: u64) -> Self {
102        Self {
103            version,
104            position
105        }
106    }
107
108    /// Returns the version number of this journal entry.
109    ///
110    /// # Returns
111    ///
112    /// The version number as a `u32`.
113    ///
114    /// # Examples
115    ///
116    /// ```
117    /// use aimx::aim::Journal;
118    ///
119    /// let journal = Journal::new(3, 200);
120    /// assert_eq!(journal.version(), 3);
121    /// ```
122    pub fn version(&self) -> u32 {
123        self.version
124    }
125
126    /// Returns the byte position of this journal entry.
127    ///
128    /// # Returns
129    ///
130    /// The byte offset position as a `u64`.
131    ///
132    /// # Examples
133    ///
134    /// ```
135    /// use aimx::aim::Journal;
136    ///
137    /// let journal = Journal::new(1, 50);
138    /// assert_eq!(journal.position(), 50);
139    /// ```
140    pub fn position(&self) -> u64 {
141        self.position
142    }
143}
144
145/// Journals version headers in an .aim file and saves the journal entries to a .jnl file.
146///
147/// This function scans through an AIM file, identifies version headers (lines starting with '['),
148/// and creates journal entries for complete versions (those without partial components).
149/// The resulting journal entries are saved to a corresponding .jnl file.
150///
151/// # Arguments
152///
153/// * `journals` - Vector to store the parsed journal entries
154/// * `aim_path` - Path to the .aim file to journal
155///
156/// # Returns
157///
158/// * `Ok(())` on successful journal creation
159/// * `Err` if the file cannot be read or parsed
160///
161/// # Examples
162///
163/// ```no_run
164/// use std::path::Path;
165/// use aimx::aim::{Journal, journal_file};
166///
167/// let mut journals = Vec::new();
168/// let result = journal_file(&mut journals, Path::new("workflow.aim"));
169/// ```
170pub fn journal_file(journals: &mut Vec<Journal>, aim_path: &Path) -> Result<()> {
171    // Check if file exists
172    if !aim_path.exists() {
173        return Err(anyhow!("Invalid filename: {}", aim_path.to_string_lossy()));
174    }
175
176    // Open file for reading
177    let file = File::open(aim_path)?;
178
179    // Clear previous journal entries
180    journals.clear();
181    
182    // Create buffered reader
183    let mut reader = BufReader::new(file);
184    
185    // Track position manually since BufReader doesn't provide seek position easily
186    let mut position: u64 = 0;
187    let mut line = String::new();
188    
189    // Iterate through lines
190    loop {
191        line.clear();
192        //position = reader.stream_position()?;
193        let bytes_read = reader.read_line(&mut line)?;
194        
195        // Break if we reached EOF
196        if bytes_read == 0 {
197            break;
198        }
199        
200        // Check if line starts with '[' (after trimming whitespace)
201        let trimmed_line = line.trim_start();
202        if trimmed_line.starts_with('[') {
203            // Try to parse as version header
204            match parse_version(trimmed_line) {
205                Ok(version) => {
206                    // Only record if partial is 0 (e.g., [123] not [123:1])
207                    if version.partial() == 0 {
208                        journals.push(Journal {
209                            version: version.epoc(),
210                            position: position,
211                        });
212                    }
213                }
214                // Skip invalid version headers
215                Err(_) => {}
216            }
217        }
218        
219        // Update position for next line
220        position += bytes_read as u64;
221    }
222    
223    // Save the journal to file
224    journal_save(aim_path, journals)?;
225    
226    Ok(())
227}
228
229/// Saves the journal vector to a .jnl file for the corresponding .aim file.
230///
231/// This function takes a vector of journal entries and writes them to a .jnl file
232/// that corresponds to the provided .aim file path. Each entry is written as
233/// "version,position" on a separate line.
234///
235/// # Arguments
236///
237/// * `aim_path` - Path to the .aim file (used to derive the .jnl filename)
238/// * `journals` - Vector of journal entries to save
239///
240/// # Returns
241///
242/// * `Ok(())` on successful save
243/// * `Err` if the file cannot be created or written to
244///
245/// # Examples
246///
247/// ```no_run
248/// use std::path::Path;
249/// use aimx::aim::{Journal, journal_save};
250///
251/// let journals = vec![Journal::new(1, 0), Journal::new(2, 100)];
252/// let result = journal_save(Path::new("workflow.aim"), &journals);
253/// ```
254pub fn journal_save(aim_path: &Path, journals: &Vec<Journal>) -> Result<()> {
255    let jnl_path = to_journal_path(aim_path)?;
256
257    // Create a new file or truncate an existing one
258    let mut file = File::create(&jnl_path)?;
259    
260    for journal in journals {
261        writeln!(file, "{},{}", journal.version, journal.position)?;
262    }
263    
264    Ok(())
265}
266
267/// Converts an .aim file path to the corresponding .jnl file path.
268///
269/// This function takes an AIM file path and returns the corresponding journal file path
270/// by replacing the .aim extension with .jnl.
271///
272/// # Arguments
273///
274/// * `aim_path` - Path to the .aim file
275///
276/// # Returns
277///
278/// * `Ok(PathBuf)` with the corresponding .jnl file path
279/// * `Err` if the file path is invalid
280///
281/// # Examples
282///
283/// ```
284/// use std::path::Path;
285/// use aimx::aim::to_journal_path;
286///
287/// let jnl_path = to_journal_path(Path::new("examples/workflow.aim")).unwrap();
288/// assert_eq!(jnl_path, Path::new("examples/workflow.jnl"));
289/// ```
290pub fn to_journal_path(aim_path: &Path) -> Result<PathBuf> {
291    let stem = aim_path.file_stem().ok_or_else(||
292        anyhow!("Invalid filename: {}", aim_path.to_string_lossy()))?;
293    let parent = aim_path.parent().unwrap_or_else(|| Path::new(""));
294    
295    let mut jnl_path = parent.to_path_buf();
296    jnl_path.push(format!("{}.jnl", stem.to_string_lossy()));
297    
298    Ok(jnl_path)
299}
300
301/// Parse an unsigned 32-bit integer.
302///
303/// This is a helper function for parsing journal entries.
304fn parse_u32(input: &str) -> IResult<&str, u32> {
305    map_res(digit1, |s: &str| s.parse::<u32>()).parse(input)
306}
307
308/// Parse an unsigned 64-bit integer.
309///
310/// This is a helper function for parsing journal entries.
311fn parse_u64(input: &str) -> IResult<&str, u64> {
312    map_res(digit1, |s: &str| s.parse::<u64>()).parse(input)
313}
314
315/// Parse a single journal entry (version,position).
316///
317/// This is a helper function that parses a single line from a .jnl file
318/// in the format "version,position" and returns a Journal struct.
319fn parse_journal_entry(input: &str) -> IResult<&str, Journal> {
320    let (input, (version, position)) = separated_pair(
321        parse_u32,
322        tag(","),
323        parse_u64,
324    ).parse(input)?;
325    
326    Ok((input, Journal { version, position }))
327}
328
329/// Parse an entire .jnl file's contents.
330///
331/// This function parses the contents of a journal file, where each line
332/// represents a journal entry in the format "version,position".
333///
334/// # Arguments
335///
336/// * `journals` - Vector to store the parsed journal entries
337/// * `input` - String content of the .jnl file
338///
339/// # Returns
340///
341/// * `Ok(())` on successful parsing
342/// * `Err` if there's a parse error
343fn parse_journal(journals: &mut Vec<Journal>, input: &str) -> Result<()> {
344    let lines: Vec<&str> = input.lines().collect();
345    // Clear previous journal entries
346    journals.clear();
347    for line in lines {
348        match parse_journal_entry(line) {
349            Ok((_, journal)) => journals.push(journal),
350            Err(nom::Err::Error(e)) |
351            Err(nom::Err::Failure(e)) => return Err(anyhow!("Parse error: {}", e)),
352            Err(nom::Err::Incomplete(_)) => return Err(anyhow!("Incomplete input")),
353        }
354    }
355    Ok(())
356}
357
358/// Loads journal entries from a .jnl file.
359///
360/// This function loads journal entries from a .jnl file. If the .jnl file doesn't
361/// exist, it will attempt to create it by scanning the corresponding .aim file.
362///
363/// # Arguments
364///
365/// * `journals` - Vector to store the loaded journal entries
366/// * `aim_path` - Path to the .aim file to load the journal for
367///
368/// # Returns
369///
370/// * `Ok(())` on successful load
371/// * `Err` if the file cannot be read or parsed
372///
373/// # Examples
374///
375/// ```no_run
376/// use std::path::Path;
377/// use aimx::aim::{Journal, journal_load};
378///
379/// let mut journals = Vec::new();
380/// let result = journal_load(&mut journals, Path::new("workflow.aim"));
381/// ```
382pub fn journal_load(journals: &mut Vec<Journal>, aim_path: &Path) -> Result<()> {
383    let jnl_path = to_journal_path(aim_path)?;
384    // Check if file exists
385    if !Path::new(&jnl_path).exists() {
386        return journal_file(journals, aim_path);
387    }
388
389    // Read the entire file
390    let mut file = File::open(jnl_path)?;
391    let mut contents = String::new();
392    file.read_to_string(&mut contents)?;
393    
394    // Parse the contents
395    parse_journal(journals, &contents)
396}