use std::collections::BTreeMap;
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use anyhow::{anyhow, Context, Result};
use directories::{ProjectDirs, UserDirs};
use serde::Deserialize;
use crate::backend::models::{SafariSession, SessionSummary, SessionTab, SessionWindow};
use crate::backend::utils;
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum SessionDiskFormat {
Current(SafariSession),
Legacy(LegacySafariSession),
}
#[derive(Debug, Deserialize)]
struct LegacySafariSession {
time: String,
#[serde(flatten)]
windows: BTreeMap<String, Vec<SessionTab>>,
}
pub fn storage_dir() -> Result<PathBuf> {
if let Some(path) = env::var_os("SAFARI_STORAGE_DIR").map(PathBuf::from) {
fs::create_dir_all(&path).with_context(|| {
format!(
"Failed to create session storage directory {} from SAFARI_STORAGE_DIR",
path.display()
)
})?;
return Ok(path);
}
let project_dirs = ProjectDirs::from("dev", "lhpqaq", "safari")
.ok_or_else(|| anyhow!("Could not determine application data directory"))?;
let path = project_dirs.data_dir().join("sessions");
fs::create_dir_all(&path).context("Failed to create session storage directory")?;
Ok(path)
}
pub fn save_session(session: &SafariSession) -> Result<PathBuf> {
let dir = storage_dir()?;
let stem = utils::session_file_stem(&session.captured_at);
let path = unique_session_path(&dir, &stem);
write_session_file(&path, session)?;
Ok(path)
}
pub fn load_session(path: &Path) -> Result<SafariSession> {
let content = fs::read_to_string(path)
.with_context(|| format!("Failed to read session file {}", path.display()))?;
let disk_format: SessionDiskFormat = serde_json::from_str(&content)
.with_context(|| format!("Failed to parse session file {}", path.display()))?;
Ok(match disk_format {
SessionDiskFormat::Current(session) => session,
SessionDiskFormat::Legacy(session) => SafariSession::new(
session.time,
session
.windows
.into_iter()
.map(|(title, tabs)| SessionWindow { title, tabs })
.collect(),
),
})
}
pub fn list_sessions() -> Result<Vec<SessionSummary>> {
collect_sessions_from_dirs(&session_roots()?)
}
pub fn delete_session(path: &Path) -> Result<()> {
fs::remove_file(path)
.with_context(|| format!("Failed to delete session file {}", path.display()))?;
Ok(())
}
fn session_roots() -> Result<Vec<PathBuf>> {
let mut roots = vec![storage_dir()?];
if let Some(legacy_root) = legacy_storage_dir()? {
if !roots.iter().any(|root| root == &legacy_root) {
roots.push(legacy_root);
}
}
Ok(roots)
}
fn legacy_storage_dir() -> Result<Option<PathBuf>> {
let user_dirs = match UserDirs::new() {
Some(user_dirs) => user_dirs,
None => return Ok(None),
};
let legacy_path = user_dirs.home_dir().join(".safari");
if legacy_path.exists() {
Ok(Some(legacy_path))
} else {
Ok(None)
}
}
fn unique_session_path(dir: &Path, stem: &str) -> PathBuf {
let mut candidate = dir.join(format!("{stem}.json"));
let mut suffix = 1usize;
while candidate.exists() {
candidate = dir.join(format!("{stem}-{suffix}.json"));
suffix += 1;
}
candidate
}
fn write_session_file(path: &Path, session: &SafariSession) -> Result<()> {
let json = serde_json::to_vec_pretty(session).context("Failed to serialize session")?;
let temp_path = path.with_extension("json.tmp");
fs::write(&temp_path, json)
.with_context(|| format!("Failed to write temporary file {}", temp_path.display()))?;
fs::rename(&temp_path, path)
.with_context(|| format!("Failed to finalize session file {}", path.display()))?;
Ok(())
}
fn collect_sessions_from_dirs(dirs: &[PathBuf]) -> Result<Vec<SessionSummary>> {
let mut sessions = Vec::new();
for dir in dirs {
if !dir.exists() {
continue;
}
for entry in fs::read_dir(dir)
.with_context(|| format!("Failed to read session directory {}", dir.display()))?
{
let entry = entry?;
let path = entry.path();
if path.extension().and_then(|extension| extension.to_str()) != Some("json") {
continue;
}
if let Ok(session) = load_session(&path) {
sessions.push(SessionSummary::from_session(path, &session));
}
}
}
sessions.sort_by(|left, right| {
utils::timestamp_sort_key(&right.captured_at)
.cmp(&utils::timestamp_sort_key(&left.captured_at))
.then_with(|| right.file_name.cmp(&left.file_name))
});
Ok(sessions)
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::{SystemTime, UNIX_EPOCH};
fn temp_dir(name: &str) -> PathBuf {
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system time before unix epoch")
.as_nanos();
let path = std::env::temp_dir().join(format!("safari-tests-{name}-{unique}"));
fs::create_dir_all(&path).expect("failed to create temp dir");
path
}
#[test]
fn loads_legacy_session_shape() {
let dir = temp_dir("legacy");
let path = dir.join("legacy.json");
fs::write(
&path,
r#"{
"time": "2026-04-15 12:30",
"Window 1": [{"title": "Example", "url": "https://example.com"}]
}"#,
)
.expect("failed to write legacy fixture");
let session = load_session(&path).expect("legacy session should load");
assert_eq!(session.window_count(), 1);
assert_eq!(session.tab_count(), 1);
assert_eq!(session.captured_at, "2026-04-15 12:30");
}
#[test]
fn sorts_sessions_newest_first() {
let dir = temp_dir("sorting");
let older = dir.join("older.json");
let newer = dir.join("newer.json");
write_session_file(
&older,
&SafariSession::new(
"2026-04-15T12:00:00.000+08:00".to_string(),
vec![SessionWindow {
title: "Window 1".to_string(),
tabs: vec![SessionTab {
title: "Older".to_string(),
url: "https://older.example".to_string(),
}],
}],
),
)
.expect("failed to write older session");
write_session_file(
&newer,
&SafariSession::new(
"2026-04-15T12:30:00.000+08:00".to_string(),
vec![SessionWindow {
title: "Window 1".to_string(),
tabs: vec![SessionTab {
title: "Newer".to_string(),
url: "https://newer.example".to_string(),
}],
}],
),
)
.expect("failed to write newer session");
let sessions = collect_sessions_from_dirs(&[dir]).expect("failed to collect sessions");
assert_eq!(sessions.len(), 2);
assert_eq!(sessions[0].file_name, "newer.json");
assert_eq!(sessions[1].file_name, "older.json");
}
}