use std::path::{Path, PathBuf}; use tracing::warn; use super::snapshot::{parse_snapshot, snapshot_file_version, SessionSnapshot, SNAPSHOT_VERSION}; fn session_path() -> PathBuf { crate::session::data_dir().join("session.json") } // Follow symlinks manually so a write through a (possibly dangling) symlink // lands on the target. `fs::canonicalize` requires the target to exist, which // excludes the dangling-symlink case stow users hit on the very first save. fn resolve_write_target(path: &Path) -> std::io::Result { let mut current = path.to_path_buf(); for _ in 0..16 { let meta = match std::fs::symlink_metadata(¤t) { Ok(meta) => meta, Err(_) => return Ok(current), }; if !meta.file_type().is_symlink() { return Ok(current); } let link = std::fs::read_link(¤t)?; current = if link.is_absolute() { link } else { current .parent() .unwrap_or_else(|| Path::new(".")) .join(link) }; } Ok(current) } pub(super) fn save_to_path(path: &Path, snapshot: &SessionSnapshot) -> std::io::Result<()> { let target = resolve_write_target(path)?; if let Some(parent) = target.parent() { std::fs::create_dir_all(parent)?; } let json = serde_json::to_string_pretty(snapshot)?; let tmp_path = target.with_extension("json.tmp"); std::fs::write(&tmp_path, &json)?; if let Err(err) = std::fs::rename(&tmp_path, &target) { let _ = std::fs::remove_file(&tmp_path); return Err(err); } Ok(()) } pub(super) fn clear_path(path: &Path) -> std::io::Result<()> { match std::fs::remove_file(path) { Ok(()) => Ok(()), Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()), Err(err) => Err(err), } } pub fn save(snapshot: &SessionSnapshot) { let path = session_path(); if let Err(err) = save_to_path(&path, snapshot) { crate::logging::session_save_failed(&path, &err.to_string()); return; } crate::logging::session_saved(&path, snapshot.workspaces.len()); } pub fn clear() { let path = session_path(); if let Err(err) = clear_path(&path) { crate::logging::session_clear_failed(&path, &err.to_string()); return; } crate::logging::session_cleared(&path); } pub fn load() -> Option { let path = session_path(); if !path.exists() { return None; } let content = match std::fs::read_to_string(&path) { Ok(content) => content, Err(err) => { warn!(err = %err, "failed to read session file"); return None; } }; match parse_snapshot(&content) { Ok(snapshot) => Some(snapshot), Err(err) => { if let Some(version) = snapshot_file_version(&content) { if version > SNAPSHOT_VERSION { warn!( file_version = version, supported = SNAPSHOT_VERSION, "session file is from a newer herdr version, ignoring" ); return None; } } warn!(err = %err, "failed to parse session file, ignoring"); None } } } #[cfg(test)] mod tests { use super::*; use crate::app::state::AgentPanelScope; fn temp_session_path(name: &str) -> PathBuf { let unique = format!( "herdr-session-tests-{}-{}-{}", name, std::process::id(), std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_nanos() ); std::env::temp_dir().join(unique).join("session.json") } fn empty_snapshot() -> SessionSnapshot { SessionSnapshot { version: SNAPSHOT_VERSION, workspaces: vec![], active: None, selected: 0, agent_panel_scope: AgentPanelScope::CurrentWorkspace, sidebar_width: Some(26), sidebar_section_split: Some(0.5), } } #[test] fn clear_path_removes_existing_session_file() { let path = temp_session_path("clear-existing"); save_to_path(&path, &empty_snapshot()).unwrap(); clear_path(&path).unwrap(); assert!(!path.exists()); } #[test] fn clear_path_ignores_missing_session_file() { let path = temp_session_path("clear-missing"); clear_path(&path).unwrap(); assert!(!path.exists()); } #[cfg(unix)] #[test] fn save_to_path_preserves_existing_symlink() { let target = temp_session_path("symlink-target"); let link = target.with_file_name("link.json"); save_to_path(&target, &empty_snapshot()).unwrap(); std::os::unix::fs::symlink(&target, &link).unwrap(); let mut snap = empty_snapshot(); snap.selected = 7; save_to_path(&link, &snap).unwrap(); assert!(std::fs::symlink_metadata(&link) .unwrap() .file_type() .is_symlink()); let parsed = parse_snapshot(&std::fs::read_to_string(&target).unwrap()).unwrap(); assert_eq!(parsed.selected, 7); } #[cfg(unix)] #[test] fn save_to_path_writes_through_dangling_symlink() { let target = temp_session_path("dangling-target"); let link = target.with_file_name("link.json"); std::fs::create_dir_all(target.parent().unwrap()).unwrap(); std::os::unix::fs::symlink(&target, &link).unwrap(); save_to_path(&link, &empty_snapshot()).unwrap(); assert!(std::fs::symlink_metadata(&link) .unwrap() .file_type() .is_symlink()); assert!(target.exists()); } #[cfg(unix)] #[test] fn save_to_path_resolves_relative_symlink() { let session = temp_session_path("relative-symlink"); let dir = session.parent().unwrap(); std::fs::create_dir_all(dir).unwrap(); let target = dir.join("real.json"); let link = dir.join("link.json"); std::os::unix::fs::symlink("real.json", &link).unwrap(); save_to_path(&link, &empty_snapshot()).unwrap(); assert!(std::fs::symlink_metadata(&link) .unwrap() .file_type() .is_symlink()); assert!(target.exists()); } }