1use 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 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 pub fn trigger_command_count(&self) -> usize {
101 usize::from(self.always) + usize::from(self.once) + usize::from(self.never)
102 }
103
104 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 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}