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}