Skip to main content

stash_cli/cmd/
mod.rs

1use clap::{ArgAction, Args, Parser, Subcommand};
2#[cfg(feature = "completion")]
3use clap::{CommandFactory, ValueEnum};
4#[cfg(feature = "completion")]
5use clap_complete::{Shell, generate};
6use signal_hook::consts::signal::{SIGINT, SIGTERM};
7use std::io::{self, IsTerminal};
8
9use crate::store::{matches_meta, MetaSelection};
10use crate::store;
11use crate::store::Meta;
12
13mod attr;
14mod ls;
15mod path;
16mod push;
17mod rm;
18
19#[derive(Parser, Debug)]
20#[command(
21    name = "stash",
22    version,
23    about = "A local store for pipeline output and ad hoc file snapshots",
24    long_about = "A local store for pipeline output and ad hoc file snapshots.\n\nWhen used without a subcommand, stash picks a mode automatically:\n  - in the middle of a pipeline, it behaves like `stash tee`\n  - otherwise, it behaves like `stash push`",
25    after_help = "Links:\n  Docs: https://github.com/vrypan/stash/tree/main/docs\n  Reference: https://github.com/vrypan/stash/blob/main/docs/reference.md\n  Issues: https://github.com/vrypan/stash/issues",
26    disable_help_subcommand = true
27)]
28struct Cli {
29    #[command(flatten)]
30    push: push::PushArgs,
31
32    #[command(subcommand)]
33    command: Option<Command>,
34}
35
36#[derive(Subcommand, Debug)]
37enum Command {
38    #[command(about = "Store and return the entry key")]
39    Push(push::PushArgs),
40    #[command(about = "Store and forward to stdout")]
41    Tee(push::TeeArgs),
42    #[command(about = "Print an entry's raw data to stdout")]
43    Cat(CatArgs),
44    #[command(about = "List entries")]
45    Ls(ls::LsArgs),
46    #[command(about = "Show or update entry attributes")]
47    Attr(attr::AttrArgs),
48    #[command(about = "List attribute keys across the stash")]
49    Attrs(AttrsArgs),
50    #[command(about = "Print stash paths")]
51    Path(path::PathArgs),
52    #[command(about = "Remove entries")]
53    Rm(rm::RmArgs),
54    #[command(about = "Print the newest entry and remove it")]
55    Pop,
56}
57
58#[derive(Args, Debug, Clone, Default)]
59struct CatArgs {
60    #[arg(help = "Entry references: id, n, or @n")]
61    refs: Vec<String>,
62
63    #[arg(short = 'a', long = "attr", value_name = "name|name=value", action = ArgAction::Append, help = "Print entries where an attribute is set, or equals a value (repeatable)")]
64    attr: Vec<String>,
65
66    #[arg(short = 'r', long = "reverse", help = "Print entries in reverse order")]
67    reverse: bool,
68}
69
70#[derive(Args, Debug, Clone)]
71struct AttrsArgs {
72    #[arg(long, help = "Include entry count")]
73    count: bool,
74}
75
76#[cfg(feature = "completion")]
77#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
78enum CompletionShell {
79    Bash,
80    Fish,
81    Zsh,
82}
83
84#[cfg(feature = "completion")]
85impl From<CompletionShell> for Shell {
86    fn from(value: CompletionShell) -> Self {
87        match value {
88            CompletionShell::Bash => Shell::Bash,
89            CompletionShell::Fish => Shell::Fish,
90            CompletionShell::Zsh => Shell::Zsh,
91        }
92    }
93}
94
95pub fn main_entry() {
96    if let Err(err) = run() {
97        if err.kind() == io::ErrorKind::BrokenPipe {
98            std::process::exit(0);
99        }
100        if let Some(partial_err) = err
101            .get_ref()
102            .and_then(|e| e.downcast_ref::<store::PartialSavedError>())
103        {
104            let code = match partial_err.signal {
105                Some(SIGINT) => 130,
106                Some(SIGTERM) => 143,
107                _ => 1,
108            };
109            std::process::exit(code);
110        }
111        eprintln!("error: {err}");
112        std::process::exit(1);
113    }
114}
115
116pub fn run() -> io::Result<()> {
117    let cli = Cli::parse();
118    match cli.command {
119        Some(Command::Push(args)) => push::push_command(args),
120        Some(Command::Tee(args)) => push::tee_command(args),
121        Some(Command::Cat(args)) => cat_command(args),
122        Some(Command::Ls(args)) => ls::ls_command(args),
123        Some(Command::Attr(args)) => attr::attr_command(args),
124        Some(Command::Attrs(args)) => attrs_command(args),
125        Some(Command::Path(args)) => path::path_command(args),
126        Some(Command::Rm(args)) => rm::rm_command(args),
127        Some(Command::Pop) => pop_command(),
128        None => {
129            if smart_mode_uses_tee(&cli.push) {
130                push::tee_command(push::TeeArgs {
131                    attr: cli.push.attr,
132                    print: cli.push.print,
133                    save_on_error: true,
134                })
135            } else {
136                push::push_command(cli.push)
137            }
138        }
139    }
140}
141
142fn smart_mode_uses_tee(args: &push::PushArgs) -> bool {
143    args.file.is_none() && !io::stdin().is_terminal() && !io::stdout().is_terminal()
144}
145
146// Shared by ls and log: filter, order, and limit the entry list.
147fn collect_entries(sel: &MetaSelection, reverse: bool, limit: usize) -> io::Result<Vec<Meta>> {
148    let mut items = store::list()?
149        .into_iter()
150        .filter(|m| matches_meta(&m.attrs, sel))
151        .collect::<Vec<_>>();
152    if reverse {
153        items.reverse();
154    }
155    if limit > 0 && items.len() > limit {
156        items.truncate(limit);
157    }
158    Ok(items)
159}
160
161fn cat_command(args: CatArgs) -> io::Result<()> {
162    let stdout = io::stdout();
163    let mut out = stdout.lock();
164
165    if !args.attr.is_empty() {
166        if !args.refs.is_empty() {
167            return Err(io::Error::new(
168                io::ErrorKind::InvalidInput,
169                "cat accepts either <ref>... or --attr",
170            ));
171        }
172        let filters = parse_attr_match_filters(&args.attr)?;
173        let mut items = store::list()?
174            .into_iter()
175            .filter(|meta| matches_attr_match_filters(&meta.attrs, &filters))
176            .collect::<Vec<_>>();
177        if args.reverse {
178            items.reverse();
179        }
180        for item in items {
181            store::cat_to_writer(&item.id, &mut out)?;
182        }
183        return Ok(());
184    }
185
186    if args.refs.is_empty() {
187        return store::cat_to_writer(&store::newest()?.id, &mut out);
188    }
189
190    let refs: Vec<&String> = if args.reverse {
191        args.refs.iter().rev().collect()
192    } else {
193        args.refs.iter().collect()
194    };
195
196    for reference in refs {
197        let id = store::resolve(reference)?;
198        store::cat_to_writer(&id, &mut out)?;
199    }
200    Ok(())
201}
202
203#[derive(Clone, Debug)]
204struct AttrMatchFilter {
205    key: String,
206    value: Option<String>,
207}
208
209fn parse_attr_match_filters(values: &[String]) -> io::Result<Vec<AttrMatchFilter>> {
210    let mut filters = Vec::with_capacity(values.len());
211    for value in values {
212        if value.trim().is_empty() || value.contains(',') {
213            return Err(io::Error::new(
214                io::ErrorKind::InvalidInput,
215                "--attr accepts name or name=value and is repeatable",
216            ));
217        }
218        if let Some((key, attr_value)) = value.split_once('=') {
219            filters.push(AttrMatchFilter {
220                key: key.to_string(),
221                value: Some(attr_value.to_string()),
222            });
223        } else {
224            filters.push(AttrMatchFilter {
225                key: value.to_string(),
226                value: None,
227            });
228        }
229    }
230    Ok(filters)
231}
232
233fn matches_attr_match_filters(
234    attrs: &std::collections::BTreeMap<String, String>,
235    filters: &[AttrMatchFilter],
236) -> bool {
237    filters.iter().all(|filter| match &filter.value {
238        Some(value) => attrs.get(&filter.key) == Some(value),
239        None => attrs.contains_key(&filter.key),
240    })
241}
242
243fn pop_command() -> io::Result<()> {
244    let newest = store::newest()?;
245    let stdout = io::stdout();
246    let mut out = stdout.lock();
247    store::cat_to_writer(&newest.id, &mut out)?;
248    store::remove(&newest.id)
249}
250
251fn attrs_command(args: AttrsArgs) -> io::Result<()> {
252    for (key, count) in store::all_attr_keys()? {
253        if args.count {
254            println!("{key}\t{count}");
255        } else {
256            println!("{key}");
257        }
258    }
259    Ok(())
260}
261
262/// Generate shell completions for `stash` into `writer`.
263/// Called by the standalone `stash-completion` binary.
264#[cfg(feature = "completion")]
265pub fn write_completions(shell: Shell, writer: &mut dyn io::Write) {
266    let mut cmd = Cli::command();
267    let name = cmd.get_name().to_string();
268    generate(shell, &mut cmd, name, writer);
269}