1use 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
12pub const LEGACY_DIR_NAME: &str = ".legacy";
14
15#[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#[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#[must_use]
47pub fn cwd_session_dir(sessions_root: &Path, cwd: &Path) -> PathBuf {
48 sessions_root.join(encode_cwd(cwd))
49}
50
51#[must_use]
53pub fn session_file_name(header: &SessionHeader) -> String {
54 format!("{}_{}.jsonl", header.timestamp.file_stem(), header.id)
55}
56
57#[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#[must_use]
68pub fn default_sessions_root() -> Option<PathBuf> {
69 default_agent_dir().map(|dir| dir.join("sessions"))
70}
71
72pub 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
94pub(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
110pub(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
145pub(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
158pub(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#[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
183pub(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
191pub(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
201pub(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#[derive(Clone, Debug, PartialEq, Eq)]
219pub struct MigratedSession {
220 pub path: Option<PathBuf>,
222 pub legacy_path: PathBuf,
224 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
256pub 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
297pub(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}