1use std::io;
4use std::path::{Path, PathBuf};
5
6use chrono::{DateTime, Datelike, TimeZone, Utc};
7
8use crate::cli::TimeWindow;
9
10#[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 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 pub fn ingest_state_file(&self) -> PathBuf {
52 self.state_dir().join("ingest.jsonl")
53 }
54
55 pub fn event_partition(&self, partition: Partition) -> PathBuf {
57 self.events_dir().join(partition.file_name())
58 }
59
60 pub fn prompt_partition(&self, partition: Partition) -> PathBuf {
62 self.prompts_dir().join(partition.file_name())
63 }
64
65 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 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#[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 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 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 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 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
162fn 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
179pub 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}