@@ -98,6 +98,8 @@
#[arg(long)]
organization: Option<String>,
},
/// Manage which repositories a standard applies to (STD-* only)
Applicability(ApplicabilityArgs),
/// Show the traceability matrix
Matrix {
/// Repository (org/repo)
@@ -118,6 +120,46 @@
}
#[derive(Args)]
pub struct ApplicabilityArgs {
#[command(subcommand)]
pub command: ApplicabilityCommand,
}
#[derive(Subcommand)]
pub enum ApplicabilityCommand {
/// List repositories opted into a standard
List {
/// Standard ID (STD-…)
id: String,
/// Organization slug
#[arg(long)]
organization: String,
},
/// Opt a repository into a standard (idempotent)
Add {
/// Standard ID (STD-…)
id: String,
/// Organization slug
#[arg(long)]
organization: String,
/// Repository slug within the organization
#[arg(long)]
repo: String,
},
/// Opt a repository out of a standard (idempotent)
Remove {
/// Standard ID (STD-…)
id: String,
/// Organization slug
#[arg(long)]
organization: String,
/// Repository slug within the organization
#[arg(long)]
repo: String,
},
}
#[derive(Args)]
pub struct StatusArgs {
/// Repository (org/repo)
#[arg(long)]
@@ -309,6 +351,7 @@
repo,
organization,
} => delete(&id, repo.as_deref(), organization.as_deref()).await,
RequirementCommand::Applicability(args) => applicability(args).await,
RequirementCommand::Matrix { repo } => matrix(repo.as_deref()).await,
RequirementCommand::Status(args) => status(args).await,
RequirementCommand::Seed { repo, clear } => seed(repo.as_deref(), clear).await,
@@ -608,6 +651,107 @@
)
}
async fn applicability(args: ApplicabilityArgs) -> Result<(), Box<dyn std::error::Error>> {
match args.command {
ApplicabilityCommand::List { id, organization } => {
require_standard_id(&id)?;
applicability_list(&id, &organization).await
}
ApplicabilityCommand::Add {
id,
organization,
repo,
} => {
require_standard_id(&id)?;
applicability_add(&id, &organization, &repo).await
}
ApplicabilityCommand::Remove {
id,
organization,
repo,
} => {
require_standard_id(&id)?;
applicability_remove(&id, &organization, &repo).await
}
}
}
fn require_standard_id(id: &str) -> Result<(), Box<dyn std::error::Error>> {
match infer_kind(id) {
Kind::Standard => Ok(()),
Kind::Requirement => {
Err(format!("applicability is standards-only — '{id}' is a REQ-* requirement").into())
}
Kind::Unknown => Err(unknown_prefix_error(id).into()),
}
}
async fn applicability_list(
std_id: &str,
organization: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let resp: serde_json::Value = client
.get(&format!(
"/{organization}/standards/{std_id}/applicabilities"
))
.await?;
let repos = resp
.get("repositories")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
output::header(&format!("Repos opted into '{std_id}' ({organization})"));
let rows: Vec<Vec<String>> = repos
.iter()
.map(|r| {
vec![
r["slug"].as_str().unwrap_or("").to_string(),
r["name"].as_str().unwrap_or("").to_string(),
r["visibility"].as_str().unwrap_or("").to_string(),
]
})
.collect();
output::print_table(&["SLUG", "NAME", "VISIBILITY"], &rows);
println!("\n{} repositories", rows.len());
Ok(())
}
async fn applicability_add(
std_id: &str,
organization: &str,
repo: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let _: serde_json::Value = client
.put(
&format!("/{organization}/standards/{std_id}/applicabilities/{repo}"),
&serde_json::json!({}),
)
.await?;
output::success(&format!(
"Opted '{organization}/{repo}' into standard '{std_id}'"
));
Ok(())
}
async fn applicability_remove(
std_id: &str,
organization: &str,
repo: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
client
.delete_empty(&format!(
"/{organization}/standards/{std_id}/applicabilities/{repo}"
))
.await?;
output::success(&format!(
"Opted '{organization}/{repo}' out of standard '{std_id}'"
));
Ok(())
}
struct CreateInputs<'a> {
requirement_id: &'a str,
title: &'a str,
@@ -1657,6 +1801,50 @@
// resolves a default repo when present, which makes the negative case
// unreliable without mocking the config layer. The org-missing test above
// is sufficient to prove the pre-validation pattern works.
#[test]
fn require_standard_id_rejects_req() {
let err = require_standard_id("REQ-A-1").unwrap_err().to_string();
assert!(err.contains("standards-only"), "got: {err}");
}
#[test]
fn require_standard_id_rejects_unknown() {
let err = require_standard_id("XYZ-1").unwrap_err().to_string();
assert!(err.contains("REQ-"), "got: {err}");
assert!(err.contains("STD-"), "got: {err}");
}
#[test]
fn require_standard_id_accepts_std() {
assert!(require_standard_id("STD-A-1").is_ok());
}
#[tokio::test]
async fn applicability_list_rejects_req_id() {
let args = ApplicabilityArgs {
command: ApplicabilityCommand::List {
id: "REQ-A-1".into(),
organization: "fangorn".into(),
},
};
let err = applicability(args).await.unwrap_err().to_string();
assert!(err.contains("standards-only"), "got: {err}");
}
#[tokio::test]
async fn applicability_add_rejects_unknown_prefix() {
let args = ApplicabilityArgs {
command: ApplicabilityCommand::Add {
id: "XYZ-1".into(),
organization: "fangorn".into(),
repo: "myapp".into(),
},
};
let err = applicability(args).await.unwrap_err().to_string();
assert!(err.contains("REQ-"), "got: {err}");
assert!(err.contains("STD-"), "got: {err}");
}
#[tokio::test]
async fn list_rejects_unknown_kind() {