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