use clap::{Args, Parser, Subcommand};
use crate::diagnostic::Diagnostic;
use crate::render::{Format, HeaderMode, TableOptions};
const AFTER_HELP_PROJECT_LIST: &str =
"Columns (--columns): shortcode, shortname, longname, status, data_models, iri";
const AFTER_HELP_PROJECT_DESCRIBE: &str =
"Columns (--columns): shortcode, shortname, longname, status, data_models, iri";
const AFTER_HELP_PROJECT_DUMP: &str = "Columns (--columns): path (--delete mode: deleted)";
const AFTER_HELP_DATA_MODEL_LIST: &str =
"Columns (--columns): name, iri, label, last_modified, is_builtin";
const AFTER_HELP_DATA_MODEL_DESCRIBE: &str =
"Columns (--columns): name, iri, label, last_modified, resource_types";
const AFTER_HELP_DATA_MODEL_STRUCTURE: &str =
"Columns (--columns): source, target, kind, field, target_data_model";
const AFTER_HELP_RESOURCE_TYPE_LIST: &str = "Columns (--columns): name, iri, label, is_builtin";
const AFTER_HELP_RESOURCE_TYPE_DESCRIBE: &str = "Columns (--columns): name, iri, value_type, link_target, cardinality, label, is_builtin, data_model";
const AFTER_HELP_RESOURCE_LIST: &str = "Columns (--columns): label, iri, ark_url, creation_date, last_modified, resource_type\n\n\
Scan behaviour: a bare --resource-type name (no ://) scans all project data-models; \
use --data-model or a full IRI to skip the scan. See also: `dsp docs concepts`\n\n\
--order-by: field name (e.g. title) or full field IRI; sorts ascending; \
targets project-defined fields (full IRI passed verbatim; bare name resolved to its field IRI)";
const AFTER_HELP_RESOURCE_DESCRIBE: &str = "Columns (--columns): label, iri, resource_type, ark_url, creation_date, last_modified, attached_project, owner, visibility, your_access\n\
Columns (--columns) with --values: label, iri, field, field_label, value_type, value, comment\n\
Default columns with --values: field, field_label, value_type, value\n\n\
See also: `dsp docs concepts`";
const AFTER_HELP_AUTH_LOGIN: &str = "Columns (--columns): server, user, expires_at, state";
const AFTER_HELP_AUTH_STATUS: &str = "Columns (--columns): server, user, expires_at, state";
const AFTER_HELP_AUTH_LOGOUT: &str = "Columns (--columns): server, was_cached";
const AFTER_HELP_AUTH_SET_TOKEN: &str = "Columns (--columns): server, user, expires_at, state";
#[derive(Debug, Args)]
pub struct FormatArgs {
#[arg(
long,
value_enum,
default_value_t = Format::Prose,
value_name = "FORMAT"
)]
pub format: Format,
#[arg(short = 'j', long = "json")]
pub json: bool,
#[arg(short = 'l', long = "lines")]
pub lines: bool,
#[arg(long, value_name = "COLS")]
pub columns: Option<String>,
#[arg(long, conflicts_with = "header_only")]
pub no_header: bool,
#[arg(long)]
pub header_only: bool,
}
impl FormatArgs {
pub fn resolve(&self) -> Format {
if self.json {
Format::Json
} else if self.lines {
Format::Lines
} else {
self.format
}
}
pub fn table_options(&self, format: Format) -> Result<TableOptions, Diagnostic> {
if self.columns.is_some() && !matches!(format, Format::Csv | Format::Tsv | Format::Lines) {
return Err(Diagnostic::Usage(
"--columns works with csv, tsv, and lines output".to_string(),
));
}
if (self.no_header || self.header_only) && !matches!(format, Format::Csv | Format::Tsv) {
return Err(Diagnostic::Usage(
"--no-header and --header-only work with csv and tsv output only (lines has no header concept)"
.to_string(),
));
}
let columns = if let Some(ref raw) = self.columns {
if raw.is_empty() {
return Err(Diagnostic::Usage(
"--columns requires at least one column name".to_string(),
));
}
let parts: Vec<&str> = raw.split(',').collect();
for part in &parts {
if part.is_empty() {
return Err(Diagnostic::Usage(format!(
"--columns contains a blank segment in \"{raw}\"; \
use a comma-separated list with no empty entries"
)));
}
}
let mut seen = std::collections::HashSet::new();
for part in &parts {
if !seen.insert(*part) {
return Err(Diagnostic::Usage(format!(
"--columns contains duplicate column \"{part}\"; \
each column may appear at most once"
)));
}
}
Some(parts.iter().map(|s| s.to_string()).collect())
} else {
None
};
let header = if self.header_only {
HeaderMode::Only
} else if self.no_header {
HeaderMode::Off
} else {
HeaderMode::On
};
Ok(TableOptions { columns, header })
}
}
#[derive(Debug, Parser)]
#[command(
name = "dsp",
version,
about = "AI-agent-friendly CLI for the DaSCH Service Platform.",
long_about = "AI-agent-friendly CLI for the DaSCH Service Platform (DSP).\n\
Run `dsp docs` to list available documentation topics.",
max_term_width = 100
)]
pub struct Cli {
#[arg(short = 'v', long = "verbose", action = clap::ArgAction::Count, global = true)]
pub verbose: u8,
#[command(subcommand)]
pub command: TopLevel,
}
#[derive(Debug, Subcommand)]
pub enum TopLevel {
Auth {
#[command(subcommand)]
cmd: AuthCmd,
},
Vre {
#[command(subcommand)]
cmd: VreCmd,
},
Docs(DocsArgs),
}
#[derive(Debug, Subcommand)]
pub enum AuthCmd {
Login(LoginArgs),
Status(StatusArgs),
Logout(LogoutArgs),
#[command(name = "set-token")]
SetToken(SetTokenArgs),
Token(TokenArgs),
}
#[derive(Debug, Args)]
#[command(after_help = AFTER_HELP_AUTH_LOGIN)]
pub struct LoginArgs {
#[arg(short = 's', long, env = "DSP_SERVER")]
pub server: Option<String>,
#[arg(short = 'u', long, env = "DSP_USER")]
pub user: Option<String>,
#[command(flatten)]
pub format: FormatArgs,
}
#[derive(Debug, Args)]
#[command(after_help = AFTER_HELP_AUTH_STATUS)]
pub struct StatusArgs {
#[arg(short = 's', long, env = "DSP_SERVER")]
pub server: Option<String>,
#[command(flatten)]
pub format: FormatArgs,
}
#[derive(Debug, Args)]
#[command(after_help = AFTER_HELP_AUTH_LOGOUT)]
pub struct LogoutArgs {
#[arg(short = 's', long, env = "DSP_SERVER")]
pub server: Option<String>,
#[command(flatten)]
pub format: FormatArgs,
}
#[derive(Debug, Args)]
#[command(after_help = AFTER_HELP_AUTH_SET_TOKEN)]
pub struct SetTokenArgs {
#[arg(short = 's', long, env = "DSP_SERVER")]
pub server: Option<String>,
#[command(flatten)]
pub format: FormatArgs,
}
#[derive(Debug, Args)]
pub struct TokenArgs {
#[arg(short = 's', long, env = "DSP_SERVER")]
pub server: Option<String>,
}
#[derive(Debug, Subcommand)]
pub enum VreCmd {
Project {
#[command(subcommand)]
cmd: ProjectCmd,
},
#[command(name = "data-model")]
DataModel {
#[command(subcommand)]
cmd: DataModelCmd,
},
#[command(name = "resource-type")]
ResourceType {
#[command(subcommand)]
cmd: ResourceTypeCmd,
},
Resource {
#[command(subcommand)]
cmd: ResourceCmd,
},
}
#[derive(Debug, Subcommand)]
pub enum ProjectCmd {
List(ProjectListArgs),
Describe(ProjectDescribeArgs),
Dump(ProjectDumpArgs),
}
#[derive(Debug, Args)]
#[command(after_help = AFTER_HELP_PROJECT_LIST)]
pub struct ProjectListArgs {
#[arg(short = 's', long, env = "DSP_SERVER")]
pub server: Option<String>,
#[arg(long)]
pub filter: Option<String>,
#[command(flatten)]
pub format: FormatArgs,
}
#[derive(Debug, Args)]
#[command(after_help = AFTER_HELP_PROJECT_DESCRIBE)]
pub struct ProjectDescribeArgs {
#[arg(short = 's', long, env = "DSP_SERVER")]
pub server: Option<String>,
#[arg(short = 'p', long)]
pub project: Option<String>,
#[command(flatten)]
pub format: FormatArgs,
}
#[derive(Debug, Args)]
#[command(after_help = AFTER_HELP_PROJECT_DUMP)]
pub struct ProjectDumpArgs {
#[arg(short = 's', long, env = "DSP_SERVER")]
pub server: Option<String>,
#[arg(short = 'p', long)]
pub project: Option<String>,
#[arg(long)]
pub skip_assets: bool,
#[arg(short = 'o', long)]
pub output: Option<std::path::PathBuf>,
#[arg(long)]
pub force: bool,
#[arg(long)]
pub cleanup: bool,
#[arg(long, default_value_t = 3600, value_parser = clap::value_parser!(u64).range(1..))]
pub timeout: u64,
#[arg(long, conflicts_with = "delete")]
pub replace: bool,
#[arg(
long,
conflicts_with_all = ["replace", "output", "force", "skip_assets", "cleanup"]
)]
pub delete: bool,
#[arg(long, requires = "replace", conflicts_with = "delete")]
pub discard_other_project: bool,
#[command(flatten)]
pub format: FormatArgs,
}
#[derive(Debug, Subcommand)]
pub enum DataModelCmd {
List(DataModelListArgs),
Describe(DataModelDescribeArgs),
Structure(DataModelStructureArgs),
}
#[derive(Debug, Args)]
#[command(after_help = AFTER_HELP_DATA_MODEL_LIST)]
pub struct DataModelListArgs {
#[arg(short = 's', long, env = "DSP_SERVER")]
pub server: Option<String>,
#[arg(short = 'p', long)]
pub project: Option<String>,
#[arg(long)]
pub filter: Option<String>,
#[arg(long)]
pub include_builtins: bool,
#[command(flatten)]
pub format: FormatArgs,
}
#[derive(Debug, Args)]
#[command(after_help = AFTER_HELP_DATA_MODEL_DESCRIBE)]
pub struct DataModelDescribeArgs {
#[arg(short = 's', long, env = "DSP_SERVER")]
pub server: Option<String>,
#[arg(short = 'p', long)]
pub project: Option<String>,
#[arg(long = "data-model")]
pub data_model: Option<String>,
#[command(flatten)]
pub format: FormatArgs,
}
#[derive(Debug, Args)]
#[command(after_help = AFTER_HELP_DATA_MODEL_STRUCTURE)]
pub struct DataModelStructureArgs {
#[arg(short = 's', long, env = "DSP_SERVER")]
pub server: Option<String>,
#[arg(short = 'p', long)]
pub project: Option<String>,
#[arg(long = "data-model")]
pub data_model: Option<String>,
#[arg(long)]
pub include_builtins: bool,
#[command(flatten)]
pub format: FormatArgs,
}
#[derive(Debug, Subcommand)]
pub enum ResourceTypeCmd {
List(ResourceTypeListArgs),
Describe(ResourceTypeDescribeArgs),
}
#[derive(Debug, Args)]
#[command(after_help = AFTER_HELP_RESOURCE_TYPE_LIST)]
pub struct ResourceTypeListArgs {
#[arg(short = 's', long, env = "DSP_SERVER")]
pub server: Option<String>,
#[arg(short = 'p', long)]
pub project: Option<String>,
#[arg(long = "data-model")]
pub data_model: Option<String>,
#[arg(long)]
pub filter: Option<String>,
#[arg(long)]
pub include_builtins: bool,
#[command(flatten)]
pub format: FormatArgs,
}
#[derive(Debug, Args)]
#[command(after_help = AFTER_HELP_RESOURCE_TYPE_DESCRIBE)]
pub struct ResourceTypeDescribeArgs {
#[arg(short = 's', long, env = "DSP_SERVER")]
pub server: Option<String>,
#[arg(short = 'p', long)]
pub project: Option<String>,
#[arg(long = "data-model")]
pub data_model: Option<String>,
#[arg(long = "resource-type")]
pub resource_type: Option<String>,
#[arg(long)]
pub include_builtins: bool,
#[command(flatten)]
pub format: FormatArgs,
}
#[derive(Debug, Subcommand)]
pub enum ResourceCmd {
List(ResourceListArgs),
Describe(ResourceDescribeArgs),
}
#[derive(Debug, Args)]
#[command(after_help = AFTER_HELP_RESOURCE_LIST)]
pub struct ResourceListArgs {
#[arg(short = 's', long, env = "DSP_SERVER")]
pub server: Option<String>,
#[arg(short = 'p', long)]
pub project: Option<String>,
#[arg(long = "resource-type")]
pub resource_type: Option<String>,
#[arg(long = "data-model")]
pub data_model: Option<String>,
#[arg(long, value_parser = clap::value_parser!(u32), conflicts_with = "all")]
pub page: Option<u32>,
#[arg(long)]
pub all: bool,
#[arg(long)]
pub filter: Option<String>,
#[arg(long = "order-by")]
pub order_by: Option<String>,
#[command(flatten)]
pub format: FormatArgs,
}
#[derive(Debug, Args)]
#[command(after_help = AFTER_HELP_RESOURCE_DESCRIBE)]
pub struct ResourceDescribeArgs {
#[arg(short = 's', long, env = "DSP_SERVER")]
pub server: Option<String>,
#[arg(long)]
pub resource: Option<String>,
#[arg(short = 'p', long)]
pub project: Option<String>,
#[arg(long)]
pub values: bool,
#[command(flatten)]
pub format: FormatArgs,
}
#[derive(Debug, Args)]
pub struct DocsArgs {
pub topic: Option<String>,
#[arg(long, conflicts_with = "json")]
pub pager: bool,
#[arg(
short = 'j',
long = "json",
conflicts_with_all = ["topic", "pager"]
)]
pub json: bool,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::render::{
AUTH_LOGIN_COLUMNS, AUTH_LOGOUT_COLUMNS, DATA_MODEL_DESCRIBE_COLUMNS,
DATA_MODEL_STRUCTURE_COLUMNS, DATA_MODELS_COLUMNS, Format, HeaderMode,
PROJECT_DUMP_COLUMNS, PROJECT_DUMP_DELETED_COLUMNS, PROJECTS_COLUMNS,
RESOURCE_DESCRIBE_COLUMNS, RESOURCE_DESCRIBE_VALUES_COLUMNS,
RESOURCE_DESCRIBE_VALUES_DEFAULT_COLUMNS, RESOURCE_LIST_COLUMNS,
RESOURCE_TYPE_DESCRIBE_COLUMNS, RESOURCE_TYPES_COLUMNS,
};
use clap::CommandFactory;
fn fmt_args(
format: Format,
json: bool,
lines: bool,
columns: Option<&str>,
no_header: bool,
header_only: bool,
) -> FormatArgs {
FormatArgs {
format,
json,
lines,
columns: columns.map(|s| s.to_string()),
no_header,
header_only,
}
}
#[test]
fn columns_with_prose_default_rejected() {
let args = fmt_args(Format::Prose, false, false, Some("name"), false, false);
let result = args.table_options(Format::Prose);
assert!(
matches!(result, Err(Diagnostic::Usage(_))),
"expected Usage, got {result:?}"
);
}
#[test]
fn columns_with_json_rejected() {
let args = fmt_args(Format::Prose, true, false, Some("name"), false, false);
let resolved = args.resolve(); let result = args.table_options(resolved);
assert!(
matches!(result, Err(Diagnostic::Usage(_))),
"expected Usage, got {result:?}"
);
}
#[test]
fn columns_with_lines_accepted() {
let args = fmt_args(Format::Prose, false, true, Some("name"), false, false);
let resolved = args.resolve(); let result = args.table_options(resolved);
assert!(result.is_ok(), "expected Ok, got {result:?}");
let opts = result.unwrap();
assert_eq!(opts.columns, Some(vec!["name".to_string()]));
}
#[test]
fn columns_with_format_lines_flag_accepted() {
let args = fmt_args(Format::Lines, false, false, Some("iri"), false, false);
let resolved = args.resolve(); let result = args.table_options(resolved);
assert!(result.is_ok(), "expected Ok, got {result:?}");
let opts = result.unwrap();
assert_eq!(opts.columns, Some(vec!["iri".to_string()]));
}
#[test]
fn columns_with_csv_accepted() {
let args = fmt_args(
Format::Csv,
false,
false,
Some("shortcode,iri"),
false,
false,
);
let result = args.table_options(Format::Csv);
assert!(result.is_ok(), "expected Ok, got {result:?}");
let opts = result.unwrap();
assert_eq!(
opts.columns,
Some(vec!["shortcode".to_string(), "iri".to_string()])
);
}
#[test]
fn no_header_with_prose_rejected() {
let args = fmt_args(Format::Prose, false, false, None, true, false);
let resolved = args.resolve(); let result = args.table_options(resolved);
assert!(
matches!(result, Err(Diagnostic::Usage(_))),
"expected Usage for --no-header + prose, got {result:?}"
);
}
#[test]
fn header_only_with_prose_rejected() {
let args = fmt_args(Format::Prose, false, false, None, false, true);
let resolved = args.resolve(); let result = args.table_options(resolved);
assert!(
matches!(result, Err(Diagnostic::Usage(_))),
"expected Usage for --header-only + prose, got {result:?}"
);
}
#[test]
fn no_header_with_lines_rejected() {
let args = fmt_args(Format::Prose, false, true, None, true, false);
let resolved = args.resolve(); let result = args.table_options(resolved);
assert!(
matches!(result, Err(Diagnostic::Usage(_))),
"expected Usage for --no-header + lines, got {result:?}"
);
}
#[test]
fn header_only_with_json_rejected() {
let args = fmt_args(Format::Prose, true, false, None, false, true);
let resolved = args.resolve(); let result = args.table_options(resolved);
assert!(
matches!(result, Err(Diagnostic::Usage(_))),
"expected Usage for --header-only + json, got {result:?}"
);
}
#[test]
fn no_header_with_csv_accepted() {
let args = fmt_args(Format::Csv, false, false, None, true, false);
let opts = args.table_options(Format::Csv).unwrap();
assert_eq!(opts.header, HeaderMode::Off);
}
#[test]
fn header_only_with_tsv_accepted() {
let args = fmt_args(Format::Tsv, false, false, None, false, true);
let opts = args.table_options(Format::Tsv).unwrap();
assert_eq!(opts.header, HeaderMode::Only);
}
#[test]
fn default_gives_header_on() {
let args = fmt_args(Format::Csv, false, false, None, false, false);
let opts = args.table_options(Format::Csv).unwrap();
assert_eq!(opts.header, HeaderMode::On);
}
#[test]
fn empty_columns_value_rejected() {
let args = fmt_args(Format::Csv, false, false, Some(""), false, false);
let result = args.table_options(Format::Csv);
assert!(
matches!(result, Err(Diagnostic::Usage(_))),
"expected Usage for empty --columns, got {result:?}"
);
}
#[test]
fn blank_segment_rejected() {
let args = fmt_args(Format::Csv, false, false, Some("a,,b"), false, false);
let result = args.table_options(Format::Csv);
assert!(
matches!(result, Err(Diagnostic::Usage(_))),
"expected Usage for blank segment, got {result:?}"
);
}
#[test]
fn duplicate_rejected() {
let args = fmt_args(Format::Csv, false, false, Some("iri,iri"), false, false);
let result = args.table_options(Format::Csv);
assert!(
matches!(result, Err(Diagnostic::Usage(_))),
"expected Usage for duplicate column, got {result:?}"
);
}
#[test]
fn single_column_accepted() {
let args = fmt_args(Format::Lines, false, false, Some("iri"), false, false);
let opts = args.table_options(Format::Lines).unwrap();
assert_eq!(opts.columns, Some(vec!["iri".to_string()]));
}
#[test]
fn multiple_columns_select_and_reorder() {
let args = fmt_args(
Format::Csv,
false,
false,
Some("iri,shortcode,label"),
false,
false,
);
let opts = args.table_options(Format::Csv).unwrap();
assert_eq!(
opts.columns,
Some(vec![
"iri".to_string(),
"shortcode".to_string(),
"label".to_string()
])
);
}
#[test]
fn no_columns_gives_none() {
let args = fmt_args(Format::Csv, false, false, None, false, false);
let opts = args.table_options(Format::Csv).unwrap();
assert_eq!(opts.columns, None);
}
fn extract_columns_part(after_help: &str) -> &str {
after_help
.strip_prefix("Columns (--columns): ")
.expect("after_help must start with 'Columns (--columns): '")
}
#[test]
fn after_help_matches_consts() {
let cases: &[(&str, &[&str])] = &[
(AFTER_HELP_PROJECT_LIST, PROJECTS_COLUMNS),
(AFTER_HELP_PROJECT_DESCRIBE, PROJECTS_COLUMNS),
(AFTER_HELP_DATA_MODEL_LIST, DATA_MODELS_COLUMNS),
(AFTER_HELP_DATA_MODEL_DESCRIBE, DATA_MODEL_DESCRIBE_COLUMNS),
(
AFTER_HELP_DATA_MODEL_STRUCTURE,
DATA_MODEL_STRUCTURE_COLUMNS,
),
(AFTER_HELP_RESOURCE_TYPE_LIST, RESOURCE_TYPES_COLUMNS),
(
AFTER_HELP_RESOURCE_TYPE_DESCRIBE,
RESOURCE_TYPE_DESCRIBE_COLUMNS,
),
(AFTER_HELP_AUTH_LOGIN, AUTH_LOGIN_COLUMNS),
(AFTER_HELP_AUTH_STATUS, AUTH_LOGIN_COLUMNS),
(AFTER_HELP_AUTH_LOGOUT, AUTH_LOGOUT_COLUMNS),
(AFTER_HELP_AUTH_SET_TOKEN, AUTH_LOGIN_COLUMNS),
];
for (help_str, const_cols) in cases {
let extracted = extract_columns_part(help_str);
let expected = const_cols.join(", ");
assert_eq!(
extracted, expected,
"after_help drift for \"{help_str}\": \
help says \"{extracted}\" but const says \"{expected}\""
);
}
let dump_extracted = extract_columns_part(AFTER_HELP_PROJECT_DUMP);
assert!(
dump_extracted.starts_with(PROJECT_DUMP_COLUMNS[0]),
"AFTER_HELP_PROJECT_DUMP must start with PROJECT_DUMP_COLUMNS[0] (\"path\"); \
got \"{dump_extracted}\""
);
assert!(
dump_extracted.contains(PROJECT_DUMP_DELETED_COLUMNS[0]),
"AFTER_HELP_PROJECT_DUMP must contain PROJECT_DUMP_DELETED_COLUMNS[0] (\"deleted\"); \
got \"{dump_extracted}\""
);
let rl_extracted = extract_columns_part(AFTER_HELP_RESOURCE_LIST);
let rl_first_line = rl_extracted
.split('\n')
.next()
.expect("AFTER_HELP_RESOURCE_LIST must have at least one line");
let rl_expected = RESOURCE_LIST_COLUMNS.join(", ");
assert_eq!(
rl_first_line, rl_expected,
"AFTER_HELP_RESOURCE_LIST columns line must match RESOURCE_LIST_COLUMNS; \
got \"{rl_first_line}\" but expected \"{rl_expected}\""
);
let rd_extracted = extract_columns_part(AFTER_HELP_RESOURCE_DESCRIBE);
let rd_first_line = rd_extracted
.split('\n')
.next()
.expect("AFTER_HELP_RESOURCE_DESCRIBE must have at least one line");
let rd_expected = RESOURCE_DESCRIBE_COLUMNS.join(", ");
assert_eq!(
rd_first_line, rd_expected,
"AFTER_HELP_RESOURCE_DESCRIBE columns line must match RESOURCE_DESCRIBE_COLUMNS; \
got \"{rd_first_line}\" but expected \"{rd_expected}\""
);
let rd_values_prefix = "Columns (--columns) with --values: ";
let rd_values_line = AFTER_HELP_RESOURCE_DESCRIBE
.lines()
.find(|l| l.starts_with(rd_values_prefix))
.expect("AFTER_HELP_RESOURCE_DESCRIBE must have a --values columns line");
let rd_values_extracted = rd_values_line
.strip_prefix(rd_values_prefix)
.expect("prefix already matched by find()");
let rd_values_expected = RESOURCE_DESCRIBE_VALUES_COLUMNS.join(", ");
assert_eq!(
rd_values_extracted, rd_values_expected,
"AFTER_HELP_RESOURCE_DESCRIBE --values columns line must match \
RESOURCE_DESCRIBE_VALUES_COLUMNS; got \"{rd_values_extracted}\" but expected \
\"{rd_values_expected}\""
);
let rd_values_default_prefix = "Default columns with --values: ";
let rd_values_default_line = AFTER_HELP_RESOURCE_DESCRIBE
.lines()
.find(|l| l.starts_with(rd_values_default_prefix))
.expect("AFTER_HELP_RESOURCE_DESCRIBE must have a default --values columns line");
let rd_values_default_extracted = rd_values_default_line
.strip_prefix(rd_values_default_prefix)
.expect("prefix already matched by find()");
let rd_values_default_expected = RESOURCE_DESCRIBE_VALUES_DEFAULT_COLUMNS.join(", ");
assert_eq!(
rd_values_default_extracted, rd_values_default_expected,
"AFTER_HELP_RESOURCE_DESCRIBE default --values columns line must match \
RESOURCE_DESCRIBE_VALUES_DEFAULT_COLUMNS; got \"{rd_values_default_extracted}\" \
but expected \"{rd_values_default_expected}\""
);
}
fn collect_long_flags(cmd: &clap::Command, out: &mut std::collections::BTreeSet<String>) {
for arg in cmd.get_arguments() {
if let Some(long) = arg.get_long()
&& long != "help"
&& long != "version"
{
out.insert(long.to_string());
}
}
for sub in cmd.get_subcommands() {
collect_long_flags(sub, out);
}
}
#[test]
fn skill_md_documents_every_long_flag() {
const SKILL: &str = include_str!("../../skill/SKILL.md");
let cmd = Cli::command();
let mut flags = std::collections::BTreeSet::new();
collect_long_flags(&cmd, &mut flags);
let mut missing = Vec::new();
for flag in &flags {
let needle = format!("--{flag}");
let mut found = false;
for (start, _) in SKILL.match_indices(&needle) {
let after = start + needle.len();
let next_char = SKILL[after..].chars().next();
let boundary = match next_char {
None => true,
Some(c) => !(c.is_ascii_alphanumeric() || c == '-'),
};
if boundary {
found = true;
break;
}
}
if !found {
missing.push(needle);
}
}
assert!(
missing.is_empty(),
"SKILL.md is missing these clap long-flags: {missing:?} — document them in \
skill/SKILL.md or the CLI drifted from the skill"
);
}
}