Skip to main content

link_cli/
cli.rs

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