use std::collections::HashMap;
use std::io::{Read, Write};
use std::path::Path;
use serde::{Deserialize, Serialize};
use crate::layout::Layout;
use crate::pane::{Pane, PaneLaunch};
use crate::project::RestartPolicy;
use crate::render::BorderStyle;
use crate::tab::TabManager;
pub const SNAPSHOT_VERSION: u32 = 3;
pub const MIN_SUPPORTED_VERSION: u32 = 1;
const SCROLLBACK_SOFT_WARN_BYTES: u32 = 100 * 1024 * 1024;
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct WorkspaceSnapshot {
pub version: u32,
pub shell: String,
pub border_style: BorderStyle,
pub show_status_bar: bool,
#[serde(default = "default_true")]
pub show_tab_bar: bool,
#[serde(default = "default_scrollback")]
pub scrollback: usize,
pub active_tab: usize,
pub tabs: Vec<TabSnapshot>,
}
fn default_true() -> bool {
true
}
fn default_scrollback() -> usize {
10_000
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct TabSnapshot {
pub name: String,
pub layout: Layout,
pub active_pane: usize,
#[serde(default)]
pub zoomed_pane: Option<usize>,
#[serde(default)]
pub broadcast: bool,
pub panes: Vec<PaneSnapshot>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct PaneSnapshot {
pub id: usize,
pub launch: PaneLaunch,
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub cwd: Option<String>,
#[serde(default)]
pub env: HashMap<String, String>,
#[serde(default)]
pub restart: RestartPolicy,
#[serde(default)]
pub shell: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scrollback: Option<ScrollbackBlob>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cursor_pos: Option<(u16, u16)>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ScrollbackBlob {
pub encoding: ScrollbackEncoding,
pub rows: u32,
pub bytes_uncompressed: u32,
pub payload: Vec<u8>,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ScrollbackEncoding {
#[default]
BincodeGz,
}
#[allow(dead_code)]
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct RowSnapshot {
pub text: String,
pub attrs: Vec<u8>,
}
impl ScrollbackBlob {
#[allow(dead_code)]
pub fn encode_bincode_gz(rows: &[RowSnapshot]) -> anyhow::Result<Self> {
let raw = bincode::serialize(rows)
.map_err(|e| anyhow::anyhow!("bincode encode scrollback: {e}"))?;
let bytes_uncompressed = u32::try_from(raw.len()).unwrap_or(u32::MAX);
let mut encoder = flate2::write::GzEncoder::new(
Vec::with_capacity(raw.len() / 4),
flate2::Compression::default(),
);
encoder
.write_all(&raw)
.map_err(|e| anyhow::anyhow!("gzip encode scrollback: {e}"))?;
let payload = encoder
.finish()
.map_err(|e| anyhow::anyhow!("gzip finish scrollback: {e}"))?;
Ok(Self {
encoding: ScrollbackEncoding::BincodeGz,
rows: u32::try_from(rows.len()).unwrap_or(u32::MAX),
bytes_uncompressed,
payload,
})
}
#[allow(dead_code)]
pub fn decode(&self) -> anyhow::Result<Option<Vec<RowSnapshot>>> {
match self.encoding {
ScrollbackEncoding::BincodeGz => {
let mut decoder = flate2::read::GzDecoder::new(&self.payload[..]);
let mut buf =
Vec::with_capacity(self.bytes_uncompressed.min(64 * 1024 * 1024) as usize);
decoder
.read_to_end(&mut buf)
.map_err(|e| anyhow::anyhow!("gzip decode scrollback: {e}"))?;
let rows: Vec<RowSnapshot> = bincode::deserialize(&buf)
.map_err(|e| anyhow::anyhow!("bincode decode scrollback: {e}"))?;
Ok(Some(rows))
}
}
}
}
impl WorkspaceSnapshot {
#[allow(clippy::too_many_arguments)]
pub fn from_live(
tab_mgr: &TabManager,
tab_name: &str,
layout: &Layout,
panes: &HashMap<usize, Pane>,
active_pane: usize,
zoomed_pane: Option<usize>,
broadcast: bool,
restart_policies: &HashMap<usize, RestartPolicy>,
shell: &str,
border_style: BorderStyle,
show_status_bar: bool,
show_tab_bar: bool,
scrollback: usize,
) -> Self {
let mut tabs = Vec::with_capacity(tab_mgr.count);
for i in 0..tab_mgr.count {
if i == tab_mgr.active_idx {
tabs.push(TabSnapshot {
name: tab_name.to_string(),
layout: layout.clone(),
active_pane,
zoomed_pane,
broadcast,
panes: snapshot_panes(layout, panes, restart_policies),
});
} else if let Some(tab) = tab_mgr.get_inactive(i) {
tabs.push(TabSnapshot {
name: tab.name.clone(),
layout: tab.layout.clone(),
active_pane: tab.active_pane,
zoomed_pane: tab.zoomed_pane,
broadcast: tab.broadcast,
panes: snapshot_panes(&tab.layout, &tab.panes, &tab.restart_policies),
});
}
}
Self {
version: SNAPSHOT_VERSION,
shell: shell.to_string(),
border_style,
show_status_bar,
show_tab_bar,
scrollback,
active_tab: tab_mgr.active_idx,
tabs,
}
}
pub fn validate(&self) -> anyhow::Result<()> {
if !is_supported_version(self.version) {
anyhow::bail!(
"unsupported snapshot version: {found} (current = {current}, \
minimum supported = {min}). Run `ezpn upgrade-snapshot <path>` \
on older files.",
found = self.version,
current = SNAPSHOT_VERSION,
min = MIN_SUPPORTED_VERSION
);
}
if self.tabs.is_empty() {
anyhow::bail!("snapshot has no tabs");
}
for (ti, tab) in self.tabs.iter().enumerate() {
let mut snapshot_ids: Vec<usize> = tab.panes.iter().map(|pane| pane.id).collect();
snapshot_ids.sort_unstable();
snapshot_ids.dedup();
let mut layout_ids = tab.layout.pane_ids();
layout_ids.sort_unstable();
if snapshot_ids != layout_ids {
anyhow::bail!("snapshot panes do not match layout leaves in tab {}", ti);
}
if !layout_ids.contains(&tab.active_pane) {
anyhow::bail!(
"snapshot active pane does not exist in layout in tab {}",
ti
);
}
for pane in &tab.panes {
if let Some(blob) = &pane.scrollback {
if blob.bytes_uncompressed > SCROLLBACK_SOFT_WARN_BYTES {
eprintln!(
"ezpn: snapshot tab {ti} pane {pid} scrollback is \
{mb} MB uncompressed (soft cap {cap} MB)",
pid = pane.id,
mb = blob.bytes_uncompressed / (1024 * 1024),
cap = SCROLLBACK_SOFT_WARN_BYTES / (1024 * 1024),
);
}
}
}
}
if self.active_tab >= self.tabs.len() {
anyhow::bail!("snapshot active_tab index out of range");
}
Ok(())
}
}
pub fn is_supported_version(v: u32) -> bool {
(MIN_SUPPORTED_VERSION..=SNAPSHOT_VERSION).contains(&v)
}
fn snapshot_panes(
layout: &Layout,
panes: &HashMap<usize, Pane>,
restart_policies: &HashMap<usize, RestartPolicy>,
) -> Vec<PaneSnapshot> {
layout
.pane_ids()
.into_iter()
.map(|id| {
let pane = panes.get(&id);
PaneSnapshot {
id,
launch: pane
.map(|p| p.launch().clone())
.unwrap_or(PaneLaunch::Shell),
name: pane.and_then(|p| p.name().map(|s| s.to_string())),
cwd: pane
.and_then(|p| p.live_cwd())
.map(|p| p.to_string_lossy().to_string()),
env: pane.map(|p| p.initial_env().clone()).unwrap_or_default(),
restart: restart_policies.get(&id).cloned().unwrap_or_default(),
shell: pane.and_then(|p| p.initial_shell().map(|s| s.to_string())),
scrollback: None,
cursor_pos: None,
}
})
.collect()
}
fn migrate_v1(v1_json: &serde_json::Value) -> anyhow::Result<WorkspaceSnapshot> {
let shell = v1_json["shell"].as_str().unwrap_or("/bin/sh").to_string();
let active_pane = v1_json["active_pane"].as_u64().unwrap_or(0) as usize;
let border_style: BorderStyle =
serde_json::from_value(v1_json["border_style"].clone()).unwrap_or(BorderStyle::Rounded);
let show_status_bar = v1_json["show_status_bar"].as_bool().unwrap_or(true);
let layout: Layout = serde_json::from_value(v1_json["layout"].clone())?;
let v1_panes: Vec<serde_json::Value> = v1_json["panes"].as_array().cloned().unwrap_or_default();
let panes: Vec<PaneSnapshot> = v1_panes
.into_iter()
.map(|p| PaneSnapshot {
id: p["id"].as_u64().unwrap_or(0) as usize,
launch: serde_json::from_value(p["launch"].clone()).unwrap_or(PaneLaunch::Shell),
name: None,
cwd: None,
env: HashMap::new(),
restart: RestartPolicy::default(),
shell: None,
scrollback: None,
cursor_pos: None,
})
.collect();
let tab = TabSnapshot {
name: "1".to_string(),
layout,
active_pane,
zoomed_pane: None,
broadcast: false,
panes,
};
Ok(WorkspaceSnapshot {
version: SNAPSHOT_VERSION,
shell,
border_style,
show_status_bar,
show_tab_bar: true,
scrollback: 10_000,
active_tab: 0,
tabs: vec![tab],
})
}
pub fn load_snapshot(path: impl AsRef<Path>) -> anyhow::Result<WorkspaceSnapshot> {
let (snapshot, _on_disk) = load_snapshot_with_meta(path)?;
Ok(snapshot)
}
pub fn load_snapshot_with_meta(path: impl AsRef<Path>) -> anyhow::Result<(WorkspaceSnapshot, u32)> {
validate_path(path.as_ref())?;
let content = std::fs::read_to_string(path)?;
let (snapshot, on_disk) = parse_snapshot_str(&content)?;
snapshot.validate()?;
Ok((snapshot, on_disk))
}
fn parse_snapshot_str(content: &str) -> anyhow::Result<(WorkspaceSnapshot, u32)> {
let raw: serde_json::Value = serde_json::from_str(content)?;
let version = raw["version"].as_u64().unwrap_or(0) as u32;
if !is_supported_version(version) {
anyhow::bail!(
"unsupported snapshot version: {found} (current = {current}, \
minimum supported = {min}). Run `ezpn upgrade-snapshot <path>` \
on older files.",
found = version,
current = SNAPSHOT_VERSION,
min = MIN_SUPPORTED_VERSION
);
}
let snapshot = match version {
1 => migrate_v1(&raw)?,
2 => migrate_v2(raw)?,
_ => serde_json::from_value::<WorkspaceSnapshot>(raw)?,
};
Ok((snapshot, version))
}
fn migrate_v2(mut raw: serde_json::Value) -> anyhow::Result<WorkspaceSnapshot> {
if let serde_json::Value::Object(map) = &mut raw {
map.insert(
"version".into(),
serde_json::Value::Number(SNAPSHOT_VERSION.into()),
);
}
let snap: WorkspaceSnapshot = serde_json::from_value(raw)?;
Ok(snap)
}
pub fn save_snapshot(path: impl AsRef<Path>, snapshot: &WorkspaceSnapshot) -> anyhow::Result<()> {
validate_path(path.as_ref())?;
save_snapshot_raw(path, snapshot)
}
fn save_snapshot_raw(path: impl AsRef<Path>, snapshot: &WorkspaceSnapshot) -> anyhow::Result<()> {
let json = serde_json::to_string_pretty(snapshot)?;
let path = path.as_ref();
let tmp = path.with_extension(format!("tmp.{}", std::process::id()));
std::fs::write(&tmp, &json)?;
if let Err(e) = std::fs::rename(&tmp, path) {
let _ = std::fs::remove_file(&tmp);
return Err(e.into());
}
Ok(())
}
pub fn auto_save_dir() -> Option<std::path::PathBuf> {
let dir = if let Ok(data_dir) = std::env::var("XDG_DATA_HOME") {
std::path::PathBuf::from(data_dir)
.join("ezpn")
.join("sessions")
} else if let Ok(home) = std::env::var("HOME") {
std::path::PathBuf::from(home)
.join(".local")
.join("share")
.join("ezpn")
.join("sessions")
} else {
return None;
};
std::fs::create_dir_all(&dir).ok()?;
Some(dir)
}
pub fn auto_save(session_name: &str, snapshot: &WorkspaceSnapshot) {
if let Some(dir) = auto_save_dir() {
let path = dir.join(format!("{}.json", session_name));
if let Err(e) = save_snapshot_raw(&path, snapshot) {
eprintln!("ezpn: auto-save failed: {e}");
}
}
}
#[allow(dead_code)] pub fn auto_load(session_name: &str) -> Option<WorkspaceSnapshot> {
let dir = auto_save_dir()?;
let path = dir.join(format!("{}.json", session_name));
if !path.exists() {
return None;
}
let content = std::fs::read_to_string(&path).ok()?;
let (snapshot, _on_disk) = parse_snapshot_str(&content).ok()?;
snapshot.validate().ok()?;
Some(snapshot)
}
fn validate_path(path: &Path) -> anyhow::Result<()> {
let s = path.to_string_lossy();
if s.contains("..") {
anyhow::bail!("path traversal (..) not allowed: {}", s);
}
for component in path.components() {
if let std::path::Component::Normal(name) = component {
let name = name.to_string_lossy();
if name.starts_with('.') && !name.contains("ezpn") {
anyhow::bail!("refusing to use hidden path: {}", s);
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::layout::Layout;
fn make_v2_snapshot() -> WorkspaceSnapshot {
WorkspaceSnapshot {
version: SNAPSHOT_VERSION,
shell: "/bin/zsh".to_string(),
border_style: BorderStyle::Double,
show_status_bar: false,
show_tab_bar: true,
scrollback: 10_000,
active_tab: 0,
tabs: vec![TabSnapshot {
name: "1".to_string(),
layout: Layout::from_grid(1, 2),
active_pane: 1,
zoomed_pane: None,
broadcast: false,
panes: vec![
PaneSnapshot {
id: 0,
launch: PaneLaunch::Shell,
name: None,
cwd: None,
env: HashMap::new(),
restart: RestartPolicy::Never,
shell: None,
scrollback: None,
cursor_pos: None,
},
PaneSnapshot {
id: 1,
launch: PaneLaunch::Command("cargo test".to_string()),
name: Some("tests".to_string()),
cwd: Some("/tmp".to_string()),
env: HashMap::new(),
restart: RestartPolicy::OnFailure,
shell: None,
scrollback: None,
cursor_pos: None,
},
],
}],
}
}
#[test]
fn snapshot_validation_rejects_mismatched_panes() {
let mut snapshot = make_v2_snapshot();
snapshot.tabs[0].panes.pop(); assert!(snapshot.validate().is_err());
}
#[test]
fn snapshot_round_trips_json() {
let snapshot = make_v2_snapshot();
let json = serde_json::to_string(&snapshot).expect("serialize snapshot");
let decoded =
serde_json::from_str::<WorkspaceSnapshot>(&json).expect("deserialize snapshot");
assert_eq!(decoded.tabs.len(), 1);
assert_eq!(decoded.tabs[0].active_pane, 1);
assert_eq!(decoded.tabs[0].panes.len(), 2);
assert_eq!(
decoded.tabs[0].panes[1].launch,
PaneLaunch::Command("cargo test".to_string())
);
assert_eq!(decoded.tabs[0].panes[1].name, Some("tests".to_string()));
assert_eq!(decoded.tabs[0].panes[1].restart, RestartPolicy::OnFailure);
}
#[test]
fn validate_path_rejects_relative_dotfiles() {
assert!(validate_path(Path::new(".bashrc")).is_err());
assert!(validate_path(Path::new(".ssh/config")).is_err());
assert!(validate_path(Path::new(".ezpn-session.json")).is_ok());
assert!(validate_path(Path::new("sessions/.ezpn/dev.json")).is_ok());
}
#[test]
fn v1_migration_produces_valid_v2() {
let layout = Layout::from_grid(1, 1);
let layout_json = serde_json::to_value(&layout).expect("serialize layout");
let v1_json = serde_json::json!({
"version": 1,
"shell": "/bin/bash",
"active_pane": 0,
"border_style": "rounded",
"show_status_bar": true,
"layout": layout_json,
"panes": [{ "id": 0, "launch": "shell" }]
});
let snapshot = migrate_v1(&v1_json).expect("migration");
assert_eq!(snapshot.version, SNAPSHOT_VERSION);
assert_eq!(snapshot.tabs.len(), 1);
assert_eq!(snapshot.tabs[0].active_pane, 0);
assert!(snapshot.validate().is_ok());
}
#[test]
fn validate_rejects_out_of_range_active_tab() {
let mut snapshot = make_v2_snapshot();
snapshot.active_tab = 99;
assert!(snapshot.validate().is_err());
}
#[test]
fn multi_tab_round_trip() {
let snapshot = WorkspaceSnapshot {
version: SNAPSHOT_VERSION,
shell: "/bin/zsh".to_string(),
border_style: BorderStyle::Rounded,
show_status_bar: true,
show_tab_bar: false,
scrollback: 5000,
active_tab: 1,
tabs: vec![
TabSnapshot {
name: "editor".to_string(),
layout: Layout::from_grid(1, 1),
active_pane: 0,
zoomed_pane: None,
broadcast: false,
panes: vec![PaneSnapshot {
id: 0,
launch: PaneLaunch::Command("nvim .".to_string()),
name: Some("nvim".to_string()),
cwd: Some("/home/user/project".to_string()),
env: HashMap::new(),
restart: RestartPolicy::Never,
shell: None,
scrollback: None,
cursor_pos: None,
}],
},
TabSnapshot {
name: "server".to_string(),
layout: Layout::from_grid(1, 2),
active_pane: 1,
zoomed_pane: Some(1),
broadcast: true,
panes: vec![
PaneSnapshot {
id: 0,
launch: PaneLaunch::Command("npm run dev".to_string()),
name: Some("dev".to_string()),
cwd: Some("/tmp".to_string()),
env: [("PORT".to_string(), "3000".to_string())].into(),
restart: RestartPolicy::OnFailure,
shell: Some("/bin/bash".to_string()),
scrollback: None,
cursor_pos: None,
},
PaneSnapshot {
id: 1,
launch: PaneLaunch::Shell,
name: None,
cwd: None,
env: HashMap::new(),
restart: RestartPolicy::Never,
shell: None,
scrollback: None,
cursor_pos: None,
},
],
},
],
};
let json = serde_json::to_string_pretty(&snapshot).unwrap();
let decoded: WorkspaceSnapshot = serde_json::from_str(&json).unwrap();
decoded.validate().unwrap();
assert_eq!(decoded.active_tab, 1);
assert_eq!(decoded.scrollback, 5000);
assert!(!decoded.show_tab_bar);
assert_eq!(decoded.tabs.len(), 2);
assert_eq!(decoded.tabs[0].name, "editor");
assert_eq!(decoded.tabs[0].panes[0].name, Some("nvim".to_string()));
assert_eq!(
decoded.tabs[0].panes[0].cwd,
Some("/home/user/project".to_string())
);
assert_eq!(decoded.tabs[1].name, "server");
assert_eq!(decoded.tabs[1].zoomed_pane, Some(1));
assert!(decoded.tabs[1].broadcast);
assert_eq!(decoded.tabs[1].panes[0].restart, RestartPolicy::OnFailure);
assert_eq!(
decoded.tabs[1].panes[0].shell,
Some("/bin/bash".to_string())
);
assert_eq!(
decoded.tabs[1].panes[0].env.get("PORT"),
Some(&"3000".to_string())
);
}
#[test]
fn pane_metadata_defaults_on_missing_fields() {
let json = serde_json::json!({
"version": 2,
"shell": "/bin/sh",
"border_style": "single",
"show_status_bar": true,
"active_tab": 0,
"tabs": [{
"name": "1",
"layout": serde_json::to_value(Layout::from_grid(1, 1)).unwrap(),
"active_pane": 0,
"panes": [{
"id": 0,
"launch": "shell"
}]
}]
});
let snapshot: WorkspaceSnapshot = serde_json::from_value(json).unwrap();
snapshot.validate().unwrap();
let pane = &snapshot.tabs[0].panes[0];
assert_eq!(pane.name, None);
assert_eq!(pane.cwd, None);
assert!(pane.env.is_empty());
assert_eq!(pane.restart, RestartPolicy::Never);
assert_eq!(pane.shell, None);
assert_eq!(snapshot.scrollback, 10_000); assert!(snapshot.show_tab_bar); }
#[test]
fn supported_version_window_includes_v1_v2_v3() {
assert!(is_supported_version(1));
assert!(is_supported_version(2));
assert!(is_supported_version(3));
assert!(!is_supported_version(0));
assert!(!is_supported_version(SNAPSHOT_VERSION + 1));
}
#[test]
fn scrollback_blob_round_trips_text_lossless() {
let rows: Vec<RowSnapshot> = (0..1000)
.map(|i| RowSnapshot {
text: if i % 7 == 0 {
String::new()
} else if i % 11 == 0 {
"한글 wide テスト".repeat(4)
} else {
format!("line {i:04}: the quick brown fox")
},
attrs: if i % 5 == 0 {
vec![1, 2, 3, 4]
} else {
Vec::new()
},
})
.collect();
let blob = ScrollbackBlob::encode_bincode_gz(&rows).expect("encode");
assert_eq!(blob.encoding, ScrollbackEncoding::BincodeGz);
assert_eq!(blob.rows, 1000);
let compressed = blob.payload.len() as f64;
let raw = blob.bytes_uncompressed as f64;
assert!(
raw / compressed >= 4.0,
"expected ≥4× compression on shell-history-like text, got {ratio:.2}× ({compressed} / {raw})",
ratio = raw / compressed,
);
let decoded = blob.decode().expect("decode").expect("known encoding");
assert_eq!(decoded, rows);
}
#[test]
fn v3_snapshot_round_trips_with_scrollback() {
let layout = Layout::from_grid(1, 1);
let blob = ScrollbackBlob::encode_bincode_gz(&[
RowSnapshot {
text: "$ make".into(),
attrs: Vec::new(),
},
RowSnapshot {
text: "ok".into(),
attrs: Vec::new(),
},
])
.unwrap();
let snapshot = WorkspaceSnapshot {
version: SNAPSHOT_VERSION,
shell: "/bin/zsh".into(),
border_style: BorderStyle::Rounded,
show_status_bar: true,
show_tab_bar: true,
scrollback: 10_000,
active_tab: 0,
tabs: vec![TabSnapshot {
name: "1".into(),
layout,
active_pane: 0,
zoomed_pane: None,
broadcast: false,
panes: vec![PaneSnapshot {
id: 0,
launch: PaneLaunch::Shell,
name: None,
cwd: None,
env: HashMap::new(),
restart: RestartPolicy::Never,
shell: None,
scrollback: Some(blob.clone()),
cursor_pos: Some((1, 2)),
}],
}],
};
let json = serde_json::to_string(&snapshot).unwrap();
let decoded: WorkspaceSnapshot = serde_json::from_str(&json).unwrap();
decoded.validate().unwrap();
let pane = &decoded.tabs[0].panes[0];
let restored = pane.scrollback.as_ref().unwrap().decode().unwrap().unwrap();
assert_eq!(restored.len(), 2);
assert_eq!(restored[0].text, "$ make");
assert_eq!(pane.cursor_pos, Some((1, 2)));
}
#[test]
fn v3_writer_omits_scrollback_field_when_none() {
let snapshot = make_v2_snapshot();
let json = serde_json::to_value(&snapshot).unwrap();
let pane0 = &json["tabs"][0]["panes"][0];
assert!(
pane0.get("scrollback").is_none(),
"scrollback must be skipped when None (got {pane0:?})"
);
assert!(pane0.get("cursor_pos").is_none());
}
#[test]
fn parse_snapshot_str_migrates_v2_to_v3() {
let layout = Layout::from_grid(1, 1);
let layout_json = serde_json::to_value(&layout).unwrap();
let v2_doc = serde_json::json!({
"version": 2,
"shell": "/bin/sh",
"border_style": "single",
"show_status_bar": true,
"show_tab_bar": true,
"scrollback": 10000,
"active_tab": 0,
"tabs": [{
"name": "1",
"layout": layout_json,
"active_pane": 0,
"panes": [{ "id": 0, "launch": "shell" }]
}]
});
let s = serde_json::to_string(&v2_doc).unwrap();
let (snap, on_disk) = parse_snapshot_str(&s).unwrap();
assert_eq!(on_disk, 2);
assert_eq!(snap.version, SNAPSHOT_VERSION);
assert!(snap.tabs[0].panes[0].scrollback.is_none());
assert!(snap.tabs[0].panes[0].cursor_pos.is_none());
}
#[test]
fn parse_snapshot_str_migration_is_idempotent() {
let layout = Layout::from_grid(1, 1);
let layout_json = serde_json::to_value(&layout).unwrap();
let v2_doc = serde_json::json!({
"version": 2,
"shell": "/bin/sh",
"border_style": "single",
"show_status_bar": true,
"show_tab_bar": true,
"scrollback": 10000,
"active_tab": 0,
"tabs": [{
"name": "1",
"layout": layout_json,
"active_pane": 0,
"panes": [{ "id": 0, "launch": "shell" }]
}]
});
let v2_str = serde_json::to_string(&v2_doc).unwrap();
let (s1, on_disk1) = parse_snapshot_str(&v2_str).unwrap();
let v3_str = serde_json::to_string(&s1).unwrap();
let (s2, on_disk2) = parse_snapshot_str(&v3_str).unwrap();
assert_eq!(on_disk1, 2);
assert_eq!(on_disk2, SNAPSHOT_VERSION);
assert_eq!(s1.version, s2.version);
assert_eq!(s1.tabs.len(), s2.tabs.len());
}
#[test]
fn parse_snapshot_str_rejects_unknown_version_with_pointer_to_upgrade_cli() {
let layout = Layout::from_grid(1, 1);
let layout_json = serde_json::to_value(&layout).unwrap();
let bad_doc = serde_json::json!({
"version": 9999,
"shell": "/bin/sh",
"border_style": "single",
"show_status_bar": true,
"show_tab_bar": true,
"scrollback": 10000,
"active_tab": 0,
"tabs": [{
"name": "1",
"layout": layout_json,
"active_pane": 0,
"panes": [{ "id": 0, "launch": "shell" }]
}]
});
let s = serde_json::to_string(&bad_doc).unwrap();
let err = parse_snapshot_str(&s).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("9999"),
"error must mention bad version: {msg}"
);
assert!(
msg.contains("upgrade-snapshot"),
"error must point at the CLI: {msg}"
);
}
}