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}