Skip to main content

fren_date/log/
writer.rs

1//! JSONL transaction-log writer.
2
3use crate::FrenError;
4use serde::{Deserialize, Serialize};
5use std::fs::{File, OpenOptions};
6use std::io::{BufWriter, Write};
7use std::path::{Path, PathBuf};
8use uuid::Uuid;
9
10/// Trait for sinks that record rename events. The default implementation
11/// is [`JsonlLogSink`]; tests can use [`NullLogSink`].
12pub trait LogSink {
13    /// Append one record. Errors are returned but the executor decides
14    /// whether to continue.
15    fn append(&mut self, record: &LogRecord) -> Result<(), FrenError>;
16    /// Path of the underlying log file, if any. Used for the
17    /// `ExecutionReport.log_path`.
18    fn path(&self) -> Option<&Path>;
19}
20
21/// One record in the JSONL stream.
22#[derive(Debug, Clone, Serialize, Deserialize)]
23#[serde(tag = "type", rename_all = "snake_case")]
24pub enum LogRecord {
25    /// Batch header, written first.
26    Batch {
27        /// Schema version.
28        #[serde(rename = "v")]
29        v: u32,
30        /// Batch UUID.
31        id: Uuid,
32        /// ISO timestamp.
33        ts: String,
34        /// Subcommand: `rename`, `merge`, etc.
35        cmd: String,
36        /// Original CLI args.
37        args: Vec<String>,
38        /// Working directory at invocation.
39        cwd: PathBuf,
40        /// `fren` version string.
41        fren_version: String,
42    },
43    /// Per-rename record.
44    Rename {
45        /// Schema version.
46        #[serde(rename = "v")]
47        v: u32,
48        /// Per-record timestamp.
49        ts: String,
50        /// Original path.
51        from: PathBuf,
52        /// New path.
53        to: PathBuf,
54        /// Item kind: `file`, `dir`, `symlink`.
55        kind: String,
56    },
57    /// Completion marker.
58    End {
59        /// Schema version.
60        #[serde(rename = "v")]
61        v: u32,
62        /// Per-record timestamp.
63        ts: String,
64        /// `ok` / `partial` / `error`.
65        status: String,
66        /// Renames applied.
67        applied: usize,
68        /// Plans skipped.
69        skipped: usize,
70        /// Errors encountered.
71        errors: usize,
72    },
73}
74
75/// JSONL log sink that appends to a file in
76/// `${XDG_STATE_HOME:-~/.local/state}/fren/log/`.
77pub struct JsonlLogSink {
78    writer: BufWriter<File>,
79    path: PathBuf,
80}
81
82impl JsonlLogSink {
83    /// Open a new log file. The filename is
84    /// `<ISO-timestamp>-<batch-uuid>.jsonl`. The directory is created if
85    /// missing.
86    pub fn open(log_dir: Option<&Path>, batch_id: Uuid, ts: &str) -> Result<Self, FrenError> {
87        let dir = match log_dir {
88            Some(p) => p.to_path_buf(),
89            None => default_log_dir(),
90        };
91        std::fs::create_dir_all(&dir).map_err(|source| FrenError::Io {
92            path: dir.clone(),
93            source,
94        })?;
95        let filename = format!("{ts}-{batch_id}.jsonl");
96        let path = dir.join(filename);
97        let file = OpenOptions::new()
98            .create(true)
99            .append(true)
100            .open(&path)
101            .map_err(|source| FrenError::Io {
102                path: path.clone(),
103                source,
104            })?;
105        Ok(Self {
106            writer: BufWriter::new(file),
107            path,
108        })
109    }
110}
111
112impl LogSink for JsonlLogSink {
113    fn append(&mut self, record: &LogRecord) -> Result<(), FrenError> {
114        let line =
115            serde_json::to_string(record).map_err(|e| FrenError::InvalidInput(e.to_string()))?;
116        writeln!(self.writer, "{line}").map_err(|source| FrenError::Io {
117            path: self.path.clone(),
118            source,
119        })?;
120        self.writer.flush().map_err(|source| FrenError::Io {
121            path: self.path.clone(),
122            source,
123        })?;
124        Ok(())
125    }
126
127    fn path(&self) -> Option<&Path> {
128        Some(&self.path)
129    }
130}
131
132/// No-op sink for tests / `--no-log`.
133pub struct NullLogSink;
134
135impl LogSink for NullLogSink {
136    fn append(&mut self, _record: &LogRecord) -> Result<(), FrenError> {
137        Ok(())
138    }
139    fn path(&self) -> Option<&Path> {
140        None
141    }
142}
143
144fn default_log_dir() -> PathBuf {
145    if let Some(state) = std::env::var_os("XDG_STATE_HOME") {
146        return PathBuf::from(state).join("fren").join("log");
147    }
148    if let Some(home) = std::env::var_os("HOME") {
149        return PathBuf::from(home)
150            .join(".local")
151            .join("state")
152            .join("fren")
153            .join("log");
154    }
155    // Last-resort fallback: current dir.
156    PathBuf::from(".fren-log")
157}