aimx/aim/
journal.rs

1//! Journal index for AIM version headers.
2//!
3//! Maintains `.jnl` sidecar files that map version headers in `.aim` files to
4//! byte offsets, enabling direct seeks to specific workflow versions without
5//! re-scanning the source.
6
7use anyhow::{Result, anyhow};
8use jiff::civil::DateTime;
9use nom::{
10    IResult, Parser, character::{char, complete::{digit1, multispace1}}, combinator::map_res,
11    sequence::preceded,
12};
13use std::{
14    fmt,
15    fs::File,
16    io::{BufRead, BufReader, Read, Write},
17    path::{Path, PathBuf},
18    sync::Arc,
19};
20use crate::{aim::{Writer, WriterLike, parse_version}, literals::parse_date};
21
22/// Journal entry mapping a version to its byte offset within an `.aim` file.
23#[derive(Debug, PartialEq, Copy, Clone)]
24pub struct Journal {
25    version: u32,
26    position: u64,
27    date: DateTime
28}
29
30impl Journal {
31    /// Create a new entry.
32    ///
33    /// `version` is the workflow version epoch; `position` is the byte offset of its header.
34    pub fn new(version: u32, position: u64, date: DateTime) -> Self {
35        Self { version, position, date }
36    }
37
38    /// Version number for this entry.
39    pub fn version(&self) -> u32 {
40        self.version
41    }
42
43    /// Byte offset for this entry.
44    pub fn position(&self) -> u64 {
45        self.position
46    }
47
48    pub fn date(&self) -> DateTime {
49        self.date
50    }
51
52    pub fn print(&self, writer: &mut Writer) {
53        writer.write_unsigned(self.version);
54        writer.write_char(',');
55        writer.write_u64(self.position);
56        writer.write_char(' ');
57        writer.write_date(&self.date);
58    }
59
60    /// Return the formula-string representation (round-trippable by the parser).
61    pub fn to_formula(&self) -> String {
62        let mut writer = Writer::formulizer();
63        self.print(&mut writer);
64        writer.finish()
65    }
66}
67
68impl WriterLike for Journal {
69    fn write(&self, writer: &mut Writer) {
70        self.print(writer);
71    }
72}
73
74impl fmt::Display for Journal {
75    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
76        write!(f, "{}", self.to_stringized())
77    }
78}
79
80/// Build `.jnl` index for an `.aim` file.
81///
82/// Scans `aim_path` for complete version headers and writes non-partial entries
83/// to the sidecar `.jnl`. Overwrites the provided `journals` with the result.
84pub fn build_journal(journals: &mut Vec<Arc<Journal>>, aim_path: &Path) -> Result<()> {
85    // Check if file exists
86    if !aim_path.exists() {
87        return Err(anyhow!("Invalid filename: {}", aim_path.to_string_lossy()));
88    }
89
90    // Open file for reading
91    let file = File::open(aim_path)?;
92
93    // Clear previous journal entries
94    journals.clear();
95
96    // Create buffered reader
97    let mut reader = BufReader::new(file);
98
99    // Track position manually since BufReader doesn't provide seek position easily
100    let mut position: u64 = 0;
101    let mut line = String::new();
102
103    // Iterate through lines
104    loop {
105        line.clear();
106        let bytes_read = reader.read_line(&mut line)?;
107
108        // Break if we reached EOF
109        if bytes_read == 0 {
110            break;
111        }
112
113        // Check if line starts with '[' (after trimming whitespace)
114        let trimmed_line = line.trim_start();
115        if trimmed_line.starts_with('[') {
116            // Try to parse as version header
117            match parse_version(trimmed_line) {
118                Ok((_, version)) => {
119                    // Only record if partial is 0 (e.g., [123] not [123:1])
120                    if version.partial() == 0 {
121                        journals.push(Arc::new(Journal {
122                            version: version.epoch(),
123                            position: position,
124                            date: version.date(),
125                        }));
126                    }
127                }
128                // Skip invalid version headers
129                Err(_) => {}
130            }
131        }
132
133        // Update position for next line
134        position += bytes_read as u64;
135    }
136
137    // Save the journal to file
138    save_journal(aim_path, journals)?;
139
140    Ok(())
141}
142
143/// Save journal entries for `aim_path` to its `.jnl` file.
144///
145/// Writes each entry as `version,position` and truncates any existing journal.
146pub fn save_journal(aim_path: &Path, journals: &Vec<Arc<Journal>>) -> Result<()> {
147    let jnl_path = to_journal_path(aim_path)?;
148
149    // Create a new file or truncate an existing one
150    let mut file = File::create(&jnl_path)?;
151
152    for journal in journals {
153        writeln!(file, "{}", journal)?;
154    }
155
156    Ok(())
157}
158
159/// Derive `.jnl` path from an `.aim` file path.
160///
161/// Replaces the `.aim` extension with `.jnl`; fails if `aim_path` has no file name.
162pub fn to_journal_path(aim_path: &Path) -> Result<PathBuf> {
163    let stem = aim_path
164        .file_stem()
165        .ok_or_else(|| anyhow!("Invalid filename: {}", aim_path.to_string_lossy()))?;
166    let parent = aim_path.parent().unwrap_or_else(|| Path::new(""));
167
168    let mut jnl_path = parent.to_path_buf();
169    jnl_path.push(format!("{}.jnl", stem.to_string_lossy()));
170
171    Ok(jnl_path)
172}
173
174/// Parse an unsigned 32-bit integer for journal fields.
175fn parse_u32(input: &str) -> IResult<&str, u32> {
176    map_res(digit1, |s: &str| s.parse::<u32>()).parse(input)
177}
178
179/// Parse an unsigned 64-bit integer for journal fields.
180fn parse_u64(input: &str) -> IResult<&str, u64> {
181    map_res(digit1, |s: &str| s.parse::<u64>()).parse(input)
182}
183
184/// Parse a single `version,position` line into a [`Journal`].
185fn parse_journal_entry(input: &str) -> IResult<&str, Journal> {
186    let (input, (version, position, date)) = (
187        parse_u32,
188        preceded(
189            char(','),
190            parse_u64
191        ),
192        preceded(
193            multispace1,
194            parse_date
195        )
196    ).parse(input)?;
197    Ok((input, Journal { version, position, date }))
198}
199
200/// Parse newline-delimited `version,position` entries into `journals`.
201fn parse_journal(journals: &mut Vec<Arc<Journal>>, input: &str) -> Result<()> {
202    let lines: Vec<&str> = input.lines().collect();
203    // Clear previous journal entries
204    journals.clear();
205    for line in lines {
206        match parse_journal_entry(line) {
207            Ok((_, journal)) => journals.push(Arc::new(journal)),
208            Err(nom::Err::Error(e)) | Err(nom::Err::Failure(e)) => {
209                return Err(anyhow!("Parse error: {}", e));
210            }
211            Err(nom::Err::Incomplete(_)) => return Err(anyhow!("Incomplete input")),
212        }
213    }
214    Ok(())
215}
216
217/// Load journal entries for `aim_path`.
218///
219/// Reads the `.jnl` file into `journals`, or (if missing) regenerates it via
220/// [`build_journal`] from the `.aim` source.
221pub fn load_journal(journals: &mut Vec<Arc<Journal>>, aim_path: &Path) -> Result<()> {
222    let jnl_path = to_journal_path(aim_path)?;
223    // Check if file exists
224    if !Path::new(&jnl_path).exists() {
225        return build_journal(journals, aim_path);
226    }
227
228    // Read the entire file
229    let mut file = File::open(jnl_path)?;
230    let mut contents = String::new();
231    file.read_to_string(&mut contents)?;
232
233    // Parse the contents
234    parse_journal(journals, &contents)
235}