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