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
146fn 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#[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}