Skip to main content

dsp_cli/cli/
mod.rs

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