Skip to main content

dsp_cli/cli/
mod.rs

1//! CLI parsing — layer 1 of dsp-cli/ADR-0008.
2//!
3//! Produces typed argument structs from `argv`. The structs flow through the
4//! action layer; no business logic lives here.
5
6use clap::{Args, Parser, Subcommand};
7
8use crate::diagnostic::Diagnostic;
9use crate::render::{Format, HeaderMode, TableOptions};
10
11// ── after_help column-doc strings ─────────────────────────────────────────────
12//
13// Each tabular leaf command carries a static `after_help` line that documents
14// its column set.  These literals are derived from the per-noun column-set
15// consts in `crate::render` (step 5, D9).  They are intentionally NOT built
16// at runtime from the consts — clap requires `&'static str`.  A drift-guard
17// unit test (`test_after_help_matches_consts`) asserts that each literal's
18// column list exactly matches the joined `crate::render` const, so adding a
19// column without updating the literal fails CI.
20
21const AFTER_HELP_PROJECT_LIST: &str = "Columns (--columns): shortcode, shortname, longname, status, data_models, iri";
22
23const AFTER_HELP_PROJECT_DESCRIBE: &str =
24    "Columns (--columns): shortcode, shortname, longname, status, data_models, iri";
25
26const AFTER_HELP_PROJECT_DUMP: &str = "Columns (--columns): path  (--delete mode: deleted)";
27
28const AFTER_HELP_DATA_MODEL_LIST: &str = "Columns (--columns): name, iri, label, last_modified, is_builtin";
29
30const AFTER_HELP_DATA_MODEL_DESCRIBE: &str = "Columns (--columns): name, iri, label, last_modified, resource_types";
31
32const AFTER_HELP_DATA_MODEL_STRUCTURE: &str = "Columns (--columns): source, target, kind, field, target_data_model";
33
34const AFTER_HELP_RESOURCE_TYPE_LIST: &str = "Columns (--columns): name, iri, label, is_builtin, count";
35
36/// Full 8-column set for `resource-type describe` (one row per field).
37/// `iri` is accessible via `--columns iri` (hidden from the default csv/tsv
38/// output by the lean-default mechanism, but present in `all_columns`).
39const AFTER_HELP_RESOURCE_TYPE_DESCRIBE: &str =
40    "Columns (--columns): name, iri, value_type, link_target, cardinality, label, is_builtin, data_model";
41
42const AFTER_HELP_RESOURCE_LIST: &str = "Columns (--columns): label, iri, ark_url, creation_date, last_modified, resource_type\n\n\
43     Scan behaviour: a bare --resource-type name (no ://) scans all project data-models; \
44     use --data-model or a full IRI to skip the scan. See also: `dsp docs concepts`\n\n\
45     --order-by: field name (e.g. title) or full field IRI; sorts ascending; \
46     targets project-defined fields (full IRI passed verbatim; bare name resolved to its field IRI)";
47
48const AFTER_HELP_RESOURCE_DESCRIBE: &str = "Columns (--columns): label, iri, resource_type, ark_url, creation_date, last_modified, attached_project, owner, visibility, your_access\n\
49     Columns (--columns) with --values: label, iri, field, field_label, value_type, value, comment\n\
50     Default columns with --values: field, field_label, value_type, value\n\n\
51     See also: `dsp docs concepts`";
52
53const AFTER_HELP_VOCABULARY_LIST: &str = "Columns (--columns): name, iri, label_en, label_de, label_fr, label_it, label_rm, label, comment_en, comment_de, comment_fr, comment_it, comment_rm, comment, nodes, depth\n\n\
54     --filter matches name and every label value in every language (all languages kept — no language preference); comments are not scanned.\n\
55     --count fetches each vocabulary's full tree (one extra request PER vocabulary, sequential — this can be dozens of calls on a project with many large vocabularies); a failed per-tree fetch degrades that row's nodes/depth to empty rather than failing the whole command, and is disclosed.";
56
57const AFTER_HELP_VOCABULARY_DESCRIBE: &str = "Columns (--columns): node_iri, number, name, label_en, label_de, label_fr, label_it, label_rm, label, comment_en, comment_de, comment_fr, comment_it, comment_rm, comment, path, position, depth, parent_iri\n\n\
58     Emits the WHOLE vocabulary tree with no depth limit and no pagination — the largest vocabulary on prod has ~4400 nodes.\n\
59     `number` is a 1-based dotted outline in DFS order; it is NOT a sort key (`1.10` sorts before `1.2` lexicographically) — never re-sort tabular output on this column.\n\
60     A node IRI (e.g. pasted from `resource describe --values`) resolves upward to its vocabulary automatically; the addressed node is marked in prose/json output (not in tabular columns — match on node_iri instead).\n\
61     --subtree narrows output to the addressed node's own branch; it requires a node IRI — a bare name or a root IRI with --subtree is a usage error.";
62
63const AFTER_HELP_AUTH_LOGIN: &str = "Columns (--columns): server, user, expires_at, state";
64
65const AFTER_HELP_AUTH_STATUS: &str = "Columns (--columns): server, user, expires_at, state";
66
67const AFTER_HELP_AUTH_LOGOUT: &str = "Columns (--columns): server, was_cached";
68
69const AFTER_HELP_AUTH_SET_TOKEN: &str = "Columns (--columns): server, user, expires_at, state";
70
71/// No columns line — this leaf has no tabular output (D2, plan 035): no
72/// `--format`/`-j`/`-l`/`--columns`, the same carve-out as `dsp auth token`.
73const AFTER_HELP_SPARQL_QUERY: &str = "--accept aliases: json (default, application/sparql-results+json), \
74     xml (application/sparql-results+xml), csv (text/csv), tsv (text/tab-separated-values), \
75     turtle (text/turtle), ntriples (application/n-triples), jsonld (application/ld+json). \
76     A value containing '/' is forwarded verbatim as a raw media type.\n\n\
77     Caveat: Fuseki does not 406 on an Accept it cannot satisfy — it silently falls back to its \
78     own default serialization (application/sparql-results+xml), so a wrong --accept shows up as \
79     unexpected output, not as an error.\n\n\
80     Requires a system-administrator (SystemAdmin) token.\n\n\
81     --query-file accepts a leading-dash path as-is (allow_hyphen_values).\n\n\
82     --query on the command line is the LEAST private input: it lands in shell history and is \
83     readable via `ps`/`/proc/<pid>/cmdline` for the life of the process, AND dsp-api logs the \
84     full query text together with your username on every call. Prefer stdin or --query-file for \
85     anything sensitive. See also: dsp docs sparql";
86
87// ── output format ─────────────────────────────────────────────────────────────
88
89/// Output format selection for vre data commands.
90///
91/// Flattened into each of the six vre leaf Args structs. Provides `--format`,
92/// `-j`/`--json`, and `-l`/`--lines` as parallel selection paths.
93///
94/// Precedence (resolved by [`FormatArgs::resolve`]): `-j` > `-l` > `--format`.
95/// No `conflicts_with` is used — clap treats `default_value_t` as "implicitly
96/// set", which would make valid calls like `dsp vre project list -j` collide
97/// with the prose default. The helper-method precedence is simpler and correct.
98///
99/// See [dsp-cli/ADR-0003](../../docs/adr/0003-chaining-and-output.md) for the output
100/// format specification and the design-decisions section of the 003 plan for
101/// why this is per-leaf rather than global.
102#[derive(Debug, Args)]
103pub struct FormatArgs {
104    /// Output format (default: prose).
105    #[arg(
106        long,
107        value_enum,
108        default_value_t = Format::Prose,
109        value_name = "FORMAT"
110    )]
111    pub format: Format,
112
113    /// Shortcut for --format=json.
114    #[arg(short = 'j', long = "json")]
115    pub json: bool,
116
117    /// Shortcut for --format=lines.
118    #[arg(short = 'l', long = "lines")]
119    pub lines: bool,
120
121    /// Output columns for tabular formats (csv, tsv, lines): comma-separated list
122    /// that selects and reorders columns. Valid names are listed in the Columns
123    /// line below. Duplicates are rejected.
124    #[arg(long, value_name = "COLS")]
125    pub columns: Option<String>,
126
127    /// Omit the csv/tsv header row, e.g. appending rows to an existing file.
128    #[arg(long, conflicts_with = "header_only")]
129    pub no_header: bool,
130
131    /// Emit only the csv/tsv header row, no data rows. Note: the command still
132    /// contacts the server.
133    #[arg(long)]
134    pub header_only: bool,
135}
136
137impl FormatArgs {
138    /// Resolve the effective format. Precedence: `-j` > `-l` > `--format`.
139    pub fn resolve(&self) -> Format {
140        if self.json {
141            Format::Json
142        } else if self.lines {
143            Format::Lines
144        } else {
145            self.format
146        }
147    }
148
149    /// Validate and resolve tabular options from the CLI flags.
150    ///
151    /// `format` must be the **resolved** format (call [`FormatArgs::resolve`]
152    /// first — never pass `self.format`, which is always `Prose` when `-j`/`-l`
153    /// are used).
154    ///
155    /// ## Validation rules
156    ///
157    /// - `--columns` is only valid with `csv`, `tsv`, or `lines` output. Any other format →
158    ///   `Diagnostic::Usage`.
159    /// - `--no-header` / `--header-only` are only valid with `csv` or `tsv`. `lines` has no header
160    ///   concept. Any other format → `Diagnostic::Usage`.
161    /// - `--columns` value: the string must be non-empty; each comma-separated token must be
162    ///   non-blank (no `a,,b`); no duplicates allowed. Unknown column names are validated later by
163    ///   the engine (which knows the per-noun set).
164    ///
165    /// Returns a `TableOptions` whose `columns` field is guaranteed to be
166    /// syntactically valid (non-empty `Some(Vec)` with no blank entries and no
167    /// duplicates), or `None` if `--columns` was not supplied.
168    pub fn table_options(&self, format: Format) -> Result<TableOptions, Diagnostic> {
169        // Validate --columns scope.
170        if self.columns.is_some() && !matches!(format, Format::Csv | Format::Tsv | Format::Lines) {
171            return Err(Diagnostic::Usage("--columns works with csv, tsv, and lines output".to_string()));
172        }
173
174        // Validate --no-header / --header-only scope.
175        if (self.no_header || self.header_only) && !matches!(format, Format::Csv | Format::Tsv) {
176            return Err(Diagnostic::Usage(
177                "--no-header and --header-only work with csv and tsv output only (lines has no header concept)"
178                    .to_string(),
179            ));
180        }
181
182        // Parse --columns value.
183        let columns = if let Some(ref raw) = self.columns {
184            if raw.is_empty() {
185                return Err(Diagnostic::Usage("--columns requires at least one column name".to_string()));
186            }
187            let parts: Vec<&str> = raw.split(',').collect();
188            // Reject blank segments (e.g. "a,,b" or trailing comma).
189            for part in &parts {
190                if part.is_empty() {
191                    return Err(Diagnostic::Usage(format!(
192                        "--columns contains a blank segment in \"{raw}\"; \
193                         use a comma-separated list with no empty entries"
194                    )));
195                }
196            }
197            // Reject duplicates.
198            let mut seen = std::collections::HashSet::new();
199            for part in &parts {
200                if !seen.insert(*part) {
201                    return Err(Diagnostic::Usage(format!(
202                        "--columns contains duplicate column \"{part}\"; \
203                         each column may appear at most once"
204                    )));
205                }
206            }
207            Some(parts.iter().map(|s| s.to_string()).collect())
208        } else {
209            None
210        };
211
212        let header = if self.header_only {
213            HeaderMode::Only
214        } else if self.no_header {
215            HeaderMode::Off
216        } else {
217            HeaderMode::On
218        };
219
220        Ok(TableOptions { columns, header })
221    }
222}
223
224/// `dsp` — AI-agent-friendly CLI for the DaSCH Service Platform.
225#[derive(Debug, Parser)]
226#[command(
227    name = "dsp",
228    version,
229    about = "AI-agent-friendly CLI for the DaSCH Service Platform.",
230    long_about = "AI-agent-friendly CLI for the DaSCH Service Platform (DSP).\n\
231Run `dsp docs` to list available documentation topics.",
232    max_term_width = 100
233)]
234pub struct Cli {
235    /// Increase log verbosity (-v=info, -vv=debug, -vvv=trace). RUST_LOG overrides.
236    #[arg(short = 'v', long = "verbose", action = clap::ArgAction::Count, global = true)]
237    pub verbose: u8,
238
239    /// Allow a non-local --server to use insecure http:// (see `dsp docs connecting`).
240    #[arg(
241        long = "allow-insecure-server",
242        env = "DSP_ALLOW_INSECURE_SERVER",
243        value_parser = clap::builder::BoolishValueParser::new(),
244        action = clap::ArgAction::SetTrue,
245        global = true
246    )]
247    pub allow_insecure_server: bool,
248
249    #[command(subcommand)]
250    pub command: TopLevel,
251}
252
253impl Cli {
254    /// The effective output format of this invocation, per command. `None`
255    /// marks a command with no user-facing formatted output whose result must
256    /// not drive format-gated side effects (e.g. `auth token`, a raw-credential
257    /// pipe). `docs` has no `--format`: `-j` → Json, else Prose.
258    ///
259    /// Layer-neutral by design: the parser resolves the format; it does not
260    /// name or know what consumes it (the update-notice gate is one consumer).
261    pub fn output_format(&self) -> Option<Format> {
262        match &self.command {
263            TopLevel::Auth { cmd } => match cmd {
264                AuthCmd::Login(args) => Some(args.format.resolve()),
265                AuthCmd::Status(args) => Some(args.format.resolve()),
266                AuthCmd::Logout(args) => Some(args.format.resolve()),
267                AuthCmd::SetToken(args) => Some(args.format.resolve()),
268                AuthCmd::Token(_) => None,
269            },
270            TopLevel::Vre { cmd } => match cmd {
271                VreCmd::Project { cmd } => match cmd {
272                    ProjectCmd::List(args) => Some(args.format.resolve()),
273                    ProjectCmd::Describe(args) => Some(args.format.resolve()),
274                    ProjectCmd::Dump(args) => Some(args.format.resolve()),
275                },
276                VreCmd::DataModel { cmd } => match cmd {
277                    DataModelCmd::List(args) => Some(args.format.resolve()),
278                    DataModelCmd::Describe(args) => Some(args.format.resolve()),
279                    DataModelCmd::Structure(args) => Some(args.format.resolve()),
280                },
281                VreCmd::ResourceType { cmd } => match cmd {
282                    ResourceTypeCmd::List(args) => Some(args.format.resolve()),
283                    ResourceTypeCmd::Describe(args) => Some(args.format.resolve()),
284                },
285                VreCmd::Resource { cmd } => match cmd {
286                    ResourceCmd::List(args) => Some(args.format.resolve()),
287                    ResourceCmd::Describe(args) => Some(args.format.resolve()),
288                },
289                VreCmd::Vocabulary { cmd } => match cmd {
290                    VocabularyCmd::List(args) => Some(args.format.resolve()),
291                    VocabularyCmd::Describe(args) => Some(args.format.resolve()),
292                },
293                // No Renderer, no --format (D2, plan 035) — same carve-out as
294                // AuthCmd::Token(_): stdout is a store-authored byte stream,
295                // not dsp-cli's envelope, so the update-notice gate stays silent.
296                VreCmd::Sparql { cmd } => match cmd {
297                    SparqlCmd::Query(_) => None,
298                },
299            },
300            TopLevel::Docs(args) => Some(if args.json { Format::Json } else { Format::Prose }),
301        }
302    }
303
304    /// The `--server`/`-s` value for this invocation, if the command has one and it
305    /// was supplied. `None` for `docs` (no server flag) or when unset. Used by the
306    /// top-level error handler for best-effort `_meta.server` (D3 of plan 032).
307    pub fn server_flag(&self) -> Option<&str> {
308        match &self.command {
309            TopLevel::Auth { cmd } => match cmd {
310                AuthCmd::Login(args) => args.server.as_deref(),
311                AuthCmd::Status(args) => args.server.as_deref(),
312                AuthCmd::Logout(args) => args.server.as_deref(),
313                AuthCmd::SetToken(args) => args.server.as_deref(),
314                AuthCmd::Token(args) => args.server.as_deref(),
315            },
316            TopLevel::Vre { cmd } => match cmd {
317                VreCmd::Project { cmd } => match cmd {
318                    ProjectCmd::List(args) => args.server.as_deref(),
319                    ProjectCmd::Describe(args) => args.server.as_deref(),
320                    ProjectCmd::Dump(args) => args.server.as_deref(),
321                },
322                VreCmd::DataModel { cmd } => match cmd {
323                    DataModelCmd::List(args) => args.server.as_deref(),
324                    DataModelCmd::Describe(args) => args.server.as_deref(),
325                    DataModelCmd::Structure(args) => args.server.as_deref(),
326                },
327                VreCmd::ResourceType { cmd } => match cmd {
328                    ResourceTypeCmd::List(args) => args.server.as_deref(),
329                    ResourceTypeCmd::Describe(args) => args.server.as_deref(),
330                },
331                VreCmd::Resource { cmd } => match cmd {
332                    ResourceCmd::List(args) => args.server.as_deref(),
333                    ResourceCmd::Describe(args) => args.server.as_deref(),
334                },
335                VreCmd::Vocabulary { cmd } => match cmd {
336                    VocabularyCmd::List(args) => args.server.as_deref(),
337                    VocabularyCmd::Describe(args) => args.server.as_deref(),
338                },
339                VreCmd::Sparql { cmd } => match cmd {
340                    SparqlCmd::Query(args) => args.server.as_deref(),
341                },
342            },
343            TopLevel::Docs(_) => None,
344        }
345    }
346}
347
348// Top-level command groups — areas (`vre`, `repo`) and meta-groups
349// (`auth`, `docs`). See dsp-cli/ADR-0006.
350#[derive(Debug, Subcommand)]
351pub enum TopLevel {
352    /// Authentication management.
353    Auth {
354        #[command(subcommand)]
355        cmd: AuthCmd,
356    },
357
358    /// Virtual Research Environment (VRE) operations.
359    Vre {
360        #[command(subcommand)]
361        cmd: VreCmd,
362    },
363
364    /// Embedded end-user documentation; `dsp docs` lists topics.
365    Docs(DocsArgs),
366}
367
368// ── auth ─────────────────────────────────────────────────────────────────────
369
370/// Auth subcommands.
371#[derive(Debug, Subcommand)]
372pub enum AuthCmd {
373    /// Log in to a DSP server and cache the session token.
374    ///
375    /// See also: dsp docs connecting
376    Login(LoginArgs),
377
378    /// Show authentication status for a DSP server.
379    Status(StatusArgs),
380
381    /// Log out from a DSP server and clear the cached session token.
382    Logout(LogoutArgs),
383
384    /// Cache a pre-issued bearer token read from stdin.
385    ///
386    /// Reads a JWT from stdin, verifies it against the server with a live
387    /// probe, and — only if the probe succeeds — writes it into the auth
388    /// cache. Subsequent commands then reuse the token until it expires.
389    ///
390    /// See also: dsp docs connecting
391    #[command(name = "set-token")]
392    SetToken(SetTokenArgs),
393
394    /// Print the resolved bearer token to stdout, for piping.
395    ///
396    /// Prints a bearer credential to stdout — see the cautions in `dsp docs
397    /// connecting`.
398    ///
399    /// See also: dsp docs connecting
400    Token(TokenArgs),
401}
402
403/// Arguments for `dsp auth login`.
404#[derive(Debug, Args)]
405#[command(after_help = AFTER_HELP_AUTH_LOGIN)]
406pub struct LoginArgs {
407    /// DSP server URL or shortcut (e.g. `https://api.example.org` or `prod`).
408    /// Can also be set via the `DSP_SERVER` environment variable or a `.env` file.
409    #[arg(short = 's', long, env = "DSP_SERVER")]
410    pub server: Option<String>,
411
412    /// User identifier for authentication: an email address, a username, or a user IRI.
413    /// Can also be set via the `DSP_USER` environment variable or a `.env` file.
414    #[arg(short = 'u', long, env = "DSP_USER")]
415    pub user: Option<String>,
416
417    #[command(flatten)]
418    pub format: FormatArgs,
419}
420
421/// Arguments for `dsp auth status`.
422#[derive(Debug, Args)]
423#[command(after_help = AFTER_HELP_AUTH_STATUS)]
424pub struct StatusArgs {
425    /// DSP server URL or shortcut (e.g. `https://api.example.org` or `prod`).
426    /// Can also be set via the `DSP_SERVER` environment variable or a `.env` file.
427    #[arg(short = 's', long, env = "DSP_SERVER")]
428    pub server: Option<String>,
429
430    #[command(flatten)]
431    pub format: FormatArgs,
432}
433
434/// Arguments for `dsp auth logout`.
435#[derive(Debug, Args)]
436#[command(after_help = AFTER_HELP_AUTH_LOGOUT)]
437pub struct LogoutArgs {
438    /// DSP server URL or shortcut (e.g. `https://api.example.org` or `prod`).
439    /// Can also be set via the `DSP_SERVER` environment variable or a `.env` file.
440    #[arg(short = 's', long, env = "DSP_SERVER")]
441    pub server: Option<String>,
442
443    #[command(flatten)]
444    pub format: FormatArgs,
445}
446
447/// Arguments for `dsp auth set-token`.
448///
449/// No `--token` flag: the token is read from stdin to avoid leaking it into
450/// the shell history, `ps` output, or audit logs. See dsp-cli/ADR-0007.
451#[derive(Debug, Args)]
452#[command(after_help = AFTER_HELP_AUTH_SET_TOKEN)]
453pub struct SetTokenArgs {
454    /// DSP server URL or shortcut (e.g. `https://api.example.org` or `prod`).
455    /// Can also be set via the `DSP_SERVER` environment variable or a `.env` file.
456    #[arg(short = 's', long, env = "DSP_SERVER")]
457    pub server: Option<String>,
458
459    #[command(flatten)]
460    pub format: FormatArgs,
461}
462
463/// Arguments for `dsp auth token`.
464///
465/// No `--format`/`-j`/`-l`: the token is printed verbatim, bare, with no
466/// envelope. No `after_help` columns line either — there is no tabular output.
467#[derive(Debug, Args)]
468pub struct TokenArgs {
469    /// DSP server URL or shortcut (e.g. `https://api.example.org` or `prod`).
470    /// Can also be set via the `DSP_SERVER` environment variable or a `.env` file.
471    #[arg(short = 's', long, env = "DSP_SERVER")]
472    pub server: Option<String>,
473}
474
475// ── vre ───────────────────────────────────────────────────────────────────────
476
477/// VRE noun-group subcommands.
478#[derive(Debug, Subcommand)]
479pub enum VreCmd {
480    /// Manage DSP projects.
481    ///
482    /// A project is the top-level container on DSP. Every data-model and
483    /// resource belongs to exactly one project. See also: dsp docs concepts
484    Project {
485        #[command(subcommand)]
486        cmd: ProjectCmd,
487    },
488
489    /// Manage data-models within a project.
490    ///
491    /// A data-model (called "ontology" in DSP-API) defines the schema for a
492    /// project's resources: the resource-types, their fields, and value-types.
493    // Explicit name is load-bearing — the CLI surface is stable (dsp-cli/ADR-0002);
494    // don't strip this as "redundant with default kebab-case."
495    #[command(name = "data-model")]
496    DataModel {
497        #[command(subcommand)]
498        cmd: DataModelCmd,
499    },
500
501    /// Manage resource-types within a data-model.
502    ///
503    /// A resource-type (called "class" in DSP-API) defines the structure of
504    /// one kind of scholarly object: its fields, value-types, and cardinalities.
505    // Explicit name is load-bearing — the CLI surface is stable (dsp-cli/ADR-0002);
506    // don't strip this as "redundant with default kebab-case."
507    #[command(name = "resource-type")]
508    ResourceType {
509        #[command(subcommand)]
510        cmd: ResourceTypeCmd,
511    },
512
513    /// List resource instances within a project.
514    ///
515    /// Fetches the actual data instances (scholarly objects) stored in the DSP
516    /// server for a given resource-type, with optional pagination. See also:
517    /// dsp docs concepts
518    Resource {
519        #[command(subcommand)]
520        cmd: ResourceCmd,
521    },
522
523    /// Manage controlled vocabularies within a project (DSP-API "list").
524    ///
525    /// See also: dsp docs concepts
526    Vocabulary {
527        #[command(subcommand)]
528        cmd: VocabularyCmd,
529    },
530
531    /// Raw SPARQL 1.1 query passthrough to the server's own triplestore.
532    ///
533    /// Deliberately does NOT abstract DSP-API: the response is the store's
534    /// own document, byte-exact, in whatever media type it negotiated. No
535    /// Renderer, no --format. Requires a SystemAdmin token. See also: dsp
536    /// docs sparql
537    Sparql {
538        #[command(subcommand)]
539        cmd: SparqlCmd,
540    },
541}
542
543// ── vre project ───────────────────────────────────────────────────────────────
544
545/// Project verb subcommands.
546#[derive(Debug, Subcommand)]
547pub enum ProjectCmd {
548    /// List all projects on the DSP server.
549    ///
550    /// See also: dsp docs concepts
551    List(ProjectListArgs),
552
553    /// Describe a single DSP project.
554    ///
555    /// See also: dsp docs concepts
556    Describe(ProjectDescribeArgs),
557
558    /// Trigger and download a project dump (a server-produced bagit-zip archive).
559    ///
560    /// Connects to the DSP server, triggers a server-side dump of the specified
561    /// project, polls until the dump is ready, and downloads the resulting
562    /// bagit-zip archive to a local file. Binary assets (images, audio, video,
563    /// etc.) are included by default; pass `--skip-assets` to download only
564    /// the structured RDF data.
565    ///
566    /// **Requires a system-administrator token.** Obtain one via
567    /// `dsp auth login --server <server>` or set the `DSP_TOKEN` environment
568    /// variable.
569    Dump(ProjectDumpArgs),
570}
571
572/// Arguments for `dsp vre project list`.
573///
574/// Lists all projects on the DSP server. Use `--filter` to narrow results by a
575/// case-insensitive substring match over shortcode, shortname, and longname.
576/// Authentication is optional: an anonymous caller sees all public projects; an
577/// authenticated caller may see additional ones depending on server policy.
578#[derive(Debug, Args)]
579#[command(after_help = AFTER_HELP_PROJECT_LIST)]
580pub struct ProjectListArgs {
581    /// DSP server URL or shortcut (e.g. `https://api.example.org` or `prod`).
582    /// Can also be set via the `DSP_SERVER` environment variable or a `.env` file.
583    #[arg(short = 's', long, env = "DSP_SERVER")]
584    pub server: Option<String>,
585
586    /// Filter projects by case-insensitive substring over shortcode, shortname,
587    /// and longname.
588    #[arg(long)]
589    pub filter: Option<String>,
590
591    #[command(flatten)]
592    pub format: FormatArgs,
593}
594
595/// Arguments for `dsp vre project describe`.
596#[derive(Debug, Args)]
597#[command(after_help = AFTER_HELP_PROJECT_DESCRIBE)]
598pub struct ProjectDescribeArgs {
599    /// DSP server URL or shortcut (e.g. `https://api.example.org` or `prod`).
600    /// Can also be set via the `DSP_SERVER` environment variable or a `.env` file.
601    #[arg(short = 's', long, env = "DSP_SERVER")]
602    pub server: Option<String>,
603
604    /// Shortcode, shortname, or IRI of the project to describe.
605    #[arg(short = 'p', long)]
606    pub project: Option<String>,
607
608    #[command(flatten)]
609    pub format: FormatArgs,
610}
611
612/// Arguments for `dsp vre project dump`.
613///
614/// Triggers a server-side project dump (a bagit-zip archive of the project's
615/// data) and downloads it to a local file. Assets (images, audio, video, etc.)
616/// are included by default; use `--skip-assets` to download only the
617/// structured RDF data.
618///
619/// **Requires a system-administrator token.** Obtain one via
620/// `dsp auth login --server <server>` or set the `DSP_TOKEN` environment
621/// variable.
622#[derive(Debug, Args)]
623#[command(after_help = AFTER_HELP_PROJECT_DUMP)]
624pub struct ProjectDumpArgs {
625    /// DSP server URL or shortcut (e.g. `https://api.example.org` or `prod`).
626    /// Can also be set via the `DSP_SERVER` environment variable or a `.env` file.
627    #[arg(short = 's', long, env = "DSP_SERVER")]
628    pub server: Option<String>,
629
630    /// Shortcode, shortname, or IRI of the project to dump.
631    #[arg(short = 'p', long)]
632    pub project: Option<String>,
633
634    /// Skip binary assets (images, audio, video, etc.); download only the
635    /// project's structured RDF data. Assets are included by default.
636    #[arg(long)]
637    pub skip_assets: bool,
638
639    /// Write the dump to this path instead of the default
640    /// `./<shortcode>-<timestamp>.zip`.
641    #[arg(short = 'o', long)]
642    pub output: Option<std::path::PathBuf>,
643
644    /// Overwrite an existing output file. Without this flag, the command
645    /// refuses to overwrite an existing path.
646    #[arg(long)]
647    pub force: bool,
648
649    /// Delete the server-side dump after a successful download.
650    #[arg(long)]
651    pub cleanup: bool,
652
653    /// Abort if the dump has not completed within this many seconds
654    /// (must be at least 1).
655    #[arg(long, default_value_t = 3600, value_parser = clap::value_parser!(u64).range(1..))]
656    pub timeout: u64,
657
658    /// Discard this project's existing dump and create a fresh one. If the
659    /// server's single dump slot is held by a **different** project, this refuses
660    /// unless `--discard-other-project` is also given.
661    #[arg(long, conflicts_with = "delete")]
662    pub replace: bool,
663
664    /// Remove this project's dump without downloading. If the slot is held by a
665    /// different project, this is a no-op (it never removes another project's dump).
666    #[arg(
667        long,
668        conflicts_with_all = ["replace", "output", "force", "skip_assets", "cleanup"]
669    )]
670    pub delete: bool,
671
672    /// Only valid with `--replace`. The DSP-API holds one dump server-wide; if
673    /// the slot is held by a **different** project, also discard *that* project's
674    /// dump to make room. Without this, `--replace` refuses when the slot belongs
675    /// to another project. (Distinct from `--force`, which only governs
676    /// overwriting the local output file.)
677    #[arg(long, requires = "replace", conflicts_with = "delete")]
678    pub discard_other_project: bool,
679
680    #[command(flatten)]
681    pub format: FormatArgs,
682}
683
684// ── vre data-model ────────────────────────────────────────────────────────────
685
686/// Data-model verb subcommands.
687#[derive(Debug, Subcommand)]
688pub enum DataModelCmd {
689    /// List all data-models in a project.
690    ///
691    /// See also: dsp docs concepts
692    List(DataModelListArgs),
693
694    /// Describe a single data-model.
695    ///
696    /// See also: dsp docs concepts
697    Describe(DataModelDescribeArgs),
698
699    /// Show the relations (links + inheritance) between a data-model's resource-types.
700    ///
701    /// See also: dsp docs concepts
702    Structure(DataModelStructureArgs),
703}
704
705/// Arguments for `dsp vre data-model list`.
706#[derive(Debug, Args)]
707#[command(after_help = AFTER_HELP_DATA_MODEL_LIST)]
708pub struct DataModelListArgs {
709    /// DSP server URL or shortcut (e.g. `https://api.example.org` or `prod`).
710    /// Can also be set via the `DSP_SERVER` environment variable or a `.env` file.
711    #[arg(short = 's', long, env = "DSP_SERVER")]
712    pub server: Option<String>,
713
714    /// Shortcode, shortname, or IRI of the project whose data-models to list.
715    #[arg(short = 'p', long)]
716    pub project: Option<String>,
717
718    /// Filter data-models by case-insensitive substring over name and label.
719    #[arg(long)]
720    pub filter: Option<String>,
721
722    /// Also list the platform built-in data-models (knora-api, standoff,
723    /// salsah-gui) that every project inherits. Off by default.
724    #[arg(long)]
725    pub include_builtins: bool,
726
727    #[command(flatten)]
728    pub format: FormatArgs,
729}
730
731/// Arguments for `dsp vre data-model describe`.
732#[derive(Debug, Args)]
733#[command(after_help = AFTER_HELP_DATA_MODEL_DESCRIBE)]
734pub struct DataModelDescribeArgs {
735    /// DSP server URL or shortcut (e.g. `https://api.example.org` or `prod`).
736    /// Can also be set via the `DSP_SERVER` environment variable or a `.env` file.
737    #[arg(short = 's', long, env = "DSP_SERVER")]
738    pub server: Option<String>,
739
740    /// Shortcode, shortname, or IRI of the project containing the data-model.
741    #[arg(short = 'p', long)]
742    pub project: Option<String>,
743
744    /// Name or IRI of the data-model to describe.
745    #[arg(long = "data-model")]
746    pub data_model: Option<String>,
747
748    #[command(flatten)]
749    pub format: FormatArgs,
750}
751
752/// Arguments for `dsp vre data-model structure`.
753#[derive(Debug, Args)]
754#[command(after_help = AFTER_HELP_DATA_MODEL_STRUCTURE)]
755pub struct DataModelStructureArgs {
756    /// DSP server URL or shortcut (e.g. `https://api.example.org` or `prod`).
757    /// Can also be set via the `DSP_SERVER` environment variable or a `.env` file.
758    #[arg(short = 's', long, env = "DSP_SERVER")]
759    pub server: Option<String>,
760
761    /// Shortcode, shortname, or IRI of the project containing the data-model.
762    #[arg(short = 'p', long)]
763    pub project: Option<String>,
764
765    /// Name or IRI of the data-model whose structure to show.
766    #[arg(long = "data-model")]
767    pub data_model: Option<String>,
768
769    /// Also show relations to/from the platform built-in resource-types
770    /// (e.g. inherits edges to `Resource` or `StillImageRepresentation`).
771    /// Off by default.
772    #[arg(long)]
773    pub include_builtins: bool,
774
775    #[command(flatten)]
776    pub format: FormatArgs,
777}
778
779// ── vre resource-type ─────────────────────────────────────────────────────────
780
781/// Resource-type verb subcommands.
782#[derive(Debug, Subcommand)]
783pub enum ResourceTypeCmd {
784    /// List all resource-types in a data-model.
785    ///
786    /// See also: dsp docs concepts
787    List(ResourceTypeListArgs),
788
789    /// Describe a single resource-type, including its fields and value-types.
790    ///
791    /// See also: dsp docs concepts
792    Describe(ResourceTypeDescribeArgs),
793}
794
795/// Arguments for `dsp vre resource-type list`.
796#[derive(Debug, Args)]
797#[command(after_help = AFTER_HELP_RESOURCE_TYPE_LIST)]
798pub struct ResourceTypeListArgs {
799    /// DSP server URL or shortcut (e.g. `https://api.example.org` or `prod`).
800    /// Can also be set via the `DSP_SERVER` environment variable or a `.env` file.
801    #[arg(short = 's', long, env = "DSP_SERVER")]
802    pub server: Option<String>,
803
804    /// Shortcode, shortname, or IRI of the project.
805    #[arg(short = 'p', long)]
806    pub project: Option<String>,
807
808    /// Name or IRI of the data-model containing the resource-types.
809    #[arg(long = "data-model")]
810    pub data_model: Option<String>,
811
812    /// Filter resource-types by case-insensitive substring over name and label.
813    #[arg(long)]
814    pub filter: Option<String>,
815
816    /// Also list the platform built-in resource-types a user can instantiate
817    /// (Region, AudioSegment, VideoSegment, LinkObj) that every project inherits.
818    /// Off by default.
819    #[arg(long)]
820    pub include_builtins: bool,
821
822    /// Also fetch and show instance counts per resource-type (one extra HTTP
823    /// call). Off by default. Counts are non-deleted but NOT permission-filtered
824    /// (unlike `resource list`) — a disclosure note is emitted when this flag is
825    /// used.
826    #[arg(long)]
827    pub count: bool,
828
829    #[command(flatten)]
830    pub format: FormatArgs,
831}
832
833/// Arguments for `dsp vre resource-type describe`.
834#[derive(Debug, Args)]
835#[command(after_help = AFTER_HELP_RESOURCE_TYPE_DESCRIBE)]
836pub struct ResourceTypeDescribeArgs {
837    /// DSP server URL or shortcut (e.g. `https://api.example.org` or `prod`).
838    /// Can also be set via the `DSP_SERVER` environment variable or a `.env` file.
839    #[arg(short = 's', long, env = "DSP_SERVER")]
840    pub server: Option<String>,
841
842    /// Shortcode, shortname, or IRI of the project.
843    #[arg(short = 'p', long)]
844    pub project: Option<String>,
845
846    /// Name or IRI of the data-model containing the resource-type.
847    #[arg(long = "data-model")]
848    pub data_model: Option<String>,
849
850    /// Name or IRI of the resource-type to describe.
851    #[arg(long = "resource-type")]
852    pub resource_type: Option<String>,
853
854    /// Also show the built-in (platform) fields every resource inherits
855    /// (arkUrl, permissions, timestamps, …). Off by default.
856    #[arg(long)]
857    pub include_builtins: bool,
858
859    /// Also fetch and show instance counts per resource-type (one extra HTTP
860    /// call). Off by default. Counts are non-deleted but NOT permission-filtered
861    /// (unlike `resource list`) — a disclosure note is emitted when this flag is
862    /// used.
863    #[arg(long)]
864    pub count: bool,
865
866    #[command(flatten)]
867    pub format: FormatArgs,
868}
869
870// ── vre resource ──────────────────────────────────────────────────────────────
871
872/// Resource verb subcommands.
873#[derive(Debug, Subcommand)]
874pub enum ResourceCmd {
875    /// List resource instances of a given type within a project.
876    ///
877    /// Fetches the actual data instances stored in DSP for a resource-type.
878    /// Supports single-page (`--page N`) and all-pages (`--all`) modes.
879    /// Authentication is optional; anonymous callers see only public resources.
880    ///
881    /// See also: dsp docs concepts
882    List(ResourceListArgs),
883
884    /// Fetch the envelope metadata of a single resource by its internal IRI.
885    ///
886    /// Returns the resource's label, resource-type, IRI, ARK URL, creation and
887    /// last-modification dates, owning project, owner, visibility, and your
888    /// access level. Field values (the actual data) are omitted by default;
889    /// pass `--values` to include them.
890    ///
891    /// Use `--resource` with the resource's internal IRI. ARK addressing is not
892    /// supported in v1 — use the internal IRI directly. Optionally, supply
893    /// `--project` to guard that the resource belongs to the expected project.
894    ///
895    /// Authentication is optional; anonymous callers see only public resources.
896    ///
897    /// See also: dsp docs concepts
898    Describe(ResourceDescribeArgs),
899}
900
901/// Arguments for `dsp vre resource list`.
902#[derive(Debug, Args)]
903#[command(after_help = AFTER_HELP_RESOURCE_LIST)]
904pub struct ResourceListArgs {
905    /// DSP server URL or shortcut (e.g. `https://api.example.org` or `prod`).
906    /// Can also be set via the `DSP_SERVER` environment variable or a `.env` file.
907    #[arg(short = 's', long, env = "DSP_SERVER")]
908    pub server: Option<String>,
909
910    /// Shortcode, shortname, or IRI of the project.
911    #[arg(short = 'p', long)]
912    pub project: Option<String>,
913
914    /// Name or full IRI of the resource-type to list instances of.
915    /// A bare name triggers a scan across all project data-models; use
916    /// `--data-model` or a full IRI (`://` heuristic) to skip the scan.
917    #[arg(long = "resource-type")]
918    pub resource_type: Option<String>,
919
920    /// Name or IRI of the data-model to scope the resource-type search.
921    /// Optional; narrows the bare-name scan to one data-model.
922    #[arg(long = "data-model")]
923    pub data_model: Option<String>,
924
925    /// Page number to fetch (zero-based). Cannot be combined with `--all`.
926    /// Defaults to page 0 when neither `--page` nor `--all` is given.
927    #[arg(long, value_parser = clap::value_parser!(u32), conflicts_with = "all")]
928    pub page: Option<u32>,
929
930    /// Fetch all pages until the server reports no more results.
931    /// Cannot be combined with `--page`.
932    #[arg(long)]
933    pub all: bool,
934
935    /// Filter resources by case-insensitive substring over the label.
936    #[arg(long)]
937    pub filter: Option<String>,
938
939    /// Field name (e.g. `title`) or full field IRI to sort by (ascending).
940    /// A bare field name is resolved to the resource-type's field IRI;
941    /// a full IRI (contains `://`) is passed to the server verbatim.
942    /// Targets project-defined fields; ascending order only.
943    #[arg(long = "order-by")]
944    pub order_by: Option<String>,
945
946    #[command(flatten)]
947    pub format: FormatArgs,
948}
949
950/// Arguments for `dsp vre resource describe`.
951///
952/// Fetches the envelope metadata of a single resource by its internal IRI.
953/// Authentication is optional; anonymous callers see only publicly-visible
954/// resources. Use `--project` to assert that the resource belongs to the
955/// expected project (a cross-project guard — fails if the resource's
956/// attached project does not match).
957///
958/// **ARK addressing is not supported in v1.** Use the resource's internal IRI
959/// (e.g. `http://rdfh.ch/0803/AbCdEf`) as returned by `dsp vre resource list`.
960#[derive(Debug, Args)]
961#[command(after_help = AFTER_HELP_RESOURCE_DESCRIBE)]
962pub struct ResourceDescribeArgs {
963    /// DSP server URL or shortcut (e.g. `https://api.example.org` or `prod`).
964    /// Can also be set via the `DSP_SERVER` environment variable or a `.env` file.
965    #[arg(short = 's', long, env = "DSP_SERVER")]
966    pub server: Option<String>,
967
968    /// Internal IRI of the resource to describe (e.g. `http://rdfh.ch/0803/AbCdEf`).
969    /// ARK addressing is not supported in v1 — use the internal IRI directly.
970    /// Run `dsp vre resource list` to discover resource IRIs.
971    #[arg(long)]
972    pub resource: Option<String>,
973
974    /// Shortcode, shortname, or IRI of the expected project. When given, the
975    /// command fails with a usage error unless the resource's attached project
976    /// matches this value. Omit to describe a resource regardless of project.
977    #[arg(short = 'p', long)]
978    pub project: Option<String>,
979
980    /// Include the resource's field values in the output (off by default; metadata
981    /// envelope only when omitted). In `prose` and `json` this adds a values
982    /// section on top of the metadata; in tabular formats (`csv`, `tsv`, `lines`)
983    /// the output becomes one row per value instead of the metadata row.
984    /// Resolving field and vocabulary-item labels requires additional server requests.
985    /// See also: `dsp docs concepts`
986    #[arg(long)]
987    pub values: bool,
988
989    #[command(flatten)]
990    pub format: FormatArgs,
991}
992
993// ── vre vocabulary ───────────────────────────────────────────────────────────
994
995/// Vocabulary verb subcommands.
996#[derive(Debug, Subcommand)]
997pub enum VocabularyCmd {
998    /// List a project's vocabularies.
999    ///
1000    /// See also: dsp docs concepts
1001    List(VocabularyListArgs),
1002
1003    /// Describe a single vocabulary's full tree.
1004    ///
1005    /// See also: dsp docs concepts
1006    Describe(VocabularyDescribeArgs),
1007}
1008
1009/// Arguments for `dsp vre vocabulary list`.
1010///
1011/// Lists a project's vocabularies (DSP-API "lists"). Authentication is
1012/// optional — vocabularies are public data (D2/schema-side read).
1013#[derive(Debug, Args)]
1014#[command(after_help = AFTER_HELP_VOCABULARY_LIST)]
1015pub struct VocabularyListArgs {
1016    /// DSP server URL or shortcut (e.g. `https://api.example.org` or `prod`).
1017    /// Can also be set via the `DSP_SERVER` environment variable or a `.env` file.
1018    #[arg(short = 's', long, env = "DSP_SERVER")]
1019    pub server: Option<String>,
1020
1021    /// Shortcode, shortname, or IRI of the project.
1022    #[arg(short = 'p', long)]
1023    pub project: Option<String>,
1024
1025    /// Filter vocabularies by case-insensitive substring over name and every label.
1026    #[arg(long)]
1027    pub filter: Option<String>,
1028
1029    /// Also fetch each vocabulary's full tree to show node/depth counts (one
1030    /// extra HTTP request per vocabulary). Off by default.
1031    #[arg(long)]
1032    pub count: bool,
1033
1034    #[command(flatten)]
1035    pub format: FormatArgs,
1036}
1037
1038/// Arguments for `dsp vre vocabulary describe`.
1039///
1040/// Describes a single vocabulary's full tree, with no depth limit and no
1041/// pagination. A node IRI (e.g. pasted from `resource describe --values`)
1042/// resolves upward to its vocabulary automatically and is marked in the
1043/// output; use `--subtree` to narrow output to that node's own branch.
1044#[derive(Debug, Args)]
1045#[command(after_help = AFTER_HELP_VOCABULARY_DESCRIBE)]
1046pub struct VocabularyDescribeArgs {
1047    /// DSP server URL or shortcut (e.g. `https://api.example.org` or `prod`).
1048    /// Can also be set via the `DSP_SERVER` environment variable or a `.env` file.
1049    #[arg(short = 's', long, env = "DSP_SERVER")]
1050    pub server: Option<String>,
1051
1052    /// Shortcode, shortname, or IRI of the project. Required only when
1053    /// --vocabulary is a bare name (a full IRI needs no project).
1054    #[arg(short = 'p', long)]
1055    pub project: Option<String>,
1056
1057    /// Name or IRI of the vocabulary (or one of its nodes) to describe.
1058    #[arg(long = "vocabulary")]
1059    pub vocabulary: Option<String>,
1060
1061    /// Narrow output to the addressed node's own branch. Requires a node
1062    /// IRI (a bare name or a root IRI is a usage error).
1063    #[arg(long)]
1064    pub subtree: bool,
1065
1066    #[command(flatten)]
1067    pub format: FormatArgs,
1068}
1069
1070// ── vre sparql ───────────────────────────────────────────────────────────────
1071
1072/// Sparql verb subcommands.
1073#[derive(Debug, Subcommand)]
1074pub enum SparqlCmd {
1075    /// Run a raw SPARQL 1.1 query against the server's triplestore.
1076    ///
1077    /// See also: dsp docs sparql
1078    Query(SparqlQueryArgs),
1079}
1080
1081/// Arguments for `dsp vre sparql query`.
1082///
1083/// No `--format`/`-j`/`-l`/`--columns`/`--no-header` (D2, plan 035): the
1084/// response body is a store-authored document in a store-negotiated media
1085/// type, and dsp-cli's envelope would corrupt or double-encode it. Copied
1086/// from `TokenArgs`' precedent (D2) — the third no-Renderer command.
1087#[derive(Debug, Args)]
1088#[command(after_help = AFTER_HELP_SPARQL_QUERY)]
1089pub struct SparqlQueryArgs {
1090    /// DSP server URL or shortcut (e.g. `https://api.example.org` or `prod`).
1091    /// Can also be set via the `DSP_SERVER` environment variable or a `.env` file.
1092    #[arg(short = 's', long, env = "DSP_SERVER")]
1093    pub server: Option<String>,
1094
1095    /// The SPARQL query text. Mutually exclusive with --query-file; if
1096    /// neither is given, the query is read from stdin. See --help's note on
1097    /// shell-history/`ps` exposure.
1098    #[arg(long, conflicts_with = "query_file")]
1099    pub query: Option<String>,
1100
1101    /// Path to a file containing the SPARQL query text. Mutually exclusive
1102    /// with --query. A leading-dash path is accepted as-is
1103    /// (`--query-file -foo.rq`), via `allow_hyphen_values`.
1104    #[arg(long, allow_hyphen_values = true)]
1105    pub query_file: Option<String>,
1106
1107    /// Requested response media type: an alias (json/xml/csv/tsv/turtle/
1108    /// ntriples/jsonld) or a raw media type containing '/'. Defaults to
1109    /// `json` (application/sparql-results+json) — the store's own default,
1110    /// with no Accept sent, is XML. See --help for the full alias table and
1111    /// the Fuseki silent-fallback caveat.
1112    #[arg(long)]
1113    pub accept: Option<String>,
1114
1115    /// Client-side request timeout, in whole seconds. Bounds the request
1116    /// DOWN from the server's own guardrail; it cannot raise it. Default:
1117    /// 3600 (one hour) — the server's own ~135s deadline is what ends a slow
1118    /// query in practice.
1119    #[arg(long, default_value_t = 3600, value_parser = clap::value_parser!(u64).range(1..))]
1120    pub timeout: u64,
1121}
1122
1123// ── docs ──────────────────────────────────────────────────────────────────────
1124
1125/// Arguments for `dsp docs [topic]`.
1126#[derive(Debug, Args)]
1127pub struct DocsArgs {
1128    /// Topic to display. Omit to list all topics. Topics: dsp-cli, dsp,
1129    /// concepts, identifiers, connecting, output, workflows, errors,
1130    /// dsp-tools, sparql. Run `dsp docs` for one-line descriptions.
1131    pub topic: Option<String>,
1132
1133    /// Page the output through $PAGER (default `less`).
1134    #[arg(long, conflicts_with = "json")]
1135    pub pager: bool,
1136
1137    /// Emit the topic index as machine-readable JSON. Cannot be combined with a
1138    /// topic name or --pager.
1139    #[arg(
1140        short = 'j',
1141        long = "json",
1142        conflicts_with_all = ["topic", "pager"]
1143    )]
1144    pub json: bool,
1145}
1146
1147// ── Unit tests for FormatArgs::table_options and after_help drift guard ──────
1148
1149#[cfg(test)]
1150mod tests {
1151    use super::*;
1152    use crate::render::{
1153        AUTH_LOGIN_COLUMNS, AUTH_LOGOUT_COLUMNS, DATA_MODEL_DESCRIBE_COLUMNS, DATA_MODEL_STRUCTURE_COLUMNS,
1154        DATA_MODELS_COLUMNS, Format, HeaderMode, PROJECT_DUMP_COLUMNS, PROJECT_DUMP_DELETED_COLUMNS, PROJECTS_COLUMNS,
1155        RESOURCE_DESCRIBE_COLUMNS, RESOURCE_DESCRIBE_VALUES_COLUMNS, RESOURCE_DESCRIBE_VALUES_DEFAULT_COLUMNS,
1156        RESOURCE_LIST_COLUMNS, RESOURCE_TYPE_DESCRIBE_COLUMNS, RESOURCE_TYPES_COLUMNS, VOCABULARIES_COLUMNS,
1157        VOCABULARY_DESCRIBE_COLUMNS,
1158    };
1159
1160    /// Helper: construct a minimal FormatArgs with only the given flags set;
1161    /// all others default to "unset / false / None".
1162    fn fmt_args(
1163        format: Format,
1164        json: bool,
1165        lines: bool,
1166        columns: Option<&str>,
1167        no_header: bool,
1168        header_only: bool,
1169    ) -> FormatArgs {
1170        FormatArgs {
1171            format,
1172            json,
1173            lines,
1174            columns: columns.map(|s| s.to_string()),
1175            no_header,
1176            header_only,
1177        }
1178    }
1179
1180    // ── format-combination validation ─────────────────────────────────────────
1181
1182    #[test]
1183    fn columns_with_prose_default_rejected() {
1184        // --columns with the default (prose) format → Usage error.
1185        let args = fmt_args(Format::Prose, false, false, Some("name"), false, false);
1186        let result = args.table_options(Format::Prose);
1187        assert!(matches!(result, Err(Diagnostic::Usage(_))), "expected Usage, got {result:?}");
1188    }
1189
1190    #[test]
1191    fn columns_with_json_rejected() {
1192        // --columns -j → resolved format is Json → Usage error.
1193        let args = fmt_args(Format::Prose, true, false, Some("name"), false, false);
1194        let resolved = args.resolve(); // Json
1195        let result = args.table_options(resolved);
1196        assert!(matches!(result, Err(Diagnostic::Usage(_))), "expected Usage, got {result:?}");
1197    }
1198
1199    #[test]
1200    fn columns_with_lines_accepted() {
1201        // --columns with -l (lines) → ok.
1202        let args = fmt_args(Format::Prose, false, true, Some("name"), false, false);
1203        let resolved = args.resolve(); // Lines
1204        let result = args.table_options(resolved);
1205        assert!(result.is_ok(), "expected Ok, got {result:?}");
1206        let opts = result.unwrap();
1207        assert_eq!(opts.columns, Some(vec!["name".to_string()]));
1208    }
1209
1210    #[test]
1211    fn columns_with_format_lines_flag_accepted() {
1212        // --columns --format lines → resolved via the --format branch (not -l).
1213        let args = fmt_args(Format::Lines, false, false, Some("iri"), false, false);
1214        let resolved = args.resolve(); // Lines (via --format)
1215        let result = args.table_options(resolved);
1216        assert!(result.is_ok(), "expected Ok, got {result:?}");
1217        let opts = result.unwrap();
1218        assert_eq!(opts.columns, Some(vec!["iri".to_string()]));
1219    }
1220
1221    #[test]
1222    fn columns_with_csv_accepted() {
1223        let args = fmt_args(Format::Csv, false, false, Some("shortcode,iri"), false, false);
1224        let result = args.table_options(Format::Csv);
1225        assert!(result.is_ok(), "expected Ok, got {result:?}");
1226        let opts = result.unwrap();
1227        assert_eq!(opts.columns, Some(vec!["shortcode".to_string(), "iri".to_string()]));
1228    }
1229
1230    // ── header-flag validation ────────────────────────────────────────────────
1231
1232    #[test]
1233    fn no_header_with_prose_rejected() {
1234        // --no-header with the default prose format → Usage.
1235        let args = fmt_args(Format::Prose, false, false, None, true, false);
1236        let resolved = args.resolve(); // Prose
1237        let result = args.table_options(resolved);
1238        assert!(
1239            matches!(result, Err(Diagnostic::Usage(_))),
1240            "expected Usage for --no-header + prose, got {result:?}"
1241        );
1242    }
1243
1244    #[test]
1245    fn header_only_with_prose_rejected() {
1246        // --header-only with the default prose format → Usage.
1247        let args = fmt_args(Format::Prose, false, false, None, false, true);
1248        let resolved = args.resolve(); // Prose
1249        let result = args.table_options(resolved);
1250        assert!(
1251            matches!(result, Err(Diagnostic::Usage(_))),
1252            "expected Usage for --header-only + prose, got {result:?}"
1253        );
1254    }
1255
1256    #[test]
1257    fn no_header_with_lines_rejected() {
1258        // --no-header with lines → Usage (lines has no header concept).
1259        let args = fmt_args(Format::Prose, false, true, None, true, false);
1260        let resolved = args.resolve(); // Lines
1261        let result = args.table_options(resolved);
1262        assert!(
1263            matches!(result, Err(Diagnostic::Usage(_))),
1264            "expected Usage for --no-header + lines, got {result:?}"
1265        );
1266    }
1267
1268    #[test]
1269    fn header_only_with_json_rejected() {
1270        // --header-only with -j → Usage.
1271        let args = fmt_args(Format::Prose, true, false, None, false, true);
1272        let resolved = args.resolve(); // Json
1273        let result = args.table_options(resolved);
1274        assert!(
1275            matches!(result, Err(Diagnostic::Usage(_))),
1276            "expected Usage for --header-only + json, got {result:?}"
1277        );
1278    }
1279
1280    #[test]
1281    fn no_header_with_csv_accepted() {
1282        let args = fmt_args(Format::Csv, false, false, None, true, false);
1283        let opts = args.table_options(Format::Csv).unwrap();
1284        assert_eq!(opts.header, HeaderMode::Off);
1285    }
1286
1287    #[test]
1288    fn header_only_with_tsv_accepted() {
1289        let args = fmt_args(Format::Tsv, false, false, None, false, true);
1290        let opts = args.table_options(Format::Tsv).unwrap();
1291        assert_eq!(opts.header, HeaderMode::Only);
1292    }
1293
1294    #[test]
1295    fn default_gives_header_on() {
1296        let args = fmt_args(Format::Csv, false, false, None, false, false);
1297        let opts = args.table_options(Format::Csv).unwrap();
1298        assert_eq!(opts.header, HeaderMode::On);
1299    }
1300
1301    // ── column syntax validation ──────────────────────────────────────────────
1302
1303    #[test]
1304    fn empty_columns_value_rejected() {
1305        let args = fmt_args(Format::Csv, false, false, Some(""), false, false);
1306        let result = args.table_options(Format::Csv);
1307        assert!(
1308            matches!(result, Err(Diagnostic::Usage(_))),
1309            "expected Usage for empty --columns, got {result:?}"
1310        );
1311    }
1312
1313    #[test]
1314    fn blank_segment_rejected() {
1315        // "a,,b" has an empty middle segment.
1316        let args = fmt_args(Format::Csv, false, false, Some("a,,b"), false, false);
1317        let result = args.table_options(Format::Csv);
1318        assert!(
1319            matches!(result, Err(Diagnostic::Usage(_))),
1320            "expected Usage for blank segment, got {result:?}"
1321        );
1322    }
1323
1324    #[test]
1325    fn duplicate_rejected() {
1326        let args = fmt_args(Format::Csv, false, false, Some("iri,iri"), false, false);
1327        let result = args.table_options(Format::Csv);
1328        assert!(
1329            matches!(result, Err(Diagnostic::Usage(_))),
1330            "expected Usage for duplicate column, got {result:?}"
1331        );
1332    }
1333
1334    #[test]
1335    fn single_column_accepted() {
1336        let args = fmt_args(Format::Lines, false, false, Some("iri"), false, false);
1337        let opts = args.table_options(Format::Lines).unwrap();
1338        assert_eq!(opts.columns, Some(vec!["iri".to_string()]));
1339    }
1340
1341    #[test]
1342    fn multiple_columns_select_and_reorder() {
1343        // Columns come back in the user-supplied order (the engine honours it).
1344        let args = fmt_args(Format::Csv, false, false, Some("iri,shortcode,label"), false, false);
1345        let opts = args.table_options(Format::Csv).unwrap();
1346        assert_eq!(
1347            opts.columns,
1348            Some(vec!["iri".to_string(), "shortcode".to_string(), "label".to_string()])
1349        );
1350    }
1351
1352    #[test]
1353    fn no_columns_gives_none() {
1354        let args = fmt_args(Format::Csv, false, false, None, false, false);
1355        let opts = args.table_options(Format::Csv).unwrap();
1356        assert_eq!(opts.columns, None);
1357    }
1358
1359    // ── drift-guard: after_help column list must match per-noun consts ─────────
1360    //
1361    // Each assertion checks that the corresponding AFTER_HELP_* constant's column
1362    // list exactly matches `<CONST>.join(", ")`.  Adding a column to the const
1363    // without updating the literal (or vice versa) fails this test, preventing
1364    // silent drift between the runtime engine and the help text.
1365    //
1366    // RESOURCE_TYPE_DESCRIBE_DEFAULT_COLUMNS (and any other "lean default" consts)
1367    // are intentionally NOT listed here: they are internal engine defaults, not
1368    // user-facing column sets. The drift-guard covers all_columns consts only —
1369    // those are the valid names documented in each command's --help output.
1370    //
1371    // PROJECT_DUMP uses a bespoke two-mode literal rather than a join, so it is
1372    // tested separately against both component consts.
1373
1374    /// Extract the column list from an after_help string of the form
1375    /// "Columns (--columns): col1, col2, ..."  or the dump variant
1376    /// "Columns (--columns): path (with --delete: deleted)".
1377    ///
1378    /// Returns everything after the ": " that follows "Columns (--columns)".
1379    fn extract_columns_part(after_help: &str) -> &str {
1380        after_help
1381            .strip_prefix("Columns (--columns): ")
1382            .expect("after_help must start with 'Columns (--columns): '")
1383    }
1384
1385    #[test]
1386    fn after_help_matches_consts() {
1387        // Each pair: (after_help_const, column_const_as_joined_string).
1388        // Uses a Vec so a new pair is one line.
1389        let cases: &[(&str, &[&str])] = &[
1390            (AFTER_HELP_PROJECT_LIST, PROJECTS_COLUMNS),
1391            (AFTER_HELP_PROJECT_DESCRIBE, PROJECTS_COLUMNS),
1392            (AFTER_HELP_DATA_MODEL_LIST, DATA_MODELS_COLUMNS),
1393            (AFTER_HELP_DATA_MODEL_DESCRIBE, DATA_MODEL_DESCRIBE_COLUMNS),
1394            (AFTER_HELP_DATA_MODEL_STRUCTURE, DATA_MODEL_STRUCTURE_COLUMNS),
1395            (AFTER_HELP_RESOURCE_TYPE_LIST, RESOURCE_TYPES_COLUMNS),
1396            (AFTER_HELP_RESOURCE_TYPE_DESCRIBE, RESOURCE_TYPE_DESCRIBE_COLUMNS),
1397            (AFTER_HELP_AUTH_LOGIN, AUTH_LOGIN_COLUMNS),
1398            (AFTER_HELP_AUTH_STATUS, AUTH_LOGIN_COLUMNS),
1399            (AFTER_HELP_AUTH_LOGOUT, AUTH_LOGOUT_COLUMNS),
1400            (AFTER_HELP_AUTH_SET_TOKEN, AUTH_LOGIN_COLUMNS),
1401        ];
1402
1403        for (help_str, const_cols) in cases {
1404            let extracted = extract_columns_part(help_str);
1405            let expected = const_cols.join(", ");
1406            assert_eq!(
1407                extracted, expected,
1408                "after_help drift for \"{help_str}\": \
1409                 help says \"{extracted}\" but const says \"{expected}\""
1410            );
1411        }
1412
1413        // PROJECT_DUMP is bespoke (two-mode literal) — check it structurally.
1414        let dump_extracted = extract_columns_part(AFTER_HELP_PROJECT_DUMP);
1415        assert!(
1416            dump_extracted.starts_with(PROJECT_DUMP_COLUMNS[0]),
1417            "AFTER_HELP_PROJECT_DUMP must start with PROJECT_DUMP_COLUMNS[0] (\"path\"); \
1418             got \"{dump_extracted}\""
1419        );
1420        assert!(
1421            dump_extracted.contains(PROJECT_DUMP_DELETED_COLUMNS[0]),
1422            "AFTER_HELP_PROJECT_DUMP must contain PROJECT_DUMP_DELETED_COLUMNS[0] (\"deleted\"); \
1423             got \"{dump_extracted}\""
1424        );
1425
1426        // RESOURCE_LIST has a multi-line after_help (columns line + scan-behaviour
1427        // summary). Check that the first line exactly matches the column const.
1428        let rl_extracted = extract_columns_part(AFTER_HELP_RESOURCE_LIST);
1429        let rl_first_line = rl_extracted
1430            .split('\n')
1431            .next()
1432            .expect("AFTER_HELP_RESOURCE_LIST must have at least one line");
1433        let rl_expected = RESOURCE_LIST_COLUMNS.join(", ");
1434        assert_eq!(
1435            rl_first_line, rl_expected,
1436            "AFTER_HELP_RESOURCE_LIST columns line must match RESOURCE_LIST_COLUMNS; \
1437             got \"{rl_first_line}\" but expected \"{rl_expected}\""
1438        );
1439
1440        // RESOURCE_DESCRIBE also has a multi-line after_help (columns + "See also").
1441        // Check that the first line exactly matches the column const.
1442        let rd_extracted = extract_columns_part(AFTER_HELP_RESOURCE_DESCRIBE);
1443        let rd_first_line = rd_extracted
1444            .split('\n')
1445            .next()
1446            .expect("AFTER_HELP_RESOURCE_DESCRIBE must have at least one line");
1447        let rd_expected = RESOURCE_DESCRIBE_COLUMNS.join(", ");
1448        assert_eq!(
1449            rd_first_line, rd_expected,
1450            "AFTER_HELP_RESOURCE_DESCRIBE columns line must match RESOURCE_DESCRIBE_COLUMNS; \
1451             got \"{rd_first_line}\" but expected \"{rd_expected}\""
1452        );
1453
1454        // RESOURCE_DESCRIBE also documents the --values column set (full + lean
1455        // default). Locate each sibling line by its distinct prefix and
1456        // exact-match the remainder — these prefixes don't collide with the
1457        // "Columns (--columns): " check above (that one anchors on the first
1458        // line of the whole string).
1459        let rd_values_prefix = "Columns (--columns) with --values: ";
1460        let rd_values_line = AFTER_HELP_RESOURCE_DESCRIBE
1461            .lines()
1462            .find(|l| l.starts_with(rd_values_prefix))
1463            .expect("AFTER_HELP_RESOURCE_DESCRIBE must have a --values columns line");
1464        let rd_values_extracted = rd_values_line
1465            .strip_prefix(rd_values_prefix)
1466            .expect("prefix already matched by find()");
1467        let rd_values_expected = RESOURCE_DESCRIBE_VALUES_COLUMNS.join(", ");
1468        assert_eq!(
1469            rd_values_extracted, rd_values_expected,
1470            "AFTER_HELP_RESOURCE_DESCRIBE --values columns line must match \
1471             RESOURCE_DESCRIBE_VALUES_COLUMNS; got \"{rd_values_extracted}\" but expected \
1472             \"{rd_values_expected}\""
1473        );
1474
1475        let rd_values_default_prefix = "Default columns with --values: ";
1476        let rd_values_default_line = AFTER_HELP_RESOURCE_DESCRIBE
1477            .lines()
1478            .find(|l| l.starts_with(rd_values_default_prefix))
1479            .expect("AFTER_HELP_RESOURCE_DESCRIBE must have a default --values columns line");
1480        let rd_values_default_extracted = rd_values_default_line
1481            .strip_prefix(rd_values_default_prefix)
1482            .expect("prefix already matched by find()");
1483        let rd_values_default_expected = RESOURCE_DESCRIBE_VALUES_DEFAULT_COLUMNS.join(", ");
1484        assert_eq!(
1485            rd_values_default_extracted, rd_values_default_expected,
1486            "AFTER_HELP_RESOURCE_DESCRIBE default --values columns line must match \
1487             RESOURCE_DESCRIBE_VALUES_DEFAULT_COLUMNS; got \"{rd_values_default_extracted}\" \
1488             but expected \"{rd_values_default_expected}\""
1489        );
1490
1491        // VOCABULARY_LIST also has a multi-line after_help (columns line plus
1492        // --filter/--count disclosure notes). Check that the first line exactly
1493        // matches the column const.
1494        let vl_extracted = extract_columns_part(AFTER_HELP_VOCABULARY_LIST);
1495        let vl_first_line = vl_extracted
1496            .split('\n')
1497            .next()
1498            .expect("AFTER_HELP_VOCABULARY_LIST must have at least one line");
1499        let vl_expected = VOCABULARIES_COLUMNS.join(", ");
1500        assert_eq!(
1501            vl_first_line, vl_expected,
1502            "AFTER_HELP_VOCABULARY_LIST columns line must match VOCABULARIES_COLUMNS; \
1503             got \"{vl_first_line}\" but expected \"{vl_expected}\""
1504        );
1505
1506        // VOCABULARY_DESCRIBE also has a multi-line after_help (columns line plus
1507        // several explanatory paragraphs). Check that the first line exactly
1508        // matches the column const.
1509        let vd_extracted = extract_columns_part(AFTER_HELP_VOCABULARY_DESCRIBE);
1510        let vd_first_line = vd_extracted
1511            .split('\n')
1512            .next()
1513            .expect("AFTER_HELP_VOCABULARY_DESCRIBE must have at least one line");
1514        let vd_expected = VOCABULARY_DESCRIBE_COLUMNS.join(", ");
1515        assert_eq!(
1516            vd_first_line, vd_expected,
1517            "AFTER_HELP_VOCABULARY_DESCRIBE columns line must match VOCABULARY_DESCRIBE_COLUMNS; \
1518             got \"{vd_first_line}\" but expected \"{vd_expected}\""
1519        );
1520    }
1521
1522    // ── Cli::output_format ────────────────────────────────────────────────────
1523
1524    #[test]
1525    fn output_format_vre_project_list_prose_default() {
1526        let cli = Cli::try_parse_from(["dsp", "vre", "project", "list"]).unwrap();
1527        assert_eq!(cli.output_format(), Some(Format::Prose));
1528    }
1529
1530    #[test]
1531    fn output_format_vre_project_list_json_flag() {
1532        let cli = Cli::try_parse_from(["dsp", "vre", "project", "list", "-j"]).unwrap();
1533        assert_eq!(cli.output_format(), Some(Format::Json));
1534    }
1535
1536    #[test]
1537    fn output_format_vre_project_list_lines_flag() {
1538        let cli = Cli::try_parse_from(["dsp", "vre", "project", "list", "-l"]).unwrap();
1539        assert_eq!(cli.output_format(), Some(Format::Lines));
1540    }
1541
1542    #[test]
1543    fn output_format_docs_topic_is_prose() {
1544        let cli = Cli::try_parse_from(["dsp", "docs", "concepts"]).unwrap();
1545        assert_eq!(cli.output_format(), Some(Format::Prose));
1546    }
1547
1548    #[test]
1549    fn output_format_docs_json_flag() {
1550        let cli = Cli::try_parse_from(["dsp", "docs", "-j"]).unwrap();
1551        assert_eq!(cli.output_format(), Some(Format::Json));
1552    }
1553
1554    #[test]
1555    fn output_format_auth_token_is_none() {
1556        let cli = Cli::try_parse_from(["dsp", "auth", "token", "-s", "dev"]).unwrap();
1557        assert_eq!(cli.output_format(), None);
1558    }
1559
1560    // ── Cli::server_flag ──────────────────────────────────────────────────────
1561
1562    #[test]
1563    fn server_flag_with_flag_supplied() {
1564        // An explicit --server always wins over any ambient DSP_SERVER env var
1565        // (clap precedence: explicit CLI arg > env), so no env guarding needed here.
1566        let cli = Cli::try_parse_from(["dsp", "vre", "project", "list", "--server", "dev"]).unwrap();
1567        assert_eq!(cli.server_flag(), Some("dev"));
1568    }
1569
1570    #[test]
1571    fn server_flag_none_when_unset() {
1572        // Every leaf Args struct's `server` field carries `#[arg(env =
1573        // "DSP_SERVER")]`, so there is no way to exercise the "genuinely
1574        // unset" arm via `Cli::try_parse_from` without either mutating the
1575        // real process environment (unsound here: `cargo test` runs unit
1576        // tests in parallel, so one thread clearing `DSP_SERVER` while
1577        // another thread's `try_parse_from` reads it is a data race — which
1578        // is exactly why `std::env::remove_var` requires `unsafe` in current
1579        // Rust) or serializing this test against every other test that
1580        // touches `DSP_SERVER` (no such crate/pattern — e.g. `serial_test` —
1581        // exists in this project, and one isn't worth adding for a single
1582        // test).
1583        //
1584        // Instead, construct the `Cli` value directly, bypassing clap
1585        // parsing (and thus the env read) entirely. This still exercises the
1586        // real match-arm + `.as_deref()` logic in `server_flag()` — it just
1587        // reaches the `None` field value by construction instead of by
1588        // parsing absent input, so it is deterministic with zero env risk.
1589        let cli = Cli {
1590            verbose: 0,
1591            allow_insecure_server: false,
1592            command: TopLevel::Vre {
1593                cmd: VreCmd::Project {
1594                    cmd: ProjectCmd::List(ProjectListArgs {
1595                        server: None,
1596                        filter: None,
1597                        format: fmt_args(Format::Prose, false, false, None, false, false),
1598                    }),
1599                },
1600            },
1601        };
1602        assert_eq!(cli.server_flag(), None);
1603    }
1604
1605    #[test]
1606    fn server_flag_docs_is_none() {
1607        let cli = Cli::try_parse_from(["dsp", "docs", "concepts"]).unwrap();
1608        assert_eq!(cli.server_flag(), None);
1609    }
1610}