Skip to main content

oino_session/manager/
files.rs

1//! Path rules and file helpers for the session store (session-v3 §6.1).
2
3use std::fs::{self, File, OpenOptions};
4use std::io::{BufRead, BufReader, Write};
5use std::path::{Component, Path, PathBuf};
6
7use crate::entry::{FileLine, SessionEntry, SessionHeader};
8use crate::error::SessionError;
9use crate::migrate::{FileFormat, MigrationWarning, detect, legacy::migrate_oino_legacy_text};
10use crate::time::system_time_ms;
11
12/// Name of the directory (inside the sessions root) that keeps migrated pre-v1 files.
13pub const LEGACY_DIR_NAME: &str = ".legacy";
14
15/// Absolute (joined to the process cwd) and lexically normalised, without
16/// resolving symlinks.
17#[must_use]
18pub fn resolve_path(path: &Path) -> PathBuf {
19    let absolute = if path.is_absolute() {
20        path.to_path_buf()
21    } else {
22        std::env::current_dir().map_or_else(|_| path.to_path_buf(), |cwd| cwd.join(path))
23    };
24    let mut normalised = PathBuf::new();
25    for component in absolute.components() {
26        match component {
27            Component::CurDir => {}
28            Component::ParentDir => {
29                normalised.pop();
30            }
31            other => normalised.push(other),
32        }
33    }
34    normalised
35}
36
37/// `--<cwd>--` directory name: leading `/` or `\` removed, every `/`, `\`, `:` → `-`.
38#[must_use]
39pub fn encode_cwd(cwd: &Path) -> String {
40    let text = resolve_path(cwd).to_string_lossy().into_owned();
41    let trimmed = text.strip_prefix(['/', '\\']).unwrap_or(&text);
42    format!("--{}--", trimmed.replace(['/', '\\', ':'], "-"))
43}
44
45/// `<sessions_root>/--<cwd>--/`.
46#[must_use]
47pub fn cwd_session_dir(sessions_root: &Path, cwd: &Path) -> PathBuf {
48    sessions_root.join(encode_cwd(cwd))
49}
50
51/// `<ts>_<sessionId>.jsonl` for a header.
52#[must_use]
53pub fn session_file_name(header: &SessionHeader) -> String {
54    format!("{}_{}.jsonl", header.timestamp.file_stem(), header.id)
55}
56
57/// Default agent dir: `OINO_AGENT_DIR`, else `~/.oino`.
58#[must_use]
59pub fn default_agent_dir() -> Option<PathBuf> {
60    if let Some(dir) = std::env::var_os("OINO_AGENT_DIR").filter(|d| !d.is_empty()) {
61        return Some(PathBuf::from(dir));
62    }
63    std::env::home_dir().map(|home| home.join(".oino"))
64}
65
66/// Default sessions root: `<agent_dir>/sessions`.
67#[must_use]
68pub fn default_sessions_root() -> Option<PathBuf> {
69    default_agent_dir().map(|dir| dir.join("sessions"))
70}
71
72/// Session id rule: `^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$`.
73///
74/// # Errors
75///
76/// [`SessionError::InvalidSessionId`] with the fixed message.
77pub fn validate_session_id(id: &str) -> Result<(), SessionError> {
78    let bytes = id.as_bytes();
79    let edge_ok = |b: Option<&u8>| b.is_some_and(u8::is_ascii_alphanumeric);
80    let middle_ok = bytes
81        .iter()
82        .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'));
83    if edge_ok(bytes.first()) && edge_ok(bytes.last()) && middle_ok {
84        Ok(())
85    } else {
86        Err(SessionError::InvalidSessionId(id.to_owned()))
87    }
88}
89
90pub(crate) fn read_to_string(path: &Path) -> Result<String, SessionError> {
91    fs::read_to_string(path).map_err(|e| SessionError::io(path, e))
92}
93
94/// First non-empty line of a file (bounded to 1 MiB, as the header scan requires).
95pub(crate) fn first_line(path: &Path) -> Result<String, SessionError> {
96    let file = File::open(path).map_err(|e| SessionError::io(path, e))?;
97    let mut reader = std::io::Read::take(BufReader::new(file), 1024 * 1024);
98    let mut line = String::new();
99    loop {
100        line.clear();
101        let read = reader
102            .read_line(&mut line)
103            .map_err(|e| SessionError::io(path, e))?;
104        if read == 0 || !line.trim().is_empty() {
105            return Ok(line.trim_end_matches(['\n', '\r']).to_owned());
106        }
107    }
108}
109
110/// Header of a session file, `None` when the first line is not a valid header.
111pub(crate) fn read_header(path: &Path) -> Option<SessionHeader> {
112    match FileLine::parse(&first_line(path).ok()?) {
113        Ok(FileLine::Header(header)) => Some(header),
114        _ => None,
115    }
116}
117
118pub(crate) fn serialize_lines(
119    header: &SessionHeader,
120    entries: &[SessionEntry],
121) -> Result<String, SessionError> {
122    let json_error = |source| SessionError::Json {
123        path: PathBuf::new(),
124        line: 0,
125        source,
126    };
127    let mut text = serde_json::to_string(header).map_err(json_error)?;
128    text.push('\n');
129    for entry in entries {
130        text.push_str(&serde_json::to_string(entry).map_err(json_error)?);
131        text.push('\n');
132    }
133    Ok(text)
134}
135
136fn create_parent(path: &Path) -> Result<(), SessionError> {
137    match path.parent() {
138        Some(parent) if !parent.as_os_str().is_empty() => {
139            fs::create_dir_all(parent).map_err(|e| SessionError::io(parent, e))
140        }
141        _ => Ok(()),
142    }
143}
144
145/// Creates `path` exclusively and writes `text`.
146pub(crate) fn write_new(path: &Path, text: &str) -> Result<(), SessionError> {
147    create_parent(path)?;
148    let mut file = OpenOptions::new()
149        .write(true)
150        .create_new(true)
151        .open(path)
152        .map_err(|e| SessionError::io(path, e))?;
153    file.write_all(text.as_bytes())
154        .and_then(|()| file.flush())
155        .map_err(|e| SessionError::io(path, e))
156}
157
158/// Replaces `path` atomically: `<path>.tmp`, fsync, rename.
159pub(crate) fn write_atomic(path: &Path, text: &str) -> Result<(), SessionError> {
160    create_parent(path)?;
161    let mut tmp = path.as_os_str().to_owned();
162    tmp.push(".tmp");
163    let tmp = PathBuf::from(tmp);
164    let mut file = File::create(&tmp).map_err(|e| SessionError::io(&tmp, e))?;
165    file.write_all(text.as_bytes())
166        .and_then(|()| file.sync_all())
167        .map_err(|e| SessionError::io(&tmp, e))?;
168    fs::rename(&tmp, path).map_err(|e| SessionError::io(path, e))
169}
170
171/// Appends one line by opening the file (`O_APPEND`, single write). For migration and
172/// repair code paths that do not own a [`SessionManager`](super::SessionManager) writer
173/// handle (D-WRITER: the manager itself appends through a held handle).
174#[allow(
175    dead_code,
176    reason = "kept for the migration/repair paths (core spec §2.1 D-WRITER); no caller yet"
177)]
178pub(crate) fn append_line(path: &Path, line: &str) -> Result<(), SessionError> {
179    let mut file = open_append(path)?;
180    write_line(&mut file, path, line)
181}
182
183/// Opens `path` for appending: the held `O_APPEND` writer handle of a session file.
184pub(crate) fn open_append(path: &Path) -> Result<File, SessionError> {
185    OpenOptions::new()
186        .append(true)
187        .open(path)
188        .map_err(|e| SessionError::io(path, e))
189}
190
191/// Appends one line on a held `O_APPEND` handle (single `write_all`).
192pub(crate) fn write_line(file: &mut File, path: &Path, line: &str) -> Result<(), SessionError> {
193    let mut buffer = String::with_capacity(line.len() + 1);
194    buffer.push_str(line);
195    buffer.push('\n');
196    file.write_all(buffer.as_bytes())
197        .and_then(|()| file.flush())
198        .map_err(|e| SessionError::io(path, e))
199}
200
201/// fsync of the directory holding `path`, making the file's directory entry (create or
202/// rename) durable. POSIX only: Windows has no directory-fsync contract (the same
203/// exception deepseek's JSONL backend documents, `session-persistence-jsonl/src/win32.ts`).
204pub(crate) fn sync_dir(path: &Path) -> Result<(), SessionError> {
205    #[cfg(unix)]
206    {
207        if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
208            let dir = File::open(parent).map_err(|e| SessionError::io(parent, e))?;
209            dir.sync_all().map_err(|e| SessionError::io(parent, e))?;
210        }
211    }
212    #[cfg(not(unix))]
213    let _ = path;
214    Ok(())
215}
216
217/// Outcome of migrating one pre-v1 Oino file (D21).
218#[derive(Clone, Debug, PartialEq, Eq)]
219pub struct MigratedSession {
220    /// New v3 file, `None` when the session had no messages (only moved aside).
221    pub path: Option<PathBuf>,
222    /// Where the original was moved (`<root>/.legacy/<name>`).
223    pub legacy_path: PathBuf,
224    /// Dropped/approximated records.
225    pub warnings: Vec<MigrationWarning>,
226}
227
228fn unique_path(dir: &Path, stem: &str, id: &str) -> PathBuf {
229    let mut candidate = dir.join(format!("{stem}_{id}.jsonl"));
230    let mut suffix = 1;
231    while candidate.exists() {
232        candidate = dir.join(format!("{stem}-{suffix}_{id}.jsonl"));
233        suffix += 1;
234    }
235    candidate
236}
237
238fn move_aside(path: &Path, sessions_root: &Path) -> Result<PathBuf, SessionError> {
239    let legacy_dir = sessions_root.join(LEGACY_DIR_NAME);
240    fs::create_dir_all(&legacy_dir).map_err(|e| SessionError::io(&legacy_dir, e))?;
241    let name = path
242        .file_name()
243        .map_or_else(|| "session.jsonl".into(), std::ffi::OsStr::to_os_string);
244    let mut target = legacy_dir.join(&name);
245    let mut suffix = 1;
246    while target.exists() {
247        let mut numbered = name.clone();
248        numbered.push(format!(".{suffix}"));
249        target = legacy_dir.join(numbered);
250        suffix += 1;
251    }
252    fs::rename(path, &target).map_err(|e| SessionError::io(path, e))?;
253    Ok(target)
254}
255
256/// Migrates a pre-v1 Oino file to `<sessions_root>/--<cwd>--/<ts>_<id>.jsonl` and moves the
257/// original to `<sessions_root>/.legacy/` (session-v3 §5.2 steps 6–7).
258///
259/// # Errors
260///
261/// I/O errors, or [`SessionError::MissingHeader`] when the file has no legacy header.
262pub fn migrate_legacy_file(
263    path: &Path,
264    sessions_root: &Path,
265) -> Result<MigratedSession, SessionError> {
266    let text = read_to_string(path)?;
267    let base_ms = fs::metadata(path)
268        .and_then(|meta| meta.modified())
269        .map_or_else(|_| crate::time::now_ms(), system_time_ms);
270    let migration = migrate_oino_legacy_text(&text, base_ms).map_err(|error| match error {
271        SessionError::MissingHeader(_) => SessionError::MissingHeader(path.to_path_buf()),
272        other => other,
273    })?;
274    let new_path = if migration.has_messages {
275        let dir = cwd_session_dir(sessions_root, Path::new(&migration.header.cwd));
276        let target = unique_path(
277            &dir,
278            &migration.header.timestamp.file_stem(),
279            &migration.header.id,
280        );
281        write_atomic(
282            &target,
283            &serialize_lines(&migration.header, &migration.entries)?,
284        )?;
285        Some(target)
286    } else {
287        None
288    };
289    let legacy_path = move_aside(path, sessions_root)?;
290    Ok(MigratedSession {
291        path: new_path,
292        legacy_path,
293        warnings: migration.warnings,
294    })
295}
296
297/// Whether the file's first line is a pre-v1 Oino record.
298pub(crate) fn is_legacy_file(path: &Path) -> bool {
299    first_line(path).is_ok_and(|line| matches!(detect(&line), FileFormat::OinoLegacy { .. }))
300}
301
302#[cfg(test)]
303mod tests {
304    use super::*;
305
306    #[test]
307    fn encodes_cwd_like_pi() {
308        assert_eq!(encode_cwd(Path::new("/home/oi/p")), "--home-oi-p--");
309        assert_eq!(encode_cwd(Path::new("/a/./b/../c")), "--a-c--");
310    }
311
312    #[test]
313    fn session_id_rule() {
314        for ok in ["a", "a.b_c-d", "019fcbea-df52-7491"] {
315            assert!(validate_session_id(ok).is_ok(), "{ok}");
316        }
317        for bad in ["", "-a", "a-", "a b", "a/b"] {
318            assert!(validate_session_id(bad).is_err(), "{bad}");
319        }
320    }
321}