Skip to main content

link_cli/
cli.rs

1//! Command-line argument parsing for the `clink` binary.
2
3use anyhow::{bail, Result};
4use std::env;
5use std::ffi::OsString;
6
7const DEFAULT_DATABASE_FILENAME: &str = "db.links";
8
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct Cli {
11    pub db: String,
12    pub query: Option<String>,
13    pub query_arg: Option<String>,
14    pub trace: bool,
15    pub auto_create_missing_references: bool,
16    pub structure: Option<u32>,
17    pub before: bool,
18    pub changes: bool,
19    pub after: bool,
20    pub lino_input: Option<String>,
21    pub lino_output: Option<String>,
22    pub transactions: bool,
23    pub transactions_file: Option<String>,
24    pub commit_mode: Option<String>,
25    pub retention: Option<String>,
26    pub vc: bool,
27    pub vc_file: Option<String>,
28    pub branch: Option<String>,
29    pub branch_from: Option<i64>,
30    pub checkout: Option<String>,
31    pub tag: Option<String>,
32    pub list_branches: bool,
33    pub list_tags: bool,
34    pub show_log: bool,
35}
36
37impl Default for Cli {
38    fn default() -> Self {
39        Self {
40            db: DEFAULT_DATABASE_FILENAME.to_string(),
41            query: None,
42            query_arg: None,
43            trace: false,
44            auto_create_missing_references: false,
45            structure: None,
46            before: false,
47            changes: false,
48            after: false,
49            lino_input: None,
50            lino_output: None,
51            transactions: false,
52            transactions_file: None,
53            commit_mode: None,
54            retention: None,
55            vc: false,
56            vc_file: None,
57            branch: None,
58            branch_from: None,
59            checkout: None,
60            tag: None,
61            list_branches: false,
62            list_tags: false,
63            show_log: false,
64        }
65    }
66}
67
68#[derive(Debug, Clone, PartialEq, Eq)]
69pub enum CliCommand {
70    Run(Box<Cli>),
71    Help,
72    Version,
73}
74
75impl Cli {
76    /// True when any flag in the transactions decorator family was passed.
77    pub fn transactions_requested(&self) -> bool {
78        self.transactions
79            || self.transactions_file.is_some()
80            || self.commit_mode.is_some()
81            || self.retention.is_some()
82            || self.show_log
83            || self.vc_requested()
84    }
85
86    /// True when any flag in the version-control decorator family was passed.
87    pub fn vc_requested(&self) -> bool {
88        self.vc
89            || self.vc_file.is_some()
90            || self.branch.is_some()
91            || self.branch_from.is_some()
92            || self.checkout.is_some()
93            || self.tag.is_some()
94            || self.list_branches
95            || self.list_tags
96    }
97
98    pub fn parse() -> Result<CliCommand> {
99        lino_arguments::init();
100        Self::parse_from(env::args_os())
101    }
102
103    pub fn parse_from<I, T>(args: I) -> Result<CliCommand>
104    where
105        I: IntoIterator<Item = T>,
106        T: Into<OsString>,
107    {
108        let mut cli = Cli::default();
109        let mut args = args
110            .into_iter()
111            .map(|arg| arg.into().to_string_lossy().into_owned())
112            .peekable();
113
114        let _program = args.next();
115
116        while let Some(arg) = args.next() {
117            if let Some(value) = inline_value(&arg, &["--db", "--data-source", "--data"]) {
118                cli.db = value.to_string();
119                continue;
120            }
121            if let Some(value) = inline_value(&arg, &["--query", "--apply", "--do"]) {
122                cli.query = Some(value.to_string());
123                continue;
124            }
125            if let Some(value) = inline_value(&arg, &["--structure"]) {
126                cli.structure = Some(parse_link_id("--structure", value)?);
127                continue;
128            }
129            if let Some(value) = inline_value(&arg, &["--trace"]) {
130                cli.trace = parse_bool("--trace", value)?;
131                continue;
132            }
133            if let Some(value) = inline_value(&arg, &["--auto-create-missing-references"]) {
134                cli.auto_create_missing_references =
135                    parse_bool("--auto-create-missing-references", value)?;
136                continue;
137            }
138            if let Some(value) = inline_value(&arg, &["--before"]) {
139                cli.before = parse_bool("--before", value)?;
140                continue;
141            }
142            if let Some(value) = inline_value(&arg, &["--changes"]) {
143                cli.changes = parse_bool("--changes", value)?;
144                continue;
145            }
146            if let Some(value) = inline_value(&arg, &["--after", "--links"]) {
147                cli.after = parse_bool("--after", value)?;
148                continue;
149            }
150            if let Some(value) = inline_value(&arg, &["--out", "--lino-output", "--export"]) {
151                cli.lino_output = Some(value.to_string());
152                continue;
153            }
154            if let Some(value) = inline_value(&arg, &["--in", "--lino-input", "--import"]) {
155                cli.lino_input = Some(value.to_string());
156                continue;
157            }
158            if let Some(value) = inline_value(&arg, &["--transactions"]) {
159                cli.transactions = parse_bool("--transactions", value)?;
160                continue;
161            }
162            if let Some(value) = inline_value(&arg, &["--transactions-file"]) {
163                cli.transactions_file = Some(value.to_string());
164                continue;
165            }
166            if let Some(value) = inline_value(&arg, &["--commit-mode"]) {
167                cli.commit_mode = Some(value.to_string());
168                continue;
169            }
170            if let Some(value) = inline_value(&arg, &["--retention"]) {
171                cli.retention = Some(value.to_string());
172                continue;
173            }
174            if let Some(value) = inline_value(&arg, &["--vc"]) {
175                cli.vc = parse_bool("--vc", value)?;
176                continue;
177            }
178            if let Some(value) = inline_value(&arg, &["--vc-file"]) {
179                cli.vc_file = Some(value.to_string());
180                continue;
181            }
182            if let Some(value) = inline_value(&arg, &["--branch"]) {
183                cli.branch = Some(value.to_string());
184                continue;
185            }
186            if let Some(value) = inline_value(&arg, &["--branch-from"]) {
187                cli.branch_from = Some(parse_seq("--branch-from", value)?);
188                continue;
189            }
190            if let Some(value) = inline_value(&arg, &["--checkout"]) {
191                cli.checkout = Some(value.to_string());
192                continue;
193            }
194            if let Some(value) = inline_value(&arg, &["--tag"]) {
195                cli.tag = Some(value.to_string());
196                continue;
197            }
198            if let Some(value) = inline_value(&arg, &["--list-branches"]) {
199                cli.list_branches = parse_bool("--list-branches", value)?;
200                continue;
201            }
202            if let Some(value) = inline_value(&arg, &["--list-tags"]) {
203                cli.list_tags = parse_bool("--list-tags", value)?;
204                continue;
205            }
206            if let Some(value) = inline_value(&arg, &["--log"]) {
207                cli.show_log = parse_bool("--log", value)?;
208                continue;
209            }
210
211            match arg.as_str() {
212                "-h" | "--help" => return Ok(CliCommand::Help),
213                "-V" | "--version" => return Ok(CliCommand::Version),
214                "-d" | "--db" | "--data-source" | "--data" => {
215                    cli.db = next_value(&mut args, &arg)?;
216                }
217                "-q" | "--query" | "--apply" | "--do" => {
218                    cli.query = Some(next_value(&mut args, &arg)?);
219                }
220                "-t" | "--trace" => {
221                    cli.trace = next_bool_value(&mut args, true)?;
222                }
223                "--auto-create-missing-references" => {
224                    cli.auto_create_missing_references = next_bool_value(&mut args, true)?;
225                }
226                "-s" | "--structure" => {
227                    let value = next_value(&mut args, &arg)?;
228                    cli.structure = Some(parse_link_id(&arg, &value)?);
229                }
230                "-b" | "--before" => {
231                    cli.before = next_bool_value(&mut args, true)?;
232                }
233                "-c" | "--changes" => {
234                    cli.changes = next_bool_value(&mut args, true)?;
235                }
236                "-a" | "--after" | "--links" => {
237                    cli.after = next_bool_value(&mut args, true)?;
238                }
239                "--out" | "--lino-output" | "--export" => {
240                    cli.lino_output = Some(next_value(&mut args, &arg)?);
241                }
242                "--in" | "--lino-input" | "--import" => {
243                    cli.lino_input = Some(next_value(&mut args, &arg)?);
244                }
245                "--transactions" => {
246                    cli.transactions = next_bool_value(&mut args, true)?;
247                }
248                "--transactions-file" => {
249                    cli.transactions_file = Some(next_value(&mut args, &arg)?);
250                }
251                "--commit-mode" => {
252                    cli.commit_mode = Some(next_value(&mut args, &arg)?);
253                }
254                "--retention" => {
255                    cli.retention = Some(next_value(&mut args, &arg)?);
256                }
257                "--vc" => {
258                    cli.vc = next_bool_value(&mut args, true)?;
259                }
260                "--vc-file" => {
261                    cli.vc_file = Some(next_value(&mut args, &arg)?);
262                }
263                "--branch" => {
264                    cli.branch = Some(next_value(&mut args, &arg)?);
265                }
266                "--branch-from" => {
267                    let value = next_value(&mut args, &arg)?;
268                    cli.branch_from = Some(parse_seq(&arg, &value)?);
269                }
270                "--checkout" => {
271                    cli.checkout = Some(next_value(&mut args, &arg)?);
272                }
273                "--tag" => {
274                    cli.tag = Some(next_value(&mut args, &arg)?);
275                }
276                "--list-branches" => {
277                    cli.list_branches = next_bool_value(&mut args, true)?;
278                }
279                "--list-tags" => {
280                    cli.list_tags = next_bool_value(&mut args, true)?;
281                }
282                "--log" => {
283                    cli.show_log = next_bool_value(&mut args, true)?;
284                }
285                "--" => {
286                    for value in args.by_ref() {
287                        set_positional_query(&mut cli, value)?;
288                    }
289                    break;
290                }
291                value if value.starts_with('-') => {
292                    bail!("unknown option '{value}'");
293                }
294                value => {
295                    set_positional_query(&mut cli, value.to_string())?;
296                }
297            }
298        }
299
300        Ok(CliCommand::Run(Box::new(cli)))
301    }
302
303    pub fn print_help() {
304        print!("{}", Self::help_text());
305    }
306
307    pub fn help_text() -> &'static str {
308        concat!(
309            "LiNo CLI Tool for managing links data store\n\n",
310            "Usage: clink [OPTIONS] [QUERY]\n\n",
311            "Arguments:\n",
312            "  [QUERY]  LiNo query for CRUD operation\n\n",
313            "Options:\n",
314            "  -d, --db <DB>, --data-source <DB>, --data <DB>\n",
315            "          Path to the links database file [default: db.links]\n",
316            "  -q, --query <QUERY>, --apply <QUERY>, --do <QUERY>\n",
317            "          LiNo query for CRUD operation\n",
318            "  -t, --trace\n",
319            "          Enable trace (verbose output)\n",
320            "      --auto-create-missing-references\n",
321            "          Create missing numeric and named references as self-referential point links\n",
322            "  -s, --structure <STRUCTURE>\n",
323            "          ID of the link to format its structure\n",
324            "  -b, --before\n",
325            "          Print the state of the database before applying changes\n",
326            "  -c, --changes\n",
327            "          Print the changes applied by the query\n",
328            "  -a, --after, --links\n",
329            "          Print the state of the database after applying changes\n",
330            "      --in <IN>, --lino-input <IN>, --import <IN>\n",
331            "          Read and import a LiNo file into the database\n",
332            "      --out <OUT>, --lino-output <OUT>, --export <OUT>\n",
333            "          Write the complete database as a LiNo file\n",
334            "      --transactions\n",
335            "          Enable the transactions layer (default log path: <db>.transitions.links)\n",
336            "      --transactions-file <FILE>\n",
337            "          Path to the transitions log store (implies --transactions)\n",
338            "      --commit-mode <MODE>\n",
339            "          Choose 'sync' or 'async' commits (default: sync, implies --transactions)\n",
340            "      --retention <SPEC>\n",
341            "          Log retention policy: 'infinite', 'sized:<n>', or 'chunked:<n>:<dir>'\n",
342            "          (implies --transactions)\n",
343            "      --vc\n",
344            "          Enable the version-control decorator (implies --transactions)\n",
345            "      --vc-file <FILE>\n",
346            "          Path to the version-control branches store\n",
347            "          (default: <db>.versioncontrol.links)\n",
348            "      --branch <NAME>\n",
349            "          Switch to a branch (creating it if --branch-from is also passed).\n",
350            "          Implies --vc.\n",
351            "      --branch-from <SEQ>\n",
352            "          When creating a branch with --branch, fork from this sequence point\n",
353            "      --checkout <POINT>\n",
354            "          Time-travel to a specific transition sequence or named tag.\n",
355            "          Implies --vc.\n",
356            "      --tag <NAME[=SEQ]>\n",
357            "          Create a tag at current head or at the given sequence point.\n",
358            "          Implies --vc.\n",
359            "      --list-branches\n",
360            "          List version-control branches and exit\n",
361            "      --list-tags\n",
362            "          List version-control tags and exit\n",
363            "      --log\n",
364            "          Print the transitions log and exit (implies --transactions)\n",
365            "  -h, --help\n",
366            "          Print help\n",
367            "  -V, --version\n",
368            "          Print version\n",
369        )
370    }
371
372    pub fn version_text() -> String {
373        format!("clink {}", env!("CARGO_PKG_VERSION"))
374    }
375}
376
377fn inline_value<'a>(arg: &'a str, names: &[&str]) -> Option<&'a str> {
378    names.iter().find_map(|name| {
379        arg.strip_prefix(name)
380            .and_then(|rest| rest.strip_prefix('='))
381    })
382}
383
384fn next_value<I>(args: &mut I, option: &str) -> Result<String>
385where
386    I: Iterator<Item = String>,
387{
388    args.next()
389        .ok_or_else(|| anyhow::anyhow!("missing value for option '{option}'"))
390}
391
392fn next_bool_value<I>(args: &mut std::iter::Peekable<I>, default: bool) -> Result<bool>
393where
394    I: Iterator<Item = String>,
395{
396    if let Some(value) = args.peek().and_then(|value| bool_literal(value)) {
397        args.next();
398        Ok(value)
399    } else {
400        Ok(default)
401    }
402}
403
404fn parse_bool(option: &str, value: &str) -> Result<bool> {
405    bool_literal(value)
406        .ok_or_else(|| anyhow::anyhow!("invalid boolean value '{value}' for {option}"))
407}
408
409fn bool_literal(value: &str) -> Option<bool> {
410    match value.to_ascii_lowercase().as_str() {
411        "true" | "1" | "yes" | "on" => Some(true),
412        "false" | "0" | "no" | "off" => Some(false),
413        _ => None,
414    }
415}
416
417fn parse_link_id(option: &str, value: &str) -> Result<u32> {
418    value
419        .parse()
420        .map_err(|_| anyhow::anyhow!("invalid link id '{value}' for {option}"))
421}
422
423fn parse_seq(option: &str, value: &str) -> Result<i64> {
424    value
425        .parse()
426        .map_err(|_| anyhow::anyhow!("invalid sequence value '{value}' for {option}"))
427}
428
429fn set_positional_query(cli: &mut Cli, value: String) -> Result<()> {
430    if cli.query_arg.is_some() {
431        bail!("unexpected extra positional argument '{value}'");
432    }
433
434    cli.query_arg = Some(value);
435    Ok(())
436}