From 77673c84256237b1641173ac5e20ea39c8e88f08 Mon Sep 17 00:00:00 2001 From: James Devine Date: Sun, 17 May 2026 13:41:26 +0100 Subject: [PATCH] feat(cli): add ado-aw run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements PR 6 of the Phase 1 CLI overhaul. Queues an ADO build for every definition that matches a local fixture. With `--wait`, polls each queued build until completion and exits with an aggregate exit code: 0 only if every queued build succeeded. CLI surface: ado-aw run [PATH] --org --project --pat --branch --parameters k=v[,k=v...] --wait --poll-interval --timeout --dry-run The module entry point is `run::dispatch`, not `run::run`, so call sites read `crate::run::dispatch(...)` cleanly. A doc comment in src/run.rs flags this so a later contributor doesn't "fix" the asymmetry. Implements the `queue_build` and `get_build` stubs in `src/ado/mod.rs` that PR 1 (#580) introduced. Decision logic is split into two pure functions exercised by 16 unit tests: - `parse_parameters` — accepts `--parameters a=1,b=2` and repeated occurrences; preserves equals signs inside values; rejects malformed pairs (missing `=`, empty key); skips blank entries. - `classify_build` — `(status, result)` → InProgress / Succeeded / Failed terminal-state classifier. Treats canceled, partiallySucceeded, missing `result`, and any other non-`succeeded` result as Failed when `status == completed`; anything else is InProgress. `--poll-interval` and `--timeout` are gated on `--wait` via clap `requires`, so the integration test catches the constraint at parse time. Transient poll-time HTTP errors keep the target on the pending list and retry on the next tick; the polling loop bails on the overall `--timeout`. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- AGENTS.md | 1 + docs/cli.md | 11 ++ src/ado/mod.rs | 100 ++++++++-- src/main.rs | 65 ++++++ src/run.rs | 415 +++++++++++++++++++++++++++++++++++++++ tests/run_integration.rs | 55 ++++++ 6 files changed, 635 insertions(+), 12 deletions(-) create mode 100644 src/run.rs create mode 100644 tests/run_integration.rs diff --git a/AGENTS.md b/AGENTS.md index 49561a50..fc25bb63 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -77,6 +77,7 @@ Every compiled pipeline runs as three sequential jobs: │ ├── mcp.rs # SafeOutputs MCP server (stdio + HTTP) │ ├── configure.rs # `configure` CLI command — orchestration shim atop `src/ado/` │ ├── enable.rs # `enable` CLI command — registers ADO build definitions for compiled pipelines and ensures they are enabled +│ ├── run.rs # `run` CLI command — queues builds for matched definitions, optional polling to completion (module entry is `dispatch`) │ ├── ado/ # Shared Azure DevOps REST helpers (auth, list/match/PATCH/POST) │ │ └── mod.rs # Used by `configure` and the lifecycle commands (enable, disable, remove, list, run, status, secrets) │ ├── detect.rs # Agentic pipeline detection (helper for `configure`) diff --git a/docs/cli.md b/docs/cli.md index e4144da3..5d126ec8 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -55,3 +55,14 @@ Global flags (apply to all subcommands): `--verbose, -v` (enable info-level logg - `--token ` - The token value for `--also-set-token`. Falls back to `$GITHUB_TOKEN`, then to an interactive prompt. Requires `--also-set-token`. **Source-repo scope (Phase 1):** `enable` requires the local git remote to be an Azure DevOps Git remote (the source repo is what gets registered as the definition's repository). GitHub-hosted source repos are gated on a follow-up. + +- `run [PATH]` - Queue an ADO build for every ADO definition that matches a local fixture (under `PATH`). With `--wait`, poll each queued build until completion and exit with an aggregate result — 0 only if every queued build succeeded. + - `--org ` - Override: Azure DevOps organization (URL or bare org name). Inferred from git remote by default. + - `--project ` - Override: Azure DevOps project name (inferred from git remote by default). + - `--pat ` / `AZURE_DEVOPS_EXT_PAT` env var - PAT for ADO API authentication (Azure CLI fallback if omitted). + - `--branch ` - Source branch to queue (e.g. `refs/heads/main`). Defaults to the definition's `defaultBranch`. + - `--parameters ` - ADO `templateParameters`. Repeatable and/or comma-separated. All values are strings (ADO coerces as the definition requires). Rejects malformed pairs (missing `=`). + - `--wait` - Poll each queued build to completion before exiting. + - `--poll-interval ` - Polling period when `--wait` is set (default 10). + - `--timeout ` - Hard cap on the polling loop when `--wait` is set (default 1800). + - `--dry-run` - Print the planned `templateParameters` body without calling the ADO API. diff --git a/src/ado/mod.rs b/src/ado/mod.rs index d7793ddb..c189c2d8 100644 --- a/src/ado/mod.rs +++ b/src/ado/mod.rs @@ -1038,26 +1038,102 @@ pub async fn create_definition( /// build's `id`. `branch` defaults to the definition's `defaultBranch` when /// `None`. `parameters` are passed through as ADO `templateParameters`. pub async fn queue_build( - _client: &reqwest::Client, - _ctx: &AdoContext, - _auth: &AdoAuth, - _definition_id: u64, - _branch: Option<&str>, - _parameters: &serde_json::Map, + client: &reqwest::Client, + ctx: &AdoContext, + auth: &AdoAuth, + definition_id: u64, + branch: Option<&str>, + parameters: &serde_json::Map, ) -> Result { - anyhow::bail!("not yet implemented: filled in by PR 6 (ado-aw run)") + let url = format!( + "{}/{}/_apis/build/builds?api-version=7.1", + ctx.org_url.trim_end_matches('/'), + ctx.project, + ); + + let mut body = serde_json::json!({ + "definition": { "id": definition_id } + }); + if let Some(b) = branch { + body["sourceBranch"] = serde_json::Value::String(b.to_string()); + } + if !parameters.is_empty() { + // ADO `templateParameters` is a string-keyed map of stringly- + // typed values; the caller has already coerced everything to + // strings via `parse_parameters`. + body["templateParameters"] = serde_json::Value::Object(parameters.clone()); + } + + debug!("POST queue build for definition {}: {}", definition_id, url); + + let resp = auth + .apply(client.post(&url)) + .header("Content-Type", "application/json") + .json(&body) + .send() + .await + .with_context(|| format!("Failed to queue build for definition {}", definition_id))?; + + let status = resp.status(); + if !status.is_success() { + let resp_body = resp.text().await.unwrap_or_default(); + anyhow::bail!( + "ADO API returned {} when queuing build for definition {}: {}", + status, + definition_id, + resp_body + ); + } + + let resp_body: serde_json::Value = resp + .json() + .await + .context("Failed to parse queue-build response")?; + + resp_body + .get("id") + .and_then(|v| v.as_u64()) + .context("queue_build response has no numeric 'id' field") } /// Fetch the full JSON body of a build. /// /// Calls `GET /_apis/build/builds/{id}?api-version=7.1`. pub async fn get_build( - _client: &reqwest::Client, - _ctx: &AdoContext, - _auth: &AdoAuth, - _build_id: u64, + client: &reqwest::Client, + ctx: &AdoContext, + auth: &AdoAuth, + build_id: u64, ) -> Result { - anyhow::bail!("not yet implemented: filled in by PR 6 (ado-aw run)") + let url = format!( + "{}/{}/_apis/build/builds/{}?api-version=7.1", + ctx.org_url.trim_end_matches('/'), + ctx.project, + build_id + ); + + debug!("GET build {}: {}", build_id, url); + + let resp = auth + .apply(client.get(&url)) + .send() + .await + .with_context(|| format!("Failed to fetch build {}", build_id))?; + + let status = resp.status(); + if !status.is_success() { + let body = resp.text().await.unwrap_or_default(); + anyhow::bail!( + "ADO API returned {} when fetching build {}: {}", + status, + build_id, + body + ); + } + + resp.json() + .await + .with_context(|| format!("Failed to parse build {} response", build_id)) } /// Fetch the most recent build for a definition. diff --git a/src/main.rs b/src/main.rs index 9d19f316..6fc4c28f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -14,6 +14,7 @@ mod init; mod logging; mod mcp; mod ndjson; +mod run; pub mod runtimes; pub mod sanitize; mod safeoutputs; @@ -174,6 +175,43 @@ enum Commands { #[arg(long, requires = "also_set_token")] token: Option, }, + /// Queue a build for every ADO definition that matches a local fixture (optionally wait for completion). + Run { + /// Path to the repository root (defaults to current directory). Used + /// to auto-discover compiled pipelines, same as `compile`. + path: Option, + /// Override: Azure DevOps organization (URL like `https://dev.azure.com/myorg`, + /// or just the org name `myorg`). Inferred from git remote by default. + #[arg(long)] + org: Option, + /// Override: Azure DevOps project name (inferred from git remote by default). + #[arg(long)] + project: Option, + /// PAT for ADO API authentication (prefer setting AZURE_DEVOPS_EXT_PAT env var; + /// Azure CLI fallback if omitted). + #[arg(long, env = "AZURE_DEVOPS_EXT_PAT")] + pat: Option, + /// Source branch to queue. Defaults to the definition's `defaultBranch`. + #[arg(long)] + branch: Option, + /// ADO `templateParameters` as `key=value` pairs. Repeatable and/or + /// comma-separated (`--parameters a=1,b=2 --parameters c=3`). + #[arg(long)] + parameters: Vec, + /// Poll each queued build to completion before exiting; aggregate result + /// determines the exit code. + #[arg(long)] + wait: bool, + /// Seconds between polls when `--wait` is set. + #[arg(long, default_value_t = 10, requires = "wait")] + poll_interval: u64, + /// Maximum seconds to wait when `--wait` is set. + #[arg(long, default_value_t = 1800, requires = "wait")] + timeout: u64, + /// Print the planned queue body without calling the ADO API. + #[arg(long)] + dry_run: bool, + }, } #[derive(Parser, Debug)] @@ -524,6 +562,7 @@ async fn main() -> Result<()> { Some(Commands::Init { .. }) => "init", Some(Commands::Configure { .. }) => "configure", Some(Commands::Enable { .. }) => "enable", + Some(Commands::Run { .. }) => "run", None => "ado-aw", }; @@ -656,6 +695,32 @@ async fn main() -> Result<()> { }) .await?; } + Commands::Run { + path, + org, + project, + pat, + branch, + parameters, + wait, + poll_interval, + timeout, + dry_run, + } => { + run::dispatch(run::RunOptions { + org: org.as_deref(), + project: project.as_deref(), + pat: pat.as_deref(), + path: path.as_deref(), + branch: branch.as_deref(), + parameters: ¶meters, + wait, + poll_interval_secs: poll_interval, + timeout_secs: timeout, + dry_run, + }) + .await?; + } } Ok(()) } diff --git a/src/run.rs b/src/run.rs new file mode 100644 index 00000000..2e798f8e --- /dev/null +++ b/src/run.rs @@ -0,0 +1,415 @@ +//! The `run` CLI command. +//! +//! Queues a build for every ADO definition that matches a local +//! fixture. With `--wait`, polls each queued build until completion +//! and exits with a status code that reflects the aggregate result. +//! Phase 1 of the pipeline-lifecycle CLI family — see `docs/cli.md`. +//! +//! Naming nit: the module-level entry point is `dispatch`, not `run`, +//! so call sites don't end up reading `run::run(...)`. Don't rename +//! it back to `run` — future contributors will find this comment if +//! they try. + +use anyhow::{Context, Result}; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +use crate::ado::{ + AdoAuth, AdoContext, MatchedDefinition, get_build, match_definitions, queue_build, + resolve_ado_context, resolve_auth, +}; +use crate::detect; + +/// Parse `--parameters foo=bar,baz=qux` (and its repeatable form) into +/// a JSON map. Pure function; reject malformed pairs. +pub fn parse_parameters(values: &[String]) -> Result> { + let mut out = serde_json::Map::new(); + for raw in values { + for pair in raw.split(',') { + let pair = pair.trim(); + if pair.is_empty() { + continue; + } + let Some((k, v)) = pair.split_once('=') else { + anyhow::bail!( + "Invalid --parameters pair '{}': expected key=value (no '=' found).", + pair + ); + }; + let key = k.trim(); + if key.is_empty() { + anyhow::bail!("Invalid --parameters pair '{}': empty key.", pair); + } + // All values are strings — ADO coerces template-parameter + // values as the pipeline definition requires. + out.insert(key.to_string(), serde_json::Value::String(v.trim().to_string())); + } + } + Ok(out) +} + +/// Build a `(definition_id, queued_build_id)` poll-target pair. +#[derive(Debug, Clone, Copy)] +struct PollTarget { + definition_id: u64, + build_id: u64, +} + +/// Pure decision: given an ADO build JSON body, what's the terminal +/// state from the operator's perspective? +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BuildOutcome { + /// `status` is anything but `completed`. Keep polling. + InProgress, + /// `status == "completed"` and `result == "succeeded"`. + Succeeded, + /// `status == "completed"` and `result` is anything else (failed, + /// canceled, partiallySucceeded). + Failed, +} + +/// Pure function: classify a build's terminal state from its JSON +/// body. Tested independently of any HTTP code. +pub fn classify_build(body: &serde_json::Value) -> BuildOutcome { + let status = body.get("status").and_then(|v| v.as_str()).unwrap_or(""); + if status != "completed" { + return BuildOutcome::InProgress; + } + let result = body.get("result").and_then(|v| v.as_str()).unwrap_or(""); + if result == "succeeded" { + BuildOutcome::Succeeded + } else { + BuildOutcome::Failed + } +} + +/// CLI options for [`dispatch`]. +pub struct RunOptions<'a> { + pub org: Option<&'a str>, + pub project: Option<&'a str>, + pub pat: Option<&'a str>, + pub path: Option<&'a Path>, + pub branch: Option<&'a str>, + /// Raw `--parameters` arguments (one entry per CLI occurrence). + pub parameters: &'a [String], + pub wait: bool, + pub poll_interval_secs: u64, + pub timeout_secs: u64, + pub dry_run: bool, +} + +/// Run the `run` command — kept as `dispatch` to avoid the awkward +/// `run::run(...)` call site that a plain `run` would produce. See the +/// module-level comment. +pub async fn dispatch(opts: RunOptions<'_>) -> Result<()> { + let parameters = parse_parameters(opts.parameters)?; + + let repo_path: PathBuf = match opts.path { + Some(p) => tokio::fs::canonicalize(p) + .await + .with_context(|| format!("Could not resolve path: {}", p.display()))?, + None => tokio::fs::canonicalize(".") + .await + .context("Could not resolve current directory")?, + }; + + let auth = resolve_auth(opts.pat).await?; + let ado_ctx = resolve_ado_context(&repo_path, opts.org, opts.project).await?; + + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(30)) + .build() + .context("Failed to create HTTP client")?; + + println!("Scanning for agentic pipelines..."); + let detected = detect::detect_pipelines(&repo_path).await?; + if detected.is_empty() { + println!("No agentic pipelines found."); + return Ok(()); + } + + let matched = match_definitions(&client, &ado_ctx, &auth, &detected).await?; + if matched.is_empty() { + anyhow::bail!( + "No ADO definitions matched any local fixture. Run `ado-aw list` to \ + diagnose." + ); + } + + println!("{} definition(s) to queue.", matched.len()); + println!(); + + if opts.dry_run { + for m in &matched { + print_queue_plan(m, opts.branch, ¶meters); + } + return Ok(()); + } + + let mut targets: Vec = Vec::new(); + let mut queue_failure = 0usize; + + for m in &matched { + match queue_build(&client, &ado_ctx, &auth, m.id, opts.branch, ¶meters).await { + Ok(build_id) => { + println!( + "▶ queued: {} (id={}) → build {} at {}/{}/_build/results?buildId={}", + m.name, + m.id, + build_id, + ado_ctx.org_url.trim_end_matches('/'), + ado_ctx.project, + build_id + ); + targets.push(PollTarget { + definition_id: m.id, + build_id, + }); + } + Err(e) => { + eprintln!("✗ failed to queue: {} (id={}): {:#}", m.name, m.id, e); + queue_failure += 1; + } + } + } + + if !opts.wait { + println!(); + println!( + "Queued {} build(s); {} failed to queue.", + targets.len(), + queue_failure + ); + if queue_failure > 0 { + anyhow::bail!("{} build(s) failed to queue", queue_failure); + } + return Ok(()); + } + + let poll_outcome = poll_until_complete( + &client, + &ado_ctx, + &auth, + &targets, + Duration::from_secs(opts.poll_interval_secs), + Duration::from_secs(opts.timeout_secs), + ) + .await?; + + println!(); + println!( + "Wait summary: {} succeeded, {} failed, {} still in progress (timeout), {} failed to queue.", + poll_outcome.succeeded, poll_outcome.failed, poll_outcome.in_progress, queue_failure, + ); + + let non_success = poll_outcome.failed + poll_outcome.in_progress + queue_failure; + if non_success > 0 { + anyhow::bail!("not all builds succeeded"); + } + Ok(()) +} + +fn print_queue_plan( + m: &MatchedDefinition, + branch: Option<&str>, + parameters: &serde_json::Map, +) { + let mut body = serde_json::json!({ + "definition": { "id": m.id } + }); + if let Some(b) = branch { + body["sourceBranch"] = serde_json::Value::String(b.to_string()); + } + if !parameters.is_empty() { + body["templateParameters"] = serde_json::Value::Object(parameters.clone()); + } + println!("[dry-run] ▶ would queue: {} (id={})", m.name, m.id); + println!("{}", serde_json::to_string_pretty(&body).unwrap_or_default()); +} + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +struct PollOutcome { + succeeded: usize, + failed: usize, + in_progress: usize, +} + +async fn poll_until_complete( + client: &reqwest::Client, + ctx: &AdoContext, + auth: &AdoAuth, + targets: &[PollTarget], + poll_interval: Duration, + timeout: Duration, +) -> Result { + let started = Instant::now(); + let mut outcome = PollOutcome::default(); + let mut pending: Vec = targets.to_vec(); + + println!(); + println!( + "Waiting for {} build(s) (poll every {}s, timeout {}s)...", + pending.len(), + poll_interval.as_secs(), + timeout.as_secs() + ); + + while !pending.is_empty() { + if started.elapsed() >= timeout { + println!("⚠ wait timed out after {}s", timeout.as_secs()); + outcome.in_progress = pending.len(); + return Ok(outcome); + } + + let mut next_pending = Vec::new(); + for t in &pending { + match get_build(client, ctx, auth, t.build_id).await { + Ok(body) => match classify_build(&body) { + BuildOutcome::InProgress => next_pending.push(*t), + BuildOutcome::Succeeded => { + println!("✓ build {} (definition {}) succeeded", t.build_id, t.definition_id); + outcome.succeeded += 1; + } + BuildOutcome::Failed => { + let result = body + .get("result") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + println!( + "✗ build {} (definition {}) finished with result={}", + t.build_id, t.definition_id, result + ); + outcome.failed += 1; + } + }, + Err(e) => { + eprintln!( + " warning: poll error for build {} (definition {}): {:#}", + t.build_id, t.definition_id, e + ); + // Treat transient poll errors as still-in-progress; + // we'll retry on the next tick. + next_pending.push(*t); + } + } + } + pending = next_pending; + + if !pending.is_empty() { + tokio::time::sleep(poll_interval).await; + } + } + + Ok(outcome) +} + +#[cfg(test)] +mod tests { + use super::*; + + // ============ parse_parameters ============ + + #[test] + fn parse_parameters_single_pair() { + let m = parse_parameters(&["foo=bar".to_string()]).unwrap(); + assert_eq!(m.get("foo").unwrap().as_str(), Some("bar")); + } + + #[test] + fn parse_parameters_comma_separated() { + let m = parse_parameters(&["foo=bar,baz=qux".to_string()]).unwrap(); + assert_eq!(m.get("foo").unwrap().as_str(), Some("bar")); + assert_eq!(m.get("baz").unwrap().as_str(), Some("qux")); + } + + #[test] + fn parse_parameters_repeated() { + let m = parse_parameters(&["a=1".to_string(), "b=2".to_string()]).unwrap(); + assert_eq!(m.get("a").unwrap().as_str(), Some("1")); + assert_eq!(m.get("b").unwrap().as_str(), Some("2")); + } + + #[test] + fn parse_parameters_repeated_comma_mix() { + let m = + parse_parameters(&["a=1,b=2".to_string(), "c=3".to_string()]).unwrap(); + assert_eq!(m.len(), 3); + } + + #[test] + fn parse_parameters_value_with_equals() { + // Split on first '=' only; subsequent equals are part of the value. + let m = parse_parameters(&["key=a=b=c".to_string()]).unwrap(); + assert_eq!(m.get("key").unwrap().as_str(), Some("a=b=c")); + } + + #[test] + fn parse_parameters_rejects_missing_equals() { + let err = parse_parameters(&["nope".to_string()]).unwrap_err(); + assert!(err.to_string().contains("no '='"), "got: {}", err); + } + + #[test] + fn parse_parameters_rejects_empty_key() { + let err = parse_parameters(&["=bar".to_string()]).unwrap_err(); + assert!(err.to_string().contains("empty key"), "got: {}", err); + } + + #[test] + fn parse_parameters_empty_input_returns_empty() { + let m = parse_parameters(&[]).unwrap(); + assert!(m.is_empty()); + } + + #[test] + fn parse_parameters_skips_blank_pairs() { + // Trailing/duplicate commas are forgiving. + let m = parse_parameters(&["foo=bar,,".to_string()]).unwrap(); + assert_eq!(m.len(), 1); + } + + // ============ classify_build ============ + + #[test] + fn classify_in_progress_when_status_not_completed() { + let body = serde_json::json!({ "status": "inProgress", "result": "succeeded" }); + assert_eq!(classify_build(&body), BuildOutcome::InProgress); + } + + #[test] + fn classify_in_progress_when_status_missing() { + let body = serde_json::json!({ "result": "succeeded" }); + assert_eq!(classify_build(&body), BuildOutcome::InProgress); + } + + #[test] + fn classify_succeeded_when_completed_and_succeeded() { + let body = serde_json::json!({ "status": "completed", "result": "succeeded" }); + assert_eq!(classify_build(&body), BuildOutcome::Succeeded); + } + + #[test] + fn classify_failed_when_completed_failed() { + let body = serde_json::json!({ "status": "completed", "result": "failed" }); + assert_eq!(classify_build(&body), BuildOutcome::Failed); + } + + #[test] + fn classify_failed_when_completed_canceled() { + let body = serde_json::json!({ "status": "completed", "result": "canceled" }); + assert_eq!(classify_build(&body), BuildOutcome::Failed); + } + + #[test] + fn classify_failed_when_completed_partial() { + let body = + serde_json::json!({ "status": "completed", "result": "partiallySucceeded" }); + assert_eq!(classify_build(&body), BuildOutcome::Failed); + } + + #[test] + fn classify_failed_when_completed_without_result() { + let body = serde_json::json!({ "status": "completed" }); + assert_eq!(classify_build(&body), BuildOutcome::Failed); + } +} diff --git a/tests/run_integration.rs b/tests/run_integration.rs new file mode 100644 index 00000000..f75b881d --- /dev/null +++ b/tests/run_integration.rs @@ -0,0 +1,55 @@ +//! Integration tests for `ado-aw run`. + +use std::path::PathBuf; + +fn binary() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_ado-aw")) +} + +#[test] +fn run_help_describes_command() { + let output = std::process::Command::new(binary()) + .args(["run", "--help"]) + .output() + .expect("Failed to run ado-aw run --help"); + assert!(output.status.success(), "--help should exit 0"); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("Queue a build"), + "Help text should describe the run command, got:\n{stdout}" + ); + for flag in [ + "--org", + "--project", + "--pat", + "--branch", + "--parameters", + "--wait", + "--poll-interval", + "--timeout", + "--dry-run", + ] { + assert!( + stdout.contains(flag), + "Expected --help to advertise {flag}, got:\n{stdout}" + ); + } +} + +#[test] +fn run_rejects_poll_interval_without_wait() { + // clap should reject `--poll-interval` (and `--timeout`) when `--wait` is absent. + let output = std::process::Command::new(binary()) + .args(["run", "--poll-interval", "5"]) + .output() + .expect("Failed to run ado-aw run"); + assert!( + !output.status.success(), + "Expected non-zero exit when --poll-interval used without --wait" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("--wait") || stderr.contains("wait"), + "stderr should reference the requires-constraint, got:\n{stderr}" + ); +}