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