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