vcsq_cli/
lib.rs

1//! `vcsq` CLI thinly wraps [`vcsq_lib`] to answer version-control questions about any directory.
2//!
3//! Usage: `vcsq SUB_CMD DIR`
4//! Example: `vcsq is-clean .`
5//!
6//! See `--help` for complete doc, and README at <https://gitlab.com/jzacsh/vcsq> for more.
7use clap::{Parser, Subcommand};
8use std::io;
9use thiserror::Error;
10use vcsq_lib::plexer;
11use vcsq_lib::repo::{Driver, DriverError, QueryDir};
12
13/// Top-Level instructions into which we parse the resluts of CLI args.
14#[derive(Parser, Debug)]
15#[command(
16    name = "vcsq",
17    version,
18    about = "vcs queries in rust",
19    long_about = "vcsq is a rust CLI providing Version Control System (VCS) inspection, without you
20needing to know each VCS's proprietary incantations."
21)]
22pub struct MainArgs {
23    /// Directory for which you'd like to ask VCS questions.
24    #[arg(short, long)]
25    pub dir: Option<QueryDir>,
26
27    #[command(subcommand)]
28    pub query: Option<QueryCmd>,
29}
30
31#[derive(Error, Debug)]
32enum CliError {
33    #[error("usage error: {0}")]
34    Usage(String),
35
36    #[error("vcs error: {0}")]
37    Plexing(#[from] DriverError),
38
39    #[error("{0}")]
40    Unknown(String),
41}
42
43impl MainArgs {
44    /// Alternative to clap's parse, just so we can handle defaults
45    ///
46    // TODO: (feature,clap) fix this clunkiness: somehow allow lone positional arg of a
47    // directory for the case that no subcommand is passed. IDK how to do that in clap.
48    pub(self) fn reduce(&self) -> Result<QueryCmd, CliError> {
49        if let Some(q) = &self.query {
50            Ok(q.clone())
51        } else {
52            let dir = self
53                .dir
54                .clone()
55                .ok_or(CliError::Usage(
56                    "require either subcmd with a query or a direct --dir".into(),
57                ))?
58                .clone();
59            Ok(QueryCmd::Brand { dir })
60        }
61    }
62}
63
64/// Sub-commands of the CLI that map to a single VCS query.
65// TODO: (clap, rust) figure out how to shorten the names of these subcommands so they rust-lang
66// naming doesn't turn into annoyingly-long (and hyphenated) names.
67//
68// TODO: (feature) impl a subcommand that lets you know which $PATH dependencies are found.
69#[derive(Debug, Subcommand, Clone)]
70pub enum QueryCmd {
71    /// Prints the brand of the VCS repo, or exits non-zero if it's not a known VCS repo.
72    #[command(arg_required_else_help = true)]
73    Brand { dir: QueryDir },
74
75    /// Prints the root dir of the repo
76    #[command(arg_required_else_help = true)]
77    Root { dir: QueryDir },
78
79    /// Whether VCS repo is in a clean state, or has uncommitted work.
80    #[command(arg_required_else_help = true)]
81    IsClean {
82        // TODO: (feature) implement subcommand here, eg: enum {diffstat, diff, files}
83        dir: QueryDir,
84    },
85
86    /// Print the VCS repo's current revision ID (eg: rev in Mercurial, ref in git, etc).
87    #[command(arg_required_else_help = true)]
88    CurrentId {
89        dir: QueryDir,
90
91        /// Whether to be silent about any answers being flawed, in the event `IsClean` is false.
92        #[arg(long, default_value_t = false)]
93        dirty_ok: bool,
94    },
95
96    /// Print the VCS repo's current human-readable revision (eg: branch or tag in git, bookmark in
97    /// jj)
98    #[command(arg_required_else_help = true)]
99    #[cfg(debug_assertions)]
100    CurrentName {
101        dir: QueryDir,
102
103        /// Whether to be silent about any answers being flawed, in the event `IsClean` is false.
104        #[arg(long, default_value_t = false)]
105        dirty_ok: bool,
106    },
107
108    /// Print the VCS repo's parent revision ID to the current point in history (eg: rev in
109    /// Mercurial, ref in git, etc).
110    #[command(arg_required_else_help = true)]
111    #[cfg(debug_assertions)]
112    ParentId { dir: QueryDir },
113
114    /// Print the VCS repo's parent revision's human-readable revision name for the first parent it
115    /// finds with one, or until it has stepped --max steps. Non-zero exit with no stderr output
116    /// indicates one wasn't found.
117    #[command(arg_required_else_help = true)]
118    #[cfg(debug_assertions)]
119    ParentName {
120        dir: QueryDir,
121
122        /// Max number of parents back to walk when seeking a parent with a hand-written ref name.
123        // TODO: (rust) there's a type-way to express positive natural numbers, yeah?
124        max: u64,
125    },
126
127    /// Lists filepaths tracked by this repo, ignoring the state of the repo (ie: any "staged"
128    /// (git) or deleted "working-copy" (jj) edits. The goal of this listing is to show the full
129    /// listing of the repository's contents, as of the time of the current commit.
130    #[command(arg_required_else_help = true)]
131    TrackedFiles { dir: QueryDir },
132
133    /// Lists filepaths touched that are the cause of the repo being dirty, or lists no output if
134    /// the repo isn't dirty (thus can be used as a 1:1 proxy for `IsClean`'s behavior).
135    #[command(arg_required_else_help = true)]
136    DirtyFiles {
137        dir: QueryDir,
138        #[arg(long, default_value_t = false)]
139        clean_ok: bool,
140        // TODO: (feature) add flag like "--exists" to only show files that are currently present
141        // (eg: so this can be piped right to an editor's args).
142    },
143
144    /// Prints what files were touched by the `CurrentId`
145    #[command(arg_required_else_help = true)]
146    #[cfg(debug_assertions)]
147    CurrentFiles {
148        dir: QueryDir,
149
150        /// Whether to be silent about any answers being flawed, in the event `IsClean` is false.
151        dirty_ok: bool,
152        // TODO: (feature) allow an optional Id or Name  (ref or bookmark) of which to compare
153        // (instead of just the default which is "parent commit").
154
155        // TODO: (feature) implement subcommand here, eg: enum {diffstat, diff, files} (unified
156        // with IsClean)
157    },
158
159    /// Prints any system/$PATH info that might be useful for debugging issues this binary might
160    /// have on your system.
161    CheckHealth,
162}
163
164impl QueryCmd {
165    fn dir(&self) -> Option<QueryDir> {
166        self.dir_path().cloned()
167    }
168
169    // TODO: (rust) way to ask clap to make a global positional arg for all these subcommands, so
170    // we can rely on its presence?
171    fn dir_path(&self) -> Option<&QueryDir> {
172        match self {
173            QueryCmd::Brand { dir }
174            | QueryCmd::Root { dir }
175            | QueryCmd::IsClean { dir }
176            | QueryCmd::DirtyFiles { dir, clean_ok: _ }
177            | QueryCmd::TrackedFiles { dir }
178            | QueryCmd::CurrentId { dir, dirty_ok: _ } => Some(dir),
179            QueryCmd::CheckHealth => None,
180            #[cfg(debug_assertions)]
181            QueryCmd::CurrentName { dir, dirty_ok: _ }
182            | QueryCmd::ParentId { dir }
183            | QueryCmd::ParentName { dir, max: _ }
184            | QueryCmd::CurrentFiles { dir, dirty_ok: _ } => Some(dir),
185        }
186    }
187}
188
189struct PlexerQuery<'a> {
190    plexer: plexer::Repo,
191    cli: QueryCmd,
192    stdout: &'a mut dyn io::Write,
193}
194
195impl<'a> PlexerQuery<'a> {
196    fn new(
197        args: &'a MainArgs,
198        stdout: &'a mut dyn io::Write,
199    ) -> Result<Option<PlexerQuery<'a>>, CliError> {
200        let query = args.reduce()?;
201        let Some(dir) = query.dir() else {
202            return Ok(None);
203        };
204        if !dir.is_dir() {
205            return Err(CliError::Usage(
206                "dir must be a readable directory".to_string(),
207            ));
208        }
209        let plexer = plexer::Repo::new_driver(&dir)?;
210        Ok(Some(PlexerQuery {
211            plexer,
212            cli: query,
213            stdout,
214        }))
215    }
216
217    pub fn handle_query(&mut self) -> Result<u8, CliError> {
218        match self.cli {
219            QueryCmd::Brand { dir: _ } => {
220                writeln!(self.stdout, "{:?}", self.plexer.brand)
221                    .unwrap_or_else(|_| panic!("failed stdout write of: {:?}", self.plexer.brand));
222            }
223            QueryCmd::Root { dir: _ } => {
224                let root_path = self.plexer.root()?;
225                let dir_path = root_path.as_path().to_str().ok_or_else(|| {
226                    CliError::Unknown(format!("vcs generated invalid unicode: {root_path:?}"))
227                })?;
228                writeln!(self.stdout, "{dir_path}")
229                    .unwrap_or_else(|_| panic!("failed stdout write of: {dir_path}"));
230            }
231            QueryCmd::IsClean { dir: _ } => {
232                let is_clean = self.plexer.is_clean().map_err(CliError::Plexing)?;
233                return Ok(u8::from(!is_clean));
234            }
235            QueryCmd::CheckHealth => panic!("bug: PlexerQuery() should not be constructed for the generalized CheckHealth query"),
236            QueryCmd::CurrentId {
237                dir: _,
238                dirty_ok,
239            } => {
240                let current_id = self.plexer.current_ref_id(dirty_ok)?;
241                writeln!(self.stdout, "{current_id}").unwrap_or_else(|_| {
242                    panic!("failed stdout write of: {current_id}")
243                });
244            },
245            #[cfg(debug_assertions)]
246            QueryCmd::CurrentName {
247                dir: _,
248                dirty_ok: _,
249            }
250            | QueryCmd::ParentId { dir: _ }
251            | QueryCmd::ParentName { dir: _, max: _ }
252            | QueryCmd::CurrentFiles {
253                dir: _,
254                dirty_ok: _,
255            } => todo!(),
256            QueryCmd::DirtyFiles { dir: _, clean_ok } => {
257                let files = self
258                    .plexer
259                    .dirty_files(clean_ok)
260                    .map_err(CliError::Plexing)?;
261                for file in files {
262                    writeln!(self.stdout, "{}", file.display()).unwrap_or_else(|_| {
263                        panic!("failed stdout write of: {}", file.display())
264                    });
265                }
266            }
267            QueryCmd::TrackedFiles { dir: _ } => {
268                let files = self
269                    .plexer
270                    .tracked_files()
271                    .map_err(CliError::Plexing)?;
272                for file in files {
273                    writeln!(self.stdout, "{}", file.display()).unwrap_or_else(|_| {
274                        panic!("failed stdout write of: {}", file.display())
275                    });
276                }
277            }
278        }
279        Ok(0)
280    }
281}
282
283/// Core logic the CLI binary runs, but with injectable deps; designed fr `main()`'s use-case.
284///
285/// NOTE: this is separate from main purely so we can e2e (ie: so we can dependency-inject
286/// stdio/stderr, etc. into `PlexerQuery`). For more on e2e testing a rust CLI, see:
287/// - <https://doc.rust-lang.org/book/ch11-03-test-organization.html#integration-tests-for-binary-crates>
288/// - <https://rust-cli.github.io/book/tutorial/testing.html#testing-cli-applications-by-running-them>
289///
290/// # Panics
291/// Should only panic if stderr or stdout writes fail.
292pub fn main_vcsquery(
293    args: &MainArgs,
294    stdout: &mut dyn io::Write,
295    stderr: &mut dyn io::Write,
296) -> u8 {
297    let plexerq = match PlexerQuery::new(args, stdout) {
298        Ok(pq) => pq,
299        Err(e) => {
300            writeln!(stderr, "{e}").unwrap_or_else(|_| panic!("failed stderr write of: {e}"));
301            return 1;
302        }
303    };
304    if let Some(mut pq) = plexerq {
305        return match pq.handle_query() {
306            Ok(ret) => ret,
307            Err(e) => {
308                writeln!(stderr, "{e}").unwrap_or_else(|_| panic!("failed stderr write of: {e}"));
309                1
310            }
311        };
312    }
313
314    let mut has_fail = false;
315    for report in plexer::check_health() {
316        let message = match &report.health {
317            Ok(h) => h.stdout.clone(),
318            Err(e) => e.to_string(),
319        };
320        if report.health.is_err() {
321            writeln!(stderr, "FAIL: check for {:?}:\n{}", report.brand, message)
322                .unwrap_or_else(|e| panic!("failed stderr write: {e}"));
323            has_fail = true;
324        } else {
325            writeln!(stdout, "PASS: check for {:?}:\n{}", report.brand, message)
326                .unwrap_or_else(|e| panic!("failed stderr write: {e}"));
327        }
328    }
329    u8::from(has_fail)
330}
331
332// NOTE: lack of unit tests here, is purely because of the coverage via e2e tests ./tests/
333// sub-codebase of this binary target. That doesn't mean unit tests won't be appropriate in this
334// file in the future.