Skip to main content

tmux_backup/actions/
save.rs

1//! Retrieve session information and panes content save to a backup.
2
3use std::path::{Path, PathBuf};
4
5use async_fs as fs;
6use futures::future::join_all;
7use smol;
8use tempfile::TempDir;
9
10use crate::{Result, management::archive::v1, tmux};
11use tmux_lib::utils;
12
13/// Shell commands that are recognized for prompt line dropping.
14///
15/// When capturing pane content, if the active command is one of these shells,
16/// we can optionally drop the last N lines to avoid capturing the shell prompt.
17const DETECTED_SHELLS: &[&str] = &["zsh", "bash", "fish"];
18
19/// Save the tmux sessions, windows and panes into a backup at `backup_dirpath`.
20///
21/// After saving, this function returns the path to the backup and the number of
22/// sessions, windows and panes.
23///
24/// # Notes
25///
26/// - The `backup_dirpath` folder is assumed to exist (done during catalog initialization).
27/// - Backups have a name similar to `backup-20220731T222948.tar.zst`.
28///
29pub async fn save<P: AsRef<Path>>(
30    backup_dirpath: P,
31    num_lines_to_drop: usize,
32) -> Result<(PathBuf, v1::Overview)> {
33    // Prepare the temp directory.
34    let temp_dir = TempDir::new()?;
35
36    // Save sessions & windows into `metadata.json` in the temp folder.
37    let metadata_task: smol::Task<Result<(PathBuf, PathBuf, u16, u16)>> = {
38        let temp_dirpath = temp_dir.path().to_path_buf();
39
40        smol::spawn(async move {
41            let temp_version_filepath = temp_dirpath.join(v1::VERSION_FILENAME);
42            fs::write(&temp_version_filepath, v1::FORMAT_VERSION).await?;
43
44            let metadata = v1::Metadata::new().await?;
45
46            let json = serde_json::to_string(&metadata)?;
47
48            let temp_metadata_filepath = temp_dirpath.join(v1::METADATA_FILENAME);
49            fs::write(temp_metadata_filepath.as_path(), json).await?;
50
51            Ok((
52                temp_version_filepath,
53                temp_metadata_filepath,
54                metadata.sessions.len() as u16,
55                metadata.windows.len() as u16,
56            ))
57        })
58    };
59
60    // Save pane contents in the temp folder.
61    let (temp_panes_content_dir, num_panes) = {
62        let temp_panes_content_dir = temp_dir.path().join(v1::PANES_DIR_NAME);
63        fs::create_dir_all(&temp_panes_content_dir).await?;
64
65        let panes = tmux::pane::available_panes().await?;
66        let num_panes = panes.len() as u16;
67        save_panes_content(panes, &temp_panes_content_dir, num_lines_to_drop).await?;
68
69        (temp_panes_content_dir, num_panes)
70    };
71    let (temp_version_filepath, temp_metadata_filepath, num_sessions, num_windows) =
72        metadata_task.await?;
73
74    // Tar-compress content of temp folder into a new backup file in `backup_dirpath`.
75    let new_backup_filepath = v1::new_backup_filepath(backup_dirpath.as_ref());
76
77    v1::create_from_paths(
78        &new_backup_filepath,
79        &temp_version_filepath,
80        &temp_metadata_filepath,
81        &temp_panes_content_dir,
82    )?;
83
84    // Cleanup the entire temp folder.
85    temp_dir.close()?;
86
87    let overview = v1::Overview {
88        version: v1::FORMAT_VERSION.to_string(),
89        num_sessions,
90        num_windows,
91        num_panes,
92    };
93
94    Ok((new_backup_filepath, overview))
95}
96
97/// Determine if the given command is a recognized shell.
98///
99/// Used to decide whether to drop trailing lines (shell prompt) when capturing pane content.
100fn is_shell_command(command: &str) -> bool {
101    DETECTED_SHELLS.contains(&command)
102}
103
104/// Calculate how many lines to drop from pane capture based on the active command.
105///
106/// If the pane is running a recognized shell, we drop `num_lines_to_drop` lines
107/// to avoid capturing the shell prompt. For other commands, we keep everything.
108fn lines_to_drop_for_pane(pane_command: &str, num_lines_to_drop: usize) -> usize {
109    if is_shell_command(pane_command) {
110        num_lines_to_drop
111    } else {
112        0
113    }
114}
115
116/// For each provided pane, retrieve the content and save it into `destination_dir`.
117async fn save_panes_content<P: AsRef<Path>>(
118    panes: Vec<tmux::pane::Pane>,
119    destination_dir: P,
120    num_lines_to_drop: usize,
121) -> Result<()> {
122    let mut handles = Vec::new();
123
124    for pane in panes {
125        let dest_dir = destination_dir.as_ref().to_path_buf();
126        let drop_n_last_lines = lines_to_drop_for_pane(&pane.command, num_lines_to_drop);
127
128        let handle = smol::spawn(async move {
129            let stdout = pane.capture().await.unwrap();
130            let cleaned_buffer = utils::cleanup_captured_buffer(&stdout, drop_n_last_lines);
131
132            let filename = format!("pane-{}.txt", pane.id);
133            let filepath = dest_dir.join(filename);
134            fs::write(filepath, cleaned_buffer).await
135        });
136        handles.push(handle);
137    }
138
139    join_all(handles).await;
140    Ok(())
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146
147    mod shell_detection {
148        use super::*;
149
150        #[test]
151        fn recognizes_zsh() {
152            assert!(is_shell_command("zsh"));
153        }
154
155        #[test]
156        fn recognizes_bash() {
157            assert!(is_shell_command("bash"));
158        }
159
160        #[test]
161        fn recognizes_fish() {
162            assert!(is_shell_command("fish"));
163        }
164
165        #[test]
166        fn rejects_vim() {
167            assert!(!is_shell_command("vim"));
168        }
169
170        #[test]
171        fn rejects_nvim() {
172            assert!(!is_shell_command("nvim"));
173        }
174
175        #[test]
176        fn rejects_python() {
177            assert!(!is_shell_command("python"));
178        }
179
180        #[test]
181        fn rejects_empty_command() {
182            assert!(!is_shell_command(""));
183        }
184
185        #[test]
186        fn rejects_similar_but_different() {
187            // Shell name as substring shouldn't match
188            assert!(!is_shell_command("zsh-5.9"));
189            assert!(!is_shell_command("/bin/zsh"));
190            assert!(!is_shell_command("bash-5.2"));
191        }
192
193        #[test]
194        fn case_sensitive() {
195            assert!(!is_shell_command("ZSH"));
196            assert!(!is_shell_command("BASH"));
197            assert!(!is_shell_command("Fish"));
198        }
199    }
200
201    mod lines_to_drop {
202        use super::*;
203
204        #[test]
205        fn drops_lines_for_shells() {
206            assert_eq!(lines_to_drop_for_pane("zsh", 2), 2);
207            assert_eq!(lines_to_drop_for_pane("bash", 3), 3);
208            assert_eq!(lines_to_drop_for_pane("fish", 1), 1);
209        }
210
211        #[test]
212        fn zero_drop_for_non_shells() {
213            assert_eq!(lines_to_drop_for_pane("vim", 5), 0);
214            assert_eq!(lines_to_drop_for_pane("python", 10), 0);
215            assert_eq!(lines_to_drop_for_pane("htop", 3), 0);
216        }
217
218        #[test]
219        fn zero_requested_means_zero_dropped() {
220            assert_eq!(lines_to_drop_for_pane("zsh", 0), 0);
221            assert_eq!(lines_to_drop_for_pane("bash", 0), 0);
222        }
223    }
224
225    mod constants {
226        use super::*;
227
228        #[test]
229        fn detected_shells_includes_common_shells() {
230            assert!(DETECTED_SHELLS.contains(&"zsh"));
231            assert!(DETECTED_SHELLS.contains(&"bash"));
232            assert!(DETECTED_SHELLS.contains(&"fish"));
233        }
234    }
235}