use std::fs;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use crate::domain::history::{format_rfc3339, project_slug, sanitize_name};
use crate::domain::session::{SessionRecord, SCHEMA_VERSION};
const SESSION_EXT: &str = "json";
fn now_epoch_secs() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
fn state_dir() -> Option<PathBuf> {
if cfg!(windows) {
return std::env::var_os("LOCALAPPDATA")
.filter(|v| !v.is_empty())
.map(PathBuf::from);
}
if let Some(xdg) = std::env::var_os("XDG_STATE_HOME").filter(|v| !v.is_empty()) {
return Some(PathBuf::from(xdg));
}
std::env::var_os("HOME")
.filter(|v| !v.is_empty())
.map(|home| PathBuf::from(home).join(".local").join("state"))
}
pub fn resolve_dir(configured: Option<&str>) -> Option<PathBuf> {
match configured {
Some(p) if !p.is_empty() => Some(PathBuf::from(p)),
_ => state_dir().map(|d| d.join("oneharness").join("sessions")),
}
}
#[must_use]
pub fn session_path(dir: &Path, project: &Path, name: &str) -> PathBuf {
let slug = project_slug(&project.display().to_string());
dir.join(slug)
.join(format!("{}.{SESSION_EXT}", sanitize_name(name)))
}
#[must_use]
pub fn read(path: &Path) -> Option<SessionRecord> {
let text = fs::read_to_string(path).ok()?;
serde_json::from_str(&text).ok()
}
pub fn write(
path: &Path,
project: &Path,
harness: &str,
name: &str,
token: &str,
existing: Option<&SessionRecord>,
) -> std::io::Result<()> {
let now = format_rfc3339(now_epoch_secs());
let created = existing.map_or_else(|| now.clone(), |record| record.created.clone());
let record = SessionRecord {
schema_version: SCHEMA_VERSION.to_string(),
name: sanitize_name(name),
project: project.display().to_string(),
harness: harness.to_string(),
token: token.to_string(),
created,
updated: now,
};
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let mut text = serde_json::to_string_pretty(&record)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
text.push('\n');
let tmp = path.with_extension("json.tmp");
fs::write(&tmp, text)?;
fs::rename(&tmp, path)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn temp_dir(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"oh-session-{}-{}-{}",
tag,
std::process::id(),
now_epoch_secs()
));
fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn resolve_dir_prefers_configured_path() {
assert_eq!(
resolve_dir(Some("/custom/sessions")),
Some(PathBuf::from("/custom/sessions"))
);
let empty = resolve_dir(Some(""));
let none = resolve_dir(None);
assert_eq!(empty, none);
}
#[test]
fn session_path_partitions_by_project_slug_and_name() {
let path = session_path(
Path::new("/store"),
Path::new("/home/me/My Proj"),
"Greet Flow",
);
assert_eq!(
path,
PathBuf::from("/store/home-me-My-Proj/greet-flow.json")
);
}
#[test]
fn read_missing_is_none() {
let dir = temp_dir("missing");
assert!(read(&dir.join("nope.json")).is_none());
}
#[test]
fn write_then_read_round_trips_and_preserves_created() {
let dir = temp_dir("roundtrip");
let project = Path::new("/home/me/proj");
let path = session_path(&dir, project, "chat");
write(&path, project, "claude-code", "chat", "sess-1", None).unwrap();
let first = read(&path).unwrap();
assert_eq!(first.token, "sess-1");
assert_eq!(first.harness, "claude-code");
assert_eq!(first.name, "chat");
write(
&path,
project,
"claude-code",
"chat",
"sess-2",
Some(&first),
)
.unwrap();
let second = read(&path).unwrap();
assert_eq!(second.token, "sess-2");
assert_eq!(second.created, first.created);
fs::remove_dir_all(&dir).ok();
}
#[test]
fn read_ignores_a_corrupt_record() {
let dir = temp_dir("corrupt");
let path = dir.join("bad.json");
fs::write(&path, b"{ not json").unwrap();
assert!(read(&path).is_none());
fs::remove_dir_all(&dir).ok();
}
}