Skip to main content

warden/store/
paths.rs

1//! Data-dir resolution and the monthly partition layout.
2
3use std::io;
4use std::path::{Path, PathBuf};
5
6use chrono::{DateTime, Datelike, TimeZone, Utc};
7
8use crate::cli::TimeWindow;
9
10/// Layout of the store on disk.
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct StorePaths {
13    root: PathBuf,
14}
15
16impl StorePaths {
17    pub fn new(root: impl Into<PathBuf>) -> Self {
18        Self { root: root.into() }
19    }
20
21    /// Resolve the store root: `--data-dir` beats config, config beats
22    /// `~/.warden`.
23    pub fn resolve(flag: Option<&Path>, configured: Option<&Path>) -> Result<Self, io::Error> {
24        if let Some(path) = flag.or(configured) {
25            return Ok(Self::new(expand_tilde(path)?));
26        }
27        Ok(Self::new(default_root()?))
28    }
29
30    pub fn root(&self) -> &Path {
31        &self.root
32    }
33
34    pub fn events_dir(&self) -> PathBuf {
35        self.root.join("events")
36    }
37
38    pub fn prompts_dir(&self) -> PathBuf {
39        self.root.join("prompts")
40    }
41
42    pub fn state_dir(&self) -> PathBuf {
43        self.root.join("state")
44    }
45
46    pub fn config_file(&self) -> PathBuf {
47        self.root.join("config.toml")
48    }
49
50    /// `state/ingest.jsonl` — append-only cursors, last record per path wins.
51    pub fn ingest_state_file(&self) -> PathBuf {
52        self.state_dir().join("ingest.jsonl")
53    }
54
55    /// `events/YYYY-MM.jsonl` for the partition an event timestamp belongs to.
56    pub fn event_partition(&self, partition: Partition) -> PathBuf {
57        self.events_dir().join(partition.file_name())
58    }
59
60    /// `prompts/YYYY-MM.jsonl`, partitioned identically to events.
61    pub fn prompt_partition(&self, partition: Partition) -> PathBuf {
62        self.prompts_dir().join(partition.file_name())
63    }
64
65    /// Partition files in `dir` overlapping `window`, chronologically.
66    ///
67    /// `events/` and `prompts/` are partitioned identically, so the overlap rule
68    /// lives here once: both the event scanner and the prompt reader call this
69    /// rather than each deciding for itself what "overlaps" means.
70    ///
71    /// A missing directory is an empty store, not an error.
72    pub fn partitions_in(
73        dir: &Path,
74        window: TimeWindow,
75    ) -> Result<Vec<(Partition, PathBuf)>, io::Error> {
76        let entries = match std::fs::read_dir(dir) {
77            Ok(entries) => entries,
78            Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
79            Err(err) => return Err(err),
80        };
81
82        let mut found = Vec::new();
83        for entry in entries {
84            let path = entry?.path();
85            if path.extension().and_then(|ext| ext.to_str()) != Some("jsonl") {
86                continue;
87            }
88            let Some(partition) = path
89                .file_stem()
90                .and_then(|stem| stem.to_str())
91                .and_then(Partition::parse_stem)
92            else {
93                continue;
94            };
95            // Half-open overlap: [start, end) against [from, to).
96            if partition.end_ms() > window.from_ms && partition.start_ms() < window.to_ms {
97                found.push((partition, path));
98            }
99        }
100        found.sort_by_key(|(partition, _)| *partition);
101        Ok(found)
102    }
103}
104
105/// One month of data, in UTC. Partition membership is decided by the event's
106/// UTC month, so a boundary is unambiguous regardless of local timezone.
107#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
108pub struct Partition {
109    pub year: i32,
110    pub month: u32,
111}
112
113impl Partition {
114    pub fn new(year: i32, month: u32) -> Self {
115        debug_assert!((1..=12).contains(&month));
116        Self { year, month }
117    }
118
119    /// The partition an epoch-millisecond timestamp routes to.
120    pub fn for_timestamp(ts_ms: i64) -> Option<Self> {
121        let dt: DateTime<Utc> = Utc.timestamp_millis_opt(ts_ms).single()?;
122        Some(Self::new(dt.year(), dt.month()))
123    }
124
125    /// Parse `YYYY-MM` from a partition file stem.
126    pub fn parse_stem(stem: &str) -> Option<Self> {
127        let (year, month) = stem.split_once('-')?;
128        if year.len() != 4 || month.len() != 2 {
129            return None;
130        }
131        let year: i32 = year.parse().ok()?;
132        let month: u32 = month.parse().ok()?;
133        (1..=12).contains(&month).then(|| Self::new(year, month))
134    }
135
136    pub fn file_name(&self) -> String {
137        format!("{:04}-{:02}.jsonl", self.year, self.month)
138    }
139
140    /// Inclusive epoch-ms start of the month.
141    pub fn start_ms(&self) -> i64 {
142        Utc.with_ymd_and_hms(self.year, self.month, 1, 0, 0, 0)
143            .single()
144            .map(|dt| dt.timestamp_millis())
145            .unwrap_or(i64::MIN)
146    }
147
148    /// Exclusive epoch-ms end of the month (start of the next one).
149    pub fn end_ms(&self) -> i64 {
150        self.next().start_ms()
151    }
152
153    pub fn next(&self) -> Self {
154        if self.month == 12 {
155            Self::new(self.year + 1, 1)
156        } else {
157            Self::new(self.year, self.month + 1)
158        }
159    }
160}
161
162/// `~/.warden`.
163fn default_root() -> Result<PathBuf, io::Error> {
164    Ok(home_dir()?.join(".warden"))
165}
166
167fn home_dir() -> Result<PathBuf, io::Error> {
168    let home = std::env::var_os("HOME")
169        .filter(|value| !value.is_empty())
170        .or_else(|| std::env::var_os("USERPROFILE").filter(|value| !value.is_empty()));
171    home.map(PathBuf::from).ok_or_else(|| {
172        io::Error::new(
173            io::ErrorKind::NotFound,
174            "cannot determine home directory; pass --data-dir",
175        )
176    })
177}
178
179/// Expand a leading `~` so config files can use the same notation as the README.
180pub fn expand_tilde(path: &Path) -> Result<PathBuf, io::Error> {
181    let Some(text) = path.to_str() else {
182        return Ok(path.to_path_buf());
183    };
184    match text.strip_prefix('~') {
185        Some("") => home_dir(),
186        Some(rest) if rest.starts_with('/') => Ok(home_dir()?.join(rest.trim_start_matches('/'))),
187        _ => Ok(path.to_path_buf()),
188    }
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194
195    #[test]
196    fn routes_by_utc_month() {
197        let ts = Utc
198            .with_ymd_and_hms(2026, 8, 4, 12, 0, 0)
199            .unwrap()
200            .timestamp_millis();
201        assert_eq!(
202            Partition::for_timestamp(ts).unwrap(),
203            Partition::new(2026, 8)
204        );
205    }
206
207    #[test]
208    fn month_boundary_is_utc_exact() {
209        let last = Utc
210            .with_ymd_and_hms(2026, 7, 31, 23, 59, 59)
211            .unwrap()
212            .timestamp_millis()
213            + 999;
214        let first = last + 1;
215        assert_eq!(
216            Partition::for_timestamp(last).unwrap(),
217            Partition::new(2026, 7)
218        );
219        assert_eq!(
220            Partition::for_timestamp(first).unwrap(),
221            Partition::new(2026, 8)
222        );
223        assert_eq!(Partition::new(2026, 7).end_ms(), first);
224    }
225
226    #[test]
227    fn december_rolls_into_january() {
228        assert_eq!(Partition::new(2026, 12).next(), Partition::new(2027, 1));
229        assert_eq!(
230            Partition::for_timestamp(Partition::new(2026, 12).end_ms()).unwrap(),
231            Partition::new(2027, 1)
232        );
233    }
234
235    #[test]
236    fn file_names_round_trip() {
237        let p = Partition::new(2026, 1);
238        assert_eq!(p.file_name(), "2026-01.jsonl");
239        assert_eq!(Partition::parse_stem("2026-01").unwrap(), p);
240        assert_eq!(Partition::parse_stem("2026-13"), None);
241        assert_eq!(Partition::parse_stem("nonsense"), None);
242        assert_eq!(Partition::parse_stem("2026-1"), None);
243    }
244
245    #[test]
246    fn data_dir_flag_beats_config() {
247        let flag = PathBuf::from("/flag");
248        let cfg = PathBuf::from("/cfg");
249        assert_eq!(
250            StorePaths::resolve(Some(&flag), Some(&cfg)).unwrap().root(),
251            Path::new("/flag")
252        );
253        assert_eq!(
254            StorePaths::resolve(None, Some(&cfg)).unwrap().root(),
255            Path::new("/cfg")
256        );
257    }
258
259    #[test]
260    fn layout_is_the_documented_one() {
261        let paths = StorePaths::new("/store");
262        assert_eq!(
263            paths.event_partition(Partition::new(2026, 8)),
264            Path::new("/store/events/2026-08.jsonl")
265        );
266        assert_eq!(
267            paths.prompt_partition(Partition::new(2026, 8)),
268            Path::new("/store/prompts/2026-08.jsonl")
269        );
270        assert_eq!(
271            paths.ingest_state_file(),
272            Path::new("/store/state/ingest.jsonl")
273        );
274        assert_eq!(paths.config_file(), Path::new("/store/config.toml"));
275    }
276}