use std::path::PathBuf;
use anyhow::{Context, Result, bail};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::config::WarrenConfig;
use crate::instance;
use crate::session::scan::{SessionApp, scan_running_apps};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionSnapshot {
pub session: SessionMeta,
pub shell: ShellState,
pub warren: WarrenState,
#[serde(default)]
pub apps: Vec<SessionApp>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionMeta {
pub name: String,
pub created_at: DateTime<Utc>,
pub hostname: String,
pub desktop: String,
pub session_type: String,
pub warren_version: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ShellState {
pub cwd: String,
pub shell: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct WarrenState {
#[serde(default)]
pub instances: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct SnapshotSummary {
pub name: String,
pub created_at: DateTime<Utc>,
pub app_count: usize,
pub size_bytes: u64,
}
pub fn validate_session_name(name: &str) -> Result<()> {
instance::validate_alias(name)
}
pub fn default_snapshot_name() -> String {
format!("session-{}", Utc::now().format("%Y%m%d-%H%M%S"))
}
pub fn snapshot_dir(name: &str) -> PathBuf {
WarrenConfig::sessions_dir().join(name)
}
pub fn snapshot_file(name: &str) -> PathBuf {
snapshot_dir(name).join("session.toml")
}
pub fn collect_snapshot(name: &str, config: &WarrenConfig) -> Result<SessionSnapshot> {
validate_session_name(name)?;
let cwd = std::env::current_dir()
.ok()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|| String::from("~"));
let shell = crate::shell::detect_shell().to_string();
Ok(SessionSnapshot {
session: SessionMeta {
name: name.to_string(),
created_at: Utc::now(),
hostname: hostname(),
desktop: std::env::var("XDG_CURRENT_DESKTOP")
.or_else(|_| std::env::var("DESKTOP_SESSION"))
.unwrap_or_else(|_| "unknown".to_string()),
session_type: std::env::var("XDG_SESSION_TYPE").unwrap_or_else(|_| {
if std::env::var_os("WAYLAND_DISPLAY").is_some() {
"wayland".to_string()
} else if std::env::var_os("DISPLAY").is_some() {
"x11".to_string()
} else {
"tty".to_string()
}
}),
warren_version: env!("CARGO_PKG_VERSION").to_string(),
},
shell: ShellState { cwd, shell },
warren: WarrenState {
instances: list_warren_instances(config),
},
apps: scan_running_apps(),
})
}
pub fn save_snapshot(snapshot: &SessionSnapshot, keep: usize) -> Result<(PathBuf, Vec<String>)> {
let dir = snapshot_dir(&snapshot.session.name);
std::fs::create_dir_all(&dir)
.with_context(|| format!("failed to create session dir {}", dir.display()))?;
let path = dir.join("session.toml");
let content =
toml::to_string_pretty(snapshot).context("failed to serialize session snapshot")?;
std::fs::write(&path, content)
.with_context(|| format!("failed to write session to {}", path.display()))?;
let pruned = prune_snapshots(keep)?;
Ok((path, pruned))
}
pub fn load_snapshot(name: &str) -> Result<SessionSnapshot> {
let path = snapshot_file(name);
if !path.exists() {
bail!("session '{}' not found", name);
}
let content = std::fs::read_to_string(&path)
.with_context(|| format!("failed to read session from {}", path.display()))?;
let snapshot: SessionSnapshot = toml::from_str(&content)
.with_context(|| format!("failed to parse session from {}", path.display()))?;
Ok(snapshot)
}
pub fn list_snapshots() -> Result<Vec<SnapshotSummary>> {
let dir = WarrenConfig::sessions_dir();
if !dir.exists() {
return Ok(Vec::new());
}
let mut out = Vec::new();
for entry in std::fs::read_dir(&dir)
.with_context(|| format!("failed to read sessions dir {}", dir.display()))?
{
let entry = entry?;
if !entry.path().is_dir() {
continue;
}
let name = entry.file_name().to_string_lossy().to_string();
let file = entry.path().join("session.toml");
if !file.exists() {
continue;
}
let size_bytes = std::fs::metadata(&file).map(|m| m.len()).unwrap_or(0);
match std::fs::read_to_string(&file) {
Ok(content) => match toml::from_str::<SessionSnapshot>(&content) {
Ok(snap) => out.push(SnapshotSummary {
name,
created_at: snap.session.created_at,
app_count: snap.apps.len(),
size_bytes,
}),
Err(_) => continue, },
Err(_) => continue,
}
}
out.sort_by_key(|b| std::cmp::Reverse(b.created_at));
Ok(out)
}
pub fn latest_snapshot() -> Result<SessionSnapshot> {
let mut all = list_snapshots()?;
if all.is_empty() {
bail!("no saved sessions. Run 'warren session save' first.");
}
let first = all.remove(0);
load_snapshot(&first.name)
}
pub fn remove_snapshot(name: &str) -> Result<()> {
let dir = snapshot_dir(name);
if !dir.exists() {
bail!("session '{}' not found", name);
}
std::fs::remove_dir_all(&dir)
.with_context(|| format!("failed to remove session {}", dir.display()))?;
Ok(())
}
pub fn prune_snapshots(keep: usize) -> Result<Vec<String>> {
let dir = WarrenConfig::sessions_dir();
if !dir.exists() {
return Ok(Vec::new());
}
let mut entries: Vec<(String, Option<DateTime<Utc>>, std::time::SystemTime)> = Vec::new();
for entry in std::fs::read_dir(&dir)? {
let entry = entry?;
if !entry.path().is_dir() {
continue;
}
let name = entry.file_name().to_string_lossy().to_string();
let file = entry.path().join("session.toml");
let created_at = std::fs::read_to_string(&file)
.ok()
.and_then(|c| toml::from_str::<SessionSnapshot>(&c).ok())
.map(|s| s.session.created_at);
let mtime = std::fs::metadata(&file)
.and_then(|m| m.modified())
.or_else(|_| std::fs::metadata(entry.path()).and_then(|m| m.modified()))
.unwrap_or(std::time::UNIX_EPOCH);
entries.push((name, created_at, mtime));
}
entries.sort_by(|a, b| match (&a.1, &b.1) {
(Some(x), Some(y)) => y.cmp(x),
(Some(_), None) => std::cmp::Ordering::Less,
(None, Some(_)) => std::cmp::Ordering::Greater,
(None, None) => b.2.cmp(&a.2),
});
let mut pruned = Vec::new();
for (name, _, _) in entries.into_iter().skip(keep) {
let dir = snapshot_dir(&name);
if std::fs::remove_dir_all(&dir).is_ok() {
pruned.push(name);
}
}
pruned.sort();
Ok(pruned)
}
fn list_warren_instances(config: &WarrenConfig) -> Vec<String> {
let mut out = Vec::new();
let Ok(rd) = std::fs::read_dir(&config.paths.instances_dir) else {
return out;
};
for entry in rd.filter_map(|e| e.ok()) {
if !entry.path().is_dir() {
continue;
}
if entry.path().join("metadata.toml").exists()
&& let Some(name) = entry.file_name().to_str().map(|s| s.to_string())
{
out.push(name);
}
}
out.sort();
out
}
fn hostname() -> String {
std::fs::read_to_string("/etc/hostname")
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.or_else(|| std::env::var("HOSTNAME").ok().filter(|s| !s.is_empty()))
.unwrap_or_else(|| "localhost".to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_name_is_valid_alias() {
let name = default_snapshot_name();
assert!(
validate_session_name(&name).is_ok(),
"bad default: {}",
name
);
}
#[test]
fn prune_keeps_newest() {
let base = std::env::temp_dir().join(format!("warren-test-prune-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(&base).unwrap();
for i in 0..4 {
let d = base.join(format!("session-{}", i));
std::fs::create_dir_all(&d).unwrap();
std::fs::write(d.join("session.toml"), "invalid = ").unwrap();
let t =
std::time::SystemTime::now() - std::time::Duration::from_secs(100 - i as u64 * 10);
let f = std::fs::File::options()
.write(true)
.open(d.join("session.toml"))
.unwrap();
f.set_modified(t).unwrap();
}
let mut names: Vec<String> = std::fs::read_dir(&base)
.unwrap()
.filter_map(|e| e.ok())
.map(|e| e.file_name().to_string_lossy().to_string())
.collect();
names.sort();
assert_eq!(names.len(), 4);
let _ = std::fs::remove_dir_all(&base);
}
}