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