use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use crate::bookmarks::{default_base, encode_path};
fn is_false(b: &bool) -> bool {
!*b
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct SavedTab {
pub root: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cursor: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub preview: Option<String>,
#[serde(default, skip_serializing_if = "is_false")]
pub preview_diff: bool,
#[serde(default, skip_serializing_if = "is_false")]
pub show_hidden: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub open_dir: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct SavedSession {
#[serde(default, skip_serializing_if = "String::is_empty")]
pub dir: String,
#[serde(default)]
pub active: usize,
#[serde(default)]
pub tabs: Vec<SavedTab>,
}
pub struct SessionStore {
path: PathBuf,
dir: String,
}
impl SessionStore {
pub fn load(start_dir: &Path) -> Self {
Self::with_base(default_base(), start_dir)
}
pub fn with_base(base: PathBuf, start_dir: &Path) -> Self {
Self {
path: base
.join("sessions")
.join(format!("{}.toml", encode_path(start_dir))),
dir: start_dir.to_string_lossy().to_string(),
}
}
pub fn read(&self) -> Option<SavedSession> {
let text = std::fs::read_to_string(&self.path).ok()?;
toml::from_str(&text).ok()
}
pub fn write(&self, mut sess: SavedSession) -> Result<()> {
sess.dir = self.dir.clone();
if let Some(parent) = self.path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("セッション保存先の作成: {}", parent.display()))?;
}
let text = toml::to_string(&sess).context("セッションの TOML 整形")?;
let mut tmp = self.path.clone().into_os_string();
tmp.push(".tmp");
let tmp = PathBuf::from(tmp);
std::fs::write(&tmp, text)
.with_context(|| format!("セッション一時保存: {}", tmp.display()))?;
std::fs::rename(&tmp, &self.path)
.with_context(|| format!("セッション保存: {}", self.path.display()))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn write_read_round_trip_records_dir_and_tabs() {
let base = std::env::temp_dir().join("konoma_session_store_test_base");
let _ = std::fs::remove_dir_all(&base);
let proj = std::env::temp_dir().join("konoma_session_store_test_proj");
std::fs::create_dir_all(&proj).unwrap();
let store = SessionStore::with_base(base.clone(), &proj);
let sess = SavedSession {
dir: String::new(), active: 1,
tabs: vec![
SavedTab {
root: "/tmp/a".into(),
cursor: Some("/tmp/a/x.txt".into()),
preview: None,
..Default::default()
},
SavedTab {
root: "/tmp/b".into(),
cursor: None,
preview: Some("/tmp/b/y.md".into()),
..Default::default()
},
],
};
store.write(sess.clone()).unwrap();
let got = SessionStore::with_base(base.clone(), &proj).read().unwrap();
assert_eq!(got.dir, proj.to_string_lossy(), "起動 dir を記録する");
assert_eq!(got.active, 1);
assert_eq!(got.tabs, sess.tabs);
let proj2 = std::env::temp_dir().join("konoma_session_store_test_proj2");
std::fs::create_dir_all(&proj2).unwrap();
assert!(SessionStore::with_base(base.clone(), &proj2)
.read()
.is_none());
std::fs::remove_dir_all(&base).ok();
std::fs::remove_dir_all(&proj).ok();
std::fs::remove_dir_all(&proj2).ok();
}
#[test]
fn missing_or_corrupt_file_reads_none() {
let base = std::env::temp_dir().join("konoma_session_corrupt_test_base");
let _ = std::fs::remove_dir_all(&base);
let proj = std::env::temp_dir().join("konoma_session_corrupt_test_proj");
std::fs::create_dir_all(&proj).unwrap();
let store = SessionStore::with_base(base.clone(), &proj);
assert!(store.read().is_none(), "ファイル無し = セッション無し");
std::fs::create_dir_all(store.path.parent().unwrap()).unwrap();
std::fs::write(&store.path, "this is [not toml").unwrap();
assert!(store.read().is_none(), "壊れたファイルはクラッシュせず無視");
std::fs::remove_dir_all(&base).ok();
std::fs::remove_dir_all(&proj).ok();
}
#[test]
fn write_replaces_in_place_and_leaves_no_temp() {
let base = std::env::temp_dir().join("konoma_session_atomic_test_base");
let _ = std::fs::remove_dir_all(&base);
let proj = std::env::temp_dir().join("konoma_session_atomic_test_proj");
std::fs::create_dir_all(&proj).unwrap();
let store = SessionStore::with_base(base.clone(), &proj);
let mk = |root: &str| SavedSession {
active: 0,
tabs: vec![SavedTab {
root: root.into(),
..Default::default()
}],
..Default::default()
};
store.write(mk("/x")).unwrap();
store.write(mk("/y")).unwrap();
assert_eq!(
store.read().unwrap().tabs[0].root,
"/y",
"2回目の書込みが反映"
);
let leftovers = std::fs::read_dir(base.join("sessions"))
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| e.file_name().to_string_lossy().ends_with(".tmp"))
.count();
assert_eq!(leftovers, 0, "temp ファイルを残さない");
std::fs::remove_dir_all(&base).ok();
std::fs::remove_dir_all(&proj).ok();
}
}