tmux_backup/
config.rs

1//! Configuration.
2
3use std::env;
4use std::path::PathBuf;
5
6use clap::{ArgAction, Parser, Subcommand, ValueEnum, ValueHint};
7use clap_complete::Shell;
8
9use crate::management::{backup::BackupStatus, compaction::Strategy};
10
11/// Save or restore Tmux sessions.
12#[derive(Debug, Parser)]
13#[clap(author, about, version)]
14#[clap(propagate_version = true)]
15pub struct Config {
16    /// Location of backups.
17    ///
18    /// If unspecified, it falls back on: `$XDG_STATE_HOME/tmux-backup`, then on
19    /// `$HOME/.local/state/tmux-backup`.
20    #[arg(short = 'd', long = "dirpath", value_hint = ValueHint::DirPath,
21        default_value_os_t = default_backup_dirpath())]
22    pub backup_dirpath: PathBuf,
23
24    /// Selection of commands.
25    #[command(subcommand)]
26    pub command: Command,
27}
28
29/// Indicate whether to save (resp. restore) the Tmux sessions to (resp. from) a backup.
30#[derive(Debug, Subcommand)]
31pub enum Command {
32    /// Save the Tmux sessions to a new backup file.
33    ///
34    /// Sessions, windows, and panes geometry + content are saved in an archive format inside the
35    /// backup folder. In that folder, the backup name is expected to be similar to
36    /// `backup-20220531T123456.tar.zst`.
37    ///
38    /// If you run this command via a Tmux keybinding, use the `--to-tmux` flag in order to send a
39    /// one-line report to the Tmux status bar. If you run this command from the terminal, ignore
40    /// this flag in order to print the one-line report in the terminal.
41    Save {
42        /// Choose a strategy for managing backups.
43        #[command(flatten)]
44        strategy: StrategyConfig,
45
46        /// Print a one-line report in the Tmux status bar, otherwise print to stdout.
47        #[arg(long, action = ArgAction::SetTrue)]
48        to_tmux: bool,
49
50        /// Delete purgeable backups after saving.
51        #[arg(long, action = ArgAction::SetTrue)]
52        compact: bool,
53
54        /// Number of lines to ignore during capture if the active command is a shell.
55        ///
56        /// At the time of saving, for each pane where the active command is one of (`zsh`, `bash`,
57        /// `fish`), the shell prompt is waiting for input. If tmux-backup naively captures the
58        /// entire history, on restoring that backup, a new shell prompt will also appear. This
59        /// obviously pollutes history with repeated shell prompts.
60        ///
61        /// If you know the number of lines your shell prompt occupies on screen, set this option
62        /// to that number (simply `1` in my case). These last lines will not be captured. On
63        /// restore, this gives the illusion of history continuity without repetition.
64        #[arg(
65            short = 'i',
66            long = "ignore-last-lines",
67            value_name = "NUMBER",
68            default_value_t = 0
69        )]
70        num_lines_to_drop: u8,
71    },
72
73    /// Restore the Tmux sessions from a backup file.
74    ///
75    /// Sessions, windows and panes geometry + content are read from the backup marked as "current"
76    /// (often the most recent backup) inside the backup folder. In that folder, the backup name is
77    /// expected to be similar to `backup-20220531T123456.tar.zst`.
78    ///
79    /// If you run this command via a Tmux keybinding, use the `--to-tmux` flag in order to send a
80    /// one-line report to the Tmux status bar. If you run this command from the terminal, ignore
81    /// this flag in order to print the one-line report in the terminal.
82    Restore {
83        /// Choose a strategy for managing backups.
84        #[command(flatten)]
85        strategy: StrategyConfig,
86
87        /// Print a one-line report in the Tmux status bar, otherwise print to stdout.
88        #[arg(long, action = ArgAction::SetTrue)]
89        to_tmux: bool,
90
91        /// Filepath of the backup to restore, by default, pick latest.
92        #[arg(value_parser)]
93        backup_filepath: Option<PathBuf>,
94    },
95
96    /// Catalog commands.
97    Catalog {
98        /// Choose a strategy for managing backups.
99        #[command(flatten)]
100        strategy: StrategyConfig,
101
102        /// Catalog commands.
103        #[command(subcommand)]
104        command: CatalogSubcommand,
105    },
106
107    /// Describe the content of a backup file.
108    Describe {
109        /// Path to the backup file.
110        #[arg(value_parser, value_hint = ValueHint::FilePath)]
111        backup_filepath: PathBuf,
112    },
113
114    /// Print a shell completion script to stdout.
115    GenerateCompletion {
116        /// Shell for which you want completion.
117        #[arg(value_enum, value_parser = clap::value_parser!(Shell))]
118        shell: Shell,
119    },
120
121    /// Outputs the default tmux plugin config to stdout.
122    ///
123    /// Similar to shell completions, this is done once when installing tmux-backup. Type
124    /// `tmux-backup init > ~/.tmux/plugins/tmux-backup.tmux`. and source it
125    /// from your `~/.tmux.conf`. See the README for details.
126    Init,
127}
128
129/// Catalog subcommands.
130#[derive(Debug, Subcommand)]
131pub enum CatalogSubcommand {
132    /// Print a list of backups to stdout.
133    ///
134    /// By default, this prints a table of backups, age and status with colors. The flag `--details`
135    /// prints additional columns.
136    ///
137    /// If the flag `--filepaths` is set, only absolute filepaths are printed. This can be used in
138    /// scripting scenarios.
139    ///
140    /// Options `--only purgeable` or `--only retainable` will list only the corresponding backups.
141    /// They will activate the flag `--filepaths` automatically.
142    List {
143        /// Add details columns to the table.
144        ///
145        /// Print number of sessions, windows and panes in the backup and the backup's format
146        /// version. This is slightly slower because it requires each backup file to be partially
147        /// read.
148        #[arg(long = "details", action = ArgAction::SetTrue)]
149        details_flag: bool,
150
151        /// List only backups having this status.
152        #[arg(long = "only", value_enum, value_parser)]
153        only_backup_status: Option<BackupStatus>,
154
155        /// Print filepaths instead of the table format.
156        #[arg(long = "filepaths", action = ArgAction::SetTrue)]
157        filepaths_flag: bool,
158    },
159
160    /// Apply the catalog's compaction strategy: this deletes all purgable backups.
161    Compact,
162}
163
164/// Strategy values
165#[derive(Debug, Clone, ValueEnum)]
166enum StrategyValues {
167    /// Apply a most-recent strategy, keeping only n backups.
168    MostRecent,
169
170    /// Apply a classic backup strategy.
171    ///
172    /// Keep
173    /// the lastest per hour for the past 24 hours,
174    /// the lastest per day for the past 7 days,
175    /// the lastest per week of the past 4 weeks,
176    /// the lastest per month of this year.
177    Classic,
178}
179
180/// Strategy configuration.
181#[derive(Debug, clap::Args)]
182pub struct StrategyConfig {
183    #[arg(short = 's', long = "strategy", value_enum, default_value_t = StrategyValues::MostRecent)]
184    strategy: StrategyValues,
185
186    /// Number of recent backups to keep, for instance 10.
187    #[arg(
188        short = 'n',
189        long,
190        value_name = "NUMBER",
191        value_parser = clap::value_parser!(u16).range(1..),
192        default_value_t = 10,
193    )]
194    num_backups: u16,
195}
196
197//
198// Helpers
199//
200
201impl StrategyConfig {
202    /// Compaction Strategy corresponding to the CLI arguments.
203    pub fn strategy(&self) -> Strategy {
204        match self.strategy {
205            StrategyValues::MostRecent => Strategy::most_recent(self.num_backups as usize),
206            StrategyValues::Classic => Strategy::Classic,
207        }
208    }
209}
210
211/// Determine the folder where to save backups.
212///
213/// If `$XDG_STATE_HOME` is defined, the function returns `$XDG_STATE_HOME/tmux-backup`, otherwise,
214/// it returns `$HOME/.local/state/tmux-backup`.
215///
216/// # Panics
217///
218/// This function panics if even `$HOME` cannot be obtained from the environment.
219fn default_backup_dirpath() -> PathBuf {
220    let state_home = match env::var("XDG_STATE_HOME") {
221        Ok(v) => PathBuf::from(v),
222        Err(_) => match env::var("HOME") {
223            Ok(v) => PathBuf::from(v).join(".local").join("state"),
224            Err(_) => panic!("Cannot find `$HOME` in the environment"),
225        },
226    };
227
228    state_home.join("tmux-backup")
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234    use clap::Parser;
235
236    mod strategy_config {
237        use super::*;
238
239        // Helper to parse a save command and extract its strategy
240        // Note: strategy flags (-s, -n) belong to the subcommand, not the root
241        fn parse_save_strategy(subcommand_args: &[&str]) -> Strategy {
242            let mut full_args = vec!["tmux-backup", "save"];
243            full_args.extend(subcommand_args);
244
245            let config = Config::try_parse_from(full_args).unwrap();
246            match config.command {
247                Command::Save { strategy, .. } => strategy.strategy(),
248                _ => panic!("Expected Save command"),
249            }
250        }
251
252        #[test]
253        fn default_strategy_is_most_recent_with_10() {
254            let strategy = parse_save_strategy(&[]);
255
256            match strategy {
257                Strategy::KeepMostRecent { k } => assert_eq!(k, 10),
258                _ => panic!("Expected KeepMostRecent"),
259            }
260        }
261
262        #[test]
263        fn explicit_most_recent_strategy() {
264            let strategy = parse_save_strategy(&["-s", "most-recent"]);
265
266            match strategy {
267                Strategy::KeepMostRecent { k } => assert_eq!(k, 10), // default n
268                _ => panic!("Expected KeepMostRecent"),
269            }
270        }
271
272        #[test]
273        fn most_recent_with_custom_count() {
274            let strategy = parse_save_strategy(&["-s", "most-recent", "-n", "25"]);
275
276            match strategy {
277                Strategy::KeepMostRecent { k } => assert_eq!(k, 25),
278                _ => panic!("Expected KeepMostRecent"),
279            }
280        }
281
282        #[test]
283        fn classic_strategy() {
284            let strategy = parse_save_strategy(&["-s", "classic"]);
285
286            assert!(matches!(strategy, Strategy::Classic));
287        }
288
289        #[test]
290        fn long_form_arguments_work() {
291            let strategy =
292                parse_save_strategy(&["--strategy", "most-recent", "--num-backups", "42"]);
293
294            match strategy {
295                Strategy::KeepMostRecent { k } => assert_eq!(k, 42),
296                _ => panic!("Expected KeepMostRecent"),
297            }
298        }
299
300        #[test]
301        fn num_backups_ignored_for_classic() {
302            // -n is accepted but ignored for classic strategy
303            let strategy = parse_save_strategy(&["-s", "classic", "-n", "99"]);
304
305            assert!(matches!(strategy, Strategy::Classic));
306        }
307    }
308
309    mod cli_parsing {
310        use super::*;
311
312        #[test]
313        fn save_command_parses() {
314            let config = Config::try_parse_from(["tmux-backup", "save"]).unwrap();
315            assert!(matches!(config.command, Command::Save { .. }));
316        }
317
318        #[test]
319        fn save_with_compact_flag() {
320            let config = Config::try_parse_from(["tmux-backup", "save", "--compact"]).unwrap();
321            match config.command {
322                Command::Save { compact, .. } => assert!(compact),
323                _ => panic!("Expected Save command"),
324            }
325        }
326
327        #[test]
328        fn save_with_to_tmux_flag() {
329            let config = Config::try_parse_from(["tmux-backup", "save", "--to-tmux"]).unwrap();
330            match config.command {
331                Command::Save { to_tmux, .. } => assert!(to_tmux),
332                _ => panic!("Expected Save command"),
333            }
334        }
335
336        #[test]
337        fn save_with_ignore_lines() {
338            let config = Config::try_parse_from(["tmux-backup", "save", "-i", "2"]).unwrap();
339            match config.command {
340                Command::Save {
341                    num_lines_to_drop, ..
342                } => assert_eq!(num_lines_to_drop, 2),
343                _ => panic!("Expected Save command"),
344            }
345        }
346
347        #[test]
348        fn restore_command_parses() {
349            let config = Config::try_parse_from(["tmux-backup", "restore"]).unwrap();
350            assert!(matches!(config.command, Command::Restore { .. }));
351        }
352
353        #[test]
354        fn restore_with_specific_file() {
355            let config =
356                Config::try_parse_from(["tmux-backup", "restore", "/path/to/backup.tar.zst"])
357                    .unwrap();
358            match config.command {
359                Command::Restore {
360                    backup_filepath, ..
361                } => {
362                    assert_eq!(
363                        backup_filepath,
364                        Some(PathBuf::from("/path/to/backup.tar.zst"))
365                    );
366                }
367                _ => panic!("Expected Restore command"),
368            }
369        }
370
371        #[test]
372        fn catalog_list_command() {
373            let config = Config::try_parse_from(["tmux-backup", "catalog", "list"]).unwrap();
374            match config.command {
375                Command::Catalog { command, .. } => {
376                    assert!(matches!(command, CatalogSubcommand::List { .. }));
377                }
378                _ => panic!("Expected Catalog command"),
379            }
380        }
381
382        #[test]
383        fn catalog_list_with_details() {
384            let config =
385                Config::try_parse_from(["tmux-backup", "catalog", "list", "--details"]).unwrap();
386            match config.command {
387                Command::Catalog { command, .. } => match command {
388                    CatalogSubcommand::List { details_flag, .. } => {
389                        assert!(details_flag);
390                    }
391                    _ => panic!("Expected List subcommand"),
392                },
393                _ => panic!("Expected Catalog command"),
394            }
395        }
396
397        #[test]
398        fn catalog_list_with_only_purgeable() {
399            let config =
400                Config::try_parse_from(["tmux-backup", "catalog", "list", "--only", "purgeable"])
401                    .unwrap();
402            match config.command {
403                Command::Catalog { command, .. } => match command {
404                    CatalogSubcommand::List {
405                        only_backup_status, ..
406                    } => {
407                        assert!(matches!(only_backup_status, Some(BackupStatus::Purgeable)));
408                    }
409                    _ => panic!("Expected List subcommand"),
410                },
411                _ => panic!("Expected Catalog command"),
412            }
413        }
414
415        #[test]
416        fn catalog_compact_command() {
417            let config = Config::try_parse_from(["tmux-backup", "catalog", "compact"]).unwrap();
418            match config.command {
419                Command::Catalog { command, .. } => {
420                    assert!(matches!(command, CatalogSubcommand::Compact));
421                }
422                _ => panic!("Expected Catalog command"),
423            }
424        }
425
426        #[test]
427        fn custom_backup_dirpath() {
428            let config =
429                Config::try_parse_from(["tmux-backup", "-d", "/custom/path", "save"]).unwrap();
430            assert_eq!(config.backup_dirpath, PathBuf::from("/custom/path"));
431        }
432
433        #[test]
434        fn describe_command() {
435            let config =
436                Config::try_parse_from(["tmux-backup", "describe", "/path/to/backup.tar.zst"])
437                    .unwrap();
438            match config.command {
439                Command::Describe { backup_filepath } => {
440                    assert_eq!(backup_filepath, PathBuf::from("/path/to/backup.tar.zst"));
441                }
442                _ => panic!("Expected Describe command"),
443            }
444        }
445
446        #[test]
447        fn generate_completion_command() {
448            let config =
449                Config::try_parse_from(["tmux-backup", "generate-completion", "bash"]).unwrap();
450            match config.command {
451                Command::GenerateCompletion { shell } => {
452                    assert!(matches!(shell, Shell::Bash));
453                }
454                _ => panic!("Expected GenerateCompletion command"),
455            }
456        }
457
458        #[test]
459        fn init_command() {
460            let config = Config::try_parse_from(["tmux-backup", "init"]).unwrap();
461            assert!(matches!(config.command, Command::Init));
462        }
463
464        #[test]
465        fn rejects_invalid_num_backups_zero() {
466            let result = Config::try_parse_from(["tmux-backup", "-n", "0", "save"]);
467            assert!(result.is_err());
468        }
469
470        #[test]
471        fn rejects_negative_num_backups() {
472            let result = Config::try_parse_from(["tmux-backup", "-n", "-5", "save"]);
473            assert!(result.is_err());
474        }
475    }
476
477    // Note: Testing `default_backup_dirpath()` would require manipulating
478    // environment variables (XDG_STATE_HOME, HOME), which can interfere with
479    // other tests running in parallel. Consider using a test harness like
480    // `temp_env` or running these tests serially with `#[serial]` if needed.
481}