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