Skip to main content

par_term/session/
storage.rs

1//! File I/O for session persistence
2//!
3//! Sessions are stored in `~/.config/par-term/last_session.yaml`
4
5use super::SessionState;
6use anyhow::{Context, Result};
7use std::path::PathBuf;
8
9/// Get the path to the session state file
10pub fn session_path() -> PathBuf {
11    dirs::config_dir()
12        .unwrap_or_else(|| PathBuf::from("."))
13        .join("par-term")
14        .join("last_session.yaml")
15}
16
17/// Save session state to the default location
18pub fn save_session(state: &SessionState) -> Result<()> {
19    save_session_to(state, session_path())
20}
21
22/// Save session state to a specific file
23pub fn save_session_to(state: &SessionState, path: PathBuf) -> Result<()> {
24    // Ensure parent directory exists
25    if let Some(parent) = path.parent() {
26        std::fs::create_dir_all(parent)
27            .with_context(|| format!("Failed to create config directory {:?}", parent))?;
28    }
29
30    let contents = serde_yaml::to_string(state).context("Failed to serialize session state")?;
31
32    std::fs::write(&path, contents)
33        .with_context(|| format!("Failed to write session state to {:?}", path))?;
34
35    log::info!(
36        "Saved session state ({} windows) to {:?}",
37        state.windows.len(),
38        path
39    );
40    Ok(())
41}
42
43/// Load session state from the default location
44///
45/// Returns `None` if the file doesn't exist or is empty.
46/// Returns an error if the file exists but is corrupt.
47pub fn load_session() -> Result<Option<SessionState>> {
48    load_session_from(session_path())
49}
50
51/// Load session state from a specific file
52pub fn load_session_from(path: PathBuf) -> Result<Option<SessionState>> {
53    if !path.exists() {
54        return Ok(None);
55    }
56
57    let contents = std::fs::read_to_string(&path)
58        .with_context(|| format!("Failed to read session state from {:?}", path))?;
59
60    if contents.trim().is_empty() {
61        return Ok(None);
62    }
63
64    let state: SessionState = serde_yaml::from_str(&contents)
65        .with_context(|| format!("Failed to parse session state from {:?}", path))?;
66
67    log::info!(
68        "Loaded session state ({} windows) from {:?}",
69        state.windows.len(),
70        path
71    );
72    Ok(Some(state))
73}
74
75/// Remove the session state file (e.g., after successful restore)
76pub fn clear_session() -> Result<()> {
77    let path = session_path();
78    if path.exists() {
79        std::fs::remove_file(&path)
80            .with_context(|| format!("Failed to remove session state file {:?}", path))?;
81    }
82    Ok(())
83}
84
85#[cfg(test)]
86mod tests {
87    use super::*;
88    use crate::session::{SessionState, SessionTab, SessionWindow};
89    use tempfile::tempdir;
90
91    fn sample_session() -> SessionState {
92        SessionState {
93            saved_at: "2025-01-01T00:00:00Z".to_string(),
94            windows: vec![SessionWindow {
95                position: (100, 200),
96                size: (800, 600),
97                tabs: vec![SessionTab {
98                    cwd: Some("/home/user/work".to_string()),
99                    title: "work".to_string(),
100                    pane_layout: None,
101                }],
102                active_tab_index: 0,
103            }],
104        }
105    }
106
107    #[test]
108    fn test_load_nonexistent_file() {
109        let temp = tempdir().unwrap();
110        let path = temp.path().join("nonexistent.yaml");
111        let result = load_session_from(path).unwrap();
112        assert!(result.is_none());
113    }
114
115    #[test]
116    fn test_load_empty_file() {
117        let temp = tempdir().unwrap();
118        let path = temp.path().join("empty.yaml");
119        std::fs::write(&path, "").unwrap();
120        let result = load_session_from(path).unwrap();
121        assert!(result.is_none());
122    }
123
124    #[test]
125    fn test_load_corrupt_file() {
126        let temp = tempdir().unwrap();
127        let path = temp.path().join("corrupt.yaml");
128        std::fs::write(&path, "not: valid: yaml: [[[").unwrap();
129        let result = load_session_from(path);
130        assert!(result.is_err());
131    }
132
133    #[test]
134    fn test_save_and_load_roundtrip() {
135        let temp = tempdir().unwrap();
136        let path = temp.path().join("session.yaml");
137
138        let state = sample_session();
139        save_session_to(&state, path.clone()).unwrap();
140
141        let loaded = load_session_from(path).unwrap().unwrap();
142        assert_eq!(loaded.windows.len(), 1);
143        assert_eq!(loaded.windows[0].position, (100, 200));
144        assert_eq!(loaded.windows[0].size, (800, 600));
145        assert_eq!(loaded.windows[0].tabs.len(), 1);
146        assert_eq!(
147            loaded.windows[0].tabs[0].cwd,
148            Some("/home/user/work".to_string())
149        );
150        assert_eq!(loaded.windows[0].tabs[0].title, "work");
151    }
152
153    #[test]
154    fn test_save_creates_parent_directory() {
155        let temp = tempdir().unwrap();
156        let path = temp.path().join("nested").join("dir").join("session.yaml");
157
158        let state = sample_session();
159        save_session_to(&state, path.clone()).unwrap();
160        assert!(path.exists());
161    }
162
163    #[test]
164    fn test_serialization_with_pane_layout() {
165        use crate::pane::SplitDirection;
166        use crate::session::SessionPaneNode;
167
168        let state = SessionState {
169            saved_at: "2025-01-01T00:00:00Z".to_string(),
170            windows: vec![SessionWindow {
171                position: (0, 0),
172                size: (1920, 1080),
173                tabs: vec![SessionTab {
174                    cwd: Some("/home/user".to_string()),
175                    title: "dev".to_string(),
176                    pane_layout: Some(SessionPaneNode::Split {
177                        direction: SplitDirection::Vertical,
178                        ratio: 0.5,
179                        first: Box::new(SessionPaneNode::Leaf {
180                            cwd: Some("/home/user/code".to_string()),
181                        }),
182                        second: Box::new(SessionPaneNode::Split {
183                            direction: SplitDirection::Horizontal,
184                            ratio: 0.6,
185                            first: Box::new(SessionPaneNode::Leaf {
186                                cwd: Some("/home/user/logs".to_string()),
187                            }),
188                            second: Box::new(SessionPaneNode::Leaf {
189                                cwd: Some("/home/user/tests".to_string()),
190                            }),
191                        }),
192                    }),
193                }],
194                active_tab_index: 0,
195            }],
196        };
197
198        let temp = tempdir().unwrap();
199        let path = temp.path().join("pane_session.yaml");
200
201        save_session_to(&state, path.clone()).unwrap();
202        let loaded = load_session_from(path).unwrap().unwrap();
203
204        // Verify the nested pane layout survived roundtrip
205        let tab = &loaded.windows[0].tabs[0];
206        assert!(tab.pane_layout.is_some());
207        match tab.pane_layout.as_ref().unwrap() {
208            SessionPaneNode::Split {
209                direction, ratio, ..
210            } => {
211                assert_eq!(*direction, SplitDirection::Vertical);
212                assert!((ratio - 0.5).abs() < f32::EPSILON);
213            }
214            _ => panic!("Expected Split at root"),
215        }
216    }
217
218    #[test]
219    fn test_split_direction_serde() {
220        use crate::pane::SplitDirection;
221
222        let h = SplitDirection::Horizontal;
223        let v = SplitDirection::Vertical;
224
225        let h_yaml = serde_yaml::to_string(&h).unwrap();
226        let v_yaml = serde_yaml::to_string(&v).unwrap();
227
228        let h_back: SplitDirection = serde_yaml::from_str(&h_yaml).unwrap();
229        let v_back: SplitDirection = serde_yaml::from_str(&v_yaml).unwrap();
230
231        assert_eq!(h, h_back);
232        assert_eq!(v, v_back);
233    }
234
235    #[test]
236    fn test_clear_session() {
237        let temp = tempdir().unwrap();
238        let path = temp.path().join("to_clear.yaml");
239        std::fs::write(&path, "test").unwrap();
240        assert!(path.exists());
241
242        // We can't easily test clear_session() since it uses fixed path,
243        // but we can test the file removal logic
244        std::fs::remove_file(&path).unwrap();
245        assert!(!path.exists());
246    }
247}