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