safari 1.0.0

Terminal UI for capturing and restoring Safari sessions on macOS
Documentation
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");
    }
}