diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9dae72e..67d365e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,6 +37,9 @@ jobs: - name: Doc Tests run: cargo test --doc && cargo test --doc --features pkce-auth + - name: Check module size limits + run: ./cli-engine/scripts/check-module-size.sh + # cli-engine depends on the new cli-engine-macros crate (path + version # dependency), which isn't on crates.io until its first real release # (see release.yml) — until then, the dry run can't resolve it, which diff --git a/AGENTS.md b/AGENTS.md index 00e5562..80920fa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,6 +11,12 @@ These instructions apply to the Rust `cli_engine` crate in this workspace. integration tests in `tests/`. - Do not add implementation code, docs, fixtures, or tests from unrelated implementations to this repository. +## Code File Structure + +- No hand-written `.rs` file exceeds 1000 lines. CI enforces this with `cli-engine/scripts/check-module-size.sh`. When a file grows past the limit, split it into a directory module (`foo.rs` becomes `foo/mod.rs` plus sibling files) grouped by cohesive purpose — not by mechanically chopping it into equal chunks. `cli-engine/src/cli/` (`builtins.rs`, `completion.rs`, `help.rs`, `tree_render.rs`) is an example of this pattern. +- Prefer functions that fit on one "screen" (~35 lines) as a rule of thumb. When a function's purpose can't be seen without scrolling, extract named helper steps. +- Keep comments succinct and useful to a reader with no memory of this coding session or its PR review thread: explain a non-obvious local decision (a hidden constraint, a workaround, a subtle invariant), not what the code does or why *this change* did it. + ## Design Direction - Preserve the cli-engine concepts: domain modules, noun-based groups, leaf commands, colon-separated command paths, middleware, authentication, authorization, output envelopes, schemas, guides, search, and transport helpers. @@ -29,7 +35,6 @@ These instructions apply to the Rust `cli_engine` crate in this workspace. - Keep public names idiomatic Rust: snake_case functions and fields, PascalCase types, clear module names. - Avoid clever abstractions unless they clearly reduce repeated command-author work. - Public APIs should have useful rustdoc comments. Explain behavior, errors, and invariants where they matter. -- Source comments should explain non-obvious local decisions. ## Creating A Consumer CLI @@ -278,6 +283,7 @@ cargo clippy --all-targets -- -D warnings RUSTDOCFLAGS='-D warnings' cargo doc --no-deps cargo test --all-targets cargo test --doc +./cli-engine/scripts/check-module-size.sh ``` Some human-output tests assume width 80 (non-TTY). On a wide interactive terminal they diff --git a/cli-engine/scripts/check-module-size.sh b/cli-engine/scripts/check-module-size.sh new file mode 100755 index 0000000..43b0a97 --- /dev/null +++ b/cli-engine/scripts/check-module-size.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# Fails if any hand-written .rs file exceeds the line-count limit — see +# AGENTS.md's "Code File Structure" section for the file-layout convention +# this enforces. +# +# Scope: cli-engine/src and cli-engine-macros/src — every workspace member's +# hand-written source. Deliberately excludes target/ (build output; nothing +# under it is committed source). +set -euo pipefail + +repo_root="$(cd "$(dirname "$0")/../.." && pwd)" +limit=1000 +violations=0 + +while IFS= read -r -d '' file; do + lines=$(wc -l < "$file") + if [ "$lines" -gt "$limit" ]; then + echo " $file: $lines lines (limit $limit)" + violations=$((violations + 1)) + fi +done < <(find "$repo_root/cli-engine/src" "$repo_root/cli-engine-macros/src" \ + -name '*.rs' -print0 2>/dev/null) + +if [ "$violations" -gt 0 ]; then + echo "ERROR: $violations file(s) over the ${limit}-line limit (shown above)." + echo "Split by concern into a directory module — see AGENTS.md." + exit 1 +fi + +echo "==> All .rs files are within the ${limit}-line limit" diff --git a/cli-engine/src/auth/pkce.rs b/cli-engine/src/auth/pkce.rs deleted file mode 100644 index 4ef7a46..0000000 --- a/cli-engine/src/auth/pkce.rs +++ /dev/null @@ -1,2073 +0,0 @@ -//! OAuth 2.0 PKCE authentication provider. -//! -//! Implements the browser-based Authorization Code + PKCE flow (RFC 7636). -//! Tokens are persisted through a pluggable [`CredentialStorage`] backend -//! (see [`crate::auth::storage`]) rather than a hard-wired keychain. By default -//! the backend is resolved from configuration — the `--credential-store` flag, -//! the `${PREFIX}_CREDENTIAL_STORE` env var, the engine config file, or the -//! `keyring` default — so an operator can disable the system keychain on -//! environments where it is unavailable (headless Linux, WSL) without code -//! changes. The three modes are: -//! -//! - `Keyring` (default): system keychain only. -//! - `Auto`: keychain with a transparent unencrypted-file fallback when the -//! keychain backend is unavailable. -//! - `File`: never contact the keychain; store unencrypted JSON under -//! `//credentials/-.json`, where -//! `` is `$XDG_CONFIG_HOME`, `$HOME/Library/Application -//! Support` (macOS), `$HOME/.config` (other Unix), or `%APPDATA%` (Windows). -//! -//! See [`CredentialStore`](crate::config::CredentialStore). A backend can also be -//! injected directly with -//! [`PkceAuthProvider::with_storage`](crate::auth::pkce::PkceAuthProvider::with_storage) -//! or forced with -//! [`PkceAuthProvider::with_credential_store`](crate::auth::pkce::PkceAuthProvider::with_credential_store). -//! -//! # Setup -//! -//! ```no_run -//! use std::sync::Arc; -//! use cli_engine::{CliConfig, auth::pkce::PkceAuthProvider}; -//! -//! let provider = Arc::new(PkceAuthProvider::new( -//! "my-provider", -//! "https://auth.example.com/oauth/authorize", -//! "https://auth.example.com/oauth/token", -//! "my-client-id", -//! &["openid", "profile"], -//! )); -//! -//! let config = CliConfig::new("mycli", "My CLI", "mycli") -//! .with_default_auth_provider("my-provider") -//! .with_auth_provider(provider); -//! ``` -//! -//! For per-environment OAuth config (different client id or endpoints per env), -//! wire the provider to a shared -//! [`Environments`](crate::environments::Environments) with -//! [`PkceAuthProvider::with_environments`](crate::auth::pkce::PkceAuthProvider::with_environments); -//! the resolved environment then drives the OAuth config for the active `env`. -//! A field the resolved environment leaves empty falls back to the base -//! config passed to -//! [`PkceAuthProvider::new`](crate::auth::pkce::PkceAuthProvider::new) — there -//! is no environment-variable override for OAuth fields. - -use std::{ - collections::{HashMap, HashSet}, - io::Write, - net::{SocketAddr, TcpListener}, - sync::Arc, - time::Duration, -}; - -use async_trait::async_trait; -use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; -use chrono::{SecondsFormat, Utc}; -use rand::Rng; -use serde::{Deserialize, Serialize}; -use serde_json::{Map, Value}; -use sha2::{Digest, Sha256}; -use tokio::sync::RwLock; -use zeroize::{Zeroize, ZeroizeOnDrop}; - -use crate::{ - Credential, Result, - auth::AuthProvider, - auth::CredentialRequest, - auth::storage::{CredentialKey, CredentialStorage, default_storage, storage_for}, - config::CredentialStore, - env_config::{EnvConfig, SourceChain, ValueSource}, - error::CliCoreError, -}; - -const REDIRECT_PORT_DEFAULT: u16 = 7443; -const TOKEN_EXPIRY_BUFFER_SECS: i64 = 30; -/// Default timeout applied to OAuth token-endpoint requests (exchange/refresh) -/// so a stalled token server cannot hang the CLI indefinitely. -const TOKEN_REQUEST_TIMEOUT_DEFAULT: Duration = Duration::from_secs(30); - -/// Stored token with expiry tracking. -/// -/// Token fields are zeroized on drop to limit in-memory exposure. -#[derive(Clone, Serialize, Deserialize, Zeroize, ZeroizeOnDrop)] -struct StoredToken { - access_token: String, - expires_at: i64, - refresh_token: Option, - /// Scopes the token was obtained with (granted by the authorization server, - /// or the requested set when the server does not echo `scope`). Lets scope - /// coverage work for opaque access tokens and IdPs that do not expose scopes - /// in the access token itself. Not secret, so excluded from zeroization. - /// - /// `#[serde(default)]` keeps tokens written before this field was added - /// loadable from the keychain (they decode with an empty set, falling back to - /// the JWT `scope`/`scp` claim as before). - #[serde(default)] - #[zeroize(skip)] - scopes: Vec, -} - -impl std::fmt::Debug for StoredToken { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("StoredToken") - .field("access_token", &"[redacted]") - .field("expires_at", &self.expires_at) - .field( - "refresh_token", - if self.refresh_token.is_some() { - &"Some([redacted])" - } else { - &"None" - }, - ) - .field("scopes", &self.scopes) - .finish() - } -} - -impl StoredToken { - fn is_valid(&self) -> bool { - let now = Utc::now().timestamp(); - self.expires_at - TOKEN_EXPIRY_BUFFER_SECS > now - } -} - -/// The effective OAuth values for an environment. No default on -/// `client_id`/`auth_url`/`token_url`: a provider whose base config and -/// resolved environment both leave one of these blank must fail loudly -/// (`EnvConfigError::MissingField`), not silently assemble with an empty -/// endpoint. `scopes` is different — an empty scope list is a normal, -/// supported configuration (see [`PkceAuthProvider::effective_scopes`]), not -/// a sign of a never-initialized provider. -#[derive(Debug, Clone, Default, EnvConfig)] -struct OAuthSection { - client_id: String, - auth_url: String, - token_url: String, - #[env_config(default = Vec::new())] - scopes: Vec, -} - -/// OAuth 2.0 PKCE authentication provider. -/// -/// Stores one token per `(env, provider)` pair in the system keychain. -/// The keychain service name is `//`. -#[derive(Debug)] -pub struct PkceAuthProvider { - name: String, - auth_url: String, - token_url: String, - client_id: String, - scopes: Vec, - /// Optional environment resolver; when set, per-env OAuth config comes from - /// the resolved environment instead of the base config passed to - /// [`PkceAuthProvider::new`]. Looked up by the `env` passed to - /// [`AuthProvider::get_credential`]. - environments: Option>, - redirect_port: u16, - redirect_uri: Option, - /// Timeout applied to token-endpoint requests (exchange and refresh). - token_timeout: Duration, - /// Shared HTTP client for token-endpoint traffic, built once and reused by - /// exchange and refresh so connections and TLS configuration are pooled - /// rather than rebuilt per request. The user-agent and timeout are applied - /// per request (not baked into the client) so they reflect the value - /// published at execution time, not at provider construction. - client: reqwest::Client, - app_id: String, - /// Explicit storage backend injected via [`PkceAuthProvider::with_storage`]. - /// Wins over `store_mode` and the config-driven default. - storage_override: Option>, - /// Explicit storage mode from [`PkceAuthProvider::with_credential_store`]. - /// Forces a built-in backend, bypassing flag/env/config resolution. - store_mode: Option, - /// Lazily-resolved storage backend. Built on first use so `--schema` / - /// `--dry-run` (which never resolve a credential) touch no keychain/config. - storage: tokio::sync::OnceCell>, - /// Prioritized JWT claim names used to derive `Credential.identity` from the - /// decoded access-token payload. First non-empty string claim wins. - identity_claims: Vec, - /// In-process token cache keyed by env. - cache: Arc>>, - /// Scope implication relationships from [`PkceAuthProvider::with_scope_hierarchy`]. - /// Empty by default, which preserves exact-string scope matching. - scope_hierarchy: ScopeHierarchy, -} - -/// Default prioritized claim names for deriving a human-readable identity. -const DEFAULT_IDENTITY_CLAIMS: &[&str] = - &["email", "preferred_username", "username", "name", "sub"]; - -impl PkceAuthProvider { - /// Creates a new PKCE provider. - /// - /// - `name`: Provider registration name (e.g. `"primary"`) - /// - `auth_url`: Authorization endpoint - /// - `token_url`: Token endpoint - /// - `client_id`: OAuth client ID - /// - `scopes`: Default OAuth scopes - #[must_use] - pub fn new( - name: impl Into, - auth_url: impl Into, - token_url: impl Into, - client_id: impl Into, - scopes: &[impl AsRef], - ) -> Self { - Self { - name: name.into(), - auth_url: auth_url.into(), - token_url: token_url.into(), - client_id: client_id.into(), - scopes: scopes.iter().map(|s| s.as_ref().to_owned()).collect(), - environments: None, - redirect_port: REDIRECT_PORT_DEFAULT, - redirect_uri: None, - token_timeout: TOKEN_REQUEST_TIMEOUT_DEFAULT, - client: reqwest::Client::new(), - app_id: String::new(), - storage_override: None, - store_mode: None, - storage: tokio::sync::OnceCell::new(), - identity_claims: DEFAULT_IDENTITY_CLAIMS - .iter() - .map(|claim| (*claim).to_owned()) - .collect(), - cache: Arc::new(RwLock::new(HashMap::new())), - scope_hierarchy: ScopeHierarchy::new(), - } - } - - /// Sources per-environment OAuth config from a shared - /// [`Environments`](crate::environments::Environments). - /// - /// Given an `env`, every OAuth-driven method on this provider resolves its - /// OAuth config from two tiers, highest priority first: the resolved - /// environment's own TOML value, then this provider's base configuration - /// from [`PkceAuthProvider::new`]. There is no environment-variable - /// override for either tier. Prefer wiring an - /// [`Environments`](crate::environments::Environments) over relying on - /// the base `client_id`/`auth_url`/`token_url` when the consumer registers - /// environments via - /// [`CliConfig::with_environments`](crate::CliConfig::with_environments) — - /// it's the single-source-of-truth path. - /// - /// A field absent from the resolved environment falls through to the - /// base config, so a partial environment can override only the client id - /// while inheriting the provider's base endpoints. - /// - /// # Examples - /// - /// ``` - /// use std::sync::Arc; - /// use cli_engine::{ - /// auth::pkce::PkceAuthProvider, - /// environments::{EnvTable, Environments}, - /// }; - /// - /// let environments = Arc::new( - /// Environments::new("prod").with_environment( - /// "dev", - /// EnvTable::new() - /// .with("client_id", "dev-client-id") - /// .with("auth_url", "https://api.dev-godaddy.com/v2/oauth2/authorize") - /// .with("token_url", "https://api.dev-godaddy.com/v2/oauth2/token"), - /// ), - /// ); - /// - /// let provider = PkceAuthProvider::new( - /// "godaddy", - /// "https://api.godaddy.com/v2/oauth2/authorize", - /// "https://api.godaddy.com/v2/oauth2/token", - /// "prod-client-id", - /// &["openid", "profile"], - /// ) - /// .with_environments(environments); - /// # let _ = provider; - /// ``` - #[must_use] - pub fn with_environments( - mut self, - environments: Arc, - ) -> Self { - self.environments = Some(environments); - self - } - - /// Sets the local redirect server port (default: 7443). - #[must_use] - pub fn with_redirect_port(mut self, port: u16) -> Self { - self.redirect_port = port; - self - } - - /// Sets the timeout applied to token-endpoint requests (authorization-code - /// exchange and refresh). - /// - /// Defaults to 30 seconds. This bounds only the HTTP token requests; the - /// interactive browser/callback wait has its own separate timeout. - #[must_use] - pub fn with_token_timeout(mut self, timeout: Duration) -> Self { - self.token_timeout = timeout; - self - } - - /// Overrides the redirect URI sent to the authorization server. - /// - /// By default the redirect URI is `http://127.0.0.1:{port}/callback`. Use - /// this when the OAuth client is allowlisted with a different URI, such as - /// `http://localhost:{port}/callback`. The local listener always binds to - /// `127.0.0.1` regardless of what is set here. - #[must_use] - pub fn with_redirect_uri(mut self, uri: impl Into) -> Self { - self.redirect_uri = Some(uri.into()); - self - } - - /// Sets the application id used as the keychain service prefix. - #[must_use] - pub fn with_app_id(mut self, app_id: impl Into) -> Self { - self.app_id = app_id.into(); - self - } - - /// Adds extra scopes beyond the default set. - #[must_use] - pub fn with_extra_scopes(mut self, scopes: &[impl AsRef]) -> Self { - self.scopes - .extend(scopes.iter().map(|s| s.as_ref().to_owned())); - self - } - - /// Injects a custom credential storage backend. - /// - /// Takes precedence over [`with_credential_store`](Self::with_credential_store) - /// and the config-driven default. Use this to plug in a bespoke - /// [`CredentialStorage`] (for example an in-memory store in tests, or a - /// remote secret manager). - #[must_use] - pub fn with_storage(mut self, storage: Arc) -> Self { - self.storage_override = Some(storage); - self - } - - /// Forces a built-in credential storage mode, bypassing the - /// flag/env/config resolution. - /// - /// Use [`CredentialStore::File`] to skip the system keychain entirely (the - /// escape hatch for headless Linux / WSL), [`CredentialStore::Auto`] for a - /// keychain-with-file-fallback, or [`CredentialStore::Keyring`] for - /// keychain-only. When unset, the mode is resolved per - /// [`crate::config::resolve_credential_store`]. - #[must_use] - pub fn with_credential_store(mut self, mode: CredentialStore) -> Self { - self.store_mode = Some(mode); - self - } - - /// Enables a file-based fallback when the system keychain is unavailable - /// (e.g. headless Linux / WSL without a running secret-service daemon). - /// - /// `true` maps to [`CredentialStore::Auto`] and `false` to - /// [`CredentialStore::Keyring`]. - #[must_use] - #[deprecated( - since = "0.3.0", - note = "use with_credential_store(CredentialStore::Auto) or (CredentialStore::Keyring)" - )] - pub fn with_file_fallback(self, enabled: bool) -> Self { - self.with_credential_store(if enabled { - CredentialStore::Auto - } else { - CredentialStore::Keyring - }) - } - - /// Overrides the prioritized JWT claim names used to derive - /// [`Credential::identity`](crate::Credential) from the decoded access-token - /// payload. - /// - /// The first claim whose value is a non-empty string wins. The default order - /// is `email`, `preferred_username`, `username`, `name`, `sub`. Use this when - /// the identity provider exposes the human identity under a non-standard - /// claim name. - #[must_use] - pub fn with_identity_claims(mut self, claims: &[impl AsRef]) -> Self { - self.identity_claims = claims.iter().map(|c| c.as_ref().to_owned()).collect(); - self - } - - /// Declares scope implication relationships (for example, a granted - /// `admin` scope covering a required `read` scope) so step-up only - /// re-authenticates when the current token genuinely lacks a required - /// scope. - /// - /// Empty by default, which preserves exact-string scope matching. - #[must_use] - pub fn with_scope_hierarchy(mut self, hierarchy: ScopeHierarchy) -> Self { - self.scope_hierarchy = hierarchy; - self - } - - /// Builds a [`Credential`] from a stored token, deriving `identity` and `sub` - /// from the access-token JWT claims when present. - fn build_credential(&self, env: &str, token: &StoredToken) -> Credential { - let claims = decode_jwt_claims(&token.access_token); - let identity = claims - .as_ref() - .map(|claims| extract_identity(claims, &self.identity_claims)) - .unwrap_or_default(); - let sub = claims - .as_ref() - .and_then(|claims| claims.get("sub")) - .and_then(Value::as_str) - .unwrap_or_default() - .to_owned(); - Credential { - token: token.access_token.clone(), - env: env.to_owned(), - provider: self.name.clone(), - expires_at: chrono::DateTime::from_timestamp(token.expires_at, 0) - .map(|dt| dt.to_rfc3339_opts(SecondsFormat::Secs, true)) - .unwrap_or_default(), - identity, - sub, - scopes: granted_scopes(token), - refreshable: token.refresh_token.is_some(), - ..Credential::default() - } - } - - /// Computes the effective OAuth config for `env` with a SINGLE environment - /// resolution (at most one `environments.toml` read), by assembling an - /// [`OAuthSection`] from a two-tier [`SourceChain`], highest priority - /// first: - /// - /// 1. The resolved environment's own TOML value (compiled + file layers). - /// 2. This provider's own base config, from [`PkceAuthProvider::new`]. - /// - /// Token flows call this once and reuse the result so they don't re-read the - /// environments file once per field. - /// - /// # Errors - /// - /// Returns an error if a present TOML value fails to convert to its - /// field's type (for example a non-array `scopes` value), or if - /// `client_id`/`auth_url`/`token_url` is blank in *both* tiers — a - /// provider whose base config was never given a real value, and whose - /// resolved environment (if any) doesn't supply one either, fails loudly - /// rather than assembling with an empty endpoint. - fn effective_oauth(&self, env: &str) -> Result { - let env_source = - self.environments - .as_ref() - .and_then(|environments| match environments.source(env) { - Ok(source) => Some(source), - Err(err) => { - tracing::debug!( - env, - error = %err, - "environment resolve failed; falling back to base OAuth config" - ); - None - } - }); - let base = ValueSource::new() - .with("client_id", self.client_id.clone()) - .with("auth_url", self.auth_url.clone()) - .with("token_url", self.token_url.clone()) - .with("scopes", self.scopes.clone()); - - let mut chain = SourceChain::new(); - if let Some(env_source) = &env_source { - chain = chain.push(env_source); - } - chain = chain.push(&base); - - OAuthSection::assemble(&chain).map_err(CliCoreError::from) - } - - /// Default scopes for `env`: the resolved environment's scopes when - /// non-empty, otherwise the provider's base scopes. - /// - /// # Errors - /// - /// See [`effective_oauth`](Self::effective_oauth). - fn effective_scopes(&self, env: &str) -> Result> { - Ok(self.effective_oauth(env)?.scopes) - } - - fn effective_redirect_uri(&self) -> String { - self.redirect_uri - .clone() - .unwrap_or_else(|| format!("http://127.0.0.1:{}/callback", self.redirect_port)) - } - - /// Parses the effective redirect URI and returns `(bind_port, callback_path)`. - fn parse_redirect_uri(&self) -> Result<(u16, String)> { - let uri_str = self.effective_redirect_uri(); - let parsed = url::Url::parse(&uri_str) - .map_err(|e| CliCoreError::message(format!("invalid redirect URI '{uri_str}': {e}")))?; - let port = parsed - .port() - .or_else(|| parsed.port_or_known_default()) - .ok_or_else(|| { - CliCoreError::message(format!("redirect URI '{uri_str}' has no port")) - })?; - let path = parsed.path().to_owned(); - Ok((port, path)) - } - - /// Builds the storage key for this provider and `env`. - fn credential_key<'key>(&'key self, env: &'key str) -> CredentialKey<'key> { - CredentialKey::new(&self.app_id, &self.name, env) - } - - /// Returns the credential storage backend, resolving and caching it on first - /// use. Precedence: an injected [`with_storage`](Self::with_storage) backend, - /// then a forced [`with_credential_store`](Self::with_credential_store) mode, - /// then the config-driven [`default_storage`]. - /// - /// Resolution is lazy so paths that never resolve a credential (`--schema`, - /// `--dry-run`) build no storage and touch neither the keychain nor config. - async fn storage(&self) -> &Arc { - self.storage - .get_or_init(async || { - if let Some(storage) = &self.storage_override { - storage.clone() - } else if let Some(mode) = self.store_mode { - storage_for(mode) - } else { - default_storage(&self.app_id) - } - }) - .await - } - - /// Loads and deserializes the stored token for `env`, if present. - /// - /// On a corrupt/undecodable blob, best-effort deletes it (self-heal) and - /// returns `None` so the caller re-authenticates rather than looping on the - /// bad entry. - async fn load_stored(&self, env: &str) -> Option { - let key = self.credential_key(env); - let raw = self.storage().await.load(&key).await?; - match serde_json::from_str::(&raw) { - Ok(token) => Some(token), - Err(e) => { - tracing::warn!(env, error = %e, "stored token JSON invalid; clearing"); - self.storage().await.delete(&key).await; - None - } - } - } - - /// Serializes and persists `token` for `env` via the storage backend. - async fn save_stored(&self, env: &str, token: &StoredToken) -> Result<()> { - let json = serde_json::to_string(token).map_err(CliCoreError::from)?; - let key = self.credential_key(env); - self.storage().await.save(&key, &json).await - } - - /// Removes any stored token for `env` via the storage backend. - async fn delete_stored(&self, env: &str) { - let key = self.credential_key(env); - self.storage().await.delete(&key).await; - } - - async fn cached_token(&self, env: &str) -> Option { - let cache = self.cache.read().await; - cache.get(env).filter(|t| t.is_valid()).cloned() - } - - async fn store_cached_token(&self, env: &str, token: StoredToken) { - let mut cache = self.cache.write().await; - cache.insert(env.to_owned(), token); - } - - async fn resolve_token(&self, env: &str) -> Result { - if let Some(token) = self.existing_token(env).await? { - return Ok(token); - } - let scopes = self.effective_scopes(env)?; - self.reauthenticate(env, &scopes).await - } - - /// Returns a usable token from the in-memory cache, keychain, or a refresh — - /// **without** launching an interactive PKCE flow. `None` means the caller - /// must authenticate. Keeping this flow-free lets `get_credential_for` decide - /// the scope set for a single login instead of authenticating twice. - async fn existing_token(&self, env: &str) -> Result> { - if let Some(token) = self.cached_token(env).await { - return Ok(Some(token)); - } - if let Some(token) = self.load_stored(env).await { - if token.is_valid() { - self.store_cached_token(env, token.clone()).await; - return Ok(Some(token)); - } - if let Some(refresh_token) = token.refresh_token.as_deref() - && let Ok(mut refreshed) = self - .refresh_access_token(env, refresh_token, &token.scopes) - .await - { - if refreshed.refresh_token.is_none() { - refreshed.refresh_token = Some(refresh_token.to_owned()); - } - self.save_stored(env, &refreshed).await?; - self.store_cached_token(env, refreshed.clone()).await; - return Ok(Some(refreshed)); - } - } - Ok(None) - } - - /// Runs a fresh interactive PKCE flow requesting exactly `scopes`, replacing - /// any stored token for `env`. - async fn reauthenticate(&self, env: &str, scopes: &[String]) -> Result { - let token = self.run_pkce_flow_with(env, scopes).await?; - // Persist first — the keychain write overwrites the existing entry for - // this env — and only update the in-memory cache after a successful - // save. This avoids destroying a still-valid token if the save fails - // (e.g. keychain unavailable and file fallback disabled). - self.save_stored(env, &token).await?; - self.store_cached_token(env, token.clone()).await; - Ok(token) - } - - /// Runs the browser PKCE flow requesting exactly `scopes` (used both for the - /// default login and for scope step-up, which requests a wider union). - async fn run_pkce_flow_with(&self, env: &str, scopes: &[String]) -> Result { - let (code_verifier, code_challenge) = pkce_challenge(); - let state = random_state(); - // Resolve the OAuth config once for this whole flow (authorize + exchange). - let oauth = self.effective_oauth(env)?; - let redirect_uri = self.effective_redirect_uri(); - let scope = scopes.join(" "); - - let auth_params = [ - ("response_type", "code"), - ("client_id", &oauth.client_id), - ("redirect_uri", &redirect_uri), - ("scope", &scope), - ("state", &state), - ("code_challenge", &code_challenge), - ("code_challenge_method", "S256"), - ]; - let url = url::Url::parse_with_params(&oauth.auth_url, &auth_params) - .map_err(|err| CliCoreError::message(format!("invalid auth URL: {err}")))?; - - let (bind_port, callback_path) = self.parse_redirect_uri()?; - - // Start the local callback server before opening the browser so the - // redirect lands as soon as the user approves. - let listener = - TcpListener::bind(SocketAddr::from(([127, 0, 0, 1], bind_port))).map_err(|err| { - CliCoreError::message(format!( - "failed to bind callback server on port {bind_port}: {err}" - )) - })?; - - emit_browser_login_prompt(&url); - drop(open::that(url.as_str())); - - let code = - wait_for_callback(listener, &state, &callback_path, Duration::from_secs(120)).await?; - let token = self - .exchange_code_for_token(&oauth, &code, &code_verifier, scopes) - .await?; - emit_auth_complete_message(); - Ok(token) - } - - /// Builds a POST to an OAuth token endpoint on the provider's shared client. - /// - /// Token traffic does not go through [`HttpClient`](crate::transport::HttpClient) - /// — that client is built for authenticated, JSON-bodied backend calls, - /// whereas the token endpoint is unauthenticated and form-encoded. The - /// user-agent and timeout are attached here per request (read at call time) - /// so every outbound call, including credential acquisition and refresh, is - /// attributed consistently and bounded. - fn token_request(&self, token_url: &str, params: &[(&str, &str)]) -> reqwest::RequestBuilder { - self.client - .post(token_url) - .header( - reqwest::header::USER_AGENT, - crate::transport::client::default_user_agent(), - ) - .timeout(self.token_timeout) - .form(params) - } - - async fn exchange_code_for_token( - &self, - oauth: &OAuthSection, - code: &str, - code_verifier: &str, - requested_scopes: &[String], - ) -> Result { - let redirect_uri = self.effective_redirect_uri(); - - let params = [ - ("grant_type", "authorization_code"), - ("client_id", &oauth.client_id), - ("redirect_uri", &redirect_uri), - ("code", code), - ("code_verifier", code_verifier), - ]; - let response = self - .token_request(&oauth.token_url, ¶ms) - .send() - .await - .map_err(|err| CliCoreError::message(format!("token request failed: {err}")))?; - - if !response.status().is_success() { - let status = response.status(); - let body = response.text().await.unwrap_or_default(); - return Err(CliCoreError::message(format!( - "token endpoint returned {status}: {body}" - ))); - } - - parse_token_response(response, requested_scopes).await - } - - async fn refresh_access_token( - &self, - env: &str, - refresh_token: &str, - prior_scopes: &[String], - ) -> Result { - let oauth = self.effective_oauth(env)?; - let params = [ - ("grant_type", "refresh_token"), - ("client_id", &oauth.client_id), - ("refresh_token", refresh_token), - ]; - let response = self - .token_request(&oauth.token_url, ¶ms) - .send() - .await - .map_err(|err| CliCoreError::message(format!("token refresh failed: {err}")))?; - - if !response.status().is_success() { - let status = response.status(); - let body = response.text().await.unwrap_or_default(); - return Err(CliCoreError::message(format!( - "refresh endpoint returned {status}: {body}" - ))); - } - - parse_token_response(response, prior_scopes).await - } -} - -#[async_trait] -impl AuthProvider for PkceAuthProvider { - fn name(&self) -> &str { - &self.name - } - - async fn get_credential(&self, env: &str, _command: &str, _tier: &str) -> Result { - let token = self.resolve_token(env).await?; - Ok(self.build_credential(env, &token)) - } - - async fn get_credential_for(&self, req: &CredentialRequest<'_>) -> Result { - let env = req.env; - let required = &req.meta.scopes; - - // Look for a usable token WITHOUT launching a flow, so we can pick the - // scope set for a single login rather than authenticating twice (e.g. - // `auth login --scope X` logs out first; resolving defaults and then - // stepping up would open the browser twice). - if let Some(token) = self.existing_token(env).await? { - // Decide based on what the token grants (JWT claim plus the scopes it - // was obtained with). - let granted = granted_scopes(&token); - match plan_step_up(&granted, required, &self.scope_hierarchy) { - StepUp::Covered => return Ok(self.build_credential(env, &token)), - // Step-up is re-consent: the authorization server has no silent - // scope-expansion grant, so acquire the missing scopes with a - // fresh login — the same browser flow the no-token path runs - // below, rather than failing when stdio is not a TTY. Resolve - // per-env defaults only now (off the cached-token hot path) and - // request defaults ∪ already-granted ∪ required so step-up never - // drops previously-acquired scopes. - StepUp::Reauthenticate => { - let union = union_scopes(&self.effective_scopes(env)?, &granted, required); - let token = self.reauthenticate(env, &union).await?; - ensure_granted(env, &token, required, &self.scope_hierarchy)?; - return Ok(self.build_credential(env, &token)); - } - } - } - - // No usable token: authenticate once, requesting defaults ∪ required. - let union = union_scopes(&self.effective_scopes(env)?, &[], required); - let token = self.reauthenticate(env, &union).await?; - ensure_granted(env, &token, required, &self.scope_hierarchy)?; - Ok(self.build_credential(env, &token)) - } - - async fn status(&self, env: &str) -> Result { - let Some(token) = self.load_stored(env).await else { - return Err(CliCoreError::message(format!( - "not logged in for environment {env:?}" - ))); - }; - Ok(self.build_credential(env, &token)) - } - - async fn logout(&self, env: &str) -> Result<()> { - self.delete_stored(env).await; - let mut cache = self.cache.write().await; - cache.remove(env); - Ok(()) - } - - async fn list_environments(&self) -> Result> { - // Keyring and file-fallback storage do not support listing; return only - // the in-memory cache keys as a hint. Tokens that survived a restart via - // file fallback are not enumerated here. - let cache = self.cache.read().await; - Ok(cache.keys().cloned().collect()) - } -} - -fn emit_browser_login_prompt(url: &url::Url) { - let mut stderr = std::io::stderr().lock(); - drop(writeln!(stderr, "Opening browser for authentication…")); - drop(writeln!( - stderr, - "If the browser does not open, visit:\n {url}" - )); -} - -// Printed once the OAuth token is in hand, so a caller who goes on to run a -// long-running command (e.g. a purchase) doesn't mistake that work for the -// browser flow still being pending. See DEVEX-892 / GDDEVPLAT-64. -fn emit_auth_complete_message() { - let mut stderr = std::io::stderr().lock(); - drop(writeln!(stderr, "Authentication complete.")); -} - -/// Generates a PKCE code verifier and SHA-256 code challenge. -fn pkce_challenge() -> (String, String) { - let bytes: [u8; 32] = rand::rng().random(); - let verifier = URL_SAFE_NO_PAD.encode(bytes); - let hash = Sha256::digest(verifier.as_bytes()); - let challenge = URL_SAFE_NO_PAD.encode(hash); - (verifier, challenge) -} - -/// Generates a random OAuth state parameter. -fn random_state() -> String { - let bytes: [u8; 16] = rand::rng().random(); - URL_SAFE_NO_PAD.encode(bytes) -} - -/// Waits for the OAuth callback on the given listener, validates state and path. -/// -/// Accepts connections in a loop so that stray connections (port scanners, -/// browser preflight requests) do not consume the single callback attempt. -/// Uses async I/O so the future is properly cancelled on Ctrl+C. -async fn wait_for_callback( - listener: TcpListener, - expected_state: &str, - expected_path: &str, - timeout: Duration, -) -> Result { - use tokio::io::{AsyncReadExt, AsyncWriteExt}; - - listener - .set_nonblocking(true) - .map_err(|err| CliCoreError::message(format!("callback server setup failed: {err}")))?; - let listener = tokio::net::TcpListener::from_std(listener) - .map_err(|err| CliCoreError::message(format!("callback server setup failed: {err}")))?; - - let expected_state = expected_state.to_owned(); - let expected_path = expected_path.to_owned(); - let result = tokio::time::timeout(timeout, async move { - loop { - let (mut stream, _) = match listener.accept().await { - Ok(conn) => conn, - Err(_) => { - // Back off before retrying so a persistent accept failure - // (e.g. file-descriptor exhaustion) cannot spin the CPU until - // the timeout fires. The sleep is an await point, so Ctrl+C - // still cancels the flow promptly. - tokio::time::sleep(Duration::from_millis(50)).await; - continue; - } - }; - let mut buf = vec![0_u8; 4096]; - let n = match stream.read(&mut buf).await { - Ok(0) | Err(_) => continue, - Ok(n) => n, - }; - let request = String::from_utf8_lossy(&buf[..n]); - - if extract_request_path(&request).as_deref() != Some(expected_path.as_str()) { - drop( - stream - .write_all(b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n") - .await, - ); - continue; - } - - let code = extract_query_param(&request, "code"); - let state = extract_query_param(&request, "state"); - - let html_response = if state.as_deref() == Some(&expected_state) && code.is_some() { - "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\n\r\n\ - Authentication successful. You may close this window." - } else { - "HTTP/1.1 400 Bad Request\r\nContent-Type: text/html\r\n\r\n\ - Authentication failed. Please try again." - }; - drop(stream.write_all(html_response.as_bytes()).await); - - if state.as_deref() == Some(expected_state.as_str()) { - return code - .ok_or_else(|| CliCoreError::message("no authorization code in callback")); - } - } - }) - .await; - - match result { - Ok(inner) => inner, - Err(_) => Err(CliCoreError::message( - "timed out waiting for OAuth callback", - )), - } -} - -/// Extracts the path component from an HTTP request line (without query string). -fn extract_request_path(request: &str) -> Option { - let line = request.lines().next()?; - let path_with_query = line.split_whitespace().nth(1)?; - Some( - path_with_query - .split_once('?') - .map_or(path_with_query, |(p, _)| p) - .to_owned(), - ) -} - -/// Extracts a query parameter value from an HTTP request line. -fn extract_query_param(request: &str, name: &str) -> Option { - let line = request.lines().next()?; - let path = line.split_whitespace().nth(1)?; - let query = path.split_once('?')?.1; - url::form_urlencoded::parse(query.as_bytes()) - .find(|(key, _)| key == name) - .map(|(_, value)| value.into_owned()) -} - -#[derive(Debug, Deserialize)] -struct TokenResponse { - access_token: String, - expires_in: Option, - refresh_token: Option, - /// Space-delimited scopes the server actually granted, when it echoes them. - scope: Option, -} - -/// Decodes the claims (payload) segment of a JWT **without verifying the -/// signature**. -/// -/// The returned claims are used to display a human-readable identity in -/// `auth status` and audit logs, and (via [`scopes_from_jwt`]) to decide whether -/// scope step-up needs a fresh login. These are convenience/optimization reads, -/// **not** trust or authorization decisions — the authorization server remains -/// the source of truth for granted scopes — so signature verification is -/// intentionally skipped. Opaque (non-JWT) tokens and any decode/parse failure -/// yield `None`, leaving the identity blank (and treating scopes as absent, which -/// just forces a re-auth). -fn decode_jwt_claims(token: &str) -> Option> { - // A JWT is `header.payload.signature`; the payload is the middle segment, - // base64url-encoded without padding. - let payload = token.split('.').nth(1)?; - let bytes = URL_SAFE_NO_PAD.decode(payload).ok()?; - serde_json::from_slice(&bytes).ok() -} - -/// Returns `defaults ∪ granted ∪ required`, order-preserving and de-duplicated. -fn union_scopes(defaults: &[String], granted: &[String], required: &[String]) -> Vec { - let mut union = defaults.to_vec(); - for scope in granted.iter().chain(required.iter()) { - if !union.contains(scope) { - union.push(scope.clone()); - } - } - union -} - -/// Reads the granted scopes from a JWT access token. -/// -/// OAuth uses a space-delimited `scope` string (RFC), but some IdPs (e.g. Azure -/// AD) use `scp`, and either may be encoded as a JSON array — so all of those -/// forms are accepted. Returns an empty list for opaque (non-JWT) tokens or -/// tokens without a recognized scope claim; coverage then falls back to the -/// scopes recorded on the [`StoredToken`] (see [`granted_scopes`]). -fn scopes_from_jwt(token: &str) -> Vec { - let Some(claims) = decode_jwt_claims(token) else { - return Vec::new(); - }; - for key in ["scope", "scp"] { - if let Some(value) = claims.get(key) { - let scopes = scopes_from_claim(value); - if !scopes.is_empty() { - return scopes; - } - } - } - Vec::new() -} - -/// Parses a scope claim that may be a space-delimited string or a JSON array of -/// (possibly space-delimited) strings. -fn scopes_from_claim(value: &Value) -> Vec { - match value { - Value::String(scope) => scope.split_whitespace().map(str::to_owned).collect(), - Value::Array(items) => items - .iter() - .filter_map(Value::as_str) - .flat_map(str::split_whitespace) - .map(str::to_owned) - .collect(), - _ => Vec::new(), - } -} - -/// Scope implication relationships for an identity provider whose scopes -/// nest (for example, a granted `write` scope also covering `read`). -/// -/// Attach one to a [`PkceAuthProvider`] with -/// [`with_scope_hierarchy`](PkceAuthProvider::with_scope_hierarchy) so scope -/// coverage checks stop treating scopes as opaque, unrelated strings. Empty -/// by default, which falls back to exact-string matching — today's behavior. -#[derive(Debug, Default, Clone)] -pub struct ScopeHierarchy { - implies: HashMap>, -} - -impl ScopeHierarchy { - /// Creates an empty hierarchy, equivalent to exact-string scope matching. - #[must_use] - pub fn new() -> Self { - Self::default() - } - - /// Declares that holding `scope` also satisfies every scope in `implied`. - /// - /// Implications compose transitively: if `admin` implies `write` and - /// `write` implies `read`, a granted `admin` scope covers a required - /// `read` scope. - #[must_use] - pub fn with_implication( - mut self, - scope: impl Into, - implied: &[impl AsRef], - ) -> Self { - self.implies - .entry(scope.into()) - .or_default() - .extend(implied.iter().map(|s| s.as_ref().to_owned())); - self - } - - /// True if `required` is present in `granted` verbatim, or transitively - /// implied by something in `granted`. - fn covers(&self, granted: &[String], required: &str) -> bool { - let mut queue: Vec<&str> = granted.iter().map(String::as_str).collect(); - let mut visited: HashSet<&str> = HashSet::new(); - while let Some(scope) = queue.pop() { - if scope == required { - return true; - } - if !visited.insert(scope) { - continue; - } - if let Some(implied) = self.implies.get(scope) { - queue.extend(implied.iter().map(String::as_str)); - } - } - false - } -} - -/// All scopes an access token is known to carry: the JWT `scope`/`scp` claim -/// plus the scopes recorded when the token was obtained. The recorded scopes -/// make coverage work for opaque tokens and IdPs that omit scopes from the -/// access token. -fn granted_scopes(token: &StoredToken) -> Vec { - let mut scopes = scopes_from_jwt(&token.access_token); - for scope in &token.scopes { - if !scopes.contains(scope) { - scopes.push(scope.clone()); - } - } - scopes -} - -/// The action scope step-up should take for a token, given what it already -/// grants and what the command requires. Pure so the decision is unit-testable -/// without real TTY detection or a browser flow. -#[derive(Debug, PartialEq, Eq)] -enum StepUp { - /// The token already covers every required scope. - Covered, - /// Re-authenticate to acquire the missing scopes. The caller builds the - /// requested set (defaults ∪ granted ∪ required) only on this path, so - /// resolving per-env default scopes stays off the cached-token hot path. - Reauthenticate, -} - -/// Decides the step-up action from what the token grants versus what the command -/// requires. Deliberately does NOT take the per-env default scopes: the coverage -/// decision needs only `granted`/`required`, so the caller can avoid resolving -/// defaults (potential `environments.toml` I/O) when a cached token already -/// covers the requirement. -fn plan_step_up(granted: &[String], required: &[String], hierarchy: &ScopeHierarchy) -> StepUp { - let covered = required - .iter() - .all(|scope| hierarchy.covers(granted, scope.as_str())); - if covered { - StepUp::Covered - } else { - StepUp::Reauthenticate - } -} - -/// Confirms a freshly (re)authenticated token actually grants `required`. -/// -/// Re-consent does not guarantee the authorization server grants every requested -/// scope (it may decline by policy). When the difference is detectable — the -/// token is a JWT exposing its scopes, or the token response echoed a narrower -/// `scope` — return a clear error instead of handing back an under-scoped token -/// that the API would later reject with a 403, and instead of re-prompting in a -/// loop the server will keep refusing. (For opaque tokens whose grant the server -/// does not echo, the recorded scopes equal what was requested, so an undetected -/// decline still surfaces downstream as a 403.) -fn ensure_granted( - env: &str, - token: &StoredToken, - required: &[String], - hierarchy: &ScopeHierarchy, -) -> Result<()> { - let granted = granted_scopes(token); - let missing: Vec = required - .iter() - .filter(|scope| !hierarchy.covers(&granted, scope.as_str())) - .cloned() - .collect(); - if missing.is_empty() { - Ok(()) - } else { - Err(CliCoreError::message(format!( - "authorization server did not grant required scope(s) for {env:?}: {}", - missing.join(", ") - ))) - } -} - -/// Returns the first claim value that is a non-empty string, in priority order. -fn extract_identity(claims: &Map, priority: &[String]) -> String { - priority - .iter() - .filter_map(|name| claims.get(name).and_then(Value::as_str)) - .find(|value| !value.is_empty()) - .unwrap_or_default() - .to_owned() -} - -async fn parse_token_response( - response: reqwest::Response, - requested_scopes: &[String], -) -> Result { - let body: TokenResponse = response - .json() - .await - .map_err(|err| CliCoreError::message(format!("failed to parse token response: {err}")))?; - let expires_in = body.expires_in.unwrap_or(3600); - let expires_at = Utc::now().timestamp() + expires_in; - // Record what the token grants: the server's echoed `scope` when present, - // otherwise the scopes we asked for. This is the coverage signal for opaque - // tokens, which carry no readable scope claim. - let scopes = body - .scope - .as_deref() - .map(|scope| { - scope - .split_whitespace() - .map(str::to_owned) - .collect::>() - }) - .filter(|scopes| !scopes.is_empty()) - .unwrap_or_else(|| requested_scopes.to_vec()); - Ok(StoredToken { - access_token: body.access_token, - expires_at, - refresh_token: body.refresh_token, - scopes, - }) -} - -#[cfg(test)] -#[allow(unsafe_code)] -mod tests { - use serde_json::json; - - use super::*; - - fn test_provider() -> PkceAuthProvider { - PkceAuthProvider::new( - "test", - "https://example.com/auth", - "https://example.com/token", - "client-id", - &["openid"], - ) - } - - fn valid_token(access_token: &str) -> StoredToken { - StoredToken { - access_token: access_token.to_owned(), - expires_at: Utc::now().timestamp() + 3600, - refresh_token: None, - scopes: Vec::new(), - } - } - - fn token_with_scopes(access_token: &str, scopes: &[&str]) -> StoredToken { - // No struct-update from `valid_token`: StoredToken is `Drop` - // (ZeroizeOnDrop), so fields cannot be moved out of another instance. - StoredToken { - access_token: access_token.to_owned(), - expires_at: Utc::now().timestamp() + 3600, - refresh_token: None, - scopes: scopes.iter().map(|s| (*s).to_owned()).collect(), - } - } - - fn expired_token() -> StoredToken { - StoredToken { - access_token: "old-token".to_owned(), - // Older than the expiry buffer so is_valid() returns false. - expires_at: Utc::now().timestamp() - TOKEN_EXPIRY_BUFFER_SECS - 1, - refresh_token: None, - scopes: Vec::new(), - } - } - - fn envs_for_test() -> Arc { - use crate::environments::{EnvTable, Environments}; - Arc::new( - Environments::new("prod").with_environment( - "prod", - EnvTable::new() - .with("client_id", "prod-client") - .with("auth_url", "https://prod.example.com/auth") - .with("token_url", "https://prod.example.com/token") - .with("scopes", vec!["openid", "prod.read"]), - ), - ) - } - - /// A provider wired to an [`Environments`](crate::environments::Environments) - /// resolver sources its per-env OAuth config (client id, endpoints, scopes) - /// from the resolved environment, making the environment the single source - /// of truth. - #[test] - fn environment_wired_provider_sources_oauth_from_resolver() { - let provider = PkceAuthProvider::new( - "godaddy", - "https://base/auth", - "https://base/token", - "base-client", - &["openid"], - ) - .with_environments(envs_for_test()); - let oauth = provider.effective_oauth("prod").expect("assembles"); - assert_eq!(oauth.client_id, "prod-client"); - assert_eq!(oauth.auth_url, "https://prod.example.com/auth"); - assert_eq!(oauth.token_url, "https://prod.example.com/token"); - assert_eq!( - oauth.scopes, - vec!["openid".to_owned(), "prod.read".to_owned()] - ); - } - - /// A resolved environment's `scopes = []` is treated as absent, not as a - /// deliberate "no scopes" override — it falls through to the provider's - /// real base scopes, the same way a blank `client_id`/`auth_url` string - /// would. An OAuth flow with zero scopes is never what a wired - /// environment actually means; it's the same kind of unset-placeholder - /// case blank-string collapsing already exists to catch. - #[test] - fn environment_with_empty_scopes_falls_back_to_base_scopes() { - use crate::environments::{EnvTable, Environments}; - - let environments = Arc::new( - Environments::new("prod").with_environment( - "prod", - EnvTable::new() - .with("client_id", "prod-client") - .with("scopes", Vec::::new()), - ), - ); - let provider = PkceAuthProvider::new( - "godaddy", - "https://base/auth", - "https://base/token", - "base-client", - &["openid", "base.read"], - ) - .with_environments(environments); - - let oauth = provider.effective_oauth("prod").expect("assembles"); - assert_eq!( - oauth.client_id, "prod-client", - "the environment's own value still wins" - ); - assert_eq!( - oauth.scopes, - vec!["openid".to_owned(), "base.read".to_owned()], - "an empty scopes array from the environment must defer to the base config's real scopes" - ); - } - - /// A provider with no environment resolver falls back to the base client id, - /// endpoints, and scopes for every env. - #[test] - fn non_wired_provider_uses_base_config() { - let provider = PkceAuthProvider::new( - "godaddy", - "https://base/auth", - "https://base/token", - "base-client", - &["openid"], - ); - let oauth = provider.effective_oauth("anything").expect("assembles"); - assert_eq!(oauth.client_id, "base-client"); - assert_eq!(oauth.scopes, vec!["openid".to_owned()]); - } - - /// OAuth token traffic must carry the engine's configured default - /// user-agent so it is attributed consistently with all other outbound - /// calls (some upstream WAFs reject requests without a User-Agent). - #[test] - fn token_request_carries_default_user_agent() { - let _guard = crate::transport::client::UA_TEST_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let _restore = crate::transport::client::RestoreDefaultUserAgent; - crate::transport::set_default_user_agent("ua-probe/7.7"); - let provider = test_provider().with_token_timeout(Duration::from_secs(12)); - let request = provider - .token_request( - "https://example.com/token", - &[("grant_type", "refresh_token")], - ) - .build() - .expect("token request should build"); - let header = request - .headers() - .get(reqwest::header::USER_AGENT) - .expect("token request should set a user-agent"); - assert_eq!(header, "ua-probe/7.7"); - assert_eq!(request.timeout(), Some(&Duration::from_secs(12))); - } - - /// OAuth token requests must not hang indefinitely: the provider applies a - /// 30s timeout by default. - #[test] - fn default_token_timeout_is_thirty_seconds() { - assert_eq!(test_provider().token_timeout, Duration::from_secs(30)); - } - - /// The default token timeout can be overridden per provider. - #[test] - fn with_token_timeout_overrides_default() { - let provider = test_provider().with_token_timeout(Duration::from_secs(5)); - assert_eq!(provider.token_timeout, Duration::from_secs(5)); - } - - /// store_cached_token + cached_token round-trip: the mechanism used by - /// the persistence fix must reliably write and read tokens from the cache. - #[tokio::test] - async fn cache_stores_and_retrieves_valid_token() { - let provider = test_provider(); - let token = valid_token("access-abc"); - - provider.store_cached_token("dev", token.clone()).await; - - let cached = provider.cached_token("dev").await; - assert!(cached.is_some(), "expected cached token to be present"); - assert_eq!( - cached.expect("token must be present").access_token, - "access-abc" - ); - } - - /// Expired tokens must not be returned from the cache; the caller would - /// then proceed to the keychain or PKCE flow. - #[tokio::test] - async fn cached_token_ignores_expired_tokens() { - let provider = test_provider(); - provider.store_cached_token("dev", expired_token()).await; - - assert!( - provider.cached_token("dev").await.is_none(), - "expired token should not be returned from cache" - ); - } - - #[test] - fn scopes_from_jwt_parses_scope_claim() { - let token = make_jwt(&json!({ "scope": "a b c" })); - assert_eq!(scopes_from_jwt(&token), vec!["a", "b", "c"]); - } - - #[test] - fn scopes_from_jwt_parses_scp_and_array_claims() { - // Azure-style `scp` array. - let scp = make_jwt(&json!({ "scp": ["a", "b"] })); - assert_eq!(scopes_from_jwt(&scp), vec!["a", "b"]); - // `scope` encoded as an array. - let array = make_jwt(&json!({ "scope": ["a", "b c"] })); - assert_eq!(scopes_from_jwt(&array), vec!["a", "b", "c"]); - // Empty `scope` falls through to `scp`. - let mixed = make_jwt(&json!({ "scope": "", "scp": ["x"] })); - assert_eq!(scopes_from_jwt(&mixed), vec!["x"]); - } - - #[test] - fn granted_scopes_uses_recorded_scopes_for_opaque_token() { - // An opaque (non-JWT) token carries no readable claim, so coverage comes - // from the scopes recorded when it was obtained. - let token = token_with_scopes("opaque-token", &["a", "b"]); - assert_eq!(granted_scopes(&token), vec!["a", "b"]); - } - - #[test] - fn ensure_granted_rejects_a_token_missing_required_scopes() { - let required = vec!["a".to_owned(), "b".to_owned()]; - let hierarchy = ScopeHierarchy::new(); - // JWT that exposes only `a` → `b` is detectably not granted. - let jwt = valid_token(&make_jwt(&json!({ "scope": "a" }))); - let err = ensure_granted("dev", &jwt, &required, &hierarchy).expect_err("b is not granted"); - assert!( - err.to_string().contains("did not grant required scope(s)"), - "{err}" - ); - assert!(err.to_string().contains('b'), "{err}"); - - // A token granting both passes. - let ok = valid_token(&make_jwt(&json!({ "scope": "a b" }))); - ensure_granted("dev", &ok, &required, &hierarchy).expect("both granted"); - // Recorded scopes (opaque token) also satisfy the check. - let opaque = token_with_scopes("opaque", &["a", "b"]); - ensure_granted("dev", &opaque, &required, &hierarchy).expect("recorded scopes granted"); - } - - #[test] - fn ensure_granted_accepts_hierarchy_covered_grant() { - // The IdP literally grants only `admin`; the hierarchy says that - // covers `read`, so the exact-string-missing scope should not error. - let required = vec!["read".to_owned()]; - let hierarchy = ScopeHierarchy::new().with_implication("admin", &["read"]); - let jwt = valid_token(&make_jwt(&json!({ "scope": "admin" }))); - ensure_granted("dev", &jwt, &required, &hierarchy).expect("admin implies read"); - } - - #[test] - fn plan_step_up_covers_or_reauthenticates() { - let granted = vec!["base".to_owned(), "read".to_owned()]; - let read = vec!["read".to_owned()]; - let write = vec!["write".to_owned()]; - let hierarchy = ScopeHierarchy::new(); - - // Already covered (decision needs only granted vs required). - assert_eq!(plan_step_up(&granted, &read, &hierarchy), StepUp::Covered); - // Missing → reauthenticate, with no interactivity gate: step-up now - // mirrors the no-token path and acquires the scope via a fresh login - // rather than failing when stdio is not a TTY. The caller builds the - // union (defaults ∪ granted ∪ required) only on this path. - assert_eq!( - plan_step_up(&granted, &write, &hierarchy), - StepUp::Reauthenticate - ); - // The union itself (defaults ∪ granted ∪ required) is covered by - // union_scopes' own test. - } - - #[test] - fn plan_step_up_covers_via_hierarchy() { - let granted = vec!["admin".to_owned()]; - let read = vec!["read".to_owned()]; - let hierarchy = ScopeHierarchy::new().with_implication("admin", &["read"]); - - assert_eq!(plan_step_up(&granted, &read, &hierarchy), StepUp::Covered); - } - - #[test] - fn scope_hierarchy_covers_transitively() { - let hierarchy = ScopeHierarchy::new() - .with_implication("a", &["b"]) - .with_implication("b", &["c"]); - - assert!(hierarchy.covers(&["a".to_owned()], "c")); - } - - #[test] - fn scope_hierarchy_ignores_cycles() { - let hierarchy = ScopeHierarchy::new() - .with_implication("a", &["b"]) - .with_implication("b", &["a"]); - - // Terminates instead of looping, and still resolves correctly. - assert!(hierarchy.covers(&["a".to_owned()], "b")); - assert!(!hierarchy.covers(&["a".to_owned()], "z")); - } - - #[test] - fn scope_hierarchy_defaults_to_exact_match() { - let hierarchy = ScopeHierarchy::new(); - - assert!(hierarchy.covers(&["read".to_owned()], "read")); - assert!(!hierarchy.covers(&["admin".to_owned()], "read")); - } - - /// An opaque cached token whose recorded scopes cover the requirement is - /// returned without starting a flow — proving coverage no longer depends on - /// a readable JWT scope claim. - #[tokio::test] - async fn get_credential_for_uses_recorded_scopes_for_opaque_token() { - let provider = test_provider(); - provider - .store_cached_token("dev", token_with_scopes("opaque-token", &["read", "write"])) - .await; - - let meta = crate::middleware::CommandMeta { - scopes: vec!["read".to_owned()], - ..crate::middleware::CommandMeta::default() - }; - let req = CredentialRequest::new("dev", "app:list", "read", &meta); - let credential = provider - .get_credential_for(&req) - .await - .expect("recorded scopes cover the requirement"); - assert_eq!(credential.token, "opaque-token"); - } - - #[test] - fn union_scopes_dedupes_and_preserves_order() { - let defaults = vec!["a".to_owned(), "b".to_owned()]; - let granted = vec!["b".to_owned(), "c".to_owned()]; - let required = vec!["c".to_owned(), "d".to_owned()]; - assert_eq!( - union_scopes(&defaults, &granted, &required), - vec!["a", "b", "c", "d"] - ); - } - - #[test] - fn scopes_from_jwt_empty_for_opaque_or_missing() { - assert!(scopes_from_jwt("opaque-token").is_empty()); - let no_scope = make_jwt(&json!({ "sub": "user" })); - assert!(scopes_from_jwt(&no_scope).is_empty()); - } - - /// When the cached token's JWT already covers the required scopes, - /// `get_credential_for` must return it without starting a PKCE flow. - #[tokio::test] - async fn get_credential_for_uses_cached_token_when_scopes_covered() { - let provider = test_provider(); - let token = valid_token(&make_jwt(&json!({ - "scope": "apps.app-registry:read apps.app-registry:write", - "sub": "user-1", - }))); - provider.store_cached_token("dev", token).await; - - let mut meta = crate::middleware::CommandMeta::default(); - meta.set_scopes(vec!["apps.app-registry:read".to_owned()]); - let req = CredentialRequest { - env: "dev", - command: "app:list", - tier: "read", - meta: &meta, - }; - let credential = provider - .get_credential_for(&req) - .await - .expect("cached token covers required scopes"); - assert_eq!(credential.sub, "user-1"); - } - - /// With no required scopes, `get_credential_for` behaves like - /// `get_credential` and returns the cached token unchanged. - #[tokio::test] - async fn get_credential_for_no_scopes_returns_cached() { - let provider = test_provider(); - provider - .store_cached_token("dev", valid_token("opaque")) - .await; - let meta = crate::middleware::CommandMeta::default(); - let req = CredentialRequest { - env: "dev", - command: "app:list", - tier: "read", - meta: &meta, - }; - let credential = provider - .get_credential_for(&req) - .await - .expect("no scopes required"); - assert_eq!(credential.token, "opaque"); - } - - #[test] - fn redirect_uri_default_uses_127_0_0_1_and_redirect_port() { - let provider = test_provider().with_redirect_port(9000); - assert_eq!( - provider.effective_redirect_uri(), - "http://127.0.0.1:9000/callback" - ); - } - - #[test] - fn with_redirect_uri_overrides_default() { - let provider = test_provider().with_redirect_uri("http://localhost:8080/auth/callback"); - assert_eq!( - provider.effective_redirect_uri(), - "http://localhost:8080/auth/callback" - ); - } - - #[test] - fn parse_redirect_uri_extracts_port_and_path_from_default() { - let provider = test_provider().with_redirect_port(9000); - let (port, path) = provider.parse_redirect_uri().expect("valid URI"); - assert_eq!(port, 9000); - assert_eq!(path, "/callback"); - } - - #[test] - fn parse_redirect_uri_extracts_port_and_path_from_custom_uri() { - let provider = test_provider().with_redirect_uri("http://localhost:8080/auth/callback"); - let (port, path) = provider.parse_redirect_uri().expect("valid URI"); - assert_eq!(port, 8080); - assert_eq!(path, "/auth/callback"); - } - - #[test] - fn with_redirect_uri_does_not_affect_listener_host() { - // The port is derived from the URI, but the listener always binds to - // 127.0.0.1 — this test confirms the URI host does not change that. - let provider = test_provider().with_redirect_uri("http://localhost:7777/callback"); - let (port, _) = provider.parse_redirect_uri().expect("valid URI"); - assert_eq!(port, 7777); - // Caller uses 127.0.0.1 for bind regardless; SocketAddr construction - // is in run_pkce_flow and is not repeated here. - } - - #[test] - fn extract_request_path_strips_query_string() { - assert_eq!( - extract_request_path("GET /auth/callback?code=abc&state=xyz HTTP/1.1\r\n"), - Some("/auth/callback".to_owned()), - ); - } - - #[test] - fn extract_request_path_handles_no_query_string() { - assert_eq!( - extract_request_path("GET /callback HTTP/1.1\r\n"), - Some("/callback".to_owned()), - ); - } - - #[test] - fn extract_query_param_skips_malformed_pairs() { - let request = "GET /callback?foo&code=abc123&state=xyz HTTP/1.1\r\nHost: localhost\r\n"; - assert_eq!( - extract_query_param(request, "code"), - Some("abc123".to_owned()), - ); - assert_eq!( - extract_query_param(request, "state"), - Some("xyz".to_owned()), - ); - } - - #[test] - fn extract_query_param_decodes_percent_encoding() { - let request = "GET /callback?code=a%20b%2Bc&state=ok HTTP/1.1\r\n"; - assert_eq!( - extract_query_param(request, "code"), - Some("a b+c".to_owned()), - ); - } - - /// resolve_token must return a pre-seeded in-memory token without - /// triggering the PKCE browser flow (which would require a port and browser). - /// This also exercises the cache-hit path that follows token persistence. - #[tokio::test] - async fn resolve_token_returns_cached_token_without_pkce_flow() { - let provider = test_provider(); - provider - .store_cached_token("dev", valid_token("cached-token")) - .await; - - let resolved = provider - .resolve_token("dev") - .await - .expect("resolve from cache"); - assert_eq!(resolved.access_token, "cached-token"); - } - - /// list_environments returns only in-memory cache keys; tokens written to - /// disk via file fallback during a previous session are not enumerated. - #[tokio::test] - async fn list_environments_returns_only_cached_keys() { - let provider = test_provider(); - provider.store_cached_token("dev", valid_token("t1")).await; - provider.store_cached_token("prod", valid_token("t2")).await; - - let mut envs = provider.list_environments().await.expect("list"); - envs.sort(); - assert_eq!(envs, ["dev", "prod"]); - } - - /// A provider with no cache entries returns an empty list, regardless of - /// what credential files may exist on disk from a previous session. - #[tokio::test] - async fn list_environments_returns_empty_without_cache() { - let provider = test_provider(); - let envs = provider.list_environments().await.expect("list"); - assert!(envs.is_empty(), "expected empty list for a fresh provider"); - } - - /// Builds an unsigned-looking JWT (`header.payload.signature`) whose payload - /// is the given claims object, base64url-encoded without padding. - fn make_jwt(claims: &Value) -> String { - let header = URL_SAFE_NO_PAD.encode(br#"{"alg":"none","typ":"JWT"}"#); - let payload = URL_SAFE_NO_PAD.encode(serde_json::to_vec(claims).expect("serialize claims")); - format!("{header}.{payload}.signature") - } - - #[test] - fn decode_jwt_claims_extracts_payload() { - let token = make_jwt(&json!({"email": "user@example.com", "sub": "abc123"})); - let claims = decode_jwt_claims(&token).expect("claims decode"); - assert_eq!( - claims.get("email").and_then(Value::as_str), - Some("user@example.com") - ); - assert_eq!(claims.get("sub").and_then(Value::as_str), Some("abc123")); - } - - #[test] - fn decode_jwt_claims_returns_none_for_non_jwt() { - assert!(decode_jwt_claims("opaque-access-token").is_none()); - assert!(decode_jwt_claims("only.two").is_none()); - // Valid structure but the payload is not valid base64/JSON. - assert!(decode_jwt_claims("aaa.!!!.bbb").is_none()); - } - - #[test] - fn extract_identity_honors_priority_and_skips_empty() { - let priority: Vec = DEFAULT_IDENTITY_CLAIMS - .iter() - .map(|c| (*c).to_owned()) - .collect(); - // `email` is empty, so the next non-empty claim (`preferred_username`) wins. - let claims = serde_json::from_value(json!({ - "email": "", - "preferred_username": "jdoe", - "name": "Jane Doe", - })) - .expect("claims map"); - assert_eq!(extract_identity(&claims, &priority), "jdoe"); - - // No matching claim yields an empty identity. - let empty = serde_json::from_value(json!({"unrelated": "x"})).expect("claims map"); - assert_eq!(extract_identity(&empty, &priority), ""); - } - - #[test] - fn build_credential_populates_identity_and_sub() { - let provider = test_provider(); - let token = valid_token(&make_jwt(&json!({ - "email": "user@example.com", - "sub": "subject-1", - }))); - let credential = provider.build_credential("prod", &token); - assert_eq!(credential.identity, "user@example.com"); - assert_eq!(credential.sub, "subject-1"); - assert_eq!(credential.env, "prod"); - assert_eq!(credential.provider, "test"); - } - - #[test] - fn build_credential_populates_scopes_from_stored_token() { - let provider = test_provider(); - let mut token = valid_token(&make_jwt(&json!({"sub": "subject-1"}))); - token.scopes = vec!["a".to_owned(), "b".to_owned()]; - let credential = provider.build_credential("prod", &token); - assert_eq!(credential.scopes, vec!["a", "b"]); - } - - #[test] - fn build_credential_sets_refreshable_when_refresh_token_present() { - let provider = test_provider(); - let mut token = valid_token(&make_jwt(&json!({"sub": "subject-1"}))); - token.refresh_token = Some("a-refresh-token".to_owned()); - let credential = provider.build_credential("prod", &token); - assert!(credential.refreshable); - } - - #[test] - fn build_credential_leaves_refreshable_false_without_refresh_token() { - let provider = test_provider(); - let token = valid_token(&make_jwt(&json!({"sub": "subject-1"}))); - let credential = provider.build_credential("prod", &token); - assert!(!credential.refreshable); - } - - #[test] - fn build_credential_leaves_identity_blank_for_opaque_token() { - let provider = test_provider(); - let token = valid_token("opaque-token"); - let credential = provider.build_credential("prod", &token); - assert_eq!(credential.identity, ""); - assert_eq!(credential.sub, ""); - } - - #[test] - fn with_identity_claims_overrides_selection() { - let provider = test_provider().with_identity_claims(&["custom_user"]); - let token = valid_token(&make_jwt(&json!({ - "email": "ignored@example.com", - "custom_user": "picked", - }))); - let credential = provider.build_credential("prod", &token); - assert_eq!(credential.identity, "picked"); - } - - /// In-memory [`CredentialStorage`] double: lets us assert the provider - /// delegates load/save/delete without any real keychain or filesystem. - #[derive(Debug, Default)] - struct MemoryStorage { - entries: std::sync::Mutex>, - } - - impl MemoryStorage { - fn entry_key(key: &CredentialKey<'_>) -> String { - format!("{}/{}/{}", key.app_id, key.provider, key.env) - } - } - - #[async_trait] - impl CredentialStorage for MemoryStorage { - async fn load(&self, key: &CredentialKey<'_>) -> Option { - self.entries - .lock() - .unwrap_or_else(|e| e.into_inner()) - .get(&Self::entry_key(key)) - .cloned() - } - - async fn save(&self, key: &CredentialKey<'_>, value: &str) -> Result<()> { - self.entries - .lock() - .unwrap_or_else(|e| e.into_inner()) - .insert(Self::entry_key(key), value.to_owned()); - Ok(()) - } - - async fn delete(&self, key: &CredentialKey<'_>) { - self.entries - .lock() - .unwrap_or_else(|e| e.into_inner()) - .remove(&Self::entry_key(key)); - } - } - - #[test] - #[allow(deprecated)] - fn with_file_fallback_maps_to_store_modes() { - assert_eq!( - test_provider().with_file_fallback(true).store_mode, - Some(CredentialStore::Auto) - ); - assert_eq!( - test_provider().with_file_fallback(false).store_mode, - Some(CredentialStore::Keyring) - ); - } - - #[test] - fn builders_record_storage_selection() { - assert_eq!( - test_provider() - .with_credential_store(CredentialStore::File) - .store_mode, - Some(CredentialStore::File) - ); - let provider = test_provider().with_storage(Arc::new(MemoryStorage::default())); - assert!(provider.storage_override.is_some()); - } - - #[tokio::test] - async fn provider_delegates_to_injected_storage() { - let mem = Arc::new(MemoryStorage::default()); - let provider = test_provider().with_app_id("app").with_storage(mem.clone()); - - // No entry yet: status reports not-logged-in. - assert!(provider.status("dev").await.is_err()); - - // Saving routes through the injected store. - provider - .save_stored("dev", &valid_token("tok")) - .await - .expect("save"); - let key = CredentialKey::new("app", "test", "dev"); - assert!(mem.load(&key).await.is_some(), "token reached the store"); - - // And status reads it back. - let cred = provider.status("dev").await.expect("status"); - assert_eq!(cred.token, "tok"); - - // Logout clears it from the store. - provider.logout("dev").await.expect("logout"); - assert!(mem.load(&key).await.is_none(), "token removed on logout"); - } - - #[tokio::test] - async fn corrupt_stored_blob_self_heals() { - let mem = Arc::new(MemoryStorage::default()); - let key = CredentialKey::new("app", "test", "dev"); - mem.save(&key, "not-valid-json").await.expect("seed"); - - let provider = test_provider().with_app_id("app").with_storage(mem.clone()); - assert!(provider.load_stored("dev").await.is_none()); - assert!( - mem.load(&key).await.is_none(), - "corrupt blob should be deleted (self-heal)" - ); - } - - #[tokio::test] - // The guard is intentionally held across awaits to serialize env mutation. - #[allow(clippy::await_holding_lock)] - async fn file_store_round_trips_without_keyring() { - let dir = tempfile::tempdir().expect("tempdir"); - // Hold the shared lock + env guard across the awaits. - let _lock = crate::config::test_env::lock(); - let _env = crate::config::test_env::EnvVarGuard::set("XDG_CONFIG_HOME", Some(dir.path())); - - let provider = test_provider() - .with_app_id("app") - .with_credential_store(CredentialStore::File); - assert!(provider.status("dev").await.is_err()); - provider - .save_stored("dev", &valid_token("filetok")) - .await - .expect("save"); - let cred = provider.status("dev").await.expect("status"); - assert_eq!(cred.token, "filetok"); - } - - /// The fix: a provider wired to a shared `Arc` whose - /// `environments.toml` file layer defines `prod` with a different `client_id` - /// resolves the FILE's client id. This proves the provider's file layer - /// resolves — the shared, app_id-stamped instance reaches the provider rather - /// than an unstamped copy whose file path is `None`. - #[test] - fn wired_provider_resolves_client_id_from_environments_file() { - use crate::environments::{EnvTable, Environments}; - - let dir = tempfile::tempdir().expect("tempdir"); - let file = dir.path().join("environments.toml"); - std::fs::write( - &file, - r#" -[prod] -client_id = "file-prod-client" -"#, - ) - .expect("write environments.toml"); - - let environments = Arc::new( - Environments::new("prod") - .with_app_id("x") - .with_environment( - "prod", - EnvTable::new().with("client_id", "compiled-prod-client"), - ) - .with_config_file_path_override(file), - ); - - let provider = PkceAuthProvider::new( - "godaddy", - "https://base/auth", - "https://base/token", - "base-client", - &["openid"], - ) - .with_environments(environments); - - // The file overrides the compiled client id, which itself overrides the - // provider's base — proving the wired provider reads the file layer. - assert_eq!( - provider - .effective_oauth("prod") - .expect("assembles") - .client_id, - "file-prod-client" - ); - } - - /// A wired provider's resolved environment overrides the base config's - /// client id. - #[test] - fn wired_provider_resolved_env_overrides_base_client_id() { - use crate::environments::{EnvTable, Environments}; - - let environments = Arc::new( - Environments::new("prod") - .with_app_id("x") - .with_environment("prod", EnvTable::new().with("client_id", "env-client")), - ); - let wired = test_provider().with_environments(environments); - assert_eq!( - wired.effective_oauth("prod").expect("assembles").client_id, - "env-client" - ); - } - - /// `client_id`/`auth_url`/`token_url` have no default: a provider whose - /// base config was never given a real client id, and with no environment - /// wired to supply one either, must fail loudly rather than assemble with - /// an empty client id. - #[test] - fn effective_oauth_rejects_a_never_initialized_client_id() { - let provider = PkceAuthProvider::new( - "test", - "https://example.com/auth", - "https://example.com/token", - "", - &["openid"], - ); - let err = provider - .effective_oauth("prod") - .expect_err("a blank client_id with no other tier to supply one must be an error"); - assert!(err.to_string().contains("client_id")); - } -} diff --git a/cli-engine/src/auth/pkce/callback_server.rs b/cli-engine/src/auth/pkce/callback_server.rs new file mode 100644 index 0000000..4a243d2 --- /dev/null +++ b/cli-engine/src/auth/pkce/callback_server.rs @@ -0,0 +1,139 @@ +use std::{io::Write, net::TcpListener, time::Duration}; + +use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; +use rand::Rng; +use sha2::{Digest, Sha256}; + +use crate::{Result, error::CliCoreError}; + +pub(super) fn emit_browser_login_prompt(url: &url::Url) { + let mut stderr = std::io::stderr().lock(); + drop(writeln!(stderr, "Opening browser for authentication…")); + drop(writeln!( + stderr, + "If the browser does not open, visit:\n {url}" + )); +} + +// Printed once the OAuth token is in hand, so a caller who goes on to run a +// long-running command (e.g. a purchase) doesn't mistake that work for the +// browser flow still being pending. See DEVEX-892 / GDDEVPLAT-64. +pub(super) fn emit_auth_complete_message() { + let mut stderr = std::io::stderr().lock(); + drop(writeln!(stderr, "Authentication complete.")); +} + +/// Generates a PKCE code verifier and SHA-256 code challenge. +pub(super) fn pkce_challenge() -> (String, String) { + let bytes: [u8; 32] = rand::rng().random(); + let verifier = URL_SAFE_NO_PAD.encode(bytes); + let hash = Sha256::digest(verifier.as_bytes()); + let challenge = URL_SAFE_NO_PAD.encode(hash); + (verifier, challenge) +} + +/// Generates a random OAuth state parameter. +pub(super) fn random_state() -> String { + let bytes: [u8; 16] = rand::rng().random(); + URL_SAFE_NO_PAD.encode(bytes) +} + +/// Waits for the OAuth callback on the given listener, validates state and path. +/// +/// Accepts connections in a loop so that stray connections (port scanners, +/// browser preflight requests) do not consume the single callback attempt. +/// Uses async I/O so the future is properly cancelled on Ctrl+C. +pub(super) async fn wait_for_callback( + listener: TcpListener, + expected_state: &str, + expected_path: &str, + timeout: Duration, +) -> Result { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + listener + .set_nonblocking(true) + .map_err(|err| CliCoreError::message(format!("callback server setup failed: {err}")))?; + let listener = tokio::net::TcpListener::from_std(listener) + .map_err(|err| CliCoreError::message(format!("callback server setup failed: {err}")))?; + + let expected_state = expected_state.to_owned(); + let expected_path = expected_path.to_owned(); + let result = tokio::time::timeout(timeout, async move { + loop { + let (mut stream, _) = match listener.accept().await { + Ok(conn) => conn, + Err(_) => { + // Back off before retrying so a persistent accept failure + // (e.g. file-descriptor exhaustion) cannot spin the CPU until + // the timeout fires. The sleep is an await point, so Ctrl+C + // still cancels the flow promptly. + tokio::time::sleep(Duration::from_millis(50)).await; + continue; + } + }; + let mut buf = vec![0_u8; 4096]; + let n = match stream.read(&mut buf).await { + Ok(0) | Err(_) => continue, + Ok(n) => n, + }; + let request = String::from_utf8_lossy(&buf[..n]); + + if extract_request_path(&request).as_deref() != Some(expected_path.as_str()) { + drop( + stream + .write_all(b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n") + .await, + ); + continue; + } + + let code = extract_query_param(&request, "code"); + let state = extract_query_param(&request, "state"); + + let html_response = if state.as_deref() == Some(&expected_state) && code.is_some() { + "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\n\r\n\ + Authentication successful. You may close this window." + } else { + "HTTP/1.1 400 Bad Request\r\nContent-Type: text/html\r\n\r\n\ + Authentication failed. Please try again." + }; + drop(stream.write_all(html_response.as_bytes()).await); + + if state.as_deref() == Some(expected_state.as_str()) { + return code + .ok_or_else(|| CliCoreError::message("no authorization code in callback")); + } + } + }) + .await; + + match result { + Ok(inner) => inner, + Err(_) => Err(CliCoreError::message( + "timed out waiting for OAuth callback", + )), + } +} + +/// Extracts the path component from an HTTP request line (without query string). +pub(super) fn extract_request_path(request: &str) -> Option { + let line = request.lines().next()?; + let path_with_query = line.split_whitespace().nth(1)?; + Some( + path_with_query + .split_once('?') + .map_or(path_with_query, |(p, _)| p) + .to_owned(), + ) +} + +/// Extracts a query parameter value from an HTTP request line. +pub(super) fn extract_query_param(request: &str, name: &str) -> Option { + let line = request.lines().next()?; + let path = line.split_whitespace().nth(1)?; + let query = path.split_once('?')?.1; + url::form_urlencoded::parse(query.as_bytes()) + .find(|(key, _)| key == name) + .map(|(_, value)| value.into_owned()) +} diff --git a/cli-engine/src/auth/pkce/mod.rs b/cli-engine/src/auth/pkce/mod.rs new file mode 100644 index 0000000..7e5e038 --- /dev/null +++ b/cli-engine/src/auth/pkce/mod.rs @@ -0,0 +1,842 @@ +//! OAuth 2.0 PKCE authentication provider. +//! +//! Implements the browser-based Authorization Code + PKCE flow (RFC 7636). +//! Tokens are persisted through a pluggable [`CredentialStorage`] backend +//! (see [`crate::auth::storage`]) rather than a hard-wired keychain. By default +//! the backend is resolved from configuration — the `--credential-store` flag, +//! the `${PREFIX}_CREDENTIAL_STORE` env var, the engine config file, or the +//! `keyring` default — so an operator can disable the system keychain on +//! environments where it is unavailable (headless Linux, WSL) without code +//! changes. The three modes are: +//! +//! - `Keyring` (default): system keychain only. +//! - `Auto`: keychain with a transparent unencrypted-file fallback when the +//! keychain backend is unavailable. +//! - `File`: never contact the keychain; store unencrypted JSON under +//! `//credentials/-.json`, where +//! `` is `$XDG_CONFIG_HOME`, `$HOME/Library/Application +//! Support` (macOS), `$HOME/.config` (other Unix), or `%APPDATA%` (Windows). +//! +//! See [`CredentialStore`](crate::config::CredentialStore). A backend can also be +//! injected directly with +//! [`PkceAuthProvider::with_storage`](crate::auth::pkce::PkceAuthProvider::with_storage) +//! or forced with +//! [`PkceAuthProvider::with_credential_store`](crate::auth::pkce::PkceAuthProvider::with_credential_store). +//! +//! # Setup +//! +//! ```no_run +//! use std::sync::Arc; +//! use cli_engine::{CliConfig, auth::pkce::PkceAuthProvider}; +//! +//! let provider = Arc::new(PkceAuthProvider::new( +//! "my-provider", +//! "https://auth.example.com/oauth/authorize", +//! "https://auth.example.com/oauth/token", +//! "my-client-id", +//! &["openid", "profile"], +//! )); +//! +//! let config = CliConfig::new("mycli", "My CLI", "mycli") +//! .with_default_auth_provider("my-provider") +//! .with_auth_provider(provider); +//! ``` +//! +//! For per-environment OAuth config (different client id or endpoints per env), +//! wire the provider to a shared +//! [`Environments`](crate::environments::Environments) with +//! [`PkceAuthProvider::with_environments`](crate::auth::pkce::PkceAuthProvider::with_environments); +//! the resolved environment then drives the OAuth config for the active `env`. +//! A field the resolved environment leaves empty falls back to the base +//! config passed to +//! [`PkceAuthProvider::new`](crate::auth::pkce::PkceAuthProvider::new) — there +//! is no environment-variable override for OAuth fields. + +use std::{collections::HashMap, net::TcpListener, sync::Arc, time::Duration}; + +use async_trait::async_trait; +use chrono::SecondsFormat; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use tokio::sync::RwLock; +use zeroize::{Zeroize, ZeroizeOnDrop}; + +use crate::{ + Credential, Result, + auth::AuthProvider, + auth::CredentialRequest, + auth::storage::{CredentialKey, CredentialStorage, default_storage, storage_for}, + config::CredentialStore, + env_config::{EnvConfig, SourceChain, ValueSource}, + error::CliCoreError, +}; + +mod callback_server; +mod scopes; +#[cfg(test)] +mod tests; + +use callback_server::{ + emit_auth_complete_message, emit_browser_login_prompt, pkce_challenge, random_state, + wait_for_callback, +}; +pub use scopes::ScopeHierarchy; +use scopes::{ + StepUp, decode_jwt_claims, ensure_granted, extract_identity, granted_scopes, + parse_token_response, plan_step_up, union_scopes, +}; + +const REDIRECT_PORT_DEFAULT: u16 = 7443; +const TOKEN_EXPIRY_BUFFER_SECS: i64 = 30; +/// Default timeout applied to OAuth token-endpoint requests (exchange/refresh) +/// so a stalled token server cannot hang the CLI indefinitely. +const TOKEN_REQUEST_TIMEOUT_DEFAULT: Duration = Duration::from_secs(30); + +/// Stored token with expiry tracking. +/// +/// Token fields are zeroized on drop to limit in-memory exposure. +#[derive(Clone, Serialize, Deserialize, Zeroize, ZeroizeOnDrop)] +struct StoredToken { + access_token: String, + expires_at: i64, + refresh_token: Option, + /// Scopes the token was obtained with (granted by the authorization server, + /// or the requested set when the server does not echo `scope`). Lets scope + /// coverage work for opaque access tokens and IdPs that do not expose scopes + /// in the access token itself. Not secret, so excluded from zeroization. + /// + /// `#[serde(default)]` keeps tokens written before this field was added + /// loadable from the keychain (they decode with an empty set, falling back to + /// the JWT `scope`/`scp` claim as before). + #[serde(default)] + #[zeroize(skip)] + scopes: Vec, +} + +impl std::fmt::Debug for StoredToken { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("StoredToken") + .field("access_token", &"[redacted]") + .field("expires_at", &self.expires_at) + .field( + "refresh_token", + if self.refresh_token.is_some() { + &"Some([redacted])" + } else { + &"None" + }, + ) + .field("scopes", &self.scopes) + .finish() + } +} + +impl StoredToken { + fn is_valid(&self) -> bool { + let now = chrono::Utc::now().timestamp(); + self.expires_at - TOKEN_EXPIRY_BUFFER_SECS > now + } +} + +/// The effective OAuth values for an environment. No default on +/// `client_id`/`auth_url`/`token_url`: a provider whose base config and +/// resolved environment both leave one of these blank must fail loudly +/// (`EnvConfigError::MissingField`), not silently assemble with an empty +/// endpoint. `scopes` is different — an empty scope list is a normal, +/// supported configuration (see [`PkceAuthProvider::effective_scopes`]), not +/// a sign of a never-initialized provider. +#[derive(Debug, Clone, Default, EnvConfig)] +struct OAuthSection { + client_id: String, + auth_url: String, + token_url: String, + #[env_config(default = Vec::new())] + scopes: Vec, +} + +/// OAuth 2.0 PKCE authentication provider. +/// +/// Stores one token per `(env, provider)` pair in the system keychain. +/// The keychain service name is `//`. +#[derive(Debug)] +pub struct PkceAuthProvider { + name: String, + auth_url: String, + token_url: String, + client_id: String, + scopes: Vec, + /// Optional environment resolver; when set, per-env OAuth config comes from + /// the resolved environment instead of the base config passed to + /// [`PkceAuthProvider::new`]. Looked up by the `env` passed to + /// [`AuthProvider::get_credential`]. + environments: Option>, + redirect_port: u16, + redirect_uri: Option, + /// Timeout applied to token-endpoint requests (exchange and refresh). + token_timeout: Duration, + /// Shared HTTP client for token-endpoint traffic, built once and reused by + /// exchange and refresh so connections and TLS configuration are pooled + /// rather than rebuilt per request. The user-agent and timeout are applied + /// per request (not baked into the client) so they reflect the value + /// published at execution time, not at provider construction. + client: reqwest::Client, + app_id: String, + /// Explicit storage backend injected via [`PkceAuthProvider::with_storage`]. + /// Wins over `store_mode` and the config-driven default. + storage_override: Option>, + /// Explicit storage mode from [`PkceAuthProvider::with_credential_store`]. + /// Forces a built-in backend, bypassing flag/env/config resolution. + store_mode: Option, + /// Lazily-resolved storage backend. Built on first use so `--schema` / + /// `--dry-run` (which never resolve a credential) touch no keychain/config. + storage: tokio::sync::OnceCell>, + /// Prioritized JWT claim names used to derive `Credential.identity` from the + /// decoded access-token payload. First non-empty string claim wins. + identity_claims: Vec, + /// In-process token cache keyed by env. + cache: Arc>>, + /// Scope implication relationships from [`PkceAuthProvider::with_scope_hierarchy`]. + /// Empty by default, which preserves exact-string scope matching. + scope_hierarchy: ScopeHierarchy, +} + +/// Default prioritized claim names for deriving a human-readable identity. +const DEFAULT_IDENTITY_CLAIMS: &[&str] = + &["email", "preferred_username", "username", "name", "sub"]; + +impl PkceAuthProvider { + /// Creates a new PKCE provider. + /// + /// - `name`: Provider registration name (e.g. `"primary"`) + /// - `auth_url`: Authorization endpoint + /// - `token_url`: Token endpoint + /// - `client_id`: OAuth client ID + /// - `scopes`: Default OAuth scopes + #[must_use] + pub fn new( + name: impl Into, + auth_url: impl Into, + token_url: impl Into, + client_id: impl Into, + scopes: &[impl AsRef], + ) -> Self { + Self { + name: name.into(), + auth_url: auth_url.into(), + token_url: token_url.into(), + client_id: client_id.into(), + scopes: scopes.iter().map(|s| s.as_ref().to_owned()).collect(), + environments: None, + redirect_port: REDIRECT_PORT_DEFAULT, + redirect_uri: None, + token_timeout: TOKEN_REQUEST_TIMEOUT_DEFAULT, + client: reqwest::Client::new(), + app_id: String::new(), + storage_override: None, + store_mode: None, + storage: tokio::sync::OnceCell::new(), + identity_claims: DEFAULT_IDENTITY_CLAIMS + .iter() + .map(|claim| (*claim).to_owned()) + .collect(), + cache: Arc::new(RwLock::new(HashMap::new())), + scope_hierarchy: ScopeHierarchy::new(), + } + } + + /// Sources per-environment OAuth config from a shared + /// [`Environments`](crate::environments::Environments). + /// + /// Given an `env`, every OAuth-driven method on this provider resolves its + /// OAuth config from two tiers, highest priority first: the resolved + /// environment's own TOML value, then this provider's base configuration + /// from [`PkceAuthProvider::new`]. There is no environment-variable + /// override for either tier. Prefer wiring an + /// [`Environments`](crate::environments::Environments) over relying on + /// the base `client_id`/`auth_url`/`token_url` when the consumer registers + /// environments via + /// [`CliConfig::with_environments`](crate::CliConfig::with_environments) — + /// it's the single-source-of-truth path. + /// + /// A field absent from the resolved environment falls through to the + /// base config, so a partial environment can override only the client id + /// while inheriting the provider's base endpoints. + /// + /// # Examples + /// + /// ``` + /// use std::sync::Arc; + /// use cli_engine::{ + /// auth::pkce::PkceAuthProvider, + /// environments::{EnvTable, Environments}, + /// }; + /// + /// let environments = Arc::new( + /// Environments::new("prod").with_environment( + /// "dev", + /// EnvTable::new() + /// .with("client_id", "dev-client-id") + /// .with("auth_url", "https://api.dev-godaddy.com/v2/oauth2/authorize") + /// .with("token_url", "https://api.dev-godaddy.com/v2/oauth2/token"), + /// ), + /// ); + /// + /// let provider = PkceAuthProvider::new( + /// "godaddy", + /// "https://api.godaddy.com/v2/oauth2/authorize", + /// "https://api.godaddy.com/v2/oauth2/token", + /// "prod-client-id", + /// &["openid", "profile"], + /// ) + /// .with_environments(environments); + /// # let _ = provider; + /// ``` + #[must_use] + pub fn with_environments( + mut self, + environments: Arc, + ) -> Self { + self.environments = Some(environments); + self + } + + /// Sets the local redirect server port (default: 7443). + #[must_use] + pub fn with_redirect_port(mut self, port: u16) -> Self { + self.redirect_port = port; + self + } + + /// Sets the timeout applied to token-endpoint requests (authorization-code + /// exchange and refresh). + /// + /// Defaults to 30 seconds. This bounds only the HTTP token requests; the + /// interactive browser/callback wait has its own separate timeout. + #[must_use] + pub fn with_token_timeout(mut self, timeout: Duration) -> Self { + self.token_timeout = timeout; + self + } + + /// Overrides the redirect URI sent to the authorization server. + /// + /// By default the redirect URI is `http://127.0.0.1:{port}/callback`. Use + /// this when the OAuth client is allowlisted with a different URI, such as + /// `http://localhost:{port}/callback`. The local listener always binds to + /// `127.0.0.1` regardless of what is set here. + #[must_use] + pub fn with_redirect_uri(mut self, uri: impl Into) -> Self { + self.redirect_uri = Some(uri.into()); + self + } + + /// Sets the application id used as the keychain service prefix. + #[must_use] + pub fn with_app_id(mut self, app_id: impl Into) -> Self { + self.app_id = app_id.into(); + self + } + + /// Adds extra scopes beyond the default set. + #[must_use] + pub fn with_extra_scopes(mut self, scopes: &[impl AsRef]) -> Self { + self.scopes + .extend(scopes.iter().map(|s| s.as_ref().to_owned())); + self + } + + /// Injects a custom credential storage backend. + /// + /// Takes precedence over [`with_credential_store`](Self::with_credential_store) + /// and the config-driven default. Use this to plug in a bespoke + /// [`CredentialStorage`] (for example an in-memory store in tests, or a + /// remote secret manager). + #[must_use] + pub fn with_storage(mut self, storage: Arc) -> Self { + self.storage_override = Some(storage); + self + } + + /// Forces a built-in credential storage mode, bypassing the + /// flag/env/config resolution. + /// + /// Use [`CredentialStore::File`] to skip the system keychain entirely (the + /// escape hatch for headless Linux / WSL), [`CredentialStore::Auto`] for a + /// keychain-with-file-fallback, or [`CredentialStore::Keyring`] for + /// keychain-only. When unset, the mode is resolved per + /// [`crate::config::resolve_credential_store`]. + #[must_use] + pub fn with_credential_store(mut self, mode: CredentialStore) -> Self { + self.store_mode = Some(mode); + self + } + + /// Enables a file-based fallback when the system keychain is unavailable + /// (e.g. headless Linux / WSL without a running secret-service daemon). + /// + /// `true` maps to [`CredentialStore::Auto`] and `false` to + /// [`CredentialStore::Keyring`]. + #[must_use] + #[deprecated( + since = "0.3.0", + note = "use with_credential_store(CredentialStore::Auto) or (CredentialStore::Keyring)" + )] + pub fn with_file_fallback(self, enabled: bool) -> Self { + self.with_credential_store(if enabled { + CredentialStore::Auto + } else { + CredentialStore::Keyring + }) + } + + /// Overrides the prioritized JWT claim names used to derive + /// [`Credential::identity`](crate::Credential) from the decoded access-token + /// payload. + /// + /// The first claim whose value is a non-empty string wins. The default order + /// is `email`, `preferred_username`, `username`, `name`, `sub`. Use this when + /// the identity provider exposes the human identity under a non-standard + /// claim name. + #[must_use] + pub fn with_identity_claims(mut self, claims: &[impl AsRef]) -> Self { + self.identity_claims = claims.iter().map(|c| c.as_ref().to_owned()).collect(); + self + } + + /// Declares scope implication relationships (for example, a granted + /// `admin` scope covering a required `read` scope) so step-up only + /// re-authenticates when the current token genuinely lacks a required + /// scope. + /// + /// Empty by default, which preserves exact-string scope matching. + #[must_use] + pub fn with_scope_hierarchy(mut self, hierarchy: ScopeHierarchy) -> Self { + self.scope_hierarchy = hierarchy; + self + } + + /// Builds a [`Credential`] from a stored token, deriving `identity` and `sub` + /// from the access-token JWT claims when present. + fn build_credential(&self, env: &str, token: &StoredToken) -> Credential { + let claims = decode_jwt_claims(&token.access_token); + let identity = claims + .as_ref() + .map(|claims| extract_identity(claims, &self.identity_claims)) + .unwrap_or_default(); + let sub = claims + .as_ref() + .and_then(|claims| claims.get("sub")) + .and_then(Value::as_str) + .unwrap_or_default() + .to_owned(); + Credential { + token: token.access_token.clone(), + env: env.to_owned(), + provider: self.name.clone(), + expires_at: chrono::DateTime::from_timestamp(token.expires_at, 0) + .map(|dt| dt.to_rfc3339_opts(SecondsFormat::Secs, true)) + .unwrap_or_default(), + identity, + sub, + scopes: granted_scopes(token), + refreshable: token.refresh_token.is_some(), + ..Credential::default() + } + } + + /// Computes the effective OAuth config for `env` with a SINGLE environment + /// resolution (at most one `environments.toml` read), by assembling an + /// [`OAuthSection`] from a two-tier [`SourceChain`], highest priority + /// first: + /// + /// 1. The resolved environment's own TOML value (compiled + file layers). + /// 2. This provider's own base config, from [`PkceAuthProvider::new`]. + /// + /// Token flows call this once and reuse the result so they don't re-read the + /// environments file once per field. + /// + /// # Errors + /// + /// Returns an error if a present TOML value fails to convert to its + /// field's type (for example a non-array `scopes` value), or if + /// `client_id`/`auth_url`/`token_url` is blank in *both* tiers — a + /// provider whose base config was never given a real value, and whose + /// resolved environment (if any) doesn't supply one either, fails loudly + /// rather than assembling with an empty endpoint. + fn effective_oauth(&self, env: &str) -> Result { + let env_source = + self.environments + .as_ref() + .and_then(|environments| match environments.source(env) { + Ok(source) => Some(source), + Err(err) => { + tracing::debug!( + env, + error = %err, + "environment resolve failed; falling back to base OAuth config" + ); + None + } + }); + let base = ValueSource::new() + .with("client_id", self.client_id.clone()) + .with("auth_url", self.auth_url.clone()) + .with("token_url", self.token_url.clone()) + .with("scopes", self.scopes.clone()); + + let mut chain = SourceChain::new(); + if let Some(env_source) = &env_source { + chain = chain.push(env_source); + } + chain = chain.push(&base); + + OAuthSection::assemble(&chain).map_err(CliCoreError::from) + } + + /// Default scopes for `env`: the resolved environment's scopes when + /// non-empty, otherwise the provider's base scopes. + /// + /// # Errors + /// + /// See [`effective_oauth`](Self::effective_oauth). + fn effective_scopes(&self, env: &str) -> Result> { + Ok(self.effective_oauth(env)?.scopes) + } + + fn effective_redirect_uri(&self) -> String { + self.redirect_uri + .clone() + .unwrap_or_else(|| format!("http://127.0.0.1:{}/callback", self.redirect_port)) + } + + /// Parses the effective redirect URI and returns `(bind_port, callback_path)`. + fn parse_redirect_uri(&self) -> Result<(u16, String)> { + let uri_str = self.effective_redirect_uri(); + let parsed = url::Url::parse(&uri_str) + .map_err(|e| CliCoreError::message(format!("invalid redirect URI '{uri_str}': {e}")))?; + let port = parsed + .port() + .or_else(|| parsed.port_or_known_default()) + .ok_or_else(|| { + CliCoreError::message(format!("redirect URI '{uri_str}' has no port")) + })?; + let path = parsed.path().to_owned(); + Ok((port, path)) + } + + /// Builds the storage key for this provider and `env`. + fn credential_key<'key>(&'key self, env: &'key str) -> CredentialKey<'key> { + CredentialKey::new(&self.app_id, &self.name, env) + } + + /// Returns the credential storage backend, resolving and caching it on first + /// use. Precedence: an injected [`with_storage`](Self::with_storage) backend, + /// then a forced [`with_credential_store`](Self::with_credential_store) mode, + /// then the config-driven [`default_storage`]. + /// + /// Resolution is lazy so paths that never resolve a credential (`--schema`, + /// `--dry-run`) build no storage and touch neither the keychain nor config. + async fn storage(&self) -> &Arc { + self.storage + .get_or_init(async || { + if let Some(storage) = &self.storage_override { + storage.clone() + } else if let Some(mode) = self.store_mode { + storage_for(mode) + } else { + default_storage(&self.app_id) + } + }) + .await + } + + /// Loads and deserializes the stored token for `env`, if present. + /// + /// On a corrupt/undecodable blob, best-effort deletes it (self-heal) and + /// returns `None` so the caller re-authenticates rather than looping on the + /// bad entry. + async fn load_stored(&self, env: &str) -> Option { + let key = self.credential_key(env); + let raw = self.storage().await.load(&key).await?; + match serde_json::from_str::(&raw) { + Ok(token) => Some(token), + Err(e) => { + tracing::warn!(env, error = %e, "stored token JSON invalid; clearing"); + self.storage().await.delete(&key).await; + None + } + } + } + + /// Serializes and persists `token` for `env` via the storage backend. + async fn save_stored(&self, env: &str, token: &StoredToken) -> Result<()> { + let json = serde_json::to_string(token).map_err(CliCoreError::from)?; + let key = self.credential_key(env); + self.storage().await.save(&key, &json).await + } + + /// Removes any stored token for `env` via the storage backend. + async fn delete_stored(&self, env: &str) { + let key = self.credential_key(env); + self.storage().await.delete(&key).await; + } + + async fn cached_token(&self, env: &str) -> Option { + let cache = self.cache.read().await; + cache.get(env).filter(|t| t.is_valid()).cloned() + } + + async fn store_cached_token(&self, env: &str, token: StoredToken) { + let mut cache = self.cache.write().await; + cache.insert(env.to_owned(), token); + } + + async fn resolve_token(&self, env: &str) -> Result { + if let Some(token) = self.existing_token(env).await? { + return Ok(token); + } + let scopes = self.effective_scopes(env)?; + self.reauthenticate(env, &scopes).await + } + + /// Returns a usable token from the in-memory cache, keychain, or a refresh — + /// **without** launching an interactive PKCE flow. `None` means the caller + /// must authenticate. Keeping this flow-free lets `get_credential_for` decide + /// the scope set for a single login instead of authenticating twice. + async fn existing_token(&self, env: &str) -> Result> { + if let Some(token) = self.cached_token(env).await { + return Ok(Some(token)); + } + if let Some(token) = self.load_stored(env).await { + if token.is_valid() { + self.store_cached_token(env, token.clone()).await; + return Ok(Some(token)); + } + if let Some(refresh_token) = token.refresh_token.as_deref() + && let Ok(mut refreshed) = self + .refresh_access_token(env, refresh_token, &token.scopes) + .await + { + if refreshed.refresh_token.is_none() { + refreshed.refresh_token = Some(refresh_token.to_owned()); + } + self.save_stored(env, &refreshed).await?; + self.store_cached_token(env, refreshed.clone()).await; + return Ok(Some(refreshed)); + } + } + Ok(None) + } + + /// Runs a fresh interactive PKCE flow requesting exactly `scopes`, replacing + /// any stored token for `env`. + async fn reauthenticate(&self, env: &str, scopes: &[String]) -> Result { + let token = self.run_pkce_flow_with(env, scopes).await?; + // Persist first — the keychain write overwrites the existing entry for + // this env — and only update the in-memory cache after a successful + // save. This avoids destroying a still-valid token if the save fails + // (e.g. keychain unavailable and file fallback disabled). + self.save_stored(env, &token).await?; + self.store_cached_token(env, token.clone()).await; + Ok(token) + } + + /// Runs the browser PKCE flow requesting exactly `scopes` (used both for the + /// default login and for scope step-up, which requests a wider union). + async fn run_pkce_flow_with(&self, env: &str, scopes: &[String]) -> Result { + let (code_verifier, code_challenge) = pkce_challenge(); + let state = random_state(); + // Resolve the OAuth config once for this whole flow (authorize + exchange). + let oauth = self.effective_oauth(env)?; + let redirect_uri = self.effective_redirect_uri(); + let scope = scopes.join(" "); + + let auth_params = [ + ("response_type", "code"), + ("client_id", &oauth.client_id), + ("redirect_uri", &redirect_uri), + ("scope", &scope), + ("state", &state), + ("code_challenge", &code_challenge), + ("code_challenge_method", "S256"), + ]; + let url = url::Url::parse_with_params(&oauth.auth_url, &auth_params) + .map_err(|err| CliCoreError::message(format!("invalid auth URL: {err}")))?; + + let (bind_port, callback_path) = self.parse_redirect_uri()?; + + // Start the local callback server before opening the browser so the + // redirect lands as soon as the user approves. + let listener = TcpListener::bind(std::net::SocketAddr::from(([127, 0, 0, 1], bind_port))) + .map_err(|err| { + CliCoreError::message(format!( + "failed to bind callback server on port {bind_port}: {err}" + )) + })?; + + emit_browser_login_prompt(&url); + drop(open::that(url.as_str())); + + let code = + wait_for_callback(listener, &state, &callback_path, Duration::from_secs(120)).await?; + let token = self + .exchange_code_for_token(&oauth, &code, &code_verifier, scopes) + .await?; + emit_auth_complete_message(); + Ok(token) + } + + /// Builds a POST to an OAuth token endpoint on the provider's shared client. + /// + /// Token traffic does not go through [`HttpClient`](crate::transport::HttpClient) + /// — that client is built for authenticated, JSON-bodied backend calls, + /// whereas the token endpoint is unauthenticated and form-encoded. The + /// user-agent and timeout are attached here per request (read at call time) + /// so every outbound call, including credential acquisition and refresh, is + /// attributed consistently and bounded. + fn token_request(&self, token_url: &str, params: &[(&str, &str)]) -> reqwest::RequestBuilder { + self.client + .post(token_url) + .header( + reqwest::header::USER_AGENT, + crate::transport::client::default_user_agent(), + ) + .timeout(self.token_timeout) + .form(params) + } + + async fn exchange_code_for_token( + &self, + oauth: &OAuthSection, + code: &str, + code_verifier: &str, + requested_scopes: &[String], + ) -> Result { + let redirect_uri = self.effective_redirect_uri(); + + let params = [ + ("grant_type", "authorization_code"), + ("client_id", &oauth.client_id), + ("redirect_uri", &redirect_uri), + ("code", code), + ("code_verifier", code_verifier), + ]; + let response = self + .token_request(&oauth.token_url, ¶ms) + .send() + .await + .map_err(|err| CliCoreError::message(format!("token request failed: {err}")))?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(CliCoreError::message(format!( + "token endpoint returned {status}: {body}" + ))); + } + + parse_token_response(response, requested_scopes).await + } + + async fn refresh_access_token( + &self, + env: &str, + refresh_token: &str, + prior_scopes: &[String], + ) -> Result { + let oauth = self.effective_oauth(env)?; + let params = [ + ("grant_type", "refresh_token"), + ("client_id", &oauth.client_id), + ("refresh_token", refresh_token), + ]; + let response = self + .token_request(&oauth.token_url, ¶ms) + .send() + .await + .map_err(|err| CliCoreError::message(format!("token refresh failed: {err}")))?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(CliCoreError::message(format!( + "refresh endpoint returned {status}: {body}" + ))); + } + + parse_token_response(response, prior_scopes).await + } +} + +#[async_trait] +impl AuthProvider for PkceAuthProvider { + fn name(&self) -> &str { + &self.name + } + + async fn get_credential(&self, env: &str, _command: &str, _tier: &str) -> Result { + let token = self.resolve_token(env).await?; + Ok(self.build_credential(env, &token)) + } + + async fn get_credential_for(&self, req: &CredentialRequest<'_>) -> Result { + let env = req.env; + let required = &req.meta.scopes; + + // Look for a usable token WITHOUT launching a flow, so we can pick the + // scope set for a single login rather than authenticating twice (e.g. + // `auth login --scope X` logs out first; resolving defaults and then + // stepping up would open the browser twice). + if let Some(token) = self.existing_token(env).await? { + // Decide based on what the token grants (JWT claim plus the scopes it + // was obtained with). + let granted = granted_scopes(&token); + match plan_step_up(&granted, required, &self.scope_hierarchy) { + StepUp::Covered => return Ok(self.build_credential(env, &token)), + // Step-up is re-consent: the authorization server has no silent + // scope-expansion grant, so acquire the missing scopes with a + // fresh login — the same browser flow the no-token path runs + // below, rather than failing when stdio is not a TTY. Resolve + // per-env defaults only now (off the cached-token hot path) and + // request defaults ∪ already-granted ∪ required so step-up never + // drops previously-acquired scopes. + StepUp::Reauthenticate => { + let union = union_scopes(&self.effective_scopes(env)?, &granted, required); + let token = self.reauthenticate(env, &union).await?; + ensure_granted(env, &token, required, &self.scope_hierarchy)?; + return Ok(self.build_credential(env, &token)); + } + } + } + + // No usable token: authenticate once, requesting defaults ∪ required. + let union = union_scopes(&self.effective_scopes(env)?, &[], required); + let token = self.reauthenticate(env, &union).await?; + ensure_granted(env, &token, required, &self.scope_hierarchy)?; + Ok(self.build_credential(env, &token)) + } + + async fn status(&self, env: &str) -> Result { + let Some(token) = self.load_stored(env).await else { + return Err(CliCoreError::message(format!( + "not logged in for environment {env:?}" + ))); + }; + Ok(self.build_credential(env, &token)) + } + + async fn logout(&self, env: &str) -> Result<()> { + self.delete_stored(env).await; + let mut cache = self.cache.write().await; + cache.remove(env); + Ok(()) + } + + async fn list_environments(&self) -> Result> { + // Keyring and file-fallback storage do not support listing; return only + // the in-memory cache keys as a hint. Tokens that survived a restart via + // file fallback are not enumerated here. + let cache = self.cache.read().await; + Ok(cache.keys().cloned().collect()) + } +} diff --git a/cli-engine/src/auth/pkce/scopes.rs b/cli-engine/src/auth/pkce/scopes.rs new file mode 100644 index 0000000..e4f61b3 --- /dev/null +++ b/cli-engine/src/auth/pkce/scopes.rs @@ -0,0 +1,267 @@ +use std::collections::{HashMap, HashSet}; + +use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; +use serde::Deserialize; +use serde_json::{Map, Value}; + +use super::StoredToken; +use crate::{Result, error::CliCoreError}; + +#[derive(Debug, Deserialize)] +pub(super) struct TokenResponse { + access_token: String, + expires_in: Option, + refresh_token: Option, + /// Space-delimited scopes the server actually granted, when it echoes them. + scope: Option, +} + +/// Decodes the claims (payload) segment of a JWT **without verifying the +/// signature**. +/// +/// The returned claims are used to display a human-readable identity in +/// `auth status` and audit logs, and (via [`scopes_from_jwt`]) to decide whether +/// scope step-up needs a fresh login. These are convenience/optimization reads, +/// **not** trust or authorization decisions — the authorization server remains +/// the source of truth for granted scopes — so signature verification is +/// intentionally skipped. Opaque (non-JWT) tokens and any decode/parse failure +/// yield `None`, leaving the identity blank (and treating scopes as absent, which +/// just forces a re-auth). +pub(super) fn decode_jwt_claims(token: &str) -> Option> { + // A JWT is `header.payload.signature`; the payload is the middle segment, + // base64url-encoded without padding. + let payload = token.split('.').nth(1)?; + let bytes = URL_SAFE_NO_PAD.decode(payload).ok()?; + serde_json::from_slice(&bytes).ok() +} + +/// Returns `defaults ∪ granted ∪ required`, order-preserving and de-duplicated. +pub(super) fn union_scopes( + defaults: &[String], + granted: &[String], + required: &[String], +) -> Vec { + let mut union = defaults.to_vec(); + for scope in granted.iter().chain(required.iter()) { + if !union.contains(scope) { + union.push(scope.clone()); + } + } + union +} + +/// Reads the granted scopes from a JWT access token. +/// +/// OAuth uses a space-delimited `scope` string (RFC), but some IdPs (e.g. Azure +/// AD) use `scp`, and either may be encoded as a JSON array — so all of those +/// forms are accepted. Returns an empty list for opaque (non-JWT) tokens or +/// tokens without a recognized scope claim; coverage then falls back to the +/// scopes recorded on the [`StoredToken`] (see [`granted_scopes`]). +pub(super) fn scopes_from_jwt(token: &str) -> Vec { + let Some(claims) = decode_jwt_claims(token) else { + return Vec::new(); + }; + for key in ["scope", "scp"] { + if let Some(value) = claims.get(key) { + let scopes = scopes_from_claim(value); + if !scopes.is_empty() { + return scopes; + } + } + } + Vec::new() +} + +/// Parses a scope claim that may be a space-delimited string or a JSON array of +/// (possibly space-delimited) strings. +fn scopes_from_claim(value: &Value) -> Vec { + match value { + Value::String(scope) => scope.split_whitespace().map(str::to_owned).collect(), + Value::Array(items) => items + .iter() + .filter_map(Value::as_str) + .flat_map(str::split_whitespace) + .map(str::to_owned) + .collect(), + _ => Vec::new(), + } +} + +/// Scope implication relationships for an identity provider whose scopes +/// nest (for example, a granted `write` scope also covering `read`). +/// +/// Attach one to a [`PkceAuthProvider`](super::PkceAuthProvider) with +/// [`with_scope_hierarchy`](super::PkceAuthProvider::with_scope_hierarchy) so +/// scope coverage checks stop treating scopes as opaque, unrelated strings. +/// Empty by default, which falls back to exact-string matching — today's +/// behavior. +#[derive(Debug, Default, Clone)] +pub struct ScopeHierarchy { + implies: HashMap>, +} + +impl ScopeHierarchy { + /// Creates an empty hierarchy, equivalent to exact-string scope matching. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Declares that holding `scope` also satisfies every scope in `implied`. + /// + /// Implications compose transitively: if `admin` implies `write` and + /// `write` implies `read`, a granted `admin` scope covers a required + /// `read` scope. + #[must_use] + pub fn with_implication( + mut self, + scope: impl Into, + implied: &[impl AsRef], + ) -> Self { + self.implies + .entry(scope.into()) + .or_default() + .extend(implied.iter().map(|s| s.as_ref().to_owned())); + self + } + + /// True if `required` is present in `granted` verbatim, or transitively + /// implied by something in `granted`. + pub(super) fn covers(&self, granted: &[String], required: &str) -> bool { + let mut queue: Vec<&str> = granted.iter().map(String::as_str).collect(); + let mut visited: HashSet<&str> = HashSet::new(); + while let Some(scope) = queue.pop() { + if scope == required { + return true; + } + if !visited.insert(scope) { + continue; + } + if let Some(implied) = self.implies.get(scope) { + queue.extend(implied.iter().map(String::as_str)); + } + } + false + } +} + +/// All scopes an access token is known to carry: the JWT `scope`/`scp` claim +/// plus the scopes recorded when the token was obtained. The recorded scopes +/// make coverage work for opaque tokens and IdPs that omit scopes from the +/// access token. +pub(super) fn granted_scopes(token: &StoredToken) -> Vec { + let mut scopes = scopes_from_jwt(&token.access_token); + for scope in &token.scopes { + if !scopes.contains(scope) { + scopes.push(scope.clone()); + } + } + scopes +} + +/// The action scope step-up should take for a token, given what it already +/// grants and what the command requires. Pure so the decision is unit-testable +/// without real TTY detection or a browser flow. +#[derive(Debug, PartialEq, Eq)] +pub(super) enum StepUp { + /// The token already covers every required scope. + Covered, + /// Re-authenticate to acquire the missing scopes. The caller builds the + /// requested set (defaults ∪ granted ∪ required) only on this path, so + /// resolving per-env default scopes stays off the cached-token hot path. + Reauthenticate, +} + +/// Decides the step-up action from what the token grants versus what the command +/// requires. Deliberately does NOT take the per-env default scopes: the coverage +/// decision needs only `granted`/`required`, so the caller can avoid resolving +/// defaults (potential `environments.toml` I/O) when a cached token already +/// covers the requirement. +pub(super) fn plan_step_up( + granted: &[String], + required: &[String], + hierarchy: &ScopeHierarchy, +) -> StepUp { + let covered = required + .iter() + .all(|scope| hierarchy.covers(granted, scope.as_str())); + if covered { + StepUp::Covered + } else { + StepUp::Reauthenticate + } +} + +/// Confirms a freshly (re)authenticated token actually grants `required`. +/// +/// Re-consent does not guarantee the authorization server grants every requested +/// scope (it may decline by policy). When the difference is detectable — the +/// token is a JWT exposing its scopes, or the token response echoed a narrower +/// `scope` — return a clear error instead of handing back an under-scoped token +/// that the API would later reject with a 403, and instead of re-prompting in a +/// loop the server will keep refusing. (For opaque tokens whose grant the server +/// does not echo, the recorded scopes equal what was requested, so an undetected +/// decline still surfaces downstream as a 403.) +pub(super) fn ensure_granted( + env: &str, + token: &StoredToken, + required: &[String], + hierarchy: &ScopeHierarchy, +) -> Result<()> { + let granted = granted_scopes(token); + let missing: Vec = required + .iter() + .filter(|scope| !hierarchy.covers(&granted, scope.as_str())) + .cloned() + .collect(); + if missing.is_empty() { + Ok(()) + } else { + Err(CliCoreError::message(format!( + "authorization server did not grant required scope(s) for {env:?}: {}", + missing.join(", ") + ))) + } +} + +/// Returns the first claim value that is a non-empty string, in priority order. +pub(super) fn extract_identity(claims: &Map, priority: &[String]) -> String { + priority + .iter() + .filter_map(|name| claims.get(name).and_then(Value::as_str)) + .find(|value| !value.is_empty()) + .unwrap_or_default() + .to_owned() +} + +pub(super) async fn parse_token_response( + response: reqwest::Response, + requested_scopes: &[String], +) -> Result { + let body: TokenResponse = response + .json() + .await + .map_err(|err| CliCoreError::message(format!("failed to parse token response: {err}")))?; + let expires_in = body.expires_in.unwrap_or(3600); + let expires_at = chrono::Utc::now().timestamp() + expires_in; + // Record what the token grants: the server's echoed `scope` when present, + // otherwise the scopes we asked for. This is the coverage signal for opaque + // tokens, which carry no readable scope claim. + let scopes = body + .scope + .as_deref() + .map(|scope| { + scope + .split_whitespace() + .map(str::to_owned) + .collect::>() + }) + .filter(|scopes| !scopes.is_empty()) + .unwrap_or_else(|| requested_scopes.to_vec()); + Ok(StoredToken { + access_token: body.access_token, + expires_at, + refresh_token: body.refresh_token, + scopes, + }) +} diff --git a/cli-engine/src/auth/pkce/tests.rs b/cli-engine/src/auth/pkce/tests.rs new file mode 100644 index 0000000..b94c930 --- /dev/null +++ b/cli-engine/src/auth/pkce/tests.rs @@ -0,0 +1,866 @@ +use std::sync::Arc; +use std::time::Duration; + +use async_trait::async_trait; +use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; +use chrono::Utc; +use serde_json::{Value, json}; + +use super::callback_server::{extract_query_param, extract_request_path}; +use super::scopes::{ + ScopeHierarchy, StepUp, decode_jwt_claims, ensure_granted, extract_identity, granted_scopes, + plan_step_up, scopes_from_jwt, union_scopes, +}; +use super::{DEFAULT_IDENTITY_CLAIMS, PkceAuthProvider, StoredToken, TOKEN_EXPIRY_BUFFER_SECS}; +use crate::CredentialRequest; +use crate::auth::AuthProvider; +use crate::auth::storage::{CredentialKey, CredentialStorage}; +use crate::config::CredentialStore; +use std::collections::HashMap; + +fn test_provider() -> PkceAuthProvider { + PkceAuthProvider::new( + "test", + "https://example.com/auth", + "https://example.com/token", + "client-id", + &["openid"], + ) +} + +fn valid_token(access_token: &str) -> StoredToken { + StoredToken { + access_token: access_token.to_owned(), + expires_at: Utc::now().timestamp() + 3600, + refresh_token: None, + scopes: Vec::new(), + } +} + +fn token_with_scopes(access_token: &str, scopes: &[&str]) -> StoredToken { + // No struct-update from `valid_token`: StoredToken is `Drop` + // (ZeroizeOnDrop), so fields cannot be moved out of another instance. + StoredToken { + access_token: access_token.to_owned(), + expires_at: Utc::now().timestamp() + 3600, + refresh_token: None, + scopes: scopes.iter().map(|s| (*s).to_owned()).collect(), + } +} + +fn expired_token() -> StoredToken { + StoredToken { + access_token: "old-token".to_owned(), + // Older than the expiry buffer so is_valid() returns false. + expires_at: Utc::now().timestamp() - TOKEN_EXPIRY_BUFFER_SECS - 1, + refresh_token: None, + scopes: Vec::new(), + } +} + +fn envs_for_test() -> Arc { + use crate::environments::{EnvTable, Environments}; + Arc::new( + Environments::new("prod").with_environment( + "prod", + EnvTable::new() + .with("client_id", "prod-client") + .with("auth_url", "https://prod.example.com/auth") + .with("token_url", "https://prod.example.com/token") + .with("scopes", vec!["openid", "prod.read"]), + ), + ) +} + +/// A provider wired to an [`Environments`](crate::environments::Environments) +/// resolver sources its per-env OAuth config (client id, endpoints, scopes) +/// from the resolved environment, making the environment the single source +/// of truth. +#[test] +fn environment_wired_provider_sources_oauth_from_resolver() { + let provider = PkceAuthProvider::new( + "godaddy", + "https://base/auth", + "https://base/token", + "base-client", + &["openid"], + ) + .with_environments(envs_for_test()); + let oauth = provider.effective_oauth("prod").expect("assembles"); + assert_eq!(oauth.client_id, "prod-client"); + assert_eq!(oauth.auth_url, "https://prod.example.com/auth"); + assert_eq!(oauth.token_url, "https://prod.example.com/token"); + assert_eq!( + oauth.scopes, + vec!["openid".to_owned(), "prod.read".to_owned()] + ); +} + +/// A resolved environment's `scopes = []` is treated as absent, not as a +/// deliberate "no scopes" override — it falls through to the provider's +/// real base scopes, the same way a blank `client_id`/`auth_url` string +/// would. An OAuth flow with zero scopes is never what a wired +/// environment actually means; it's the same kind of unset-placeholder +/// case blank-string collapsing already exists to catch. +#[test] +fn environment_with_empty_scopes_falls_back_to_base_scopes() { + use crate::environments::{EnvTable, Environments}; + + let environments = Arc::new( + Environments::new("prod").with_environment( + "prod", + EnvTable::new() + .with("client_id", "prod-client") + .with("scopes", Vec::::new()), + ), + ); + let provider = PkceAuthProvider::new( + "godaddy", + "https://base/auth", + "https://base/token", + "base-client", + &["openid", "base.read"], + ) + .with_environments(environments); + + let oauth = provider.effective_oauth("prod").expect("assembles"); + assert_eq!( + oauth.client_id, "prod-client", + "the environment's own value still wins" + ); + assert_eq!( + oauth.scopes, + vec!["openid".to_owned(), "base.read".to_owned()], + "an empty scopes array from the environment must defer to the base config's real scopes" + ); +} + +/// A provider with no environment resolver falls back to the base client id, +/// endpoints, and scopes for every env. +#[test] +fn non_wired_provider_uses_base_config() { + let provider = PkceAuthProvider::new( + "godaddy", + "https://base/auth", + "https://base/token", + "base-client", + &["openid"], + ); + let oauth = provider.effective_oauth("anything").expect("assembles"); + assert_eq!(oauth.client_id, "base-client"); + assert_eq!(oauth.scopes, vec!["openid".to_owned()]); +} + +/// OAuth token traffic must carry the engine's configured default +/// user-agent so it is attributed consistently with all other outbound +/// calls (some upstream WAFs reject requests without a User-Agent). +#[test] +fn token_request_carries_default_user_agent() { + let _guard = crate::transport::client::UA_TEST_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let _restore = crate::transport::client::RestoreDefaultUserAgent; + crate::transport::set_default_user_agent("ua-probe/7.7"); + let provider = test_provider().with_token_timeout(Duration::from_secs(12)); + let request = provider + .token_request( + "https://example.com/token", + &[("grant_type", "refresh_token")], + ) + .build() + .expect("token request should build"); + let header = request + .headers() + .get(reqwest::header::USER_AGENT) + .expect("token request should set a user-agent"); + assert_eq!(header, "ua-probe/7.7"); + assert_eq!(request.timeout(), Some(&Duration::from_secs(12))); +} + +/// OAuth token requests must not hang indefinitely: the provider applies a +/// 30s timeout by default. +#[test] +fn default_token_timeout_is_thirty_seconds() { + assert_eq!(test_provider().token_timeout, Duration::from_secs(30)); +} + +/// The default token timeout can be overridden per provider. +#[test] +fn with_token_timeout_overrides_default() { + let provider = test_provider().with_token_timeout(Duration::from_secs(5)); + assert_eq!(provider.token_timeout, Duration::from_secs(5)); +} + +/// store_cached_token + cached_token round-trip: the mechanism used by +/// the persistence fix must reliably write and read tokens from the cache. +#[tokio::test] +async fn cache_stores_and_retrieves_valid_token() { + let provider = test_provider(); + let token = valid_token("access-abc"); + + provider.store_cached_token("dev", token.clone()).await; + + let cached = provider.cached_token("dev").await; + assert!(cached.is_some(), "expected cached token to be present"); + assert_eq!( + cached.expect("token must be present").access_token, + "access-abc" + ); +} + +/// Expired tokens must not be returned from the cache; the caller would +/// then proceed to the keychain or PKCE flow. +#[tokio::test] +async fn cached_token_ignores_expired_tokens() { + let provider = test_provider(); + provider.store_cached_token("dev", expired_token()).await; + + assert!( + provider.cached_token("dev").await.is_none(), + "expired token should not be returned from cache" + ); +} + +#[test] +fn scopes_from_jwt_parses_scope_claim() { + let token = make_jwt(&json!({ "scope": "a b c" })); + assert_eq!(scopes_from_jwt(&token), vec!["a", "b", "c"]); +} + +#[test] +fn scopes_from_jwt_parses_scp_and_array_claims() { + // Azure-style `scp` array. + let scp = make_jwt(&json!({ "scp": ["a", "b"] })); + assert_eq!(scopes_from_jwt(&scp), vec!["a", "b"]); + // `scope` encoded as an array. + let array = make_jwt(&json!({ "scope": ["a", "b c"] })); + assert_eq!(scopes_from_jwt(&array), vec!["a", "b", "c"]); + // Empty `scope` falls through to `scp`. + let mixed = make_jwt(&json!({ "scope": "", "scp": ["x"] })); + assert_eq!(scopes_from_jwt(&mixed), vec!["x"]); +} + +#[test] +fn granted_scopes_uses_recorded_scopes_for_opaque_token() { + // An opaque (non-JWT) token carries no readable claim, so coverage comes + // from the scopes recorded when it was obtained. + let token = token_with_scopes("opaque-token", &["a", "b"]); + assert_eq!(granted_scopes(&token), vec!["a", "b"]); +} + +#[test] +fn ensure_granted_rejects_a_token_missing_required_scopes() { + let required = vec!["a".to_owned(), "b".to_owned()]; + let hierarchy = ScopeHierarchy::new(); + // JWT that exposes only `a` → `b` is detectably not granted. + let jwt = valid_token(&make_jwt(&json!({ "scope": "a" }))); + let err = ensure_granted("dev", &jwt, &required, &hierarchy).expect_err("b is not granted"); + assert!( + err.to_string().contains("did not grant required scope(s)"), + "{err}" + ); + assert!(err.to_string().contains('b'), "{err}"); + + // A token granting both passes. + let ok = valid_token(&make_jwt(&json!({ "scope": "a b" }))); + ensure_granted("dev", &ok, &required, &hierarchy).expect("both granted"); + // Recorded scopes (opaque token) also satisfy the check. + let opaque = token_with_scopes("opaque", &["a", "b"]); + ensure_granted("dev", &opaque, &required, &hierarchy).expect("recorded scopes granted"); +} + +#[test] +fn ensure_granted_accepts_hierarchy_covered_grant() { + // The IdP literally grants only `admin`; the hierarchy says that + // covers `read`, so the exact-string-missing scope should not error. + let required = vec!["read".to_owned()]; + let hierarchy = ScopeHierarchy::new().with_implication("admin", &["read"]); + let jwt = valid_token(&make_jwt(&json!({ "scope": "admin" }))); + ensure_granted("dev", &jwt, &required, &hierarchy).expect("admin implies read"); +} + +#[test] +fn plan_step_up_covers_or_reauthenticates() { + let granted = vec!["base".to_owned(), "read".to_owned()]; + let read = vec!["read".to_owned()]; + let write = vec!["write".to_owned()]; + let hierarchy = ScopeHierarchy::new(); + + // Already covered (decision needs only granted vs required). + assert_eq!(plan_step_up(&granted, &read, &hierarchy), StepUp::Covered); + // Missing → reauthenticate, with no interactivity gate: step-up now + // mirrors the no-token path and acquires the scope via a fresh login + // rather than failing when stdio is not a TTY. The caller builds the + // union (defaults ∪ granted ∪ required) only on this path. + assert_eq!( + plan_step_up(&granted, &write, &hierarchy), + StepUp::Reauthenticate + ); + // The union itself (defaults ∪ granted ∪ required) is covered by + // union_scopes' own test. +} + +#[test] +fn plan_step_up_covers_via_hierarchy() { + let granted = vec!["admin".to_owned()]; + let read = vec!["read".to_owned()]; + let hierarchy = ScopeHierarchy::new().with_implication("admin", &["read"]); + + assert_eq!(plan_step_up(&granted, &read, &hierarchy), StepUp::Covered); +} + +#[test] +fn scope_hierarchy_covers_transitively() { + let hierarchy = ScopeHierarchy::new() + .with_implication("a", &["b"]) + .with_implication("b", &["c"]); + + assert!(hierarchy.covers(&["a".to_owned()], "c")); +} + +#[test] +fn scope_hierarchy_ignores_cycles() { + let hierarchy = ScopeHierarchy::new() + .with_implication("a", &["b"]) + .with_implication("b", &["a"]); + + // Terminates instead of looping, and still resolves correctly. + assert!(hierarchy.covers(&["a".to_owned()], "b")); + assert!(!hierarchy.covers(&["a".to_owned()], "z")); +} + +#[test] +fn scope_hierarchy_defaults_to_exact_match() { + let hierarchy = ScopeHierarchy::new(); + + assert!(hierarchy.covers(&["read".to_owned()], "read")); + assert!(!hierarchy.covers(&["admin".to_owned()], "read")); +} + +/// An opaque cached token whose recorded scopes cover the requirement is +/// returned without starting a flow — proving coverage no longer depends on +/// a readable JWT scope claim. +#[tokio::test] +async fn get_credential_for_uses_recorded_scopes_for_opaque_token() { + let provider = test_provider(); + provider + .store_cached_token("dev", token_with_scopes("opaque-token", &["read", "write"])) + .await; + + let meta = crate::middleware::CommandMeta { + scopes: vec!["read".to_owned()], + ..crate::middleware::CommandMeta::default() + }; + let req = CredentialRequest::new("dev", "app:list", "read", &meta); + let credential = provider + .get_credential_for(&req) + .await + .expect("recorded scopes cover the requirement"); + assert_eq!(credential.token, "opaque-token"); +} + +#[test] +fn union_scopes_dedupes_and_preserves_order() { + let defaults = vec!["a".to_owned(), "b".to_owned()]; + let granted = vec!["b".to_owned(), "c".to_owned()]; + let required = vec!["c".to_owned(), "d".to_owned()]; + assert_eq!( + union_scopes(&defaults, &granted, &required), + vec!["a", "b", "c", "d"] + ); +} + +#[test] +fn scopes_from_jwt_empty_for_opaque_or_missing() { + assert!(scopes_from_jwt("opaque-token").is_empty()); + let no_scope = make_jwt(&json!({ "sub": "user" })); + assert!(scopes_from_jwt(&no_scope).is_empty()); +} + +/// When the cached token's JWT already covers the required scopes, +/// `get_credential_for` must return it without starting a PKCE flow. +#[tokio::test] +async fn get_credential_for_uses_cached_token_when_scopes_covered() { + let provider = test_provider(); + let token = valid_token(&make_jwt(&json!({ + "scope": "apps.app-registry:read apps.app-registry:write", + "sub": "user-1", + }))); + provider.store_cached_token("dev", token).await; + + let mut meta = crate::middleware::CommandMeta::default(); + meta.set_scopes(vec!["apps.app-registry:read".to_owned()]); + let req = CredentialRequest { + env: "dev", + command: "app:list", + tier: "read", + meta: &meta, + }; + let credential = provider + .get_credential_for(&req) + .await + .expect("cached token covers required scopes"); + assert_eq!(credential.sub, "user-1"); +} + +/// With no required scopes, `get_credential_for` behaves like +/// `get_credential` and returns the cached token unchanged. +#[tokio::test] +async fn get_credential_for_no_scopes_returns_cached() { + let provider = test_provider(); + provider + .store_cached_token("dev", valid_token("opaque")) + .await; + let meta = crate::middleware::CommandMeta::default(); + let req = CredentialRequest { + env: "dev", + command: "app:list", + tier: "read", + meta: &meta, + }; + let credential = provider + .get_credential_for(&req) + .await + .expect("no scopes required"); + assert_eq!(credential.token, "opaque"); +} + +#[test] +fn redirect_uri_default_uses_127_0_0_1_and_redirect_port() { + let provider = test_provider().with_redirect_port(9000); + assert_eq!( + provider.effective_redirect_uri(), + "http://127.0.0.1:9000/callback" + ); +} + +#[test] +fn with_redirect_uri_overrides_default() { + let provider = test_provider().with_redirect_uri("http://localhost:8080/auth/callback"); + assert_eq!( + provider.effective_redirect_uri(), + "http://localhost:8080/auth/callback" + ); +} + +#[test] +fn parse_redirect_uri_extracts_port_and_path_from_default() { + let provider = test_provider().with_redirect_port(9000); + let (port, path) = provider.parse_redirect_uri().expect("valid URI"); + assert_eq!(port, 9000); + assert_eq!(path, "/callback"); +} + +#[test] +fn parse_redirect_uri_extracts_port_and_path_from_custom_uri() { + let provider = test_provider().with_redirect_uri("http://localhost:8080/auth/callback"); + let (port, path) = provider.parse_redirect_uri().expect("valid URI"); + assert_eq!(port, 8080); + assert_eq!(path, "/auth/callback"); +} + +#[test] +fn with_redirect_uri_does_not_affect_listener_host() { + // The port is derived from the URI, but the listener always binds to + // 127.0.0.1 — this test confirms the URI host does not change that. + let provider = test_provider().with_redirect_uri("http://localhost:7777/callback"); + let (port, _) = provider.parse_redirect_uri().expect("valid URI"); + assert_eq!(port, 7777); + // Caller uses 127.0.0.1 for bind regardless; SocketAddr construction + // is in run_pkce_flow and is not repeated here. +} + +#[test] +fn extract_request_path_strips_query_string() { + assert_eq!( + extract_request_path("GET /auth/callback?code=abc&state=xyz HTTP/1.1\r\n"), + Some("/auth/callback".to_owned()), + ); +} + +#[test] +fn extract_request_path_handles_no_query_string() { + assert_eq!( + extract_request_path("GET /callback HTTP/1.1\r\n"), + Some("/callback".to_owned()), + ); +} + +#[test] +fn extract_query_param_skips_malformed_pairs() { + let request = "GET /callback?foo&code=abc123&state=xyz HTTP/1.1\r\nHost: localhost\r\n"; + assert_eq!( + extract_query_param(request, "code"), + Some("abc123".to_owned()), + ); + assert_eq!( + extract_query_param(request, "state"), + Some("xyz".to_owned()), + ); +} + +#[test] +fn extract_query_param_decodes_percent_encoding() { + let request = "GET /callback?code=a%20b%2Bc&state=ok HTTP/1.1\r\n"; + assert_eq!( + extract_query_param(request, "code"), + Some("a b+c".to_owned()), + ); +} + +/// resolve_token must return a pre-seeded in-memory token without +/// triggering the PKCE browser flow (which would require a port and browser). +/// This also exercises the cache-hit path that follows token persistence. +#[tokio::test] +async fn resolve_token_returns_cached_token_without_pkce_flow() { + let provider = test_provider(); + provider + .store_cached_token("dev", valid_token("cached-token")) + .await; + + let resolved = provider + .resolve_token("dev") + .await + .expect("resolve from cache"); + assert_eq!(resolved.access_token, "cached-token"); +} + +/// list_environments returns only in-memory cache keys; tokens written to +/// disk via file fallback during a previous session are not enumerated. +#[tokio::test] +async fn list_environments_returns_only_cached_keys() { + let provider = test_provider(); + provider.store_cached_token("dev", valid_token("t1")).await; + provider.store_cached_token("prod", valid_token("t2")).await; + + let mut envs = provider.list_environments().await.expect("list"); + envs.sort(); + assert_eq!(envs, ["dev", "prod"]); +} + +/// A provider with no cache entries returns an empty list, regardless of +/// what credential files may exist on disk from a previous session. +#[tokio::test] +async fn list_environments_returns_empty_without_cache() { + let provider = test_provider(); + let envs = provider.list_environments().await.expect("list"); + assert!(envs.is_empty(), "expected empty list for a fresh provider"); +} + +/// Builds an unsigned-looking JWT (`header.payload.signature`) whose payload +/// is the given claims object, base64url-encoded without padding. +fn make_jwt(claims: &Value) -> String { + let header = URL_SAFE_NO_PAD.encode(br#"{"alg":"none","typ":"JWT"}"#); + let payload = URL_SAFE_NO_PAD.encode(serde_json::to_vec(claims).expect("serialize claims")); + format!("{header}.{payload}.signature") +} + +#[test] +fn decode_jwt_claims_extracts_payload() { + let token = make_jwt(&json!({"email": "user@example.com", "sub": "abc123"})); + let claims = decode_jwt_claims(&token).expect("claims decode"); + assert_eq!( + claims.get("email").and_then(Value::as_str), + Some("user@example.com") + ); + assert_eq!(claims.get("sub").and_then(Value::as_str), Some("abc123")); +} + +#[test] +fn decode_jwt_claims_returns_none_for_non_jwt() { + assert!(decode_jwt_claims("opaque-access-token").is_none()); + assert!(decode_jwt_claims("only.two").is_none()); + // Valid structure but the payload is not valid base64/JSON. + assert!(decode_jwt_claims("aaa.!!!.bbb").is_none()); +} + +#[test] +fn extract_identity_honors_priority_and_skips_empty() { + let priority: Vec = DEFAULT_IDENTITY_CLAIMS + .iter() + .map(|c| (*c).to_owned()) + .collect(); + // `email` is empty, so the next non-empty claim (`preferred_username`) wins. + let claims = serde_json::from_value(json!({ + "email": "", + "preferred_username": "jdoe", + "name": "Jane Doe", + })) + .expect("claims map"); + assert_eq!(extract_identity(&claims, &priority), "jdoe"); + + // No matching claim yields an empty identity. + let empty = serde_json::from_value(json!({"unrelated": "x"})).expect("claims map"); + assert_eq!(extract_identity(&empty, &priority), ""); +} + +#[test] +fn build_credential_populates_identity_and_sub() { + let provider = test_provider(); + let token = valid_token(&make_jwt(&json!({ + "email": "user@example.com", + "sub": "subject-1", + }))); + let credential = provider.build_credential("prod", &token); + assert_eq!(credential.identity, "user@example.com"); + assert_eq!(credential.sub, "subject-1"); + assert_eq!(credential.env, "prod"); + assert_eq!(credential.provider, "test"); +} + +#[test] +fn build_credential_populates_scopes_from_stored_token() { + let provider = test_provider(); + let mut token = valid_token(&make_jwt(&json!({"sub": "subject-1"}))); + token.scopes = vec!["a".to_owned(), "b".to_owned()]; + let credential = provider.build_credential("prod", &token); + assert_eq!(credential.scopes, vec!["a", "b"]); +} + +#[test] +fn build_credential_sets_refreshable_when_refresh_token_present() { + let provider = test_provider(); + let mut token = valid_token(&make_jwt(&json!({"sub": "subject-1"}))); + token.refresh_token = Some("a-refresh-token".to_owned()); + let credential = provider.build_credential("prod", &token); + assert!(credential.refreshable); +} + +#[test] +fn build_credential_leaves_refreshable_false_without_refresh_token() { + let provider = test_provider(); + let token = valid_token(&make_jwt(&json!({"sub": "subject-1"}))); + let credential = provider.build_credential("prod", &token); + assert!(!credential.refreshable); +} + +#[test] +fn build_credential_leaves_identity_blank_for_opaque_token() { + let provider = test_provider(); + let token = valid_token("opaque-token"); + let credential = provider.build_credential("prod", &token); + assert_eq!(credential.identity, ""); + assert_eq!(credential.sub, ""); +} + +#[test] +fn with_identity_claims_overrides_selection() { + let provider = test_provider().with_identity_claims(&["custom_user"]); + let token = valid_token(&make_jwt(&json!({ + "email": "ignored@example.com", + "custom_user": "picked", + }))); + let credential = provider.build_credential("prod", &token); + assert_eq!(credential.identity, "picked"); +} + +/// In-memory [`CredentialStorage`] double: lets us assert the provider +/// delegates load/save/delete without any real keychain or filesystem. +#[derive(Debug, Default)] +struct MemoryStorage { + entries: std::sync::Mutex>, +} + +impl MemoryStorage { + fn entry_key(key: &CredentialKey<'_>) -> String { + format!("{}/{}/{}", key.app_id, key.provider, key.env) + } +} + +#[async_trait] +impl CredentialStorage for MemoryStorage { + async fn load(&self, key: &CredentialKey<'_>) -> Option { + self.entries + .lock() + .unwrap_or_else(|e| e.into_inner()) + .get(&Self::entry_key(key)) + .cloned() + } + + async fn save(&self, key: &CredentialKey<'_>, value: &str) -> crate::Result<()> { + self.entries + .lock() + .unwrap_or_else(|e| e.into_inner()) + .insert(Self::entry_key(key), value.to_owned()); + Ok(()) + } + + async fn delete(&self, key: &CredentialKey<'_>) { + self.entries + .lock() + .unwrap_or_else(|e| e.into_inner()) + .remove(&Self::entry_key(key)); + } +} + +#[test] +#[allow(deprecated)] +fn with_file_fallback_maps_to_store_modes() { + assert_eq!( + test_provider().with_file_fallback(true).store_mode, + Some(CredentialStore::Auto) + ); + assert_eq!( + test_provider().with_file_fallback(false).store_mode, + Some(CredentialStore::Keyring) + ); +} + +#[test] +fn builders_record_storage_selection() { + assert_eq!( + test_provider() + .with_credential_store(CredentialStore::File) + .store_mode, + Some(CredentialStore::File) + ); + let provider = test_provider().with_storage(Arc::new(MemoryStorage::default())); + assert!(provider.storage_override.is_some()); +} + +#[tokio::test] +async fn provider_delegates_to_injected_storage() { + let mem = Arc::new(MemoryStorage::default()); + let provider = test_provider().with_app_id("app").with_storage(mem.clone()); + + // No entry yet: status reports not-logged-in. + assert!(provider.status("dev").await.is_err()); + + // Saving routes through the injected store. + provider + .save_stored("dev", &valid_token("tok")) + .await + .expect("save"); + let key = CredentialKey::new("app", "test", "dev"); + assert!(mem.load(&key).await.is_some(), "token reached the store"); + + // And status reads it back. + let cred = provider.status("dev").await.expect("status"); + assert_eq!(cred.token, "tok"); + + // Logout clears it from the store. + provider.logout("dev").await.expect("logout"); + assert!(mem.load(&key).await.is_none(), "token removed on logout"); +} + +#[tokio::test] +async fn corrupt_stored_blob_self_heals() { + let mem = Arc::new(MemoryStorage::default()); + let key = CredentialKey::new("app", "test", "dev"); + mem.save(&key, "not-valid-json").await.expect("seed"); + + let provider = test_provider().with_app_id("app").with_storage(mem.clone()); + assert!(provider.load_stored("dev").await.is_none()); + assert!( + mem.load(&key).await.is_none(), + "corrupt blob should be deleted (self-heal)" + ); +} + +#[tokio::test] +// The guard is intentionally held across awaits to serialize env mutation. +#[allow(clippy::await_holding_lock)] +async fn file_store_round_trips_without_keyring() { + let dir = tempfile::tempdir().expect("tempdir"); + // Hold the shared lock + env guard across the awaits. + let _lock = crate::config::test_env::lock(); + let _env = crate::config::test_env::EnvVarGuard::set("XDG_CONFIG_HOME", Some(dir.path())); + + let provider = test_provider() + .with_app_id("app") + .with_credential_store(CredentialStore::File); + assert!(provider.status("dev").await.is_err()); + provider + .save_stored("dev", &valid_token("filetok")) + .await + .expect("save"); + let cred = provider.status("dev").await.expect("status"); + assert_eq!(cred.token, "filetok"); +} + +/// The fix: a provider wired to a shared `Arc` whose +/// `environments.toml` file layer defines `prod` with a different `client_id` +/// resolves the FILE's client id. This proves the provider's file layer +/// resolves — the shared, app_id-stamped instance reaches the provider rather +/// than an unstamped copy whose file path is `None`. +#[test] +fn wired_provider_resolves_client_id_from_environments_file() { + use crate::environments::{EnvTable, Environments}; + + let dir = tempfile::tempdir().expect("tempdir"); + let file = dir.path().join("environments.toml"); + std::fs::write( + &file, + r#" +[prod] +client_id = "file-prod-client" +"#, + ) + .expect("write environments.toml"); + + let environments = Arc::new( + Environments::new("prod") + .with_app_id("x") + .with_environment( + "prod", + EnvTable::new().with("client_id", "compiled-prod-client"), + ) + .with_config_file_path_override(file), + ); + + let provider = PkceAuthProvider::new( + "godaddy", + "https://base/auth", + "https://base/token", + "base-client", + &["openid"], + ) + .with_environments(environments); + + // The file overrides the compiled client id, which itself overrides the + // provider's base — proving the wired provider reads the file layer. + assert_eq!( + provider + .effective_oauth("prod") + .expect("assembles") + .client_id, + "file-prod-client" + ); +} + +/// A wired provider's resolved environment overrides the base config's +/// client id. +#[test] +fn wired_provider_resolved_env_overrides_base_client_id() { + use crate::environments::{EnvTable, Environments}; + + let environments = Arc::new( + Environments::new("prod") + .with_app_id("x") + .with_environment("prod", EnvTable::new().with("client_id", "env-client")), + ); + let wired = test_provider().with_environments(environments); + assert_eq!( + wired.effective_oauth("prod").expect("assembles").client_id, + "env-client" + ); +} + +/// `client_id`/`auth_url`/`token_url` have no default: a provider whose +/// base config was never given a real client id, and with no environment +/// wired to supply one either, must fail loudly rather than assemble with +/// an empty client id. +#[test] +fn effective_oauth_rejects_a_never_initialized_client_id() { + let provider = PkceAuthProvider::new( + "test", + "https://example.com/auth", + "https://example.com/token", + "", + &["openid"], + ); + let err = provider + .effective_oauth("prod") + .expect_err("a blank client_id with no other tier to supply one must be an error"); + assert!(err.to_string().contains("client_id")); +} diff --git a/cli-engine/src/cli.rs b/cli-engine/src/cli.rs deleted file mode 100644 index 603cec3..0000000 --- a/cli-engine/src/cli.rs +++ /dev/null @@ -1,5452 +0,0 @@ -use std::{ - collections::{BTreeMap, BTreeSet}, - future::Future, - io::Write, - path::{Path, PathBuf}, - process::ExitCode, - sync::{Arc, Mutex}, - time::Duration, -}; - -mod builtins; -mod completion; -mod help; -mod tree_render; - -use clap::{Arg, ArgMatches, Command, builder::PossibleValuesParser}; - -use crate::{ - ActivityEmitter, Auditor, AuthProvider, Authorizer, CliCoreError, CommandMeta, CommandSpec, - FeatureFlag, GroupSpec, GuideEntry, Middleware, MiddlewareRequest, Result, RuntimeCommandSpec, - RuntimeGroupSpec, - auth::commands::auth_command_group, - command::{ - CommandContext, StreamSender, command_args_from_matches, command_path_from_matches, - leaf_matches, - }, - error::exit_code_for_error, - feature_flags::{FlagEntry, FlagPolicy, FlagRegistry, Stage}, - flags::{ - GlobalFlags, derive_bool_flags, derive_value_flags, extract_command_path, - extract_output_format, global_flags_from_matches, has_true_schema_flag, min_stage_env_var, - output_env_var, register_global_flags, register_reason_flag, resolve_default_output_format, - }, - guide::{guide_content, render_guide_human}, - module::{Module, ModuleContext}, - output::{ - FieldInfo, HumanViewDef, HumanViewRegistry, NextAction, SchemaRegistry, - format_help_section, global_human_view_registry_snapshot, global_schema_registry_snapshot, - }, - search::{SearchDocument, SearchIndex}, -}; - -use builtins::{ - completion_args, completion_command, guide_args, guide_command, help_args, help_command, - search_args, search_command, -}; -use help::{GROUP_HELP_TEMPLATE, ROOT_HELP_TEMPLATE}; -pub use help::{ModuleHelpEntry, build_root_long, render_next_actions_human}; - -/// Build metadata shown by the root `--version` flag. -#[derive(Clone, Debug, Default, Eq, PartialEq)] -pub struct BuildInfo { - /// Semantic version or other release label. - pub version: String, - /// Optional source control commit identifier. - pub commit: Option, - /// Optional build date string. - pub date: Option, -} - -impl BuildInfo { - /// Creates build metadata with only a version string. - #[must_use] - pub fn new(version: impl Into) -> Self { - Self { - version: version.into(), - commit: None, - date: None, - } - } - - /// Adds a commit identifier to the version string shown by `--version`. - #[must_use] - pub fn with_commit(mut self, commit: impl Into) -> Self { - self.commit = Some(commit.into()); - self - } - - /// Adds a build date to the version string shown by `--version`. - #[must_use] - pub fn with_date(mut self, date: impl Into) -> Self { - self.date = Some(date.into()); - self - } - - /// Returns the rendered version string used by the root `--version` flag. - #[must_use] - pub fn version_string(&self) -> String { - let commit = self.commit.as_deref().unwrap_or_default(); - let date = self.date.as_deref().unwrap_or_default(); - - if commit.is_empty() && date.is_empty() { - self.version.clone() - } else { - format!("{} (commit {commit}, built {date})", self.version) - } - } -} - -/// Late dependency initializer run once before real command execution. -pub type InitDeps = Arc Result<()> + Send + Sync>; -/// Hook used to add application-specific global flags to the root `clap` command. -pub type RegisterFlags = Arc Command + Send + Sync>; -/// Hook used to copy parsed application-specific flags into middleware. -pub type ApplyFlags = Arc Result<()> + Send + Sync>; -/// Hook run immediately before executable commands and built-ins. -pub type PreRun = - Arc Result<()> + Send + Sync>; -/// Hook used to adjust command metadata globally before middleware executes. -pub type ResolveMeta = Arc CommandMeta + Send + Sync>; -/// Hook called after a CLI run completes. -pub type OnShutdown = Arc; -/// Hook that contributes extra root-scope `search` documents. -pub type ExtraSearchDocs = Arc Vec + Send + Sync>; -/// Hook that supplies the suggested next actions shown when the CLI is invoked -/// with no subcommand (bare root). The same actions drive a human "Next actions" -/// section and the JSON discovery envelope. -pub type RootNextActions = Arc Vec + Send + Sync>; - -/// Default name for the admin help category, under which the engine files the -/// built-in `auth` command when a consumer does not override it via -/// [`CliConfig::with_admin_category`]. -const DEFAULT_ADMIN_CATEGORY: &str = "Admin"; - -/// Maximum number of chained `argv0` dispatch hand-offs before the engine -/// refuses to recurse further. Real multi-call nesting is zero or one level; -/// this bounds a pathologically long explicit `argv0 … argv0 …` chain so it -/// errors cleanly instead of overflowing the stack. -const MAX_ARGV0_DEPTH: usize = 16; - -/// How the engine behaves when invoked under a registered alternative `argv[0]` -/// name (busybox/git-style multi-call dispatch). -/// -/// A route is selected when the binary's `argv[0]` basename — or the name given -/// to the hidden `argv0` command — matches a key registered via -/// [`CliConfig::with_argv0_alias`] or [`CliConfig::with_argv0_personality`]. An -/// `argv[0]` that matches no route falls through to the default CLI, so existing -/// applications that register no routes are unaffected. -/// -/// Non-exhaustive: more route kinds may be added in future releases. Register -/// routes through the [`CliConfig`] builders rather than matching on variants. -#[derive(Clone)] -#[non_exhaustive] -pub enum Argv0Route { - /// Rewrite the invocation into these canonical subcommand tokens and run it - /// through the normal command tree, with the real argument tail appended. - /// - /// For example, an `Alias(vec!["project".into(), "list".into()])` registered - /// under `pl` makes `pl --team x` behave exactly like `project list --team x`. - Alias(Vec), - /// Run an entirely separate CLI application built from the returned - /// [`CliConfig`] (its own root name, commands, flags, and auth). The - /// configuration is built lazily, only when the route is actually dispatched, - /// so registering a personality costs nothing for invocations that never hit it. - Personality(Arc CliConfig + Send + Sync>), -} - -impl std::fmt::Debug for Argv0Route { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Alias(tokens) => formatter.debug_tuple("Alias").field(tokens).finish(), - Self::Personality(_) => formatter.write_str("Personality(..)"), - } - } -} - -/// On-disk mechanism used by [`Cli::create_link`] to materialize an alternative -/// `argv[0]` name so the binary can be invoked under it. -/// -/// Installers pick the mechanism that suits the platform and environment; -/// self-healing code can re-run [`Cli::create_link`] to restore a deleted link. -/// -/// Non-exhaustive: more link mechanisms may be added in future releases. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -#[non_exhaustive] -pub enum Argv0LinkMethod { - /// A symbolic link to the target executable (`` on Unix, `.exe` - /// on Windows). On Windows this may require Developer Mode or elevation. - SoftLink, - /// A hard link to the target executable (`` on Unix, `.exe` on - /// Windows). The link must live on the same volume as the target. - HardLink, - /// A small shim script that forwards to the target via the `argv0` command: - /// a `.cmd` batch file on Windows, or an executable `` shell - /// script on Unix. Useful when links are unavailable or inconvenient. - Script, -} - -/// Top-level subcommand names that are reserved by the engine and must not be -/// used as module group names. [`Cli::add_module_group`] rejects a group whose -/// name matches a reserved name so the engine's built-in command always wins. -pub(crate) const BUILTIN_COMMAND_NAMES: [&str; 5] = - ["help", "guide", "tree", "completion", "search"]; - -/// Declarative configuration for a CLI application. -/// -/// Use [`CliConfig::new`] for the common path and chain `with_*` methods for -/// modules, auth providers, guides, views, and lifecycle hooks. Direct struct -/// literals remain available for advanced setup and tests. -#[derive(Clone, Default)] -pub struct CliConfig { - /// Root command name shown in usage output. - pub name: String, - /// One-line root command description. - pub short: String, - /// Optional longer root command description. Defaults to `short`. - pub long: Option, - /// Version/build metadata for `--version`. - pub build: BuildInfo, - /// Application id stored in middleware and output metadata. - pub app_id: String, - /// Fallback auth provider when a command does not select one explicitly. - pub default_auth_provider: Option, - /// Domain modules mounted under the root command. - pub modules: Vec, - /// Additional top-level runtime commands. - pub commands: Vec, - /// Additional commands mounted as siblings of the built-in `auth` - /// group's `login`/`status`/`logout` (e.g. `auth scopes`). Populate via - /// [`CliConfig::with_auth_extra_commands`]; folded in internally after - /// the built-in group is built, so the built-ins are never lost or - /// overwritten. - pub auth_extra_commands: Vec, - /// Global guide entries mounted under `guide`. - pub guides: Vec, - /// Global human output views. - pub views: Vec, - /// Providers registered before command execution starts. - pub auth_providers: Vec>, - /// Optional override for the process-wide outbound User-Agent. When unset, - /// the engine derives `name/version` from this config. See - /// [`CliConfig::user_agent_string`]. - pub user_agent: Option, - /// Extra HTTP header names to redact in `--debug transport` output, on top - /// of the built-in sensitive set (`authorization`, `proxy-authorization`, - /// `cookie`, `set-cookie`, `x-api-key`). Set CLI-specific secret-bearing - /// headers here — e.g. a custom API-key header an auth injector adds. - /// Populate via [`CliConfig::with_redacted_debug_headers`]. - pub redacted_debug_headers: Vec, - /// Optional authorization gatekeeper injected into middleware. - pub authz: Option>, - /// Optional audit recorder injected into middleware. - pub auditor: Option>, - /// Optional activity event sink injected into middleware. - pub activity: Option>, - /// Optional late initializer for runtime dependencies. - pub init_deps: Option, - /// Optional hook for adding application-specific global flags. - pub register_flags: Option, - /// Optional hook for applying parsed application-specific flags. - pub apply_flags: Option, - /// Optional hook run before executable commands and built-ins. - pub pre_run: Option, - /// Optional hook for global command metadata adjustments. - pub meta_resolver: Option, - /// Optional hook called after each run. - pub on_shutdown: Option, - /// Optional root-scope search document provider. - pub extra_search_docs: Option, - /// Optional provider for the bare-root suggested next actions. - pub root_next_actions: Option, - /// Name of the admin help category. The engine files its built-in `auth` - /// command under this heading; apps should use the same name for their own - /// admin modules (e.g. godaddy's `env`). When unset, defaults to `"Admin"`; - /// set it to match a consumer's own taxonomy (e.g. gdx's "Administration"). - pub admin_category: Option, - /// Whether to mount the built-in `config` command group (`config - /// get`/`set`/`path`/`list`). Off by default to avoid colliding with a - /// consumer's own `config` noun. Enable via - /// [`CliConfig::with_config_commands`]. - pub config_commands: bool, - /// Alternative `argv[0]` names this binary may be invoked as, mapped to the - /// behavior the engine should take (busybox/git-style multi-call dispatch). - /// - /// Keyed by the bare alternative name (no path, no extension). Empty by - /// default, in which case argv0 dispatch is inert and behavior is identical - /// to a binary that never opted in. Populate via [`CliConfig::with_argv0_alias`] - /// and [`CliConfig::with_argv0_personality`]. - pub argv0_routes: BTreeMap, - /// Optional first-class environment system. - /// - /// Registered via [`CliConfig::with_environments`]. When set, the engine - /// registers a global `--env` flag, seeds the active environment into - /// middleware, and exposes it to handlers through - /// [`CommandContext::environment`](crate::command::CommandContext::environment). - pub environments: Option>, - /// Explicit argv override for [`Cli::new`]'s startup `--env` prescan, - /// mainly used to make tests hermetic. - pub startup_args: Option>, - /// Minimum feature stage required for a flagged command, group, or module - /// to remain mounted. - /// - /// Defaults to [`Stage::Ga`] via [`Stage`]'s own `Default`, which combined - /// with an empty [`feature_overrides`](Self::feature_overrides) is the - /// zero-config behavior: nothing is gated unless a command/group/module - /// opts in with `.with_feature_flag(...)`, and even then it stays visible - /// until this is lowered. Lower it (e.g. to [`Stage::Beta`] or - /// [`Stage::Experimental`]) to opt a build or environment into - /// pre-release commands. Set via [`CliConfig::with_min_stage`]. - pub min_stage: Stage, - /// Per-key stage overrides that substitute a forced stage for a flag - /// key's own declared stage before comparing against - /// [`min_stage`](Self::min_stage). - /// - /// Empty by default. Populate via [`CliConfig::with_feature_override`] to - /// force one named flag to a specific effective stage — e.g. forcing a - /// single flag to [`Stage::Ga`] to turn it on for internal testing without - /// lowering [`min_stage`](Self::min_stage) for every other flagged - /// command, or forcing it to [`Stage::Experimental`] to disable it even - /// under a permissive `min_stage`. See [`FlagPolicy::visible`] for the - /// exact comparison. - pub feature_overrides: BTreeMap, - /// Whether to auto-enable interactive mode when a TTY is detected. - /// - /// When `false` (the default), commands only run interactively if the user - /// passes `--interactive` explicitly. When `true`, the engine auto-detects - /// a TTY (stdin + stderr) and defaults to interactive mode — meaning - /// missing required arguments will be prompted for instead of erroring. - /// - /// Set via [`CliConfig::with_auto_interactive`]. Start with `false` for - /// backwards compatibility; flip to `true` once the CLI's commands have - /// been tested under interactive prompting. - pub auto_interactive: bool, -} - -impl CliConfig { - /// Creates the minimum useful CLI configuration. - #[must_use] - pub fn new( - name: impl Into, - short: impl Into, - app_id: impl Into, - ) -> Self { - Self { - name: name.into(), - short: short.into(), - app_id: app_id.into(), - ..Self::default() - } - } - - /// Sets root long help text. - #[must_use] - pub fn with_long(mut self, long: impl Into) -> Self { - self.long = Some(long.into()); - self - } - - /// Sets build metadata used by `--version`. - #[must_use] - pub fn with_build(mut self, build: BuildInfo) -> Self { - self.build = build; - self - } - - /// Sets the fallback auth provider for commands that do not name one. - #[must_use] - pub fn with_default_auth_provider(mut self, provider: impl Into) -> Self { - self.default_auth_provider = Some(provider.into()); - self - } - - /// Registers a first-class environment system. - /// - /// When set, [`Cli::new`] registers a global `--env` flag, seeds the active - /// environment into middleware (explicit `--env` > persisted active > - /// configured default), and exposes the resolved environment to handlers via - /// [`CommandContext::environment`](crate::command::CommandContext::environment). - /// - /// The [`Environments`](crate::environments::Environments) is stored as-is, so - /// the consumer is responsible for configuring it before wrapping it in an - /// `Arc`: - /// - /// - Call - /// [`Environments::with_app_id`](crate::environments::Environments::with_app_id) - /// with the **same** `app_id` passed to [`CliConfig::new`], so the config - /// file and active-environment persistence resolve to the application's - /// config directory. (An empty `app_id` makes - /// [`Environments::config_file_path`](crate::environments::Environments::config_file_path) - /// return `None`, silently disabling the `environments.toml` file layer.) - /// - Call - /// [`Environments::with_config_file(true)`](crate::environments::Environments::with_config_file) - /// if the application loads a user-editable `environments.toml`. - /// - **Share the same `Arc`** with any `PkceAuthProvider::with_environments` - /// (available with the `pkce-auth` feature): - /// the provider's OAuth file layer and active-environment persistence must - /// resolve against the identical, `app_id`-stamped instance the engine sees, - /// or a file-defined environment (or a file override of a compiled - /// environment's `client_id`) will be visible to `env info` yet invisible to - /// the actual OAuth login. - #[must_use] - pub fn with_environments( - mut self, - environments: Arc, - ) -> Self { - self.environments = Some(environments); - self - } - - /// Overrides the argv [`Cli::new`] prescans for `--env` before pruning the - /// command tree, instead of the real process argv. - /// - /// Only meaningful alongside [`with_environments`](Self::with_environments) - /// — otherwise `Cli::new` never registers `--env` or does the prescan at - /// all, so this is silently unused. Element `0` is treated as the program - /// name and skipped, the same convention [`Cli::run`]/[`Cli::execute_from`] - /// use for their own `args` parameter. - /// - /// This matters beyond tests: tree pruning is decided once, at `Cli::new` - /// time, from either this override or real process argv — never from the - /// `args` a later [`Cli::run`]/[`Cli::execute_from`] call receives. Any - /// caller that builds the `Cli` once and later runs it with a synthetic - /// argv (e.g. a wrapper binary invoking it programmatically, or a fixed - /// argument list unrelated to `std::env::args_os()`) should pass the same - /// `--env` here too, or an environment named only in the later call's - /// argv won't have been consulted for pruning, and a flagged command that - /// environment would reveal (or hide) can disagree with what actually - /// dispatches. A test that configures `with_environments` should call - /// this (even with an empty iterator) to keep construction hermetic; - /// without it, `Cli::new` reads whatever real argv the test binary itself - /// was invoked with. - #[must_use] - pub fn with_startup_args(mut self, args: I) -> Self - where - I: IntoIterator, - S: Into, - { - self.startup_args = Some(args.into_iter().map(Into::into).collect()); - self - } - - /// Sets the minimum feature stage required for a flagged command, group, - /// or module to remain mounted. - /// - /// See [`min_stage`](Self::min_stage) for the default and [`FlagPolicy`] - /// for how it combines with [`feature_overrides`](Self::feature_overrides) - /// during command-tree pruning. - #[must_use] - pub fn with_min_stage(mut self, stage: Stage) -> Self { - self.min_stage = stage; - self - } - - /// Enables auto-interactive mode: when a TTY is detected, the CLI - /// defaults to interactive prompting for missing required arguments. - /// - /// Off by default for backwards compatibility. Enable once commands have - /// been tested under interactive prompting. `--interactive` still works as - /// an explicit override regardless of this setting. - #[must_use] - pub fn with_auto_interactive(mut self, enabled: bool) -> Self { - self.auto_interactive = enabled; - self - } - - /// Adds (or replaces) a per-key feature-flag stage override. - /// - /// See [`feature_overrides`](Self::feature_overrides) for how the - /// override participates in the [`FlagPolicy::visible`] comparison. - #[must_use] - pub fn with_feature_override(mut self, key: impl Into, stage: Stage) -> Self { - self.feature_overrides.insert(key.into(), stage); - self - } - - /// Builds the merged [`FlagPolicy`] used for command-tree pruning from - /// this config's `min_stage` and `feature_overrides`. - fn flag_policy(&self) -> FlagPolicy { - FlagPolicy { - min_stage: self.min_stage, - overrides: self.feature_overrides.clone(), - } - } - - /// Overrides the outbound User-Agent string for all HTTP traffic. - /// - /// When unset, the engine derives `name/version` from this config (see - /// [`CliConfig::user_agent_string`]). Set this when the upstream APIs expect - /// a specific product token. The resolved value is applied process-wide on - /// execution via [`crate::transport::set_default_user_agent`], so it reaches - /// both command [`HttpClient`](crate::transport::HttpClient)s and the - /// engine's own OAuth token requests. - #[must_use] - pub fn with_user_agent(mut self, user_agent: impl Into) -> Self { - self.user_agent = Some(user_agent.into()); - self - } - - /// Adds HTTP header names to redact in `--debug transport` output, on top of - /// the built-in sensitive set. - /// - /// Use this for CLI-specific secret-bearing headers that are not standard - /// auth headers — for example a custom API-key header that an - /// [`AuthInjector`](crate::transport::AuthInjector) sets. Matching is - /// case-insensitive and additive: the built-in set is always redacted. - /// Calls accumulate. Names are trimmed and empty entries are dropped, so a - /// mistyped value with stray whitespace cannot silently disable redaction. - #[must_use] - pub fn with_redacted_debug_headers( - mut self, - names: impl IntoIterator>, - ) -> Self { - self.redacted_debug_headers - .extend(names.into_iter().filter_map(|name| { - let name = name.into().trim().to_owned(); - (!name.is_empty()).then_some(name) - })); - self - } - - /// Returns the outbound User-Agent string the CLI presents on HTTP requests. - /// - /// Resolution order: - /// 1. an explicit [`with_user_agent`](Self::with_user_agent) override; - /// 2. otherwise `name/version` (for example `gdx/1.2.3`); - /// 3. otherwise just `name` when no build version is set. - #[must_use] - pub fn user_agent_string(&self) -> String { - if let Some(user_agent) = &self.user_agent { - return user_agent.clone(); - } - if self.build.version.is_empty() { - self.name.clone() - } else { - format!("{}/{}", self.name, self.build.version) - } - } - - /// Adds one domain module. - /// - /// # Reserved group names - /// - /// The top-level group names `help`, `guide`, `tree`, and `completion` are - /// reserved by the engine. A module whose root group uses one of these - /// names will be rejected at registration time (logged as a warning) so - /// the engine's own built-in always takes precedence in the command tree. - #[must_use] - pub fn with_module(mut self, module: Module) -> Self { - self.modules.push(module); - self - } - - /// Adds several domain modules. - /// - /// See [`with_module`](Self::with_module) for the list of reserved group names. - #[must_use] - pub fn with_modules(mut self, modules: impl IntoIterator) -> Self { - self.modules.extend(modules); - self - } - - /// Adds a top-level runtime command outside a module. - #[must_use] - pub fn with_command(mut self, command: RuntimeCommandSpec) -> Self { - self.commands.push(command); - self - } - - /// Adds commands mounted as siblings of the built-in `auth` group's - /// `login`/`status`/`logout`. - /// - /// Use this to extend `auth` with consumer-specific subcommands (e.g. - /// `auth scopes`) without losing or duplicating the built-ins — unlike - /// pre-registering an `auth` [`Module`], which either drops the built-ins - /// entirely or has them silently overwrite any extra command added this - /// way, these are folded in additively after building the built-in group. - #[must_use] - pub fn with_auth_extra_commands( - mut self, - commands: impl IntoIterator, - ) -> Self { - self.auth_extra_commands.extend(commands); - self - } - - /// Adds one global guide. - #[must_use] - pub fn with_guide(mut self, guide: GuideEntry) -> Self { - self.guides.push(guide); - self - } - - /// Adds several global guides. - #[must_use] - pub fn with_guides(mut self, guides: impl IntoIterator) -> Self { - self.guides.extend(guides); - self - } - - /// Adds one global human view. - #[must_use] - pub fn with_view(mut self, view: HumanViewDef) -> Self { - self.views.push(view); - self - } - - /// Registers one auth provider. - #[must_use] - pub fn with_auth_provider(mut self, provider: Arc) -> Self { - self.auth_providers.push(provider); - self - } - - /// Sets the authorization gatekeeper. - #[must_use] - pub fn with_authz(mut self, authz: Arc) -> Self { - self.authz = Some(authz); - self - } - - /// Sets the audit recorder. - #[must_use] - pub fn with_auditor(mut self, auditor: Arc) -> Self { - self.auditor = Some(auditor); - self - } - - /// Sets the activity event sink. - #[must_use] - pub fn with_activity(mut self, activity: Arc) -> Self { - self.activity = Some(activity); - self - } - - /// Sets the late dependency initializer. - #[must_use] - pub fn with_init_deps(mut self, init_deps: InitDeps) -> Self { - self.init_deps = Some(init_deps); - self - } - - /// Sets the application-specific global flag registration hook. - #[must_use] - pub fn with_register_flags(mut self, register_flags: RegisterFlags) -> Self { - self.register_flags = Some(register_flags); - self - } - - /// Sets the application-specific parsed flag application hook. - #[must_use] - pub fn with_apply_flags(mut self, apply_flags: ApplyFlags) -> Self { - self.apply_flags = Some(apply_flags); - self - } - - /// Sets the pre-run hook. - #[must_use] - pub fn with_pre_run(mut self, pre_run: PreRun) -> Self { - self.pre_run = Some(pre_run); - self - } - - /// Sets the command metadata resolver hook. - #[must_use] - pub fn with_meta_resolver(mut self, meta_resolver: ResolveMeta) -> Self { - self.meta_resolver = Some(meta_resolver); - self - } - - /// Sets the shutdown hook. - #[must_use] - pub fn with_on_shutdown(mut self, on_shutdown: OnShutdown) -> Self { - self.on_shutdown = Some(on_shutdown); - self - } - - /// Sets the provider for additional root-scope search documents. - #[must_use] - pub fn with_extra_search_docs(mut self, extra_search_docs: ExtraSearchDocs) -> Self { - self.extra_search_docs = Some(extra_search_docs); - self - } - - /// Sets the provider for the bare-root suggested next actions. - #[must_use] - pub fn with_root_next_actions(mut self, root_next_actions: RootNextActions) -> Self { - self.root_next_actions = Some(root_next_actions); - self - } - - /// Sets the name of the admin help category. The engine files the built-in - /// `auth` command there; apps should use the same name for their own admin - /// modules (e.g. godaddy's `env`). Optional: defaults to `"Admin"`. - #[must_use] - pub fn with_admin_category(mut self, category: impl Into) -> Self { - self.admin_category = Some(category.into()); - self - } - - /// Mounts the built-in `config` command group (`config get`/`set`/`path`/ - /// `list`) for reading and writing the per-application config file. - /// - /// Off by default so it never collides with a consumer's own `config` noun; - /// the group is filed under the admin help category when enabled. - #[must_use] - pub fn with_config_commands(mut self) -> Self { - self.config_commands = true; - self - } - - /// Registers an alternative `argv[0]` name that acts as a shortcut to a - /// command path on this same CLI. - /// - /// When the binary is invoked under `name` (via symlink, hardlink, copy, or - /// the hidden `argv0` command), the engine behaves as if the user had typed - /// `command_path` followed by the real argument tail, routed through the - /// normal command tree. For example: - /// - /// ``` - /// use cli_engine::CliConfig; - /// - /// // Invoking the binary as `pl --team platform` runs `project list --team platform`. - /// let config = CliConfig::new("my-cli", "Team CLI", "my-cli") - /// .with_argv0_alias("pl", ["project", "list"]); - /// ``` - /// - /// `name` must be a simple token: non-empty and composed only of ASCII - /// letters, digits, `-`, or `_` (no dots, spaces, path separators, or shell - /// metacharacters), and it must differ from the CLI's own name. These are - /// debug-asserted. The restriction keeps the name usable as a link/shim - /// filename and an `argv[0]` basename (which is matched with its extension - /// stripped, so a dot would break matching). - #[must_use] - pub fn with_argv0_alias( - mut self, - name: impl Into, - command_path: impl IntoIterator>, - ) -> Self { - let name = name.into(); - debug_assert!( - is_valid_argv0_name(&name), - "argv0 route name {name:?} must be non-empty and contain only ASCII letters, digits, '-', or '_'" - ); - debug_assert!( - name != self.name, - "argv0 route name {name:?} must differ from the CLI's own name {:?}", - self.name - ); - let tokens = command_path.into_iter().map(Into::into).collect(); - self.argv0_routes.insert(name, Argv0Route::Alias(tokens)); - self - } - - /// Registers an alternative `argv[0]` name that runs an entirely separate CLI - /// application. - /// - /// When the binary is invoked under `name`, the engine builds a fresh - /// [`CliConfig`] from `build` and runs that application instead — its own root - /// name, commands, flags, and auth. The closure runs lazily, only when the - /// route is dispatched, so unused personalities cost nothing. The personality - /// presents the name from its own [`CliConfig`] in help and usage output. - /// - /// ``` - /// use cli_engine::CliConfig; - /// - /// let config = CliConfig::new("my-cli", "Team CLI", "my-cli") - /// .with_argv0_personality("legacy-tool", || { - /// CliConfig::new("legacy-tool", "Legacy compatibility shim", "legacy-tool") - /// }); - /// ``` - /// - /// `name` follows the same contract as [`CliConfig::with_argv0_alias`]: a - /// simple `[A-Za-z0-9_-]` token, differing from the CLI's own name - /// (debug-asserted). - #[must_use] - pub fn with_argv0_personality( - mut self, - name: impl Into, - build: impl Fn() -> CliConfig + Send + Sync + 'static, - ) -> Self { - let name = name.into(); - debug_assert!( - is_valid_argv0_name(&name), - "argv0 route name {name:?} must be non-empty and contain only ASCII letters, digits, '-', or '_'" - ); - debug_assert!( - name != self.name, - "argv0 route name {name:?} must differ from the CLI's own name {:?}", - self.name - ); - self.argv0_routes - .insert(name, Argv0Route::Personality(Arc::new(build))); - self - } -} - -impl std::fmt::Debug for CliConfig { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter - .debug_struct("CliConfig") - .field("name", &self.name) - .field("short", &self.short) - .field("long", &self.long) - .field("build", &self.build) - .field("app_id", &self.app_id) - .field("default_auth_provider", &self.default_auth_provider) - .field("modules", &self.modules) - .field("commands", &self.commands) - .field("guides", &self.guides) - .field("views", &self.views) - .field("auth_providers_len", &self.auth_providers.len()) - .field("has_authz", &self.authz.is_some()) - .field("has_auditor", &self.auditor.is_some()) - .field("has_activity", &self.activity.is_some()) - .field("has_init_deps", &self.init_deps.is_some()) - .field("has_register_flags", &self.register_flags.is_some()) - .field("has_apply_flags", &self.apply_flags.is_some()) - .field("has_pre_run", &self.pre_run.is_some()) - .field("has_meta_resolver", &self.meta_resolver.is_some()) - .field("has_on_shutdown", &self.on_shutdown.is_some()) - .field("has_extra_search_docs", &self.extra_search_docs.is_some()) - .field("has_root_next_actions", &self.root_next_actions.is_some()) - .field("admin_category", &self.admin_category) - .field( - "argv0_routes", - &self.argv0_routes.keys().collect::>(), - ) - .field("min_stage", &self.min_stage) - .field("feature_overrides", &self.feature_overrides) - .finish() - } -} - -/// Captured result of running a CLI in tests or embedding contexts. -#[derive(Clone, Debug, PartialEq)] -pub struct CliRunOutput { - /// Process-style exit code. - pub exit_code: i32, - /// Rendered stdout or stderr payload. - pub rendered: String, -} - -impl From for CliRunOutput { - fn from(o: crate::middleware::MiddlewareOutput) -> Self { - Self { - exit_code: o.exit_code, - rendered: o.rendered, - } - } -} - -/// Configured CLI application. -/// -/// A `Cli` owns the `clap` command tree, middleware, registered runtime -/// commands, guides, schemas, and built-ins. Consumer binaries normally create -/// one `Cli` and call [`Cli::execute`]. -#[derive(Clone)] -pub struct Cli { - config: CliConfig, - middleware: Middleware, - root: Command, - commands: BTreeMap, - module_entries: Vec, - guide_entries: Vec, - init_deps: Option, - apply_flags: Option, - pre_run: Option, - meta_resolver: Option, - on_shutdown: Option, - extra_search_docs: Option, - root_next_actions: Option, - init_state: Arc>>>, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -struct InitFailure { - message: String, - code: String, - system: String, - request_id: String, - fix: Option, - exit_code: i32, -} - -impl InitFailure { - fn capture(err: &CliCoreError) -> Self { - let envelope = crate::output::build_error_envelope(err, ""); - let (code, system, request_id) = envelope.error.map_or_else( - || ("ERROR".to_owned(), String::new(), String::new()), - |error| (error.code, error.system, error.request_id), - ); - Self { - message: err.to_string(), - code, - system, - request_id, - fix: envelope.fix, - exit_code: exit_code_for_error(err), - } - } - - fn into_error(self) -> CliCoreError { - let message = CliCoreError::SystemMessage { - message: self.message, - system: self.system, - code: self.code, - request_id: self.request_id, - }; - CliCoreError::with_exit_code( - self.exit_code, - CliCoreError::with_fix(self.fix.unwrap_or_default(), message), - ) - } -} - -impl std::fmt::Debug for Cli { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter - .debug_struct("Cli") - .field("config", &self.config) - .field("middleware", &self.middleware) - .field("root", &self.root) - .field("commands", &self.commands) - .field("module_entries", &self.module_entries) - .field("guide_entries", &self.guide_entries) - .field("has_init_deps", &self.init_deps.is_some()) - .field("has_apply_flags", &self.apply_flags.is_some()) - .field("has_pre_run", &self.pre_run.is_some()) - .field("has_meta_resolver", &self.meta_resolver.is_some()) - .field("has_on_shutdown", &self.on_shutdown.is_some()) - .field("has_extra_search_docs", &self.extra_search_docs.is_some()) - .field("has_root_next_actions", &self.root_next_actions.is_some()) - .finish() - } -} - -impl Cli { - /// Builds a CLI application from declarative configuration. - #[must_use] - pub fn new(config: CliConfig) -> Self { - let auth_providers = config.auth_providers.clone(); - let guides = config.guides.clone(); - let views = config.views.clone(); - let modules = config.modules.clone(); - let commands = config.commands.clone(); - let init_deps = config.init_deps.clone(); - let apply_flags = config.apply_flags.clone(); - let pre_run = config.pre_run.clone(); - let meta_resolver = config.meta_resolver.clone(); - let on_shutdown = config.on_shutdown.clone(); - let extra_search_docs = config.extra_search_docs.clone(); - let root_next_actions = config.root_next_actions.clone(); - let mut root = Command::new(config.name.clone()) - .about(config.short.clone()) - .disable_help_subcommand(true) - .version(config.build.version_string()); - if let Some(long) = &config.long - && !long.is_empty() - { - root = root.long_about(long.clone()); - } - root = register_global_flags(root) - .subcommand(help_command()) - .subcommand(guide_command()) - .subcommand(Command::new("tree").about("Display full command tree")) - .subcommand(completion_command()) - .subcommand(search_command()); - if let Some(register_flags) = &config.register_flags { - root = register_flags(root); - } - // `--reason` is only meaningful when something actually consumes it — - // an authorizer, auditor, or activity emitter. Apps with none of those - // registered never see the flag at all, rather than a flag whose value - // is captured and silently discarded. This checks the eager `CliConfig` - // fields only: an authorizer/auditor/activity emitter installed later via - // `init_deps` runs per-request, after flag registration, so it can't be - // observed here. Apps that want `--reason` must set `authz`/`auditor`/ - // `activity` directly on `CliConfig`, not exclusively through `init_deps`. - if config.authz.is_some() || config.auditor.is_some() || config.activity.is_some() { - root = register_reason_flag(root); - } - if config.environments.is_some() { - root = root.arg( - Arg::new("env") - .long("env") - .global(true) - .value_name("ENV") - .display_order(crate::flags::global_flag_order::ENV) - .help("Override the active environment (see: env list)"), - ); - } - let intro = config - .long - .as_deref() - .filter(|long| !long.is_empty()) - .unwrap_or(config.short.as_str()); - root = root - .long_about(build_root_long(intro, &[], false)) - .help_template(ROOT_HELP_TEMPLATE); - - let mut middleware = Middleware::new(); - middleware.app_id = config.app_id.clone(); - // One-time, macOS-only: move any pre-existing $HOME/.config/ - // contents to $HOME/Library/Application Support/ before the - // config file below is loaded from its (possibly new) location. - crate::fs::migrate_macos_config_dir(&config.app_id); - // Load the per-application config file once at startup; cloned into each - // per-run middleware so handlers and module registration share it. - middleware.config = Arc::new(crate::config::ConfigFile::load(&config.app_id)); - middleware.default_auth_provider = config.default_auth_provider.clone().unwrap_or_default(); - middleware.authz = config.authz.clone(); - middleware.auditor = config.auditor.clone(); - middleware.activity = config.activity.clone(); - middleware - .schema_registry - .merge(&global_schema_registry_snapshot()); - middleware - .human_views - .merge(&global_human_view_registry_snapshot()); - if let Some(environments) = &config.environments { - // Seed the sticky/default active environment now, but let a - // startup `--env` win over it if one is present: `prescan_env_flag` - // scans `startup_args` (or, when unset, the real process argv) the - // same way `apply_env_flag` will parse it for real per invocation - // — this is what lets a same-invocation `--env ` affect the - // `flag_policy` computed below (and therefore which flagged - // commands get pruned), not just `middleware.env`. The real, - // per-invocation value used for dispatch still comes from - // `apply_env_flag`'s clap parse in `run_with_depth`; this prescan - // only decides tree shape earlier than clap otherwise could, - // since that decision can't be revisited once the tree is built. - let startup_args = config - .startup_args - .clone() - .unwrap_or_else(|| std::env::args_os().collect()); - let startup_env_flag = prescan_env_flag( - startup_args - .iter() - .skip(1) // argv[0] is the program name, same convention `run`/`execute_from` use - .map(|arg| arg.to_string_lossy().into_owned()), - ); - // The same `Arc` the consumer shared with any `PkceAuthProvider` is - // reused, so the file layer and active-env persistence resolve - // consistently. - middleware.env = - environments.effective_active(startup_env_flag.as_deref(), &middleware.config); - middleware.environments = Some(Arc::clone(environments)); - } - let mut flag_policy = config.flag_policy(); - if let Some(min_stage) = global_min_stage_override(&config.app_id) { - flag_policy.min_stage = min_stage; - } - if let Some(environments) = &middleware.environments - && let Ok(source) = environments.source(&middleware.env) - { - let chain = crate::env_config::SourceChain::new().push(&source); - match crate::env_config::resolve_field::( - &chain, - "min_stage", - "min_stage", - None, - false, - crate::env_config::default_from_toml::, - |_raw: &str| -> std::result::Result { Err(String::new()) }, - ) { - Ok(Some(min_stage)) => flag_policy.min_stage = min_stage, - Ok(None) => {} - Err(err) => { - tracing::warn!(env = %middleware.env, error = %err, "ignoring invalid environment min_stage"); - } - } - match crate::env_config::resolve_field::>( - &chain, - "feature_overrides", - "feature_overrides", - None, - false, - crate::env_config::default_from_toml::>, - |_raw: &str| -> std::result::Result, String> { - Err(String::new()) - }, - ) { - Ok(Some(overrides)) => flag_policy.overrides.extend(overrides), - Ok(None) => {} - Err(err) => { - tracing::warn!(env = %middleware.env, error = %err, "ignoring invalid environment feature_overrides"); - } - } - } - middleware.flag_policy = flag_policy; - - let mut cli = Self { - config, - middleware, - root, - commands: BTreeMap::new(), - module_entries: Vec::new(), - guide_entries: Vec::new(), - init_deps, - apply_flags, - pre_run, - meta_resolver, - on_shutdown, - extra_search_docs, - root_next_actions, - init_state: Arc::new(Mutex::new(None)), - }; - for provider in auth_providers { - cli.register_auth_provider(provider); - } - if cli.middleware.default_auth_provider.is_empty() - && let Some(provider) = cli.middleware.auth.registered_names().first() - { - cli.middleware.default_auth_provider = provider.clone(); - } - if !cli.middleware.default_auth_provider.is_empty() { - cli.ensure_auth_command(); - } - for view in views { - cli.middleware.human_views.register(view); - } - cli.add_guides(guides); - for module in modules { - cli.add_module(module); - } - for command in commands { - cli.add_command(command); - } - if cli.config.config_commands { - cli.ensure_config_command(); - } - if cli.config.environments.is_some() { - cli.ensure_env_command(); - } - cli.ensure_flags_command(); - cli - } - - /// Lists the auto-registered `auth` command under the admin help category so - /// it is never uncategorized once clap's auto subcommand list is suppressed. - /// Defaults to [`DEFAULT_ADMIN_CATEGORY`]; `admin_category` overrides it to - /// align with a consumer's own taxonomy. - fn register_auth_help_entry(&mut self) { - let category = self - .config - .admin_category - .clone() - .unwrap_or_else(|| DEFAULT_ADMIN_CATEGORY.to_owned()); - let already_listed = self.module_entries.iter().any(|entry| entry.name == "auth"); - let short = self - .root - .find_subcommand("auth") - .filter(|auth| !auth.is_hide_set()) - .map(|auth| { - auth.get_about() - .map(ToString::to_string) - .unwrap_or_default() - }); - if !already_listed && let Some(short) = short { - self.module_entries.push(ModuleHelpEntry { - category, - name: "auth".to_owned(), - short, - }); - } - self.refresh_root_long(); - } - - /// Returns the shared middleware template. - #[must_use] - pub fn middleware(&self) -> &Middleware { - &self.middleware - } - - /// Returns mutable middleware for advanced application setup. - pub fn middleware_mut(&mut self) -> &mut Middleware { - &mut self.middleware - } - - /// Executes the CLI with process arguments and process stdout/stderr. - pub async fn execute(&self) -> ExitCode { - let mut stdout = std::io::stdout().lock(); - let mut stderr = std::io::stderr().lock(); - match self - .execute_from(std::env::args_os(), &mut stdout, &mut stderr) - .await - { - Ok(code) => code, - Err(err) => { - drop(writeln!(stderr, "{err}")); - ExitCode::from(1) - } - } - } - - /// Executes the CLI with caller-provided args and output writers. - /// - /// If `args` carries a synthetic `--env` unrelated to real process argv - /// (or to whatever [`CliConfig::with_startup_args`] this `Cli` was built - /// with), command-tree pruning — decided once, at construction time — - /// won't reflect it; see `with_startup_args`'s doc for why. - pub async fn execute_from( - &self, - args: I, - stdout: &mut O, - stderr: &mut E, - ) -> std::io::Result - where - I: IntoIterator, - S: Into + Clone, - O: Write, - E: Write, - { - self.execute_from_until_signal(args, stdout, stderr, shutdown_signal()) - .await - } - - /// Executes the CLI until either command completion or a shutdown signal future resolves. - pub async fn execute_from_until_signal( - &self, - args: I, - stdout: &mut O, - stderr: &mut E, - shutdown: Shutdown, - ) -> std::io::Result - where - I: IntoIterator, - S: Into + Clone, - O: Write, - E: Write, - Shutdown: Future, - { - self.install_default_user_agent(); - let output = run_until_signal(self.run(args), shutdown).await; - if output.exit_code == 130 - && output.rendered == "command interrupted\n" - && let Some(on_shutdown) = &self.on_shutdown - { - on_shutdown(); - } - if output.exit_code == 0 { - stdout.write_all(output.rendered.as_bytes())?; - } else { - stderr.write_all(output.rendered.as_bytes())?; - } - Ok(process_exit_code(output.exit_code)) - } - - /// Publishes the configured outbound User-Agent process-wide so that - /// command [`HttpClient`](crate::transport::HttpClient)s and the engine's - /// own OAuth token requests share it. - /// - /// Called from the execution entrypoints rather than [`Cli::new`] so that - /// merely constructing a `Cli` (as tests do in bulk) does not mutate global - /// state. See [`CliConfig::user_agent_string`] for resolution order. - fn install_default_user_agent(&self) { - crate::transport::set_default_user_agent(self.config.user_agent_string()); - } - - /// Registers an auth provider after construction. - pub fn register_auth_provider(&mut self, provider: Arc) -> &mut Self { - self.middleware.auth.register(provider); - self.ensure_auth_command(); - self.refresh_root_long(); - self - } - - /// Returns the built `clap` root command. - #[must_use] - pub fn root_command(&self) -> &Command { - &self.root - } - - /// Adds one runtime module group after construction. - pub fn add_module_group( - &mut self, - category: impl Into, - group: RuntimeGroupSpec, - ) -> &mut Self { - self.add_module_group_inner(category, group, None) - } - - /// Shared implementation behind [`add_module_group`](Self::add_module_group) - /// and [`add_module`](Self::add_module). `inherited` is the effective - /// feature flag the group's enclosing module declared (if any), so a - /// module-level flag cascades down to the group even though - /// `add_module_group` itself has no concept of a module. - fn add_module_group_inner( - &mut self, - category: impl Into, - group: RuntimeGroupSpec, - inherited: Option, - ) -> &mut Self { - // Prevent consumer modules from shadowing engine built-ins in the clap - // command tree. A reserved group name would override the engine's own - // subcommand (last-writer-wins in clap) and corrupt the dispatch path. - if BUILTIN_COMMAND_NAMES.contains(&group.group.name.as_str()) { - tracing::warn!( - name = %group.group.name, - "module group name is reserved by cli-engine built-ins; the group will not be registered" - ); - return self; - } - - let mut prefix = Vec::new(); - let Some(group) = prune_feature_flag_tree( - group, - inherited.as_ref(), - &self.middleware.flag_policy, - &mut prefix, - &mut self.middleware.flag_registry, - ) else { - return self; - }; - - let category = category.into(); - if !group.group.hidden { - self.module_entries.push(ModuleHelpEntry { - category, - name: group.group.name.clone(), - short: group.group.short.clone(), - }); - } - - let mut prefix = Vec::new(); - register_runtime_group_metadata( - &group, - &mut prefix, - &mut self.middleware.schema_registry, - &mut self.middleware.human_views, - ); - let mut prefix = Vec::new(); - group.register_commands(&mut prefix, &mut self.commands); - let mut prefix = Vec::new(); - let clap_group = runtime_group_clap_command_with_schema_help( - &group, - &mut prefix, - &self.middleware.schema_registry, - ); - self.root = self.root.clone().subcommand(clap_group); - self.refresh_root_long(); - self - } - - /// Adds one module after construction. - pub fn add_module(&mut self, module: Module) -> &mut Self { - for view in module.views.clone() { - self.middleware.human_views.register(view); - } - self.add_guides(module.guides.clone()); - let mut context = ModuleContext::new(&mut self.middleware); - let group = (module.register)(&mut context); - let (guides, views) = context.into_parts(); - for view in views { - self.middleware.human_views.register(view); - } - self.add_guides(guides); - self.add_module_group_inner(module.category, group, module.feature_flag.clone()) - } - - /// Adds one top-level runtime command after construction. - pub fn add_command(&mut self, command: RuntimeCommandSpec) -> &mut Self { - let name = command.spec.name.clone(); - register_command_schema(&command.spec, &name, &mut self.middleware.schema_registry); - self.commands.insert(name, command.clone()); - self.root = self - .root - .clone() - .subcommand(command_clap_command_with_schema_help( - &command.spec, - &command.spec.name, - &self.middleware.schema_registry, - )); - self - } - - /// Controls whether the built-in `guide` command is advertised. - pub fn set_has_guide(&mut self, has_guide: bool) -> &mut Self { - if has_guide && self.guide_entries.is_empty() && !has_subcommand(&self.root, "guide") { - self.root = self.root.clone().subcommand(guide_command()); - } - self.sync_guide_topic_values(); - self.refresh_root_long(); - self - } - - /// Adds guide entries after construction. - pub fn add_guides(&mut self, entries: impl IntoIterator) -> &mut Self { - let mut seen = self - .guide_entries - .iter() - .map(|entry| entry.name.clone()) - .collect::>(); - for entry in entries { - if seen.insert(entry.name.clone()) { - self.guide_entries.push(entry); - } - } - if !self.guide_entries.is_empty() && !has_subcommand(&self.root, "guide") { - self.root = self.root.clone().subcommand(guide_command()); - } - self.sync_guide_topic_values(); - self.refresh_root_long(); - self - } - - /// Re-attaches the `guide` subcommand's `topic` arg possible values from - /// the current [`Self::guide_entries`], so shell completion knows about - /// guide names, which are not all registered up front. - fn sync_guide_topic_values(&mut self) { - if self.guide_entries.is_empty() { - return; - } - let names = self - .guide_entries - .iter() - .map(|entry| entry.name.clone()) - .collect::>(); - if let Some(guide_cmd) = self.root.find_subcommand_mut("guide") { - let taken = std::mem::replace(guide_cmd, Command::new("guide")); - *guide_cmd = taken.mut_arg("topic", |arg| { - arg.value_parser(PossibleValuesParser::new(names)) - }); - } - } - - /// Resolves busybox/git-style `argv[0]` dispatch before the normal pipeline. - /// - /// Returns [`Argv0Outcome::Proceed`] with the (possibly rewritten) argument - /// vector to feed the normal command pipeline, or [`Argv0Outcome::Handled`] - /// with a fully rendered result when a personality ran or an explicit `argv0` - /// invocation was rejected. When no routes are registered this is inert and - /// returns the arguments unchanged. `depth` counts chained hand-offs and - /// bounds recursion via [`MAX_ARGV0_DEPTH`]. - async fn resolve_argv0(&self, text_args: Vec, depth: usize) -> Argv0Outcome { - if self.config.argv0_routes.is_empty() { - return Argv0Outcome::Proceed(text_args); - } - - if depth > MAX_ARGV0_DEPTH { - return Argv0Outcome::Handled( - self.render_argv0_error(&text_args, "argv0 dispatch recursion limit exceeded"), - ); - } - - // The hidden `argv0` meta-command (` argv0 [args...]`) forces - // a route without an actual symlink. It is recognized positionally as the - // first argument after the program name and is never registered with clap, - // so it stays absent from `--help`, `tree`, and the `search` command. - let explicit = text_args.get(1).map(String::as_str) == Some("argv0"); - let (name, rest) = if explicit { - match text_args.get(2) { - None => { - return Argv0Outcome::Handled(self.render_argv0_error( - &text_args, - "the argv0 command requires a name to dispatch as", - )); - } - // Normalize the explicit name the same way as a symlink basename - // so a route registered as `whatever` matches whether the caller - // passed `whatever`, `whatever.exe`, or a `.cmd` shim's `whatever.cmd`. - Some(name) => ( - program_basename(name), - text_args - .get(3..) - .map(<[String]>::to_vec) - .unwrap_or_default(), - ), - } - } else { - let name = text_args - .first() - .map(|arg| program_basename(arg)) - .unwrap_or_default(); - let rest = text_args - .get(1..) - .map(<[String]>::to_vec) - .unwrap_or_default(); - (name, rest) - }; - - match self.config.argv0_routes.get(&name) { - Some(Argv0Route::Alias(tokens)) => { - // Rewrite as ` `. Element 0 is - // the canonical name so the downstream program-name skip applies. - let mut rewritten = Vec::with_capacity(1 + tokens.len() + rest.len()); - rewritten.push(self.config.name.clone()); - rewritten.extend(tokens.iter().cloned()); - rewritten.extend(rest); - Argv0Outcome::Proceed(rewritten) - } - Some(Argv0Route::Personality(build)) => { - // Hand off to an independent CLI built lazily from the route. Its - // own config name leads so its help/usage and program-name skip - // render correctly. `Box::pin` breaks the recursive `async fn`; - // `depth + 1` bounds a pathological chain of hand-offs. - let config = build(); - let bin = config.name.clone(); - let alt = Self::new(config); - let mut alt_args = Vec::with_capacity(1 + rest.len()); - alt_args.push(bin); - alt_args.extend(rest); - Argv0Outcome::Handled(Box::pin(alt.run_with_depth(alt_args, depth + 1)).await) - } - None if explicit => Argv0Outcome::Handled(self.render_argv0_error( - &text_args, - format!( - "{name:?} is not a registered argv0 name; known names: {}", - self.known_argv0_names() - ), - )), - None => { - // Unregistered name (e.g. the binary renamed to something we do not - // recognize): fall through to the default CLI. Normalizing element 0 - // to the canonical name lets a renamed binary parse as the default - // application instead of treating its name as a command token. - let mut rewritten = Vec::with_capacity(1 + rest.len()); - rewritten.push(self.config.name.clone()); - rewritten.extend(rest); - Argv0Outcome::Proceed(rewritten) - } - } - } - - /// Computes the default output format for this run — the fallback used - /// when no explicit `--output`/`--json`/`--human`/`--toon` is given. - fn resolve_run_output_format(&self) -> String { - use std::io::IsTerminal; - - let env = std::env::var(output_env_var(&self.config.app_id)).ok(); - let engine_config = self.middleware.config.engine(); - resolve_default_output_format( - env.as_deref(), - engine_config.output.format.as_deref(), - std::io::stdout().is_terminal(), - ) - } - - /// Comma-separated, sorted list of registered alternative `argv[0]` names, - /// used in the error shown for an unknown explicit `argv0` invocation. - fn known_argv0_names(&self) -> String { - self.config - .argv0_routes - .keys() - .cloned() - .collect::>() - .join(", ") - } - - /// Renders an `argv0`-dispatch error through the engine's structured error - /// envelope so it honors `--output` (parsed from the raw args, since dispatch - /// runs before clap) and the shared exit-code mapping, matching every other - /// CLI error rather than emitting bare text. - fn render_argv0_error(&self, text_args: &[String], message: impl Into) -> CliRunOutput { - let mut middleware = self.middleware.clone(); - middleware.output_format = - extract_output_format(text_args, &self.resolve_run_output_format()); - let err = CliCoreError::message(message); - self.finish_run(render_cli_error(&middleware, &err, &self.config.app_id)) - } - - /// Returns the registered alternative `argv[0]` names, sorted. - /// - /// Useful for install or self-healing code that iterates the names and calls - /// [`Cli::create_link`] for each. - #[must_use] - pub fn argv0_names(&self) -> Vec<&str> { - self.config - .argv0_routes - .keys() - .map(String::as_str) - .collect() - } - - /// Creates an on-disk link in `dir` that lets the binary be invoked under the - /// registered alternative `argv[0]` name `name`, using `method`. - /// - /// `target` is the executable the link points at; pass `None` to use the - /// current executable ([`std::env::current_exe`]), which is the common choice - /// for install and self-healing code. The file name follows the platform and - /// method: a symlink or hard link is `` on Unix and `.exe` on - /// Windows; a [`Argv0LinkMethod::Script`] shim is `.cmd` on Windows and - /// an executable `` shell script on Unix. - /// - /// The call ensures the desired state idempotently: if the destination already - /// matches what would be created (a symlink to `target`, a hard link with the - /// same contents, or a shim with identical contents) it is left untouched and - /// its path returned; if it exists but differs (wrong kind, stale target, or - /// edited shim) it is replaced. This makes the call safe to re-run as install - /// or self-healing code, restoring both deleted and corrupted links. The - /// directory is created if necessary. - /// - /// # Errors - /// - /// Returns an error if `name` is not a registered route, if the current - /// executable cannot be resolved (when `target` is `None`), or if the - /// directory or link cannot be created or replaced (e.g. insufficient - /// privilege for a Windows symlink, or a hard link across volumes). - pub fn create_link( - &self, - name: &str, - dir: impl AsRef, - target: Option<&Path>, - method: Argv0LinkMethod, - ) -> std::io::Result { - if !self.config.argv0_routes.contains_key(name) { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidInput, - format!("{name:?} is not a registered argv0 name"), - )); - } - - let dir = dir.as_ref(); - std::fs::create_dir_all(dir)?; - let link = dir.join(argv0_link_file_name(name, method)); - - // Resolve the target up front so an existing entry can be compared against it. - let resolved_target; - let target = match target { - Some(target) => target, - None => { - resolved_target = std::env::current_exe()?; - resolved_target.as_path() - } - }; - - // Ensure-desired-state. `symlink_metadata` does not follow links, so a - // present-but-dangling link still counts as existing. A matching entry is - // left untouched (idempotent); a differing one is removed and recreated. - if std::fs::symlink_metadata(&link).is_ok() { - if argv0_link_matches(&link, target, name, method)? { - return Ok(link); - } - std::fs::remove_file(&link)?; - } - - match method { - Argv0LinkMethod::SoftLink => create_symlink(target, &link)?, - Argv0LinkMethod::HardLink => std::fs::hard_link(target, &link)?, - Argv0LinkMethod::Script => { - std::fs::write(&link, argv0_script_contents(target, name))?; - make_executable(&link)?; - } - } - Ok(link) - } - - /// Runs the CLI with provided args and captures the rendered result. - /// - /// Same `--env`/tree-pruning caveat as [`Cli::execute_from`]: see - /// [`CliConfig::with_startup_args`]. - pub async fn run(&self, args: I) -> CliRunOutput - where - I: IntoIterator, - S: Into + Clone, - { - self.run_with_depth(args, 0).await - } - - /// Runs the CLI like [`Cli::run`], threading the `argv0` dispatch recursion - /// `depth` so a chain of personality hand-offs is bounded by [`MAX_ARGV0_DEPTH`]. - async fn run_with_depth(&self, args: I, depth: usize) -> CliRunOutput - where - I: IntoIterator, - S: Into + Clone, - { - let raw_args = args - .into_iter() - .map(Into::into) - .collect::>(); - let text_args = raw_args - .iter() - .map(|arg| arg.to_string_lossy().into_owned()) - .collect::>(); - let text_args = match self.resolve_argv0(text_args, depth).await { - Argv0Outcome::Handled(output) => return output, - Argv0Outcome::Proceed(args) => args, - }; - let mut clap_args = normalize_optional_global_flags_before_command(&self.root, &text_args); - if has_root_version_flag(&text_args, &self.root, &self.config.name) { - return self.finish_run(CliRunOutput { - exit_code: 0, - rendered: format!( - "{} version {}\n", - self.config.name, - self.config.build.version_string() - ), - }); - } - if let Some(output) = self.try_run_schema_bypass(&text_args) { - return output; - } - // Resolve the positional command path once and share it between the - // group-help rewrite and the unknown-command check below. - let bool_flags = derive_bool_flags(&self.root); - let value_flags = derive_value_flags(&self.root); - let positionals = - positional_command_tokens(&text_args, &self.config.name, &bool_flags, &value_flags); - let command_keyword_count = - command_keyword_count(&text_args, &self.config.name, &bool_flags, &value_flags); - if let Some(parts) = - group_help_target_parts(&self.root, &positionals, command_keyword_count) - { - // Rewrite ` help [sub...]` into the canonical - // `help [sub...]` so it flows through the curated root - // `help` command, which also runs global-flag parsing and the - // `pre_run` hook (matching `help ` and bare-group help). - // Only the positional command tokens are reordered; every flag and - // its value is preserved in place so e.g. `--output json` survives. - clap_args = rewrite_group_help_args( - &clap_args, - &self.config.name, - &bool_flags, - &value_flags, - &parts, - ); - } else if let Some(unknown) = - detect_unknown_group_command(&self.root, &positionals[..command_keyword_count]) - { - // Hint/re-dispatch only when the whole path resolves to one command. - if let Some(corrections) = - full_command_correction(&self.root, &positionals[..command_keyword_count]) - { - let display = correction_display( - &self.config.name, - &positionals[..command_keyword_count], - &corrections, - ); - let full_fix_message = format_did_you_mean(&unknown.base, &display); - match crate::prompt::confirm_command_correction( - &clap_args, - &display, - self.config.auto_interactive, - ) { - crate::prompt::CommandCorrection::Accepted => { - for (index, replacement) in &corrections { - clap_args = replace_positional_command_token( - &clap_args, - &self.config.name, - &bool_flags, - &value_flags, - *index, - replacement, - ); - } - clap_args = rewrite_group_help_if_needed( - &self.root, - &clap_args, - &self.config.name, - &bool_flags, - &value_flags, - ); - } - crate::prompt::CommandCorrection::Declined => { - return self.finish_run(CliRunOutput { - exit_code: 1, - rendered: full_fix_message, - }); - } - crate::prompt::CommandCorrection::Cancelled => { - return self.finish_run(CliRunOutput { - exit_code: 130, - rendered: "Cancelled.".to_owned(), - }); - } - } - } else { - return self.finish_run(CliRunOutput { - exit_code: 1, - rendered: unknown.base, - }); - } - } - - let matches = match self.root.clone().try_get_matches_from(&clap_args) { - Ok(matches) => matches, - Err(err) => { - // Attempt interactive recovery for missing required arguments. - if let Some(recovery) = crate::prompt::try_recover_missing_args( - &err, - &clap_args, - &self.root, - &self.config.name, - self.config.auto_interactive, - ) { - match recovery { - crate::prompt::RecoveryResult::Recovered { args } => { - match self.root.clone().try_get_matches_from(args) { - Ok(m) => m, - Err(retry_err) => { - return self.finish_run(CliRunOutput { - exit_code: retry_err.exit_code(), - rendered: retry_err.to_string(), - }); - } - } - } - crate::prompt::RecoveryResult::Cancelled { resume } => { - return self.finish_run(CliRunOutput { - exit_code: 130, - rendered: format!("Cancelled. Resume with:\n {resume}\n"), - }); - } - } - } else { - return self.finish_run(CliRunOutput { - exit_code: err.exit_code(), - rendered: err.to_string(), - }); - } - } - }; - - let default_format = self.resolve_run_output_format(); - let flags = - global_flags_from_matches(&matches, &default_format, self.config.auto_interactive); - // Publish the --credential-store override so auth providers resolving - // their storage backend see it at the top of the precedence chain. - crate::config::set_credential_store_flag(flags.credential_store); - let command_timeout = match parse_command_timeout(&flags.timeout) { - Ok(timeout) => timeout, - Err(err) => { - return self.finish_run(render_cli_error( - &self.middleware, - &err, - &self.config.app_id, - )); - } - }; - let mut middleware = self.middleware.clone(); - apply_global_flags(&mut middleware, &flags, command_timeout); - install_debug_transport_logger(&flags.debug, &self.config.redacted_debug_headers); - if let Err(err) = self.apply_config_flags(&matches, &mut middleware) { - return self.finish_run(render_cli_error(&middleware, &err, &self.config.app_id)); - } - // Validate and apply `--env` for built-in paths (help/tree/guide/group - // help) so they reflect the selected environment and reject unknowns. - if let Err(err) = self.apply_env_flag(&matches, &mut middleware) { - return self.finish_run(render_cli_error(&middleware, &err, &self.config.app_id)); - } - - let command_path = command_path_from_matches(&self.config.name, &matches); - if command_path == "help" { - if let Err(err) = self.run_pre_run(&mut middleware, &command_path, &help_args(&matches)) - { - return self.finish_run(render_cli_error(&middleware, &err, &self.config.app_id)); - } - return self.finish_run(self.render_help_command(&matches)); - } - if command_path == "tree" { - if let Err(err) = self.run_pre_run( - &mut middleware, - &command_path, - &crate::middleware::ValueMap::new(), - ) { - return self.finish_run(render_cli_error(&middleware, &err, &self.config.app_id)); - } - return self.finish_run(tree_render::render_tree( - &self.root, - &self.config.app_id, - &middleware, - )); - } - if command_path == "guide" { - if let Err(err) = - self.run_pre_run(&mut middleware, &command_path, &guide_args(&matches)) - { - return self.finish_run(render_cli_error(&middleware, &err, &self.config.app_id)); - } - return self.finish_run(self.render_guide(&matches, &flags.output_format)); - } - if command_path == "search" { - let args = search_args(&matches); - if let Err(err) = self.run_pre_run(&mut middleware, &command_path, &args) { - return self.finish_run(render_cli_error(&middleware, &err, &self.config.app_id)); - } - let query = args - .get("query") - .and_then(|v| v.as_str()) - .unwrap_or_default(); - let scope_path = args - .get("scope") - .and_then(|v| v.as_str()) - .unwrap_or_default(); - let scope = self.resolve_search_scope(scope_path); - return self.finish_run(self.render_search(query, &scope, &flags.output_format)); - } - if command_path == "completion" { - let args = completion_args(&matches); - if let Err(err) = self.run_pre_run(&mut middleware, &command_path, &args) { - return self.finish_run(render_cli_error(&middleware, &err, &self.config.app_id)); - } - let install = args - .get("install") - .and_then(|v| v.as_bool()) - .unwrap_or(false); - let shell_opt = args - .get("shell") - .and_then(|v| v.as_str()) - .map(str::to_owned); - if install { - use crate::cli::completion::{detect_shell, parse_shell}; - let shell = match shell_opt { - Some(ref s) => match parse_shell(s) { - Ok(s) => s, - Err(e) => { - return self.finish_run(render_cli_error( - &middleware, - &e, - &self.config.app_id, - )); - } - }, - None => match detect_shell() { - Ok(s) => s, - Err(e) => { - return self.finish_run(render_cli_error( - &middleware, - &e, - &self.config.app_id, - )); - } - }, - }; - return self.finish_run( - completion::install(&self.root, &self.config.name, shell) - .await - .unwrap_or_else(|e| render_cli_error(&middleware, &e, &self.config.app_id)), - ); - } - return self.finish_run(self.render_completion_print(shell_opt, &middleware)); - } - let Some(command) = self.commands.get(&command_path) else { - if !command_path.is_empty() - && let Some(group) = find_command_by_colon_path(&self.root, &command_path) - && group.get_subcommands().next().is_some() - { - if let Err(err) = self.run_pre_run( - &mut middleware, - &command_path, - &crate::middleware::ValueMap::new(), - ) { - return self.finish_run(render_cli_error( - &middleware, - &err, - &self.config.app_id, - )); - } - if middleware.interactive - && let Some(subcommand) = single_leaf_subcommand(group) - { - let augmented = inject_subcommand_after_command_path( - &text_args, - &self.config.name, - &command_path, - &subcommand, - &bool_flags, - &value_flags, - ); - return Box::pin(self.run_with_depth(augmented, depth + 1)).await; - } - return self.finish_run(self.render_bare_group_discovery( - group, - &command_path, - &middleware, - )); - } - if command_path.is_empty() - && let Some(root_next_actions) = &self.root_next_actions - { - // Bare-root discovery is static (help text / metadata + action - // pointers) and must always be available as a cold-start entry - // point, so we skip `pre_run` here — matching the no-hook - // bare-root path below, which also renders help without it. - let actions = root_next_actions(); - return self.finish_run(self.render_root(&middleware, actions)); - } - return self.finish_run(CliRunOutput { - exit_code: if command_path.is_empty() { 0 } else { 1 }, - rendered: if command_path.is_empty() { - self.root.clone().render_long_help().to_string() - } else { - format!("unknown command {command_path:?}") - }, - }); - }; - - let mut middleware = match self.initialized_middleware() { - Ok(middleware) => middleware, - Err(err) => { - return self.finish_run(render_cli_error(&middleware, &err, &self.config.app_id)); - } - }; - apply_global_flags(&mut middleware, &flags, command_timeout); - install_debug_transport_logger(&flags.debug, &self.config.redacted_debug_headers); - if let Err(err) = self.apply_config_flags(&matches, &mut middleware) { - return self.finish_run(render_cli_error(&middleware, &err, &self.config.app_id)); - } - // The global `--env` flag overrides the seeded active environment for - // this invocation; an unknown name surfaces as an error envelope. - if let Err(err) = self.apply_env_flag(&matches, &mut middleware) { - return self.finish_run(render_cli_error(&middleware, &err, &self.config.app_id)); - } - - let leaf = leaf_matches(&matches); - apply_pagination_flags(&mut middleware, &command.spec, leaf); - let args = command_args_from_matches(leaf, &command.spec, false); - let user_args = command_args_from_matches(leaf, &command.spec, true); - let pagination_command = command.spec.pagination.is_some().then(|| { - pagination_command_base( - &self.config.name, - &command_path, - &command.spec, - &user_args, - &flags, - ) - }); - if let Err(err) = self.run_pre_run(&mut middleware, &command_path, &args) { - return self.finish_run(render_cli_error(&middleware, &err, &self.config.app_id)); - } - let meta = self.resolve_meta(&command_path, command.spec.metadata()); - let default_fields = command.spec.default_fields.clone().unwrap_or_default(); - let system = command.spec.system.clone().unwrap_or_default(); - // The human view this command declared: an explicit shared id wins; - // otherwise an inline `with_view` was registered under the command path - // at build time, so reference it by that path. `None` renders generic - // human output. - let view_id = command - .spec - .view_id - .clone() - .or_else(|| (!command.spec.view_columns.is_empty()).then(|| command_path.clone())); - - if let Some(streaming_handler) = command.streaming_handler.clone() { - let result = run_with_timeout( - command_timeout, - &flags.timeout, - run_streaming_command( - &middleware, - MiddlewareRequest { - meta, - command_path: &command_path, - system: &system, - user_args, - args, - default_fields: &default_fields, - view_id: view_id.as_deref(), - auth: command.spec.auth, - raw_output: command.spec.raw_output, - pagination_command, - }, - Arc::new(leaf.clone()), - streaming_handler, - ), - ) - .await; - return self.finish_run(match result { - Ok(output) => output, - Err(err) => render_cli_error(&middleware, &err, &self.config.app_id), - }); - } - - let handler = command.handler.clone(); - let args_for_handler = args.clone(); - let user_args_for_handler = user_args.clone(); - let handler_path = command_path.clone(); - let middleware_for_handler = middleware.clone(); - let raw_matches_for_handler = Arc::new(leaf.clone()); - let result = run_with_timeout( - command_timeout, - &flags.timeout, - middleware.run( - MiddlewareRequest { - meta, - command_path: &command_path, - system: &system, - user_args, - args, - default_fields: &default_fields, - view_id: view_id.as_deref(), - auth: command.spec.auth, - raw_output: command.spec.raw_output, - pagination_command, - }, - async move |credential| { - handler(CommandContext { - credential, - args: args_for_handler, - user_args: user_args_for_handler, - command_path: handler_path, - middleware: middleware_for_handler, - raw_matches: raw_matches_for_handler, - }) - .await - }, - ), - ) - .await; - - match result { - Ok(output) => self.finish_run(output.into()), - Err(err) => self.finish_run(render_cli_error(&middleware, &err, &self.config.app_id)), - } - } - - fn try_run_schema_bypass(&self, args: &[String]) -> Option { - if !has_true_schema_flag(args) { - return None; - } - let bool_flags = derive_bool_flags(&self.root); - let value_flags = derive_value_flags(&self.root); - let command_path = - self.canonical_command_path(&extract_command_path(args, &bool_flags, &value_flags)); - // `--schema` is an inspection flag and must not require the command's own - // arguments, so it short-circuits before clap validates them. Only fire - // for a real leaf command, though: unknown paths and groups fall through - // so clap and `detect_unknown_group_command` can report them as usual. - let command = find_command_by_colon_path(&self.root, &command_path)?; - if command.get_subcommands().next().is_some() { - return None; - } - let output_format = extract_output_format(args, &self.resolve_run_output_format()); - // When no schema is registered, report that rather than running the - // command — matching the middleware's no-schema response so the public - // path and the lower layer agree even when required args are missing. - match self.middleware.schema_registry.get_by_path(&command_path) { - Some(schema) => Some(self.render_schema(schema, &output_format)), - None => Some(self.render_schema( - crate::output::no_schema_response(&command_path), - &output_format, - )), - } - } - - fn render_schema(&self, data: impl serde::Serialize, output_format: &str) -> CliRunOutput { - let format: crate::output::OutputFormat = match output_format.parse() { - Ok(format) => format, - Err(err) => { - return CliRunOutput { - exit_code: exit_code_for_error(&err), - rendered: err.to_string(), - }; - } - }; - let envelope = - crate::Envelope::success(data, self.config.app_id.clone()).prepare_for_render(""); - match crate::output::render(format, &envelope) { - Ok(rendered) => CliRunOutput { - exit_code: 0, - rendered, - }, - Err(err) => CliRunOutput { - exit_code: exit_code_for_error(&err), - rendered: err.to_string(), - }, - } - } - - /// Renders a bare group invocation (no subcommand given). - /// - /// Human output keeps the existing clap help text; every other format, - /// explicit `--output json`/`--toon`, or the non-TTY default an agent - /// sees with no `--output` flag at all — gets an explicit JSON - /// command-tree subset scoped to this group, built with the same - /// [`crate::tree`] machinery as the top-level `tree` command. - fn render_bare_group_discovery( - &self, - group: &Command, - command_path: &str, - middleware: &Middleware, - ) -> CliRunOutput { - let format: crate::output::OutputFormat = match middleware.output_format.parse() { - Ok(format) => format, - Err(err) => { - return CliRunOutput { - exit_code: exit_code_for_error(&err), - rendered: err.to_string(), - }; - } - }; - if format == crate::output::OutputFormat::Human { - return CliRunOutput { - exit_code: 0, - rendered: group.clone().render_long_help().to_string(), - }; - } - let path = format!("{} {}", self.config.name, command_path.replace(':', " ")); - let tree = crate::tree::build_tree_from_clap_with_path(group, path); - tree_render::render_tree_envelope(tree, &self.config.app_id, middleware, format) - } - - fn render_search(&self, query: &str, scope: &str, output_format: &str) -> CliRunOutput { - let format: crate::output::OutputFormat = match output_format.parse() { - Ok(format) => format, - Err(err) => { - return CliRunOutput { - exit_code: exit_code_for_error(&err), - rendered: err.to_string(), - }; - } - }; - let docs = self.search_documents(scope); - let results = SearchIndex::new(docs).search(query, 10); - let envelope = - crate::Envelope::success(results, self.config.app_id.clone()).prepare_for_render(""); - match crate::output::render(format, &envelope) { - Ok(rendered) => CliRunOutput { - exit_code: 0, - rendered, - }, - Err(err) => CliRunOutput { - exit_code: exit_code_for_error(&err), - rendered: err.to_string(), - }, - } - } - - /// Renders the bare-root response. For human output, renders long help plus - /// a "Next actions" section so a human invoking the CLI with no arguments - /// gets readable guidance; for machine-readable output, emits a discovery - /// envelope (light metadata + next actions). The output format has already - /// resolved the TTY/env/flag policy, so this just branches on it. - fn render_root(&self, middleware: &Middleware, actions: Vec) -> CliRunOutput { - // Reject an invalid explicit `--output` here too, matching the normal - // command path (`Middleware::render_envelope`). `OutputFormat::from_str` - // is infallible and would otherwise silently coerce an unrecognized - // value (e.g. `--output yaml`) to JSON instead of reporting the error. - if !crate::output::is_valid_output_format(&middleware.output_format) { - let err = CliCoreError::InvalidOutputFormat(middleware.output_format.clone()); - return CliRunOutput { - exit_code: exit_code_for_error(&err), - rendered: err.to_string(), - }; - } - let format = middleware - .output_format - .parse() - .unwrap_or(crate::output::OutputFormat::Json); - if format == crate::output::OutputFormat::Human { - // Fold the suggested actions into the root long-about so they render - // alongside the other curated sections (before Usage) instead of - // dangling beneath clap's options dump. - let base_long = self - .root - .get_long_about() - .map(ToString::to_string) - .unwrap_or_default(); - let long = format!("{base_long}{}", render_next_actions_human(&actions)); - let rendered = self - .root - .clone() - .long_about(long) - .render_long_help() - .to_string(); - return CliRunOutput { - exit_code: 0, - rendered, - }; - } - let description = self - .config - .long - .as_deref() - .filter(|long| !long.is_empty()) - .unwrap_or(self.config.short.as_str()); - let data = serde_json::json!({ - "description": description, - "version": self.config.build.version, - }); - let envelope = crate::Envelope::success(data, self.config.app_id.clone()) - .with_next_actions(actions) - .prepare_for_render(&middleware.verbose); - match crate::output::render(format, &envelope) { - Ok(rendered) => CliRunOutput { - exit_code: 0, - rendered, - }, - Err(err) => CliRunOutput { - exit_code: exit_code_for_error(&err), - rendered: err.to_string(), - }, - } - } - - fn search_documents(&self, scope: &str) -> Vec { - let (scoped, mut prefix) = find_command_and_canonical_path_by_colon_path(&self.root, scope) - .unwrap_or((&self.root, Vec::new())); - let mut docs = Vec::new(); - let mut aliases = Vec::new(); - append_command_alias_terms(scoped, &mut aliases); - collect_command_search_documents(scoped, &mut prefix, &mut aliases, &mut docs); - if scope.is_empty() { - for entry in &self.guide_entries { - docs.push(SearchDocument { - id: format!("guide:{}", entry.name), - kind: "guide".to_owned(), - title: format!("guide {}", entry.name), - summary: entry.summary.clone(), - content: format!("{} {}", entry.summary, entry.content), - }); - } - if let Some(extra_search_docs) = &self.extra_search_docs { - docs.extend(extra_search_docs()); - } - } - docs - } - - /// Resolves `--scope`'s colon-separated path (e.g. `domain` or - /// `domain:list`) to the canonical scope string [`Self::search_documents`] - /// expects, matching aliases the same way a real command path would (via - /// [`canonical_path_from_parts`]'s `find_subcommand` walk). An empty or - /// unresolvable scope falls back to an unscoped (root) search rather than - /// erroring — `search` staying permissive here matches how a typo in a - /// search *query* just yields fewer results instead of a hard failure. - /// An unresolvable (non-empty) scope prints a best-effort stderr hint - /// first, so a typo like `--scope doamin` doesn't silently widen the - /// search with no explanation for the extra results. - fn resolve_search_scope(&self, scope_path: &str) -> String { - if scope_path.is_empty() { - return String::new(); - } - let parts: Vec = scope_path.split(':').map(str::to_owned).collect(); - match canonical_path_from_parts(&self.root, &parts) { - Some(scope) => scope, - None => { - warn_unresolvable_search_scope(scope_path); - String::new() - } - } - } - - fn canonical_command_path(&self, command_path: &str) -> String { - find_command_and_canonical_path_by_colon_path(&self.root, command_path).map_or_else( - || command_path.to_owned(), - |(_, canonical)| canonical.join(":"), - ) - } - - fn render_guide(&self, matches: &ArgMatches, output_format: &str) -> CliRunOutput { - use std::io::IsTerminal; - - // Reject an invalid explicit `--output` here too, matching the normal - // command path and `render_root`; otherwise an unrecognized value (e.g. - // `--output yaml`) would silently fall through and emit raw content. - if !crate::output::is_valid_output_format(output_format) { - let err = CliCoreError::InvalidOutputFormat(output_format.to_owned()); - return CliRunOutput { - exit_code: exit_code_for_error(&err), - rendered: err.to_string(), - }; - } - - let leaf = leaf_matches(matches); - let topic = leaf.get_one::("topic").map(String::as_str); - match guide_content(&self.guide_entries, topic) { - Ok(rendered) => { - // Only reflow an actual guide topic body, and only for human output. - // The topic list is plain text (not markdown) and json/toon keep the - // raw markdown so their output stays deterministic. - let rendered = if topic.is_some() && output_format == "human" { - let is_tty = std::io::stdout().is_terminal(); - render_guide_human(&rendered, crate::output::terminal_width(), is_tty) - } else { - rendered - }; - CliRunOutput { - exit_code: 0, - rendered, - } - } - Err(err) => CliRunOutput { - exit_code: 1, - rendered: err, - }, - } - } - - fn render_completion_print( - &self, - shell_opt: Option, - middleware: &Middleware, - ) -> CliRunOutput { - use crate::cli::completion::{detect_shell, generate_script, parse_shell}; - let shell = match shell_opt { - Some(s) => match parse_shell(&s) { - Ok(s) => s, - Err(e) => return render_cli_error(middleware, &e, &self.config.app_id), - }, - None => match detect_shell() { - Ok(s) => s, - Err(e) => return render_cli_error(middleware, &e, &self.config.app_id), - }, - }; - match generate_script(&self.root, &self.config.name, shell) { - Ok(script) => CliRunOutput { - exit_code: 0, - rendered: script, - }, - Err(e) => render_cli_error(middleware, &e, &self.config.app_id), - } - } - - fn render_help_command(&self, matches: &ArgMatches) -> CliRunOutput { - let leaf = leaf_matches(matches); - let parts = leaf - .get_many::("command") - .map(|values| values.map(String::as_str).collect::>()) - .unwrap_or_default(); - self.render_help_for_parts(&parts) - } - - /// Renders the curated help text for a resolved command path. - /// - /// Empty `parts` render the root help. A path that resolves to a group or - /// command renders that command's long help; an unresolved path returns the - /// standard "unknown command" guidance with a non-zero exit code. Shared by - /// the root `help ` command and the ` help` subcommand form. - fn render_help_for_parts(&self, parts: &[&str]) -> CliRunOutput { - if parts.is_empty() { - return CliRunOutput { - exit_code: 0, - rendered: self.root.clone().render_long_help().to_string(), - }; - } - let Some(command) = find_help_target(&self.root, parts) else { - return CliRunOutput { - exit_code: 1, - rendered: format!( - "unknown command {:?} — run '{} help' for available commands", - parts.join(" "), - self.config.name - ), - }; - }; - CliRunOutput { - exit_code: 0, - rendered: command.clone().render_long_help().to_string(), - } - } - - fn refresh_root_long(&mut self) { - // Module-categorized entries, plus any visible top-level command that is - // neither categorized nor an engine built-in, listed under a generic - // "Commands" section. This keeps every command discoverable once clap's - // auto subcommand list is suppressed by the root help template. - let builtins = BUILTIN_COMMAND_NAMES; - let categorized: BTreeSet<&str> = self - .module_entries - .iter() - .map(|entry| entry.name.as_str()) - .collect(); - let mut generic: Vec = self - .root - .get_subcommands() - .filter(|command| !command.is_hide_set()) - .filter(|command| !builtins.contains(&command.get_name())) - .filter(|command| !categorized.contains(command.get_name())) - .map(|command| ModuleHelpEntry { - category: "Commands".to_owned(), - name: command.get_name().to_owned(), - short: command - .get_about() - .map(ToString::to_string) - .unwrap_or_default(), - }) - .collect(); - generic.sort_by(|left, right| left.name.cmp(&right.name)); - - let mut entries = self.module_entries.clone(); - entries.extend(generic); - let has_guide = !self.guide_entries.is_empty() || has_subcommand(&self.root, "guide"); - let intro = self - .config - .long - .as_deref() - .filter(|long| !long.is_empty()) - .unwrap_or(self.config.short.as_str()); - self.root = self - .root - .clone() - .long_about(build_root_long(intro, &entries, has_guide)); - } - - fn ensure_auth_command(&mut self) { - let default_provider = self.default_auth_provider(); - let registered_names = self.middleware.auth.registered_names(); - if default_provider.is_empty() && registered_names.is_empty() { - return; - } - let replacing_builtin = self.commands.contains_key("auth:login"); - if has_subcommand(&self.root, "auth") && !replacing_builtin { - return; - } - let mut group = auth_command_group(&default_provider, ®istered_names); - let mut seen_names: std::collections::HashSet = - group.commands.iter().map(|c| c.spec.name.clone()).collect(); - for extra in self.config.auth_extra_commands.clone() { - if !seen_names.insert(extra.spec.name.clone()) { - tracing::warn!( - command = %extra.spec.name, - "auth_extra_commands entry collides with a built-in auth subcommand or an \ - earlier auth_extra_commands entry; ignoring" - ); - continue; - } - group = group.with_command(extra); - } - let mut prefix = Vec::new(); - register_runtime_group_metadata( - &group, - &mut prefix, - &mut self.middleware.schema_registry, - &mut self.middleware.human_views, - ); - let mut prefix = Vec::new(); - group.register_commands(&mut prefix, &mut self.commands); - let mut prefix = Vec::new(); - let clap_group = runtime_group_clap_command_with_schema_help( - &group, - &mut prefix, - &self.middleware.schema_registry, - ); - self.root = if replacing_builtin { - self.root.clone().mut_subcommand("auth", |_| clap_group) - } else { - self.root.clone().subcommand(clap_group) - }; - // Categorize `auth` wherever it is ensured (construction or a later - // `register_auth_provider`), so it never falls into the generic - // "Commands" bucket. Idempotent via the `already_listed` guard. - self.register_auth_help_entry(); - } - - /// Mounts the built-in `config` command group and files it under the admin - /// help category. Idempotent and yields to a consumer-defined `config` - /// subcommand if one already exists. - fn ensure_config_command(&mut self) { - if has_subcommand(&self.root, "config") { - return; - } - let group = crate::config_commands::config_command_group(); - let mut prefix = Vec::new(); - group.register_commands(&mut prefix, &mut self.commands); - let mut prefix = Vec::new(); - let clap_group = runtime_group_clap_command_with_schema_help( - &group, - &mut prefix, - &self.middleware.schema_registry, - ); - self.root = self.root.clone().subcommand(clap_group); - let category = self - .config - .admin_category - .clone() - .unwrap_or_else(|| DEFAULT_ADMIN_CATEGORY.to_owned()); - if !self - .module_entries - .iter() - .any(|entry| entry.name == "config") - { - self.module_entries.push(ModuleHelpEntry { - category, - name: "config".to_owned(), - short: "Read and write the CLI config file".to_owned(), - }); - } - self.refresh_root_long(); - } - - /// Mounts the built-in `env` command group and files it under the admin - /// help category. Idempotent and yields to a consumer-defined `env` - /// subcommand if one already exists. - fn ensure_env_command(&mut self) { - if has_subcommand(&self.root, "env") { - return; - } - let group = crate::env_commands::env_command_group(); - let mut prefix = Vec::new(); - group.register_commands(&mut prefix, &mut self.commands); - let mut prefix = Vec::new(); - let clap_group = runtime_group_clap_command_with_schema_help( - &group, - &mut prefix, - &self.middleware.schema_registry, - ); - self.root = self.root.clone().subcommand(clap_group); - let category = self - .config - .admin_category - .clone() - .unwrap_or_else(|| DEFAULT_ADMIN_CATEGORY.to_owned()); - if !self.module_entries.iter().any(|e| e.name == "env") { - self.module_entries.push(ModuleHelpEntry { - category, - name: "env".to_owned(), - short: "Manage the active environment".to_owned(), - }); - } - self.refresh_root_long(); - } - - /// Mounts the built-in `flags` command group and files it under the admin - /// help category. Idempotent and yields to a consumer-defined `flags` - /// subcommand if one already exists. Unlike [`Self::ensure_env_command`], - /// this is mounted unconditionally: feature-flag introspection does not - /// depend on any opt-in system, so it is always available. - fn ensure_flags_command(&mut self) { - if has_subcommand(&self.root, "flags") { - return; - } - let group = crate::flag_commands::flags_command_group(); - let mut prefix = Vec::new(); - group.register_commands(&mut prefix, &mut self.commands); - let mut prefix = Vec::new(); - let clap_group = runtime_group_clap_command_with_schema_help( - &group, - &mut prefix, - &self.middleware.schema_registry, - ); - self.root = self.root.clone().subcommand(clap_group); - let category = self - .config - .admin_category - .clone() - .unwrap_or_else(|| DEFAULT_ADMIN_CATEGORY.to_owned()); - if !self.module_entries.iter().any(|e| e.name == "flags") { - self.module_entries.push(ModuleHelpEntry { - category, - name: "flags".to_owned(), - short: "Inspect declared feature flags".to_owned(), - }); - } - self.refresh_root_long(); - } - - fn default_auth_provider(&self) -> String { - if !self.middleware.default_auth_provider.is_empty() { - return self.middleware.default_auth_provider.clone(); - } - self.middleware - .auth - .registered_names() - .into_iter() - .next() - .unwrap_or_default() - } - - fn initialized_middleware(&self) -> Result { - let Some(init_deps) = &self.init_deps else { - return Ok(self.middleware.clone()); - }; - let mut guard = self - .init_state - .lock() - .map_err(|_| CliCoreError::message("init deps lock poisoned"))?; - if let Some(result) = guard.as_ref() { - return result.clone().map_err(InitFailure::into_error); - } - let mut middleware = self.middleware.clone(); - let result = init_deps(&mut middleware) - .map(|()| middleware) - .map_err(|err| InitFailure::capture(&err)); - *guard = Some(result.clone()); - result.map_err(InitFailure::into_error) - } - - fn apply_config_flags(&self, matches: &ArgMatches, middleware: &mut Middleware) -> Result<()> { - if let Some(apply_flags) = &self.apply_flags { - apply_flags(matches, middleware)?; - } - Ok(()) - } - - /// Applies the global `--env` override to a per-run middleware snapshot. - /// - /// The flag is only registered when environments are configured, so when it - /// is present `middleware.environments` is set too. Validates the requested - /// name against the registered environments and updates `middleware.env`, - /// returning an error for an unknown environment. - fn apply_env_flag(&self, matches: &ArgMatches, middleware: &mut Middleware) -> Result<()> { - // Guard on the environment system FIRST. The `--env` arg is only - // registered when environments are configured (the same condition that - // sets `middleware.environments`); calling `matches.get_one("env")` for - // an arg that was never registered panics in clap, which would break - // every CLI that does not use environments. - let Some(environments) = middleware.environments.as_ref() else { - return Ok(()); - }; - if let Some(env) = matches.get_one::("env") { - environments.source(env)?; - middleware.env = env.clone(); - } - Ok(()) - } - - fn run_pre_run( - &self, - middleware: &mut Middleware, - command_path: &str, - args: &crate::middleware::ValueMap, - ) -> Result<()> { - if let Some(pre_run) = &self.pre_run { - pre_run(middleware, command_path, args)?; - } - Ok(()) - } - - fn resolve_meta(&self, command_path: &str, meta: CommandMeta) -> CommandMeta { - if let Some(resolver) = &self.meta_resolver { - resolver(command_path, meta) - } else { - meta - } - } - - fn finish_run(&self, output: CliRunOutput) -> CliRunOutput { - // Clear the per-thread credential-store flag so it does not leak into - // subsequent sequential runs on the same thread. - crate::config::clear_credential_store_flag(); - if let Some(on_shutdown) = &self.on_shutdown { - on_shutdown(); - } - output - } -} - -fn apply_global_flags(middleware: &mut Middleware, flags: &GlobalFlags, timeout: Option) { - middleware.output_format = flags.output_format.clone(); - middleware.verbose = flags.verbose.clone(); - middleware.dry_run = flags.dry_run; - middleware.fields = flags.fields.clone(); - middleware.fields_explicit = flags.fields_explicit; - middleware.filter = flags.filter.clone(); - middleware.expr = flags.expr.clone(); - middleware.reason = flags.reason.clone(); - middleware.schema = flags.schema; - middleware.timeout = timeout; - middleware.debug = flags.debug.clone(); - middleware.interactive = flags.interactive; -} - -/// Sets `middleware.limit`/`middleware.offset` from a paginating command's own -/// `--limit`/`--offset` -fn apply_pagination_flags(middleware: &mut Middleware, spec: &CommandSpec, leaf: &ArgMatches) { - let Some(pagination) = spec.pagination else { - return; - }; - middleware.limit = leaf - .get_one::("limit") - .copied() - .unwrap_or(pagination.default_limit); - middleware.offset = leaf.get_one::("offset").copied().unwrap_or(0); -} - -/// Replays a paginating command's own explicit args, plus the global -/// `--filter`/`--expr`/`--fields` flags, as `--flag value` text, prefixed -/// with the CLI's binary name — the base a "view the next page" -/// [`crate::NextAction`] is built from once the response's -/// [`crate::PaginationMeta`] is known. Leading with the binary name keeps the -/// suggested command copy-pastable rather than a fragment starting at the -/// noun/verb path. -/// -/// `--filter`/`--expr`/`--fields` sit in the same output pipeline as -/// pagination itself (filter -> paginate -> expr -> fields) and change what -/// data comes back, so dropping them would make the suggested next-page -/// command return different results than the command the user actually ran. -/// Other global flags (`--output`, `--verbose`, `--env`, ...) don't affect -/// *which* data is returned, so they're intentionally left out — the caller -/// is already running under them. -/// -/// Best-effort, not a fully general clap-args reconstruction: it uses each -/// arg's real `get_long()`/`get_short()` name (never the value-map key, -/// which for derive-based args can differ from the flag — e.g. id -/// `page_size` vs flag `--page-size`), replays a multi-value arg as one -/// flag occurrence per value (round-trips correctly whether the arg is a -/// plain repeatable `ArgAction::Append` or also sets a `value_delimiter`), -/// and quotes/escapes values containing whitespace or shell metacharacters -/// (see `quote_pagination_value`). Deliberately omits `--limit`/`--offset` — -/// those are added by the caller once it knows the -/// next page's offset. -fn pagination_command_base( - binary_name: &str, - command_path: &str, - spec: &CommandSpec, - user_args: &crate::middleware::ValueMap, - flags: &GlobalFlags, -) -> String { - let mut parts = vec![ - quote_pagination_value(binary_name), - command_path.replace(':', " "), - ]; - for arg in &spec.args { - let id = arg.get_id().as_str(); - if let Some(value) = user_args.get(id) { - push_pagination_arg(&mut parts, arg, value); - } - } - for (flag, value) in [ - ("--filter", &flags.filter), - ("--expr", &flags.expr), - ("--fields", &flags.fields), - ] { - if !value.is_empty() { - parts.push(flag.to_owned()); - parts.push(quote_pagination_value(value)); - } - } - parts.join(" ") -} - -fn push_pagination_arg(parts: &mut Vec, arg: &Arg, value: &serde_json::Value) { - let flag = arg - .get_long() - .map(|long| format!("--{long}")) - .or_else(|| arg.get_short().map(|short| format!("-{short}"))); - match value { - serde_json::Value::Bool(enabled) => { - if matches!( - arg.get_action(), - clap::ArgAction::SetTrue | clap::ArgAction::SetFalse - ) { - // A switch-style flag's presence in `user_args` already means - // the user typed exactly this flag — `SetTrue` implies `true`, - // `SetFalse` implies `false` (e.g. a `--no-foo`-style arg) — - // and neither accepts an explicit `=value` token, so replay - // the bare flag rather than appending one. - if let Some(flag) = flag { - parts.push(flag); - } - } else { - // A custom bool-valued arg (`ArgAction::Set` with a bool - // value parser) takes an explicit token, so replay it like - // any other scalar. - push_flagged_value(parts, flag, &enabled.to_string()); - } - } - serde_json::Value::Array(items) => { - // Repeat the flag once per value rather than joining into one - // comma-separated token: clap collects a repeatable flag - // (`ArgAction::Append`, the common way a command declares a - // multi-value arg) the same way whether or not it also sets - // `value_delimiter(',')`, so `--scope a --scope b` round-trips - // correctly either way. A single `--scope a,b` only works when - // a delimiter was configured — for a plain `Append` arg it's - // parsed as one literal value, changing the replay's meaning. - for item in items { - push_flagged_value(parts, flag.clone(), &pagination_arg_display(item)); - } - } - serde_json::Value::Null => {} - other => push_flagged_value(parts, flag, &pagination_arg_display(other)), - } -} - -fn push_flagged_value(parts: &mut Vec, flag: Option, value: &str) { - if let Some(flag) = flag { - parts.push(flag); - } - parts.push(quote_pagination_value(value)); -} - -fn pagination_arg_display(value: &serde_json::Value) -> String { - match value { - serde_json::Value::String(text) => text.clone(), - other => other.to_string(), - } -} - -/// Quotes a value for the suggested next-page command, if it contains -/// anything beyond a small safe-unquoted allowlist. Whitespace and shell -/// metacharacters (`|`, `&`, `;`, `<`, `>`, ...) all fall outside that -/// allowlist and so trigger quoting; once quoted, `\`, `"`, `$`, and `` ` `` -/// are backslash-escaped (backslash first, so escaping the others doesn't -/// re-escape the backslashes it just inserted) so the value can't break out -/// of the double quotes or trigger POSIX-shell expansion (`$VAR`, `$(...)`, -/// backticks) if the suggestion is copy-pasted into a shell. -fn quote_pagination_value(value: &str) -> String { - let safe_unquoted = - |c: char| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '/' | ':' | '@'); - if value.is_empty() || !value.chars().all(safe_unquoted) { - let escaped = value - .replace('\\', "\\\\") - .replace('"', "\\\"") - .replace('$', "\\$") - .replace('`', "\\`"); - format!("\"{escaped}\"") - } else { - value.to_owned() - } -} - -/// Builds the transport debug logger implied by a parsed `--debug` pattern, -/// without publishing it anywhere. -/// -/// Pure so tests can assert on the decision (`--debug` pattern -> enabled or -/// not) without touching the process-wide default logger, which every -/// [`Cli::run`] call republishes — including the many unrelated tests that -/// exercise `cli.run(...)` with no `--debug` flag and would otherwise race -/// with an assertion on the shared global. -fn debug_transport_logger_for( - debug: &str, - extra_redacted: &[String], -) -> Arc { - if crate::debug_component_enabled(debug, "transport") { - Arc::new( - crate::transport::StderrTransportLogger::new() - .with_redacted_headers(extra_redacted.iter().cloned()), - ) - } else { - Arc::new(crate::transport::NoopTransportLogger) - } -} - -/// Installs (or clears) the process-wide transport debug logger from the parsed -/// `--debug` pattern. -/// -/// When `--debug` selects the `transport` component the engine publishes a -/// [`StderrTransportLogger`](crate::transport::StderrTransportLogger) — extended -/// with any [`CliConfig::with_redacted_debug_headers`] entries — which every -/// [`HttpClient`](crate::transport::HttpClient) built afterward picks up -/// automatically, with no per-command wiring. The logger is reset to a noop when -/// `transport` is not selected so the explicit setting always reflects the -/// current invocation rather than a stale process-global from an earlier one. -fn install_debug_transport_logger(debug: &str, extra_redacted: &[String]) { - crate::transport::set_default_transport_logger(debug_transport_logger_for( - debug, - extra_redacted, - )); -} - -async fn run_with_timeout( - timeout: Option, - timeout_label: &str, - future: F, -) -> Result -where - F: Future>, -{ - let Some(timeout) = timeout else { - return future.await; - }; - match tokio::time::timeout(timeout, future).await { - Ok(result) => result, - Err(_) => Err(CliCoreError::message(format!( - "command timed out after {timeout_label}" - ))), - } -} - -async fn run_until_signal(run: Run, shutdown: Shutdown) -> CliRunOutput -where - Run: Future, - Shutdown: Future, -{ - tokio::pin!(run); - tokio::pin!(shutdown); - tokio::select! { - output = &mut run => output, - () = &mut shutdown => CliRunOutput { - exit_code: 130, - rendered: "command interrupted\n".to_owned(), - }, - } -} - -#[cfg(unix)] -async fn shutdown_signal() { - let ctrl_c = tokio::signal::ctrl_c(); - match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) { - Ok(mut sigterm) => { - tokio::select! { - _ = ctrl_c => {}, - _ = sigterm.recv() => {}, - } - } - Err(_) => { - drop(ctrl_c.await); - } - } -} - -#[cfg(not(unix))] -async fn shutdown_signal() { - drop(tokio::signal::ctrl_c().await); -} - -fn parse_command_timeout(raw: &str) -> Result> { - let raw = raw.trim(); - if raw.is_empty() { - return Ok(Some(Duration::from_secs(60))); - } - let Some(seconds) = parse_duration_seconds(raw) else { - return Err(CliCoreError::message(format!( - "invalid timeout {raw:?}: expected duration like 60s, 5m, or 0s" - ))); - }; - if seconds <= 0.0 { - Ok(None) - } else { - Ok(Some(Duration::from_secs_f64(seconds))) - } -} - -fn parse_duration_seconds(raw: &str) -> Option { - for (suffix, seconds) in [ - ("ns", 0.000_000_001_f64), - ("us", 0.000_001_f64), - ("µs", 0.000_001_f64), - ("ms", 0.001_f64), - ("s", 1.0_f64), - ("m", 60.0_f64), - ("h", 3600.0_f64), - ] { - if let Some(number) = raw.strip_suffix(suffix) { - let value = number.parse::().ok()?; - if !value.is_finite() { - return None; - } - return Some(value * seconds); - } - } - None -} - -/// Reads the global `${APP_ID}_MIN_STAGE` override (see [`min_stage_env_var`]). -/// -/// Best-effort, like [`crate::config::ConfigFile::load`]'s handling of a -/// malformed config file: returns `None` when the var is unset, and also -/// `None` (after logging a warning) when it is set but fails to parse as a -/// [`Stage`], so a typo'd value cannot take the CLI down. -fn global_min_stage_override(app_id: &str) -> Option { - let var = min_stage_env_var(app_id); - let value = std::env::var(&var).ok()?; - value.parse::().map_or_else( - |err| { - tracing::warn!(var = %var, value = %value, error = %err, "ignoring invalid min-stage override"); - None - }, - Some, - ) -} - -/// Pure scan over an arg iterator for the last `--env `/`--env=` -/// occurrence — used only to seed [`Cli::new`]'s `flag_policy` (and therefore -/// which flagged commands get pruned) before the command tree is built, since -/// that decision can't be revisited once real argv is parsed. The real, -/// per-invocation `--env` value used for dispatch still comes from -/// `apply_env_flag`'s clap-based parse, unchanged; this scan never replaces -/// it, only decides tree shape earlier than clap otherwise could -/// (clap's own [`clap::Command::ignore_errors`] does not help here — it -/// still requires the rest of the argv to parse against a *known* subcommand -/// structure, and at prescan time no domain modules are registered yet, so a -/// real command path makes it bail on capturing global flags too). -/// -/// Scans the *entire* argv and keeps the *last* non-empty `--env`/`--env=` -/// value, rather than stopping at the first match — a global `--env` and a -/// command-local one sharing the same arg id can both appear in one -/// invocation, and whichever clap resolves as the effective value -/// (empirically, the last one) is the one this scan must agree with. An -/// empty value (`--env=` with nothing after the `=`, or `--env` immediately -/// followed by another flag with nothing captured) is ignored rather than -/// becoming a literal empty-string candidate. -fn prescan_env_flag(mut args: impl Iterator) -> Option { - let mut result = None; - while let Some(arg) = args.next() { - // clap's end-of-options sentinel: everything after a bare `--` is a - // positional argument, never a flag, no matter what it looks like. - // This scan must agree, or `app cmd -- --env dev` would be - // misread as a real `--env` override. - if arg == "--" { - break; - } - let value = if let Some(v) = arg.strip_prefix("--env=") { - Some(v.to_owned()) - } else if arg == "--env" { - // A space-separated value that itself looks like another flag - // (starts with `-`) is not a value at all — clap rejects this - // outright ("a value is required for '--env ' but none was - // supplied"), so this scan must not treat it as one either. An - // explicit `--env=-foo` is unambiguous and still accepted, same - // as clap's own disambiguation rule. - args.next().filter(|v| !v.starts_with('-')) - } else { - None - }; - if let Some(v) = value.filter(|v| !v.is_empty()) { - result = Some(v); - } - } - result -} - -fn render_cli_error( - middleware: &Middleware, - err: &(dyn std::error::Error + 'static), - system: &str, -) -> CliRunOutput { - let format = middleware - .output_format - .parse::() - .unwrap_or(crate::output::OutputFormat::Json); - let envelope = - crate::output::build_error_envelope(err, system).prepare_for_render(&middleware.verbose); - match crate::output::render(format, &envelope) { - Ok(rendered) => CliRunOutput { - exit_code: exit_code_for_error(err), - rendered, - }, - Err(render_err) => CliRunOutput { - exit_code: exit_code_for_error(err), - rendered: render_err.to_string(), - }, - } -} - -fn find_command_by_colon_path<'command>( - root: &'command Command, - path: &str, -) -> Option<&'command Command> { - find_command_and_canonical_path_by_colon_path(root, path).map(|(command, _)| command) -} - -fn find_help_target<'command>( - root: &'command Command, - parts: &[&str], -) -> Option<&'command Command> { - let mut current = root; - let mut matched_any = false; - for part in parts { - let Some(next) = current.find_subcommand(part) else { - break; - }; - current = next; - matched_any = true; - } - matched_any.then_some(current) -} - -fn find_command_and_canonical_path_by_colon_path<'command>( - root: &'command Command, - path: &str, -) -> Option<(&'command Command, Vec)> { - if path.is_empty() { - return Some((root, Vec::new())); - } - let mut current = root; - let mut canonical = Vec::new(); - for part in path.split(':') { - current = current.find_subcommand(part)?; - canonical.push(current.get_name().to_owned()); - } - Some((current, canonical)) -} - -fn canonical_path_from_parts(root: &Command, parts: &[String]) -> Option { - if parts.is_empty() { - return Some(String::new()); - } - let mut current = root; - let mut canonical = Vec::new(); - for part in parts { - current = current.find_subcommand(part)?; - canonical.push(current.get_name().to_owned()); - } - Some(canonical.join(":")) -} - -/// Best-effort stderr hint for a `--scope` value that didn't resolve to a -/// known command path — `resolve_search_scope` still searches everything -/// (matching a bare `search` with no `--scope` at all), so this is the only -/// signal the user gets that their scope was ignored rather than applied. -/// Written directly to a locked stderr handle (not `eprintln!`), matching -/// the transport module's own `StderrTransportLogger` convention for this -/// kind of side-channel diagnostic: best-effort, so a write failure is -/// discarded rather than surfaced as a command error. -fn warn_unresolvable_search_scope(scope_path: &str) { - let mut stderr = std::io::stderr().lock(); - stderr - .write_all( - format!( - "warning: --scope {scope_path:?} did not match a known command path; searching everything instead\n" - ) - .as_bytes(), - ) - .ok(); -} - -fn collect_command_search_documents( - command: &Command, - prefix: &mut Vec, - aliases: &mut Vec, - docs: &mut Vec, -) { - if command.is_hide_set() || BUILTIN_COMMAND_NAMES.contains(&command.get_name()) { - return; - } - if command.get_subcommands().next().is_some() { - for child in command.get_subcommands() { - prefix.push(child.get_name().to_owned()); - let alias_len = aliases.len(); - append_command_alias_terms(child, aliases); - collect_command_search_documents(child, prefix, aliases, docs); - aliases.truncate(alias_len); - prefix.pop(); - } - return; - } - if prefix.is_empty() { - prefix.push(command.get_name().to_owned()); - append_command_alias_terms(command, aliases); - } - let path = prefix.join(" "); - let alias_text = aliases.join(" "); - docs.push(SearchDocument { - id: format!("cmd:{path}"), - kind: "command".to_owned(), - title: path, - summary: command - .get_about() - .map(ToString::to_string) - .unwrap_or_default(), - content: format!( - "{} {} {} {}", - command - .get_about() - .map(ToString::to_string) - .unwrap_or_default(), - command - .get_long_about() - .map(ToString::to_string) - .unwrap_or_default(), - command_flag_text(command), - alias_text - ), - }); - if prefix.len() == 1 && prefix[0] == command.get_name() { - prefix.pop(); - } -} - -fn append_command_alias_terms(command: &Command, aliases: &mut Vec) { - aliases.extend(command.get_all_aliases().map(str::to_owned)); - aliases.extend( - command - .get_all_short_flag_aliases() - .map(|alias| alias.to_string()), - ); - aliases.extend(command.get_all_long_flag_aliases().map(str::to_owned)); -} - -fn command_flag_text(command: &Command) -> String { - command - .get_arguments() - .filter(|arg| !arg.is_hide_set()) - .filter_map(|arg| { - let mut names = Vec::new(); - if let Some(short) = arg.get_short() { - names.push(format!("-{short}")); - } - if let Some(long) = arg.get_long() { - names.push(format!("--{long}")); - } - if let Some(short_aliases) = arg.get_all_short_aliases() { - names.extend( - short_aliases - .into_iter() - .map(|short_alias| format!("-{short_alias}")), - ); - } - if let Some(aliases) = arg.get_all_aliases() { - names.extend(aliases.into_iter().map(|alias| format!("--{alias}"))); - } - (!names.is_empty()).then(|| names.join(" ")) - }) - .collect::>() - .join(" ") -} - -fn has_subcommand(command: &Command, name: &str) -> bool { - command - .get_subcommands() - .any(|child| child.get_name() == name) -} - -fn has_root_version_flag(args: &[String], root: &Command, root_name: &str) -> bool { - let bool_flags = derive_bool_flags(root); - let value_flags = derive_value_flags(root); - let mut iter = args.iter().peekable(); - if iter - .peek() - .is_some_and(|arg| arg_matches_root_name(arg, root_name)) - { - iter.next(); - } - - while let Some(arg) = iter.next() { - match arg.as_str() { - "--version" | "-v" => return true, - "--" => return false, - value if value.contains('=') || bool_flags.contains(value) => continue, - value - if value_flags.contains(value) - || unknown_flag_consumes_value(value, iter.peek()) => - { - iter.next(); - } - value if value.starts_with('-') => {} - _ => return false, - } - } - false -} - -fn normalize_optional_global_flags_before_command(root: &Command, args: &[String]) -> Vec { - let optional_string_defaults = BTreeMap::from([("--verbose", "all"), ("--debug", "*")]); - let optional_bool_defaults = BTreeMap::from([("--dry-run", "true"), ("--schema", "true")]); - let mut normalized = Vec::with_capacity(args.len()); - let mut index = 0; - let mut current = root; - while index < args.len() { - let arg = &args[index]; - if index == 0 && arg_matches_root_name(arg, root.get_name()) { - normalized.push(arg.clone()); - index += 1; - continue; - } - - if let Some(default) = optional_bool_defaults.get(arg.as_str()) { - normalized.push(format!("{arg}={default}")); - index += 1; - continue; - } - - if let Some(default) = optional_string_defaults.get(arg.as_str()) { - match args.get(index + 1) { - None => { - normalized.push(format!("{arg}={default}")); - index += 1; - continue; - } - Some(next) - if current.get_name() == root.get_name() - || next.starts_with('-') - || direct_subcommand(current, next).is_some() => - { - normalized.push(format!("{arg}={default}")); - index += 1; - continue; - } - Some(next) => { - normalized.push(arg.clone()); - normalized.push(next.clone()); - index += 2; - continue; - } - } - } - - normalized.push(arg.clone()); - if !arg.starts_with('-') - && let Some(next_command) = direct_subcommand(current, arg) - { - current = next_command; - } - index += 1; - } - normalized -} - -fn direct_subcommand<'command>( - command: &'command Command, - token: &str, -) -> Option<&'command Command> { - command.get_subcommands().find(|child| { - child.get_name() == token || child.get_all_aliases().any(|alias| alias == token) - }) -} - -/// Appends a `— did you mean "…"?` suffix to an unknown-command error clause. -fn format_did_you_mean(base: &str, suggestion: &str) -> String { - format!("{base} — did you mean {suggestion:?}?") -} - -/// First unknown group token (`unknown command "X" for "Y"`, no hint suffix). -struct UnknownGroupCommand { - base: String, -} - -/// Reports the first unknown token under a group. `positionals` must be pre-`--` -/// command keywords (slice to `command_keyword_count` like the group-help path). -fn detect_unknown_group_command( - root: &Command, - positionals: &[String], -) -> Option { - if positionals.is_empty() { - return None; - } - - let mut current = root; - let mut path = vec![root.get_name().to_owned()]; - for token in positionals { - if let Some(next) = current.find_subcommand(token) { - current = next; - path.push(next.get_name().to_owned()); - continue; - } - if current.get_subcommands().next().is_some() { - let base = format!("unknown command {token:?} for {:?}", path.join(" ")); - return Some(UnknownGroupCommand { base }); - } - return None; - } - None -} - -/// Counts positional command tokens that precede any `--` separator. -fn command_keyword_count( - args: &[String], - root_name: &str, - bool_flags: &BTreeSet, - value_flags: &BTreeSet, -) -> usize { - let positionals = positional_command_tokens(args, root_name, bool_flags, value_flags); - match args.iter().position(|arg| arg == "--") { - Some(end) => { - positional_command_tokens(&args[..end], root_name, bool_flags, value_flags).len() - } - None => positionals.len(), - } -} - -/// Rewrites ` help [sub...]` into `help [sub...]` when the form -/// is present; otherwise returns `clap_args` unchanged. -fn rewrite_group_help_if_needed( - root: &Command, - clap_args: &[String], - root_name: &str, - bool_flags: &BTreeSet, - value_flags: &BTreeSet, -) -> Vec { - let positionals = positional_command_tokens(clap_args, root_name, bool_flags, value_flags); - let keyword_count = command_keyword_count(clap_args, root_name, bool_flags, value_flags); - let Some(parts) = group_help_target_parts(root, &positionals, keyword_count) else { - return clap_args.to_vec(); - }; - rewrite_group_help_args(clap_args, root_name, bool_flags, value_flags, &parts) -} - -/// Rewrites the `target`-th positional command token to `replacement`, preserving -/// flags. Token classification mirrors [`positional_command_tokens`]. -fn replace_positional_command_token( - args: &[String], - root_name: &str, - bool_flags: &BTreeSet, - value_flags: &BTreeSet, - target: usize, - replacement: &str, -) -> Vec { - let mut out = args.to_vec(); - let mut index = 0; - if out - .first() - .is_some_and(|arg| arg_matches_root_name(arg, root_name)) - { - index = 1; - } - - let mut positional = 0; - while index < out.len() { - let arg = &out[index]; - if arg == "--" { - break; - } - if arg.contains('=') { - index += 1; - continue; - } - if bool_flags.contains(arg) { - index += 1; - continue; - } - if value_flags.contains(arg) - || unknown_flag_consumes_value(arg, out.get(index + 1).as_ref()) - { - index += 2; - continue; - } - if arg.starts_with('-') { - index += 1; - continue; - } - if positional == target { - out[index] = replacement.to_owned(); - break; - } - positional += 1; - index += 1; - } - out -} - -/// Finds the closest visible subcommand name or alias within edit-distance -/// `max(1, token_len / 3)`. Returns the canonical name; ties break alphabetically. -fn nearest_subcommand(command: &Command, token: &str) -> Option { - let token = token.to_ascii_lowercase(); - let max_distance = 1.max(token.chars().count() / 3); - - command - .get_subcommands() - .filter(|child| !child.is_hide_set()) - .filter_map(|child| { - let best = std::iter::once(child.get_name()) - .chain(child.get_all_aliases()) - .map(|candidate| strsim::osa_distance(&token, &candidate.to_ascii_lowercase())) - .min()?; - (best <= max_distance).then(|| (best, child.get_name().to_owned())) - }) - .min_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1))) - .map(|(_, name)| name) -} - -/// Corrects every unknown group token to its nearest subcommand. Returns `None` -/// when any token has no near match, or when there is nothing to correct. -/// Stops at a leaf operand, curated ` help`, or an unfixable token. -fn full_command_correction(root: &Command, positionals: &[String]) -> Option> { - let mut current = root; - let mut corrections = Vec::new(); - for (index, token) in positionals.iter().enumerate() { - if let Some(next) = current.find_subcommand(token) { - current = next; - continue; - } - if current.get_subcommands().next().is_none() { - break; - } - if token == "help" && current.find_subcommand("help").is_none() { - break; - } - let suggestion = nearest_subcommand(current, token)?; - let next = current.find_subcommand(&suggestion)?; - corrections.push((index, suggestion)); - current = next; - } - (!corrections.is_empty()).then_some(corrections) -} - -/// Prompt/display text for a correction. Last-token-only fixes show the bare -/// token; anything else shows the full corrected command path. -fn correction_display( - root_name: &str, - positionals: &[String], - corrections: &[(usize, String)], -) -> String { - if let [(index, only)] = corrections - && *index + 1 == positionals.len() - { - return only.clone(); - } - let mut tokens = vec![root_name.to_owned()]; - for (index, token) in positionals.iter().enumerate() { - let corrected = corrections - .iter() - .find(|(i, _)| *i == index) - .map(|(_, replacement)| replacement.clone()) - .unwrap_or_else(|| token.clone()); - tokens.push(corrected); - } - tokens.join(" ") -} - -#[cfg(test)] -mod unknown_command_suggestion_tests { - use super::*; - - fn sample_group() -> Command { - Command::new("gddy").subcommand( - Command::new("domain") - .alias("dns-domain") - .subcommand(Command::new("list")) - .subcommand(Command::new("available")), - ) - } - - #[test] - fn osa_distance_treats_adjacent_transposition_as_one_edit() { - // Guard against swapping to `strsim::levenshtein`, which counts swaps as two edits. - assert_eq!(strsim::osa_distance("domain", "domain"), 0); - assert_eq!(strsim::osa_distance("domian", "domain"), 1); - assert_eq!(strsim::osa_distance("lst", "list"), 1); - assert_eq!(strsim::osa_distance("lsit", "list"), 1); - assert_eq!(strsim::osa_distance("cat", "set"), 2); - } - - #[test] - fn nearest_subcommand_matches_close_typos() { - let root = sample_group(); - let domain = root.find_subcommand("domain").expect("domain registered"); - assert_eq!(nearest_subcommand(domain, "lst").as_deref(), Some("list")); - assert_eq!(nearest_subcommand(domain, "ilst").as_deref(), Some("list")); - assert_eq!( - nearest_subcommand(domain, "avaliable").as_deref(), - Some("available") - ); - } - - #[test] - fn nearest_subcommand_rejects_unrelated_tokens() { - let root = sample_group(); - let domain = root.find_subcommand("domain").expect("domain registered"); - assert_eq!(nearest_subcommand(domain, "missing"), None); - } - - #[test] - fn nearest_subcommand_returns_canonical_name_for_alias_typos() { - let root = sample_group(); - assert_eq!( - nearest_subcommand(&root, "dns-domian").as_deref(), - Some("domain") - ); - } - - #[test] - fn nearest_subcommand_skips_hidden_commands() { - let root = Command::new("gddy") - .subcommand(Command::new("visible")) - .subcommand(Command::new("hiddeen").hide(true)); - assert_eq!(nearest_subcommand(&root, "hidden"), None); - } - - #[test] - fn nearest_subcommand_rejects_short_unrelated_tokens() { - let root = Command::new("gddy").subcommand( - Command::new("config") - .subcommand(Command::new("get")) - .subcommand(Command::new("set")) - .subcommand(Command::new("add")), - ); - let config = root.find_subcommand("config").expect("config registered"); - assert_eq!(nearest_subcommand(config, "cat"), None); - assert_eq!(nearest_subcommand(config, "x"), None); - assert_eq!(nearest_subcommand(config, "st").as_deref(), Some("set")); - } - - #[test] - fn unknown_group_command_formats_did_you_mean_suffix() { - let root = sample_group(); - let unknown = detect_unknown_group_command(&root, &["domian".to_owned()]) - .expect("domian is an unknown top-level command"); - assert_eq!(unknown.base, "unknown command \"domian\" for \"gddy\""); - assert_eq!( - format_did_you_mean(&unknown.base, "domain"), - "unknown command \"domian\" for \"gddy\" — did you mean \"domain\"?" - ); - } - - #[test] - fn detect_unknown_group_command_reports_nested_typos() { - let root = sample_group(); - let unknown = detect_unknown_group_command(&root, &["domain".to_owned(), "lst".to_owned()]) - .expect("lst is an unknown subcommand of domain"); - assert_eq!(unknown.base, "unknown command \"lst\" for \"gddy domain\""); - assert_eq!( - format_did_you_mean(&unknown.base, "list"), - "unknown command \"lst\" for \"gddy domain\" — did you mean \"list\"?" - ); - } - - #[test] - fn detect_unknown_group_command_omits_hint_for_unrelated_tokens() { - let root = sample_group(); - let unknown = detect_unknown_group_command(&root, &["missing".to_owned()]) - .expect("missing is an unknown top-level command"); - assert_eq!(unknown.base, "unknown command \"missing\" for \"gddy\""); - } - - #[test] - fn full_command_correction_fixes_a_single_group_typo() { - let root = sample_group(); - let corrections = full_command_correction(&root, &["domian".to_owned()]) - .expect("domian is correctable to domain"); - assert_eq!(corrections, vec![(0, "domain".to_owned())]); - } - - #[test] - fn full_command_correction_fixes_every_typo_in_a_nested_path() { - let root = sample_group(); - let corrections = full_command_correction(&root, &["domian".to_owned(), "lst".to_owned()]) - .expect("both tokens are correctable"); - assert_eq!( - corrections, - vec![(0, "domain".to_owned()), (1, "list".to_owned())] - ); - } - - #[test] - fn full_command_correction_bails_when_a_token_has_no_near_match() { - let root = sample_group(); - assert_eq!( - full_command_correction(&root, &["domain".to_owned(), "missing".to_owned()]), - None - ); - } - - #[test] - fn full_command_correction_is_none_when_there_is_nothing_to_correct() { - let root = sample_group(); - assert_eq!(full_command_correction(&root, &["domain".to_owned()]), None); - assert_eq!(full_command_correction(&root, &[]), None); - } - - #[test] - fn full_command_correction_corrects_the_group_before_curated_help() { - let root = sample_group(); - let corrections = full_command_correction(&root, &["domian".to_owned(), "help".to_owned()]) - .expect("domian is correctable even ahead of a help token"); - assert_eq!(corrections, vec![(0, "domain".to_owned())]); - } - - #[test] - fn full_command_correction_keeps_corrections_when_a_leaf_is_followed_by_an_operand() { - let root = sample_group(); - let corrections = full_command_correction( - &root, - &[ - "domain".to_owned(), - "avaliable".to_owned(), - "example.com".to_owned(), - ], - ) - .expect("avaliable is correctable to available"); - assert_eq!(corrections, vec![(1, "available".to_owned())]); - } - - #[test] - fn correction_display_shows_the_bare_token_for_a_single_fix() { - let corrections = vec![(1, "list".to_owned())]; - assert_eq!( - correction_display( - "gddy", - &["domain".to_owned(), "lst".to_owned()], - &corrections - ), - "list" - ); - } - - #[test] - fn correction_display_shows_the_full_command_when_a_single_fix_is_not_the_last_token() { - let corrections = vec![(0, "domain".to_owned())]; - assert_eq!( - correction_display( - "gddy", - &["domian".to_owned(), "list".to_owned()], - &corrections - ), - "gddy domain list" - ); - } - - #[test] - fn correction_display_shows_the_full_command_for_multiple_fixes() { - let corrections = vec![(0, "domain".to_owned()), (1, "list".to_owned())]; - assert_eq!( - correction_display( - "gddy", - &["domian".to_owned(), "lst".to_owned()], - &corrections - ), - "gddy domain list" - ); - } - - #[test] - fn replace_positional_command_token_rewrites_only_the_target() { - let bool_flags: BTreeSet = ["--verbose".to_owned()].into_iter().collect(); - let value_flags: BTreeSet = ["--output".to_owned()].into_iter().collect(); - let args = vec![ - "gddy".to_owned(), - "--output".to_owned(), - "json".to_owned(), - "domain".to_owned(), - "lst".to_owned(), - ]; - let corrected = - replace_positional_command_token(&args, "gddy", &bool_flags, &value_flags, 1, "list"); - assert_eq!( - corrected, - vec!["gddy", "--output", "json", "domain", "list"] - ); - } - - #[test] - fn rewrite_group_help_if_needed_runs_after_typo_correction() { - let root = sample_group(); - let bool_flags = derive_bool_flags(&root); - let value_flags = derive_value_flags(&root); - let args = vec!["gddy".to_owned(), "domian".to_owned(), "help".to_owned()]; - let corrected = - replace_positional_command_token(&args, "gddy", &bool_flags, &value_flags, 0, "domain"); - assert_eq!(corrected, vec!["gddy", "domain", "help"]); - let rewritten = - rewrite_group_help_if_needed(&root, &corrected, "gddy", &bool_flags, &value_flags); - assert_eq!(rewritten, vec!["gddy", "help", "domain"]); - } -} - -/// Detects the ` help [sub...]` form and returns the command path whose -/// help should be rendered. -/// -/// The engine ships a curated root `help` command, so it disables clap's -/// auto-generated help subcommand on the root. That setting propagates to every -/// subcommand and cannot be re-enabled per child, so ` help` would -/// otherwise hit clap's "unrecognized subcommand" error even though the group's -/// help listing advertises a `help` entry. We recognize the form here so the -/// caller can route it through the curated help renderer, matching clap's -/// documented equivalence between `cmd group help sub` and `cmd help group sub`. -/// -/// Only groups (commands that have subcommands) are matched: a group is pure -/// subcommand dispatch, so a `help` token in that position is unambiguously a -/// help request. Leaf commands may accept a literal `help` positional argument, -/// so they are left for clap to parse (` --help` still works). A group -/// that registers its own real `help` subcommand is likewise deferred to clap, -/// which dispatches the user-defined command (only auto-generated help is -/// suppressed). -/// -/// `command_keyword_count` is the number of leading positionals that are -/// genuine command keywords (those before any `--`). A `help` at or beyond that -/// index is a literal operand after `--`, not a help request, so it is ignored. -fn group_help_target_parts( - root: &Command, - positionals: &[String], - command_keyword_count: usize, -) -> Option> { - let help_index = positionals.iter().position(|token| token == "help")?; - // A leading `help` is the curated root help command; let it flow through. - if help_index == 0 { - return None; - } - // A `help` after a `--` separator is a literal operand; leave it for clap. - if help_index >= command_keyword_count { - return None; - } - let prefix = &positionals[..help_index]; - let mut current = root; - for token in prefix { - current = current.find_subcommand(token)?; - } - // The token before `help` must resolve to a group; leaves are left to clap. - current.get_subcommands().next()?; - // Defer to clap when the group defines a real `help` subcommand of its own. - if current.find_subcommand("help").is_some() { - return None; - } - // ` help ` shows help for ` `. - let suffix = &positionals[help_index + 1..]; - Some(prefix.iter().chain(suffix).cloned().collect()) -} - -/// Rewrites a ` help [sub...]` invocation into the canonical -/// `help [sub...]` argument vector. -/// -/// Only the positional command tokens are reordered (from `[group..., help, -/// sub...]` to `[help, group..., sub...]`); every flag — including `key=value` -/// forms, value-consuming flags, unknown flags that consume a value, and -/// anything after `--` — is preserved in its original place. Reordering keeps -/// the positional count unchanged, so the rewritten stream is filled slot for -/// slot. `parts` is the resolved command path (group + subcommand) from -/// [`group_help_target_parts`]. -fn rewrite_group_help_args( - clap_args: &[String], - root_name: &str, - bool_flags: &BTreeSet, - value_flags: &BTreeSet, - parts: &[String], -) -> Vec { - // New positional order: the curated `help` command, then the command path. - let mut next_positional = std::iter::once("help".to_owned()) - .chain(parts.iter().cloned()) - .peekable(); - let mut out = Vec::with_capacity(clap_args.len()); - let mut iter = clap_args.iter().peekable(); - if iter - .peek() - .is_some_and(|arg| arg_matches_root_name(arg, root_name)) - && let Some(program) = iter.next() - { - out.push(program.clone()); - } - - let mut take_positional = - |fallback: &String| next_positional.next().unwrap_or(fallback.clone()); - - while let Some(arg) = iter.next() { - if arg == "--" { - out.push(arg.clone()); - // Everything after `--` is positional. - for rest in iter.by_ref() { - out.push(take_positional(rest)); - } - break; - } - if arg.contains('=') || bool_flags.contains(arg) { - out.push(arg.clone()); - continue; - } - if value_flags.contains(arg) || unknown_flag_consumes_value(arg, iter.peek()) { - out.push(arg.clone()); - if let Some(value) = iter.next() { - out.push(value.clone()); - } - continue; - } - if arg.starts_with('-') { - out.push(arg.clone()); - continue; - } - out.push(take_positional(arg)); - } - // Defensive: emit any positionals not yet placed (counts normally match). - out.extend(next_positional); - out -} - -fn positional_command_tokens( - args: &[String], - root_name: &str, - bool_flags: &BTreeSet, - value_flags: &BTreeSet, -) -> Vec { - let mut tokens = Vec::new(); - let mut iter = args.iter().peekable(); - if iter - .peek() - .is_some_and(|arg| arg_matches_root_name(arg, root_name)) - { - iter.next(); - } - - while let Some(arg) = iter.next() { - if arg == "--" { - tokens.extend(iter.cloned()); - break; - } - if arg.contains('=') { - continue; - } - if bool_flags.contains(arg) { - continue; - } - if value_flags.contains(arg) || unknown_flag_consumes_value(arg, iter.peek()) { - iter.next(); - continue; - } - if arg.starts_with('-') { - continue; - } - tokens.push(arg.clone()); - } - tokens -} - -/// Returns the sole visible leaf subcommand of a bare group, if unambiguous. -/// -/// Clap may still attach a `help` subcommand on nested groups even when the -/// root disables the auto help subcommand, so that name is excluded. -fn single_leaf_subcommand(group: &Command) -> Option { - let candidates: Vec<_> = group - .get_subcommands() - .filter(|child| !child.is_hide_set()) - .filter(|child| child.get_name() != "help") - .filter(|child| child.get_subcommands().next().is_none()) - .collect(); - if candidates.len() == 1 { - Some(candidates[0].get_name().to_string()) - } else { - None - } -} - -/// Inserts `subcommand` immediately after the colon-separated `command_path` -/// tokens in `args`, before any trailing flags or positional values. -fn inject_subcommand_after_command_path( - args: &[String], - root_name: &str, - command_path: &str, - subcommand: &str, - bool_flags: &BTreeSet, - value_flags: &BTreeSet, -) -> Vec { - let path_parts: Vec<&str> = command_path.split(':').collect(); - let mut result = Vec::with_capacity(args.len() + 1); - let mut iter = args.iter().peekable(); - - if iter - .peek() - .is_some_and(|arg| arg_matches_root_name(arg, root_name)) - { - result.push(iter.next().expect("peeked").clone()); - } - - let mut matched = 0_usize; - while let Some(arg) = iter.next() { - if arg == "--" { - result.push(arg.clone()); - result.extend(iter.cloned()); - break; - } - if arg.contains('=') { - result.push(arg.clone()); - continue; - } - if bool_flags.contains(arg) { - result.push(arg.clone()); - continue; - } - if value_flags.contains(arg) || unknown_flag_consumes_value(arg, iter.peek()) { - result.push(arg.clone()); - if let Some(value) = iter.next() { - result.push(value.clone()); - } - continue; - } - if arg.starts_with('-') { - result.push(arg.clone()); - continue; - } - - result.push(arg.clone()); - if matched < path_parts.len() && arg == path_parts[matched] { - matched += 1; - if matched == path_parts.len() { - result.push(subcommand.to_string()); - } - } - } - result -} - -fn unknown_flag_consumes_value(arg: &str, next: Option<&&String>) -> bool { - arg.starts_with('-') && next.is_some_and(|value| !value.starts_with('-')) -} - -fn arg_matches_root_name(arg: &str, root_name: &str) -> bool { - arg == root_name - || Path::new(arg) - .file_stem() - .and_then(|n| n.to_str()) - .is_some_and(|n| n == root_name) -} - -/// Outcome of [`Cli::resolve_argv0`]: either rewritten arguments to feed the -/// normal pipeline, or a fully rendered result to return immediately. -enum Argv0Outcome { - /// Continue the normal run pipeline with these arguments. - Proceed(Vec), - /// Return this already-rendered result without further processing. - Handled(CliRunOutput), -} - -/// Extracts the bare program name from an `argv[0]` value, dropping any directory -/// path and file extension (e.g. `/usr/bin/pl` or `pl.exe` both yield `pl`). -/// Falls back to the raw value when no file stem can be derived. -fn program_basename(arg: &str) -> String { - Path::new(arg) - .file_stem() - .and_then(|stem| stem.to_str()) - .map_or_else(|| arg.to_owned(), ToOwned::to_owned) -} - -/// Returns `true` when `name` is a valid alternative `argv[0]` route name: a -/// non-empty token of ASCII letters, digits, `-`, or `_`. This keeps the name -/// safe as a link/shim filename and as an `argv[0]` basename (which is matched -/// with its extension stripped, so an embedded dot would break matching). -fn is_valid_argv0_name(name: &str) -> bool { - !name.is_empty() - && name.chars().all(|character| { - character.is_ascii_alphanumeric() || character == '-' || character == '_' - }) -} - -/// Returns `true` when the entry at `link` already matches what [`Cli::create_link`] -/// would produce for `method`/`target`/`name`, so it can be left untouched. A -/// mismatch (wrong kind, stale symlink target, or differing contents) returns -/// `false` so the caller replaces it. -fn argv0_link_matches( - link: &Path, - target: &Path, - name: &str, - method: Argv0LinkMethod, -) -> std::io::Result { - let metadata = std::fs::symlink_metadata(link)?; - match method { - Argv0LinkMethod::SoftLink => { - Ok(metadata.file_type().is_symlink() && std::fs::read_link(link)? == target) - } - Argv0LinkMethod::HardLink => { - if metadata.file_type().is_symlink() { - return Ok(false); - } - // A correct hard link is indistinguishable from the target by content; - // comparing bytes also accepts an identical copy, which is harmless. - Ok(std::fs::read(link)? == std::fs::read(target)?) - } - Argv0LinkMethod::Script => { - if metadata.file_type().is_symlink() { - return Ok(false); - } - Ok(std::fs::read_to_string(link).ok() == Some(argv0_script_contents(target, name))) - } - } -} - -/// File name for an alternative `argv[0]` link, per method and host platform. -fn argv0_link_file_name(name: &str, method: Argv0LinkMethod) -> String { - let extension = match method { - Argv0LinkMethod::Script if cfg!(windows) => ".cmd", - // Unix scripts are extension-less executables; links carry `.exe` on Windows. - Argv0LinkMethod::Script => "", - _ if cfg!(windows) => ".exe", - _ => "", - }; - format!("{name}{extension}") -} - -/// Contents of an alternative `argv[0]` shim script that forwards to `target` -/// via the explicit `argv0` command. A `.cmd` batch file on Windows, an -/// executable POSIX shell script elsewhere. -fn argv0_script_contents(target: &Path, name: &str) -> String { - let target = target.display(); - if cfg!(windows) { - format!("@\"{target}\" argv0 {name} %*\r\n") - } else { - format!("#!/bin/sh\nexec \"{target}\" argv0 {name} \"$@\"\n") - } -} - -#[cfg(unix)] -fn create_symlink(target: &Path, link: &Path) -> std::io::Result<()> { - std::os::unix::fs::symlink(target, link) -} - -#[cfg(windows)] -fn create_symlink(target: &Path, link: &Path) -> std::io::Result<()> { - std::os::windows::fs::symlink_file(target, link) -} - -#[cfg(not(any(unix, windows)))] -fn create_symlink(_target: &Path, _link: &Path) -> std::io::Result<()> { - Err(std::io::Error::new( - std::io::ErrorKind::Unsupported, - "symlink creation is not supported on this platform", - )) -} - -/// Marks a freshly written shim script executable on Unix; a no-op elsewhere. -#[cfg(unix)] -fn make_executable(path: &Path) -> std::io::Result<()> { - use std::os::unix::fs::PermissionsExt; - let mut permissions = std::fs::metadata(path)?.permissions(); - permissions.set_mode(0o755); - std::fs::set_permissions(path, permissions) -} - -#[cfg(not(unix))] -fn make_executable(_path: &Path) -> std::io::Result<()> { - Ok(()) -} - -/// Walks a runtime group tree, resolving each node's effective feature flag by -/// cascading from `inherited` — a node's own [`GroupSpec::feature_flag`] or -/// [`CommandSpec::feature_flag`] wins if set, otherwise it inherits the -/// nearest ancestor's effective flag, otherwise (nothing in the ancestor -/// chain declared a flag) it implicitly resolves to [`Stage::Ga`] with no key. -/// Every node that resolves to a *named* flag (own or inherited) is recorded -/// into `registry` under its colon-separated path, together with whether -/// `policy` judged it visible. Nodes that resolve to the implicit no-flag -/// default are not recorded (there is nothing to introspect) and are always -/// visible. -/// -/// Returns `None` when this group itself should be dropped from the tree — -/// either because its effective flag is not visible under `policy`, or -/// because every one of its commands and subgroups was pruned away, leaving -/// an empty group with nothing to mount. An emptied-out group is dropped -/// unconditionally, even if its own flag was visible: a `clap` subcommand -/// group with zero children is useless either way, so this simplifies the -/// pruning logic rather than threading through a "was this group itself -/// visible but empty" distinction that no caller needs. -/// -/// Note that an invisible ancestor short-circuits before its children are -/// even visited: a more permissive flag on a descendant cannot resurrect a -/// subtree whose enclosing group already failed the visibility check. -fn prune_feature_flag_tree( - mut group: RuntimeGroupSpec, - inherited: Option<&FeatureFlag>, - policy: &FlagPolicy, - prefix: &mut Vec, - registry: &mut FlagRegistry, -) -> Option { - prefix.push(group.group.name.clone()); - - let effective = group - .group - .feature_flag - .clone() - .or_else(|| inherited.cloned()); - if !record_and_check_visibility(effective.as_ref(), policy, prefix, registry) { - prefix.pop(); - return None; - } - - let mut kept_groups = Vec::with_capacity(group.groups.len()); - for child in std::mem::take(&mut group.groups) { - if let Some(pruned) = - prune_feature_flag_tree(child, effective.as_ref(), policy, prefix, registry) - { - kept_groups.push(pruned); - } - } - group.groups = kept_groups; - - let mut kept_commands = Vec::with_capacity(group.commands.len()); - for command in std::mem::take(&mut group.commands) { - prefix.push(command.spec.name.clone()); - let command_effective = command - .spec - .feature_flag - .clone() - .or_else(|| effective.clone()); - let visible = - record_and_check_visibility(command_effective.as_ref(), policy, prefix, registry); - prefix.pop(); - if visible { - kept_commands.push(command); - } - } - group.commands = kept_commands; - - prefix.pop(); - - if group.commands.is_empty() && group.groups.is_empty() { - None - } else { - Some(group) - } -} - -/// Records `effective` at the current `prefix` path into `registry` (only -/// when it names a flag key — the implicit Ga default is not recorded) and -/// returns whether the node is visible under `policy`. -fn record_and_check_visibility( - effective: Option<&FeatureFlag>, - policy: &FlagPolicy, - prefix: &[String], - registry: &mut FlagRegistry, -) -> bool { - let Some(flag) = effective else { - return true; - }; - let visible = policy.visible(Some(flag.key.as_str()), flag.stage); - registry.record(FlagEntry { - path: prefix.join(":"), - key: flag.key.clone(), - stage: flag.stage, - visible, - }); - visible -} - -fn register_runtime_group_metadata( - group: &RuntimeGroupSpec, - prefix: &mut Vec, - schemas: &mut SchemaRegistry, - views: &mut HumanViewRegistry, -) { - prefix.push(group.group.name.clone()); - for child_group in &group.groups { - register_runtime_group_metadata(child_group, prefix, schemas, views); - } - for child in &group.commands { - prefix.push(child.spec.name.clone()); - let command_path = prefix.join(":"); - register_command_schema(&child.spec, &command_path, schemas); - // An inline `with_view` is registered under the command's own path; the - // dispatch references it by that path. A `with_view_id` takes precedence - // (dispatch uses it instead), so skip the inline registration when one is - // set — registering it would leave an unused entry. Shared views are - // registered separately by the module/CLI. - if child.spec.view_id.is_none() && !child.spec.view_columns.is_empty() { - views.register(HumanViewDef::new( - command_path, - child.spec.view_columns.clone(), - )); - } - prefix.pop(); - } - prefix.pop(); -} - -fn register_command_schema(spec: &CommandSpec, command_path: &str, schemas: &mut SchemaRegistry) { - if let Some(schema) = &spec.output_schema { - schemas.register_info(command_path.to_owned(), schema.clone()); - } -} - -fn runtime_group_clap_command_with_schema_help( - group: &RuntimeGroupSpec, - prefix: &mut Vec, - schemas: &SchemaRegistry, -) -> Command { - let mut command = group_clap_command_without_children(&group.group); - prefix.push(group.group.name.clone()); - for child_group in &group.groups { - command = command.subcommand(runtime_group_clap_command_with_schema_help( - child_group, - prefix, - schemas, - )); - } - for child in &group.commands { - prefix.push(child.spec.name.clone()); - let command_path = prefix.join(":"); - command = command.subcommand(command_clap_command_with_schema_help( - &child.spec, - &command_path, - schemas, - )); - prefix.pop(); - } - prefix.pop(); - command -} - -fn group_clap_command_without_children(group: &GroupSpec) -> Command { - let mut command = Command::new(group.name.clone()) - .about(group.short.clone()) - .help_template(GROUP_HELP_TEMPLATE); - if let Some(long) = &group.long - && !long.is_empty() - { - command = command.long_about(long.clone()); - } - for alias in &group.aliases { - command = command.alias(alias.clone()); - } - if group.hidden { - command = command.hide(true); - } - command -} - -fn command_clap_command_with_schema_help( - spec: &CommandSpec, - command_path: &str, - schemas: &SchemaRegistry, -) -> Command { - debug_assert!( - !(spec.raw_output && spec.pagination.is_some()), - "command {:?} sets both raw_output and with_pagination; a single verbatim string \ - has no pages, so the two are mutually exclusive", - spec.name - ); - let mut command = spec.clap_command(); - command = apply_dry_run_visibility(command, spec); - command = apply_pagination_args(command, spec); - let schema = schemas.get_by_path(command_path); - let default_fields = default_field_names(spec); - command = apply_fields_arg( - command, - spec, - schema.as_ref().map(|schema| schema.fields.as_slice()), - &default_fields, - ); - command = apply_output_format_visibility(command, spec); - let filter_expr_fields = schema - .as_ref() - .map_or(&[][..], |schema| schema.fields.as_slice()); - apply_filter_and_expr_examples(command, spec, filter_expr_fields) -} - -/// Hides this command's inherited `--output` flag when it opted into -/// [`CommandSpec::raw_output`]. -fn apply_output_format_visibility(command: Command, spec: &CommandSpec) -> Command { - if !spec.raw_output { - return command; - } - use std::io::IsTerminal; - command.arg( - Arg::new("output") - .long("output") - .short('o') - .value_name("FORMAT") - .default_value(if std::io::stdout().is_terminal() { - "human" - } else { - "json" - }) - .conflicts_with_all(["json", "toon", "human"]) - .display_order(crate::flags::global_flag_order::OUTPUT) - .hide(true) - .help("Ignored — this command always prints raw text"), - ) -} - -/// Hides this command's inherited `--dry-run` flag when the command isn't -/// mutating (per [`CommandSpec::metadata`]'s `dry_run_prompt` — mirrored -/// here rather than reused, since that method returns the broader -/// [`CommandMeta`], not this one bool). `--dry-run` only ever does anything -/// for a command that opted in via `.mutates(true)`/`.with_tier(...)` (see -/// `Middleware::render_envelope`'s `meta.dry_run_prompt` gate), so showing -/// it on every other command is noise. The override still parses `--dry-run` -/// identically (same value parser, same defaults) in case a caller passes -/// it anyway — hidden only changes what `--help` shows, never behavior. -fn apply_dry_run_visibility(command: Command, spec: &CommandSpec) -> Command { - let mutates = spec.mutates || spec.tier.is_some_and(crate::Tier::is_mutating); - if mutates { - return command; - } - command.arg( - Arg::new("dry-run") - .long("dry-run") - .num_args(0..=1) - .require_equals(true) - .default_missing_value("true") - .default_value("false") - .value_parser(crate::flags::compat_bool_value_parser()) - .display_order(crate::flags::global_flag_order::DRY_RUN) - .hide(true) - .help("Preview mutations without executing"), - ) -} - -/// Registers `--limit`/`--offset` on this command's own `Command` when its -/// spec opted in via [`CommandSpec::with_pagination`], and leaves the command -/// untouched otherwise so a non-paginating command never sees those flags — -/// in `--help` or on its command line. See [`flags::apply_pagination_args`]. -fn apply_pagination_args(command: Command, spec: &CommandSpec) -> Command { - let Some(pagination) = spec.pagination else { - return command; - }; - crate::flags::apply_pagination_args(command, pagination.default_limit, pagination.max_limit) -} - -/// Splits a command's raw `default_fields` string into individual field -/// names, dropping the `all`/`*` sentinels that mean "every field" rather -/// than naming a real field. -fn default_field_names(spec: &CommandSpec) -> Vec<&str> { - spec.default_fields - .as_deref() - .map(|fields| { - fields - .split(',') - .map(str::trim) - .filter(|field| !field.is_empty() && *field != "all" && *field != "*") - .collect() - }) - .unwrap_or_default() -} - -/// Overrides this command's `--fields` flag with everything specific to this -/// command: its own `default_fields` as a native clap default value (so -/// `--help` shows `[default: ...]` on the flag itself, the same way -/// `--dry-run` shows `[default: false]`), and, when a schema is registered, -/// the output-field summary table appended to the flag's own help text -/// instead of the command's description — a long field table there used to -/// push `Usage:` far down the page. Global args apply to every subcommand, -/// but a subcommand-local arg of the same name takes precedence, so this -/// only affects the one command being built here. -fn apply_fields_arg( - command: Command, - spec: &CommandSpec, - schema_fields: Option<&[FieldInfo]>, - default_fields: &[&str], -) -> Command { - if spec.raw_output { - return command.arg( - Arg::new("fields") - .long("fields") - .value_name("FIELDS") - .display_order(crate::flags::global_flag_order::FIELDS) - .hide(true) - .help("Ignored — this command always prints raw text"), - ); - } - let default_value = spec - .default_fields - .as_deref() - .filter(|fields| !fields.is_empty()); - let table = schema_fields - .filter(|fields| !fields.is_empty()) - .map(|fields| format_help_section(fields, default_fields)); - if default_value.is_none() && table.is_none() { - return command; - } - - let mut help = String::from( - "Comma-separated fields to include in output (use 'all' or '*' for everything)", - ); - if let Some(table) = &table { - help.push_str("\n\n"); - help.push_str(table.trim_end()); - } - - let mut arg = Arg::new("fields") - .long("fields") - .value_name("FIELDS") - // Must match `global_flag_order::FIELDS` — this re-registers the - // same flag with contextual help, not a new one, and needs to keep - // its place among the other global flags rather than falling back - // to this subcommand's own low, command-specific counter value. - .display_order(crate::flags::global_flag_order::FIELDS) - .help(help); - if let Some(default_value) = default_value { - arg = arg.default_value(default_value.to_owned()); - } - command.arg(arg) -} - -/// Overrides this command's `--filter` and `--expr` flags with help text -/// carrying usage examples built from its own output fields, so `--help` -/// shows them right under the flag instead of in a separate "Filter -/// examples:"/"Expr examples:" section disconnected from the flags they -/// demonstrate. Mirrors [`apply_fields_arg`]: a subcommand-local arg of the -/// same name shadows the framework's global one, and must carry the same -/// `global_flag_order` value as that global one for the same reason. -fn apply_filter_and_expr_examples( - mut command: Command, - spec: &CommandSpec, - fields: &[FieldInfo], -) -> Command { - if spec.raw_output { - return command - .arg( - Arg::new("filter") - .long("filter") - .value_name("EXPR") - .display_order(crate::flags::global_flag_order::FILTER) - .hide(true) - .help("Ignored — this command always prints raw text"), - ) - .arg( - Arg::new("expr") - .long("expr") - .value_name("EXPR") - .display_order(crate::flags::global_flag_order::EXPR) - .hide(true) - .help("Ignored — this command always prints raw text"), - ); - } - if fields.is_empty() { - return command; - } - let first_string = fields - .iter() - .find(|field| field.field_type == "string") - .map(|field| field.name.as_str()); - let first_bool = fields - .iter() - .find(|field| field.field_type == "bool") - .map(|field| field.name.as_str()); - - if first_string.is_some() || first_bool.is_some() { - let mut help = String::from("Per-item JMESPath predicate for list data"); - if let Some(name) = first_string { - help.push_str(&format!("\ne.g. --filter \"contains({name}, 'example')\"")); - } - if let Some(name) = first_bool { - help.push_str(&format!("\ne.g. --filter '{name}'")); - } - command = command.arg( - Arg::new("filter") - .long("filter") - .value_name("EXPR") - .display_order(crate::flags::global_flag_order::FILTER) - .help(help), - ); - } - - let mut expr_help = String::from("JMESPath query applied to the whole result"); - expr_help.push_str("\ne.g. --expr 'length(@)'"); - if let Some(name) = first_string { - expr_help.push_str(&format!("\ne.g. --expr '[].{name}'")); - } - command.arg( - Arg::new("expr") - .long("expr") - .value_name("EXPR") - .display_order(crate::flags::global_flag_order::EXPR) - .help(expr_help), - ) -} - -fn process_exit_code(code: i32) -> ExitCode { - if code == 0 { - return ExitCode::SUCCESS; - } - match u8::try_from(code) { - Ok(code) if code != 0 => ExitCode::from(code), - Ok(_) | Err(_) => ExitCode::from(1), - } -} - -async fn run_streaming_command( - middleware: &Middleware, - request: MiddlewareRequest<'_>, - raw_matches: Arc, - streaming_handler: crate::command::StreamingCommandHandler, -) -> Result { - use tokio::{io::AsyncWriteExt, sync::mpsc}; - - let args_for_handler = request.args.clone(); - let user_args_for_handler = request.user_args.clone(); - let handler_path = request.command_path.to_owned(); - let middleware_for_handler = middleware.clone(); - let raw_matches_for_handler = raw_matches; - - let (tx, mut rx) = mpsc::channel::(64); - let sender = StreamSender(tx); - - // Drain the channel concurrently so the handler's sends don't stall - // while the writer flushes to stdout. If stdout is under backpressure - // the bounded channel can still fill and the handler will await send. - let writer = tokio::spawn(async move { - let mut stdout = tokio::io::stdout(); - while let Some(event) = rx.recv().await { - let Ok(line) = serde_json::to_string(&event) else { - continue; - }; - if stdout.write_all(line.as_bytes()).await.is_err() - || stdout.write_all(b"\n").await.is_err() - || stdout.flush().await.is_err() - { - break; - } - } - }); - - let output = middleware - .run(request, async move |credential| { - streaming_handler( - CommandContext { - credential, - args: args_for_handler, - user_args: user_args_for_handler, - command_path: handler_path, - middleware: middleware_for_handler, - raw_matches: raw_matches_for_handler, - }, - sender, - ) - .await?; - Ok(crate::CommandResult::new(serde_json::Value::Null)) - }) - .await; - - // Handler has completed; its sender is dropped, which closes the channel. - // Wait for the writer task to flush all remaining events. - let _write_result = writer.await; - - match output { - Ok(out) if out.exit_code == 0 => Ok(CliRunOutput { - exit_code: 0, - rendered: String::new(), - }), - Ok(out) => Ok(out.into()), - Err(err) => Ok(CliRunOutput { - exit_code: exit_code_for_error(&err), - rendered: render_cli_error(middleware, &err, middleware.app_id.as_str()).rendered, - }), - } -} - -#[cfg(test)] -mod user_agent_tests { - use super::*; - - #[test] - fn user_agent_string_derives_name_and_version_by_default() { - let config = - CliConfig::new("gdx", "GoDaddy CLI", "gdx").with_build(BuildInfo::new("1.2.3")); - assert_eq!(config.user_agent_string(), "gdx/1.2.3"); - } - - #[test] - fn user_agent_string_prefers_explicit_override() { - let config = CliConfig::new("gdx", "GoDaddy CLI", "gdx") - .with_build(BuildInfo::new("1.2.3")) - .with_user_agent("gdx-cli/9.9 (custom)"); - assert_eq!(config.user_agent_string(), "gdx-cli/9.9 (custom)"); - } - - #[test] - fn user_agent_string_omits_version_when_absent() { - let config = CliConfig::new("gdx", "GoDaddy CLI", "gdx"); - assert_eq!(config.user_agent_string(), "gdx"); - } - - #[test] - fn install_default_user_agent_publishes_config_value() { - let _guard = crate::transport::client::UA_TEST_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let _restore = crate::transport::client::RestoreDefaultUserAgent; - crate::transport::set_default_user_agent("cli/dev"); - let cli = Cli::new( - CliConfig::new("uatest", "UA test", "uatest").with_build(BuildInfo::new("4.5.6")), - ); - cli.install_default_user_agent(); - assert_eq!( - crate::transport::client::default_user_agent(), - "uatest/4.5.6" - ); - } - - #[test] - fn install_debug_transport_logger_tracks_the_debug_pattern() { - // Asserts on `debug_transport_logger_for`'s decision directly rather - // than publishing to and reading back the process-wide default - // logger, which `Cli::run` republishes on every call — including the - // many unrelated tests that call `cli.run(...)` with no `--debug` - // flag and would otherwise race with this assertion. - - // `transport` selected -> an active (enabled) logger is built. - assert!(debug_transport_logger_for("transport", &[]).enabled()); - - // Wildcard with transport excluded -> a disabled (noop) logger. - assert!(!debug_transport_logger_for("*,-transport", &[]).enabled()); - - // Empty pattern -> disabled (noop). - assert!(!debug_transport_logger_for("", &[]).enabled()); - } -} - -#[cfg(test)] -mod env_config_tests { - use super::*; - - #[test] - fn with_environments_stores_shared_arc_with_consumer_app_id() { - // The consumer sets app_id on the Environments before sharing the Arc; - // CliConfig stores it as-is, so the file path resolves only because the - // consumer stamped the matching app_id (not because the engine did). - let cfg = CliConfig::new("gddy", "GoDaddy CLI", "gddy").with_environments(Arc::new( - crate::environments::Environments::new("prod") - .with_app_id("gddy") - .with_config_file(true), - )); - let envs = cfg.environments.as_ref().expect("environments set"); - assert!(envs.config_file_path().is_some()); - } - - #[tokio::test] - async fn env_flag_overrides_default_and_reaches_middleware_env() { - use crate::{CommandResult, CommandSpec, RuntimeCommandSpec}; - use serde_json::json; - let mut cli = Cli::new( - CliConfig::new("envtest", "Env test", "envtest") - .with_environments(Arc::new( - crate::environments::Environments::new("prod") - .with_environment("prod", crate::environments::EnvTable::new()) - .with_environment("ote", crate::environments::EnvTable::new()), - )) - .with_startup_args(Vec::<&str>::new()), - ); - cli.add_command(RuntimeCommandSpec::new_with_context( - CommandSpec::new("whichenv", "echo env").no_auth(true), - async |ctx| { - Ok(CommandResult::new( - json!({ "env": ctx.environment()?.name().to_owned() }), - )) - }, - )); - let out = cli - .run(["envtest", "whichenv", "--env", "ote", "--output", "json"]) - .await; - assert_eq!(out.exit_code, 0, "rendered: {}", out.rendered); - assert!(out.rendered.contains("\"env\"")); - assert!(out.rendered.contains("ote")); - } - - #[tokio::test] - async fn unknown_env_flag_produces_error_envelope() { - let cli = Cli::new( - CliConfig::new("envtest2", "Env test", "envtest2") - .with_environments(Arc::new( - crate::environments::Environments::new("prod") - .with_environment("prod", crate::environments::EnvTable::new()), - )) - .with_startup_args(Vec::<&str>::new()), - ); - let out = cli.run(["envtest2", "tree", "--env", "nope"]).await; - assert_ne!(out.exit_code, 0); - assert!(out.rendered.contains("nope")); - } -} - -#[cfg(test)] -mod prescan_env_flag_tests { - use super::*; - - fn argv(args: &[&str]) -> impl Iterator { - args.iter() - .map(|s| s.to_string()) - .collect::>() - .into_iter() - } - - #[test] - fn finds_space_separated_value() { - assert_eq!( - prescan_env_flag(argv(&["--dry-run", "--env", "dev", "list"])), - Some("dev".to_owned()) - ); - } - - #[test] - fn finds_equals_separated_value() { - assert_eq!( - prescan_env_flag(argv(&["--env=dev", "list"])), - Some("dev".to_owned()) - ); - } - - #[test] - fn is_none_without_the_flag() { - assert_eq!(prescan_env_flag(argv(&["env", "list"])), None); - } - - #[test] - fn trailing_env_flag_with_no_value_is_none() { - assert_eq!(prescan_env_flag(argv(&["--env"])), None); - } - - #[test] - fn keeps_the_last_of_multiple_occurrences() { - // A global `--env` and a command-local one sharing the same arg id - // can both appear (e.g. `app --env bar sub --env foo ...`); clap - // resolves the *last* one as effective, so this scan must too. - assert_eq!( - prescan_env_flag(argv(&["--env", "bar", "sub", "cmd", "--env", "foo", "arg"])), - Some("foo".to_owned()) - ); - } - - #[test] - fn ignores_an_empty_equals_value() { - assert_eq!(prescan_env_flag(argv(&["--env="])), None); - } - - #[test] - fn empty_occurrence_does_not_clobber_an_earlier_real_value() { - assert_eq!( - prescan_env_flag(argv(&["--env", "dev", "--env="])), - Some("dev".to_owned()) - ); - } - - #[test] - fn space_separated_value_starting_with_dash_is_not_a_value() { - // clap rejects `--env --dry-run` outright ("a value is required for - // '--env ' but none was supplied") rather than treating - // `--dry-run` as the value; this scan must agree. - assert_eq!(prescan_env_flag(argv(&["--env", "--dry-run"])), None); - } - - #[test] - fn equals_form_accepts_a_value_starting_with_dash() { - // `--env=-foo` is unambiguous (unlike the space-separated form) and - // still accepted, matching clap's own disambiguation rule. - assert_eq!( - prescan_env_flag(argv(&["--env=-foo"])), - Some("-foo".to_owned()) - ); - } - - #[test] - fn stops_at_the_end_of_options_sentinel() { - // Everything after a bare `--` is positional to clap, never a flag — - // `app cmd -- --env dev` must not be read as a real `--env` override. - assert_eq!(prescan_env_flag(argv(&["cmd", "--", "--env", "dev"])), None); - } - - #[test] - fn a_real_flag_before_the_sentinel_is_still_found() { - assert_eq!( - prescan_env_flag(argv(&["--env", "dev", "--", "positional"])), - Some("dev".to_owned()) - ); - } -} - -#[cfg(test)] -mod feature_flag_pruning_tests { - use super::*; - use crate::CommandResult; - - fn trivial_command(name: &str) -> RuntimeCommandSpec { - RuntimeCommandSpec::new( - CommandSpec::new(name, "short").no_auth(true), - async |_, _| Ok(CommandResult::new(serde_json::Value::Null)), - ) - } - - fn flagged_command(name: &str, key: &str, stage: Stage) -> RuntimeCommandSpec { - let mut command = trivial_command(name); - command.spec = command.spec.with_feature_flag(key, stage); - command - } - - fn empty_policy() -> FlagPolicy { - FlagPolicy::default() - } - - #[test] - fn no_flags_anywhere_keeps_everything() { - let group = RuntimeGroupSpec::new(GroupSpec::new("root", "short")) - .with_command(trivial_command("a")) - .with_command(trivial_command("b")) - .with_group( - RuntimeGroupSpec::new(GroupSpec::new("child", "short")) - .with_command(trivial_command("c")), - ); - - let mut prefix = Vec::new(); - let mut registry = FlagRegistry::new(); - let pruned = - prune_feature_flag_tree(group, None, &empty_policy(), &mut prefix, &mut registry); - - let pruned = pruned.expect("unflagged tree should never be dropped"); - assert_eq!(pruned.commands.len(), 2); - assert_eq!(pruned.groups.len(), 1); - assert_eq!(pruned.groups[0].commands.len(), 1); - assert!(registry.entries().is_empty()); - } - - #[test] - fn experimental_command_is_pruned_sibling_is_not() { - let group = RuntimeGroupSpec::new(GroupSpec::new("root", "short")) - .with_command(flagged_command("gated", "gated-flag", Stage::Experimental)) - .with_command(trivial_command("sibling")); - - let mut prefix = Vec::new(); - let mut registry = FlagRegistry::new(); - let pruned = - prune_feature_flag_tree(group, None, &empty_policy(), &mut prefix, &mut registry) - .expect("group still has a visible command left"); - - assert_eq!(pruned.commands.len(), 1); - assert_eq!(pruned.commands[0].spec.name, "sibling"); - - let entries = registry.entries(); - assert_eq!(entries.len(), 1); - assert_eq!(entries[0].path, "root:gated"); - assert_eq!(entries[0].key, "gated-flag"); - assert!(!entries[0].visible); - } - - #[test] - fn beta_group_pruned_under_ga_min_stage_kept_under_beta_min_stage() { - let build_tree = || { - RuntimeGroupSpec::new(GroupSpec::new("root", "short")) - .with_command(trivial_command("keep-me")) - .with_group( - RuntimeGroupSpec::new( - GroupSpec::new("flagged-group", "short") - .with_feature_flag("group-flag", Stage::Beta), - ) - .with_command(trivial_command("cmd-default")) - .with_command(flagged_command( - "cmd-ga", - "cmd-ga-flag", - Stage::Ga, - )), - ) - }; - - // Default policy (min_stage: Ga) drops the whole Beta subtree, including - // both its undeclared and explicitly-Ga-declared children, because the - // ancestor group itself already fails visibility before children are - // even visited. - let mut prefix = Vec::new(); - let mut registry = FlagRegistry::new(); - let pruned = prune_feature_flag_tree( - build_tree(), - None, - &empty_policy(), - &mut prefix, - &mut registry, - ) - .expect("root keeps its unflagged sibling command"); - assert!(pruned.groups.is_empty()); - assert_eq!(pruned.commands.len(), 1); - assert_eq!(pruned.commands[0].spec.name, "keep-me"); - // Only the group itself was recorded; its children were never visited. - assert_eq!(registry.entries().len(), 1); - assert_eq!(registry.entries()[0].path, "root:flagged-group"); - assert!(!registry.entries()[0].visible); - - // A Beta-permissive policy keeps the group and both of its children. - let policy = FlagPolicy::default().with_min_stage(Stage::Beta); - let mut prefix = Vec::new(); - let mut registry = FlagRegistry::new(); - let pruned = - prune_feature_flag_tree(build_tree(), None, &policy, &mut prefix, &mut registry) - .expect("root is kept"); - assert_eq!(pruned.groups.len(), 1); - assert_eq!(pruned.groups[0].commands.len(), 2); - assert!(registry.entries().iter().all(|entry| entry.visible)); - } - - #[test] - fn ancestor_invisibility_short_circuits_before_children_are_visited() { - // The child declares its own, more permissive Ga flag under a distinct - // key. Per the documented pruning semantics, an invisible ancestor drops - // its whole subtree unconditionally: the child's own flag is never even - // considered, because `prune_feature_flag_tree` returns `None` for the - // ancestor as soon as its own effective flag fails visibility, before - // recursing into commands or subgroups at all. - let group = RuntimeGroupSpec::new( - GroupSpec::new("ancestor", "short").with_feature_flag("ancestor-flag", Stage::Beta), - ) - .with_command(flagged_command("child", "child-flag", Stage::Ga)); - - let mut prefix = Vec::new(); - let mut registry = FlagRegistry::new(); - let pruned = - prune_feature_flag_tree(group, None, &empty_policy(), &mut prefix, &mut registry); - - assert!( - pruned.is_none(), - "invisible ancestor drops its whole subtree" - ); - // The child was never visited, so nothing about it was recorded. - assert_eq!(registry.entries().len(), 1); - assert_eq!(registry.entries()[0].path, "ancestor"); - assert!(registry.by_key("child-flag").is_empty()); - } - - #[test] - fn cascading_inherited_flag_key_and_stage_reach_unflagged_descendants() { - // Simulates a module-level flag with no per-group/per-command - // declaration anywhere below it: `inherited` here stands in for - // `Module::feature_flag`, exactly as `add_module_group_inner` passes it. - let module_flag = FeatureFlag::new("module-flag", Stage::Beta); - let group = RuntimeGroupSpec::new(GroupSpec::new("root", "short")) - .with_command(trivial_command("unflagged-child")); - - let policy = FlagPolicy::default().with_min_stage(Stage::Beta); - let mut prefix = Vec::new(); - let mut registry = FlagRegistry::new(); - let pruned = prune_feature_flag_tree( - group, - Some(&module_flag), - &policy, - &mut prefix, - &mut registry, - ) - .expect("Beta-permissive policy keeps a Beta-inherited tree"); - assert_eq!(pruned.commands.len(), 1); - - // Both the group and the descendant command recorded the *same* - // inherited key/stage, proving real cascading rather than an implicit - // Ga default at either level. - let entries = registry.entries(); - assert_eq!(entries.len(), 2); - assert_eq!(entries[0].path, "root"); - assert_eq!(entries[0].key, "module-flag"); - assert_eq!(entries[0].stage, Stage::Beta); - assert_eq!(entries[1].path, "root:unflagged-child"); - assert_eq!(entries[1].key, "module-flag"); - assert_eq!(entries[1].stage, Stage::Beta); - - // Under the default (Ga) policy the same inherited Beta flag makes the - // whole tree invisible together, since the group and its unflagged - // child resolve to the identical effective flag. - let mut prefix = Vec::new(); - let mut registry = FlagRegistry::new(); - let pruned = prune_feature_flag_tree( - RuntimeGroupSpec::new(GroupSpec::new("root", "short")) - .with_command(trivial_command("unflagged-child")), - Some(&module_flag), - &empty_policy(), - &mut prefix, - &mut registry, - ); - assert!(pruned.is_none()); - } - - #[test] - fn registry_records_only_named_flags_not_unflagged_nodes() { - let group = RuntimeGroupSpec::new(GroupSpec::new("root", "short")).with_group( - RuntimeGroupSpec::new( - GroupSpec::new("g", "short").with_feature_flag("g-flag", Stage::Beta), - ) - .with_command(trivial_command("c1")) - .with_command(flagged_command("c2", "c2-flag", Stage::Ga)), - ); - - // Permissive enough that nothing is pruned, so every node is visited. - let policy = FlagPolicy::default().with_min_stage(Stage::Experimental); - let mut prefix = Vec::new(); - let mut registry = FlagRegistry::new(); - let pruned = prune_feature_flag_tree(group, None, &policy, &mut prefix, &mut registry) - .expect("permissive policy keeps everything"); - assert_eq!(pruned.groups[0].commands.len(), 2); - - let entries = registry.entries(); - assert_eq!(entries.len(), 3, "root has no flag and is not recorded"); - assert_eq!(entries[0].path, "root:g"); - assert_eq!(entries[0].key, "g-flag"); - assert_eq!(entries[1].path, "root:g:c1"); - assert_eq!(entries[1].key, "g-flag"); - assert_eq!(entries[1].stage, Stage::Beta); - assert_eq!(entries[2].path, "root:g:c2"); - assert_eq!(entries[2].key, "c2-flag"); - assert_eq!(entries[2].stage, Stage::Ga); - assert!(entries.iter().all(|entry| entry.visible)); - } - - #[test] - fn module_feature_flag_cascades_into_its_group_via_add_module() { - // Regression test for the bug this task fixes: `add_module` used to - // discard `module.feature_flag` entirely, so a module-level flag could - // never reach its group/commands. `Module::new` returns a group with an - // unflagged command; the module itself declares Experimental, and the - // default (Ga) policy must prune the whole group away. - let module = Module::new("Test Category", |_ctx| { - RuntimeGroupSpec::new(GroupSpec::new("gated-mod", "short")) - .with_command(trivial_command("list")) - }) - .with_feature_flag("module-flag", Stage::Experimental); - - let mut cli = Cli::new(CliConfig::new("modtest", "Module test", "modtest")); - cli.add_module(module); - - assert!( - !cli.commands.contains_key("gated-mod:list"), - "module-level Experimental flag should have pruned the whole group under the default Ga policy" - ); - assert!( - !has_subcommand(&cli.root, "gated-mod"), - "the pruned group must not be mounted in the clap tree either" - ); - } - - #[test] - fn module_feature_flag_keeps_group_when_policy_allows_it() { - let module = Module::new("Test Category", |_ctx| { - RuntimeGroupSpec::new(GroupSpec::new("gated-mod-2", "short")) - .with_command(trivial_command("list")) - }) - .with_feature_flag("module-flag-2", Stage::Experimental); - - let mut cli = Cli::new( - CliConfig::new("modtest2", "Module test", "modtest2") - .with_min_stage(Stage::Experimental), - ); - cli.add_module(module); - - assert!(cli.commands.contains_key("gated-mod-2:list")); - assert!(has_subcommand(&cli.root, "gated-mod-2")); - } - - #[test] - fn active_environment_min_stage_loosens_consumer_level_policy() { - // The CliConfig itself leaves min_stage at its Ga default, which would - // normally prune this Experimental-flagged group. The active ("prod") - // environment's compiled min_stage override should reach - // `middleware.flag_policy` before pruning runs and keep it instead. - let module = Module::new("Test Category", |_ctx| { - RuntimeGroupSpec::new(GroupSpec::new("gated-mod-3", "short")) - .with_command(trivial_command("list")) - }) - .with_feature_flag("module-flag-3", Stage::Experimental); - - let mut cli = Cli::new( - CliConfig::new("modtest3", "Module test", "modtest3") - .with_environments(Arc::new( - crate::environments::Environments::new("prod").with_environment( - "prod", - crate::environments::EnvTable::new().with("min_stage", "experimental"), - ), - )) - .with_startup_args(Vec::<&str>::new()), - ); - cli.add_module(module); - - assert!(cli.commands.contains_key("gated-mod-3:list")); - assert!(has_subcommand(&cli.root, "gated-mod-3")); - } - - /// The direct proof of the startup `--env` prescan (see `Cli::new`): - /// unlike [`active_environment_min_stage_loosens_consumer_level_policy`] - /// (which exercises the *default* active environment), here "prod" is - /// the default and carries no override, while "dev" loosens `min_stage`. - /// A `--env dev` supplied via `with_startup_args` — standing in for real - /// process argv — must be consulted before `add_module` prunes the tree, - /// in the *same* construction, not just update `middleware.env` for a - /// later run. - #[test] - fn startup_env_flag_reveals_beta_and_experimental_modules_for_the_named_env() { - fn gated_module() -> Module { - Module::new("Test Category", |_ctx| { - RuntimeGroupSpec::new(GroupSpec::new("gated-mod-4", "short")) - .with_command(trivial_command("list")) - }) - .with_feature_flag("module-flag-4", Stage::Experimental) - } - fn environments() -> Arc { - Arc::new( - crate::environments::Environments::new("prod") - .with_environment("prod", crate::environments::EnvTable::new()) - .with_environment( - "dev", - crate::environments::EnvTable::new().with("min_stage", "experimental"), - ), - ) - } - - let mut with_dev_flag = Cli::new( - CliConfig::new("modtest4a", "Module test", "modtest4a") - .with_environments(environments()) - .with_startup_args(["modtest4a", "--env", "dev"]), - ); - with_dev_flag.add_module(gated_module()); - assert!( - with_dev_flag.commands.contains_key("gated-mod-4:list"), - "--env dev in startup_args should reveal the Experimental module" - ); - assert!(has_subcommand(&with_dev_flag.root, "gated-mod-4")); - - // Negative counterpart: with no `--env` at all, the default ("prod", - // no override) still governs — nothing changed for the common case. - let mut without_flag = Cli::new( - CliConfig::new("modtest4b", "Module test", "modtest4b") - .with_environments(environments()) - .with_startup_args(Vec::<&str>::new()), - ); - without_flag.add_module(gated_module()); - assert!( - !without_flag.commands.contains_key("gated-mod-4:list"), - "without --env, the default env's Ga policy should still prune the module" - ); - assert!(!has_subcommand(&without_flag.root, "gated-mod-4")); - } - - static GLOBAL_MIN_STAGE_ENV_LOCK: Mutex<()> = Mutex::new(()); - - /// RAII guard that restores (or removes) an env var on drop, even if a - /// test panics. - struct GlobalMinStageEnvGuard { - key: &'static str, - prev: Option, - } - impl GlobalMinStageEnvGuard { - /// Sets `key` to `value`. Caller must hold [`GLOBAL_MIN_STAGE_ENV_LOCK`] - /// for the guard's entire lifetime. - #[allow(unsafe_code)] - fn set(key: &'static str, value: &str) -> Self { - let prev = std::env::var_os(key); - // SAFETY: serialized by GLOBAL_MIN_STAGE_ENV_LOCK; guard - // restores/removes on any exit incl. panic. - unsafe { std::env::set_var(key, value) }; - Self { key, prev } - } - - /// Removes `key` (if set). Caller must hold - /// [`GLOBAL_MIN_STAGE_ENV_LOCK`] for the guard's entire lifetime. - #[allow(unsafe_code)] - fn unset(key: &'static str) -> Self { - let prev = std::env::var_os(key); - // SAFETY: serialized by GLOBAL_MIN_STAGE_ENV_LOCK; guard restores - // on any exit incl. panic. - unsafe { std::env::remove_var(key) }; - Self { key, prev } - } - } - impl Drop for GlobalMinStageEnvGuard { - #[allow(unsafe_code)] - fn drop(&mut self) { - // SAFETY: test holds GLOBAL_MIN_STAGE_ENV_LOCK; restore/clean up - // on any exit including panic. - unsafe { - match &self.prev { - Some(v) => std::env::set_var(self.key, v), - None => std::env::remove_var(self.key), - } - } - } - } - - #[test] - #[allow(unsafe_code)] - fn global_min_stage_override_is_a_noop_when_unset() { - let _g = GLOBAL_MIN_STAGE_ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - const VAR: &str = "UNSET_MIN_STAGE_APP_MIN_STAGE"; - // Explicitly unset (and restored on drop) rather than assumed absent, - // so the test is hermetic even if a developer/CI happens to have this - // var set. - let _guard = GlobalMinStageEnvGuard::unset(VAR); - - assert_eq!(global_min_stage_override("unset-min-stage-app"), None); - } - - #[test] - #[allow(unsafe_code)] - fn global_min_stage_override_parses_a_valid_value() { - let _g = GLOBAL_MIN_STAGE_ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - const VAR: &str = "VALID_MIN_STAGE_APP_MIN_STAGE"; - let _guard = GlobalMinStageEnvGuard::set(VAR, "beta"); - - assert_eq!( - global_min_stage_override("valid-min-stage-app"), - Some(Stage::Beta) - ); - } - - #[test] - #[allow(unsafe_code)] - fn global_min_stage_override_ignores_a_malformed_value() { - let _g = GLOBAL_MIN_STAGE_ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - const VAR: &str = "BAD_MIN_STAGE_APP_MIN_STAGE"; - let _guard = GlobalMinStageEnvGuard::set(VAR, "nightly"); - - assert_eq!(global_min_stage_override("bad-min-stage-app"), None); - } -} - -#[cfg(test)] -mod flags_command_tests { - use super::*; - use crate::CommandResult; - - /// Builds a module with one flagged group containing one flagged (via - /// inheritance) `list` command, so `flag_registry` has something to - /// introspect once the module is mounted. - fn flagged_module(group_name: &'static str, key: &'static str, stage: Stage) -> Module { - Module::new("Test Category", move |_ctx| { - RuntimeGroupSpec::new(GroupSpec::new(group_name, "short")).with_command( - RuntimeCommandSpec::new( - CommandSpec::new("list", "short").no_auth(true), - async |_, _| Ok(CommandResult::new(serde_json::Value::Null)), - ), - ) - }) - .with_feature_flag(key, stage) - } - - #[tokio::test] - async fn flags_list_reports_flagged_entries() { - let mut cli = Cli::new( - CliConfig::new("flagtest", "Flag test", "flagtest").with_min_stage(Stage::Beta), - ); - cli.add_module(flagged_module("flagged-mod", "list-flag", Stage::Beta)); - - let out = cli - .run(["flagtest", "flags", "list", "--output", "json"]) - .await; - assert_eq!(out.exit_code, 0, "rendered: {}", out.rendered); - let rendered: serde_json::Value = - serde_json::from_str(&out.rendered).expect("stdout should contain json"); - let entries = rendered["data"].as_array().expect("data should be array"); - let command_entry = entries - .iter() - .find(|entry| entry["path"] == "flagged-mod:list") - .expect("flagged command entry should be present"); - assert_eq!(command_entry["key"], "list-flag"); - assert_eq!(command_entry["stage"], "beta"); - assert_eq!(command_entry["visible"], true); - } - - #[tokio::test] - async fn flags_info_returns_policy_and_entries_for_known_key() { - let mut cli = Cli::new( - CliConfig::new("flagtest2", "Flag test", "flagtest2").with_min_stage(Stage::Beta), - ); - cli.add_module(flagged_module("flagged-mod-2", "info-flag", Stage::Beta)); - - let out = cli - .run([ - "flagtest2", - "flags", - "info", - "info-flag", - "--output", - "json", - ]) - .await; - assert_eq!(out.exit_code, 0, "rendered: {}", out.rendered); - let rendered: serde_json::Value = - serde_json::from_str(&out.rendered).expect("stdout should contain json"); - let data = &rendered["data"]; - assert_eq!(data["key"], "info-flag"); - assert_eq!(data["policy"]["min_stage"], "beta"); - assert!(data["policy"]["override"].is_null()); - let entries = data["entries"].as_array().expect("entries should be array"); - assert!(!entries.is_empty()); - assert!(entries.iter().any(|entry| { - entry["path"] == "flagged-mod-2:list" && entry["decided_by"] == "min_stage" - })); - } - - #[tokio::test] - async fn flags_info_reports_override_decided_by() { - // The module declares Experimental, which the default Ga policy would - // normally hide; the override forces Ga instead, so the entries stay - // visible even though `entry.stage` still reports the node's own - // (Experimental) declaration, not the override. - let mut cli = Cli::new( - CliConfig::new("flagtest3", "Flag test", "flagtest3") - .with_feature_override("override-flag", Stage::Ga), - ); - cli.add_module(flagged_module( - "flagged-mod-3", - "override-flag", - Stage::Experimental, - )); - - let out = cli - .run([ - "flagtest3", - "flags", - "info", - "override-flag", - "--output", - "json", - ]) - .await; - assert_eq!(out.exit_code, 0, "rendered: {}", out.rendered); - let rendered: serde_json::Value = - serde_json::from_str(&out.rendered).expect("stdout should contain json"); - let data = &rendered["data"]; - assert_eq!(data["policy"]["min_stage"], "ga"); - assert_eq!(data["policy"]["override"], "ga"); - let entries = data["entries"].as_array().expect("entries should be array"); - assert!(!entries.is_empty()); - assert!( - entries - .iter() - .all(|entry| entry["decided_by"] == "override") - ); - assert!(entries.iter().all(|entry| entry["visible"] == true)); - assert!(entries.iter().all(|entry| entry["stage"] == "experimental")); - } - - #[tokio::test] - async fn flags_info_unknown_key_errors() { - let cli = Cli::new(CliConfig::new("flagtest4", "Flag test", "flagtest4")); - - let out = cli - .run(["flagtest4", "flags", "info", "no-such-flag"]) - .await; - assert_ne!(out.exit_code, 0); - assert!(out.rendered.contains("no such flag")); - } -} diff --git a/cli-engine/src/cli/argv0.rs b/cli-engine/src/cli/argv0.rs new file mode 100644 index 0000000..ab39327 --- /dev/null +++ b/cli-engine/src/cli/argv0.rs @@ -0,0 +1,378 @@ +//! Busybox/git-style `argv[0]` multi-call dispatch: route types, resolution, +//! and on-disk link/shim management. + +use std::{ + path::{Path, PathBuf}, + sync::Arc, +}; + +use super::{Cli, CliRunOutput, config::CliConfig}; +use crate::CliCoreError; + +/// Maximum number of chained `argv0` dispatch hand-offs before the engine +/// refuses to recurse further. Real multi-call nesting is zero or one level; +/// this bounds a pathologically long explicit `argv0 … argv0 …` chain so it +/// errors cleanly instead of overflowing the stack. +pub(super) const MAX_ARGV0_DEPTH: usize = 16; + +/// How the engine behaves when invoked under a registered alternative `argv[0]` +/// name (busybox/git-style multi-call dispatch). +/// +/// A route is selected when the binary's `argv[0]` basename — or the name given +/// to the hidden `argv0` command — matches a key registered via +/// [`CliConfig::with_argv0_alias`] or [`CliConfig::with_argv0_personality`]. An +/// `argv[0]` that matches no route falls through to the default CLI, so existing +/// applications that register no routes are unaffected. +/// +/// Non-exhaustive: more route kinds may be added in future releases. Register +/// routes through the [`CliConfig`] builders rather than matching on variants. +#[derive(Clone)] +#[non_exhaustive] +pub enum Argv0Route { + /// Rewrite the invocation into these canonical subcommand tokens and run it + /// through the normal command tree, with the real argument tail appended. + /// + /// For example, an `Alias(vec!["project".into(), "list".into()])` registered + /// under `pl` makes `pl --team x` behave exactly like `project list --team x`. + Alias(Vec), + /// Run an entirely separate CLI application built from the returned + /// [`CliConfig`] (its own root name, commands, flags, and auth). The + /// configuration is built lazily, only when the route is actually dispatched, + /// so registering a personality costs nothing for invocations that never hit it. + Personality(Arc CliConfig + Send + Sync>), +} + +impl std::fmt::Debug for Argv0Route { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Alias(tokens) => formatter.debug_tuple("Alias").field(tokens).finish(), + Self::Personality(_) => formatter.write_str("Personality(..)"), + } + } +} + +/// On-disk mechanism used by [`Cli::create_link`] to materialize an alternative +/// `argv[0]` name so the binary can be invoked under it. +/// +/// Installers pick the mechanism that suits the platform and environment; +/// self-healing code can re-run [`Cli::create_link`] to restore a deleted link. +/// +/// Non-exhaustive: more link mechanisms may be added in future releases. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum Argv0LinkMethod { + /// A symbolic link to the target executable (`` on Unix, `.exe` + /// on Windows). On Windows this may require Developer Mode or elevation. + SoftLink, + /// A hard link to the target executable (`` on Unix, `.exe` on + /// Windows). The link must live on the same volume as the target. + HardLink, + /// A small shim script that forwards to the target via the `argv0` command: + /// a `.cmd` batch file on Windows, or an executable `` shell + /// script on Unix. Useful when links are unavailable or inconvenient. + Script, +} + +/// Outcome of [`Cli::resolve_argv0`]: either rewritten arguments to feed the +/// normal pipeline, or a fully rendered result to return immediately. +pub(super) enum Argv0Outcome { + /// Continue the normal run pipeline with these arguments. + Proceed(Vec), + /// Return this already-rendered result without further processing. + Handled(CliRunOutput), +} + +/// Resolves busybox/git-style `argv[0]` dispatch before the normal pipeline. +/// +/// Returns [`Argv0Outcome::Proceed`] with the (possibly rewritten) argument +/// vector to feed the normal command pipeline, or [`Argv0Outcome::Handled`] +/// with a fully rendered result when a personality ran or an explicit `argv0` +/// invocation was rejected. When no routes are registered this is inert and +/// returns the arguments unchanged. `depth` counts chained hand-offs and +/// bounds recursion via [`MAX_ARGV0_DEPTH`]. +pub(super) async fn resolve_argv0(cli: &Cli, text_args: Vec, depth: usize) -> Argv0Outcome { + if cli.config.argv0_routes.is_empty() { + return Argv0Outcome::Proceed(text_args); + } + + if depth > MAX_ARGV0_DEPTH { + return Argv0Outcome::Handled(render_argv0_error( + cli, + &text_args, + "argv0 dispatch recursion limit exceeded", + )); + } + + // The hidden `argv0` meta-command (` argv0 [args...]`) forces + // a route without an actual symlink. It is recognized positionally as the + // first argument after the program name and is never registered with clap, + // so it stays absent from `--help`, `tree`, and the `search` command. + let explicit = text_args.get(1).map(String::as_str) == Some("argv0"); + let (name, rest) = if explicit { + match text_args.get(2) { + None => { + return Argv0Outcome::Handled(render_argv0_error( + cli, + &text_args, + "the argv0 command requires a name to dispatch as", + )); + } + // Normalize the explicit name the same way as a symlink basename + // so a route registered as `whatever` matches whether the caller + // passed `whatever`, `whatever.exe`, or a `.cmd` shim's `whatever.cmd`. + Some(name) => ( + program_basename(name), + text_args + .get(3..) + .map(<[String]>::to_vec) + .unwrap_or_default(), + ), + } + } else { + let name = text_args + .first() + .map(|arg| program_basename(arg)) + .unwrap_or_default(); + let rest = text_args + .get(1..) + .map(<[String]>::to_vec) + .unwrap_or_default(); + (name, rest) + }; + + match cli.config.argv0_routes.get(&name) { + Some(Argv0Route::Alias(tokens)) => { + // Rewrite as ` `. Element 0 is + // the canonical name so the downstream program-name skip applies. + let mut rewritten = Vec::with_capacity(1 + tokens.len() + rest.len()); + rewritten.push(cli.config.name.clone()); + rewritten.extend(tokens.iter().cloned()); + rewritten.extend(rest); + Argv0Outcome::Proceed(rewritten) + } + Some(Argv0Route::Personality(build)) => { + // Hand off to an independent CLI built lazily from the route. Its + // own config name leads so its help/usage and program-name skip + // render correctly. `Box::pin` breaks the recursive `async fn`; + // `depth + 1` bounds a pathological chain of hand-offs. + let config = build(); + let bin = config.name.clone(); + let alt = Cli::new(config); + let mut alt_args = Vec::with_capacity(1 + rest.len()); + alt_args.push(bin); + alt_args.extend(rest); + Argv0Outcome::Handled( + Box::pin(super::run::run_with_depth(&alt, alt_args, depth + 1)).await, + ) + } + None if explicit => Argv0Outcome::Handled(render_argv0_error( + cli, + &text_args, + format!( + "{name:?} is not a registered argv0 name; known names: {}", + known_argv0_names(cli) + ), + )), + None => { + // Unregistered name (e.g. the binary renamed to something we do not + // recognize): fall through to the default CLI. Normalizing element 0 + // to the canonical name lets a renamed binary parse as the default + // application instead of treating its name as a command token. + let mut rewritten = Vec::with_capacity(1 + rest.len()); + rewritten.push(cli.config.name.clone()); + rewritten.extend(rest); + Argv0Outcome::Proceed(rewritten) + } + } +} + +/// Comma-separated, sorted list of registered alternative `argv[0]` names, +/// used in the error shown for an unknown explicit `argv0` invocation. +pub(super) fn known_argv0_names(cli: &Cli) -> String { + cli.config + .argv0_routes + .keys() + .cloned() + .collect::>() + .join(", ") +} + +/// Renders an `argv0`-dispatch error through the engine's structured error +/// envelope so it honors `--output` (parsed from the raw args, since dispatch +/// runs before clap) and the shared exit-code mapping, matching every other +/// CLI error rather than emitting bare text. +pub(super) fn render_argv0_error( + cli: &Cli, + text_args: &[String], + message: impl Into, +) -> CliRunOutput { + let mut middleware = cli.middleware.clone(); + middleware.output_format = + crate::flags::extract_output_format(text_args, &super::run::resolve_run_output_format(cli)); + let err = CliCoreError::message(message); + super::run::finish_run( + cli, + super::render::render_cli_error(&middleware, &err, &cli.config.app_id), + ) +} + +/// Creates an on-disk link in `dir` that lets the binary be invoked under the +/// registered alternative `argv[0]` name `name`, using `method`. Backs +/// [`Cli::create_link`]; see that method's rustdoc for the full contract. +pub(super) fn create_link( + cli: &Cli, + name: &str, + dir: impl AsRef, + target: Option<&Path>, + method: Argv0LinkMethod, +) -> std::io::Result { + if !cli.config.argv0_routes.contains_key(name) { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("{name:?} is not a registered argv0 name"), + )); + } + + let dir = dir.as_ref(); + std::fs::create_dir_all(dir)?; + let link = dir.join(argv0_link_file_name(name, method)); + + // Resolve the target up front so an existing entry can be compared against it. + let resolved_target; + let target = match target { + Some(target) => target, + None => { + resolved_target = std::env::current_exe()?; + resolved_target.as_path() + } + }; + + // Ensure-desired-state. `symlink_metadata` does not follow links, so a + // present-but-dangling link still counts as existing. A matching entry is + // left untouched (idempotent); a differing one is removed and recreated. + if std::fs::symlink_metadata(&link).is_ok() { + if argv0_link_matches(&link, target, name, method)? { + return Ok(link); + } + std::fs::remove_file(&link)?; + } + + match method { + Argv0LinkMethod::SoftLink => create_symlink(target, &link)?, + Argv0LinkMethod::HardLink => std::fs::hard_link(target, &link)?, + Argv0LinkMethod::Script => { + std::fs::write(&link, argv0_script_contents(target, name))?; + make_executable(&link)?; + } + } + Ok(link) +} + +/// Extracts the bare program name from an `argv[0]` value, dropping any directory +/// path and file extension (e.g. `/usr/bin/pl` or `pl.exe` both yield `pl`). +/// Falls back to the raw value when no file stem can be derived. +fn program_basename(arg: &str) -> String { + Path::new(arg) + .file_stem() + .and_then(|stem| stem.to_str()) + .map_or_else(|| arg.to_owned(), ToOwned::to_owned) +} + +/// Returns `true` when `name` is a valid alternative `argv[0]` route name: a +/// non-empty token of ASCII letters, digits, `-`, or `_`. This keeps the name +/// safe as a link/shim filename and as an `argv[0]` basename (which is matched +/// with its extension stripped, so an embedded dot would break matching). +pub(super) fn is_valid_argv0_name(name: &str) -> bool { + !name.is_empty() + && name.chars().all(|character| { + character.is_ascii_alphanumeric() || character == '-' || character == '_' + }) +} + +/// Returns `true` when the entry at `link` already matches what [`Cli::create_link`] +/// would produce for `method`/`target`/`name`, so it can be left untouched. A +/// mismatch (wrong kind, stale symlink target, or differing contents) returns +/// `false` so the caller replaces it. +fn argv0_link_matches( + link: &Path, + target: &Path, + name: &str, + method: Argv0LinkMethod, +) -> std::io::Result { + let metadata = std::fs::symlink_metadata(link)?; + match method { + Argv0LinkMethod::SoftLink => { + Ok(metadata.file_type().is_symlink() && std::fs::read_link(link)? == target) + } + Argv0LinkMethod::HardLink => { + if metadata.file_type().is_symlink() { + return Ok(false); + } + // A correct hard link is indistinguishable from the target by content; + // comparing bytes also accepts an identical copy, which is harmless. + Ok(std::fs::read(link)? == std::fs::read(target)?) + } + Argv0LinkMethod::Script => { + if metadata.file_type().is_symlink() { + return Ok(false); + } + Ok(std::fs::read_to_string(link).ok() == Some(argv0_script_contents(target, name))) + } + } +} + +/// File name for an alternative `argv[0]` link, per method and host platform. +fn argv0_link_file_name(name: &str, method: Argv0LinkMethod) -> String { + let extension = match method { + Argv0LinkMethod::Script if cfg!(windows) => ".cmd", + // Unix scripts are extension-less executables; links carry `.exe` on Windows. + Argv0LinkMethod::Script => "", + _ if cfg!(windows) => ".exe", + _ => "", + }; + format!("{name}{extension}") +} + +/// Contents of an alternative `argv[0]` shim script that forwards to `target` +/// via the explicit `argv0` command. A `.cmd` batch file on Windows, an +/// executable POSIX shell script elsewhere. +fn argv0_script_contents(target: &Path, name: &str) -> String { + let target = target.display(); + if cfg!(windows) { + format!("@\"{target}\" argv0 {name} %*\r\n") + } else { + format!("#!/bin/sh\nexec \"{target}\" argv0 {name} \"$@\"\n") + } +} + +#[cfg(unix)] +fn create_symlink(target: &Path, link: &Path) -> std::io::Result<()> { + std::os::unix::fs::symlink(target, link) +} + +#[cfg(windows)] +fn create_symlink(target: &Path, link: &Path) -> std::io::Result<()> { + std::os::windows::fs::symlink_file(target, link) +} + +#[cfg(not(any(unix, windows)))] +fn create_symlink(_target: &Path, _link: &Path) -> std::io::Result<()> { + Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "symlink creation is not supported on this platform", + )) +} + +/// Marks a freshly written shim script executable on Unix; a no-op elsewhere. +#[cfg(unix)] +fn make_executable(path: &Path) -> std::io::Result<()> { + use std::os::unix::fs::PermissionsExt; + let mut permissions = std::fs::metadata(path)?.permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(path, permissions) +} + +#[cfg(not(unix))] +fn make_executable(_path: &Path) -> std::io::Result<()> { + Ok(()) +} diff --git a/cli-engine/src/cli/config.rs b/cli-engine/src/cli/config.rs new file mode 100644 index 0000000..89e96f4 --- /dev/null +++ b/cli-engine/src/cli/config.rs @@ -0,0 +1,725 @@ +//! Declarative CLI configuration: [`CliConfig`], [`BuildInfo`], and the +//! lifecycle-hook type aliases used to customize a [`super::Cli`]. + +use std::{collections::BTreeMap, sync::Arc}; + +use clap::{ArgMatches, Command}; + +use super::argv0::{Argv0Route, is_valid_argv0_name}; +use crate::{ + ActivityEmitter, Auditor, AuthProvider, Authorizer, CommandMeta, GuideEntry, Middleware, + Module, Result, RuntimeCommandSpec, + feature_flags::{FlagPolicy, Stage}, + output::{HumanViewDef, NextAction}, + search::SearchDocument, +}; + +/// Late dependency initializer run once before real command execution. +pub type InitDeps = Arc Result<()> + Send + Sync>; +/// Hook used to add application-specific global flags to the root `clap` command. +pub type RegisterFlags = Arc Command + Send + Sync>; +/// Hook used to copy parsed application-specific flags into middleware. +pub type ApplyFlags = Arc Result<()> + Send + Sync>; +/// Hook run immediately before executable commands and built-ins. +pub type PreRun = + Arc Result<()> + Send + Sync>; +/// Hook used to adjust command metadata globally before middleware executes. +pub type ResolveMeta = Arc CommandMeta + Send + Sync>; +/// Hook called after a CLI run completes. +pub type OnShutdown = Arc; +/// Hook that contributes extra root-scope `search` documents. +pub type ExtraSearchDocs = Arc Vec + Send + Sync>; +/// Hook that supplies the suggested next actions shown when the CLI is invoked +/// with no subcommand (bare root). The same actions drive a human "Next actions" +/// section and the JSON discovery envelope. +pub type RootNextActions = Arc Vec + Send + Sync>; + +/// Default name for the admin help category, under which the engine files the +/// built-in `auth` command when a consumer does not override it via +/// [`CliConfig::with_admin_category`]. +pub(super) const DEFAULT_ADMIN_CATEGORY: &str = "Admin"; + +/// Top-level subcommand names that are reserved by the engine and must not be +/// used as module group names. [`super::Cli::add_module_group`] rejects a group whose +/// name matches a reserved name so the engine's built-in command always wins. +pub(crate) const BUILTIN_COMMAND_NAMES: [&str; 5] = + ["help", "guide", "tree", "completion", "search"]; + +/// Build metadata shown by the root `--version` flag. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct BuildInfo { + /// Semantic version or other release label. + pub version: String, + /// Optional source control commit identifier. + pub commit: Option, + /// Optional build date string. + pub date: Option, +} + +impl BuildInfo { + /// Creates build metadata with only a version string. + #[must_use] + pub fn new(version: impl Into) -> Self { + Self { + version: version.into(), + commit: None, + date: None, + } + } + + /// Adds a commit identifier to the version string shown by `--version`. + #[must_use] + pub fn with_commit(mut self, commit: impl Into) -> Self { + self.commit = Some(commit.into()); + self + } + + /// Adds a build date to the version string shown by `--version`. + #[must_use] + pub fn with_date(mut self, date: impl Into) -> Self { + self.date = Some(date.into()); + self + } + + /// Returns the rendered version string used by the root `--version` flag. + #[must_use] + pub fn version_string(&self) -> String { + let commit = self.commit.as_deref().unwrap_or_default(); + let date = self.date.as_deref().unwrap_or_default(); + + if commit.is_empty() && date.is_empty() { + self.version.clone() + } else { + format!("{} (commit {commit}, built {date})", self.version) + } + } +} + +/// Declarative configuration for a CLI application. +/// +/// Use [`CliConfig::new`] for the common path and chain `with_*` methods for +/// modules, auth providers, guides, views, and lifecycle hooks. Direct struct +/// literals remain available for advanced setup and tests. +#[derive(Clone, Default)] +pub struct CliConfig { + /// Root command name shown in usage output. + pub name: String, + /// One-line root command description. + pub short: String, + /// Optional longer root command description. Defaults to `short`. + pub long: Option, + /// Version/build metadata for `--version`. + pub build: BuildInfo, + /// Application id stored in middleware and output metadata. + pub app_id: String, + /// Fallback auth provider when a command does not select one explicitly. + pub default_auth_provider: Option, + /// Domain modules mounted under the root command. + pub modules: Vec, + /// Additional top-level runtime commands. + pub commands: Vec, + /// Additional commands mounted as siblings of the built-in `auth` + /// group's `login`/`status`/`logout` (e.g. `auth scopes`). Populate via + /// [`CliConfig::with_auth_extra_commands`]; folded in internally after + /// the built-in group is built, so the built-ins are never lost or + /// overwritten. + pub auth_extra_commands: Vec, + /// Global guide entries mounted under `guide`. + pub guides: Vec, + /// Global human output views. + pub views: Vec, + /// Providers registered before command execution starts. + pub auth_providers: Vec>, + /// Optional override for the process-wide outbound User-Agent. When unset, + /// the engine derives `name/version` from this config. See + /// [`CliConfig::user_agent_string`]. + pub user_agent: Option, + /// Extra HTTP header names to redact in `--debug transport` output, on top + /// of the built-in sensitive set (`authorization`, `proxy-authorization`, + /// `cookie`, `set-cookie`, `x-api-key`). Set CLI-specific secret-bearing + /// headers here — e.g. a custom API-key header an auth injector adds. + /// Populate via [`CliConfig::with_redacted_debug_headers`]. + pub redacted_debug_headers: Vec, + /// Optional authorization gatekeeper injected into middleware. + pub authz: Option>, + /// Optional audit recorder injected into middleware. + pub auditor: Option>, + /// Optional activity event sink injected into middleware. + pub activity: Option>, + /// Optional late initializer for runtime dependencies. + pub init_deps: Option, + /// Optional hook for adding application-specific global flags. + pub register_flags: Option, + /// Optional hook for applying parsed application-specific flags. + pub apply_flags: Option, + /// Optional hook run before executable commands and built-ins. + pub pre_run: Option, + /// Optional hook for global command metadata adjustments. + pub meta_resolver: Option, + /// Optional hook called after each run. + pub on_shutdown: Option, + /// Optional root-scope search document provider. + pub extra_search_docs: Option, + /// Optional provider for the bare-root suggested next actions. + pub root_next_actions: Option, + /// Name of the admin help category. The engine files its built-in `auth` + /// command under this heading; apps should use the same name for their own + /// admin modules (e.g. godaddy's `env`). When unset, defaults to `"Admin"`; + /// set it to match a consumer's own taxonomy (e.g. gdx's "Administration"). + pub admin_category: Option, + /// Whether to mount the built-in `config` command group (`config + /// get`/`set`/`path`/`list`). Off by default to avoid colliding with a + /// consumer's own `config` noun. Enable via + /// [`CliConfig::with_config_commands`]. + pub config_commands: bool, + /// Alternative `argv[0]` names this binary may be invoked as, mapped to the + /// behavior the engine should take (busybox/git-style multi-call dispatch). + /// + /// Keyed by the bare alternative name (no path, no extension). Empty by + /// default, in which case argv0 dispatch is inert and behavior is identical + /// to a binary that never opted in. Populate via [`CliConfig::with_argv0_alias`] + /// and [`CliConfig::with_argv0_personality`]. + pub argv0_routes: BTreeMap, + /// Optional first-class environment system. + /// + /// Registered via [`CliConfig::with_environments`]. When set, the engine + /// registers a global `--env` flag, seeds the active environment into + /// middleware, and exposes it to handlers through + /// [`CommandContext::environment`](crate::command::CommandContext::environment). + pub environments: Option>, + /// Explicit argv override for [`super::Cli::new`]'s startup `--env` prescan, + /// mainly used to make tests hermetic. + pub startup_args: Option>, + /// Minimum feature stage required for a flagged command, group, or module + /// to remain mounted. + /// + /// Defaults to [`Stage::Ga`] via [`Stage`]'s own `Default`, which combined + /// with an empty [`feature_overrides`](Self::feature_overrides) is the + /// zero-config behavior: nothing is gated unless a command/group/module + /// opts in with `.with_feature_flag(...)`, and even then it stays visible + /// until this is lowered. Lower it (e.g. to [`Stage::Beta`] or + /// [`Stage::Experimental`]) to opt a build or environment into + /// pre-release commands. Set via [`CliConfig::with_min_stage`]. + pub min_stage: Stage, + /// Per-key stage overrides that substitute a forced stage for a flag + /// key's own declared stage before comparing against + /// [`min_stage`](Self::min_stage). + /// + /// Empty by default. Populate via [`CliConfig::with_feature_override`] to + /// force one named flag to a specific effective stage — e.g. forcing a + /// single flag to [`Stage::Ga`] to turn it on for internal testing without + /// lowering [`min_stage`](Self::min_stage) for every other flagged + /// command, or forcing it to [`Stage::Experimental`] to disable it even + /// under a permissive `min_stage`. See [`FlagPolicy::visible`] for the + /// exact comparison. + pub feature_overrides: BTreeMap, + /// Whether to auto-enable interactive mode when a TTY is detected. + /// + /// When `false` (the default), commands only run interactively if the user + /// passes `--interactive` explicitly. When `true`, the engine auto-detects + /// a TTY (stdin + stderr) and defaults to interactive mode — meaning + /// missing required arguments will be prompted for instead of erroring. + /// + /// Set via [`CliConfig::with_auto_interactive`]. Start with `false` for + /// backwards compatibility; flip to `true` once the CLI's commands have + /// been tested under interactive prompting. + pub auto_interactive: bool, +} + +impl CliConfig { + /// Creates the minimum useful CLI configuration. + #[must_use] + pub fn new( + name: impl Into, + short: impl Into, + app_id: impl Into, + ) -> Self { + Self { + name: name.into(), + short: short.into(), + app_id: app_id.into(), + ..Self::default() + } + } + + /// Sets root long help text. + #[must_use] + pub fn with_long(mut self, long: impl Into) -> Self { + self.long = Some(long.into()); + self + } + + /// Sets build metadata used by `--version`. + #[must_use] + pub fn with_build(mut self, build: BuildInfo) -> Self { + self.build = build; + self + } + + /// Sets the fallback auth provider for commands that do not name one. + #[must_use] + pub fn with_default_auth_provider(mut self, provider: impl Into) -> Self { + self.default_auth_provider = Some(provider.into()); + self + } + + /// Registers a first-class environment system. + /// + /// When set, [`super::Cli::new`] registers a global `--env` flag, seeds the active + /// environment into middleware (explicit `--env` > persisted active > + /// configured default), and exposes the resolved environment to handlers via + /// [`CommandContext::environment`](crate::command::CommandContext::environment). + /// + /// The [`Environments`](crate::environments::Environments) is stored as-is, so + /// the consumer is responsible for configuring it before wrapping it in an + /// `Arc`: + /// + /// - Call + /// [`Environments::with_app_id`](crate::environments::Environments::with_app_id) + /// with the **same** `app_id` passed to [`CliConfig::new`], so the config + /// file and active-environment persistence resolve to the application's + /// config directory. (An empty `app_id` makes + /// [`Environments::config_file_path`](crate::environments::Environments::config_file_path) + /// return `None`, silently disabling the `environments.toml` file layer.) + /// - Call + /// [`Environments::with_config_file(true)`](crate::environments::Environments::with_config_file) + /// if the application loads a user-editable `environments.toml`. + /// - **Share the same `Arc`** with any `PkceAuthProvider::with_environments` + /// (available with the `pkce-auth` feature): + /// the provider's OAuth file layer and active-environment persistence must + /// resolve against the identical, `app_id`-stamped instance the engine sees, + /// or a file-defined environment (or a file override of a compiled + /// environment's `client_id`) will be visible to `env info` yet invisible to + /// the actual OAuth login. + #[must_use] + pub fn with_environments( + mut self, + environments: Arc, + ) -> Self { + self.environments = Some(environments); + self + } + + /// Overrides the argv [`super::Cli::new`] prescans for `--env` before pruning the + /// command tree, instead of the real process argv. + /// + /// Only meaningful alongside [`with_environments`](Self::with_environments) + /// — otherwise `Cli::new` never registers `--env` or does the prescan at + /// all, so this is silently unused. Element `0` is treated as the program + /// name and skipped, the same convention [`Cli::run`](super::Cli::run)/[`Cli::execute_from`](super::Cli::execute_from) + /// use for their own `args` parameter. + /// + /// This matters beyond tests: tree pruning is decided once, at `Cli::new` + /// time, from either this override or real process argv — never from the + /// `args` a later [`Cli::run`](super::Cli::run)/[`Cli::execute_from`](super::Cli::execute_from) call receives. Any + /// caller that builds the `Cli` once and later runs it with a synthetic + /// argv (e.g. a wrapper binary invoking it programmatically, or a fixed + /// argument list unrelated to `std::env::args_os()`) should pass the same + /// `--env` here too, or an environment named only in the later call's + /// argv won't have been consulted for pruning, and a flagged command that + /// environment would reveal (or hide) can disagree with what actually + /// dispatches. A test that configures `with_environments` should call + /// this (even with an empty iterator) to keep construction hermetic; + /// without it, `Cli::new` reads whatever real argv the test binary itself + /// was invoked with. + #[must_use] + pub fn with_startup_args(mut self, args: I) -> Self + where + I: IntoIterator, + S: Into, + { + self.startup_args = Some(args.into_iter().map(Into::into).collect()); + self + } + + /// Sets the minimum feature stage required for a flagged command, group, + /// or module to remain mounted. + /// + /// See [`min_stage`](Self::min_stage) for the default and [`FlagPolicy`] + /// for how it combines with [`feature_overrides`](Self::feature_overrides) + /// during command-tree pruning. + #[must_use] + pub fn with_min_stage(mut self, stage: Stage) -> Self { + self.min_stage = stage; + self + } + + /// Enables auto-interactive mode: when a TTY is detected, the CLI + /// defaults to interactive prompting for missing required arguments. + /// + /// Off by default for backwards compatibility. Enable once commands have + /// been tested under interactive prompting. `--interactive` still works as + /// an explicit override regardless of this setting. + #[must_use] + pub fn with_auto_interactive(mut self, enabled: bool) -> Self { + self.auto_interactive = enabled; + self + } + + /// Adds (or replaces) a per-key feature-flag stage override. + /// + /// See [`feature_overrides`](Self::feature_overrides) for how the + /// override participates in the [`FlagPolicy::visible`] comparison. + #[must_use] + pub fn with_feature_override(mut self, key: impl Into, stage: Stage) -> Self { + self.feature_overrides.insert(key.into(), stage); + self + } + + /// Builds the merged [`FlagPolicy`] used for command-tree pruning from + /// this config's `min_stage` and `feature_overrides`. + pub(super) fn flag_policy(&self) -> FlagPolicy { + FlagPolicy { + min_stage: self.min_stage, + overrides: self.feature_overrides.clone(), + } + } + + /// Overrides the outbound User-Agent string for all HTTP traffic. + /// + /// When unset, the engine derives `name/version` from this config (see + /// [`CliConfig::user_agent_string`]). Set this when the upstream APIs expect + /// a specific product token. The resolved value is applied process-wide on + /// execution via [`crate::transport::set_default_user_agent`], so it reaches + /// both command [`HttpClient`](crate::transport::HttpClient)s and the + /// engine's own OAuth token requests. + #[must_use] + pub fn with_user_agent(mut self, user_agent: impl Into) -> Self { + self.user_agent = Some(user_agent.into()); + self + } + + /// Adds HTTP header names to redact in `--debug transport` output, on top of + /// the built-in sensitive set. + /// + /// Use this for CLI-specific secret-bearing headers that are not standard + /// auth headers — for example a custom API-key header that an + /// [`AuthInjector`](crate::transport::AuthInjector) sets. Matching is + /// case-insensitive and additive: the built-in set is always redacted. + /// Calls accumulate. Names are trimmed and empty entries are dropped, so a + /// mistyped value with stray whitespace cannot silently disable redaction. + #[must_use] + pub fn with_redacted_debug_headers( + mut self, + names: impl IntoIterator>, + ) -> Self { + self.redacted_debug_headers + .extend(names.into_iter().filter_map(|name| { + let name = name.into().trim().to_owned(); + (!name.is_empty()).then_some(name) + })); + self + } + + /// Returns the outbound User-Agent string the CLI presents on HTTP requests. + /// + /// Resolution order: + /// 1. an explicit [`with_user_agent`](Self::with_user_agent) override; + /// 2. otherwise `name/version` (for example `gdx/1.2.3`); + /// 3. otherwise just `name` when no build version is set. + #[must_use] + pub fn user_agent_string(&self) -> String { + if let Some(user_agent) = &self.user_agent { + return user_agent.clone(); + } + if self.build.version.is_empty() { + self.name.clone() + } else { + format!("{}/{}", self.name, self.build.version) + } + } + + /// Adds one domain module. + /// + /// # Reserved group names + /// + /// The top-level group names `help`, `guide`, `tree`, and `completion` are + /// reserved by the engine. A module whose root group uses one of these + /// names will be rejected at registration time (logged as a warning) so + /// the engine's own built-in always takes precedence in the command tree. + #[must_use] + pub fn with_module(mut self, module: Module) -> Self { + self.modules.push(module); + self + } + + /// Adds several domain modules. + /// + /// See [`with_module`](Self::with_module) for the list of reserved group names. + #[must_use] + pub fn with_modules(mut self, modules: impl IntoIterator) -> Self { + self.modules.extend(modules); + self + } + + /// Adds a top-level runtime command outside a module. + #[must_use] + pub fn with_command(mut self, command: RuntimeCommandSpec) -> Self { + self.commands.push(command); + self + } + + /// Adds commands mounted as siblings of the built-in `auth` group's + /// `login`/`status`/`logout`. + /// + /// Use this to extend `auth` with consumer-specific subcommands (e.g. + /// `auth scopes`) without losing or duplicating the built-ins — unlike + /// pre-registering an `auth` [`Module`], which either drops the built-ins + /// entirely or has them silently overwrite any extra command added this + /// way, these are folded in additively after building the built-in group. + #[must_use] + pub fn with_auth_extra_commands( + mut self, + commands: impl IntoIterator, + ) -> Self { + self.auth_extra_commands.extend(commands); + self + } + + /// Adds one global guide. + #[must_use] + pub fn with_guide(mut self, guide: GuideEntry) -> Self { + self.guides.push(guide); + self + } + + /// Adds several global guides. + #[must_use] + pub fn with_guides(mut self, guides: impl IntoIterator) -> Self { + self.guides.extend(guides); + self + } + + /// Adds one global human view. + #[must_use] + pub fn with_view(mut self, view: HumanViewDef) -> Self { + self.views.push(view); + self + } + + /// Registers one auth provider. + #[must_use] + pub fn with_auth_provider(mut self, provider: Arc) -> Self { + self.auth_providers.push(provider); + self + } + + /// Sets the authorization gatekeeper. + #[must_use] + pub fn with_authz(mut self, authz: Arc) -> Self { + self.authz = Some(authz); + self + } + + /// Sets the audit recorder. + #[must_use] + pub fn with_auditor(mut self, auditor: Arc) -> Self { + self.auditor = Some(auditor); + self + } + + /// Sets the activity event sink. + #[must_use] + pub fn with_activity(mut self, activity: Arc) -> Self { + self.activity = Some(activity); + self + } + + /// Sets the late dependency initializer. + #[must_use] + pub fn with_init_deps(mut self, init_deps: InitDeps) -> Self { + self.init_deps = Some(init_deps); + self + } + + /// Sets the application-specific global flag registration hook. + #[must_use] + pub fn with_register_flags(mut self, register_flags: RegisterFlags) -> Self { + self.register_flags = Some(register_flags); + self + } + + /// Sets the application-specific parsed flag application hook. + #[must_use] + pub fn with_apply_flags(mut self, apply_flags: ApplyFlags) -> Self { + self.apply_flags = Some(apply_flags); + self + } + + /// Sets the pre-run hook. + #[must_use] + pub fn with_pre_run(mut self, pre_run: PreRun) -> Self { + self.pre_run = Some(pre_run); + self + } + + /// Sets the command metadata resolver hook. + #[must_use] + pub fn with_meta_resolver(mut self, meta_resolver: ResolveMeta) -> Self { + self.meta_resolver = Some(meta_resolver); + self + } + + /// Sets the shutdown hook. + #[must_use] + pub fn with_on_shutdown(mut self, on_shutdown: OnShutdown) -> Self { + self.on_shutdown = Some(on_shutdown); + self + } + + /// Sets the provider for additional root-scope search documents. + #[must_use] + pub fn with_extra_search_docs(mut self, extra_search_docs: ExtraSearchDocs) -> Self { + self.extra_search_docs = Some(extra_search_docs); + self + } + + /// Sets the provider for the bare-root suggested next actions. + #[must_use] + pub fn with_root_next_actions(mut self, root_next_actions: RootNextActions) -> Self { + self.root_next_actions = Some(root_next_actions); + self + } + + /// Sets the name of the admin help category. The engine files the built-in + /// `auth` command there; apps should use the same name for their own admin + /// modules (e.g. godaddy's `env`). Optional: defaults to `"Admin"`. + #[must_use] + pub fn with_admin_category(mut self, category: impl Into) -> Self { + self.admin_category = Some(category.into()); + self + } + + /// Mounts the built-in `config` command group (`config get`/`set`/`path`/ + /// `list`) for reading and writing the per-application config file. + /// + /// Off by default so it never collides with a consumer's own `config` noun; + /// the group is filed under the admin help category when enabled. + #[must_use] + pub fn with_config_commands(mut self) -> Self { + self.config_commands = true; + self + } + + /// Registers an alternative `argv[0]` name that acts as a shortcut to a + /// command path on this same CLI. + /// + /// When the binary is invoked under `name` (via symlink, hardlink, copy, or + /// the hidden `argv0` command), the engine behaves as if the user had typed + /// `command_path` followed by the real argument tail, routed through the + /// normal command tree. For example: + /// + /// ``` + /// use cli_engine::CliConfig; + /// + /// // Invoking the binary as `pl --team platform` runs `project list --team platform`. + /// let config = CliConfig::new("my-cli", "Team CLI", "my-cli") + /// .with_argv0_alias("pl", ["project", "list"]); + /// ``` + /// + /// `name` must be a simple token: non-empty and composed only of ASCII + /// letters, digits, `-`, or `_` (no dots, spaces, path separators, or shell + /// metacharacters), and it must differ from the CLI's own name. These are + /// debug-asserted. The restriction keeps the name usable as a link/shim + /// filename and an `argv[0]` basename (which is matched with its extension + /// stripped, so a dot would break matching). + #[must_use] + pub fn with_argv0_alias( + mut self, + name: impl Into, + command_path: impl IntoIterator>, + ) -> Self { + let name = name.into(); + debug_assert!( + is_valid_argv0_name(&name), + "argv0 route name {name:?} must be non-empty and contain only ASCII letters, digits, '-', or '_'" + ); + debug_assert!( + name != self.name, + "argv0 route name {name:?} must differ from the CLI's own name {:?}", + self.name + ); + let tokens = command_path.into_iter().map(Into::into).collect(); + self.argv0_routes.insert(name, Argv0Route::Alias(tokens)); + self + } + + /// Registers an alternative `argv[0]` name that runs an entirely separate CLI + /// application. + /// + /// When the binary is invoked under `name`, the engine builds a fresh + /// [`CliConfig`] from `build` and runs that application instead — its own root + /// name, commands, flags, and auth. The closure runs lazily, only when the + /// route is dispatched, so unused personalities cost nothing. The personality + /// presents the name from its own [`CliConfig`] in help and usage output. + /// + /// ``` + /// use cli_engine::CliConfig; + /// + /// let config = CliConfig::new("my-cli", "Team CLI", "my-cli") + /// .with_argv0_personality("legacy-tool", || { + /// CliConfig::new("legacy-tool", "Legacy compatibility shim", "legacy-tool") + /// }); + /// ``` + /// + /// `name` follows the same contract as [`CliConfig::with_argv0_alias`]: a + /// simple `[A-Za-z0-9_-]` token, differing from the CLI's own name + /// (debug-asserted). + #[must_use] + pub fn with_argv0_personality( + mut self, + name: impl Into, + build: impl Fn() -> CliConfig + Send + Sync + 'static, + ) -> Self { + let name = name.into(); + debug_assert!( + is_valid_argv0_name(&name), + "argv0 route name {name:?} must be non-empty and contain only ASCII letters, digits, '-', or '_'" + ); + debug_assert!( + name != self.name, + "argv0 route name {name:?} must differ from the CLI's own name {:?}", + self.name + ); + self.argv0_routes + .insert(name, Argv0Route::Personality(Arc::new(build))); + self + } +} + +impl std::fmt::Debug for CliConfig { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("CliConfig") + .field("name", &self.name) + .field("short", &self.short) + .field("long", &self.long) + .field("build", &self.build) + .field("app_id", &self.app_id) + .field("default_auth_provider", &self.default_auth_provider) + .field("modules", &self.modules) + .field("commands", &self.commands) + .field("guides", &self.guides) + .field("views", &self.views) + .field("auth_providers_len", &self.auth_providers.len()) + .field("has_authz", &self.authz.is_some()) + .field("has_auditor", &self.auditor.is_some()) + .field("has_activity", &self.activity.is_some()) + .field("has_init_deps", &self.init_deps.is_some()) + .field("has_register_flags", &self.register_flags.is_some()) + .field("has_apply_flags", &self.apply_flags.is_some()) + .field("has_pre_run", &self.pre_run.is_some()) + .field("has_meta_resolver", &self.meta_resolver.is_some()) + .field("has_on_shutdown", &self.on_shutdown.is_some()) + .field("has_extra_search_docs", &self.extra_search_docs.is_some()) + .field("has_root_next_actions", &self.root_next_actions.is_some()) + .field("admin_category", &self.admin_category) + .field( + "argv0_routes", + &self.argv0_routes.keys().collect::>(), + ) + .field("min_stage", &self.min_stage) + .field("feature_overrides", &self.feature_overrides) + .finish() + } +} diff --git a/cli-engine/src/cli/correction.rs b/cli-engine/src/cli/correction.rs new file mode 100644 index 0000000..01fc92f --- /dev/null +++ b/cli-engine/src/cli/correction.rs @@ -0,0 +1,673 @@ +//! Unknown-command "did you mean" correction engine and ` help` +//! rewriting. Both operate purely on the positional command tokens of a raw +//! argument vector, independent of any [`super::Cli`] instance. + +use std::collections::BTreeSet; + +use clap::Command; + +use super::lookup::{arg_matches_root_name, unknown_flag_consumes_value}; + +/// Appends a `— did you mean "…"?` suffix to an unknown-command error clause. +pub(super) fn format_did_you_mean(base: &str, suggestion: &str) -> String { + format!("{base} — did you mean {suggestion:?}?") +} + +/// First unknown group token (`unknown command "X" for "Y"`, no hint suffix). +pub(super) struct UnknownGroupCommand { + pub(super) base: String, +} + +/// Reports the first unknown token under a group. `positionals` must be pre-`--` +/// command keywords (slice to `command_keyword_count` like the group-help path). +pub(super) fn detect_unknown_group_command( + root: &Command, + positionals: &[String], +) -> Option { + if positionals.is_empty() { + return None; + } + + let mut current = root; + let mut path = vec![root.get_name().to_owned()]; + for token in positionals { + if let Some(next) = current.find_subcommand(token) { + current = next; + path.push(next.get_name().to_owned()); + continue; + } + if current.get_subcommands().next().is_some() { + let base = format!("unknown command {token:?} for {:?}", path.join(" ")); + return Some(UnknownGroupCommand { base }); + } + return None; + } + None +} + +/// Counts positional command tokens that precede any `--` separator. +pub(super) fn command_keyword_count( + args: &[String], + root_name: &str, + bool_flags: &BTreeSet, + value_flags: &BTreeSet, +) -> usize { + let positionals = positional_command_tokens(args, root_name, bool_flags, value_flags); + match args.iter().position(|arg| arg == "--") { + Some(end) => { + positional_command_tokens(&args[..end], root_name, bool_flags, value_flags).len() + } + None => positionals.len(), + } +} + +/// Rewrites ` help [sub...]` into `help [sub...]` when the form +/// is present; otherwise returns `clap_args` unchanged. +pub(super) fn rewrite_group_help_if_needed( + root: &Command, + clap_args: &[String], + root_name: &str, + bool_flags: &BTreeSet, + value_flags: &BTreeSet, +) -> Vec { + let positionals = positional_command_tokens(clap_args, root_name, bool_flags, value_flags); + let keyword_count = command_keyword_count(clap_args, root_name, bool_flags, value_flags); + let Some(parts) = group_help_target_parts(root, &positionals, keyword_count) else { + return clap_args.to_vec(); + }; + rewrite_group_help_args(clap_args, root_name, bool_flags, value_flags, &parts) +} + +/// Rewrites the `target`-th positional command token to `replacement`, preserving +/// flags. Token classification mirrors [`positional_command_tokens`]. +pub(super) fn replace_positional_command_token( + args: &[String], + root_name: &str, + bool_flags: &BTreeSet, + value_flags: &BTreeSet, + target: usize, + replacement: &str, +) -> Vec { + let mut out = args.to_vec(); + let mut index = 0; + if out + .first() + .is_some_and(|arg| arg_matches_root_name(arg, root_name)) + { + index = 1; + } + + let mut positional = 0; + while index < out.len() { + let arg = &out[index]; + if arg == "--" { + break; + } + if arg.contains('=') { + index += 1; + continue; + } + if bool_flags.contains(arg) { + index += 1; + continue; + } + if value_flags.contains(arg) + || unknown_flag_consumes_value(arg, out.get(index + 1).as_ref()) + { + index += 2; + continue; + } + if arg.starts_with('-') { + index += 1; + continue; + } + if positional == target { + out[index] = replacement.to_owned(); + break; + } + positional += 1; + index += 1; + } + out +} + +/// Finds the closest visible subcommand name or alias within edit-distance +/// `max(1, token_len / 3)`. Returns the canonical name; ties break alphabetically. +fn nearest_subcommand(command: &Command, token: &str) -> Option { + let token = token.to_ascii_lowercase(); + let max_distance = 1.max(token.chars().count() / 3); + + command + .get_subcommands() + .filter(|child| !child.is_hide_set()) + .filter_map(|child| { + let best = std::iter::once(child.get_name()) + .chain(child.get_all_aliases()) + .map(|candidate| strsim::osa_distance(&token, &candidate.to_ascii_lowercase())) + .min()?; + (best <= max_distance).then(|| (best, child.get_name().to_owned())) + }) + .min_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1))) + .map(|(_, name)| name) +} + +/// Corrects every unknown group token to its nearest subcommand. Returns `None` +/// when any token has no near match, or when there is nothing to correct. +/// Stops at a leaf operand, curated ` help`, or an unfixable token. +pub(super) fn full_command_correction( + root: &Command, + positionals: &[String], +) -> Option> { + let mut current = root; + let mut corrections = Vec::new(); + for (index, token) in positionals.iter().enumerate() { + if let Some(next) = current.find_subcommand(token) { + current = next; + continue; + } + if current.get_subcommands().next().is_none() { + break; + } + if token == "help" && current.find_subcommand("help").is_none() { + break; + } + let suggestion = nearest_subcommand(current, token)?; + let next = current.find_subcommand(&suggestion)?; + corrections.push((index, suggestion)); + current = next; + } + (!corrections.is_empty()).then_some(corrections) +} + +/// Prompt/display text for a correction. Last-token-only fixes show the bare +/// token; anything else shows the full corrected command path. +pub(super) fn correction_display( + root_name: &str, + positionals: &[String], + corrections: &[(usize, String)], +) -> String { + if let [(index, only)] = corrections + && *index + 1 == positionals.len() + { + return only.clone(); + } + let mut tokens = vec![root_name.to_owned()]; + for (index, token) in positionals.iter().enumerate() { + let corrected = corrections + .iter() + .find(|(i, _)| *i == index) + .map(|(_, replacement)| replacement.clone()) + .unwrap_or_else(|| token.clone()); + tokens.push(corrected); + } + tokens.join(" ") +} + +/// Detects the ` help [sub...]` form and returns the command path whose +/// help should be rendered. +/// +/// The engine ships a curated root `help` command, so it disables clap's +/// auto-generated help subcommand on the root. That setting propagates to every +/// subcommand and cannot be re-enabled per child, so ` help` would +/// otherwise hit clap's "unrecognized subcommand" error even though the group's +/// help listing advertises a `help` entry. We recognize the form here so the +/// caller can route it through the curated help renderer, matching clap's +/// documented equivalence between `cmd group help sub` and `cmd help group sub`. +/// +/// Only groups (commands that have subcommands) are matched: a group is pure +/// subcommand dispatch, so a `help` token in that position is unambiguously a +/// help request. Leaf commands may accept a literal `help` positional argument, +/// so they are left for clap to parse (` --help` still works). A group +/// that registers its own real `help` subcommand is likewise deferred to clap, +/// which dispatches the user-defined command (only auto-generated help is +/// suppressed). +/// +/// `command_keyword_count` is the number of leading positionals that are +/// genuine command keywords (those before any `--`). A `help` at or beyond that +/// index is a literal operand after `--`, not a help request, so it is ignored. +pub(super) fn group_help_target_parts( + root: &Command, + positionals: &[String], + command_keyword_count: usize, +) -> Option> { + let help_index = positionals.iter().position(|token| token == "help")?; + // A leading `help` is the curated root help command; let it flow through. + if help_index == 0 { + return None; + } + // A `help` after a `--` separator is a literal operand; leave it for clap. + if help_index >= command_keyword_count { + return None; + } + let prefix = &positionals[..help_index]; + let mut current = root; + for token in prefix { + current = current.find_subcommand(token)?; + } + // The token before `help` must resolve to a group; leaves are left to clap. + current.get_subcommands().next()?; + // Defer to clap when the group defines a real `help` subcommand of its own. + if current.find_subcommand("help").is_some() { + return None; + } + // ` help ` shows help for ` `. + let suffix = &positionals[help_index + 1..]; + Some(prefix.iter().chain(suffix).cloned().collect()) +} + +/// Rewrites a ` help [sub...]` invocation into the canonical +/// `help [sub...]` argument vector. +/// +/// Only the positional command tokens are reordered (from `[group..., help, +/// sub...]` to `[help, group..., sub...]`); every flag — including `key=value` +/// forms, value-consuming flags, unknown flags that consume a value, and +/// anything after `--` — is preserved in its original place. Reordering keeps +/// the positional count unchanged, so the rewritten stream is filled slot for +/// slot. `parts` is the resolved command path (group + subcommand) from +/// [`group_help_target_parts`]. +pub(super) fn rewrite_group_help_args( + clap_args: &[String], + root_name: &str, + bool_flags: &BTreeSet, + value_flags: &BTreeSet, + parts: &[String], +) -> Vec { + // New positional order: the curated `help` command, then the command path. + let mut next_positional = std::iter::once("help".to_owned()) + .chain(parts.iter().cloned()) + .peekable(); + let mut out = Vec::with_capacity(clap_args.len()); + let mut iter = clap_args.iter().peekable(); + if iter + .peek() + .is_some_and(|arg| arg_matches_root_name(arg, root_name)) + && let Some(program) = iter.next() + { + out.push(program.clone()); + } + + let mut take_positional = + |fallback: &String| next_positional.next().unwrap_or(fallback.clone()); + + while let Some(arg) = iter.next() { + if arg == "--" { + out.push(arg.clone()); + // Everything after `--` is positional. + for rest in iter.by_ref() { + out.push(take_positional(rest)); + } + break; + } + if arg.contains('=') || bool_flags.contains(arg) { + out.push(arg.clone()); + continue; + } + if value_flags.contains(arg) || unknown_flag_consumes_value(arg, iter.peek()) { + out.push(arg.clone()); + if let Some(value) = iter.next() { + out.push(value.clone()); + } + continue; + } + if arg.starts_with('-') { + out.push(arg.clone()); + continue; + } + out.push(take_positional(arg)); + } + // Defensive: emit any positionals not yet placed (counts normally match). + out.extend(next_positional); + out +} + +pub(super) fn positional_command_tokens( + args: &[String], + root_name: &str, + bool_flags: &BTreeSet, + value_flags: &BTreeSet, +) -> Vec { + let mut tokens = Vec::new(); + let mut iter = args.iter().peekable(); + if iter + .peek() + .is_some_and(|arg| arg_matches_root_name(arg, root_name)) + { + iter.next(); + } + + while let Some(arg) = iter.next() { + if arg == "--" { + tokens.extend(iter.cloned()); + break; + } + if arg.contains('=') { + continue; + } + if bool_flags.contains(arg) { + continue; + } + if value_flags.contains(arg) || unknown_flag_consumes_value(arg, iter.peek()) { + iter.next(); + continue; + } + if arg.starts_with('-') { + continue; + } + tokens.push(arg.clone()); + } + tokens +} + +/// Returns the sole visible leaf subcommand of a bare group, if unambiguous. +/// +/// Clap may still attach a `help` subcommand on nested groups even when the +/// root disables the auto help subcommand, so that name is excluded. +pub(super) fn single_leaf_subcommand(group: &Command) -> Option { + let candidates: Vec<_> = group + .get_subcommands() + .filter(|child| !child.is_hide_set()) + .filter(|child| child.get_name() != "help") + .filter(|child| child.get_subcommands().next().is_none()) + .collect(); + if candidates.len() == 1 { + Some(candidates[0].get_name().to_string()) + } else { + None + } +} + +/// Inserts `subcommand` immediately after the colon-separated `command_path` +/// tokens in `args`, before any trailing flags or positional values. +pub(super) fn inject_subcommand_after_command_path( + args: &[String], + root_name: &str, + command_path: &str, + subcommand: &str, + bool_flags: &BTreeSet, + value_flags: &BTreeSet, +) -> Vec { + let path_parts: Vec<&str> = command_path.split(':').collect(); + let mut result = Vec::with_capacity(args.len() + 1); + let mut iter = args.iter().peekable(); + + if iter + .peek() + .is_some_and(|arg| arg_matches_root_name(arg, root_name)) + { + result.push(iter.next().expect("peeked").clone()); + } + + let mut matched = 0_usize; + while let Some(arg) = iter.next() { + if arg == "--" { + result.push(arg.clone()); + result.extend(iter.cloned()); + break; + } + if arg.contains('=') { + result.push(arg.clone()); + continue; + } + if bool_flags.contains(arg) { + result.push(arg.clone()); + continue; + } + if value_flags.contains(arg) || unknown_flag_consumes_value(arg, iter.peek()) { + result.push(arg.clone()); + if let Some(value) = iter.next() { + result.push(value.clone()); + } + continue; + } + if arg.starts_with('-') { + result.push(arg.clone()); + continue; + } + + result.push(arg.clone()); + if matched < path_parts.len() && arg == path_parts[matched] { + matched += 1; + if matched == path_parts.len() { + result.push(subcommand.to_string()); + } + } + } + result +} + +#[cfg(test)] +mod unknown_command_suggestion_tests { + use super::*; + use crate::flags::{derive_bool_flags, derive_value_flags}; + + fn sample_group() -> Command { + Command::new("gddy").subcommand( + Command::new("domain") + .alias("dns-domain") + .subcommand(Command::new("list")) + .subcommand(Command::new("available")), + ) + } + + #[test] + fn osa_distance_treats_adjacent_transposition_as_one_edit() { + // Guard against swapping to `strsim::levenshtein`, which counts swaps as two edits. + assert_eq!(strsim::osa_distance("domain", "domain"), 0); + assert_eq!(strsim::osa_distance("domian", "domain"), 1); + assert_eq!(strsim::osa_distance("lst", "list"), 1); + assert_eq!(strsim::osa_distance("lsit", "list"), 1); + assert_eq!(strsim::osa_distance("cat", "set"), 2); + } + + #[test] + fn nearest_subcommand_matches_close_typos() { + let root = sample_group(); + let domain = root.find_subcommand("domain").expect("domain registered"); + assert_eq!(nearest_subcommand(domain, "lst").as_deref(), Some("list")); + assert_eq!(nearest_subcommand(domain, "ilst").as_deref(), Some("list")); + assert_eq!( + nearest_subcommand(domain, "avaliable").as_deref(), + Some("available") + ); + } + + #[test] + fn nearest_subcommand_rejects_unrelated_tokens() { + let root = sample_group(); + let domain = root.find_subcommand("domain").expect("domain registered"); + assert_eq!(nearest_subcommand(domain, "missing"), None); + } + + #[test] + fn nearest_subcommand_returns_canonical_name_for_alias_typos() { + let root = sample_group(); + assert_eq!( + nearest_subcommand(&root, "dns-domian").as_deref(), + Some("domain") + ); + } + + #[test] + fn nearest_subcommand_skips_hidden_commands() { + let root = Command::new("gddy") + .subcommand(Command::new("visible")) + .subcommand(Command::new("hiddeen").hide(true)); + assert_eq!(nearest_subcommand(&root, "hidden"), None); + } + + #[test] + fn nearest_subcommand_rejects_short_unrelated_tokens() { + let root = Command::new("gddy").subcommand( + Command::new("config") + .subcommand(Command::new("get")) + .subcommand(Command::new("set")) + .subcommand(Command::new("add")), + ); + let config = root.find_subcommand("config").expect("config registered"); + assert_eq!(nearest_subcommand(config, "cat"), None); + assert_eq!(nearest_subcommand(config, "x"), None); + assert_eq!(nearest_subcommand(config, "st").as_deref(), Some("set")); + } + + #[test] + fn unknown_group_command_formats_did_you_mean_suffix() { + let root = sample_group(); + let unknown = detect_unknown_group_command(&root, &["domian".to_owned()]) + .expect("domian is an unknown top-level command"); + assert_eq!(unknown.base, "unknown command \"domian\" for \"gddy\""); + assert_eq!( + format_did_you_mean(&unknown.base, "domain"), + "unknown command \"domian\" for \"gddy\" — did you mean \"domain\"?" + ); + } + + #[test] + fn detect_unknown_group_command_reports_nested_typos() { + let root = sample_group(); + let unknown = detect_unknown_group_command(&root, &["domain".to_owned(), "lst".to_owned()]) + .expect("lst is an unknown subcommand of domain"); + assert_eq!(unknown.base, "unknown command \"lst\" for \"gddy domain\""); + assert_eq!( + format_did_you_mean(&unknown.base, "list"), + "unknown command \"lst\" for \"gddy domain\" — did you mean \"list\"?" + ); + } + + #[test] + fn detect_unknown_group_command_omits_hint_for_unrelated_tokens() { + let root = sample_group(); + let unknown = detect_unknown_group_command(&root, &["missing".to_owned()]) + .expect("missing is an unknown top-level command"); + assert_eq!(unknown.base, "unknown command \"missing\" for \"gddy\""); + } + + #[test] + fn full_command_correction_fixes_a_single_group_typo() { + let root = sample_group(); + let corrections = full_command_correction(&root, &["domian".to_owned()]) + .expect("domian is correctable to domain"); + assert_eq!(corrections, vec![(0, "domain".to_owned())]); + } + + #[test] + fn full_command_correction_fixes_every_typo_in_a_nested_path() { + let root = sample_group(); + let corrections = full_command_correction(&root, &["domian".to_owned(), "lst".to_owned()]) + .expect("both tokens are correctable"); + assert_eq!( + corrections, + vec![(0, "domain".to_owned()), (1, "list".to_owned())] + ); + } + + #[test] + fn full_command_correction_bails_when_a_token_has_no_near_match() { + let root = sample_group(); + assert_eq!( + full_command_correction(&root, &["domain".to_owned(), "missing".to_owned()]), + None + ); + } + + #[test] + fn full_command_correction_is_none_when_there_is_nothing_to_correct() { + let root = sample_group(); + assert_eq!(full_command_correction(&root, &["domain".to_owned()]), None); + assert_eq!(full_command_correction(&root, &[]), None); + } + + #[test] + fn full_command_correction_corrects_the_group_before_curated_help() { + let root = sample_group(); + let corrections = full_command_correction(&root, &["domian".to_owned(), "help".to_owned()]) + .expect("domian is correctable even ahead of a help token"); + assert_eq!(corrections, vec![(0, "domain".to_owned())]); + } + + #[test] + fn full_command_correction_keeps_corrections_when_a_leaf_is_followed_by_an_operand() { + let root = sample_group(); + let corrections = full_command_correction( + &root, + &[ + "domain".to_owned(), + "avaliable".to_owned(), + "example.com".to_owned(), + ], + ) + .expect("avaliable is correctable to available"); + assert_eq!(corrections, vec![(1, "available".to_owned())]); + } + + #[test] + fn correction_display_shows_the_bare_token_for_a_single_fix() { + let corrections = vec![(1, "list".to_owned())]; + assert_eq!( + correction_display( + "gddy", + &["domain".to_owned(), "lst".to_owned()], + &corrections + ), + "list" + ); + } + + #[test] + fn correction_display_shows_the_full_command_when_a_single_fix_is_not_the_last_token() { + let corrections = vec![(0, "domain".to_owned())]; + assert_eq!( + correction_display( + "gddy", + &["domian".to_owned(), "list".to_owned()], + &corrections + ), + "gddy domain list" + ); + } + + #[test] + fn correction_display_shows_the_full_command_for_multiple_fixes() { + let corrections = vec![(0, "domain".to_owned()), (1, "list".to_owned())]; + assert_eq!( + correction_display( + "gddy", + &["domian".to_owned(), "lst".to_owned()], + &corrections + ), + "gddy domain list" + ); + } + + #[test] + fn replace_positional_command_token_rewrites_only_the_target() { + let bool_flags: BTreeSet = ["--verbose".to_owned()].into_iter().collect(); + let value_flags: BTreeSet = ["--output".to_owned()].into_iter().collect(); + let args = vec![ + "gddy".to_owned(), + "--output".to_owned(), + "json".to_owned(), + "domain".to_owned(), + "lst".to_owned(), + ]; + let corrected = + replace_positional_command_token(&args, "gddy", &bool_flags, &value_flags, 1, "list"); + assert_eq!( + corrected, + vec!["gddy", "--output", "json", "domain", "list"] + ); + } + + #[test] + fn rewrite_group_help_if_needed_runs_after_typo_correction() { + let root = sample_group(); + let bool_flags = derive_bool_flags(&root); + let value_flags = derive_value_flags(&root); + let args = vec!["gddy".to_owned(), "domian".to_owned(), "help".to_owned()]; + let corrected = + replace_positional_command_token(&args, "gddy", &bool_flags, &value_flags, 0, "domain"); + assert_eq!(corrected, vec!["gddy", "domain", "help"]); + let rewritten = + rewrite_group_help_if_needed(&root, &corrected, "gddy", &bool_flags, &value_flags); + assert_eq!(rewritten, vec!["gddy", "help", "domain"]); + } +} diff --git a/cli-engine/src/cli/flags_apply.rs b/cli-engine/src/cli/flags_apply.rs new file mode 100644 index 0000000..44b9d78 --- /dev/null +++ b/cli-engine/src/cli/flags_apply.rs @@ -0,0 +1,553 @@ +//! Applying parsed global/pagination flags to a per-run [`Middleware`], +//! building the "next page" command replay string, transport debug-logger +//! wiring, and small argv/env parsing helpers used before clap ever runs. + +use std::time::Duration; + +use clap::{Arg, ArgMatches}; + +use crate::{ + CliCoreError, CommandSpec, Middleware, Result, + feature_flags::Stage, + flags::{GlobalFlags, min_stage_env_var}, +}; + +pub(super) fn apply_global_flags( + middleware: &mut Middleware, + flags: &GlobalFlags, + timeout: Option, +) { + middleware.output_format = flags.output_format.clone(); + middleware.verbose = flags.verbose.clone(); + middleware.dry_run = flags.dry_run; + middleware.fields = flags.fields.clone(); + middleware.fields_explicit = flags.fields_explicit; + middleware.filter = flags.filter.clone(); + middleware.expr = flags.expr.clone(); + middleware.reason = flags.reason.clone(); + middleware.schema = flags.schema; + middleware.timeout = timeout; + middleware.debug = flags.debug.clone(); + middleware.interactive = flags.interactive; +} + +/// Sets `middleware.limit`/`middleware.offset` from a paginating command's own +/// `--limit`/`--offset` +pub(super) fn apply_pagination_flags( + middleware: &mut Middleware, + spec: &CommandSpec, + leaf: &ArgMatches, +) { + let Some(pagination) = spec.pagination else { + return; + }; + middleware.limit = leaf + .get_one::("limit") + .copied() + .unwrap_or(pagination.default_limit); + middleware.offset = leaf.get_one::("offset").copied().unwrap_or(0); +} + +/// Replays a paginating command's own explicit args, plus the global +/// `--filter`/`--expr`/`--fields` flags, as `--flag value` text, prefixed +/// with the CLI's binary name — the base a "view the next page" +/// [`crate::NextAction`] is built from once the response's +/// [`crate::PaginationMeta`] is known. Leading with the binary name keeps the +/// suggested command copy-pastable rather than a fragment starting at the +/// noun/verb path. +/// +/// `--filter`/`--expr`/`--fields` sit in the same output pipeline as +/// pagination itself (filter -> paginate -> expr -> fields) and change what +/// data comes back, so dropping them would make the suggested next-page +/// command return different results than the command the user actually ran. +/// Other global flags (`--output`, `--verbose`, `--env`, ...) don't affect +/// *which* data is returned, so they're intentionally left out — the caller +/// is already running under them. +/// +/// Best-effort, not a fully general clap-args reconstruction: it uses each +/// arg's real `get_long()`/`get_short()` name (never the value-map key, +/// which for derive-based args can differ from the flag — e.g. id +/// `page_size` vs flag `--page-size`), replays a multi-value arg as one +/// flag occurrence per value (round-trips correctly whether the arg is a +/// plain repeatable `ArgAction::Append` or also sets a `value_delimiter`), +/// and quotes/escapes values containing whitespace or shell metacharacters +/// (see `quote_pagination_value`). Deliberately omits `--limit`/`--offset` — +/// those are added by the caller once it knows the +/// next page's offset. +pub(super) fn pagination_command_base( + binary_name: &str, + command_path: &str, + spec: &CommandSpec, + user_args: &crate::middleware::ValueMap, + flags: &GlobalFlags, +) -> String { + let mut parts = vec![ + quote_pagination_value(binary_name), + command_path.replace(':', " "), + ]; + for arg in &spec.args { + let id = arg.get_id().as_str(); + if let Some(value) = user_args.get(id) { + push_pagination_arg(&mut parts, arg, value); + } + } + for (flag, value) in [ + ("--filter", &flags.filter), + ("--expr", &flags.expr), + ("--fields", &flags.fields), + ] { + if !value.is_empty() { + parts.push(flag.to_owned()); + parts.push(quote_pagination_value(value)); + } + } + parts.join(" ") +} + +fn push_pagination_arg(parts: &mut Vec, arg: &Arg, value: &serde_json::Value) { + let flag = arg + .get_long() + .map(|long| format!("--{long}")) + .or_else(|| arg.get_short().map(|short| format!("-{short}"))); + match value { + serde_json::Value::Bool(enabled) => { + if matches!( + arg.get_action(), + clap::ArgAction::SetTrue | clap::ArgAction::SetFalse + ) { + // A switch-style flag's presence in `user_args` already means + // the user typed exactly this flag — `SetTrue` implies `true`, + // `SetFalse` implies `false` (e.g. a `--no-foo`-style arg) — + // and neither accepts an explicit `=value` token, so replay + // the bare flag rather than appending one. + if let Some(flag) = flag { + parts.push(flag); + } + } else { + // A custom bool-valued arg (`ArgAction::Set` with a bool + // value parser) takes an explicit token, so replay it like + // any other scalar. + push_flagged_value(parts, flag, &enabled.to_string()); + } + } + serde_json::Value::Array(items) => { + // Repeat the flag once per value rather than joining into one + // comma-separated token: clap collects a repeatable flag + // (`ArgAction::Append`, the common way a command declares a + // multi-value arg) the same way whether or not it also sets + // `value_delimiter(',')`, so `--scope a --scope b` round-trips + // correctly either way. A single `--scope a,b` only works when + // a delimiter was configured — for a plain `Append` arg it's + // parsed as one literal value, changing the replay's meaning. + for item in items { + push_flagged_value(parts, flag.clone(), &pagination_arg_display(item)); + } + } + serde_json::Value::Null => {} + other => push_flagged_value(parts, flag, &pagination_arg_display(other)), + } +} + +fn push_flagged_value(parts: &mut Vec, flag: Option, value: &str) { + if let Some(flag) = flag { + parts.push(flag); + } + parts.push(quote_pagination_value(value)); +} + +fn pagination_arg_display(value: &serde_json::Value) -> String { + match value { + serde_json::Value::String(text) => text.clone(), + other => other.to_string(), + } +} + +/// Quotes a value for the suggested next-page command, if it contains +/// anything beyond a small safe-unquoted allowlist. Whitespace and shell +/// metacharacters (`|`, `&`, `;`, `<`, `>`, ...) all fall outside that +/// allowlist and so trigger quoting; once quoted, `\`, `"`, `$`, and `` ` `` +/// are backslash-escaped (backslash first, so escaping the others doesn't +/// re-escape the backslashes it just inserted) so the value can't break out +/// of the double quotes or trigger POSIX-shell expansion (`$VAR`, `$(...)`, +/// backticks) if the suggestion is copy-pasted into a shell. +fn quote_pagination_value(value: &str) -> String { + let safe_unquoted = + |c: char| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '/' | ':' | '@'); + if value.is_empty() || !value.chars().all(safe_unquoted) { + let escaped = value + .replace('\\', "\\\\") + .replace('"', "\\\"") + .replace('$', "\\$") + .replace('`', "\\`"); + format!("\"{escaped}\"") + } else { + value.to_owned() + } +} + +/// Builds the transport debug logger implied by a parsed `--debug` pattern, +/// without publishing it anywhere. +/// +/// Pure so tests can assert on the decision (`--debug` pattern -> enabled or +/// not) without touching the process-wide default logger, which every +/// [`Cli::run`](super::Cli::run) call republishes — including the many unrelated tests that +/// exercise `cli.run(...)` with no `--debug` flag and would otherwise race +/// with an assertion on the shared global. +fn debug_transport_logger_for( + debug: &str, + extra_redacted: &[String], +) -> std::sync::Arc { + if crate::debug_component_enabled(debug, "transport") { + std::sync::Arc::new( + crate::transport::StderrTransportLogger::new() + .with_redacted_headers(extra_redacted.iter().cloned()), + ) + } else { + std::sync::Arc::new(crate::transport::NoopTransportLogger) + } +} + +/// Installs (or clears) the process-wide transport debug logger from the parsed +/// `--debug` pattern. +/// +/// When `--debug` selects the `transport` component the engine publishes a +/// [`StderrTransportLogger`](crate::transport::StderrTransportLogger) — extended +/// with any [`CliConfig::with_redacted_debug_headers`](super::CliConfig::with_redacted_debug_headers) entries — which every +/// [`HttpClient`](crate::transport::HttpClient) built afterward picks up +/// automatically, with no per-command wiring. The logger is reset to a noop when +/// `transport` is not selected so the explicit setting always reflects the +/// current invocation rather than a stale process-global from an earlier one. +pub(super) fn install_debug_transport_logger(debug: &str, extra_redacted: &[String]) { + crate::transport::set_default_transport_logger(debug_transport_logger_for( + debug, + extra_redacted, + )); +} + +pub(super) fn parse_command_timeout(raw: &str) -> Result> { + let raw = raw.trim(); + if raw.is_empty() { + return Ok(Some(Duration::from_secs(60))); + } + let Some(seconds) = parse_duration_seconds(raw) else { + return Err(CliCoreError::message(format!( + "invalid timeout {raw:?}: expected duration like 60s, 5m, or 0s" + ))); + }; + if seconds <= 0.0 { + Ok(None) + } else { + Ok(Some(Duration::from_secs_f64(seconds))) + } +} + +fn parse_duration_seconds(raw: &str) -> Option { + for (suffix, seconds) in [ + ("ns", 0.000_000_001_f64), + ("us", 0.000_001_f64), + ("µs", 0.000_001_f64), + ("ms", 0.001_f64), + ("s", 1.0_f64), + ("m", 60.0_f64), + ("h", 3600.0_f64), + ] { + if let Some(number) = raw.strip_suffix(suffix) { + let value = number.parse::().ok()?; + if !value.is_finite() { + return None; + } + return Some(value * seconds); + } + } + None +} + +/// Reads the global `${APP_ID}_MIN_STAGE` override (see [`min_stage_env_var`]). +/// +/// Best-effort, like [`crate::config::ConfigFile::load`]'s handling of a +/// malformed config file: returns `None` when the var is unset, and also +/// `None` (after logging a warning) when it is set but fails to parse as a +/// [`Stage`], so a typo'd value cannot take the CLI down. +pub(super) fn global_min_stage_override(app_id: &str) -> Option { + let var = min_stage_env_var(app_id); + let value = std::env::var(&var).ok()?; + value.parse::().map_or_else( + |err| { + tracing::warn!(var = %var, value = %value, error = %err, "ignoring invalid min-stage override"); + None + }, + Some, + ) +} + +/// Pure scan over an arg iterator for the last `--env `/`--env=` +/// occurrence — used only to seed [`Cli::new`](super::Cli::new)'s `flag_policy` (and therefore +/// which flagged commands get pruned) before the command tree is built, since +/// that decision can't be revisited once real argv is parsed. The real, +/// per-invocation `--env` value used for dispatch still comes from +/// `apply_env_flag`'s clap-based parse, unchanged; this scan never replaces +/// it, only decides tree shape earlier than clap otherwise could +/// (clap's own [`clap::Command::ignore_errors`] does not help here — it +/// still requires the rest of the argv to parse against a *known* subcommand +/// structure, and at prescan time no domain modules are registered yet, so a +/// real command path makes it bail on capturing global flags too). +/// +/// Scans the *entire* argv and keeps the *last* non-empty `--env`/`--env=` +/// value, rather than stopping at the first match — a global `--env` and a +/// command-local one sharing the same arg id can both appear in one +/// invocation, and whichever clap resolves as the effective value +/// (empirically, the last one) is the one this scan must agree with. An +/// empty value (`--env=` with nothing after the `=`, or `--env` immediately +/// followed by another flag with nothing captured) is ignored rather than +/// becoming a literal empty-string candidate. +pub(super) fn prescan_env_flag(mut args: impl Iterator) -> Option { + let mut result = None; + while let Some(arg) = args.next() { + // clap's end-of-options sentinel: everything after a bare `--` is a + // positional argument, never a flag, no matter what it looks like. + // This scan must agree, or `app cmd -- --env dev` would be + // misread as a real `--env` override. + if arg == "--" { + break; + } + let value = if let Some(v) = arg.strip_prefix("--env=") { + Some(v.to_owned()) + } else if arg == "--env" { + // A space-separated value that itself looks like another flag + // (starts with `-`) is not a value at all — clap rejects this + // outright ("a value is required for '--env ' but none was + // supplied"), so this scan must not treat it as one either. An + // explicit `--env=-foo` is unambiguous and still accepted, same + // as clap's own disambiguation rule. + args.next().filter(|v| !v.starts_with('-')) + } else { + None + }; + if let Some(v) = value.filter(|v| !v.is_empty()) { + result = Some(v); + } + } + result +} + +#[cfg(test)] +mod user_agent_tests { + use super::*; + use crate::cli::{BuildInfo, Cli, CliConfig}; + + #[test] + fn user_agent_string_derives_name_and_version_by_default() { + let config = + CliConfig::new("gdx", "GoDaddy CLI", "gdx").with_build(BuildInfo::new("1.2.3")); + assert_eq!(config.user_agent_string(), "gdx/1.2.3"); + } + + #[test] + fn user_agent_string_prefers_explicit_override() { + let config = CliConfig::new("gdx", "GoDaddy CLI", "gdx") + .with_build(BuildInfo::new("1.2.3")) + .with_user_agent("gdx-cli/9.9 (custom)"); + assert_eq!(config.user_agent_string(), "gdx-cli/9.9 (custom)"); + } + + #[test] + fn user_agent_string_omits_version_when_absent() { + let config = CliConfig::new("gdx", "GoDaddy CLI", "gdx"); + assert_eq!(config.user_agent_string(), "gdx"); + } + + #[test] + fn install_default_user_agent_publishes_config_value() { + let _guard = crate::transport::client::UA_TEST_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let _restore = crate::transport::client::RestoreDefaultUserAgent; + crate::transport::set_default_user_agent("cli/dev"); + let cli = Cli::new( + CliConfig::new("uatest", "UA test", "uatest").with_build(BuildInfo::new("4.5.6")), + ); + cli.install_default_user_agent(); + assert_eq!( + crate::transport::client::default_user_agent(), + "uatest/4.5.6" + ); + } + + #[test] + fn install_debug_transport_logger_tracks_the_debug_pattern() { + // Asserts on `debug_transport_logger_for`'s decision directly rather + // than publishing to and reading back the process-wide default + // logger, which `Cli::run` republishes on every call — including the + // many unrelated tests that call `cli.run(...)` with no `--debug` + // flag and would otherwise race with this assertion. + + // `transport` selected -> an active (enabled) logger is built. + assert!(debug_transport_logger_for("transport", &[]).enabled()); + + // Wildcard with transport excluded -> a disabled (noop) logger. + assert!(!debug_transport_logger_for("*,-transport", &[]).enabled()); + + // Empty pattern -> disabled (noop). + assert!(!debug_transport_logger_for("", &[]).enabled()); + } +} + +#[cfg(test)] +mod env_config_tests { + use std::sync::Arc; + + use crate::cli::{Cli, CliConfig}; + + #[test] + fn with_environments_stores_shared_arc_with_consumer_app_id() { + // The consumer sets app_id on the Environments before sharing the Arc; + // CliConfig stores it as-is, so the file path resolves only because the + // consumer stamped the matching app_id (not because the engine did). + let cfg = CliConfig::new("gddy", "GoDaddy CLI", "gddy").with_environments(Arc::new( + crate::environments::Environments::new("prod") + .with_app_id("gddy") + .with_config_file(true), + )); + let envs = cfg.environments.as_ref().expect("environments set"); + assert!(envs.config_file_path().is_some()); + } + + #[tokio::test] + async fn env_flag_overrides_default_and_reaches_middleware_env() { + use crate::{CommandResult, CommandSpec, RuntimeCommandSpec}; + use serde_json::json; + let mut cli = Cli::new( + CliConfig::new("envtest", "Env test", "envtest") + .with_environments(Arc::new( + crate::environments::Environments::new("prod") + .with_environment("prod", crate::environments::EnvTable::new()) + .with_environment("ote", crate::environments::EnvTable::new()), + )) + .with_startup_args(Vec::<&str>::new()), + ); + cli.add_command(RuntimeCommandSpec::new_with_context( + CommandSpec::new("whichenv", "echo env").no_auth(true), + async |ctx| { + Ok(CommandResult::new( + json!({ "env": ctx.environment()?.name().to_owned() }), + )) + }, + )); + let out = cli + .run(["envtest", "whichenv", "--env", "ote", "--output", "json"]) + .await; + assert_eq!(out.exit_code, 0, "rendered: {}", out.rendered); + assert!(out.rendered.contains("\"env\"")); + assert!(out.rendered.contains("ote")); + } + + #[tokio::test] + async fn unknown_env_flag_produces_error_envelope() { + let cli = Cli::new( + CliConfig::new("envtest2", "Env test", "envtest2") + .with_environments(Arc::new( + crate::environments::Environments::new("prod") + .with_environment("prod", crate::environments::EnvTable::new()), + )) + .with_startup_args(Vec::<&str>::new()), + ); + let out = cli.run(["envtest2", "tree", "--env", "nope"]).await; + assert_ne!(out.exit_code, 0); + assert!(out.rendered.contains("nope")); + } +} + +#[cfg(test)] +mod prescan_env_flag_tests { + use super::*; + + fn argv(args: &[&str]) -> impl Iterator { + args.iter() + .map(|s| s.to_string()) + .collect::>() + .into_iter() + } + + #[test] + fn finds_space_separated_value() { + assert_eq!( + prescan_env_flag(argv(&["--dry-run", "--env", "dev", "list"])), + Some("dev".to_owned()) + ); + } + + #[test] + fn finds_equals_separated_value() { + assert_eq!( + prescan_env_flag(argv(&["--env=dev", "list"])), + Some("dev".to_owned()) + ); + } + + #[test] + fn is_none_without_the_flag() { + assert_eq!(prescan_env_flag(argv(&["env", "list"])), None); + } + + #[test] + fn trailing_env_flag_with_no_value_is_none() { + assert_eq!(prescan_env_flag(argv(&["--env"])), None); + } + + #[test] + fn keeps_the_last_of_multiple_occurrences() { + // A global `--env` and a command-local one sharing the same arg id + // can both appear (e.g. `app --env bar sub --env foo ...`); clap + // resolves the *last* one as effective, so this scan must too. + assert_eq!( + prescan_env_flag(argv(&["--env", "bar", "sub", "cmd", "--env", "foo", "arg"])), + Some("foo".to_owned()) + ); + } + + #[test] + fn ignores_an_empty_equals_value() { + assert_eq!(prescan_env_flag(argv(&["--env="])), None); + } + + #[test] + fn empty_occurrence_does_not_clobber_an_earlier_real_value() { + assert_eq!( + prescan_env_flag(argv(&["--env", "dev", "--env="])), + Some("dev".to_owned()) + ); + } + + #[test] + fn space_separated_value_starting_with_dash_is_not_a_value() { + // clap rejects `--env --dry-run` outright ("a value is required for + // '--env ' but none was supplied") rather than treating + // `--dry-run` as the value; this scan must agree. + assert_eq!(prescan_env_flag(argv(&["--env", "--dry-run"])), None); + } + + #[test] + fn equals_form_accepts_a_value_starting_with_dash() { + // `--env=-foo` is unambiguous (unlike the space-separated form) and + // still accepted, matching clap's own disambiguation rule. + assert_eq!( + prescan_env_flag(argv(&["--env=-foo"])), + Some("-foo".to_owned()) + ); + } + + #[test] + fn stops_at_the_end_of_options_sentinel() { + // Everything after a bare `--` is positional to clap, never a flag — + // `app cmd -- --env dev` must not be read as a real `--env` override. + assert_eq!(prescan_env_flag(argv(&["cmd", "--", "--env", "dev"])), None); + } + + #[test] + fn a_real_flag_before_the_sentinel_is_still_found() { + assert_eq!( + prescan_env_flag(argv(&["--env", "dev", "--", "positional"])), + Some("dev".to_owned()) + ); + } +} diff --git a/cli-engine/src/cli/lookup.rs b/cli-engine/src/cli/lookup.rs new file mode 100644 index 0000000..2dd9309 --- /dev/null +++ b/cli-engine/src/cli/lookup.rs @@ -0,0 +1,344 @@ +//! Command-tree lookup helpers: resolving colon-separated paths and help +//! targets against the `clap` tree, and building `search` documents from it. + +use std::{io::Write, path::Path}; + +use clap::Command; + +use super::Cli; +use crate::search::SearchDocument; + +pub(super) fn find_command_by_colon_path<'command>( + root: &'command Command, + path: &str, +) -> Option<&'command Command> { + find_command_and_canonical_path_by_colon_path(root, path).map(|(command, _)| command) +} + +pub(super) fn find_help_target<'command>( + root: &'command Command, + parts: &[&str], +) -> Option<&'command Command> { + let mut current = root; + let mut matched_any = false; + for part in parts { + let Some(next) = current.find_subcommand(part) else { + break; + }; + current = next; + matched_any = true; + } + matched_any.then_some(current) +} + +fn find_command_and_canonical_path_by_colon_path<'command>( + root: &'command Command, + path: &str, +) -> Option<(&'command Command, Vec)> { + if path.is_empty() { + return Some((root, Vec::new())); + } + let mut current = root; + let mut canonical = Vec::new(); + for part in path.split(':') { + current = current.find_subcommand(part)?; + canonical.push(current.get_name().to_owned()); + } + Some((current, canonical)) +} + +fn canonical_path_from_parts(root: &Command, parts: &[String]) -> Option { + if parts.is_empty() { + return Some(String::new()); + } + let mut current = root; + let mut canonical = Vec::new(); + for part in parts { + current = current.find_subcommand(part)?; + canonical.push(current.get_name().to_owned()); + } + Some(canonical.join(":")) +} + +/// Best-effort stderr hint for a `--scope` value that didn't resolve to a +/// known command path — `resolve_search_scope` still searches everything +/// (matching a bare `search` with no `--scope` at all), so this is the only +/// signal the user gets that their scope was ignored rather than applied. +/// Written directly to a locked stderr handle (not `eprintln!`), matching +/// the transport module's own `StderrTransportLogger` convention for this +/// kind of side-channel diagnostic: best-effort, so a write failure is +/// discarded rather than surfaced as a command error. +fn warn_unresolvable_search_scope(scope_path: &str) { + let mut stderr = std::io::stderr().lock(); + stderr + .write_all( + format!( + "warning: --scope {scope_path:?} did not match a known command path; searching everything instead\n" + ) + .as_bytes(), + ) + .ok(); +} + +fn collect_command_search_documents( + command: &Command, + prefix: &mut Vec, + aliases: &mut Vec, + docs: &mut Vec, +) { + if command.is_hide_set() || super::config::BUILTIN_COMMAND_NAMES.contains(&command.get_name()) { + return; + } + if command.get_subcommands().next().is_some() { + for child in command.get_subcommands() { + prefix.push(child.get_name().to_owned()); + let alias_len = aliases.len(); + append_command_alias_terms(child, aliases); + collect_command_search_documents(child, prefix, aliases, docs); + aliases.truncate(alias_len); + prefix.pop(); + } + return; + } + if prefix.is_empty() { + prefix.push(command.get_name().to_owned()); + append_command_alias_terms(command, aliases); + } + let path = prefix.join(" "); + let alias_text = aliases.join(" "); + docs.push(SearchDocument { + id: format!("cmd:{path}"), + kind: "command".to_owned(), + title: path, + summary: command + .get_about() + .map(ToString::to_string) + .unwrap_or_default(), + content: format!( + "{} {} {} {}", + command + .get_about() + .map(ToString::to_string) + .unwrap_or_default(), + command + .get_long_about() + .map(ToString::to_string) + .unwrap_or_default(), + command_flag_text(command), + alias_text + ), + }); + if prefix.len() == 1 && prefix[0] == command.get_name() { + prefix.pop(); + } +} + +fn append_command_alias_terms(command: &Command, aliases: &mut Vec) { + aliases.extend(command.get_all_aliases().map(str::to_owned)); + aliases.extend( + command + .get_all_short_flag_aliases() + .map(|alias| alias.to_string()), + ); + aliases.extend(command.get_all_long_flag_aliases().map(str::to_owned)); +} + +fn command_flag_text(command: &Command) -> String { + command + .get_arguments() + .filter(|arg| !arg.is_hide_set()) + .filter_map(|arg| { + let mut names = Vec::new(); + if let Some(short) = arg.get_short() { + names.push(format!("-{short}")); + } + if let Some(long) = arg.get_long() { + names.push(format!("--{long}")); + } + if let Some(short_aliases) = arg.get_all_short_aliases() { + names.extend( + short_aliases + .into_iter() + .map(|short_alias| format!("-{short_alias}")), + ); + } + if let Some(aliases) = arg.get_all_aliases() { + names.extend(aliases.into_iter().map(|alias| format!("--{alias}"))); + } + (!names.is_empty()).then(|| names.join(" ")) + }) + .collect::>() + .join(" ") +} + +pub(super) fn has_subcommand(command: &Command, name: &str) -> bool { + command + .get_subcommands() + .any(|child| child.get_name() == name) +} + +pub(super) fn has_root_version_flag(args: &[String], root: &Command, root_name: &str) -> bool { + let bool_flags = crate::flags::derive_bool_flags(root); + let value_flags = crate::flags::derive_value_flags(root); + let mut iter = args.iter().peekable(); + if iter + .peek() + .is_some_and(|arg| arg_matches_root_name(arg, root_name)) + { + iter.next(); + } + + while let Some(arg) = iter.next() { + match arg.as_str() { + "--version" | "-v" => return true, + "--" => return false, + value if value.contains('=') || bool_flags.contains(value) => continue, + value + if value_flags.contains(value) + || unknown_flag_consumes_value(value, iter.peek()) => + { + iter.next(); + } + value if value.starts_with('-') => {} + _ => return false, + } + } + false +} + +pub(super) fn normalize_optional_global_flags_before_command( + root: &Command, + args: &[String], +) -> Vec { + let optional_string_defaults = + std::collections::BTreeMap::from([("--verbose", "all"), ("--debug", "*")]); + let optional_bool_defaults = + std::collections::BTreeMap::from([("--dry-run", "true"), ("--schema", "true")]); + let mut normalized = Vec::with_capacity(args.len()); + let mut index = 0; + let mut current = root; + while index < args.len() { + let arg = &args[index]; + if index == 0 && arg_matches_root_name(arg, root.get_name()) { + normalized.push(arg.clone()); + index += 1; + continue; + } + + if let Some(default) = optional_bool_defaults.get(arg.as_str()) { + normalized.push(format!("{arg}={default}")); + index += 1; + continue; + } + + if let Some(default) = optional_string_defaults.get(arg.as_str()) { + match args.get(index + 1) { + None => { + normalized.push(format!("{arg}={default}")); + index += 1; + continue; + } + Some(next) + if current.get_name() == root.get_name() + || next.starts_with('-') + || direct_subcommand(current, next).is_some() => + { + normalized.push(format!("{arg}={default}")); + index += 1; + continue; + } + Some(next) => { + normalized.push(arg.clone()); + normalized.push(next.clone()); + index += 2; + continue; + } + } + } + + normalized.push(arg.clone()); + if !arg.starts_with('-') + && let Some(next_command) = direct_subcommand(current, arg) + { + current = next_command; + } + index += 1; + } + normalized +} + +fn direct_subcommand<'command>( + command: &'command Command, + token: &str, +) -> Option<&'command Command> { + command.get_subcommands().find(|child| { + child.get_name() == token || child.get_all_aliases().any(|alias| alias == token) + }) +} + +pub(super) fn unknown_flag_consumes_value(arg: &str, next: Option<&&String>) -> bool { + arg.starts_with('-') && next.is_some_and(|value| !value.starts_with('-')) +} + +pub(super) fn arg_matches_root_name(arg: &str, root_name: &str) -> bool { + arg == root_name + || Path::new(arg) + .file_stem() + .and_then(|n| n.to_str()) + .is_some_and(|n| n == root_name) +} + +pub(super) fn search_documents(cli: &Cli, scope: &str) -> Vec { + let (scoped, mut prefix) = find_command_and_canonical_path_by_colon_path(&cli.root, scope) + .unwrap_or((&cli.root, Vec::new())); + let mut docs = Vec::new(); + let mut aliases = Vec::new(); + append_command_alias_terms(scoped, &mut aliases); + collect_command_search_documents(scoped, &mut prefix, &mut aliases, &mut docs); + if scope.is_empty() { + for entry in &cli.guide_entries { + docs.push(SearchDocument { + id: format!("guide:{}", entry.name), + kind: "guide".to_owned(), + title: format!("guide {}", entry.name), + summary: entry.summary.clone(), + content: format!("{} {}", entry.summary, entry.content), + }); + } + if let Some(extra_search_docs) = &cli.extra_search_docs { + docs.extend(extra_search_docs()); + } + } + docs +} + +/// Resolves `--scope`'s colon-separated path (e.g. `domain` or +/// `domain:list`) to the canonical scope string [`search_documents`] +/// expects, matching aliases the same way a real command path would (via +/// [`canonical_path_from_parts`]'s `find_subcommand` walk). An empty or +/// unresolvable scope falls back to an unscoped (root) search rather than +/// erroring — `search` staying permissive here matches how a typo in a +/// search *query* just yields fewer results instead of a hard failure. +/// An unresolvable (non-empty) scope prints a best-effort stderr hint +/// first, so a typo like `--scope doamin` doesn't silently widen the +/// search with no explanation for the extra results. +pub(super) fn resolve_search_scope(cli: &Cli, scope_path: &str) -> String { + if scope_path.is_empty() { + return String::new(); + } + let parts: Vec = scope_path.split(':').map(str::to_owned).collect(); + match canonical_path_from_parts(&cli.root, &parts) { + Some(scope) => scope, + None => { + warn_unresolvable_search_scope(scope_path); + String::new() + } + } +} + +pub(super) fn canonical_command_path(cli: &Cli, command_path: &str) -> String { + find_command_and_canonical_path_by_colon_path(&cli.root, command_path).map_or_else( + || command_path.to_owned(), + |(_, canonical)| canonical.join(":"), + ) +} diff --git a/cli-engine/src/cli/mod.rs b/cli-engine/src/cli/mod.rs new file mode 100644 index 0000000..b13c8fe --- /dev/null +++ b/cli-engine/src/cli/mod.rs @@ -0,0 +1,569 @@ +use std::{ + collections::BTreeMap, + future::Future, + io::Write, + path::{Path, PathBuf}, + process::ExitCode, + sync::{Arc, Mutex}, +}; + +use clap::{Arg, Command}; + +mod argv0; +mod builtins; +mod completion; +mod config; +mod correction; +mod flags_apply; +mod help; +mod lookup; +mod registration; +mod render; +mod run; +mod schema_tree; +mod tree_render; + +use crate::{ + AuthProvider, CliCoreError, GuideEntry, Middleware, Module, RuntimeCommandSpec, + RuntimeGroupSpec, + error::exit_code_for_error, + feature_flags::Stage, + flags::{register_global_flags, register_reason_flag}, + module::ModuleContext, + output::{global_human_view_registry_snapshot, global_schema_registry_snapshot}, +}; + +pub use argv0::{Argv0LinkMethod, Argv0Route}; +use builtins::{completion_command, guide_command, help_command, search_command}; +pub use config::{ + ApplyFlags, BuildInfo, CliConfig, ExtraSearchDocs, InitDeps, OnShutdown, PreRun, RegisterFlags, + ResolveMeta, RootNextActions, +}; +use help::ROOT_HELP_TEMPLATE; +pub use help::{ModuleHelpEntry, build_root_long, render_next_actions_human}; + +/// Captured result of running a CLI in tests or embedding contexts. +#[derive(Clone, Debug, PartialEq)] +pub struct CliRunOutput { + /// Process-style exit code. + pub exit_code: i32, + /// Rendered stdout or stderr payload. + pub rendered: String, +} + +impl From for CliRunOutput { + fn from(o: crate::middleware::MiddlewareOutput) -> Self { + Self { + exit_code: o.exit_code, + rendered: o.rendered, + } + } +} + +/// Configured CLI application. +/// +/// A `Cli` owns the `clap` command tree, middleware, registered runtime +/// commands, guides, schemas, and built-ins. Consumer binaries normally create +/// one `Cli` and call [`Cli::execute`]. +#[derive(Clone)] +pub struct Cli { + config: CliConfig, + middleware: Middleware, + root: Command, + commands: BTreeMap, + module_entries: Vec, + guide_entries: Vec, + init_deps: Option, + apply_flags: Option, + pre_run: Option, + meta_resolver: Option, + on_shutdown: Option, + extra_search_docs: Option, + root_next_actions: Option, + init_state: Arc>>>, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct InitFailure { + message: String, + code: String, + system: String, + request_id: String, + fix: Option, + exit_code: i32, +} + +impl InitFailure { + fn capture(err: &CliCoreError) -> Self { + let envelope = crate::output::build_error_envelope(err, ""); + let (code, system, request_id) = envelope.error.map_or_else( + || ("ERROR".to_owned(), String::new(), String::new()), + |error| (error.code, error.system, error.request_id), + ); + Self { + message: err.to_string(), + code, + system, + request_id, + fix: envelope.fix, + exit_code: exit_code_for_error(err), + } + } + + fn into_error(self) -> CliCoreError { + let message = CliCoreError::SystemMessage { + message: self.message, + system: self.system, + code: self.code, + request_id: self.request_id, + }; + CliCoreError::with_exit_code( + self.exit_code, + CliCoreError::with_fix(self.fix.unwrap_or_default(), message), + ) + } +} + +impl std::fmt::Debug for Cli { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("Cli") + .field("config", &self.config) + .field("middleware", &self.middleware) + .field("root", &self.root) + .field("commands", &self.commands) + .field("module_entries", &self.module_entries) + .field("guide_entries", &self.guide_entries) + .field("has_init_deps", &self.init_deps.is_some()) + .field("has_apply_flags", &self.apply_flags.is_some()) + .field("has_pre_run", &self.pre_run.is_some()) + .field("has_meta_resolver", &self.meta_resolver.is_some()) + .field("has_on_shutdown", &self.on_shutdown.is_some()) + .field("has_extra_search_docs", &self.extra_search_docs.is_some()) + .field("has_root_next_actions", &self.root_next_actions.is_some()) + .finish() + } +} + +impl Cli { + /// Builds a CLI application from declarative configuration. + #[must_use] + pub fn new(config: CliConfig) -> Self { + let auth_providers = config.auth_providers.clone(); + let guides = config.guides.clone(); + let views = config.views.clone(); + let modules = config.modules.clone(); + let commands = config.commands.clone(); + let init_deps = config.init_deps.clone(); + let apply_flags = config.apply_flags.clone(); + let pre_run = config.pre_run.clone(); + let meta_resolver = config.meta_resolver.clone(); + let on_shutdown = config.on_shutdown.clone(); + let extra_search_docs = config.extra_search_docs.clone(); + let root_next_actions = config.root_next_actions.clone(); + let mut root = Command::new(config.name.clone()) + .about(config.short.clone()) + .disable_help_subcommand(true) + .version(config.build.version_string()); + if let Some(long) = &config.long + && !long.is_empty() + { + root = root.long_about(long.clone()); + } + root = register_global_flags(root) + .subcommand(help_command()) + .subcommand(guide_command()) + .subcommand(Command::new("tree").about("Display full command tree")) + .subcommand(completion_command()) + .subcommand(search_command()); + if let Some(register_flags) = &config.register_flags { + root = register_flags(root); + } + // `--reason` is only meaningful when something actually consumes it — + // an authorizer, auditor, or activity emitter. Apps with none of those + // registered never see the flag at all, rather than a flag whose value + // is captured and silently discarded. This checks the eager `CliConfig` + // fields only: an authorizer/auditor/activity emitter installed later via + // `init_deps` runs per-request, after flag registration, so it can't be + // observed here. Apps that want `--reason` must set `authz`/`auditor`/ + // `activity` directly on `CliConfig`, not exclusively through `init_deps`. + if config.authz.is_some() || config.auditor.is_some() || config.activity.is_some() { + root = register_reason_flag(root); + } + if config.environments.is_some() { + root = root.arg( + Arg::new("env") + .long("env") + .global(true) + .value_name("ENV") + .display_order(crate::flags::global_flag_order::ENV) + .help("Override the active environment (see: env list)"), + ); + } + let intro = config + .long + .as_deref() + .filter(|long| !long.is_empty()) + .unwrap_or(config.short.as_str()); + root = root + .long_about(build_root_long(intro, &[], false)) + .help_template(ROOT_HELP_TEMPLATE); + + let mut middleware = Middleware::new(); + middleware.app_id = config.app_id.clone(); + // One-time, macOS-only: move any pre-existing $HOME/.config/ + // contents to $HOME/Library/Application Support/ before the + // config file below is loaded from its (possibly new) location. + crate::fs::migrate_macos_config_dir(&config.app_id); + // Load the per-application config file once at startup; cloned into each + // per-run middleware so handlers and module registration share it. + middleware.config = Arc::new(crate::config::ConfigFile::load(&config.app_id)); + middleware.default_auth_provider = config.default_auth_provider.clone().unwrap_or_default(); + middleware.authz = config.authz.clone(); + middleware.auditor = config.auditor.clone(); + middleware.activity = config.activity.clone(); + middleware + .schema_registry + .merge(&global_schema_registry_snapshot()); + middleware + .human_views + .merge(&global_human_view_registry_snapshot()); + if let Some(environments) = &config.environments { + // Seed the sticky/default active environment now, but let a + // startup `--env` win over it if one is present: `prescan_env_flag` + // scans `startup_args` (or, when unset, the real process argv) the + // same way `apply_env_flag` will parse it for real per invocation + // — this is what lets a same-invocation `--env ` affect the + // `flag_policy` computed below (and therefore which flagged + // commands get pruned), not just `middleware.env`. The real, + // per-invocation value used for dispatch still comes from + // `apply_env_flag`'s clap parse in `run_with_depth`; this prescan + // only decides tree shape earlier than clap otherwise could, + // since that decision can't be revisited once the tree is built. + let startup_args = config + .startup_args + .clone() + .unwrap_or_else(|| std::env::args_os().collect()); + let startup_env_flag = flags_apply::prescan_env_flag( + startup_args + .iter() + .skip(1) // argv[0] is the program name, same convention `run`/`execute_from` use + .map(|arg| arg.to_string_lossy().into_owned()), + ); + // The same `Arc` the consumer shared with any `PkceAuthProvider` is + // reused, so the file layer and active-env persistence resolve + // consistently. + middleware.env = + environments.effective_active(startup_env_flag.as_deref(), &middleware.config); + middleware.environments = Some(Arc::clone(environments)); + } + let mut flag_policy = config.flag_policy(); + if let Some(min_stage) = flags_apply::global_min_stage_override(&config.app_id) { + flag_policy.min_stage = min_stage; + } + if let Some(environments) = &middleware.environments + && let Ok(source) = environments.source(&middleware.env) + { + let chain = crate::env_config::SourceChain::new().push(&source); + match crate::env_config::resolve_field::( + &chain, + "min_stage", + "min_stage", + None, + false, + crate::env_config::default_from_toml::, + |_raw: &str| -> Result { Err(String::new()) }, + ) { + Ok(Some(min_stage)) => flag_policy.min_stage = min_stage, + Ok(None) => {} + Err(err) => { + tracing::warn!(env = %middleware.env, error = %err, "ignoring invalid environment min_stage"); + } + } + match crate::env_config::resolve_field::>( + &chain, + "feature_overrides", + "feature_overrides", + None, + false, + crate::env_config::default_from_toml::>, + |_raw: &str| -> Result, String> { Err(String::new()) }, + ) { + Ok(Some(overrides)) => flag_policy.overrides.extend(overrides), + Ok(None) => {} + Err(err) => { + tracing::warn!(env = %middleware.env, error = %err, "ignoring invalid environment feature_overrides"); + } + } + } + middleware.flag_policy = flag_policy; + + let mut cli = Self { + config, + middleware, + root, + commands: BTreeMap::new(), + module_entries: Vec::new(), + guide_entries: Vec::new(), + init_deps, + apply_flags, + pre_run, + meta_resolver, + on_shutdown, + extra_search_docs, + root_next_actions, + init_state: Arc::new(Mutex::new(None)), + }; + for provider in auth_providers { + cli.register_auth_provider(provider); + } + if cli.middleware.default_auth_provider.is_empty() + && let Some(provider) = cli.middleware.auth.registered_names().first() + { + cli.middleware.default_auth_provider = provider.clone(); + } + if !cli.middleware.default_auth_provider.is_empty() { + registration::ensure_auth_command(&mut cli); + } + for view in views { + cli.middleware.human_views.register(view); + } + cli.add_guides(guides); + for module in modules { + cli.add_module(module); + } + for command in commands { + cli.add_command(command); + } + if cli.config.config_commands { + registration::ensure_config_command(&mut cli); + } + if cli.config.environments.is_some() { + registration::ensure_env_command(&mut cli); + } + registration::ensure_flags_command(&mut cli); + cli + } + + /// Returns the shared middleware template. + #[must_use] + pub fn middleware(&self) -> &Middleware { + &self.middleware + } + + /// Returns mutable middleware for advanced application setup. + pub fn middleware_mut(&mut self) -> &mut Middleware { + &mut self.middleware + } + + /// Executes the CLI with process arguments and process stdout/stderr. + pub async fn execute(&self) -> ExitCode { + run::execute(self).await + } + + /// Executes the CLI with caller-provided args and output writers. + /// + /// If `args` carries a synthetic `--env` unrelated to real process argv + /// (or to whatever [`CliConfig::with_startup_args`] this `Cli` was built + /// with), command-tree pruning — decided once, at construction time — + /// won't reflect it; see `with_startup_args`'s doc for why. + pub async fn execute_from( + &self, + args: I, + stdout: &mut O, + stderr: &mut E, + ) -> std::io::Result + where + I: IntoIterator, + S: Into + Clone, + O: Write, + E: Write, + { + run::execute_from(self, args, stdout, stderr).await + } + + /// Executes the CLI until either command completion or a shutdown signal future resolves. + pub async fn execute_from_until_signal( + &self, + args: I, + stdout: &mut O, + stderr: &mut E, + shutdown: Shutdown, + ) -> std::io::Result + where + I: IntoIterator, + S: Into + Clone, + O: Write, + E: Write, + Shutdown: Future, + { + run::execute_from_until_signal(self, args, stdout, stderr, shutdown).await + } + + /// Publishes the configured outbound User-Agent process-wide so that + /// command [`HttpClient`](crate::transport::HttpClient)s and the engine's + /// own OAuth token requests share it. + /// + /// Called from the execution entrypoints rather than [`Cli::new`] so that + /// merely constructing a `Cli` (as tests do in bulk) does not mutate global + /// state. See [`CliConfig::user_agent_string`] for resolution order. + fn install_default_user_agent(&self) { + crate::transport::set_default_user_agent(self.config.user_agent_string()); + } + + /// Registers an auth provider after construction. + pub fn register_auth_provider(&mut self, provider: Arc) -> &mut Self { + self.middleware.auth.register(provider); + registration::ensure_auth_command(self); + registration::refresh_root_long(self); + self + } + + /// Returns the built `clap` root command. + #[must_use] + pub fn root_command(&self) -> &Command { + &self.root + } + + /// Adds one runtime module group after construction. + pub fn add_module_group( + &mut self, + category: impl Into, + group: RuntimeGroupSpec, + ) -> &mut Self { + registration::add_module_group_inner(self, category, group, None) + } + + /// Adds one module after construction. + pub fn add_module(&mut self, module: Module) -> &mut Self { + for view in module.views.clone() { + self.middleware.human_views.register(view); + } + self.add_guides(module.guides.clone()); + let mut context = ModuleContext::new(&mut self.middleware); + let group = (module.register)(&mut context); + let (guides, views) = context.into_parts(); + for view in views { + self.middleware.human_views.register(view); + } + self.add_guides(guides); + registration::add_module_group_inner( + self, + module.category, + group, + module.feature_flag.clone(), + ) + } + + /// Adds one top-level runtime command after construction. + pub fn add_command(&mut self, command: RuntimeCommandSpec) -> &mut Self { + let name = command.spec.name.clone(); + schema_tree::register_command_schema( + &command.spec, + &name, + &mut self.middleware.schema_registry, + ); + self.commands.insert(name, command.clone()); + self.root = + self.root + .clone() + .subcommand(schema_tree::command_clap_command_with_schema_help( + &command.spec, + &command.spec.name, + &self.middleware.schema_registry, + )); + self + } + + /// Controls whether the built-in `guide` command is advertised. + pub fn set_has_guide(&mut self, has_guide: bool) -> &mut Self { + if has_guide + && self.guide_entries.is_empty() + && !lookup::has_subcommand(&self.root, "guide") + { + self.root = self.root.clone().subcommand(guide_command()); + } + registration::sync_guide_topic_values(self); + registration::refresh_root_long(self); + self + } + + /// Adds guide entries after construction. + pub fn add_guides(&mut self, entries: impl IntoIterator) -> &mut Self { + let mut seen = self + .guide_entries + .iter() + .map(|entry| entry.name.clone()) + .collect::>(); + for entry in entries { + if seen.insert(entry.name.clone()) { + self.guide_entries.push(entry); + } + } + if !self.guide_entries.is_empty() && !lookup::has_subcommand(&self.root, "guide") { + self.root = self.root.clone().subcommand(guide_command()); + } + registration::sync_guide_topic_values(self); + registration::refresh_root_long(self); + self + } + + /// Returns the registered alternative `argv[0]` names, sorted. + /// + /// Useful for install or self-healing code that iterates the names and calls + /// [`Cli::create_link`] for each. + #[must_use] + pub fn argv0_names(&self) -> Vec<&str> { + self.config + .argv0_routes + .keys() + .map(String::as_str) + .collect() + } + + /// Creates an on-disk link in `dir` that lets the binary be invoked under the + /// registered alternative `argv[0]` name `name`, using `method`. + /// + /// `target` is the executable the link points at; pass `None` to use the + /// current executable ([`std::env::current_exe`]), which is the common choice + /// for install and self-healing code. The file name follows the platform and + /// method: a symlink or hard link is `` on Unix and `.exe` on + /// Windows; a [`Argv0LinkMethod::Script`] shim is `.cmd` on Windows and + /// an executable `` shell script on Unix. + /// + /// The call ensures the desired state idempotently: if the destination already + /// matches what would be created (a symlink to `target`, a hard link with the + /// same contents, or a shim with identical contents) it is left untouched and + /// its path returned; if it exists but differs (wrong kind, stale target, or + /// edited shim) it is replaced. This makes the call safe to re-run as install + /// or self-healing code, restoring both deleted and corrupted links. The + /// directory is created if necessary. + /// + /// # Errors + /// + /// Returns an error if `name` is not a registered route, if the current + /// executable cannot be resolved (when `target` is `None`), or if the + /// directory or link cannot be created or replaced (e.g. insufficient + /// privilege for a Windows symlink, or a hard link across volumes). + pub fn create_link( + &self, + name: &str, + dir: impl AsRef, + target: Option<&Path>, + method: Argv0LinkMethod, + ) -> std::io::Result { + argv0::create_link(self, name, dir, target, method) + } + + /// Runs the CLI with provided args and captures the rendered result. + /// + /// Same `--env`/tree-pruning caveat as [`Cli::execute_from`]: see + /// [`CliConfig::with_startup_args`]. + pub async fn run(&self, args: I) -> CliRunOutput + where + I: IntoIterator, + S: Into + Clone, + { + run::run_with_depth(self, args, 0).await + } +} diff --git a/cli-engine/src/cli/registration.rs b/cli-engine/src/cli/registration.rs new file mode 100644 index 0000000..9b93492 --- /dev/null +++ b/cli-engine/src/cli/registration.rs @@ -0,0 +1,468 @@ +//! Post-construction registration: module/group mounting, guide-topic sync, +//! the root long-help rebuild, and the lazily-mounted built-in command +//! groups (`auth`, `config`, `env`, `flags`). + +use clap::builder::PossibleValuesParser; + +use super::{ + Cli, + config::{BUILTIN_COMMAND_NAMES, DEFAULT_ADMIN_CATEGORY}, + help::{ModuleHelpEntry, build_root_long}, + lookup::has_subcommand, + schema_tree::{ + prune_feature_flag_tree, register_runtime_group_metadata, + runtime_group_clap_command_with_schema_help, + }, +}; +use crate::{FeatureFlag, RuntimeGroupSpec, auth::commands::auth_command_group}; + +/// Lists the auto-registered `auth` command under the admin help category so +/// it is never uncategorized once clap's auto subcommand list is suppressed. +/// Defaults to [`DEFAULT_ADMIN_CATEGORY`]; `admin_category` overrides it to +/// align with a consumer's own taxonomy. +fn register_auth_help_entry(cli: &mut Cli) { + let category = cli + .config + .admin_category + .clone() + .unwrap_or_else(|| DEFAULT_ADMIN_CATEGORY.to_owned()); + let already_listed = cli.module_entries.iter().any(|entry| entry.name == "auth"); + let short = cli + .root + .find_subcommand("auth") + .filter(|auth| !auth.is_hide_set()) + .map(|auth| { + auth.get_about() + .map(ToString::to_string) + .unwrap_or_default() + }); + if !already_listed && let Some(short) = short { + cli.module_entries.push(ModuleHelpEntry { + category, + name: "auth".to_owned(), + short, + }); + } + refresh_root_long(cli); +} + +/// Shared implementation behind [`Cli::add_module_group`] and +/// [`Cli::add_module`]. `inherited` is the effective feature flag the +/// group's enclosing module declared (if any), so a module-level flag +/// cascades down to the group even though `add_module_group` itself has no +/// concept of a module. +pub(super) fn add_module_group_inner( + cli: &mut Cli, + category: impl Into, + group: RuntimeGroupSpec, + inherited: Option, +) -> &mut Cli { + // Prevent consumer modules from shadowing engine built-ins in the clap + // command tree. A reserved group name would override the engine's own + // subcommand (last-writer-wins in clap) and corrupt the dispatch path. + if BUILTIN_COMMAND_NAMES.contains(&group.group.name.as_str()) { + tracing::warn!( + name = %group.group.name, + "module group name is reserved by cli-engine built-ins; the group will not be registered" + ); + return cli; + } + + let mut prefix = Vec::new(); + let Some(group) = prune_feature_flag_tree( + group, + inherited.as_ref(), + &cli.middleware.flag_policy, + &mut prefix, + &mut cli.middleware.flag_registry, + ) else { + return cli; + }; + + let category = category.into(); + if !group.group.hidden { + cli.module_entries.push(ModuleHelpEntry { + category, + name: group.group.name.clone(), + short: group.group.short.clone(), + }); + } + + let mut prefix = Vec::new(); + register_runtime_group_metadata( + &group, + &mut prefix, + &mut cli.middleware.schema_registry, + &mut cli.middleware.human_views, + ); + let mut prefix = Vec::new(); + group.register_commands(&mut prefix, &mut cli.commands); + let mut prefix = Vec::new(); + let clap_group = runtime_group_clap_command_with_schema_help( + &group, + &mut prefix, + &cli.middleware.schema_registry, + ); + cli.root = cli.root.clone().subcommand(clap_group); + refresh_root_long(cli); + cli +} + +/// Re-attaches the `guide` subcommand's `topic` arg possible values from +/// the current guide entries, so shell completion knows about guide names, +/// which are not all registered up front. +pub(super) fn sync_guide_topic_values(cli: &mut Cli) { + if cli.guide_entries.is_empty() { + return; + } + let names = cli + .guide_entries + .iter() + .map(|entry| entry.name.clone()) + .collect::>(); + if let Some(guide_cmd) = cli.root.find_subcommand_mut("guide") { + let taken = std::mem::replace(guide_cmd, clap::Command::new("guide")); + *guide_cmd = taken.mut_arg("topic", |arg| { + arg.value_parser(PossibleValuesParser::new(names)) + }); + } +} + +pub(super) fn refresh_root_long(cli: &mut Cli) { + // Module-categorized entries, plus any visible top-level command that is + // neither categorized nor an engine built-in, listed under a generic + // "Commands" section. This keeps every command discoverable once clap's + // auto subcommand list is suppressed by the root help template. + let builtins = BUILTIN_COMMAND_NAMES; + let categorized: std::collections::BTreeSet<&str> = cli + .module_entries + .iter() + .map(|entry| entry.name.as_str()) + .collect(); + let mut generic: Vec = cli + .root + .get_subcommands() + .filter(|command| !command.is_hide_set()) + .filter(|command| !builtins.contains(&command.get_name())) + .filter(|command| !categorized.contains(command.get_name())) + .map(|command| ModuleHelpEntry { + category: "Commands".to_owned(), + name: command.get_name().to_owned(), + short: command + .get_about() + .map(ToString::to_string) + .unwrap_or_default(), + }) + .collect(); + generic.sort_by(|left, right| left.name.cmp(&right.name)); + + let mut entries = cli.module_entries.clone(); + entries.extend(generic); + let has_guide = !cli.guide_entries.is_empty() || has_subcommand(&cli.root, "guide"); + let intro = cli + .config + .long + .as_deref() + .filter(|long| !long.is_empty()) + .unwrap_or(cli.config.short.as_str()); + cli.root = cli + .root + .clone() + .long_about(build_root_long(intro, &entries, has_guide)); +} + +pub(super) fn ensure_auth_command(cli: &mut Cli) { + let default_provider = default_auth_provider(cli); + let registered_names = cli.middleware.auth.registered_names(); + if default_provider.is_empty() && registered_names.is_empty() { + return; + } + let replacing_builtin = cli.commands.contains_key("auth:login"); + if has_subcommand(&cli.root, "auth") && !replacing_builtin { + return; + } + let mut group = auth_command_group(&default_provider, ®istered_names); + let mut seen_names: std::collections::HashSet = + group.commands.iter().map(|c| c.spec.name.clone()).collect(); + for extra in cli.config.auth_extra_commands.clone() { + if !seen_names.insert(extra.spec.name.clone()) { + tracing::warn!( + command = %extra.spec.name, + "auth_extra_commands entry collides with a built-in auth subcommand or an \ + earlier auth_extra_commands entry; ignoring" + ); + continue; + } + group = group.with_command(extra); + } + let mut prefix = Vec::new(); + register_runtime_group_metadata( + &group, + &mut prefix, + &mut cli.middleware.schema_registry, + &mut cli.middleware.human_views, + ); + let mut prefix = Vec::new(); + group.register_commands(&mut prefix, &mut cli.commands); + let mut prefix = Vec::new(); + let clap_group = runtime_group_clap_command_with_schema_help( + &group, + &mut prefix, + &cli.middleware.schema_registry, + ); + cli.root = if replacing_builtin { + cli.root.clone().mut_subcommand("auth", |_| clap_group) + } else { + cli.root.clone().subcommand(clap_group) + }; + // Categorize `auth` wherever it is ensured (construction or a later + // `register_auth_provider`), so it never falls into the generic + // "Commands" bucket. Idempotent via the `already_listed` guard. + register_auth_help_entry(cli); +} + +/// Mounts the built-in `config` command group and files it under the admin +/// help category. Idempotent and yields to a consumer-defined `config` +/// subcommand if one already exists. +pub(super) fn ensure_config_command(cli: &mut Cli) { + if has_subcommand(&cli.root, "config") { + return; + } + let group = crate::config_commands::config_command_group(); + let mut prefix = Vec::new(); + group.register_commands(&mut prefix, &mut cli.commands); + let mut prefix = Vec::new(); + let clap_group = runtime_group_clap_command_with_schema_help( + &group, + &mut prefix, + &cli.middleware.schema_registry, + ); + cli.root = cli.root.clone().subcommand(clap_group); + let category = cli + .config + .admin_category + .clone() + .unwrap_or_else(|| DEFAULT_ADMIN_CATEGORY.to_owned()); + if !cli + .module_entries + .iter() + .any(|entry| entry.name == "config") + { + cli.module_entries.push(ModuleHelpEntry { + category, + name: "config".to_owned(), + short: "Read and write the CLI config file".to_owned(), + }); + } + refresh_root_long(cli); +} + +/// Mounts the built-in `env` command group and files it under the admin +/// help category. Idempotent and yields to a consumer-defined `env` +/// subcommand if one already exists. +pub(super) fn ensure_env_command(cli: &mut Cli) { + if has_subcommand(&cli.root, "env") { + return; + } + let group = crate::env_commands::env_command_group(); + let mut prefix = Vec::new(); + group.register_commands(&mut prefix, &mut cli.commands); + let mut prefix = Vec::new(); + let clap_group = runtime_group_clap_command_with_schema_help( + &group, + &mut prefix, + &cli.middleware.schema_registry, + ); + cli.root = cli.root.clone().subcommand(clap_group); + let category = cli + .config + .admin_category + .clone() + .unwrap_or_else(|| DEFAULT_ADMIN_CATEGORY.to_owned()); + if !cli.module_entries.iter().any(|e| e.name == "env") { + cli.module_entries.push(ModuleHelpEntry { + category, + name: "env".to_owned(), + short: "Manage the active environment".to_owned(), + }); + } + refresh_root_long(cli); +} + +/// Mounts the built-in `flags` command group and files it under the admin +/// help category. Idempotent and yields to a consumer-defined `flags` +/// subcommand if one already exists. Unlike [`ensure_env_command`], this is +/// mounted unconditionally: feature-flag introspection does not depend on +/// any opt-in system, so it is always available. +pub(super) fn ensure_flags_command(cli: &mut Cli) { + if has_subcommand(&cli.root, "flags") { + return; + } + let group = crate::flag_commands::flags_command_group(); + let mut prefix = Vec::new(); + group.register_commands(&mut prefix, &mut cli.commands); + let mut prefix = Vec::new(); + let clap_group = runtime_group_clap_command_with_schema_help( + &group, + &mut prefix, + &cli.middleware.schema_registry, + ); + cli.root = cli.root.clone().subcommand(clap_group); + let category = cli + .config + .admin_category + .clone() + .unwrap_or_else(|| DEFAULT_ADMIN_CATEGORY.to_owned()); + if !cli.module_entries.iter().any(|e| e.name == "flags") { + cli.module_entries.push(ModuleHelpEntry { + category, + name: "flags".to_owned(), + short: "Inspect declared feature flags".to_owned(), + }); + } + refresh_root_long(cli); +} + +fn default_auth_provider(cli: &Cli) -> String { + if !cli.middleware.default_auth_provider.is_empty() { + return cli.middleware.default_auth_provider.clone(); + } + cli.middleware + .auth + .registered_names() + .into_iter() + .next() + .unwrap_or_default() +} + +#[cfg(test)] +mod flags_command_tests { + use super::*; + use crate::{ + CommandResult, CommandSpec, GroupSpec, Module, RuntimeCommandSpec, cli::CliConfig, + feature_flags::Stage, + }; + + /// Builds a module with one flagged group containing one flagged (via + /// inheritance) `list` command, so `flag_registry` has something to + /// introspect once the module is mounted. + fn flagged_module(group_name: &'static str, key: &'static str, stage: Stage) -> Module { + Module::new("Test Category", move |_ctx| { + RuntimeGroupSpec::new(GroupSpec::new(group_name, "short")).with_command( + RuntimeCommandSpec::new( + CommandSpec::new("list", "short").no_auth(true), + async |_, _| Ok(CommandResult::new(serde_json::Value::Null)), + ), + ) + }) + .with_feature_flag(key, stage) + } + + #[tokio::test] + async fn flags_list_reports_flagged_entries() { + let mut cli = Cli::new( + CliConfig::new("flagtest", "Flag test", "flagtest").with_min_stage(Stage::Beta), + ); + cli.add_module(flagged_module("flagged-mod", "list-flag", Stage::Beta)); + + let out = cli + .run(["flagtest", "flags", "list", "--output", "json"]) + .await; + assert_eq!(out.exit_code, 0, "rendered: {}", out.rendered); + let rendered: serde_json::Value = + serde_json::from_str(&out.rendered).expect("stdout should contain json"); + let entries = rendered["data"].as_array().expect("data should be array"); + let command_entry = entries + .iter() + .find(|entry| entry["path"] == "flagged-mod:list") + .expect("flagged command entry should be present"); + assert_eq!(command_entry["key"], "list-flag"); + assert_eq!(command_entry["stage"], "beta"); + assert_eq!(command_entry["visible"], true); + } + + #[tokio::test] + async fn flags_info_returns_policy_and_entries_for_known_key() { + let mut cli = Cli::new( + CliConfig::new("flagtest2", "Flag test", "flagtest2").with_min_stage(Stage::Beta), + ); + cli.add_module(flagged_module("flagged-mod-2", "info-flag", Stage::Beta)); + + let out = cli + .run([ + "flagtest2", + "flags", + "info", + "info-flag", + "--output", + "json", + ]) + .await; + assert_eq!(out.exit_code, 0, "rendered: {}", out.rendered); + let rendered: serde_json::Value = + serde_json::from_str(&out.rendered).expect("stdout should contain json"); + let data = &rendered["data"]; + assert_eq!(data["key"], "info-flag"); + assert_eq!(data["policy"]["min_stage"], "beta"); + assert!(data["policy"]["override"].is_null()); + let entries = data["entries"].as_array().expect("entries should be array"); + assert!(!entries.is_empty()); + assert!(entries.iter().any(|entry| { + entry["path"] == "flagged-mod-2:list" && entry["decided_by"] == "min_stage" + })); + } + + #[tokio::test] + async fn flags_info_reports_override_decided_by() { + // The module declares Experimental, which the default Ga policy would + // normally hide; the override forces Ga instead, so the entries stay + // visible even though `entry.stage` still reports the node's own + // (Experimental) declaration, not the override. + let mut cli = Cli::new( + CliConfig::new("flagtest3", "Flag test", "flagtest3") + .with_feature_override("override-flag", Stage::Ga), + ); + cli.add_module(flagged_module( + "flagged-mod-3", + "override-flag", + Stage::Experimental, + )); + + let out = cli + .run([ + "flagtest3", + "flags", + "info", + "override-flag", + "--output", + "json", + ]) + .await; + assert_eq!(out.exit_code, 0, "rendered: {}", out.rendered); + let rendered: serde_json::Value = + serde_json::from_str(&out.rendered).expect("stdout should contain json"); + let data = &rendered["data"]; + assert_eq!(data["policy"]["min_stage"], "ga"); + assert_eq!(data["policy"]["override"], "ga"); + let entries = data["entries"].as_array().expect("entries should be array"); + assert!(!entries.is_empty()); + assert!( + entries + .iter() + .all(|entry| entry["decided_by"] == "override") + ); + assert!(entries.iter().all(|entry| entry["visible"] == true)); + assert!(entries.iter().all(|entry| entry["stage"] == "experimental")); + } + + #[tokio::test] + async fn flags_info_unknown_key_errors() { + let cli = Cli::new(CliConfig::new("flagtest4", "Flag test", "flagtest4")); + + let out = cli + .run(["flagtest4", "flags", "info", "no-such-flag"]) + .await; + assert_ne!(out.exit_code, 0); + assert!(out.rendered.contains("no such flag")); + } +} diff --git a/cli-engine/src/cli/render.rs b/cli-engine/src/cli/render.rs new file mode 100644 index 0000000..7eb71a2 --- /dev/null +++ b/cli-engine/src/cli/render.rs @@ -0,0 +1,302 @@ +//! Rendering for the engine's built-in commands and error/discovery paths: +//! `--schema`, bare-group discovery, `search`, bare-root, `guide`, +//! `completion --print`, and curated `help`. + +use clap::ArgMatches; + +use super::{Cli, CliRunOutput, help::render_next_actions_human, lookup, tree_render}; +use crate::{ + CliCoreError, Middleware, + command::leaf_matches, + error::exit_code_for_error, + guide::{guide_content, render_guide_human}, + output::NextAction, + search::SearchIndex, +}; + +pub(super) fn render_schema( + cli: &Cli, + data: impl serde::Serialize, + output_format: &str, +) -> CliRunOutput { + let format: crate::output::OutputFormat = match output_format.parse() { + Ok(format) => format, + Err(err) => { + return CliRunOutput { + exit_code: exit_code_for_error(&err), + rendered: err.to_string(), + }; + } + }; + let envelope = crate::Envelope::success(data, cli.config.app_id.clone()).prepare_for_render(""); + match crate::output::render(format, &envelope) { + Ok(rendered) => CliRunOutput { + exit_code: 0, + rendered, + }, + Err(err) => CliRunOutput { + exit_code: exit_code_for_error(&err), + rendered: err.to_string(), + }, + } +} + +/// Renders a bare group invocation (no subcommand given). +/// +/// Human output keeps the existing clap help text; every other format, +/// explicit `--output json`/`--toon`, or the non-TTY default an agent +/// sees with no `--output` flag at all — gets an explicit JSON +/// command-tree subset scoped to this group, built with the same +/// [`crate::tree`] machinery as the top-level `tree` command. +pub(super) fn render_bare_group_discovery( + cli: &Cli, + group: &clap::Command, + command_path: &str, + middleware: &Middleware, +) -> CliRunOutput { + let format: crate::output::OutputFormat = match middleware.output_format.parse() { + Ok(format) => format, + Err(err) => { + return CliRunOutput { + exit_code: exit_code_for_error(&err), + rendered: err.to_string(), + }; + } + }; + if format == crate::output::OutputFormat::Human { + return CliRunOutput { + exit_code: 0, + rendered: group.clone().render_long_help().to_string(), + }; + } + let path = format!("{} {}", cli.config.name, command_path.replace(':', " ")); + let tree = crate::tree::build_tree_from_clap_with_path(group, path); + tree_render::render_tree_envelope(tree, &cli.config.app_id, middleware, format) +} + +pub(super) fn render_search( + cli: &Cli, + query: &str, + scope: &str, + output_format: &str, +) -> CliRunOutput { + let format: crate::output::OutputFormat = match output_format.parse() { + Ok(format) => format, + Err(err) => { + return CliRunOutput { + exit_code: exit_code_for_error(&err), + rendered: err.to_string(), + }; + } + }; + let docs = lookup::search_documents(cli, scope); + let results = SearchIndex::new(docs).search(query, 10); + let envelope = + crate::Envelope::success(results, cli.config.app_id.clone()).prepare_for_render(""); + match crate::output::render(format, &envelope) { + Ok(rendered) => CliRunOutput { + exit_code: 0, + rendered, + }, + Err(err) => CliRunOutput { + exit_code: exit_code_for_error(&err), + rendered: err.to_string(), + }, + } +} + +/// Renders the bare-root response. For human output, renders long help plus +/// a "Next actions" section so a human invoking the CLI with no arguments +/// gets readable guidance; for machine-readable output, emits a discovery +/// envelope (light metadata + next actions). The output format has already +/// resolved the TTY/env/flag policy, so this just branches on it. +pub(super) fn render_root( + cli: &Cli, + middleware: &Middleware, + actions: Vec, +) -> CliRunOutput { + // Reject an invalid explicit `--output` here too, matching the normal + // command path (`Middleware::render_envelope`). `OutputFormat::from_str` + // is infallible and would otherwise silently coerce an unrecognized + // value (e.g. `--output yaml`) to JSON instead of reporting the error. + if !crate::output::is_valid_output_format(&middleware.output_format) { + let err = CliCoreError::InvalidOutputFormat(middleware.output_format.clone()); + return CliRunOutput { + exit_code: exit_code_for_error(&err), + rendered: err.to_string(), + }; + } + let format = middleware + .output_format + .parse() + .unwrap_or(crate::output::OutputFormat::Json); + if format == crate::output::OutputFormat::Human { + // Fold the suggested actions into the root long-about so they render + // alongside the other curated sections (before Usage) instead of + // dangling beneath clap's options dump. + let base_long = cli + .root + .get_long_about() + .map(ToString::to_string) + .unwrap_or_default(); + let long = format!("{base_long}{}", render_next_actions_human(&actions)); + let rendered = cli + .root + .clone() + .long_about(long) + .render_long_help() + .to_string(); + return CliRunOutput { + exit_code: 0, + rendered, + }; + } + let description = cli + .config + .long + .as_deref() + .filter(|long| !long.is_empty()) + .unwrap_or(cli.config.short.as_str()); + let data = serde_json::json!({ + "description": description, + "version": cli.config.build.version, + }); + let envelope = crate::Envelope::success(data, cli.config.app_id.clone()) + .with_next_actions(actions) + .prepare_for_render(&middleware.verbose); + match crate::output::render(format, &envelope) { + Ok(rendered) => CliRunOutput { + exit_code: 0, + rendered, + }, + Err(err) => CliRunOutput { + exit_code: exit_code_for_error(&err), + rendered: err.to_string(), + }, + } +} + +pub(super) fn render_guide(cli: &Cli, matches: &ArgMatches, output_format: &str) -> CliRunOutput { + use std::io::IsTerminal; + + // Reject an invalid explicit `--output` here too, matching the normal + // command path and `render_root`; otherwise an unrecognized value (e.g. + // `--output yaml`) would silently fall through and emit raw content. + if !crate::output::is_valid_output_format(output_format) { + let err = CliCoreError::InvalidOutputFormat(output_format.to_owned()); + return CliRunOutput { + exit_code: exit_code_for_error(&err), + rendered: err.to_string(), + }; + } + + let leaf = leaf_matches(matches); + let topic = leaf.get_one::("topic").map(String::as_str); + match guide_content(&cli.guide_entries, topic) { + Ok(rendered) => { + // Only reflow an actual guide topic body, and only for human output. + // The topic list is plain text (not markdown) and json/toon keep the + // raw markdown so their output stays deterministic. + let rendered = if topic.is_some() && output_format == "human" { + let is_tty = std::io::stdout().is_terminal(); + render_guide_human(&rendered, crate::output::terminal_width(), is_tty) + } else { + rendered + }; + CliRunOutput { + exit_code: 0, + rendered, + } + } + Err(err) => CliRunOutput { + exit_code: 1, + rendered: err, + }, + } +} + +pub(super) fn render_completion_print( + cli: &Cli, + shell_opt: Option, + middleware: &Middleware, +) -> CliRunOutput { + use super::completion::{detect_shell, generate_script, parse_shell}; + let shell = match shell_opt { + Some(s) => match parse_shell(&s) { + Ok(s) => s, + Err(e) => return render_cli_error(middleware, &e, &cli.config.app_id), + }, + None => match detect_shell() { + Ok(s) => s, + Err(e) => return render_cli_error(middleware, &e, &cli.config.app_id), + }, + }; + match generate_script(&cli.root, &cli.config.name, shell) { + Ok(script) => CliRunOutput { + exit_code: 0, + rendered: script, + }, + Err(e) => render_cli_error(middleware, &e, &cli.config.app_id), + } +} + +pub(super) fn render_help_command(cli: &Cli, matches: &ArgMatches) -> CliRunOutput { + let leaf = leaf_matches(matches); + let parts = leaf + .get_many::("command") + .map(|values| values.map(String::as_str).collect::>()) + .unwrap_or_default(); + render_help_for_parts(cli, &parts) +} + +/// Renders the curated help text for a resolved command path. +/// +/// Empty `parts` render the root help. A path that resolves to a group or +/// command renders that command's long help; an unresolved path returns the +/// standard "unknown command" guidance with a non-zero exit code. Shared by +/// the root `help ` command and the ` help` subcommand form. +pub(super) fn render_help_for_parts(cli: &Cli, parts: &[&str]) -> CliRunOutput { + if parts.is_empty() { + return CliRunOutput { + exit_code: 0, + rendered: cli.root.clone().render_long_help().to_string(), + }; + } + let Some(command) = lookup::find_help_target(&cli.root, parts) else { + return CliRunOutput { + exit_code: 1, + rendered: format!( + "unknown command {:?} — run '{} help' for available commands", + parts.join(" "), + cli.config.name + ), + }; + }; + CliRunOutput { + exit_code: 0, + rendered: command.clone().render_long_help().to_string(), + } +} + +pub(super) fn render_cli_error( + middleware: &Middleware, + err: &(dyn std::error::Error + 'static), + system: &str, +) -> CliRunOutput { + let format = middleware + .output_format + .parse::() + .unwrap_or(crate::output::OutputFormat::Json); + let envelope = + crate::output::build_error_envelope(err, system).prepare_for_render(&middleware.verbose); + match crate::output::render(format, &envelope) { + Ok(rendered) => CliRunOutput { + exit_code: exit_code_for_error(err), + rendered, + }, + Err(render_err) => CliRunOutput { + exit_code: exit_code_for_error(err), + rendered: render_err.to_string(), + }, + } +} diff --git a/cli-engine/src/cli/run.rs b/cli-engine/src/cli/run.rs new file mode 100644 index 0000000..76d9617 --- /dev/null +++ b/cli-engine/src/cli/run.rs @@ -0,0 +1,829 @@ +//! The core execution pipeline: argument normalization, unknown-command +//! correction, built-in command dispatch, and handler invocation. + +use std::{future::Future, io::Write, process::ExitCode, sync::Arc, time::Duration}; + +use clap::ArgMatches; + +use super::{ + Cli, CliRunOutput, InitFailure, + argv0::Argv0Outcome, + builtins::{completion_args, guide_args, help_args, search_args}, + correction::{ + command_keyword_count, detect_unknown_group_command, format_did_you_mean, + full_command_correction, group_help_target_parts, inject_subcommand_after_command_path, + positional_command_tokens, rewrite_group_help_args, rewrite_group_help_if_needed, + single_leaf_subcommand, + }, + flags_apply::{ + apply_global_flags, apply_pagination_flags, install_debug_transport_logger, + pagination_command_base, parse_command_timeout, + }, + lookup::{ + find_command_by_colon_path, has_root_version_flag, + normalize_optional_global_flags_before_command, + }, + render::{ + render_bare_group_discovery, render_cli_error, render_completion_print, render_guide, + render_help_command, render_root, render_schema, render_search, + }, + tree_render, +}; +use crate::{ + CliCoreError, MiddlewareRequest, Result, + command::{ + CommandContext, StreamSender, command_args_from_matches, command_path_from_matches, + leaf_matches, + }, + error::exit_code_for_error, + flags::{ + derive_bool_flags, derive_value_flags, extract_command_path, extract_output_format, + global_flags_from_matches, has_true_schema_flag, output_env_var, + resolve_default_output_format, + }, +}; + +/// Executes the CLI with process arguments and process stdout/stderr. +pub(super) async fn execute(cli: &Cli) -> ExitCode { + let mut stdout = std::io::stdout().lock(); + let mut stderr = std::io::stderr().lock(); + match execute_from(cli, std::env::args_os(), &mut stdout, &mut stderr).await { + Ok(code) => code, + Err(err) => { + drop(writeln!(stderr, "{err}")); + ExitCode::from(1) + } + } +} + +/// Executes the CLI with caller-provided args and output writers. +pub(super) async fn execute_from( + cli: &Cli, + args: I, + stdout: &mut O, + stderr: &mut E, +) -> std::io::Result +where + I: IntoIterator, + S: Into + Clone, + O: Write, + E: Write, +{ + execute_from_until_signal(cli, args, stdout, stderr, shutdown_signal()).await +} + +/// Executes the CLI until either command completion or a shutdown signal future resolves. +pub(super) async fn execute_from_until_signal( + cli: &Cli, + args: I, + stdout: &mut O, + stderr: &mut E, + shutdown: Shutdown, +) -> std::io::Result +where + I: IntoIterator, + S: Into + Clone, + O: Write, + E: Write, + Shutdown: Future, +{ + cli.install_default_user_agent(); + let output = run_until_signal(cli.run(args), shutdown).await; + if output.exit_code == 130 + && output.rendered == "command interrupted\n" + && let Some(on_shutdown) = &cli.on_shutdown + { + on_shutdown(); + } + if output.exit_code == 0 { + stdout.write_all(output.rendered.as_bytes())?; + } else { + stderr.write_all(output.rendered.as_bytes())?; + } + Ok(process_exit_code(output.exit_code)) +} + +/// Runs the CLI like [`Cli::run`](super::Cli::run), threading the `argv0` dispatch recursion +/// `depth` so a chain of personality hand-offs is bounded by [`MAX_ARGV0_DEPTH`](super::argv0::MAX_ARGV0_DEPTH). +pub(super) async fn run_with_depth(cli: &Cli, args: I, depth: usize) -> CliRunOutput +where + I: IntoIterator, + S: Into + Clone, +{ + let raw_args = args + .into_iter() + .map(Into::into) + .collect::>(); + let text_args = raw_args + .iter() + .map(|arg| arg.to_string_lossy().into_owned()) + .collect::>(); + let text_args = match super::argv0::resolve_argv0(cli, text_args, depth).await { + Argv0Outcome::Handled(output) => return output, + Argv0Outcome::Proceed(args) => args, + }; + let mut clap_args = normalize_optional_global_flags_before_command(&cli.root, &text_args); + if has_root_version_flag(&text_args, &cli.root, &cli.config.name) { + return finish_run( + cli, + CliRunOutput { + exit_code: 0, + rendered: format!( + "{} version {}\n", + cli.config.name, + cli.config.build.version_string() + ), + }, + ); + } + if let Some(output) = try_run_schema_bypass(cli, &text_args) { + return output; + } + // Resolve the positional command path once and share it between the + // group-help rewrite and the unknown-command check below. + let bool_flags = derive_bool_flags(&cli.root); + let value_flags = derive_value_flags(&cli.root); + let positionals = + positional_command_tokens(&text_args, &cli.config.name, &bool_flags, &value_flags); + let command_keyword_count = + command_keyword_count(&text_args, &cli.config.name, &bool_flags, &value_flags); + if let Some(parts) = group_help_target_parts(&cli.root, &positionals, command_keyword_count) { + // Rewrite ` help [sub...]` into the canonical + // `help [sub...]` so it flows through the curated root + // `help` command, which also runs global-flag parsing and the + // `pre_run` hook (matching `help ` and bare-group help). + // Only the positional command tokens are reordered; every flag and + // its value is preserved in place so e.g. `--output json` survives. + clap_args = rewrite_group_help_args( + &clap_args, + &cli.config.name, + &bool_flags, + &value_flags, + &parts, + ); + } else if let Some(unknown) = + detect_unknown_group_command(&cli.root, &positionals[..command_keyword_count]) + { + // Hint/re-dispatch only when the whole path resolves to one command. + if let Some(corrections) = + full_command_correction(&cli.root, &positionals[..command_keyword_count]) + { + let display = super::correction::correction_display( + &cli.config.name, + &positionals[..command_keyword_count], + &corrections, + ); + let full_fix_message = format_did_you_mean(&unknown.base, &display); + match crate::prompt::confirm_command_correction( + &clap_args, + &display, + cli.config.auto_interactive, + ) { + crate::prompt::CommandCorrection::Accepted => { + for (index, replacement) in &corrections { + clap_args = super::correction::replace_positional_command_token( + &clap_args, + &cli.config.name, + &bool_flags, + &value_flags, + *index, + replacement, + ); + } + clap_args = rewrite_group_help_if_needed( + &cli.root, + &clap_args, + &cli.config.name, + &bool_flags, + &value_flags, + ); + } + crate::prompt::CommandCorrection::Declined => { + return finish_run( + cli, + CliRunOutput { + exit_code: 1, + rendered: full_fix_message, + }, + ); + } + crate::prompt::CommandCorrection::Cancelled => { + return finish_run( + cli, + CliRunOutput { + exit_code: 130, + rendered: "Cancelled.".to_owned(), + }, + ); + } + } + } else { + return finish_run( + cli, + CliRunOutput { + exit_code: 1, + rendered: unknown.base, + }, + ); + } + } + + let matches = match cli.root.clone().try_get_matches_from(&clap_args) { + Ok(matches) => matches, + Err(err) => { + // Attempt interactive recovery for missing required arguments. + if let Some(recovery) = crate::prompt::try_recover_missing_args( + &err, + &clap_args, + &cli.root, + &cli.config.name, + cli.config.auto_interactive, + ) { + match recovery { + crate::prompt::RecoveryResult::Recovered { args } => { + match cli.root.clone().try_get_matches_from(args) { + Ok(m) => m, + Err(retry_err) => { + return finish_run( + cli, + CliRunOutput { + exit_code: retry_err.exit_code(), + rendered: retry_err.to_string(), + }, + ); + } + } + } + crate::prompt::RecoveryResult::Cancelled { resume } => { + return finish_run( + cli, + CliRunOutput { + exit_code: 130, + rendered: format!("Cancelled. Resume with:\n {resume}\n"), + }, + ); + } + } + } else { + return finish_run( + cli, + CliRunOutput { + exit_code: err.exit_code(), + rendered: err.to_string(), + }, + ); + } + } + }; + + let default_format = resolve_run_output_format(cli); + let flags = global_flags_from_matches(&matches, &default_format, cli.config.auto_interactive); + // Publish the --credential-store override so auth providers resolving + // their storage backend see it at the top of the precedence chain. + crate::config::set_credential_store_flag(flags.credential_store); + let command_timeout = match parse_command_timeout(&flags.timeout) { + Ok(timeout) => timeout, + Err(err) => { + return finish_run( + cli, + render_cli_error(&cli.middleware, &err, &cli.config.app_id), + ); + } + }; + let mut middleware = cli.middleware.clone(); + apply_global_flags(&mut middleware, &flags, command_timeout); + install_debug_transport_logger(&flags.debug, &cli.config.redacted_debug_headers); + if let Err(err) = apply_config_flags(cli, &matches, &mut middleware) { + return finish_run(cli, render_cli_error(&middleware, &err, &cli.config.app_id)); + } + // Validate and apply `--env` for built-in paths (help/tree/guide/group + // help) so they reflect the selected environment and reject unknowns. + if let Err(err) = apply_env_flag(&matches, &mut middleware) { + return finish_run(cli, render_cli_error(&middleware, &err, &cli.config.app_id)); + } + + let command_path = command_path_from_matches(&cli.config.name, &matches); + if command_path == "help" { + if let Err(err) = run_pre_run(cli, &mut middleware, &command_path, &help_args(&matches)) { + return finish_run(cli, render_cli_error(&middleware, &err, &cli.config.app_id)); + } + return finish_run(cli, render_help_command(cli, &matches)); + } + if command_path == "tree" { + if let Err(err) = run_pre_run( + cli, + &mut middleware, + &command_path, + &crate::middleware::ValueMap::new(), + ) { + return finish_run(cli, render_cli_error(&middleware, &err, &cli.config.app_id)); + } + return finish_run( + cli, + tree_render::render_tree(&cli.root, &cli.config.app_id, &middleware), + ); + } + if command_path == "guide" { + if let Err(err) = run_pre_run(cli, &mut middleware, &command_path, &guide_args(&matches)) { + return finish_run(cli, render_cli_error(&middleware, &err, &cli.config.app_id)); + } + return finish_run(cli, render_guide(cli, &matches, &flags.output_format)); + } + if command_path == "search" { + let args = search_args(&matches); + if let Err(err) = run_pre_run(cli, &mut middleware, &command_path, &args) { + return finish_run(cli, render_cli_error(&middleware, &err, &cli.config.app_id)); + } + let query = args + .get("query") + .and_then(|v| v.as_str()) + .unwrap_or_default(); + let scope_path = args + .get("scope") + .and_then(|v| v.as_str()) + .unwrap_or_default(); + let scope = super::lookup::resolve_search_scope(cli, scope_path); + return finish_run(cli, render_search(cli, query, &scope, &flags.output_format)); + } + if command_path == "completion" { + let args = completion_args(&matches); + if let Err(err) = run_pre_run(cli, &mut middleware, &command_path, &args) { + return finish_run(cli, render_cli_error(&middleware, &err, &cli.config.app_id)); + } + let install = args + .get("install") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let shell_opt = args + .get("shell") + .and_then(|v| v.as_str()) + .map(str::to_owned); + if install { + use crate::cli::completion::{detect_shell, parse_shell}; + let shell = match shell_opt { + Some(ref s) => match parse_shell(s) { + Ok(s) => s, + Err(e) => { + return finish_run( + cli, + render_cli_error(&middleware, &e, &cli.config.app_id), + ); + } + }, + None => match detect_shell() { + Ok(s) => s, + Err(e) => { + return finish_run( + cli, + render_cli_error(&middleware, &e, &cli.config.app_id), + ); + } + }, + }; + return finish_run( + cli, + crate::cli::completion::install(&cli.root, &cli.config.name, shell) + .await + .unwrap_or_else(|e| render_cli_error(&middleware, &e, &cli.config.app_id)), + ); + } + return finish_run(cli, render_completion_print(cli, shell_opt, &middleware)); + } + let Some(command) = cli.commands.get(&command_path) else { + if !command_path.is_empty() + && let Some(group) = find_command_by_colon_path(&cli.root, &command_path) + && group.get_subcommands().next().is_some() + { + if let Err(err) = run_pre_run( + cli, + &mut middleware, + &command_path, + &crate::middleware::ValueMap::new(), + ) { + return finish_run(cli, render_cli_error(&middleware, &err, &cli.config.app_id)); + } + if middleware.interactive + && let Some(subcommand) = single_leaf_subcommand(group) + { + let augmented = inject_subcommand_after_command_path( + &text_args, + &cli.config.name, + &command_path, + &subcommand, + &bool_flags, + &value_flags, + ); + return Box::pin(run_with_depth(cli, augmented, depth + 1)).await; + } + return finish_run( + cli, + render_bare_group_discovery(cli, group, &command_path, &middleware), + ); + } + if command_path.is_empty() + && let Some(root_next_actions) = &cli.root_next_actions + { + // Bare-root discovery is static (help text / metadata + action + // pointers) and must always be available as a cold-start entry + // point, so we skip `pre_run` here — matching the no-hook + // bare-root path below, which also renders help without it. + let actions = root_next_actions(); + return finish_run(cli, render_root(cli, &middleware, actions)); + } + return finish_run( + cli, + CliRunOutput { + exit_code: if command_path.is_empty() { 0 } else { 1 }, + rendered: if command_path.is_empty() { + cli.root.clone().render_long_help().to_string() + } else { + format!("unknown command {command_path:?}") + }, + }, + ); + }; + + let mut middleware = match initialized_middleware(cli) { + Ok(middleware) => middleware, + Err(err) => { + return finish_run(cli, render_cli_error(&middleware, &err, &cli.config.app_id)); + } + }; + apply_global_flags(&mut middleware, &flags, command_timeout); + install_debug_transport_logger(&flags.debug, &cli.config.redacted_debug_headers); + if let Err(err) = apply_config_flags(cli, &matches, &mut middleware) { + return finish_run(cli, render_cli_error(&middleware, &err, &cli.config.app_id)); + } + // The global `--env` flag overrides the seeded active environment for + // this invocation; an unknown name surfaces as an error envelope. + if let Err(err) = apply_env_flag(&matches, &mut middleware) { + return finish_run(cli, render_cli_error(&middleware, &err, &cli.config.app_id)); + } + + let leaf = leaf_matches(&matches); + apply_pagination_flags(&mut middleware, &command.spec, leaf); + let args = command_args_from_matches(leaf, &command.spec, false); + let user_args = command_args_from_matches(leaf, &command.spec, true); + let pagination_command = command.spec.pagination.is_some().then(|| { + pagination_command_base( + &cli.config.name, + &command_path, + &command.spec, + &user_args, + &flags, + ) + }); + if let Err(err) = run_pre_run(cli, &mut middleware, &command_path, &args) { + return finish_run(cli, render_cli_error(&middleware, &err, &cli.config.app_id)); + } + let meta = resolve_meta(cli, &command_path, command.spec.metadata()); + let default_fields = command.spec.default_fields.clone().unwrap_or_default(); + let system = command.spec.system.clone().unwrap_or_default(); + // The human view this command declared: an explicit shared id wins; + // otherwise an inline `with_view` was registered under the command path + // at build time, so reference it by that path. `None` renders generic + // human output. + let view_id = command + .spec + .view_id + .clone() + .or_else(|| (!command.spec.view_columns.is_empty()).then(|| command_path.clone())); + + if let Some(streaming_handler) = command.streaming_handler.clone() { + let result = run_with_timeout( + command_timeout, + &flags.timeout, + run_streaming_command( + &middleware, + MiddlewareRequest { + meta, + command_path: &command_path, + system: &system, + user_args, + args, + default_fields: &default_fields, + view_id: view_id.as_deref(), + auth: command.spec.auth, + raw_output: command.spec.raw_output, + pagination_command, + }, + Arc::new(leaf.clone()), + streaming_handler, + ), + ) + .await; + return finish_run( + cli, + match result { + Ok(output) => output, + Err(err) => render_cli_error(&middleware, &err, &cli.config.app_id), + }, + ); + } + + let handler = command.handler.clone(); + let args_for_handler = args.clone(); + let user_args_for_handler = user_args.clone(); + let handler_path = command_path.clone(); + let middleware_for_handler = middleware.clone(); + let raw_matches_for_handler = Arc::new(leaf.clone()); + let result = run_with_timeout( + command_timeout, + &flags.timeout, + middleware.run( + MiddlewareRequest { + meta, + command_path: &command_path, + system: &system, + user_args, + args, + default_fields: &default_fields, + view_id: view_id.as_deref(), + auth: command.spec.auth, + raw_output: command.spec.raw_output, + pagination_command, + }, + async move |credential| { + handler(CommandContext { + credential, + args: args_for_handler, + user_args: user_args_for_handler, + command_path: handler_path, + middleware: middleware_for_handler, + raw_matches: raw_matches_for_handler, + }) + .await + }, + ), + ) + .await; + + match result { + Ok(output) => finish_run(cli, output.into()), + Err(err) => finish_run(cli, render_cli_error(&middleware, &err, &cli.config.app_id)), + } +} + +pub(super) fn try_run_schema_bypass(cli: &Cli, args: &[String]) -> Option { + if !has_true_schema_flag(args) { + return None; + } + let bool_flags = derive_bool_flags(&cli.root); + let value_flags = derive_value_flags(&cli.root); + let command_path = super::lookup::canonical_command_path( + cli, + &extract_command_path(args, &bool_flags, &value_flags), + ); + // `--schema` is an inspection flag and must not require the command's own + // arguments, so it short-circuits before clap validates them. Only fire + // for a real leaf command, though: unknown paths and groups fall through + // so clap and `detect_unknown_group_command` can report them as usual. + let command = find_command_by_colon_path(&cli.root, &command_path)?; + if command.get_subcommands().next().is_some() { + return None; + } + let output_format = extract_output_format(args, &resolve_run_output_format(cli)); + // When no schema is registered, report that rather than running the + // command — matching the middleware's no-schema response so the public + // path and the lower layer agree even when required args are missing. + match cli.middleware.schema_registry.get_by_path(&command_path) { + Some(schema) => Some(render_schema(cli, schema, &output_format)), + None => Some(render_schema( + cli, + crate::output::no_schema_response(&command_path), + &output_format, + )), + } +} + +/// Computes the default output format for this run — the fallback used +/// when no explicit `--output`/`--json`/`--human`/`--toon` is given. +pub(super) fn resolve_run_output_format(cli: &Cli) -> String { + use std::io::IsTerminal; + + let env = std::env::var(output_env_var(&cli.config.app_id)).ok(); + let engine_config = cli.middleware.config.engine(); + resolve_default_output_format( + env.as_deref(), + engine_config.output.format.as_deref(), + std::io::stdout().is_terminal(), + ) +} + +fn initialized_middleware(cli: &Cli) -> Result { + let Some(init_deps) = &cli.init_deps else { + return Ok(cli.middleware.clone()); + }; + let mut guard = cli + .init_state + .lock() + .map_err(|_| CliCoreError::message("init deps lock poisoned"))?; + if let Some(result) = guard.as_ref() { + return result.clone().map_err(InitFailure::into_error); + } + let mut middleware = cli.middleware.clone(); + let result = init_deps(&mut middleware) + .map(|()| middleware) + .map_err(|err| InitFailure::capture(&err)); + *guard = Some(result.clone()); + result.map_err(InitFailure::into_error) +} + +fn apply_config_flags( + cli: &Cli, + matches: &ArgMatches, + middleware: &mut crate::Middleware, +) -> Result<()> { + if let Some(apply_flags) = &cli.apply_flags { + apply_flags(matches, middleware)?; + } + Ok(()) +} + +/// Applies the global `--env` override to a per-run middleware snapshot. +/// +/// The flag is only registered when environments are configured, so when it +/// is present `middleware.environments` is set too. Validates the requested +/// name against the registered environments and updates `middleware.env`, +/// returning an error for an unknown environment. +fn apply_env_flag(matches: &ArgMatches, middleware: &mut crate::Middleware) -> Result<()> { + // Guard on the environment system FIRST. The `--env` arg is only + // registered when environments are configured (the same condition that + // sets `middleware.environments`); calling `matches.get_one("env")` for + // an arg that was never registered panics in clap, which would break + // every CLI that does not use environments. + let Some(environments) = middleware.environments.as_ref() else { + return Ok(()); + }; + if let Some(env) = matches.get_one::("env") { + environments.source(env)?; + middleware.env = env.clone(); + } + Ok(()) +} + +fn run_pre_run( + cli: &Cli, + middleware: &mut crate::Middleware, + command_path: &str, + args: &crate::middleware::ValueMap, +) -> Result<()> { + if let Some(pre_run) = &cli.pre_run { + pre_run(middleware, command_path, args)?; + } + Ok(()) +} + +fn resolve_meta(cli: &Cli, command_path: &str, meta: crate::CommandMeta) -> crate::CommandMeta { + if let Some(resolver) = &cli.meta_resolver { + resolver(command_path, meta) + } else { + meta + } +} + +pub(super) fn finish_run(cli: &Cli, output: CliRunOutput) -> CliRunOutput { + // Clear the per-thread credential-store flag so it does not leak into + // subsequent sequential runs on the same thread. + crate::config::clear_credential_store_flag(); + if let Some(on_shutdown) = &cli.on_shutdown { + on_shutdown(); + } + output +} + +async fn run_with_timeout( + timeout: Option, + timeout_label: &str, + future: F, +) -> Result +where + F: Future>, +{ + let Some(timeout) = timeout else { + return future.await; + }; + match tokio::time::timeout(timeout, future).await { + Ok(result) => result, + Err(_) => Err(CliCoreError::message(format!( + "command timed out after {timeout_label}" + ))), + } +} + +async fn run_until_signal(run: Run, shutdown: Shutdown) -> CliRunOutput +where + Run: Future, + Shutdown: Future, +{ + tokio::pin!(run); + tokio::pin!(shutdown); + tokio::select! { + output = &mut run => output, + () = &mut shutdown => CliRunOutput { + exit_code: 130, + rendered: "command interrupted\n".to_owned(), + }, + } +} + +#[cfg(unix)] +pub(super) async fn shutdown_signal() { + let ctrl_c = tokio::signal::ctrl_c(); + match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) { + Ok(mut sigterm) => { + tokio::select! { + _ = ctrl_c => {}, + _ = sigterm.recv() => {}, + } + } + Err(_) => { + drop(ctrl_c.await); + } + } +} + +#[cfg(not(unix))] +pub(super) async fn shutdown_signal() { + drop(tokio::signal::ctrl_c().await); +} + +fn process_exit_code(code: i32) -> ExitCode { + if code == 0 { + return ExitCode::SUCCESS; + } + match u8::try_from(code) { + Ok(code) if code != 0 => ExitCode::from(code), + Ok(_) | Err(_) => ExitCode::from(1), + } +} + +async fn run_streaming_command( + middleware: &crate::Middleware, + request: MiddlewareRequest<'_>, + raw_matches: Arc, + streaming_handler: crate::command::StreamingCommandHandler, +) -> Result { + use tokio::{io::AsyncWriteExt, sync::mpsc}; + + let args_for_handler = request.args.clone(); + let user_args_for_handler = request.user_args.clone(); + let handler_path = request.command_path.to_owned(); + let middleware_for_handler = middleware.clone(); + let raw_matches_for_handler = raw_matches; + + let (tx, mut rx) = mpsc::channel::(64); + let sender = StreamSender(tx); + + // Drain the channel concurrently so the handler's sends don't stall + // while the writer flushes to stdout. If stdout is under backpressure + // the bounded channel can still fill and the handler will await send. + let writer = tokio::spawn(async move { + let mut stdout = tokio::io::stdout(); + while let Some(event) = rx.recv().await { + let Ok(line) = serde_json::to_string(&event) else { + continue; + }; + if stdout.write_all(line.as_bytes()).await.is_err() + || stdout.write_all(b"\n").await.is_err() + || stdout.flush().await.is_err() + { + break; + } + } + }); + + let output = middleware + .run(request, async move |credential| { + streaming_handler( + CommandContext { + credential, + args: args_for_handler, + user_args: user_args_for_handler, + command_path: handler_path, + middleware: middleware_for_handler, + raw_matches: raw_matches_for_handler, + }, + sender, + ) + .await?; + Ok(crate::CommandResult::new(serde_json::Value::Null)) + }) + .await; + + // Handler has completed; its sender is dropped, which closes the channel. + // Wait for the writer task to flush all remaining events. + let _write_result = writer.await; + + match output { + Ok(out) if out.exit_code == 0 => Ok(CliRunOutput { + exit_code: 0, + rendered: String::new(), + }), + Ok(out) => Ok(out.into()), + Err(err) => Ok(CliRunOutput { + exit_code: exit_code_for_error(&err), + rendered: render_cli_error(middleware, &err, middleware.app_id.as_str()).rendered, + }), + } +} diff --git a/cli-engine/src/cli/schema_tree.rs b/cli-engine/src/cli/schema_tree.rs new file mode 100644 index 0000000..5335c78 --- /dev/null +++ b/cli-engine/src/cli/schema_tree.rs @@ -0,0 +1,886 @@ +//! Feature-flag tree pruning and building the `clap` command tree with +//! schema-derived help text (`--fields`/`--filter`/`--expr` examples, +//! `--dry-run`/`--output` visibility, pagination args). + +use clap::{Arg, Command}; + +use super::help::GROUP_HELP_TEMPLATE; +use crate::{ + CommandSpec, FeatureFlag, GroupSpec, RuntimeGroupSpec, + feature_flags::{FlagEntry, FlagPolicy, FlagRegistry}, + output::{FieldInfo, HumanViewDef, HumanViewRegistry, SchemaRegistry, format_help_section}, +}; + +/// Walks a runtime group tree, resolving each node's effective feature flag by +/// cascading from `inherited` — a node's own [`GroupSpec::feature_flag`] or +/// [`CommandSpec::feature_flag`] wins if set, otherwise it inherits the +/// nearest ancestor's effective flag, otherwise (nothing in the ancestor +/// chain declared a flag) it implicitly resolves to [`Stage::Ga`] with no key. +/// Every node that resolves to a *named* flag (own or inherited) is recorded +/// into `registry` under its colon-separated path, together with whether +/// `policy` judged it visible. Nodes that resolve to the implicit no-flag +/// default are not recorded (there is nothing to introspect) and are always +/// visible. +/// +/// Returns `None` when this group itself should be dropped from the tree — +/// either because its effective flag is not visible under `policy`, or +/// because every one of its commands and subgroups was pruned away, leaving +/// an empty group with nothing to mount. An emptied-out group is dropped +/// unconditionally, even if its own flag was visible: a `clap` subcommand +/// group with zero children is useless either way, so this simplifies the +/// pruning logic rather than threading through a "was this group itself +/// visible but empty" distinction that no caller needs. +/// +/// Note that an invisible ancestor short-circuits before its children are +/// even visited: a more permissive flag on a descendant cannot resurrect a +/// subtree whose enclosing group already failed the visibility check. +pub(super) fn prune_feature_flag_tree( + mut group: RuntimeGroupSpec, + inherited: Option<&FeatureFlag>, + policy: &FlagPolicy, + prefix: &mut Vec, + registry: &mut FlagRegistry, +) -> Option { + prefix.push(group.group.name.clone()); + + let effective = group + .group + .feature_flag + .clone() + .or_else(|| inherited.cloned()); + if !record_and_check_visibility(effective.as_ref(), policy, prefix, registry) { + prefix.pop(); + return None; + } + + let mut kept_groups = Vec::with_capacity(group.groups.len()); + for child in std::mem::take(&mut group.groups) { + if let Some(pruned) = + prune_feature_flag_tree(child, effective.as_ref(), policy, prefix, registry) + { + kept_groups.push(pruned); + } + } + group.groups = kept_groups; + + let mut kept_commands = Vec::with_capacity(group.commands.len()); + for command in std::mem::take(&mut group.commands) { + prefix.push(command.spec.name.clone()); + let command_effective = command + .spec + .feature_flag + .clone() + .or_else(|| effective.clone()); + let visible = + record_and_check_visibility(command_effective.as_ref(), policy, prefix, registry); + prefix.pop(); + if visible { + kept_commands.push(command); + } + } + group.commands = kept_commands; + + prefix.pop(); + + if group.commands.is_empty() && group.groups.is_empty() { + None + } else { + Some(group) + } +} + +/// Records `effective` at the current `prefix` path into `registry` (only +/// when it names a flag key — the implicit Ga default is not recorded) and +/// returns whether the node is visible under `policy`. +fn record_and_check_visibility( + effective: Option<&FeatureFlag>, + policy: &FlagPolicy, + prefix: &[String], + registry: &mut FlagRegistry, +) -> bool { + let Some(flag) = effective else { + return true; + }; + let visible = policy.visible(Some(flag.key.as_str()), flag.stage); + registry.record(FlagEntry { + path: prefix.join(":"), + key: flag.key.clone(), + stage: flag.stage, + visible, + }); + visible +} + +pub(super) fn register_runtime_group_metadata( + group: &RuntimeGroupSpec, + prefix: &mut Vec, + schemas: &mut SchemaRegistry, + views: &mut HumanViewRegistry, +) { + prefix.push(group.group.name.clone()); + for child_group in &group.groups { + register_runtime_group_metadata(child_group, prefix, schemas, views); + } + for child in &group.commands { + prefix.push(child.spec.name.clone()); + let command_path = prefix.join(":"); + register_command_schema(&child.spec, &command_path, schemas); + // An inline `with_view` is registered under the command's own path; the + // dispatch references it by that path. A `with_view_id` takes precedence + // (dispatch uses it instead), so skip the inline registration when one is + // set — registering it would leave an unused entry. Shared views are + // registered separately by the module/CLI. + if child.spec.view_id.is_none() && !child.spec.view_columns.is_empty() { + views.register(HumanViewDef::new( + command_path, + child.spec.view_columns.clone(), + )); + } + prefix.pop(); + } + prefix.pop(); +} + +pub(super) fn register_command_schema( + spec: &CommandSpec, + command_path: &str, + schemas: &mut SchemaRegistry, +) { + if let Some(schema) = &spec.output_schema { + schemas.register_info(command_path.to_owned(), schema.clone()); + } +} + +pub(super) fn runtime_group_clap_command_with_schema_help( + group: &RuntimeGroupSpec, + prefix: &mut Vec, + schemas: &SchemaRegistry, +) -> Command { + let mut command = group_clap_command_without_children(&group.group); + prefix.push(group.group.name.clone()); + for child_group in &group.groups { + command = command.subcommand(runtime_group_clap_command_with_schema_help( + child_group, + prefix, + schemas, + )); + } + for child in &group.commands { + prefix.push(child.spec.name.clone()); + let command_path = prefix.join(":"); + command = command.subcommand(command_clap_command_with_schema_help( + &child.spec, + &command_path, + schemas, + )); + prefix.pop(); + } + prefix.pop(); + command +} + +fn group_clap_command_without_children(group: &GroupSpec) -> Command { + let mut command = Command::new(group.name.clone()) + .about(group.short.clone()) + .help_template(GROUP_HELP_TEMPLATE); + if let Some(long) = &group.long + && !long.is_empty() + { + command = command.long_about(long.clone()); + } + for alias in &group.aliases { + command = command.alias(alias.clone()); + } + if group.hidden { + command = command.hide(true); + } + command +} + +pub(super) fn command_clap_command_with_schema_help( + spec: &CommandSpec, + command_path: &str, + schemas: &SchemaRegistry, +) -> Command { + debug_assert!( + !(spec.raw_output && spec.pagination.is_some()), + "command {:?} sets both raw_output and with_pagination; a single verbatim string \ + has no pages, so the two are mutually exclusive", + spec.name + ); + let mut command = spec.clap_command(); + command = apply_dry_run_visibility(command, spec); + command = apply_pagination_args(command, spec); + let schema = schemas.get_by_path(command_path); + let default_fields = default_field_names(spec); + command = apply_fields_arg( + command, + spec, + schema.as_ref().map(|schema| schema.fields.as_slice()), + &default_fields, + ); + command = apply_output_format_visibility(command, spec); + let filter_expr_fields = schema + .as_ref() + .map_or(&[][..], |schema| schema.fields.as_slice()); + apply_filter_and_expr_examples(command, spec, filter_expr_fields) +} + +/// Hides this command's inherited `--output` flag when it opted into +/// [`CommandSpec::raw_output`]. +fn apply_output_format_visibility(command: Command, spec: &CommandSpec) -> Command { + if !spec.raw_output { + return command; + } + use std::io::IsTerminal; + command.arg( + Arg::new("output") + .long("output") + .short('o') + .value_name("FORMAT") + .default_value(if std::io::stdout().is_terminal() { + "human" + } else { + "json" + }) + .conflicts_with_all(["json", "toon", "human"]) + .display_order(crate::flags::global_flag_order::OUTPUT) + .hide(true) + .help("Ignored — this command always prints raw text"), + ) +} + +/// Hides this command's inherited `--dry-run` flag when the command isn't +/// mutating (per [`CommandSpec::metadata`]'s `dry_run_prompt` — mirrored +/// here rather than reused, since that method returns the broader +/// [`CommandMeta`], not this one bool). `--dry-run` only ever does anything +/// for a command that opted in via `.mutates(true)`/`.with_tier(...)` (see +/// `Middleware::render_envelope`'s `meta.dry_run_prompt` gate), so showing +/// it on every other command is noise. The override still parses `--dry-run` +/// identically (same value parser, same defaults) in case a caller passes +/// it anyway — hidden only changes what `--help` shows, never behavior. +fn apply_dry_run_visibility(command: Command, spec: &CommandSpec) -> Command { + let mutates = spec.mutates || spec.tier.is_some_and(crate::Tier::is_mutating); + if mutates { + return command; + } + command.arg( + Arg::new("dry-run") + .long("dry-run") + .num_args(0..=1) + .require_equals(true) + .default_missing_value("true") + .default_value("false") + .value_parser(crate::flags::compat_bool_value_parser()) + .display_order(crate::flags::global_flag_order::DRY_RUN) + .hide(true) + .help("Preview mutations without executing"), + ) +} + +/// Registers `--limit`/`--offset` on this command's own `Command` when its +/// spec opted in via [`CommandSpec::with_pagination`], and leaves the command +/// untouched otherwise so a non-paginating command never sees those flags — +/// in `--help` or on its command line. See [`flags::apply_pagination_args`]. +fn apply_pagination_args(command: Command, spec: &CommandSpec) -> Command { + let Some(pagination) = spec.pagination else { + return command; + }; + crate::flags::apply_pagination_args(command, pagination.default_limit, pagination.max_limit) +} + +/// Splits a command's raw `default_fields` string into individual field +/// names, dropping the `all`/`*` sentinels that mean "every field" rather +/// than naming a real field. +fn default_field_names(spec: &CommandSpec) -> Vec<&str> { + spec.default_fields + .as_deref() + .map(|fields| { + fields + .split(',') + .map(str::trim) + .filter(|field| !field.is_empty() && *field != "all" && *field != "*") + .collect() + }) + .unwrap_or_default() +} + +/// Overrides this command's `--fields` flag with everything specific to this +/// command: its own `default_fields` as a native clap default value (so +/// `--help` shows `[default: ...]` on the flag itself, the same way +/// `--dry-run` shows `[default: false]`), and, when a schema is registered, +/// the output-field summary table appended to the flag's own help text +/// instead of the command's description — a long field table there used to +/// push `Usage:` far down the page. Global args apply to every subcommand, +/// but a subcommand-local arg of the same name takes precedence, so this +/// only affects the one command being built here. +fn apply_fields_arg( + command: Command, + spec: &CommandSpec, + schema_fields: Option<&[FieldInfo]>, + default_fields: &[&str], +) -> Command { + if spec.raw_output { + return command.arg( + Arg::new("fields") + .long("fields") + .value_name("FIELDS") + .display_order(crate::flags::global_flag_order::FIELDS) + .hide(true) + .help("Ignored — this command always prints raw text"), + ); + } + let default_value = spec + .default_fields + .as_deref() + .filter(|fields| !fields.is_empty()); + let table = schema_fields + .filter(|fields| !fields.is_empty()) + .map(|fields| format_help_section(fields, default_fields)); + if default_value.is_none() && table.is_none() { + return command; + } + + let mut help = String::from( + "Comma-separated fields to include in output (use 'all' or '*' for everything)", + ); + if let Some(table) = &table { + help.push_str("\n\n"); + help.push_str(table.trim_end()); + } + + let mut arg = Arg::new("fields") + .long("fields") + .value_name("FIELDS") + // Must match `global_flag_order::FIELDS` — this re-registers the + // same flag with contextual help, not a new one, and needs to keep + // its place among the other global flags rather than falling back + // to this subcommand's own low, command-specific counter value. + .display_order(crate::flags::global_flag_order::FIELDS) + .help(help); + if let Some(default_value) = default_value { + arg = arg.default_value(default_value.to_owned()); + } + command.arg(arg) +} + +/// Overrides this command's `--filter` and `--expr` flags with help text +/// carrying usage examples built from its own output fields, so `--help` +/// shows them right under the flag instead of in a separate "Filter +/// examples:"/"Expr examples:" section disconnected from the flags they +/// demonstrate. Mirrors [`apply_fields_arg`]: a subcommand-local arg of the +/// same name shadows the framework's global one, and must carry the same +/// `global_flag_order` value as that global one for the same reason. +fn apply_filter_and_expr_examples( + mut command: Command, + spec: &CommandSpec, + fields: &[FieldInfo], +) -> Command { + if spec.raw_output { + return command + .arg( + Arg::new("filter") + .long("filter") + .value_name("EXPR") + .display_order(crate::flags::global_flag_order::FILTER) + .hide(true) + .help("Ignored — this command always prints raw text"), + ) + .arg( + Arg::new("expr") + .long("expr") + .value_name("EXPR") + .display_order(crate::flags::global_flag_order::EXPR) + .hide(true) + .help("Ignored — this command always prints raw text"), + ); + } + if fields.is_empty() { + return command; + } + let first_string = fields + .iter() + .find(|field| field.field_type == "string") + .map(|field| field.name.as_str()); + let first_bool = fields + .iter() + .find(|field| field.field_type == "bool") + .map(|field| field.name.as_str()); + + if first_string.is_some() || first_bool.is_some() { + let mut help = String::from("Per-item JMESPath predicate for list data"); + if let Some(name) = first_string { + help.push_str(&format!("\ne.g. --filter \"contains({name}, 'example')\"")); + } + if let Some(name) = first_bool { + help.push_str(&format!("\ne.g. --filter '{name}'")); + } + command = command.arg( + Arg::new("filter") + .long("filter") + .value_name("EXPR") + .display_order(crate::flags::global_flag_order::FILTER) + .help(help), + ); + } + + let mut expr_help = String::from("JMESPath query applied to the whole result"); + expr_help.push_str("\ne.g. --expr 'length(@)'"); + if let Some(name) = first_string { + expr_help.push_str(&format!("\ne.g. --expr '[].{name}'")); + } + command.arg( + Arg::new("expr") + .long("expr") + .value_name("EXPR") + .display_order(crate::flags::global_flag_order::EXPR) + .help(expr_help), + ) +} + +#[cfg(test)] +mod feature_flag_pruning_tests { + use super::*; + use crate::{ + CommandResult, GroupSpec, Module, RuntimeCommandSpec, RuntimeGroupSpec, + cli::{Cli, CliConfig, flags_apply::global_min_stage_override, lookup::has_subcommand}, + feature_flags::Stage, + }; + + fn trivial_command(name: &str) -> RuntimeCommandSpec { + RuntimeCommandSpec::new( + CommandSpec::new(name, "short").no_auth(true), + async |_, _| Ok(CommandResult::new(serde_json::Value::Null)), + ) + } + + fn flagged_command(name: &str, key: &str, stage: Stage) -> RuntimeCommandSpec { + let mut command = trivial_command(name); + command.spec = command.spec.with_feature_flag(key, stage); + command + } + + fn empty_policy() -> FlagPolicy { + FlagPolicy::default() + } + + #[test] + fn no_flags_anywhere_keeps_everything() { + let group = RuntimeGroupSpec::new(GroupSpec::new("root", "short")) + .with_command(trivial_command("a")) + .with_command(trivial_command("b")) + .with_group( + RuntimeGroupSpec::new(GroupSpec::new("child", "short")) + .with_command(trivial_command("c")), + ); + + let mut prefix = Vec::new(); + let mut registry = FlagRegistry::new(); + let pruned = + prune_feature_flag_tree(group, None, &empty_policy(), &mut prefix, &mut registry); + + let pruned = pruned.expect("unflagged tree should never be dropped"); + assert_eq!(pruned.commands.len(), 2); + assert_eq!(pruned.groups.len(), 1); + assert_eq!(pruned.groups[0].commands.len(), 1); + assert!(registry.entries().is_empty()); + } + + #[test] + fn experimental_command_is_pruned_sibling_is_not() { + let group = RuntimeGroupSpec::new(GroupSpec::new("root", "short")) + .with_command(flagged_command("gated", "gated-flag", Stage::Experimental)) + .with_command(trivial_command("sibling")); + + let mut prefix = Vec::new(); + let mut registry = FlagRegistry::new(); + let pruned = + prune_feature_flag_tree(group, None, &empty_policy(), &mut prefix, &mut registry) + .expect("group still has a visible command left"); + + assert_eq!(pruned.commands.len(), 1); + assert_eq!(pruned.commands[0].spec.name, "sibling"); + + let entries = registry.entries(); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].path, "root:gated"); + assert_eq!(entries[0].key, "gated-flag"); + assert!(!entries[0].visible); + } + + #[test] + fn beta_group_pruned_under_ga_min_stage_kept_under_beta_min_stage() { + let build_tree = || { + RuntimeGroupSpec::new(GroupSpec::new("root", "short")) + .with_command(trivial_command("keep-me")) + .with_group( + RuntimeGroupSpec::new( + GroupSpec::new("flagged-group", "short") + .with_feature_flag("group-flag", Stage::Beta), + ) + .with_command(trivial_command("cmd-default")) + .with_command(flagged_command( + "cmd-ga", + "cmd-ga-flag", + Stage::Ga, + )), + ) + }; + + // Default policy (min_stage: Ga) drops the whole Beta subtree, including + // both its undeclared and explicitly-Ga-declared children, because the + // ancestor group itself already fails visibility before children are + // even visited. + let mut prefix = Vec::new(); + let mut registry = FlagRegistry::new(); + let pruned = prune_feature_flag_tree( + build_tree(), + None, + &empty_policy(), + &mut prefix, + &mut registry, + ) + .expect("root keeps its unflagged sibling command"); + assert!(pruned.groups.is_empty()); + assert_eq!(pruned.commands.len(), 1); + assert_eq!(pruned.commands[0].spec.name, "keep-me"); + // Only the group itself was recorded; its children were never visited. + assert_eq!(registry.entries().len(), 1); + assert_eq!(registry.entries()[0].path, "root:flagged-group"); + assert!(!registry.entries()[0].visible); + + // A Beta-permissive policy keeps the group and both of its children. + let policy = FlagPolicy::default().with_min_stage(Stage::Beta); + let mut prefix = Vec::new(); + let mut registry = FlagRegistry::new(); + let pruned = + prune_feature_flag_tree(build_tree(), None, &policy, &mut prefix, &mut registry) + .expect("root is kept"); + assert_eq!(pruned.groups.len(), 1); + assert_eq!(pruned.groups[0].commands.len(), 2); + assert!(registry.entries().iter().all(|entry| entry.visible)); + } + + #[test] + fn ancestor_invisibility_short_circuits_before_children_are_visited() { + // The child declares its own, more permissive Ga flag under a distinct + // key. Per the documented pruning semantics, an invisible ancestor drops + // its whole subtree unconditionally: the child's own flag is never even + // considered, because `prune_feature_flag_tree` returns `None` for the + // ancestor as soon as its own effective flag fails visibility, before + // recursing into commands or subgroups at all. + let group = RuntimeGroupSpec::new( + GroupSpec::new("ancestor", "short").with_feature_flag("ancestor-flag", Stage::Beta), + ) + .with_command(flagged_command("child", "child-flag", Stage::Ga)); + + let mut prefix = Vec::new(); + let mut registry = FlagRegistry::new(); + let pruned = + prune_feature_flag_tree(group, None, &empty_policy(), &mut prefix, &mut registry); + + assert!( + pruned.is_none(), + "invisible ancestor drops its whole subtree" + ); + // The child was never visited, so nothing about it was recorded. + assert_eq!(registry.entries().len(), 1); + assert_eq!(registry.entries()[0].path, "ancestor"); + assert!(registry.by_key("child-flag").is_empty()); + } + + #[test] + fn cascading_inherited_flag_key_and_stage_reach_unflagged_descendants() { + // Simulates a module-level flag with no per-group/per-command + // declaration anywhere below it: `inherited` here stands in for + // `Module::feature_flag`, exactly as `add_module_group_inner` passes it. + let module_flag = FeatureFlag::new("module-flag", Stage::Beta); + let group = RuntimeGroupSpec::new(GroupSpec::new("root", "short")) + .with_command(trivial_command("unflagged-child")); + + let policy = FlagPolicy::default().with_min_stage(Stage::Beta); + let mut prefix = Vec::new(); + let mut registry = FlagRegistry::new(); + let pruned = prune_feature_flag_tree( + group, + Some(&module_flag), + &policy, + &mut prefix, + &mut registry, + ) + .expect("Beta-permissive policy keeps a Beta-inherited tree"); + assert_eq!(pruned.commands.len(), 1); + + // Both the group and the descendant command recorded the *same* + // inherited key/stage, proving real cascading rather than an implicit + // Ga default at either level. + let entries = registry.entries(); + assert_eq!(entries.len(), 2); + assert_eq!(entries[0].path, "root"); + assert_eq!(entries[0].key, "module-flag"); + assert_eq!(entries[0].stage, Stage::Beta); + assert_eq!(entries[1].path, "root:unflagged-child"); + assert_eq!(entries[1].key, "module-flag"); + assert_eq!(entries[1].stage, Stage::Beta); + + // Under the default (Ga) policy the same inherited Beta flag makes the + // whole tree invisible together, since the group and its unflagged + // child resolve to the identical effective flag. + let mut prefix = Vec::new(); + let mut registry = FlagRegistry::new(); + let pruned = prune_feature_flag_tree( + RuntimeGroupSpec::new(GroupSpec::new("root", "short")) + .with_command(trivial_command("unflagged-child")), + Some(&module_flag), + &empty_policy(), + &mut prefix, + &mut registry, + ); + assert!(pruned.is_none()); + } + + #[test] + fn registry_records_only_named_flags_not_unflagged_nodes() { + let group = RuntimeGroupSpec::new(GroupSpec::new("root", "short")).with_group( + RuntimeGroupSpec::new( + GroupSpec::new("g", "short").with_feature_flag("g-flag", Stage::Beta), + ) + .with_command(trivial_command("c1")) + .with_command(flagged_command("c2", "c2-flag", Stage::Ga)), + ); + + // Permissive enough that nothing is pruned, so every node is visited. + let policy = FlagPolicy::default().with_min_stage(Stage::Experimental); + let mut prefix = Vec::new(); + let mut registry = FlagRegistry::new(); + let pruned = prune_feature_flag_tree(group, None, &policy, &mut prefix, &mut registry) + .expect("permissive policy keeps everything"); + assert_eq!(pruned.groups[0].commands.len(), 2); + + let entries = registry.entries(); + assert_eq!(entries.len(), 3, "root has no flag and is not recorded"); + assert_eq!(entries[0].path, "root:g"); + assert_eq!(entries[0].key, "g-flag"); + assert_eq!(entries[1].path, "root:g:c1"); + assert_eq!(entries[1].key, "g-flag"); + assert_eq!(entries[1].stage, Stage::Beta); + assert_eq!(entries[2].path, "root:g:c2"); + assert_eq!(entries[2].key, "c2-flag"); + assert_eq!(entries[2].stage, Stage::Ga); + assert!(entries.iter().all(|entry| entry.visible)); + } + + #[test] + fn module_feature_flag_cascades_into_its_group_via_add_module() { + // Regression test for the bug this task fixes: `add_module` used to + // discard `module.feature_flag` entirely, so a module-level flag could + // never reach its group/commands. `Module::new` returns a group with an + // unflagged command; the module itself declares Experimental, and the + // default (Ga) policy must prune the whole group away. + let module = Module::new("Test Category", |_ctx| { + RuntimeGroupSpec::new(GroupSpec::new("gated-mod", "short")) + .with_command(trivial_command("list")) + }) + .with_feature_flag("module-flag", Stage::Experimental); + + let mut cli = Cli::new(CliConfig::new("modtest", "Module test", "modtest")); + cli.add_module(module); + + assert!( + !cli.commands.contains_key("gated-mod:list"), + "module-level Experimental flag should have pruned the whole group under the default Ga policy" + ); + assert!( + !has_subcommand(&cli.root, "gated-mod"), + "the pruned group must not be mounted in the clap tree either" + ); + } + + #[test] + fn module_feature_flag_keeps_group_when_policy_allows_it() { + let module = Module::new("Test Category", |_ctx| { + RuntimeGroupSpec::new(GroupSpec::new("gated-mod-2", "short")) + .with_command(trivial_command("list")) + }) + .with_feature_flag("module-flag-2", Stage::Experimental); + + let mut cli = Cli::new( + CliConfig::new("modtest2", "Module test", "modtest2") + .with_min_stage(Stage::Experimental), + ); + cli.add_module(module); + + assert!(cli.commands.contains_key("gated-mod-2:list")); + assert!(has_subcommand(&cli.root, "gated-mod-2")); + } + + #[test] + fn active_environment_min_stage_loosens_consumer_level_policy() { + // The CliConfig itself leaves min_stage at its Ga default, which would + // normally prune this Experimental-flagged group. The active ("prod") + // environment's compiled min_stage override should reach + // `middleware.flag_policy` before pruning runs and keep it instead. + let module = Module::new("Test Category", |_ctx| { + RuntimeGroupSpec::new(GroupSpec::new("gated-mod-3", "short")) + .with_command(trivial_command("list")) + }) + .with_feature_flag("module-flag-3", Stage::Experimental); + + let mut cli = Cli::new( + CliConfig::new("modtest3", "Module test", "modtest3") + .with_environments(std::sync::Arc::new( + crate::environments::Environments::new("prod").with_environment( + "prod", + crate::environments::EnvTable::new().with("min_stage", "experimental"), + ), + )) + .with_startup_args(Vec::<&str>::new()), + ); + cli.add_module(module); + + assert!(cli.commands.contains_key("gated-mod-3:list")); + assert!(has_subcommand(&cli.root, "gated-mod-3")); + } + + /// The direct proof of the startup `--env` prescan (see `Cli::new`): + /// unlike [`active_environment_min_stage_loosens_consumer_level_policy`] + /// (which exercises the *default* active environment), here "prod" is + /// the default and carries no override, while "dev" loosens `min_stage`. + /// A `--env dev` supplied via `with_startup_args` — standing in for real + /// process argv — must be consulted before `add_module` prunes the tree, + /// in the *same* construction, not just update `middleware.env` for a + /// later run. + #[test] + fn startup_env_flag_reveals_beta_and_experimental_modules_for_the_named_env() { + fn gated_module() -> Module { + Module::new("Test Category", |_ctx| { + RuntimeGroupSpec::new(GroupSpec::new("gated-mod-4", "short")) + .with_command(trivial_command("list")) + }) + .with_feature_flag("module-flag-4", Stage::Experimental) + } + fn environments() -> std::sync::Arc { + std::sync::Arc::new( + crate::environments::Environments::new("prod") + .with_environment("prod", crate::environments::EnvTable::new()) + .with_environment( + "dev", + crate::environments::EnvTable::new().with("min_stage", "experimental"), + ), + ) + } + + let mut with_dev_flag = Cli::new( + CliConfig::new("modtest4a", "Module test", "modtest4a") + .with_environments(environments()) + .with_startup_args(["modtest4a", "--env", "dev"]), + ); + with_dev_flag.add_module(gated_module()); + assert!( + with_dev_flag.commands.contains_key("gated-mod-4:list"), + "--env dev in startup_args should reveal the Experimental module" + ); + assert!(has_subcommand(&with_dev_flag.root, "gated-mod-4")); + + // Negative counterpart: with no `--env` at all, the default ("prod", + // no override) still governs — nothing changed for the common case. + let mut without_flag = Cli::new( + CliConfig::new("modtest4b", "Module test", "modtest4b") + .with_environments(environments()) + .with_startup_args(Vec::<&str>::new()), + ); + without_flag.add_module(gated_module()); + assert!( + !without_flag.commands.contains_key("gated-mod-4:list"), + "without --env, the default env's Ga policy should still prune the module" + ); + assert!(!has_subcommand(&without_flag.root, "gated-mod-4")); + } + + static GLOBAL_MIN_STAGE_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + + /// RAII guard that restores (or removes) an env var on drop, even if a + /// test panics. + struct GlobalMinStageEnvGuard { + key: &'static str, + prev: Option, + } + impl GlobalMinStageEnvGuard { + /// Sets `key` to `value`. Caller must hold [`GLOBAL_MIN_STAGE_ENV_LOCK`] + /// for the guard's entire lifetime. + #[allow(unsafe_code)] + fn set(key: &'static str, value: &str) -> Self { + let prev = std::env::var_os(key); + // SAFETY: serialized by GLOBAL_MIN_STAGE_ENV_LOCK; guard + // restores/removes on any exit incl. panic. + unsafe { std::env::set_var(key, value) }; + Self { key, prev } + } + + /// Removes `key` (if set). Caller must hold + /// [`GLOBAL_MIN_STAGE_ENV_LOCK`] for the guard's entire lifetime. + #[allow(unsafe_code)] + fn unset(key: &'static str) -> Self { + let prev = std::env::var_os(key); + // SAFETY: serialized by GLOBAL_MIN_STAGE_ENV_LOCK; guard restores + // on any exit incl. panic. + unsafe { std::env::remove_var(key) }; + Self { key, prev } + } + } + impl Drop for GlobalMinStageEnvGuard { + #[allow(unsafe_code)] + fn drop(&mut self) { + // SAFETY: test holds GLOBAL_MIN_STAGE_ENV_LOCK; restore/clean up + // on any exit including panic. + unsafe { + match &self.prev { + Some(v) => std::env::set_var(self.key, v), + None => std::env::remove_var(self.key), + } + } + } + } + + #[test] + #[allow(unsafe_code)] + fn global_min_stage_override_is_a_noop_when_unset() { + let _g = GLOBAL_MIN_STAGE_ENV_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + const VAR: &str = "UNSET_MIN_STAGE_APP_MIN_STAGE"; + // Explicitly unset (and restored on drop) rather than assumed absent, + // so the test is hermetic even if a developer/CI happens to have this + // var set. + let _guard = GlobalMinStageEnvGuard::unset(VAR); + + assert_eq!(global_min_stage_override("unset-min-stage-app"), None); + } + + #[test] + #[allow(unsafe_code)] + fn global_min_stage_override_parses_a_valid_value() { + let _g = GLOBAL_MIN_STAGE_ENV_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + const VAR: &str = "VALID_MIN_STAGE_APP_MIN_STAGE"; + let _guard = GlobalMinStageEnvGuard::set(VAR, "beta"); + + assert_eq!( + global_min_stage_override("valid-min-stage-app"), + Some(Stage::Beta) + ); + } + + #[test] + #[allow(unsafe_code)] + fn global_min_stage_override_ignores_a_malformed_value() { + let _g = GLOBAL_MIN_STAGE_ENV_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + const VAR: &str = "BAD_MIN_STAGE_APP_MIN_STAGE"; + let _guard = GlobalMinStageEnvGuard::set(VAR, "nightly"); + + assert_eq!(global_min_stage_override("bad-min-stage-app"), None); + } +} diff --git a/cli-engine/src/command.rs b/cli-engine/src/command.rs deleted file mode 100644 index bd6734b..0000000 --- a/cli-engine/src/command.rs +++ /dev/null @@ -1,1528 +0,0 @@ -use std::{collections::BTreeMap, future::Future, pin::Pin, sync::Arc}; - -use clap::{Arg, ArgAction, ArgGroup, ArgMatches, Command}; -use schemars::JsonSchema; -use serde_json::{Number, Value}; -use tokio::sync::mpsc; - -use crate::{ - AuthRequirement, CommandMeta, Credential, CredentialResolver, FeatureFlag, Middleware, - OutputSchema, Result, SchemaInfo, Stage, Tier, - middleware::ValueMap, - output::{NextAction, TableColumn}, -}; - -/// Sender half for streaming command output. -/// -/// Streaming handlers call [`StreamSender::send`] for each progress event. -/// The engine drains the channel and writes each event as an NDJSON line. -#[derive(Clone, Debug)] -pub struct StreamSender(pub(crate) mpsc::Sender); - -impl StreamSender { - /// Sends one event. Silently drops the event if the receiver is gone. - pub async fn send(&self, event: Value) { - drop(self.0.send(event).await); - } -} - -/// Boxed future returned by runtime command handlers. -pub type CommandFuture = Pin> + Send>>; -/// Shared command handler used by [`RuntimeCommandSpec`]. -pub type CommandHandler = Arc CommandFuture + Send + Sync>; - -/// Boxed future returned by streaming command handlers. -pub type StreamingCommandFuture = Pin> + Send>>; -/// Shared streaming handler: receives context and an event sender; returns when the stream ends. -pub type StreamingCommandHandler = - Arc StreamingCommandFuture + Send + Sync>; - -/// Data returned by a command handler. -/// -/// Command handlers should return renderable data and keep output metadata on -/// [`CommandSpec`]. The metadata field is reserved for future command-result -/// extensions that are not known when the command is registered. -/// -/// Construct with [`CommandResult::new`], then chain `with_*` methods — -/// never as a struct literal. `#[non_exhaustive]` enforces this so the engine -/// can add fields without a breaking release. -#[derive(Clone, Debug, PartialEq)] -#[non_exhaustive] -pub struct CommandResult { - /// JSON data rendered by the configured output formatter. - pub data: Value, - /// Optional command-result extension metadata. - pub metadata: CommandResultMetadata, -} - -impl CommandResult { - /// Creates a command result from renderable JSON data. - #[must_use] - pub fn new(data: Value) -> Self { - Self { - data, - metadata: CommandResultMetadata::default(), - } - } - - /// Attaches suggested follow-up actions to this result. - #[must_use] - pub fn with_next_actions(mut self, actions: Vec) -> Self { - self.metadata.next_actions = actions; - self - } - - /// Marks this result as a dry-run preview outcome. - /// - /// Call only when the handler actually skipped its mutating step because - /// [`CommandContext::dry_run`] was `true`. This requires the command to - /// have opted in via [`CommandSpec::handles_dry_run`] — otherwise - /// middleware never invokes the handler under `--dry-run` in the first - /// place. Middleware tags the audit/activity outcome as `dry-run` instead - /// of `ok` and marks the rendered envelope accordingly. - #[must_use] - pub fn with_dry_run(mut self) -> Self { - self.metadata.dry_run = true; - self - } -} - -impl From for CommandResult { - fn from(data: Value) -> Self { - Self::new(data) - } -} - -/// Optional metadata a command can attach to its result. -#[non_exhaustive] -#[derive(Clone, Debug, Default, Eq, PartialEq)] -pub struct CommandResultMetadata { - /// Suggested follow-up actions for the caller. - pub next_actions: Vec, - /// Set by [`CommandResult::with_dry_run`] when a - /// [`handles_dry_run`](CommandSpec::handles_dry_run) handler skipped its - /// mutating step. Middleware tags the audit/activity outcome and envelope - /// as `dry-run` instead of `ok` when this is `true`. - pub dry_run: bool, -} - -/// Runtime context passed to advanced command handlers. -/// -/// Most commands can use [`RuntimeCommandSpec::new`] and receive just the -/// credential and effective args. Use this context when a command needs the -/// colon path, user-supplied args, or a snapshot of middleware state. -/// -/// This struct is constructed by the framework during command dispatch. -/// Consumer code receives it in handler closures and should not construct it -/// directly. -#[derive(Clone, Debug)] -#[non_exhaustive] -pub struct CommandContext { - /// Lazy credential resolver. - pub credential: CredentialResolver, - /// Effective arguments, including defaults and framework-injected values. - pub args: ValueMap, - /// Arguments explicitly supplied by the user. - pub user_args: ValueMap, - /// Colon-separated command path such as `project:list`. - pub command_path: String, - /// Middleware snapshot for this invocation. - pub middleware: Middleware, - /// Raw `clap` matches for typed argument deserialization via derive. - pub raw_matches: Arc, -} - -impl CommandContext { - /// Returns the per-application config file as loaded at startup. - /// - /// Read a consumer-owned section with - /// [`ConfigFile::section`](crate::config::ConfigFile::section), for example - /// `ctx.config().section::("deploy")?`. Engine-reserved - /// settings are available via - /// [`ConfigFile::engine`](crate::config::ConfigFile::engine). - /// - /// **Snapshot semantics**: this is the config loaded once when - /// [`crate::cli::Cli::new`] was called. Changes made by `config set` during the same process - /// invocation (e.g. from a previous `Cli::run`) are not reflected here; - /// restart the CLI (a new `Cli::new`) to pick them up. For a one-shot CLI - /// process this is always the current on-disk state. - #[must_use] - pub fn config(&self) -> &crate::config::ConfigFile { - &self.middleware.config - } - - /// Returns whether `--dry-run` was passed for this invocation. - /// - /// Only meaningful for commands that opted in via - /// [`CommandSpec::handles_dry_run`] — other mutating commands never reach - /// their handler under `--dry-run` at all, so there's nothing to branch - /// on. An opted-in handler should run its real validation unconditionally - /// and use this only to skip the actual mutating I/O, returning a preview - /// result tagged with [`CommandResult::with_dry_run`]. - #[must_use] - pub fn dry_run(&self) -> bool { - self.middleware.dry_run - } - - /// Returns the resolved interactivity mode for this invocation. - /// - /// Use this to decide whether to prompt for missing inputs, show progress - /// spinners, or offer interactive choices. When `false`, the command should - /// fail with a descriptive error if required inputs are missing. - #[must_use] - pub fn is_interactive(&self) -> bool { - self.middleware.interactive - } - - /// Returns the resolved [`InteractivityMode`](crate::InteractivityMode). - /// - /// Equivalent to [`is_interactive`](Self::is_interactive) but returns the - /// enum for pattern matching. - #[must_use] - pub fn interactivity_mode(&self) -> crate::InteractivityMode { - self.middleware.interactive.into() - } - - /// Resolves the active environment's merged TOML table for this - /// invocation, as an [`EnvSource`](crate::env_config::EnvSource). - /// - /// The active environment name is `self.middleware.env`, seeded at startup - /// from the persisted active environment or configured default and - /// overridden per invocation by the global `--env` flag. Resolution merges - /// the compiled-in table and the `environments.toml` file layer (file - /// wins). Use this for generic introspection (see the built-in `env info` - /// command); for a typed section with the app-scoped environment-variable - /// override tier applied, use - /// [`environment_config`](Self::environment_config) instead. - /// - /// # Blocking - /// - /// When the `environments.toml` file layer is enabled, this performs - /// synchronous filesystem I/O via - /// [`Environments::source`](crate::environments::Environments::source). - /// Call it once per invocation and reuse the result rather than calling it - /// repeatedly inside an async handler on a latency-sensitive path. - /// - /// # Errors - /// - /// Returns an error if no environment system was registered via - /// [`CliConfig::with_environments`](crate::CliConfig::with_environments) or - /// if the active name does not resolve to a known environment. - pub fn environment(&self) -> Result { - let environments = self.middleware.environments.as_ref().ok_or_else(|| { - crate::error::CliCoreError::message("no environment system configured") - })?; - environments.source(&self.middleware.env) - } - - /// Resolves the active environment into a typed - /// [`EnvConfig`](crate::env_config::EnvConfig) section, with the - /// app-scoped environment-variable override tier applied (see - /// [`Environments::resolve`](crate::environments::Environments::resolve)). - /// - /// # Blocking - /// - /// See [`environment`](Self::environment). - /// - /// # Errors - /// - /// Returns an error under the same conditions as - /// [`environment`](Self::environment), or when a field's present value - /// fails to convert to its type, or a required field has no value in any - /// source and no default. - pub fn environment_config( - &self, - ) -> std::result::Result { - let environments = self.middleware.environments.as_ref().ok_or_else(|| { - crate::error::CliCoreError::message("no environment system configured") - })?; - environments.resolve(&self.middleware.env) - } - - /// Deserializes the raw argument matches into a typed args struct. - /// - /// Use this with `#[derive(clap::Args)]` structs to get type-safe access - /// to command arguments instead of working with the `ValueMap` directly. - /// - /// # Errors - /// - /// Returns an error if the matches cannot be deserialized into `T`. - pub fn typed_args(&self) -> Result { - T::from_arg_matches(self.raw_matches.as_ref()) - .map_err(|e| crate::CliCoreError::Message(format!("argument parse error: {e}"))) - } - - /// Resolves the credential for this command, triggering the auth flow on - /// first use and memoizing the result. - /// - /// Convenience wrapper over [`self.credential.resolve()`](CredentialResolver::resolve). - /// - /// # Errors - /// - /// Returns an error when the command is marked `no_auth`, or when the auth - /// provider fails to produce a credential. - pub async fn credential(&self) -> Result { - self.credential.resolve().await - } - - /// Resolves the credential when one is available, returning `Ok(None)` for - /// no-auth commands. - /// - /// Convenience wrapper over [`self.credential.try_resolve()`](CredentialResolver::try_resolve). - /// - /// # Errors - /// - /// Propagates the auth provider error when resolution is attempted and fails. - pub async fn try_credential(&self) -> Result> { - self.credential.try_resolve().await - } - - /// Resolves a credential that additionally covers `extra` scopes, on top of - /// the command's declared scopes. - /// - /// Use this when the required scopes are only known at runtime (for example - /// a generic API caller that derives scopes from the target endpoint). A - /// scope-aware auth provider re-authenticates when the cached token does not - /// already cover the requested set. - /// - /// Convenience wrapper over - /// [`self.credential.resolve_with_scopes()`](CredentialResolver::resolve_with_scopes). - /// - /// If the handler also issues HTTP requests through the transport bearer - /// injector, call this **before** the first request: the injector resolves - /// and caches a scope-unaware token, so stepping up afterwards would not - /// affect requests it already authorized. See - /// [`CredentialResolver::resolve_with_scopes`] for the full ordering note. - /// - /// # Errors - /// - /// Returns an error when the command is marked `no_auth`, or when the auth - /// provider fails to produce a credential. - pub async fn credential_with_scopes(&self, extra: &[String]) -> Result { - self.credential.resolve_with_scopes(extra).await - } -} - -/// Declarative leaf command metadata and parser arguments. -/// -/// `CommandSpec` intentionally keeps command metadata next to the command's -/// handler. This is the primary copy/paste surface for teams adding commands. -/// -/// Construct with [`CommandSpec::new`] or [`CommandSpec::from_args`], then -/// configure with the `with_*` builder methods — never as a struct literal. -/// `#[non_exhaustive]` enforces this so the engine can add fields (as it did -/// for [`arg_groups`](CommandSpec::arg_groups)) without a breaking release. -#[derive(Clone, Debug, Default)] -#[non_exhaustive] -pub struct CommandSpec { - /// Leaf command name. - pub name: String, - /// One-line command description. - pub short: String, - /// Optional long help text. - pub long: Option, - /// Alternate command names accepted by the parser. - pub aliases: Vec, - /// Whether the command runs but is hidden from help, tree, and search. - pub hidden: bool, - /// Backend/system id used in output metadata and generic error envelopes. - pub system: Option, - /// Default comma-separated field projection. - pub default_fields: Option, - /// Authentication requirement enforced by the engine for this command. - /// - /// Defaults to [`AuthRequirement::Required`] (fail-closed). Use - /// [`auth_optional`](CommandSpec::auth_optional) for commands that should run - /// logged out, or [`no_auth`](CommandSpec::no_auth) for commands that never - /// authenticate. - pub auth: AuthRequirement, - /// Auth provider name for this command. - pub auth_provider: Option, - /// Risk tier used by authentication, authorization, and dry-run. - pub tier: Option, - /// Explicit dry-run prompt marker for commands without a tier. - pub mutates: bool, - /// Opts this command into handler-driven `--dry-run`. - /// - /// Set with [`handles_dry_run`](CommandSpec::handles_dry_run). When - /// `true`, the engine skips its generic `--dry-run` short-circuit for - /// this command and invokes the handler as normal (still respecting the - /// command's [`AuthRequirement`]). The handler is responsible for - /// running its real validation unconditionally, checking - /// [`CommandContext::dry_run`] to skip only the mutating I/O, and tagging - /// its preview result with [`CommandResult::with_dry_run`]. - /// - /// **Requires a context-aware handler.** Only handlers built with - /// [`RuntimeCommandSpec::new_with_context`], - /// [`new_streaming`](RuntimeCommandSpec::new_streaming), - /// [`new_typed_with_context`](RuntimeCommandSpec::new_typed_with_context), - /// or [`new_typed_streaming`](RuntimeCommandSpec::new_typed_streaming) - /// receive a [`CommandContext`] and can call [`CommandContext::dry_run`]. - /// A handler built with [`RuntimeCommandSpec::new`]/[`new_typed`](RuntimeCommandSpec::new_typed) - /// only receives `(CredentialResolver, args)` — it has no way to observe - /// `--dry-run` at all, so opting it into `handles_dry_run` would silently - /// execute the handler's real side effects under `--dry-run` instead of - /// skipping them. `RuntimeCommandSpec::new`/`new_typed` debug-assert - /// against this misuse; release builds do not, so treat the assert as a - /// development-time safety net, not the actual guarantee — only pair this - /// field with one of the four context-aware constructors above. - pub handles_dry_run: bool, - /// Forces this command's successful output to print verbatim to stdout. - pub raw_output: bool, - /// Provider-specific auth metadata. - pub auth_metadata: BTreeMap, - /// Command-specific `clap` arguments. - pub args: Vec, - /// Argument relations (mutually-exclusive or "at least one of" groups). - /// - /// Set with [`with_arg_group`](CommandSpec::with_arg_group), or captured - /// automatically by [`from_args`](CommandSpec::from_args) from a - /// `#[derive(clap::Args)]` struct's `#[group(...)]` attribute. - pub arg_groups: Vec, - /// Optional output schema published through `--schema` and help. - pub output_schema: Option, - /// Inline human-output table columns assigned directly to this command. - /// - /// Set with [`with_view`](CommandSpec::with_view). When present (and - /// [`view_id`](CommandSpec::view_id) is unset), the engine registers these - /// columns under the command's own path so human output renders them. - pub view_columns: Vec, - /// Id of a shared human view this command should use. - /// - /// Set with [`with_view_id`](CommandSpec::with_view_id). Names a - /// [`HumanViewDef`](crate::HumanViewDef) registered with `with_view` on the - /// module or CLI, so several commands can share one table. Takes precedence - /// over inline [`view_columns`](CommandSpec::view_columns). - pub view_id: Option, - /// This command's own feature-flag declaration, if any. - /// - /// `None` means the command has no explicit stage declaration of its own, - /// in which case it inherits its effective stage from its nearest ancestor - /// (nested group, then enclosing group, then module — nearest declaration - /// wins), implicitly resolving to [`Stage::Ga`] if nothing in the ancestor - /// chain declares a flag either; see [`Stage`]'s documentation for why - /// that is its default. Set with - /// [`with_feature_flag`](CommandSpec::with_feature_flag). This field only - /// records the command's own declaration; cascading resolution against the - /// ancestor chain happens when a [`Cli`](crate::Cli) mounts the enclosing - /// module or group. - pub feature_flag: Option, - /// This command's opt-in pagination policy, if any. - /// - /// `None` (the default) means the command does not paginate: `--limit`/ - /// `--offset` are not registered for it, so they neither show up in its - /// `--help` nor parse on its command line. Set with - /// [`with_pagination`](CommandSpec::with_pagination). - pub pagination: Option, -} - -/// Opt-in pagination policy for a single command, set with -/// [`CommandSpec::with_pagination`]. -/// -/// Registering this is what makes `--limit`/`--offset` exist for a command at -/// all — without it, the engine does not register those flags, so they are -/// absent from `--help` and rejected as unknown arguments if passed. Construct -/// it with `..Default::default()`, as in the example below, so a future -/// engine release can add fields without breaking existing callers. -/// -/// ``` -/// use cli_engine::PaginationConfig; -/// -/// let pagination = PaginationConfig { -/// default_limit: 20, -/// max_limit: 100, -/// ..Default::default() -/// }; -/// assert_eq!(pagination.default_limit, 20); -/// ``` -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub struct PaginationConfig { - /// Page size applied when the user passes neither `--limit` nor - /// `--offset`. `0` (the default) means unlimited — the same "no - /// pagination" sentinel used everywhere else in the output pipeline. - pub default_limit: i64, - /// Upper bound a user can request with an explicit `--limit`. `0` (the - /// default) means uncapped. Does not affect `default_limit` itself. - pub max_limit: i64, -} - -impl CommandSpec { - /// Creates a command spec with the required name and one-line help. - #[must_use] - pub fn new(name: impl Into, short: impl Into) -> Self { - Self { - name: name.into(), - short: short.into(), - ..Self::default() - } - } - - /// Creates a command spec from a `#[derive(clap::Args)]` struct. - /// - /// Extracts the argument definitions from the derive type and populates the - /// spec's args list. The command name and help text are still required since - /// `Args` types do not carry those. Also captures any `ArgGroup`s the derive - /// macro registers (via a struct-level `#[group(...)]` attribute) into - /// [`arg_groups`](CommandSpec::arg_groups). - /// - /// **Flatten caveat**: `clap_derive` empties a struct's own implicit group's - /// member list when the struct also has a `#[command(flatten)]` field, so a - /// `#[group(required = true)]` on such a struct silently enforces nothing. - /// This is debug-asserted against below; treat it as a development-time - /// safety net, not the actual guarantee. - #[must_use] - pub fn from_args(name: impl Into, short: impl Into) -> Self { - let name = name.into(); - let placeholder = Command::new("__placeholder"); - let augmented = T::augment_args(placeholder); - let args: Vec = augmented - .get_arguments() - // `cli-engine` registers its own global `--help` flag. Retain a - // command-specific `--version` flag: it may represent a resource - // version rather than the CLI binary version. - .filter(|arg| arg.get_id().as_str() != "help") - .cloned() - .collect(); - let arg_groups: Vec = augmented.get_groups().cloned().collect(); - debug_assert!( - arg_groups - .iter() - .all(|group| !group.is_required_set() || group.get_args().count() > 0), - "command {name:?} has a required ArgGroup with no member args — likely the \ - clap_derive flatten+group interaction emptying the implicit group's \ - member list; the constraint will not be enforced" - ); - Self { - name, - short: short.into(), - args, - arg_groups, - ..Self::default() - } - } - - /// Sets expanded command help. - #[must_use] - pub fn with_long(mut self, long: impl Into) -> Self { - self.long = Some(long.into()); - self - } - - /// Adds one command alias. - #[must_use] - pub fn with_alias(mut self, alias: impl Into) -> Self { - self.aliases.push(alias.into()); - self - } - - /// Hides or shows this command in discovery output. - #[must_use] - pub fn hidden(mut self, hidden: bool) -> Self { - self.hidden = hidden; - self - } - - /// Sets the backend/system id for output metadata and error attribution. - #[must_use] - pub fn with_system(mut self, system: impl Into) -> Self { - self.system = Some(system.into()); - self - } - - /// Sets the default field projection used when `--fields` is absent. - #[must_use] - pub fn with_default_fields(mut self, default_fields: impl Into) -> Self { - self.default_fields = Some(default_fields.into()); - self - } - - /// Assigns an inline human-output table view to this command. - /// - /// The columns are registered under the command's own path, so human output - /// renders this table directly. Field selection still applies: `--fields` - /// (defaulting to [`default_fields`](CommandSpec::default_fields)) narrows - /// which of these columns show. Use - /// [`with_view_id`](CommandSpec::with_view_id) instead to point at a shared - /// view registered with `with_view` on the module or CLI. - #[must_use] - pub fn with_view(mut self, columns: impl Into>) -> Self { - self.view_columns = columns.into(); - self - } - - /// Points this command at a shared human view by id. - /// - /// The id must match a [`HumanViewDef`](crate::HumanViewDef) registered with - /// `with_view` on the module or CLI, letting several commands share one - /// table. Takes precedence over inline [`with_view`](CommandSpec::with_view) - /// columns. - #[must_use] - pub fn with_view_id(mut self, id: impl Into) -> Self { - self.view_id = Some(id.into()); - self - } - - /// Selects the auth provider for this command. - #[must_use] - pub fn with_auth_provider(mut self, provider: impl Into) -> Self { - self.auth_provider = Some(provider.into()); - self - } - - /// Marks the command as no-auth. - /// - /// `no_auth(true)` sets [`AuthRequirement::None`]: the command never resolves - /// a credential and default-env injection is suppressed. `no_auth(false)` - /// restores the default [`AuthRequirement::Required`]. - #[must_use] - pub fn no_auth(mut self, no_auth: bool) -> Self { - self.auth = if no_auth { - AuthRequirement::None - } else { - AuthRequirement::Required - }; - self - } - - /// Sets the command's [`AuthRequirement`] explicitly. - #[must_use] - pub fn auth(mut self, requirement: AuthRequirement) -> Self { - self.auth = requirement; - self - } - - /// Marks authentication as optional ([`AuthRequirement::Optional`]). - /// - /// The engine does not resolve a credential before the handler runs; the - /// handler triggers the auth flow only by calling - /// [`CredentialResolver::resolve`]/[`try_resolve`](CredentialResolver::try_resolve). - /// Use for commands that should still run when the user is logged out. - #[must_use] - pub fn auth_optional(mut self) -> Self { - self.auth = AuthRequirement::Optional; - self - } - - /// Sets the command risk tier. - #[must_use] - pub fn with_tier(mut self, tier: Tier) -> Self { - self.tier = Some(tier); - self - } - - /// Declares this command's own feature flag: the key used for policy - /// overrides and introspection, and the stage at which it becomes visible. - #[must_use] - pub fn with_feature_flag(mut self, key: impl Into, stage: Stage) -> Self { - self.feature_flag = Some(FeatureFlag::new(key, stage)); - self - } - - /// Opts this command into paginated list output. - /// - /// Registers `--limit`/`--offset` for this command only — a command that - /// never calls this does not get those flags at all, in `--help` or on - /// the command line. When the user passes neither flag, `config.default_limit` - /// applies instead of the framework's "pagination disabled" default of - /// unlimited; an explicit `--limit` above `config.max_limit` (when set) is - /// rejected before the command runs. See [`PaginationConfig`]. - #[must_use] - pub fn with_pagination(mut self, config: PaginationConfig) -> Self { - debug_assert!( - config.max_limit == 0 || config.default_limit <= config.max_limit, - "command {:?} has a default_limit ({}) greater than its max_limit ({})", - self.name, - config.default_limit, - config.max_limit - ); - self.pagination = Some(config); - self - } - - /// Adds provider-specific auth metadata. - #[must_use] - pub fn with_auth_metadata(mut self, key: impl Into, value: impl Into) -> Self { - self.auth_metadata.insert(key.into(), value.into()); - self - } - - /// Declares the OAuth scopes this command requires. - /// - /// Sugar over [`with_auth_metadata`](CommandSpec::with_auth_metadata) with the - /// `"scopes"` key (whitespace-joined). The scopes surface on - /// [`CommandMeta::scopes`](crate::CommandMeta) and reach the auth provider via - /// [`CredentialRequest`](crate::CredentialRequest); a provider that supports - /// scope step-up re-authenticates when the cached token lacks them. - #[must_use] - pub fn with_scopes(mut self, scopes: &[impl AsRef]) -> Self { - let joined = scopes - .iter() - .map(AsRef::as_ref) - .collect::>() - .join(" "); - // Mirror `CommandMeta::set_scopes`: an empty list clears the key rather - // than leaving an empty-but-present `auth_metadata["scopes"]`. - if joined.is_empty() { - self.auth_metadata.remove("scopes"); - } else { - self.auth_metadata.insert("scopes".to_owned(), joined); - } - self - } - - /// Adds a `clap` argument or option to this command. - #[must_use] - pub fn with_arg(mut self, arg: Arg) -> Self { - self.args.push(arg); - self - } - - /// Adds a `clap` flag or option to this command. - #[must_use] - pub fn with_flag(self, flag: Arg) -> Self { - self.with_arg(flag) - } - - /// Adds an argument relation (an `ArgGroup`) to this command, e.g. to - /// express "at least one of" or mutually-exclusive relationships between - /// arguments added with [`with_arg`](CommandSpec::with_arg)/[`with_flag`](CommandSpec::with_flag). - /// - /// The group's `ArgGroup::args([...])` ids must reference args already (or - /// later) added to this spec, matching `clap`'s own requirement that - /// referenced arg ids exist on the built `Command`. This replaces - /// hand-rolled `required_unless_present_any`/`conflicts_with` chains with a - /// single declarative relation. - #[must_use] - pub fn with_arg_group(mut self, group: ArgGroup) -> Self { - self.arg_groups.push(group); - self - } - - /// Registers a compact framework schema from an [`OutputSchema`] type. - #[must_use] - pub fn with_output_schema(mut self) -> Self { - self.output_schema = Some(SchemaInfo { - command: String::new(), - fields: crate::output::fields_for::(), - schema: None, - }); - self - } - - /// Registers JSON Schema generated from a Rust type with `schemars`. - #[must_use] - pub fn with_json_schema(mut self) -> Self { - self.output_schema = Some(crate::output::json_schema_info::("")); - self - } - - /// Marks whether the command should short-circuit under `--dry-run`. - #[must_use] - pub fn mutates(mut self, mutates: bool) -> Self { - self.mutates = mutates; - self - } - - /// Opts this command into handler-driven `--dry-run` instead of the - /// engine's generic short-circuit. - /// - /// See [`handles_dry_run`](CommandSpec::handles_dry_run) (the field) for - /// the contract a handler must follow once it opts in — in particular, - /// **only use this with a context-aware handler** - /// ([`RuntimeCommandSpec::new_with_context`], - /// [`new_streaming`](RuntimeCommandSpec::new_streaming), - /// [`new_typed_with_context`](RuntimeCommandSpec::new_typed_with_context), or - /// [`new_typed_streaming`](RuntimeCommandSpec::new_typed_streaming)); a - /// `new`/`new_typed` handler can't observe `--dry-run` and would execute - /// its real side effects under it regardless of this flag. - #[must_use] - pub fn handles_dry_run(mut self, handles: bool) -> Self { - self.handles_dry_run = handles; - self - } - - /// Forces this command's successful output to print verbatim to stdout. - #[must_use] - pub fn raw_output(mut self, raw_output: bool) -> Self { - self.raw_output = raw_output; - self - } - - /// Builds middleware metadata from the spec. - #[must_use] - pub fn metadata(&self) -> CommandMeta { - let mut auth_metadata = self.auth_metadata.clone(); - if let Some(provider) = &self.auth_provider - && !provider.is_empty() - { - auth_metadata.insert("provider".to_owned(), provider.clone()); - } - if let Some(tier) = self.tier - && !auth_metadata.contains_key("tier") - { - auth_metadata.insert("tier".to_owned(), tier.to_string()); - } - let scopes = auth_metadata - .get("scopes") - .map(|scopes| { - scopes - .split_whitespace() - .map(str::to_owned) - .collect::>() - }) - .unwrap_or_default(); - - CommandMeta { - dry_run_prompt: self.mutates || self.tier.is_some_and(Tier::is_mutating), - handles_dry_run: self.handles_dry_run, - auth_metadata, - scopes, - } - } - - /// Builds the `clap` command for parser registration. - #[must_use] - pub fn clap_command(&self) -> Command { - let mut command = Command::new(self.name.clone()).about(self.short.clone()); - if let Some(long) = &self.long - && !long.is_empty() - { - command = command.long_about(long.clone()); - } - for alias in &self.aliases { - command = command.alias(alias.clone()); - } - if self.hidden { - command = command.hide(true); - } - // Explicit `display_order` (rather than relying on clap's own - // implicit per-`Command` counter) guarantees these render first, as - // a block, in declaration order — see `flags::global_flag_order` - // for why leaving it implicit lets a propagated global flag collide - // with a low counter value here and interleave with these instead. - for (index, arg) in self.args.iter().enumerate() { - command = command.arg(arg.clone().display_order(index)); - } - for group in &self.arg_groups { - command = command.group(group.clone()); - } - command - } -} - -/// Declarative command group metadata. -/// -/// Groups are noun-based containers. They do not run business logic directly; -/// when invoked bare, the CLI renders group help. -/// -/// Construct with [`GroupSpec::new`], then configure with the `with_*` builder -/// methods — never as a struct literal. `#[non_exhaustive]` enforces this so -/// the engine can add fields later without a breaking release. -#[derive(Clone, Debug, Default)] -#[non_exhaustive] -pub struct GroupSpec { - /// Group command name. - pub name: String, - /// One-line group description. - pub short: String, - /// Optional long help text. - pub long: Option, - /// Alternate group names accepted by the parser. - pub aliases: Vec, - /// Whether the group runs but is hidden from discovery output. - pub hidden: bool, - /// Declarative child commands used for static tree construction. - pub commands: Vec, - /// Declarative nested groups used for static tree construction. - pub groups: Vec, - /// This group's own feature-flag declaration, if any. - /// - /// `None` means the group has no explicit stage declaration of its own, in - /// which case it inherits its effective stage from its nearest ancestor - /// (enclosing group, then module — nearest declaration wins), implicitly - /// resolving to [`Stage::Ga`] if nothing in the ancestor chain declares a - /// flag either; see [`Stage`]'s documentation for why that is its default. - /// Set with [`with_feature_flag`](GroupSpec::with_feature_flag). This field - /// only records the group's own declaration; cascading resolution against - /// the ancestor chain happens when a [`Cli`](crate::Cli) mounts the - /// enclosing module or parent group. - pub feature_flag: Option, -} - -impl GroupSpec { - /// Creates a command group with the required name and one-line help. - #[must_use] - pub fn new(name: impl Into, short: impl Into) -> Self { - Self { - name: name.into(), - short: short.into(), - ..Self::default() - } - } - - /// Sets expanded group help. - #[must_use] - pub fn with_long(mut self, long: impl Into) -> Self { - self.long = Some(long.into()); - self - } - - /// Adds one group alias. - #[must_use] - pub fn with_alias(mut self, alias: impl Into) -> Self { - self.aliases.push(alias.into()); - self - } - - /// Hides or shows this group in discovery output. - #[must_use] - pub fn hidden(mut self, hidden: bool) -> Self { - self.hidden = hidden; - self - } - - /// Adds one declarative child command. - #[must_use] - pub fn with_command(mut self, command: CommandSpec) -> Self { - self.commands.push(command); - self - } - - /// Adds one declarative nested group. - #[must_use] - pub fn with_group(mut self, group: GroupSpec) -> Self { - self.groups.push(group); - self - } - - /// Declares this group's own feature flag: the key used for policy overrides - /// and introspection, and the stage at which it becomes visible. - #[must_use] - pub fn with_feature_flag(mut self, key: impl Into, stage: Stage) -> Self { - self.feature_flag = Some(FeatureFlag::new(key, stage)); - self - } - - /// Builds the `clap` command for parser registration. - #[must_use] - pub fn clap_command(&self) -> Command { - let mut command = Command::new(self.name.clone()).about(self.short.clone()); - if let Some(long) = &self.long - && !long.is_empty() - { - command = command.long_about(long.clone()); - } - for alias in &self.aliases { - command = command.alias(alias.clone()); - } - if self.hidden { - command = command.hide(true); - } - for group in &self.groups { - command = command.subcommand(group.clap_command()); - } - for child in &self.commands { - command = command.subcommand(child.clap_command()); - } - command - } -} - -/// Executable leaf command. -/// -/// `RuntimeCommandSpec` pairs a [`CommandSpec`] with async business logic. -/// This split keeps metadata inspectable for help/search/schema generation -/// before the handler ever runs. -/// -/// Use [`RuntimeCommandSpec::new_streaming`] for commands that emit incremental -/// NDJSON progress events (e.g. long-running deployments with `--follow`). -/// -/// Construct with one of the `new*` constructors — never as a struct literal. -/// Literal construction would bypass the `handles_dry_run`/handler-shape -/// misuse checks those constructors debug-assert. `#[non_exhaustive]` also -/// means the engine can add fields without a breaking release. -#[derive(Clone)] -#[non_exhaustive] -pub struct RuntimeCommandSpec { - /// Declarative command metadata. - pub spec: CommandSpec, - /// Async command implementation. - pub handler: CommandHandler, - /// Optional streaming handler. When set, the engine writes NDJSON events - /// to stdout as they arrive instead of collecting a single envelope. - pub streaming_handler: Option, -} - -impl std::fmt::Debug for RuntimeCommandSpec { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter - .debug_struct("RuntimeCommandSpec") - .field("spec", &self.spec) - .field("is_streaming", &self.streaming_handler.is_some()) - .finish_non_exhaustive() - } -} - -impl RuntimeCommandSpec { - /// Creates a runtime command with the common handler shape. - /// - /// The handler receives a lazy [`CredentialResolver`] and the effective args. - /// Call `resolver.resolve().await?` only when the command actually needs a - /// credential; commands that ignore it never trigger an auth flow. The - /// handler returns [`CommandResult`], where `data` must be JSON-serializable. - /// - /// This handler shape has no [`CommandContext`], so it can never call - /// [`CommandContext::dry_run`] — do not pair this with - /// [`CommandSpec::handles_dry_run`] (debug-asserted; see that field's docs). - #[must_use] - pub fn new(spec: CommandSpec, handler: F) -> Self - where - F: Fn(CredentialResolver, ValueMap) -> Fut + Send + Sync + 'static, - Fut: Future> + Send + 'static, - Output: Into + Send + 'static, - { - debug_assert!( - !spec.handles_dry_run, - "command {:?} sets handles_dry_run but RuntimeCommandSpec::new's handler \ - (CredentialResolver, args) has no CommandContext and can never check \ - CommandContext::dry_run(), so it would silently run its real side effects \ - under --dry-run; use RuntimeCommandSpec::new_with_context (or \ - new_typed_with_context to keep typed args) instead", - spec.name - ); - Self { - spec, - streaming_handler: None, - handler: Arc::new(move |context| { - let future = handler(context.credential, context.args); - Box::pin(async move { future.await.map(Into::into) }) - }), - } - } - - /// Creates a runtime command with the full invocation context. - #[must_use] - pub fn new_with_context(spec: CommandSpec, handler: F) -> Self - where - F: Fn(CommandContext) -> Fut + Send + Sync + 'static, - Fut: Future> + Send + 'static, - Output: Into + Send + 'static, - { - Self { - spec, - streaming_handler: None, - handler: Arc::new(move |context| { - let future = handler(context); - Box::pin(async move { future.await.map(Into::into) }) - }), - } - } - - /// Creates a streaming command that emits NDJSON events to stdout. - /// - /// The handler receives context and a [`StreamSender`]. It should call - /// `sender.send(event).await` for each progress event, then return `Ok(())`. - /// The engine writes each event as a JSON line; stdout is flushed after each. - #[must_use] - pub fn new_streaming(spec: CommandSpec, handler: F) -> Self - where - F: Fn(CommandContext, StreamSender) -> Fut + Send + Sync + 'static, - Fut: Future> + Send + 'static, - { - debug_assert!( - !spec.raw_output, - "command {:?} sets raw_output but RuntimeCommandSpec::new_streaming writes \ - chunked NDJSON events, which does not fit a single-verbatim-string contract; \ - raw_output is only supported on non-streaming commands", - spec.name - ); - let streaming: StreamingCommandHandler = Arc::new(move |context, sender| { - let future = handler(context, sender); - Box::pin(future) - }); - Self { - spec, - streaming_handler: Some(streaming), - handler: Arc::new(|_context| Box::pin(async { Ok(CommandResult::new(Value::Null)) })), - } - } - - /// Creates a runtime command with typed argument deserialization. - /// - /// The handler receives a lazy [`CredentialResolver`] and the deserialized - /// args struct. Use with `CommandSpec::from_args::()` to get end-to-end - /// type safety from argument definition through handler consumption. - /// - /// If the handler also needs the command path, middleware, or user-supplied - /// args, use [`RuntimeCommandSpec::new_typed_with_context`] (or - /// [`RuntimeCommandSpec::new_with_context`] with - /// [`CommandContext::typed_args`]) instead. - /// - /// This handler shape has no [`CommandContext`], so it can never call - /// [`CommandContext::dry_run`] — do not pair this with - /// [`CommandSpec::handles_dry_run`] (debug-asserted; see that field's docs). - #[must_use] - pub fn new_typed(spec: CommandSpec, handler: F) -> Self - where - T: clap::FromArgMatches + Send + 'static, - F: Fn(CredentialResolver, T) -> Fut + Send + Sync + 'static, - Fut: Future> + Send + 'static, - Output: Into + Send + 'static, - { - debug_assert!( - !spec.handles_dry_run, - "command {:?} sets handles_dry_run but RuntimeCommandSpec::new_typed's handler \ - (CredentialResolver, args) has no CommandContext and can never check \ - CommandContext::dry_run(), so it would silently run its real side effects \ - under --dry-run; use RuntimeCommandSpec::new_with_context (or \ - new_typed_with_context to keep typed args) instead", - spec.name - ); - let handler = Arc::new(handler); - Self { - spec, - handler: Arc::new(move |context| { - let credential = context.credential.clone(); - let parsed = T::from_arg_matches(context.raw_matches.as_ref()); - let handler = handler.clone(); - Box::pin(async move { - let args = parsed.map_err(|e| { - crate::CliCoreError::Message(format!("argument parse error: {e}")) - })?; - handler(credential, args).await.map(Into::into) - }) - }), - streaming_handler: None, - } - } - - /// Creates a runtime command with full context and typed argument - /// deserialization. - /// - /// Combines [`new_with_context`](RuntimeCommandSpec::new_with_context)'s - /// access to [`CommandContext`] (command path, middleware snapshot, - /// user-supplied args, [`CommandContext::dry_run`]) with - /// [`new_typed`](RuntimeCommandSpec::new_typed)'s automatic - /// deserialization: the engine parses `T` from the raw matches before - /// invoking the handler, so the handler never needs to call - /// [`CommandContext::typed_args`] itself. - /// - /// Use this instead of `new_with_context` + `context.typed_args::()` - /// when a command needs full context and wants eager, guaranteed-parsed - /// typed args rather than parsing on demand. Because the handler receives - /// a [`CommandContext`], this is a valid pairing with - /// [`CommandSpec::handles_dry_run`]. - /// - /// # Errors - /// - /// The returned handler surfaces a `CliCoreError::Message` if `T` fails to - /// deserialize from the parsed matches (this should not happen for args - /// generated by `CommandSpec::from_args::()`, since `clap` already - /// validated them during parsing). - #[must_use] - pub fn new_typed_with_context(spec: CommandSpec, handler: F) -> Self - where - T: clap::FromArgMatches + Send + 'static, - F: Fn(CommandContext, T) -> Fut + Send + Sync + 'static, - Fut: Future> + Send + 'static, - Output: Into + Send + 'static, - { - let handler = Arc::new(handler); - Self { - spec, - handler: Arc::new(move |context| { - let parsed = T::from_arg_matches(context.raw_matches.as_ref()); - let handler = handler.clone(); - Box::pin(async move { - let args = parsed.map_err(|e| { - crate::CliCoreError::Message(format!("argument parse error: {e}")) - })?; - handler(context, args).await.map(Into::into) - }) - }), - streaming_handler: None, - } - } - - /// Creates a streaming command with full context and typed argument - /// deserialization. - /// - /// Combines [`new_streaming`](RuntimeCommandSpec::new_streaming)'s NDJSON - /// event emission with [`new_typed`](RuntimeCommandSpec::new_typed)'s - /// automatic deserialization: the engine parses `T` from the raw matches - /// before invoking the handler. - /// - /// # Errors - /// - /// The returned handler surfaces a `CliCoreError::Message` if `T` fails to - /// deserialize from the parsed matches. - #[must_use] - pub fn new_typed_streaming(spec: CommandSpec, handler: F) -> Self - where - T: clap::FromArgMatches + Send + 'static, - F: Fn(CommandContext, T, StreamSender) -> Fut + Send + Sync + 'static, - Fut: Future> + Send + 'static, - { - debug_assert!( - !spec.raw_output, - "command {:?} sets raw_output but RuntimeCommandSpec::new_typed_streaming writes \ - chunked NDJSON events, which does not fit a single-verbatim-string contract; \ - raw_output is only supported on non-streaming commands", - spec.name - ); - let handler = Arc::new(handler); - let streaming: StreamingCommandHandler = Arc::new(move |context, sender| { - let parsed = T::from_arg_matches(context.raw_matches.as_ref()); - let handler = handler.clone(); - Box::pin(async move { - let args = parsed.map_err(|e| { - crate::CliCoreError::Message(format!("argument parse error: {e}")) - })?; - handler(context, args, sender).await - }) - }); - Self { - spec, - streaming_handler: Some(streaming), - handler: Arc::new(|_context| Box::pin(async { Ok(CommandResult::new(Value::Null)) })), - } - } -} - -/// Executable command group with runtime children. -/// -/// Construct with [`RuntimeGroupSpec::new`], then chain `with_*` methods — -/// never as a struct literal. `#[non_exhaustive]` enforces this so the engine -/// can add fields without a breaking release. -#[derive(Clone, Debug, Default)] -#[non_exhaustive] -pub struct RuntimeGroupSpec { - /// Declarative group metadata. - pub group: GroupSpec, - /// Executable leaf commands under this group. - pub commands: Vec, - /// Executable nested groups under this group. - pub groups: Vec, -} - -impl RuntimeGroupSpec { - /// Creates a runtime group from declarative group metadata. - #[must_use] - pub fn new(group: GroupSpec) -> Self { - Self { - group, - ..Self::default() - } - } - - /// Adds one executable leaf command. - #[must_use] - pub fn with_command(mut self, command: RuntimeCommandSpec) -> Self { - self.commands.push(command); - self - } - - /// Adds one executable nested group. - #[must_use] - pub fn with_group(mut self, group: RuntimeGroupSpec) -> Self { - self.groups.push(group); - self - } - - /// Builds the `clap` command for parser registration. - #[must_use] - pub fn clap_command(&self) -> Command { - let mut command = Command::new(self.group.name.clone()).about(self.group.short.clone()); - if let Some(long) = &self.group.long - && !long.is_empty() - { - command = command.long_about(long.clone()); - } - for alias in &self.group.aliases { - command = command.alias(alias.clone()); - } - if self.group.hidden { - command = command.hide(true); - } - for group in &self.groups { - command = command.subcommand(group.clap_command()); - } - for child in &self.commands { - command = command.subcommand(child.spec.clap_command()); - } - command - } - - pub(crate) fn register_commands( - &self, - prefix: &mut Vec, - out: &mut BTreeMap, - ) { - prefix.push(self.group.name.clone()); - for group in &self.groups { - group.register_commands(prefix, out); - } - for command in &self.commands { - prefix.push(command.spec.name.clone()); - out.insert(prefix.join(":"), command.clone()); - prefix.pop(); - } - prefix.pop(); - } -} - -/// Extracts the colon-separated command path from parsed `clap` matches. -#[must_use] -pub fn command_path_from_matches(root_name: &str, matches: &ArgMatches) -> String { - let mut parts = Vec::new(); - let mut current = matches; - while let Some((name, submatches)) = current.subcommand() { - if name != root_name { - parts.push(name.to_owned()); - } - current = submatches; - } - parts.join(":") -} - -/// Builds a colon-separated command path from path parts. -/// -/// The optional annotation is used only for isolated single-command tests. -#[must_use] -pub fn command_path_from_parts(parts: &[impl AsRef], path_annotation: Option<&str>) -> String { - if parts.is_empty() { - return String::new(); - } - if parts.len() > 1 { - return parts[1..] - .iter() - .map(AsRef::as_ref) - .collect::>() - .join(":"); - } - path_annotation - .filter(|annotation| !annotation.is_empty()) - .map_or_else(|| parts[0].as_ref().to_owned(), ToOwned::to_owned) -} - -/// Returns the deepest subcommand matches. -#[must_use] -pub fn leaf_matches(matches: &ArgMatches) -> &ArgMatches { - let mut current = matches; - while let Some((_, submatches)) = current.subcommand() { - current = submatches; - } - current -} - -/// Converts parsed command arguments into the JSON-ish map consumed by middleware. -/// -/// When `changed_only` is true, only arguments that came from the command line -/// are included. This is the user-args map used by authz and audit. -#[must_use] -pub fn command_args_from_matches( - matches: &ArgMatches, - spec: &CommandSpec, - changed_only: bool, -) -> ValueMap { - let mut args = ValueMap::new(); - for arg in &spec.args { - let id = arg.get_id().to_string(); - let changed = matches - .value_source(&id) - .is_some_and(|source| source == clap::parser::ValueSource::CommandLine); - if changed_only && !changed { - continue; - } - if let Some(value) = arg_value_from_matches(matches, arg, &id) { - args.insert(id, value); - } - } - args -} - -fn arg_value_from_matches(matches: &ArgMatches, flag: &Arg, id: &str) -> Option { - matches.value_source(id)?; - - if matches!(flag.get_action(), ArgAction::SetTrue | ArgAction::SetFalse) - && let Some(value) = matches.get_one::(id) - { - return Some(Value::Bool(*value)); - } - - if let Some(value) = typed_arg_value_from_matches(matches, id) { - return Some(value); - } - - if let Some(values) = matches.get_raw(id) { - let rendered = values - .map(|value| value.to_string_lossy().into_owned()) - .collect::>(); - return match rendered.as_slice() { - [] => None, - [single] => Some(Value::String(single.clone())), - _ => Some(Value::Array( - rendered.into_iter().map(Value::String).collect(), - )), - }; - } - - if let Some(value) = matches.get_one::(id) { - return Some(Value::String(value.clone())); - } - if let Some(value) = matches.get_one::(id) { - return Some(serde_json::json!(value)); - } - if let Some(value) = matches.get_one::(id) { - return Some(serde_json::json!(value)); - } - if let Some(value) = matches.get_one::(id) { - return Some(serde_json::json!(value)); - } - None -} - -fn typed_arg_value_from_matches(matches: &ArgMatches, id: &str) -> Option { - typed_values::(matches, id, Value::Bool) - .or_else(|| typed_values::(matches, id, |value| Value::Number(value.into()))) - .or_else(|| typed_values::(matches, id, |value| Value::Number(value.into()))) - .or_else(|| typed_values::(matches, id, |value| Value::Number(value.into()))) - .or_else(|| typed_values::(matches, id, |value| Value::Number(value.into()))) - .or_else(|| typed_values::(matches, id, |value| Value::Number(value.into()))) - .or_else(|| typed_values::(matches, id, |value| Value::Number(value.into()))) - .or_else(|| typed_values::(matches, id, |value| Value::Number(value.into()))) - .or_else(|| typed_values::(matches, id, |value| Value::Number(value.into()))) - .or_else(|| { - typed_values::(matches, id, |value| { - u64::try_from(value).map_or(Value::Null, |value| Value::Number(value.into())) - }) - }) - .or_else(|| { - typed_values::(matches, id, |value| { - Number::from_f64(value).map_or(Value::Null, Value::Number) - }) - }) - .or_else(|| { - typed_values::(matches, id, |value| { - Number::from_f64(f64::from(value)).map_or(Value::Null, Value::Number) - }) - }) - .or_else(|| typed_values::(matches, id, Value::String)) -} - -fn typed_values(matches: &ArgMatches, id: &str, to_value: impl Fn(T) -> Value) -> Option -where - T: Clone + Send + Sync + 'static, -{ - let Ok(Some(values)) = matches.try_get_many::(id) else { - return None; - }; - let values = values.cloned().map(to_value).collect::>(); - match values.as_slice() { - [] => None, - [single] => Some(single.clone()), - _ => Some(Value::Array(values)), - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn command_spec_with_feature_flag_sets_key_and_stage() { - let spec = - CommandSpec::new("list", "List things").with_feature_flag("my-flag", Stage::Beta); - - let flag = spec - .feature_flag - .as_ref() - .expect("feature flag should be set"); - assert_eq!(flag.key, "my-flag"); - assert_eq!(flag.stage, Stage::Beta); - } - - #[test] - fn command_spec_feature_flag_defaults_to_none() { - let spec = CommandSpec::new("list", "List things"); - - assert!(spec.feature_flag.is_none()); - } - - #[test] - fn group_spec_with_feature_flag_sets_key_and_stage() { - let group = GroupSpec::new("project", "Manage projects") - .with_feature_flag("my-flag", Stage::Experimental); - - let flag = group - .feature_flag - .as_ref() - .expect("feature flag should be set"); - assert_eq!(flag.key, "my-flag"); - assert_eq!(flag.stage, Stage::Experimental); - } - - #[test] - fn group_spec_feature_flag_defaults_to_none() { - let group = GroupSpec::new("project", "Manage projects"); - - assert!(group.feature_flag.is_none()); - } - - #[test] - fn command_spec_with_arg_group_registers_group_on_clap_command() { - let spec = CommandSpec::new("update", "Update a thing") - .with_arg(Arg::new("a").long("a")) - .with_arg(Arg::new("b").long("b")) - .with_arg_group(ArgGroup::new("ab").args(["a", "b"]).required(true)); - - assert!( - spec.clap_command() - .try_get_matches_from(["update"]) - .is_err(), - "neither `a` nor `b` present should fail the required group" - ); - assert!( - spec.clap_command() - .try_get_matches_from(["update", "--a", "x"]) - .is_ok() - ); - } - - #[test] - fn command_spec_from_args_preserves_derive_arg_group() { - #[derive(clap::Args)] - #[group(required = true, multiple = false)] - struct ExclusiveArgs { - #[arg(long)] - one: bool, - #[arg(long)] - two: bool, - } - - let spec = CommandSpec::from_args::("bump", "Bump one thing"); - - assert_eq!(spec.arg_groups.len(), 1); - let group = &spec.arg_groups[0]; - assert!(group.is_required_set()); - assert_eq!(group.get_args().count(), 2); - } - - #[test] - fn command_spec_from_args_preserves_version_argument() { - #[derive(clap::Args)] - struct ReleaseArgs { - #[arg(long)] - version: String, - } - - let spec = CommandSpec::from_args::("release", "Create a release"); - - assert!( - spec.clap_command() - .try_get_matches_from(["release", "--version", "1.0.0"]) - .is_ok(), - "typed command arguments named `version` must remain available as `--version`" - ); - } -} diff --git a/cli-engine/src/command/group.rs b/cli-engine/src/command/group.rs new file mode 100644 index 0000000..057e01d --- /dev/null +++ b/cli-engine/src/command/group.rs @@ -0,0 +1,231 @@ +use std::collections::BTreeMap; + +use clap::Command; + +use super::{CommandSpec, RuntimeCommandSpec}; +use crate::{FeatureFlag, Stage}; + +/// Declarative command group metadata. +/// +/// Groups are noun-based containers. They do not run business logic directly; +/// when invoked bare, the CLI renders group help. +/// +/// Construct with [`GroupSpec::new`], then configure with the `with_*` builder +/// methods — never as a struct literal. `#[non_exhaustive]` enforces this so +/// the engine can add fields later without a breaking release. +#[derive(Clone, Debug, Default)] +#[non_exhaustive] +pub struct GroupSpec { + /// Group command name. + pub name: String, + /// One-line group description. + pub short: String, + /// Optional long help text. + pub long: Option, + /// Alternate group names accepted by the parser. + pub aliases: Vec, + /// Whether the group runs but is hidden from discovery output. + pub hidden: bool, + /// Declarative child commands used for static tree construction. + pub commands: Vec, + /// Declarative nested groups used for static tree construction. + pub groups: Vec, + /// This group's own feature-flag declaration, if any. + /// + /// `None` means the group has no explicit stage declaration of its own, in + /// which case it inherits its effective stage from its nearest ancestor + /// (enclosing group, then module — nearest declaration wins), implicitly + /// resolving to [`Stage::Ga`] if nothing in the ancestor chain declares a + /// flag either; see [`Stage`]'s documentation for why that is its default. + /// Set with [`with_feature_flag`](GroupSpec::with_feature_flag). This field + /// only records the group's own declaration; cascading resolution against + /// the ancestor chain happens when a [`Cli`](crate::Cli) mounts the + /// enclosing module or parent group. + pub feature_flag: Option, +} + +impl GroupSpec { + /// Creates a command group with the required name and one-line help. + #[must_use] + pub fn new(name: impl Into, short: impl Into) -> Self { + Self { + name: name.into(), + short: short.into(), + ..Self::default() + } + } + + /// Sets expanded group help. + #[must_use] + pub fn with_long(mut self, long: impl Into) -> Self { + self.long = Some(long.into()); + self + } + + /// Adds one group alias. + #[must_use] + pub fn with_alias(mut self, alias: impl Into) -> Self { + self.aliases.push(alias.into()); + self + } + + /// Hides or shows this group in discovery output. + #[must_use] + pub fn hidden(mut self, hidden: bool) -> Self { + self.hidden = hidden; + self + } + + /// Adds one declarative child command. + #[must_use] + pub fn with_command(mut self, command: CommandSpec) -> Self { + self.commands.push(command); + self + } + + /// Adds one declarative nested group. + #[must_use] + pub fn with_group(mut self, group: GroupSpec) -> Self { + self.groups.push(group); + self + } + + /// Declares this group's own feature flag: the key used for policy overrides + /// and introspection, and the stage at which it becomes visible. + #[must_use] + pub fn with_feature_flag(mut self, key: impl Into, stage: Stage) -> Self { + self.feature_flag = Some(FeatureFlag::new(key, stage)); + self + } + + /// Builds the `clap` command for parser registration. + #[must_use] + pub fn clap_command(&self) -> Command { + let mut command = Command::new(self.name.clone()).about(self.short.clone()); + if let Some(long) = &self.long + && !long.is_empty() + { + command = command.long_about(long.clone()); + } + for alias in &self.aliases { + command = command.alias(alias.clone()); + } + if self.hidden { + command = command.hide(true); + } + for group in &self.groups { + command = command.subcommand(group.clap_command()); + } + for child in &self.commands { + command = command.subcommand(child.clap_command()); + } + command + } +} + +/// Executable command group with runtime children. +/// +/// Construct with [`RuntimeGroupSpec::new`], then chain `with_*` methods — +/// never as a struct literal. `#[non_exhaustive]` enforces this so the engine +/// can add fields without a breaking release. +#[derive(Clone, Debug, Default)] +#[non_exhaustive] +pub struct RuntimeGroupSpec { + /// Declarative group metadata. + pub group: GroupSpec, + /// Executable leaf commands under this group. + pub commands: Vec, + /// Executable nested groups under this group. + pub groups: Vec, +} + +impl RuntimeGroupSpec { + /// Creates a runtime group from declarative group metadata. + #[must_use] + pub fn new(group: GroupSpec) -> Self { + Self { + group, + ..Self::default() + } + } + + /// Adds one executable leaf command. + #[must_use] + pub fn with_command(mut self, command: RuntimeCommandSpec) -> Self { + self.commands.push(command); + self + } + + /// Adds one executable nested group. + #[must_use] + pub fn with_group(mut self, group: RuntimeGroupSpec) -> Self { + self.groups.push(group); + self + } + + /// Builds the `clap` command for parser registration. + #[must_use] + pub fn clap_command(&self) -> Command { + let mut command = Command::new(self.group.name.clone()).about(self.group.short.clone()); + if let Some(long) = &self.group.long + && !long.is_empty() + { + command = command.long_about(long.clone()); + } + for alias in &self.group.aliases { + command = command.alias(alias.clone()); + } + if self.group.hidden { + command = command.hide(true); + } + for group in &self.groups { + command = command.subcommand(group.clap_command()); + } + for child in &self.commands { + command = command.subcommand(child.spec.clap_command()); + } + command + } + + pub(crate) fn register_commands( + &self, + prefix: &mut Vec, + out: &mut BTreeMap, + ) { + prefix.push(self.group.name.clone()); + for group in &self.groups { + group.register_commands(prefix, out); + } + for command in &self.commands { + prefix.push(command.spec.name.clone()); + out.insert(prefix.join(":"), command.clone()); + prefix.pop(); + } + prefix.pop(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn group_spec_with_feature_flag_sets_key_and_stage() { + let group = GroupSpec::new("project", "Manage projects") + .with_feature_flag("my-flag", Stage::Experimental); + + let flag = group + .feature_flag + .as_ref() + .expect("feature flag should be set"); + assert_eq!(flag.key, "my-flag"); + assert_eq!(flag.stage, Stage::Experimental); + } + + #[test] + fn group_spec_feature_flag_defaults_to_none() { + let group = GroupSpec::new("project", "Manage projects"); + + assert!(group.feature_flag.is_none()); + } +} diff --git a/cli-engine/src/command/matches.rs b/cli-engine/src/command/matches.rs new file mode 100644 index 0000000..541f0ea --- /dev/null +++ b/cli-engine/src/command/matches.rs @@ -0,0 +1,159 @@ +use clap::{Arg, ArgAction, ArgMatches}; +use serde_json::{Number, Value}; + +use super::CommandSpec; +use crate::middleware::ValueMap; + +/// Extracts the colon-separated command path from parsed `clap` matches. +#[must_use] +pub fn command_path_from_matches(root_name: &str, matches: &ArgMatches) -> String { + let mut parts = Vec::new(); + let mut current = matches; + while let Some((name, submatches)) = current.subcommand() { + if name != root_name { + parts.push(name.to_owned()); + } + current = submatches; + } + parts.join(":") +} + +/// Builds a colon-separated command path from path parts. +/// +/// The optional annotation is used only for isolated single-command tests. +#[must_use] +pub fn command_path_from_parts(parts: &[impl AsRef], path_annotation: Option<&str>) -> String { + if parts.is_empty() { + return String::new(); + } + if parts.len() > 1 { + return parts[1..] + .iter() + .map(AsRef::as_ref) + .collect::>() + .join(":"); + } + path_annotation + .filter(|annotation| !annotation.is_empty()) + .map_or_else(|| parts[0].as_ref().to_owned(), ToOwned::to_owned) +} + +/// Returns the deepest subcommand matches. +#[must_use] +pub fn leaf_matches(matches: &ArgMatches) -> &ArgMatches { + let mut current = matches; + while let Some((_, submatches)) = current.subcommand() { + current = submatches; + } + current +} + +/// Converts parsed command arguments into the JSON-ish map consumed by middleware. +/// +/// When `changed_only` is true, only arguments that came from the command line +/// are included. This is the user-args map used by authz and audit. +#[must_use] +pub fn command_args_from_matches( + matches: &ArgMatches, + spec: &CommandSpec, + changed_only: bool, +) -> ValueMap { + let mut args = ValueMap::new(); + for arg in &spec.args { + let id = arg.get_id().to_string(); + let changed = matches + .value_source(&id) + .is_some_and(|source| source == clap::parser::ValueSource::CommandLine); + if changed_only && !changed { + continue; + } + if let Some(value) = arg_value_from_matches(matches, arg, &id) { + args.insert(id, value); + } + } + args +} + +fn arg_value_from_matches(matches: &ArgMatches, flag: &Arg, id: &str) -> Option { + matches.value_source(id)?; + + if matches!(flag.get_action(), ArgAction::SetTrue | ArgAction::SetFalse) + && let Some(value) = matches.get_one::(id) + { + return Some(Value::Bool(*value)); + } + + if let Some(value) = typed_arg_value_from_matches(matches, id) { + return Some(value); + } + + if let Some(values) = matches.get_raw(id) { + let rendered = values + .map(|value| value.to_string_lossy().into_owned()) + .collect::>(); + return match rendered.as_slice() { + [] => None, + [single] => Some(Value::String(single.clone())), + _ => Some(Value::Array( + rendered.into_iter().map(Value::String).collect(), + )), + }; + } + + if let Some(value) = matches.get_one::(id) { + return Some(Value::String(value.clone())); + } + if let Some(value) = matches.get_one::(id) { + return Some(serde_json::json!(value)); + } + if let Some(value) = matches.get_one::(id) { + return Some(serde_json::json!(value)); + } + if let Some(value) = matches.get_one::(id) { + return Some(serde_json::json!(value)); + } + None +} + +fn typed_arg_value_from_matches(matches: &ArgMatches, id: &str) -> Option { + typed_values::(matches, id, Value::Bool) + .or_else(|| typed_values::(matches, id, |value| Value::Number(value.into()))) + .or_else(|| typed_values::(matches, id, |value| Value::Number(value.into()))) + .or_else(|| typed_values::(matches, id, |value| Value::Number(value.into()))) + .or_else(|| typed_values::(matches, id, |value| Value::Number(value.into()))) + .or_else(|| typed_values::(matches, id, |value| Value::Number(value.into()))) + .or_else(|| typed_values::(matches, id, |value| Value::Number(value.into()))) + .or_else(|| typed_values::(matches, id, |value| Value::Number(value.into()))) + .or_else(|| typed_values::(matches, id, |value| Value::Number(value.into()))) + .or_else(|| { + typed_values::(matches, id, |value| { + u64::try_from(value).map_or(Value::Null, |value| Value::Number(value.into())) + }) + }) + .or_else(|| { + typed_values::(matches, id, |value| { + Number::from_f64(value).map_or(Value::Null, Value::Number) + }) + }) + .or_else(|| { + typed_values::(matches, id, |value| { + Number::from_f64(f64::from(value)).map_or(Value::Null, Value::Number) + }) + }) + .or_else(|| typed_values::(matches, id, Value::String)) +} + +fn typed_values(matches: &ArgMatches, id: &str, to_value: impl Fn(T) -> Value) -> Option +where + T: Clone + Send + Sync + 'static, +{ + let Ok(Some(values)) = matches.try_get_many::(id) else { + return None; + }; + let values = values.cloned().map(to_value).collect::>(); + match values.as_slice() { + [] => None, + [single] => Some(single.clone()), + _ => Some(Value::Array(values)), + } +} diff --git a/cli-engine/src/command/mod.rs b/cli-engine/src/command/mod.rs new file mode 100644 index 0000000..5488d2d --- /dev/null +++ b/cli-engine/src/command/mod.rs @@ -0,0 +1,311 @@ +use std::{future::Future, pin::Pin, sync::Arc}; + +use serde_json::Value; +use tokio::sync::mpsc; + +use crate::{ + Credential, CredentialResolver, Middleware, Result, middleware::ValueMap, output::NextAction, +}; + +mod group; +mod matches; +mod runtime; +mod spec; + +pub use group::{GroupSpec, RuntimeGroupSpec}; +pub use matches::{ + command_args_from_matches, command_path_from_matches, command_path_from_parts, leaf_matches, +}; +pub use runtime::RuntimeCommandSpec; +pub use spec::{CommandSpec, PaginationConfig}; + +/// Sender half for streaming command output. +/// +/// Streaming handlers call [`StreamSender::send`] for each progress event. +/// The engine drains the channel and writes each event as an NDJSON line. +#[derive(Clone, Debug)] +pub struct StreamSender(pub(crate) mpsc::Sender); + +impl StreamSender { + /// Sends one event. Silently drops the event if the receiver is gone. + pub async fn send(&self, event: Value) { + drop(self.0.send(event).await); + } +} + +/// Boxed future returned by runtime command handlers. +pub type CommandFuture = Pin> + Send>>; +/// Shared command handler used by [`RuntimeCommandSpec`]. +pub type CommandHandler = Arc CommandFuture + Send + Sync>; + +/// Boxed future returned by streaming command handlers. +pub type StreamingCommandFuture = Pin> + Send>>; +/// Shared streaming handler: receives context and an event sender; returns when the stream ends. +pub type StreamingCommandHandler = + Arc StreamingCommandFuture + Send + Sync>; + +/// Data returned by a command handler. +/// +/// Command handlers should return renderable data and keep output metadata on +/// [`CommandSpec`]. The metadata field is reserved for future command-result +/// extensions that are not known when the command is registered. +/// +/// Construct with [`CommandResult::new`], then chain `with_*` methods — +/// never as a struct literal. `#[non_exhaustive]` enforces this so the engine +/// can add fields without a breaking release. +#[derive(Clone, Debug, PartialEq)] +#[non_exhaustive] +pub struct CommandResult { + /// JSON data rendered by the configured output formatter. + pub data: Value, + /// Optional command-result extension metadata. + pub metadata: CommandResultMetadata, +} + +impl CommandResult { + /// Creates a command result from renderable JSON data. + #[must_use] + pub fn new(data: Value) -> Self { + Self { + data, + metadata: CommandResultMetadata::default(), + } + } + + /// Attaches suggested follow-up actions to this result. + #[must_use] + pub fn with_next_actions(mut self, actions: Vec) -> Self { + self.metadata.next_actions = actions; + self + } + + /// Marks this result as a dry-run preview outcome. + /// + /// Call only when the handler actually skipped its mutating step because + /// [`CommandContext::dry_run`] was `true`. This requires the command to + /// have opted in via [`CommandSpec::handles_dry_run`] — otherwise + /// middleware never invokes the handler under `--dry-run` in the first + /// place. Middleware tags the audit/activity outcome as `dry-run` instead + /// of `ok` and marks the rendered envelope accordingly. + #[must_use] + pub fn with_dry_run(mut self) -> Self { + self.metadata.dry_run = true; + self + } +} + +impl From for CommandResult { + fn from(data: Value) -> Self { + Self::new(data) + } +} + +/// Optional metadata a command can attach to its result. +#[non_exhaustive] +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct CommandResultMetadata { + /// Suggested follow-up actions for the caller. + pub next_actions: Vec, + /// Set by [`CommandResult::with_dry_run`] when a + /// [`handles_dry_run`](CommandSpec::handles_dry_run) handler skipped its + /// mutating step. Middleware tags the audit/activity outcome and envelope + /// as `dry-run` instead of `ok` when this is `true`. + pub dry_run: bool, +} + +/// Runtime context passed to advanced command handlers. +/// +/// Most commands can use [`RuntimeCommandSpec::new`] and receive just the +/// credential and effective args. Use this context when a command needs the +/// colon path, user-supplied args, or a snapshot of middleware state. +/// +/// This struct is constructed by the framework during command dispatch. +/// Consumer code receives it in handler closures and should not construct it +/// directly. +#[derive(Clone, Debug)] +#[non_exhaustive] +pub struct CommandContext { + /// Lazy credential resolver. + pub credential: CredentialResolver, + /// Effective arguments, including defaults and framework-injected values. + pub args: ValueMap, + /// Arguments explicitly supplied by the user. + pub user_args: ValueMap, + /// Colon-separated command path such as `project:list`. + pub command_path: String, + /// Middleware snapshot for this invocation. + pub middleware: Middleware, + /// Raw `clap` matches for typed argument deserialization via derive. + pub raw_matches: Arc, +} + +impl CommandContext { + /// Returns the per-application config file as loaded at startup. + /// + /// Read a consumer-owned section with + /// [`ConfigFile::section`](crate::config::ConfigFile::section), for example + /// `ctx.config().section::("deploy")?`. Engine-reserved + /// settings are available via + /// [`ConfigFile::engine`](crate::config::ConfigFile::engine). + /// + /// **Snapshot semantics**: this is the config loaded once when + /// [`crate::cli::Cli::new`] was called. Changes made by `config set` during the same process + /// invocation (e.g. from a previous `Cli::run`) are not reflected here; + /// restart the CLI (a new `Cli::new`) to pick them up. For a one-shot CLI + /// process this is always the current on-disk state. + #[must_use] + pub fn config(&self) -> &crate::config::ConfigFile { + &self.middleware.config + } + + /// Returns whether `--dry-run` was passed for this invocation. + /// + /// Only meaningful for commands that opted in via + /// [`CommandSpec::handles_dry_run`] — other mutating commands never reach + /// their handler under `--dry-run` at all, so there's nothing to branch + /// on. An opted-in handler should run its real validation unconditionally + /// and use this only to skip the actual mutating I/O, returning a preview + /// result tagged with [`CommandResult::with_dry_run`]. + #[must_use] + pub fn dry_run(&self) -> bool { + self.middleware.dry_run + } + + /// Returns the resolved interactivity mode for this invocation. + /// + /// Use this to decide whether to prompt for missing inputs, show progress + /// spinners, or offer interactive choices. When `false`, the command should + /// fail with a descriptive error if required inputs are missing. + #[must_use] + pub fn is_interactive(&self) -> bool { + self.middleware.interactive + } + + /// Returns the resolved [`InteractivityMode`](crate::InteractivityMode). + /// + /// Equivalent to [`is_interactive`](Self::is_interactive) but returns the + /// enum for pattern matching. + #[must_use] + pub fn interactivity_mode(&self) -> crate::InteractivityMode { + self.middleware.interactive.into() + } + + /// Resolves the active environment's merged TOML table for this + /// invocation, as an [`EnvSource`](crate::env_config::EnvSource). + /// + /// The active environment name is `self.middleware.env`, seeded at startup + /// from the persisted active environment or configured default and + /// overridden per invocation by the global `--env` flag. Resolution merges + /// the compiled-in table and the `environments.toml` file layer (file + /// wins). Use this for generic introspection (see the built-in `env info` + /// command); for a typed section with the app-scoped environment-variable + /// override tier applied, use + /// [`environment_config`](Self::environment_config) instead. + /// + /// # Blocking + /// + /// When the `environments.toml` file layer is enabled, this performs + /// synchronous filesystem I/O via + /// [`Environments::source`](crate::environments::Environments::source). + /// Call it once per invocation and reuse the result rather than calling it + /// repeatedly inside an async handler on a latency-sensitive path. + /// + /// # Errors + /// + /// Returns an error if no environment system was registered via + /// [`CliConfig::with_environments`](crate::CliConfig::with_environments) or + /// if the active name does not resolve to a known environment. + pub fn environment(&self) -> Result { + let environments = self.middleware.environments.as_ref().ok_or_else(|| { + crate::error::CliCoreError::message("no environment system configured") + })?; + environments.source(&self.middleware.env) + } + + /// Resolves the active environment into a typed + /// [`EnvConfig`](crate::env_config::EnvConfig) section, with the + /// app-scoped environment-variable override tier applied (see + /// [`Environments::resolve`](crate::environments::Environments::resolve)). + /// + /// # Blocking + /// + /// See [`environment`](Self::environment). + /// + /// # Errors + /// + /// Returns an error under the same conditions as + /// [`environment`](Self::environment), or when a field's present value + /// fails to convert to its type, or a required field has no value in any + /// source and no default. + pub fn environment_config( + &self, + ) -> std::result::Result { + let environments = self.middleware.environments.as_ref().ok_or_else(|| { + crate::error::CliCoreError::message("no environment system configured") + })?; + environments.resolve(&self.middleware.env) + } + + /// Deserializes the raw argument matches into a typed args struct. + /// + /// Use this with `#[derive(clap::Args)]` structs to get type-safe access + /// to command arguments instead of working with the `ValueMap` directly. + /// + /// # Errors + /// + /// Returns an error if the matches cannot be deserialized into `T`. + pub fn typed_args(&self) -> Result { + T::from_arg_matches(self.raw_matches.as_ref()) + .map_err(|e| crate::CliCoreError::Message(format!("argument parse error: {e}"))) + } + + /// Resolves the credential for this command, triggering the auth flow on + /// first use and memoizing the result. + /// + /// Convenience wrapper over [`self.credential.resolve()`](CredentialResolver::resolve). + /// + /// # Errors + /// + /// Returns an error when the command is marked `no_auth`, or when the auth + /// provider fails to produce a credential. + pub async fn credential(&self) -> Result { + self.credential.resolve().await + } + + /// Resolves the credential when one is available, returning `Ok(None)` for + /// no-auth commands. + /// + /// Convenience wrapper over [`self.credential.try_resolve()`](CredentialResolver::try_resolve). + /// + /// # Errors + /// + /// Propagates the auth provider error when resolution is attempted and fails. + pub async fn try_credential(&self) -> Result> { + self.credential.try_resolve().await + } + + /// Resolves a credential that additionally covers `extra` scopes, on top of + /// the command's declared scopes. + /// + /// Use this when the required scopes are only known at runtime (for example + /// a generic API caller that derives scopes from the target endpoint). A + /// scope-aware auth provider re-authenticates when the cached token does not + /// already cover the requested set. + /// + /// Convenience wrapper over + /// [`self.credential.resolve_with_scopes()`](CredentialResolver::resolve_with_scopes). + /// + /// If the handler also issues HTTP requests through the transport bearer + /// injector, call this **before** the first request: the injector resolves + /// and caches a scope-unaware token, so stepping up afterwards would not + /// affect requests it already authorized. See + /// [`CredentialResolver::resolve_with_scopes`] for the full ordering note. + /// + /// # Errors + /// + /// Returns an error when the command is marked `no_auth`, or when the auth + /// provider fails to produce a credential. + pub async fn credential_with_scopes(&self, extra: &[String]) -> Result { + self.credential.resolve_with_scopes(extra).await + } +} diff --git a/cli-engine/src/command/runtime.rs b/cli-engine/src/command/runtime.rs new file mode 100644 index 0000000..d13a8c3 --- /dev/null +++ b/cli-engine/src/command/runtime.rs @@ -0,0 +1,271 @@ +use std::future::Future; +use std::sync::Arc; + +use serde_json::Value; + +use super::{ + CommandContext, CommandHandler, CommandResult, CommandSpec, StreamSender, + StreamingCommandHandler, +}; +use crate::{CredentialResolver, Result, middleware::ValueMap}; + +/// Executable leaf command. +/// +/// `RuntimeCommandSpec` pairs a [`CommandSpec`] with async business logic. +/// This split keeps metadata inspectable for help/search/schema generation +/// before the handler ever runs. +/// +/// Use [`RuntimeCommandSpec::new_streaming`] for commands that emit incremental +/// NDJSON progress events (e.g. long-running deployments with `--follow`). +/// +/// Construct with one of the `new*` constructors — never as a struct literal. +/// Literal construction would bypass the `handles_dry_run`/handler-shape +/// misuse checks those constructors debug-assert. `#[non_exhaustive]` also +/// means the engine can add fields without a breaking release. +#[derive(Clone)] +#[non_exhaustive] +pub struct RuntimeCommandSpec { + /// Declarative command metadata. + pub spec: CommandSpec, + /// Async command implementation. + pub handler: CommandHandler, + /// Optional streaming handler. When set, the engine writes NDJSON events + /// to stdout as they arrive instead of collecting a single envelope. + pub streaming_handler: Option, +} + +impl std::fmt::Debug for RuntimeCommandSpec { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("RuntimeCommandSpec") + .field("spec", &self.spec) + .field("is_streaming", &self.streaming_handler.is_some()) + .finish_non_exhaustive() + } +} + +impl RuntimeCommandSpec { + /// Creates a runtime command with the common handler shape. + /// + /// The handler receives a lazy [`CredentialResolver`] and the effective args. + /// Call `resolver.resolve().await?` only when the command actually needs a + /// credential; commands that ignore it never trigger an auth flow. The + /// handler returns [`CommandResult`], where `data` must be JSON-serializable. + /// + /// This handler shape has no [`CommandContext`], so it can never call + /// [`CommandContext::dry_run`] — do not pair this with + /// [`CommandSpec::handles_dry_run`] (debug-asserted; see that field's docs). + #[must_use] + pub fn new(spec: CommandSpec, handler: F) -> Self + where + F: Fn(CredentialResolver, ValueMap) -> Fut + Send + Sync + 'static, + Fut: Future> + Send + 'static, + Output: Into + Send + 'static, + { + debug_assert!( + !spec.handles_dry_run, + "command {:?} sets handles_dry_run but RuntimeCommandSpec::new's handler \ + (CredentialResolver, args) has no CommandContext and can never check \ + CommandContext::dry_run(), so it would silently run its real side effects \ + under --dry-run; use RuntimeCommandSpec::new_with_context (or \ + new_typed_with_context to keep typed args) instead", + spec.name + ); + Self { + spec, + streaming_handler: None, + handler: Arc::new(move |context| { + let future = handler(context.credential, context.args); + Box::pin(async move { future.await.map(Into::into) }) + }), + } + } + + /// Creates a runtime command with the full invocation context. + #[must_use] + pub fn new_with_context(spec: CommandSpec, handler: F) -> Self + where + F: Fn(CommandContext) -> Fut + Send + Sync + 'static, + Fut: Future> + Send + 'static, + Output: Into + Send + 'static, + { + Self { + spec, + streaming_handler: None, + handler: Arc::new(move |context| { + let future = handler(context); + Box::pin(async move { future.await.map(Into::into) }) + }), + } + } + + /// Creates a streaming command that emits NDJSON events to stdout. + /// + /// The handler receives context and a [`StreamSender`]. It should call + /// `sender.send(event).await` for each progress event, then return `Ok(())`. + /// The engine writes each event as a JSON line; stdout is flushed after each. + #[must_use] + pub fn new_streaming(spec: CommandSpec, handler: F) -> Self + where + F: Fn(CommandContext, StreamSender) -> Fut + Send + Sync + 'static, + Fut: Future> + Send + 'static, + { + debug_assert!( + !spec.raw_output, + "command {:?} sets raw_output but RuntimeCommandSpec::new_streaming writes \ + chunked NDJSON events, which does not fit a single-verbatim-string contract; \ + raw_output is only supported on non-streaming commands", + spec.name + ); + let streaming: StreamingCommandHandler = Arc::new(move |context, sender| { + let future = handler(context, sender); + Box::pin(future) + }); + Self { + spec, + streaming_handler: Some(streaming), + handler: Arc::new(|_context| Box::pin(async { Ok(CommandResult::new(Value::Null)) })), + } + } + + /// Creates a runtime command with typed argument deserialization. + /// + /// The handler receives a lazy [`CredentialResolver`] and the deserialized + /// args struct. Use with `CommandSpec::from_args::()` to get end-to-end + /// type safety from argument definition through handler consumption. + /// + /// If the handler also needs the command path, middleware, or user-supplied + /// args, use [`RuntimeCommandSpec::new_typed_with_context`] (or + /// [`RuntimeCommandSpec::new_with_context`] with + /// [`CommandContext::typed_args`]) instead. + /// + /// This handler shape has no [`CommandContext`], so it can never call + /// [`CommandContext::dry_run`] — do not pair this with + /// [`CommandSpec::handles_dry_run`] (debug-asserted; see that field's docs). + #[must_use] + pub fn new_typed(spec: CommandSpec, handler: F) -> Self + where + T: clap::FromArgMatches + Send + 'static, + F: Fn(CredentialResolver, T) -> Fut + Send + Sync + 'static, + Fut: Future> + Send + 'static, + Output: Into + Send + 'static, + { + debug_assert!( + !spec.handles_dry_run, + "command {:?} sets handles_dry_run but RuntimeCommandSpec::new_typed's handler \ + (CredentialResolver, args) has no CommandContext and can never check \ + CommandContext::dry_run(), so it would silently run its real side effects \ + under --dry-run; use RuntimeCommandSpec::new_with_context (or \ + new_typed_with_context to keep typed args) instead", + spec.name + ); + let handler = Arc::new(handler); + Self { + spec, + handler: Arc::new(move |context| { + let credential = context.credential.clone(); + let parsed = T::from_arg_matches(context.raw_matches.as_ref()); + let handler = handler.clone(); + Box::pin(async move { + let args = parsed.map_err(|e| { + crate::CliCoreError::Message(format!("argument parse error: {e}")) + })?; + handler(credential, args).await.map(Into::into) + }) + }), + streaming_handler: None, + } + } + + /// Creates a runtime command with full context and typed argument + /// deserialization. + /// + /// Combines [`new_with_context`](RuntimeCommandSpec::new_with_context)'s + /// access to [`CommandContext`] (command path, middleware snapshot, + /// user-supplied args, [`CommandContext::dry_run`]) with + /// [`new_typed`](RuntimeCommandSpec::new_typed)'s automatic + /// deserialization: the engine parses `T` from the raw matches before + /// invoking the handler, so the handler never needs to call + /// [`CommandContext::typed_args`] itself. + /// + /// Use this instead of `new_with_context` + `context.typed_args::()` + /// when a command needs full context and wants eager, guaranteed-parsed + /// typed args rather than parsing on demand. Because the handler receives + /// a [`CommandContext`], this is a valid pairing with + /// [`CommandSpec::handles_dry_run`]. + /// + /// # Errors + /// + /// The returned handler surfaces a `CliCoreError::Message` if `T` fails to + /// deserialize from the parsed matches (this should not happen for args + /// generated by `CommandSpec::from_args::()`, since `clap` already + /// validated them during parsing). + #[must_use] + pub fn new_typed_with_context(spec: CommandSpec, handler: F) -> Self + where + T: clap::FromArgMatches + Send + 'static, + F: Fn(CommandContext, T) -> Fut + Send + Sync + 'static, + Fut: Future> + Send + 'static, + Output: Into + Send + 'static, + { + let handler = Arc::new(handler); + Self { + spec, + handler: Arc::new(move |context| { + let parsed = T::from_arg_matches(context.raw_matches.as_ref()); + let handler = handler.clone(); + Box::pin(async move { + let args = parsed.map_err(|e| { + crate::CliCoreError::Message(format!("argument parse error: {e}")) + })?; + handler(context, args).await.map(Into::into) + }) + }), + streaming_handler: None, + } + } + + /// Creates a streaming command with full context and typed argument + /// deserialization. + /// + /// Combines [`new_streaming`](RuntimeCommandSpec::new_streaming)'s NDJSON + /// event emission with [`new_typed`](RuntimeCommandSpec::new_typed)'s + /// automatic deserialization: the engine parses `T` from the raw matches + /// before invoking the handler. + /// + /// # Errors + /// + /// The returned handler surfaces a `CliCoreError::Message` if `T` fails to + /// deserialize from the parsed matches. + #[must_use] + pub fn new_typed_streaming(spec: CommandSpec, handler: F) -> Self + where + T: clap::FromArgMatches + Send + 'static, + F: Fn(CommandContext, T, StreamSender) -> Fut + Send + Sync + 'static, + Fut: Future> + Send + 'static, + { + debug_assert!( + !spec.raw_output, + "command {:?} sets raw_output but RuntimeCommandSpec::new_typed_streaming writes \ + chunked NDJSON events, which does not fit a single-verbatim-string contract; \ + raw_output is only supported on non-streaming commands", + spec.name + ); + let handler = Arc::new(handler); + let streaming: StreamingCommandHandler = Arc::new(move |context, sender| { + let parsed = T::from_arg_matches(context.raw_matches.as_ref()); + let handler = handler.clone(); + Box::pin(async move { + let args = parsed.map_err(|e| { + crate::CliCoreError::Message(format!("argument parse error: {e}")) + })?; + handler(context, args, sender).await + }) + }); + Self { + spec, + streaming_handler: Some(streaming), + handler: Arc::new(|_context| Box::pin(async { Ok(CommandResult::new(Value::Null)) })), + } + } +} diff --git a/cli-engine/src/command/spec.rs b/cli-engine/src/command/spec.rs new file mode 100644 index 0000000..68829b7 --- /dev/null +++ b/cli-engine/src/command/spec.rs @@ -0,0 +1,604 @@ +use std::collections::BTreeMap; + +use clap::{Arg, ArgGroup, Command}; +use schemars::JsonSchema; + +use crate::{ + AuthRequirement, CommandMeta, FeatureFlag, OutputSchema, SchemaInfo, Stage, Tier, + output::TableColumn, +}; + +/// Declarative leaf command metadata and parser arguments. +/// +/// `CommandSpec` intentionally keeps command metadata next to the command's +/// handler. This is the primary copy/paste surface for teams adding commands. +/// +/// Construct with [`CommandSpec::new`] or [`CommandSpec::from_args`], then +/// configure with the `with_*` builder methods — never as a struct literal. +/// `#[non_exhaustive]` enforces this so the engine can add fields (as it did +/// for [`arg_groups`](CommandSpec::arg_groups)) without a breaking release. +#[derive(Clone, Debug, Default)] +#[non_exhaustive] +pub struct CommandSpec { + /// Leaf command name. + pub name: String, + /// One-line command description. + pub short: String, + /// Optional long help text. + pub long: Option, + /// Alternate command names accepted by the parser. + pub aliases: Vec, + /// Whether the command runs but is hidden from help, tree, and search. + pub hidden: bool, + /// Backend/system id used in output metadata and generic error envelopes. + pub system: Option, + /// Default comma-separated field projection. + pub default_fields: Option, + /// Authentication requirement enforced by the engine for this command. + /// + /// Defaults to [`AuthRequirement::Required`] (fail-closed). Use + /// [`auth_optional`](CommandSpec::auth_optional) for commands that should run + /// logged out, or [`no_auth`](CommandSpec::no_auth) for commands that never + /// authenticate. + pub auth: AuthRequirement, + /// Auth provider name for this command. + pub auth_provider: Option, + /// Risk tier used by authentication, authorization, and dry-run. + pub tier: Option, + /// Explicit dry-run prompt marker for commands without a tier. + pub mutates: bool, + /// Opts this command into handler-driven `--dry-run`. + /// + /// Set with [`handles_dry_run`](CommandSpec::handles_dry_run). When + /// `true`, the engine skips its generic `--dry-run` short-circuit for + /// this command and invokes the handler as normal (still respecting the + /// command's [`AuthRequirement`]). The handler is responsible for + /// running its real validation unconditionally, checking + /// [`CommandContext::dry_run`](crate::CommandContext::dry_run) to skip + /// only the mutating I/O, and tagging its preview result with + /// [`CommandResult::with_dry_run`](crate::CommandResult::with_dry_run). + /// + /// **Requires a context-aware handler.** Only handlers built with + /// [`RuntimeCommandSpec::new_with_context`](crate::RuntimeCommandSpec::new_with_context), + /// [`new_streaming`](crate::RuntimeCommandSpec::new_streaming), + /// [`new_typed_with_context`](crate::RuntimeCommandSpec::new_typed_with_context), + /// or [`new_typed_streaming`](crate::RuntimeCommandSpec::new_typed_streaming) + /// receive a [`CommandContext`](crate::CommandContext) and can call + /// [`CommandContext::dry_run`](crate::CommandContext::dry_run). A handler + /// built with [`RuntimeCommandSpec::new`](crate::RuntimeCommandSpec::new)/ + /// [`new_typed`](crate::RuntimeCommandSpec::new_typed) only receives + /// `(CredentialResolver, args)` — it has no way to observe `--dry-run` at + /// all, so opting it into `handles_dry_run` would silently execute the + /// handler's real side effects under `--dry-run` instead of skipping + /// them. `RuntimeCommandSpec::new`/`new_typed` debug-assert against this + /// misuse; release builds do not, so treat the assert as a + /// development-time safety net, not the actual guarantee — only pair + /// this field with one of the four context-aware constructors above. + pub handles_dry_run: bool, + /// Forces this command's successful output to print verbatim to stdout. + pub raw_output: bool, + /// Provider-specific auth metadata. + pub auth_metadata: BTreeMap, + /// Command-specific `clap` arguments. + pub args: Vec, + /// Argument relations (mutually-exclusive or "at least one of" groups). + /// + /// Set with [`with_arg_group`](CommandSpec::with_arg_group), or captured + /// automatically by [`from_args`](CommandSpec::from_args) from a + /// `#[derive(clap::Args)]` struct's `#[group(...)]` attribute. + pub arg_groups: Vec, + /// Optional output schema published through `--schema` and help. + pub output_schema: Option, + /// Inline human-output table columns assigned directly to this command. + /// + /// Set with [`with_view`](CommandSpec::with_view). When present (and + /// [`view_id`](CommandSpec::view_id) is unset), the engine registers these + /// columns under the command's own path so human output renders them. + pub view_columns: Vec, + /// Id of a shared human view this command should use. + /// + /// Set with [`with_view_id`](CommandSpec::with_view_id). Names a + /// [`HumanViewDef`](crate::HumanViewDef) registered with `with_view` on the + /// module or CLI, so several commands can share one table. Takes precedence + /// over inline [`view_columns`](CommandSpec::view_columns). + pub view_id: Option, + /// This command's own feature-flag declaration, if any. + /// + /// `None` means the command has no explicit stage declaration of its own, + /// in which case it inherits its effective stage from its nearest ancestor + /// (nested group, then enclosing group, then module — nearest declaration + /// wins), implicitly resolving to [`Stage::Ga`] if nothing in the ancestor + /// chain declares a flag either; see [`Stage`]'s documentation for why + /// that is its default. Set with + /// [`with_feature_flag`](CommandSpec::with_feature_flag). This field only + /// records the command's own declaration; cascading resolution against the + /// ancestor chain happens when a [`Cli`](crate::Cli) mounts the enclosing + /// module or group. + pub feature_flag: Option, + /// This command's opt-in pagination policy, if any. + /// + /// `None` (the default) means the command does not paginate: `--limit`/ + /// `--offset` are not registered for it, so they neither show up in its + /// `--help` nor parse on its command line. Set with + /// [`with_pagination`](CommandSpec::with_pagination). + pub pagination: Option, +} + +/// Opt-in pagination policy for a single command, set with +/// [`CommandSpec::with_pagination`]. +/// +/// Registering this is what makes `--limit`/`--offset` exist for a command at +/// all — without it, the engine does not register those flags, so they are +/// absent from `--help` and rejected as unknown arguments if passed. Construct +/// it with `..Default::default()`, as in the example below, so a future +/// engine release can add fields without breaking existing callers. +/// +/// ``` +/// use cli_engine::PaginationConfig; +/// +/// let pagination = PaginationConfig { +/// default_limit: 20, +/// max_limit: 100, +/// ..Default::default() +/// }; +/// assert_eq!(pagination.default_limit, 20); +/// ``` +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct PaginationConfig { + /// Page size applied when the user passes neither `--limit` nor + /// `--offset`. `0` (the default) means unlimited — the same "no + /// pagination" sentinel used everywhere else in the output pipeline. + pub default_limit: i64, + /// Upper bound a user can request with an explicit `--limit`. `0` (the + /// default) means uncapped. Does not affect `default_limit` itself. + pub max_limit: i64, +} + +impl CommandSpec { + /// Creates a command spec with the required name and one-line help. + #[must_use] + pub fn new(name: impl Into, short: impl Into) -> Self { + Self { + name: name.into(), + short: short.into(), + ..Self::default() + } + } + + /// Creates a command spec from a `#[derive(clap::Args)]` struct. + /// + /// Extracts the argument definitions from the derive type and populates the + /// spec's args list. The command name and help text are still required since + /// `Args` types do not carry those. Also captures any `ArgGroup`s the derive + /// macro registers (via a struct-level `#[group(...)]` attribute) into + /// [`arg_groups`](CommandSpec::arg_groups). + /// + /// **Flatten caveat**: `clap_derive` empties a struct's own implicit group's + /// member list when the struct also has a `#[command(flatten)]` field, so a + /// `#[group(required = true)]` on such a struct silently enforces nothing. + /// This is debug-asserted against below; treat it as a development-time + /// safety net, not the actual guarantee. + #[must_use] + pub fn from_args(name: impl Into, short: impl Into) -> Self { + let name = name.into(); + let placeholder = Command::new("__placeholder"); + let augmented = T::augment_args(placeholder); + let args: Vec = augmented + .get_arguments() + // `cli-engine` registers its own global `--help` flag. Retain a + // command-specific `--version` flag: it may represent a resource + // version rather than the CLI binary version. + .filter(|arg| arg.get_id().as_str() != "help") + .cloned() + .collect(); + let arg_groups: Vec = augmented.get_groups().cloned().collect(); + debug_assert!( + arg_groups + .iter() + .all(|group| !group.is_required_set() || group.get_args().count() > 0), + "command {name:?} has a required ArgGroup with no member args — likely the \ + clap_derive flatten+group interaction emptying the implicit group's \ + member list; the constraint will not be enforced" + ); + Self { + name, + short: short.into(), + args, + arg_groups, + ..Self::default() + } + } + + /// Sets expanded command help. + #[must_use] + pub fn with_long(mut self, long: impl Into) -> Self { + self.long = Some(long.into()); + self + } + + /// Adds one command alias. + #[must_use] + pub fn with_alias(mut self, alias: impl Into) -> Self { + self.aliases.push(alias.into()); + self + } + + /// Hides or shows this command in discovery output. + #[must_use] + pub fn hidden(mut self, hidden: bool) -> Self { + self.hidden = hidden; + self + } + + /// Sets the backend/system id for output metadata and error attribution. + #[must_use] + pub fn with_system(mut self, system: impl Into) -> Self { + self.system = Some(system.into()); + self + } + + /// Sets the default field projection used when `--fields` is absent. + #[must_use] + pub fn with_default_fields(mut self, default_fields: impl Into) -> Self { + self.default_fields = Some(default_fields.into()); + self + } + + /// Assigns an inline human-output table view to this command. + /// + /// The columns are registered under the command's own path, so human output + /// renders this table directly. Field selection still applies: `--fields` + /// (defaulting to [`default_fields`](CommandSpec::default_fields)) narrows + /// which of these columns show. Use + /// [`with_view_id`](CommandSpec::with_view_id) instead to point at a shared + /// view registered with `with_view` on the module or CLI. + #[must_use] + pub fn with_view(mut self, columns: impl Into>) -> Self { + self.view_columns = columns.into(); + self + } + + /// Points this command at a shared human view by id. + /// + /// The id must match a [`HumanViewDef`](crate::HumanViewDef) registered with + /// `with_view` on the module or CLI, letting several commands share one + /// table. Takes precedence over inline [`with_view`](CommandSpec::with_view) + /// columns. + #[must_use] + pub fn with_view_id(mut self, id: impl Into) -> Self { + self.view_id = Some(id.into()); + self + } + + /// Selects the auth provider for this command. + #[must_use] + pub fn with_auth_provider(mut self, provider: impl Into) -> Self { + self.auth_provider = Some(provider.into()); + self + } + + /// Marks the command as no-auth. + /// + /// `no_auth(true)` sets [`AuthRequirement::None`]: the command never resolves + /// a credential and default-env injection is suppressed. `no_auth(false)` + /// restores the default [`AuthRequirement::Required`]. + #[must_use] + pub fn no_auth(mut self, no_auth: bool) -> Self { + self.auth = if no_auth { + AuthRequirement::None + } else { + AuthRequirement::Required + }; + self + } + + /// Sets the command's [`AuthRequirement`] explicitly. + #[must_use] + pub fn auth(mut self, requirement: AuthRequirement) -> Self { + self.auth = requirement; + self + } + + /// Marks authentication as optional ([`AuthRequirement::Optional`]). + /// + /// The engine does not resolve a credential before the handler runs; the + /// handler triggers the auth flow only by calling + /// [`CredentialResolver::resolve`](crate::CredentialResolver::resolve)/ + /// [`try_resolve`](crate::CredentialResolver::try_resolve). Use for + /// commands that should still run when the user is logged out. + #[must_use] + pub fn auth_optional(mut self) -> Self { + self.auth = AuthRequirement::Optional; + self + } + + /// Sets the command risk tier. + #[must_use] + pub fn with_tier(mut self, tier: Tier) -> Self { + self.tier = Some(tier); + self + } + + /// Declares this command's own feature flag: the key used for policy + /// overrides and introspection, and the stage at which it becomes visible. + #[must_use] + pub fn with_feature_flag(mut self, key: impl Into, stage: Stage) -> Self { + self.feature_flag = Some(FeatureFlag::new(key, stage)); + self + } + + /// Opts this command into paginated list output. + /// + /// Registers `--limit`/`--offset` for this command only — a command that + /// never calls this does not get those flags at all, in `--help` or on + /// the command line. When the user passes neither flag, `config.default_limit` + /// applies instead of the framework's "pagination disabled" default of + /// unlimited; an explicit `--limit` above `config.max_limit` (when set) is + /// rejected before the command runs. See [`PaginationConfig`]. + #[must_use] + pub fn with_pagination(mut self, config: PaginationConfig) -> Self { + debug_assert!( + config.max_limit == 0 || config.default_limit <= config.max_limit, + "command {:?} has a default_limit ({}) greater than its max_limit ({})", + self.name, + config.default_limit, + config.max_limit + ); + self.pagination = Some(config); + self + } + + /// Adds provider-specific auth metadata. + #[must_use] + pub fn with_auth_metadata(mut self, key: impl Into, value: impl Into) -> Self { + self.auth_metadata.insert(key.into(), value.into()); + self + } + + /// Declares the OAuth scopes this command requires. + /// + /// Sugar over [`with_auth_metadata`](CommandSpec::with_auth_metadata) with the + /// `"scopes"` key (whitespace-joined). The scopes surface on + /// [`CommandMeta::scopes`](crate::CommandMeta) and reach the auth provider via + /// [`CredentialRequest`](crate::CredentialRequest); a provider that supports + /// scope step-up re-authenticates when the cached token lacks them. + #[must_use] + pub fn with_scopes(mut self, scopes: &[impl AsRef]) -> Self { + let joined = scopes + .iter() + .map(AsRef::as_ref) + .collect::>() + .join(" "); + // Mirror `CommandMeta::set_scopes`: an empty list clears the key rather + // than leaving an empty-but-present `auth_metadata["scopes"]`. + if joined.is_empty() { + self.auth_metadata.remove("scopes"); + } else { + self.auth_metadata.insert("scopes".to_owned(), joined); + } + self + } + + /// Adds a `clap` argument or option to this command. + #[must_use] + pub fn with_arg(mut self, arg: Arg) -> Self { + self.args.push(arg); + self + } + + /// Adds a `clap` flag or option to this command. + #[must_use] + pub fn with_flag(self, flag: Arg) -> Self { + self.with_arg(flag) + } + + /// Adds an argument relation (an `ArgGroup`) to this command, e.g. to + /// express "at least one of" or mutually-exclusive relationships between + /// arguments added with [`with_arg`](CommandSpec::with_arg)/[`with_flag`](CommandSpec::with_flag). + /// + /// The group's `ArgGroup::args([...])` ids must reference args already (or + /// later) added to this spec, matching `clap`'s own requirement that + /// referenced arg ids exist on the built `Command`. This replaces + /// hand-rolled `required_unless_present_any`/`conflicts_with` chains with a + /// single declarative relation. + #[must_use] + pub fn with_arg_group(mut self, group: ArgGroup) -> Self { + self.arg_groups.push(group); + self + } + + /// Registers a compact framework schema from an [`OutputSchema`] type. + #[must_use] + pub fn with_output_schema(mut self) -> Self { + self.output_schema = Some(SchemaInfo { + command: String::new(), + fields: crate::output::fields_for::(), + schema: None, + }); + self + } + + /// Registers JSON Schema generated from a Rust type with `schemars`. + #[must_use] + pub fn with_json_schema(mut self) -> Self { + self.output_schema = Some(crate::output::json_schema_info::("")); + self + } + + /// Marks whether the command should short-circuit under `--dry-run`. + #[must_use] + pub fn mutates(mut self, mutates: bool) -> Self { + self.mutates = mutates; + self + } + + /// Opts this command into handler-driven `--dry-run` instead of the + /// engine's generic short-circuit. + /// + /// See [`handles_dry_run`](CommandSpec::handles_dry_run) (the field) for + /// the contract a handler must follow once it opts in — in particular, + /// **only use this with a context-aware handler** + /// ([`RuntimeCommandSpec::new_with_context`](crate::RuntimeCommandSpec::new_with_context), + /// [`new_streaming`](crate::RuntimeCommandSpec::new_streaming), + /// [`new_typed_with_context`](crate::RuntimeCommandSpec::new_typed_with_context), or + /// [`new_typed_streaming`](crate::RuntimeCommandSpec::new_typed_streaming)); a + /// `new`/`new_typed` handler can't observe `--dry-run` and would execute + /// its real side effects under it regardless of this flag. + #[must_use] + pub fn handles_dry_run(mut self, handles: bool) -> Self { + self.handles_dry_run = handles; + self + } + + /// Forces this command's successful output to print verbatim to stdout. + #[must_use] + pub fn raw_output(mut self, raw_output: bool) -> Self { + self.raw_output = raw_output; + self + } + + /// Builds middleware metadata from the spec. + #[must_use] + pub fn metadata(&self) -> CommandMeta { + let mut auth_metadata = self.auth_metadata.clone(); + if let Some(provider) = &self.auth_provider + && !provider.is_empty() + { + auth_metadata.insert("provider".to_owned(), provider.clone()); + } + if let Some(tier) = self.tier + && !auth_metadata.contains_key("tier") + { + auth_metadata.insert("tier".to_owned(), tier.to_string()); + } + let scopes = auth_metadata + .get("scopes") + .map(|scopes| { + scopes + .split_whitespace() + .map(str::to_owned) + .collect::>() + }) + .unwrap_or_default(); + + CommandMeta { + dry_run_prompt: self.mutates || self.tier.is_some_and(Tier::is_mutating), + handles_dry_run: self.handles_dry_run, + auth_metadata, + scopes, + } + } + + /// Builds the `clap` command for parser registration. + #[must_use] + pub fn clap_command(&self) -> Command { + let mut command = Command::new(self.name.clone()).about(self.short.clone()); + if let Some(long) = &self.long + && !long.is_empty() + { + command = command.long_about(long.clone()); + } + for alias in &self.aliases { + command = command.alias(alias.clone()); + } + if self.hidden { + command = command.hide(true); + } + // Explicit `display_order` (rather than relying on clap's own + // implicit per-`Command` counter) guarantees these render first, as + // a block, in declaration order — see `flags::global_flag_order` + // for why leaving it implicit lets a propagated global flag collide + // with a low counter value here and interleave with these instead. + for (index, arg) in self.args.iter().enumerate() { + command = command.arg(arg.clone().display_order(index)); + } + for group in &self.arg_groups { + command = command.group(group.clone()); + } + command + } +} + +#[cfg(test)] +mod tests { + use clap::{Arg, ArgGroup}; + + use super::*; + + #[test] + fn command_spec_with_feature_flag_sets_key_and_stage() { + let spec = + CommandSpec::new("list", "List things").with_feature_flag("my-flag", Stage::Beta); + + let flag = spec + .feature_flag + .as_ref() + .expect("feature flag should be set"); + assert_eq!(flag.key, "my-flag"); + assert_eq!(flag.stage, Stage::Beta); + } + + #[test] + fn command_spec_feature_flag_defaults_to_none() { + let spec = CommandSpec::new("list", "List things"); + + assert!(spec.feature_flag.is_none()); + } + + #[test] + fn command_spec_with_arg_group_registers_group_on_clap_command() { + let spec = CommandSpec::new("update", "Update a thing") + .with_arg(Arg::new("a").long("a")) + .with_arg(Arg::new("b").long("b")) + .with_arg_group(ArgGroup::new("ab").args(["a", "b"]).required(true)); + + assert!( + spec.clap_command() + .try_get_matches_from(["update"]) + .is_err(), + "neither `a` nor `b` present should fail the required group" + ); + assert!( + spec.clap_command() + .try_get_matches_from(["update", "--a", "x"]) + .is_ok() + ); + } + + #[test] + fn command_spec_from_args_preserves_derive_arg_group() { + #[derive(clap::Args)] + #[group(required = true, multiple = false)] + struct ExclusiveArgs { + #[arg(long)] + one: bool, + #[arg(long)] + two: bool, + } + + let spec = CommandSpec::from_args::("bump", "Bump one thing"); + + assert_eq!(spec.arg_groups.len(), 1); + let group = &spec.arg_groups[0]; + assert!(group.is_required_set()); + assert_eq!(group.get_args().count(), 2); + } + + #[test] + fn command_spec_from_args_preserves_version_argument() { + #[derive(clap::Args)] + struct ReleaseArgs { + #[arg(long)] + version: String, + } + + let spec = CommandSpec::from_args::("release", "Create a release"); + + assert!( + spec.clap_command() + .try_get_matches_from(["release", "--version", "1.0.0"]) + .is_ok(), + "typed command arguments named `version` must remain available as `--version`" + ); + } +} diff --git a/cli-engine/src/flags.rs b/cli-engine/src/flags.rs deleted file mode 100644 index 9d1b66c..0000000 --- a/cli-engine/src/flags.rs +++ /dev/null @@ -1,1019 +0,0 @@ -use std::collections::BTreeSet; -use std::io::IsTerminal; - -use clap::{Arg, ArgAction, ArgMatches, Command, builder::ValueParser}; - -/// Returns `true` when the process appears to be running interactively: -/// stdin and stderr are both TTYs. -/// -/// Checking stdin ensures that piped input (`echo "" | gddy ...`) is detected -/// as non-interactive. Checking stderr ensures prompts can be displayed (since -/// `inquire` renders to stderr). Stdout is intentionally not checked — a user -/// piping output (`gddy ... | jq`) still has an interactive terminal for -/// prompts. -/// -/// Used as the default for `GlobalFlags::interactive` when the user does not -/// pass `--interactive` or `--non-interactive` explicitly. -#[must_use] -pub fn detect_interactive() -> bool { - std::io::stdin().is_terminal() && std::io::stderr().is_terminal() -} - -/// Interactivity mode for a CLI invocation. -/// -/// Commands and middleware can inspect this to decide whether to prompt for -/// missing inputs, display progress spinners, or fall back to error messages -/// suitable for scripts and CI. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum InteractivityMode { - /// The user explicitly requested interactive prompts (`--interactive`), or - /// the process is running in a TTY without CI indicators. - Interactive, - /// The user explicitly disabled prompts (`--non-interactive`), or the - /// process is running in a non-TTY / CI context. - NonInteractive, -} - -impl InteractivityMode { - /// Returns `true` when prompts and interactive flows are appropriate. - #[must_use] - pub fn is_interactive(self) -> bool { - self == Self::Interactive - } -} - -impl From for InteractivityMode { - fn from(interactive: bool) -> Self { - if interactive { - Self::Interactive - } else { - Self::NonInteractive - } - } -} - -/// Parsed framework-global flags. -/// -/// Applications can add their own global flags, but these are the built-in -/// controls understood by middleware and the output pipeline. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct GlobalFlags { - /// Output format: `json`, `human`, or `toon`. - pub output_format: String, - /// Metadata verbosity selector. - pub verbose: String, - /// Whether mutating commands should short-circuit. - pub dry_run: bool, - /// Field projection. - pub fields: String, - /// Whether `fields` came from an explicit `--fields` flag on the command - /// line, rather than clap filling in a command's `default_fields` (a - /// command with `default_fields` set registers it as that flag's native - /// default, so `fields` is non-empty even when the user never typed - /// `--fields` — this is the only reliable way to tell the two apart). - pub fields_explicit: bool, - /// JMESPath per-item filter. - pub filter: String, - /// JMESPath whole-result expression. - pub expr: String, - /// Whether schema rendering was requested. - pub schema: bool, - /// User-provided command reason. - pub reason: String, - /// Raw timeout string. - pub timeout: String, - /// Debug selector. - pub debug: String, - /// Credential storage override from `--credential-store`, if supplied. - pub credential_store: Option, - /// Interactivity mode: `true` enables prompts for missing inputs, - /// `false` disables them. Auto-detected from TTY when neither flag is given. - pub interactive: bool, -} - -impl Default for GlobalFlags { - fn default() -> Self { - Self { - output_format: "json".to_owned(), - verbose: String::new(), - dry_run: false, - fields: String::new(), - fields_explicit: false, - filter: String::new(), - expr: String::new(), - schema: false, - reason: String::new(), - timeout: "0s".to_owned(), - debug: String::new(), - credential_store: None, - interactive: detect_interactive(), - } - } -} - -/// Explicit `--help` display-order values for the engine's own global flags, -/// numbered in the order they're registered below — which is meant to read -/// as their relative importance, most-used first. -/// -/// Without this, every global flag would collide with command-specific -/// ones: clap auto-assigns each unset `display_order` as "the Nth argument -/// added to this `Command`," starting the count over at 0 on every -/// `Command` it's called on — the root (where these are declared) and each -/// subcommand alike. A subcommand's own `CommandSpec::with_arg` args get -/// low counter values (0, 1, 2, ... in declaration order) from their own -/// `Command`; a global flag propagated onto that subcommand keeps the low -/// counter value it got on the *root*. Mix the two and `--help` interleaves -/// them instead of showing command-specific flags first, as a block, in the -/// order they were declared. Parking every global flag comfortably above -/// any realistic per-command arg count keeps that from happening. -/// -/// `FIELDS`, `FILTER`, and `EXPR` are `pub(crate)` because `cli.rs` -/// re-registers those three per-command (see `apply_fields_arg` and -/// `apply_filter_and_expr_examples`) with contextual help text; they must -/// reuse these same values or the override would drift out of position. -/// -/// `LIMIT` and `OFFSET` are never registered by [`register_global_flags`] -/// itself — unlike every other value here, `--limit`/`--offset` are not -/// framework-global at all; `cli.rs` registers them directly on a single -/// command's own `Command` (see `apply_pagination_args`), and only for a -/// command that opted in via `CommandSpec::with_pagination`. These two -/// constants exist purely so that per-command registration still parks the -/// flags in the same relative `--help` position other engine flags occupy. -/// -/// `REASON` and `ENV` cover the two global flags `Cli::new` registers -/// directly (conditionally, outside `register_global_flags`) rather than -/// this module's own function — `--reason` when an authorizer/auditor/ -/// activity emitter is configured, `--env` when `CliConfig.environments` is -/// set. Both are just as subject to the collision this module exists to -/// prevent, so both need an explicit value here too. -pub(crate) mod global_flag_order { - pub(crate) const HELP: usize = 1000; - pub(crate) const OUTPUT: usize = 1001; - pub(crate) const VERBOSE: usize = 1002; - pub(crate) const DRY_RUN: usize = 1003; - pub(crate) const FIELDS: usize = 1004; - pub(crate) const FILTER: usize = 1005; - pub(crate) const EXPR: usize = 1006; - pub(crate) const LIMIT: usize = 1007; - pub(crate) const OFFSET: usize = 1008; - pub(crate) const SCHEMA: usize = 1009; - pub(crate) const TIMEOUT: usize = 1010; - pub(crate) const DEBUG: usize = 1011; - pub(crate) const CREDENTIAL_STORE: usize = 1012; - pub(crate) const JSON: usize = 1013; - pub(crate) const TOON: usize = 1014; - pub(crate) const HUMAN: usize = 1015; - pub(crate) const INTERACTIVE: usize = 1016; - pub(crate) const REASON: usize = 1017; - pub(crate) const ENV: usize = 1018; -} - -/// Registers framework-global flags on a `clap` command. -pub fn register_global_flags(command: Command) -> Command { - command - .disable_help_flag(true) - .arg( - // clap's default help arg shows an abbreviated summary for `-h` - // and the full help text for `--help`. Override it so both - // flags print the same full help everywhere; `disable_help_flag` - // propagates to every subcommand. - Arg::new("help") - .short('h') - .long("help") - .action(ArgAction::HelpLong) - .global(true) - .display_order(global_flag_order::HELP) - .help("Print help"), - ) - .arg( - Arg::new("output") - .long("output") - .short('o') - .global(true) - .display_order(global_flag_order::OUTPUT) - .value_name("FORMAT") - // This default is cosmetic, not authoritative: it's never - // actually read as a value — `global_flags_from_matches` only - // consults this arg when it was given on the command line, - // falling back to `resolve_default_output_format`'s full - // env/config/TTY precedence the rest of the time. But since - // `--help` runs in this same process, this process's own - // stdout TTY-ness is already known and stable for the whole - // run, so mirroring that one signal here (skipping the - // env-var/config-file tiers, which aren't available until a - // command actually executes) keeps what `--help` shows honest - // in the common case instead of a hardcoded, often-wrong - // `[default: json]`. - .default_value(if std::io::stdout().is_terminal() { - "human" - } else { - "json" - }) - // Only conflicts when *explicitly* given: clap's conflict - // checks ignore an arg's default value, so a bare `--json` - // with no `--output` at all is unaffected. - .conflicts_with_all(["json", "toon", "human"]) - .help( - "Output format: toon|json|human (shorthand: --json, --toon, --human); \ - defaults to human in an interactive terminal, json otherwise", - ), - ) - .arg( - Arg::new("verbose") - .long("verbose") - .global(true) - .num_args(0..=1) - .default_missing_value("all") - .value_name("FIELDS") - .display_order(global_flag_order::VERBOSE) - .help("Include metadata in output (all, or comma-separated: system,duration,args,env,identity,command,effective_args,timestamp)"), - ) - .arg( - Arg::new("dry-run") - .long("dry-run") - .global(true) - .num_args(0..=1) - .require_equals(true) - .default_missing_value("true") - .default_value("false") - .value_parser(compat_bool_value_parser()) - .display_order(global_flag_order::DRY_RUN) - .help("Preview mutations without executing"), - ) - .arg( - Arg::new("fields") - .long("fields") - .global(true) - .value_name("FIELDS") - .display_order(global_flag_order::FIELDS) - .help("Comma-separated fields to include in output (use 'all' or '*' for everything)"), - ) - .arg( - Arg::new("filter") - .long("filter") - .global(true) - .value_name("EXPR") - .display_order(global_flag_order::FILTER) - .help("Per-item JMESPath predicate for list data"), - ) - .arg( - Arg::new("expr") - .long("expr") - .global(true) - .value_name("EXPR") - .display_order(global_flag_order::EXPR) - .help("JMESPath query applied to the whole result"), - ) - .arg( - Arg::new("schema") - .long("schema") - .global(true) - .num_args(0..=1) - .require_equals(true) - .default_missing_value("true") - .default_value("false") - .value_parser(compat_bool_value_parser()) - .display_order(global_flag_order::SCHEMA) - .help("Dump output field metadata instead of running the command"), - ) - .arg( - Arg::new("timeout") - .long("timeout") - .global(true) - .allow_hyphen_values(true) - .default_value("0s") - .value_name("DURATION") - .display_order(global_flag_order::TIMEOUT) - .help("Overall command timeout (e.g. 60s, 5m); default 0s = no timeout"), - ) - .arg( - Arg::new("debug") - .long("debug") - .global(true) - .num_args(0..=1) - .default_missing_value("*") - .value_name("PATTERN") - .display_order(global_flag_order::DEBUG) - .help("Enable debug logging (comma-separated component patterns, e.g. *, transport, *,-auth)"), - ) - .arg( - Arg::new("credential-store") - .long("credential-store") - .display_order(global_flag_order::CREDENTIAL_STORE) - .global(true) - .value_name("MODE") - .value_parser(|s: &str| s.parse::()) - .help("Credential storage: auto|keyring|file (overrides env and config)"), - ) - .arg( - Arg::new("interactive") - .long("interactive") - .short('i') - .global(true) - .action(ArgAction::SetTrue) - .conflicts_with("non-interactive") - .display_order(global_flag_order::INTERACTIVE) - .help("Force interactive prompts for missing inputs (default when TTY is detected)"), - ) - .arg( - Arg::new("non-interactive") - .long("non-interactive") - .global(true) - .action(ArgAction::SetTrue) - .conflicts_with("interactive") - .hide(true) - .display_order(global_flag_order::INTERACTIVE) - .help("Disable interactive prompts; fail on missing required inputs"), - ) - .arg( - Arg::new("json") - .long("json") - .global(true) - .action(ArgAction::SetTrue) - // Mutually exclusive with the other format selectors, so - // e.g. `--json --human` together is a usage error rather - // than one silently overriding the other. - .conflicts_with_all(["toon", "human"]) - // Documented on `--output` instead of taking their own line - // in every command's already-long options list. - .hide(true) - .display_order(global_flag_order::JSON) - .help("Shorthand for --output json"), - ) - .arg( - Arg::new("toon") - .long("toon") - .global(true) - .action(ArgAction::SetTrue) - .conflicts_with_all(["json", "human"]) - .hide(true) - .display_order(global_flag_order::TOON) - .help("Shorthand for --output toon"), - ) - .arg( - Arg::new("human") - .long("human") - .global(true) - .action(ArgAction::SetTrue) - .conflicts_with_all(["json", "toon"]) - .hide(true) - .display_order(global_flag_order::HUMAN) - .help("Shorthand for --output human"), - ) -} - -/// Registers the `--reason` flag on a `clap` command. -/// -/// Not part of [`register_global_flags`]: `--reason` is only meaningful when an -/// app has registered an [`Authorizer`](crate::middleware::Authorizer), -/// [`Auditor`](crate::middleware::Auditor), or -/// [`ActivityEmitter`](crate::middleware::ActivityEmitter) to consume it (see -/// `Cli::new`'s conditional call to this function). Apps with none of those -/// configured never register this flag at all, rather than exposing a flag -/// that nothing reads. `Cli::new` only checks the eager `authz`/`auditor`/ -/// `activity` fields on `CliConfig`; installing one of these later via -/// `init_deps` does not register `--reason`, since flag registration happens -/// before `init_deps` runs. -pub fn register_reason_flag(command: Command) -> Command { - command.arg( - Arg::new("reason") - .long("reason") - .global(true) - .value_name("TEXT") - .display_order(global_flag_order::REASON) - .help("Short explanation of why this command is being run (forwarded to your authorizer, auditor, or activity emitter)"), - ) -} - -/// Registers `--limit`/`--offset` directly on one command's own `clap` -/// `Command`, for a command whose [`CommandSpec`](crate::CommandSpec) opted -/// into pagination via `with_pagination`. -pub(crate) fn apply_pagination_args( - command: Command, - default_limit: i64, - max_limit: i64, -) -> Command { - command - .arg( - Arg::new("limit") - .long("limit") - .value_parser(pagination_limit_value_parser(max_limit)) - .allow_hyphen_values(true) - .default_value(default_limit.to_string()) - .display_order(global_flag_order::LIMIT) - .help(pagination_limit_help(default_limit, max_limit)), - ) - .arg( - Arg::new("offset") - .long("offset") - .value_parser(pagination_offset_value_parser()) - .allow_hyphen_values(true) - .default_value("0") - .display_order(global_flag_order::OFFSET) - .help("Skip N items before applying limit"), - ) -} - -fn pagination_limit_help(default_limit: i64, max_limit: i64) -> String { - let mut help = format!("Max items to return (client-side, 0=all, default {default_limit}"); - if max_limit > 0 { - help.push_str(&format!(", max {max_limit}")); - } - help.push(')'); - help -} - -fn pagination_limit_value_parser(max_limit: i64) -> ValueParser { - ValueParser::new(move |raw: &str| -> Result { - let value = raw - .parse::() - .map_err(|_| format!("invalid limit value {raw:?}"))?; - if max_limit > 0 && value > max_limit { - return Err(format!("limit {value} exceeds the maximum of {max_limit}")); - } - Ok(value) - }) -} - -/// Rejects a negative `--offset` at parse time — a `clap` usage error — rather -/// than letting it reach `apply_pagination` in `output/pipeline.rs`, which -/// already rejects one, but only once the command has otherwise fully run. -fn pagination_offset_value_parser() -> ValueParser { - ValueParser::new(|raw: &str| -> Result { - let value = raw - .parse::() - .map_err(|_| format!("invalid offset value {raw:?}"))?; - if value < 0 { - return Err(format!("offset {value} must be non-negative")); - } - Ok(value) - }) -} - -/// Resolves the default output format when the user gave no explicit format. -/// -/// Precedence: `env_override`, then `config_override` (the `[output].format` -/// key in `config.toml`), then a TTY policy — an interactive terminal gets -/// human-friendly output, everything else (pipes, files, CI, most agents) -/// gets machine-readable JSON. Pure so it can be unit-tested without a real -/// terminal or config file. -#[must_use] -pub fn resolve_default_output_format( - env_override: Option<&str>, - config_override: Option<&str>, - is_tty: bool, -) -> String { - // Normalize case (env vars and config values are commonly upper/mixed - // case) and ignore blank or unrecognized values, so a stray or miscased - // override can't break all command output — only a valid format is - // honored, and an invalid one falls through to the next tier. - for candidate in [env_override, config_override].into_iter().flatten() { - let normalized = candidate.trim().to_ascii_lowercase(); - if crate::output::is_valid_output_format(&normalized) { - return normalized; - } - } - if is_tty { "human" } else { "json" }.to_owned() -} - -/// Sanitizes an app id into an environment-variable prefix: ASCII alphanumerics -/// are uppercased and every other character becomes `_`, e.g. `godaddy` -> -/// `GODADDY`, `my-cli` -> `MY_CLI`. -/// -/// Shared by the framework's app-scoped env vars (for example -/// [`output_env_var`] and `${PREFIX}_CREDENTIAL_STORE`) so they derive the same -/// prefix from a given app id. -#[must_use] -pub fn app_id_env_prefix(app_id: &str) -> String { - app_id - .chars() - .map(|c| { - if c.is_ascii_alphanumeric() { - c.to_ascii_uppercase() - } else { - '_' - } - }) - .collect() -} - -/// Derives the per-application output-format override env var from an app id, -/// e.g. `godaddy` -> `GODADDY_OUTPUT`, `gdx` -> `GDX_OUTPUT`. -#[must_use] -pub fn output_env_var(app_id: &str) -> String { - format!("{}_OUTPUT", app_id_env_prefix(app_id)) -} - -/// Derives the per-application global minimum-stage override env var from an -/// app id, e.g. `godaddy` -> `GODADDY_MIN_STAGE`, `gdx` -> `GDX_MIN_STAGE`. -#[must_use] -pub fn min_stage_env_var(app_id: &str) -> String { - format!("{}_MIN_STAGE", app_id_env_prefix(app_id)) -} - -/// Computes the default output format for `app_id`, consulting the -/// `${APP_ID}_OUTPUT` env override, the `[output].format` key in -/// `config.toml`, and whether stdout is an interactive terminal. Used as the -/// fallback when no explicit `--output`/`--json`/`--toon`/`--human` is given. -/// -/// **Blocking**: this loads `config.toml` (see -/// [`ConfigFile::load`](crate::config::ConfigFile::load)), performing -/// synchronous filesystem I/O. `Cli` itself never calls this — it resolves -/// the default from the config already loaded once at `Cli::new` time -/// instead — but a consumer calling this function directly should avoid -/// doing so from a hot path or within an async executor without -/// `spawn_blocking`. -#[must_use] -pub fn default_output_format(app_id: &str) -> String { - let env = std::env::var(output_env_var(app_id)).ok(); - let file = crate::config::load(app_id); - resolve_default_output_format( - env.as_deref(), - file.output.format.as_deref(), - std::io::stdout().is_terminal(), - ) -} - -#[must_use] -/// Extracts framework-global flags from parsed `clap` matches, falling back to -/// `default_format` when the user gave no explicit output format. -pub fn global_flags_from_matches( - matches: &ArgMatches, - default_format: &str, - auto_interactive: bool, -) -> GlobalFlags { - let output_format = if matches.get_flag("toon") { - "toon".to_owned() - } else if matches.get_flag("human") { - "human".to_owned() - } else if matches.get_flag("json") { - "json".to_owned() - } else if matches.value_source("output") == Some(clap::parser::ValueSource::CommandLine) { - matches - .get_one::("output") - .cloned() - .unwrap_or_else(|| default_format.to_owned()) - } else { - default_format.to_owned() - }; - - GlobalFlags { - output_format, - verbose: matches - .get_one::("verbose") - .cloned() - .unwrap_or_default(), - dry_run: matches.get_one::("dry-run").copied().unwrap_or(false), - fields: matches - .get_one::("fields") - .cloned() - .unwrap_or_default(), - fields_explicit: matches.value_source("fields") - == Some(clap::parser::ValueSource::CommandLine), - filter: matches - .get_one::("filter") - .cloned() - .unwrap_or_default(), - expr: matches - .get_one::("expr") - .cloned() - .unwrap_or_default(), - schema: matches.get_one::("schema").copied().unwrap_or(false), - // `--reason` is only registered when an authorizer/auditor/activity - // emitter is configured. - reason: matches - .try_get_one::("reason") - .ok() - .flatten() - .cloned() - .unwrap_or_default(), - timeout: matches - .get_one::("timeout") - .cloned() - .unwrap_or_else(|| "0s".to_owned()), - debug: matches - .get_one::("debug") - .cloned() - .unwrap_or_default(), - credential_store: matches - .get_one::("credential-store") - .copied(), - interactive: if matches.get_flag("non-interactive") { - false - } else if matches.get_flag("interactive") { - true - } else if auto_interactive { - detect_interactive() - } else { - false - }, - } -} - -#[must_use] -/// Extracts output format from raw args. -/// -/// Recognizes `--output ` / `-o ` / `--output=`, -/// plus `--json`, `--toon`, and `--human` as shorthand for their respective -/// formats. Falls back to `default_format` when none is present. -pub fn extract_output_format(args: &[impl AsRef], default_format: &str) -> String { - for index in 0..args.len() { - let arg = args[index].as_ref(); - if arg == "--output" || arg == "-o" { - return args.get(index + 1).map_or_else( - || default_format.to_owned(), - |value| value.as_ref().to_owned(), - ); - } - if let Some(value) = arg.strip_prefix("--output=") { - return value.to_owned(); - } - if arg == "--json" { - return "json".to_owned(); - } - if arg == "--toon" { - return "toon".to_owned(); - } - if arg == "--human" { - return "human".to_owned(); - } - } - default_format.to_owned() -} - -#[must_use] -/// Extracts a colon-separated command path from raw args. -pub fn extract_command_path( - args: &[impl AsRef], - bool_flags: &BTreeSet, - value_flags: &BTreeSet, -) -> String { - let mut parts = Vec::new(); - let mut index = 1; - while index < args.len() { - let arg = args[index].as_ref(); - if arg == "--schema" { - index += 1; - continue; - } - if arg.starts_with('-') { - if bool_flags.contains(arg) || arg.contains('=') { - index += 1; - continue; - } - if value_flags.contains(arg) - || (index + 1 < args.len() && !args[index + 1].as_ref().starts_with('-')) - { - index += 2; - continue; - } - index += 1; - continue; - } - parts.push(arg.to_owned()); - index += 1; - } - parts.join(":") -} - -#[must_use] -/// Reports whether raw args contain a true `--schema` flag. -pub fn has_true_schema_flag(args: &[impl AsRef]) -> bool { - for arg in args { - let arg = arg.as_ref(); - if arg == "--schema" { - return true; - } - if let Some(value) = arg.strip_prefix("--schema=") { - return parse_compat_bool(value).unwrap_or(false); - } - } - false -} - -pub(crate) fn compat_bool_value_parser() -> ValueParser { - ValueParser::new(parse_compat_bool) -} - -fn parse_compat_bool(raw: &str) -> Result { - match raw { - "1" | "t" | "T" | "TRUE" | "true" | "True" => Ok(true), - "0" | "f" | "F" | "FALSE" | "false" | "False" => Ok(false), - _ => Err(format!("invalid boolean value {raw:?}")), - } -} - -#[must_use] -/// Derives flag names that do not consume the following token. -pub fn derive_bool_flags(command: &Command) -> BTreeSet { - let mut flags = BTreeSet::from([ - "--help".to_owned(), - "-h".to_owned(), - "--verbose".to_owned(), - "--debug".to_owned(), - ]); - collect_flag_names(command, &mut |arg, name| { - if !arg_requires_value(arg) { - flags.insert(name); - } - }); - flags -} - -#[must_use] -/// Derives flag names that consume the following token. -pub fn derive_value_flags(command: &Command) -> BTreeSet { - let mut flags = BTreeSet::new(); - collect_flag_names(command, &mut |arg, name| { - if arg_requires_value(arg) { - flags.insert(name); - } - }); - flags -} - -fn collect_flag_names(command: &Command, visit: &mut impl FnMut(&Arg, String)) { - for arg in command.get_arguments() { - if arg.is_positional() { - continue; - } - if let Some(long) = arg.get_long() { - visit(arg, format!("--{long}")); - } - if let Some(short) = arg.get_short() { - visit(arg, format!("-{short}")); - } - } - for child in command.get_subcommands() { - collect_flag_names(child, visit); - } -} - -/// Reports whether a `--debug` pattern enables a named component. -/// -/// The pattern is a comma-separated list of tokens applied left to right, so -/// later tokens override earlier ones: -/// -/// - `*` enables every component; `-*` disables every component. -/// - `name` enables that component; `-name` disables it. -/// - whitespace around tokens is ignored and matching is case-insensitive. -/// -/// An empty pattern enables nothing. Tokens that name other components are -/// ignored for the queried `component`. -/// -/// # Examples -/// -/// ``` -/// use cli_engine::debug_component_enabled; -/// -/// assert!(debug_component_enabled("*", "transport")); -/// assert!(debug_component_enabled("transport", "transport")); -/// assert!(!debug_component_enabled("*,-transport", "transport")); -/// assert!(debug_component_enabled("*,-auth", "transport")); -/// assert!(!debug_component_enabled("", "transport")); -/// ``` -#[must_use] -pub fn debug_component_enabled(pattern: &str, component: &str) -> bool { - let component = component.trim().to_ascii_lowercase(); - // Fail closed: an empty component name is never enabled, not even by `*`. - if component.is_empty() { - return false; - } - let mut enabled = false; - for raw in pattern.split(',') { - let token = raw.trim(); - if token.is_empty() { - continue; - } - let (negated, name) = token - .strip_prefix('-') - .map_or((false, token), |rest| (true, rest)); - let name = name.trim().to_ascii_lowercase(); - if name == "*" || name == component { - enabled = !negated; - } - } - enabled -} - -fn arg_requires_value(arg: &Arg) -> bool { - match arg.get_action() { - ArgAction::Set | ArgAction::Append => arg - .get_num_args() - .is_none_or(|range| range.takes_values() && range.min_values() > 0), - ArgAction::SetTrue - | ArgAction::SetFalse - | ArgAction::Count - | ArgAction::Help - | ArgAction::HelpShort - | ArgAction::HelpLong - | ArgAction::Version => false, - _ => arg - .get_num_args() - .is_some_and(|range| range.takes_values() && range.min_values() > 0), - } -} - -#[cfg(test)] -mod tests { - use clap::Command; - - use super::{ - debug_component_enabled, min_stage_env_var, output_env_var, register_global_flags, - resolve_default_output_format, - }; - - #[test] - fn debug_component_matcher_handles_wildcards_and_negation() { - // Empty pattern enables nothing. - assert!(!debug_component_enabled("", "transport")); - // Wildcard enables everything. - assert!(debug_component_enabled("*", "transport")); - assert!(debug_component_enabled("*", "auth")); - // Bare name enables only that component. - assert!(debug_component_enabled("transport", "transport")); - assert!(!debug_component_enabled("transport", "auth")); - // Negation after a wildcard removes one component but keeps the rest. - assert!(!debug_component_enabled("*,-transport", "transport")); - assert!(debug_component_enabled("*,-auth", "transport")); - // `-*` disables everything; later tokens still win. - assert!(!debug_component_enabled("*,-*", "transport")); - assert!(debug_component_enabled("-*,transport", "transport")); - // Whitespace and case are ignored. - assert!(debug_component_enabled(" Transport , -auth ", "transport")); - // An empty component fails closed, even against a wildcard. - assert!(!debug_component_enabled("*", "")); - assert!(!debug_component_enabled("*", " ")); - } - - #[test] - fn default_output_format_follows_env_override_then_tty() { - // TTY policy when no env or config override. - assert_eq!(resolve_default_output_format(None, None, true), "human"); - assert_eq!(resolve_default_output_format(None, None, false), "json"); - // A valid env override wins over the TTY policy in both directions. - assert_eq!( - resolve_default_output_format(Some("json"), None, true), - "json" - ); - assert_eq!( - resolve_default_output_format(Some("human"), None, false), - "human" - ); - // Env override is case-insensitive (env vars are commonly upper-cased). - assert_eq!( - resolve_default_output_format(Some("JSON"), None, true), - "json" - ); - assert_eq!( - resolve_default_output_format(Some(" Human "), None, false), - "human" - ); - // Blank or unrecognized env overrides are ignored (fall back to TTY). - assert_eq!( - resolve_default_output_format(Some(" "), None, false), - "json" - ); - assert_eq!(resolve_default_output_format(Some(""), None, true), "human"); - assert_eq!( - resolve_default_output_format(Some("yaml"), None, false), - "json" - ); - assert_eq!( - resolve_default_output_format(Some("yaml"), None, true), - "human" - ); - } - - #[test] - fn default_output_format_config_override_wins_over_tty_but_not_env() { - // Config override wins over the TTY policy when there's no env override. - assert_eq!( - resolve_default_output_format(None, Some("json"), true), - "json" - ); - assert_eq!( - resolve_default_output_format(None, Some("human"), false), - "human" - ); - // Env override still wins over a config override. - assert_eq!( - resolve_default_output_format(Some("human"), Some("json"), false), - "human" - ); - // Blank or unrecognized config overrides are ignored (fall back to TTY). - assert_eq!( - resolve_default_output_format(None, Some("yaml"), true), - "human" - ); - assert_eq!( - resolve_default_output_format(None, Some("yaml"), false), - "json" - ); - } - - #[test] - fn output_env_var_is_derived_from_app_id() { - assert_eq!(output_env_var("godaddy"), "GODADDY_OUTPUT"); - assert_eq!(output_env_var("gdx"), "GDX_OUTPUT"); - assert_eq!(output_env_var("my-cli"), "MY_CLI_OUTPUT"); - } - - #[test] - fn min_stage_env_var_is_derived_from_app_id() { - assert_eq!(min_stage_env_var("godaddy"), "GODADDY_MIN_STAGE"); - assert_eq!(min_stage_env_var("gdx"), "GDX_MIN_STAGE"); - assert_eq!(min_stage_env_var("my-cli"), "MY_CLI_MIN_STAGE"); - } - - #[test] - fn short_and_long_help_flags_render_identical_output() { - let build = || { - register_global_flags(Command::new("testcli")) - .subcommand(Command::new("sub").about("A subcommand")) - }; - let help_text = |args: &[&str]| { - build() - .try_get_matches_from(args) - .expect_err("help action short-circuits parsing") - .to_string() - }; - - assert_eq!( - help_text(&["testcli", "-h"]), - help_text(&["testcli", "--help"]) - ); - assert_eq!( - help_text(&["testcli", "sub", "-h"]), - help_text(&["testcli", "sub", "--help"]) - ); - } - - #[test] - fn interactivity_mode_from_bool() { - use super::InteractivityMode; - assert_eq!( - InteractivityMode::from(true), - InteractivityMode::Interactive - ); - assert_eq!( - InteractivityMode::from(false), - InteractivityMode::NonInteractive - ); - assert!(InteractivityMode::Interactive.is_interactive()); - assert!(!InteractivityMode::NonInteractive.is_interactive()); - } - - #[test] - fn interactive_flag_parsing_explicit_interactive() { - use super::global_flags_from_matches; - let cmd = register_global_flags(Command::new("test")); - let matches = cmd - .try_get_matches_from(["test", "--interactive"]) - .expect("should parse"); - // --interactive works even when auto_interactive is false - let flags = global_flags_from_matches(&matches, "json", false); - assert!(flags.interactive); - } - - #[test] - fn interactive_flag_parsing_explicit_non_interactive() { - use super::global_flags_from_matches; - let cmd = register_global_flags(Command::new("test")); - let matches = cmd - .try_get_matches_from(["test", "--non-interactive"]) - .expect("should parse"); - // --non-interactive wins even when auto_interactive is true - let flags = global_flags_from_matches(&matches, "json", true); - assert!(!flags.interactive); - } - - #[test] - fn interactive_defaults_off_without_auto_interactive() { - use super::global_flags_from_matches; - let cmd = register_global_flags(Command::new("test")); - let matches = cmd.try_get_matches_from(["test"]).expect("should parse"); - // No explicit flag + auto_interactive=false → not interactive - let flags = global_flags_from_matches(&matches, "json", false); - assert!(!flags.interactive); - } - - #[test] - fn interactive_flag_conflicts() { - let cmd = register_global_flags(Command::new("test")); - let result = cmd.try_get_matches_from(["test", "--interactive", "--non-interactive"]); - assert!(result.is_err()); - } - - #[test] - fn detect_interactive_is_consistent_with_tty_state() { - // detect_interactive checks stdin + stderr TTY state. - // In CI (no real TTY), both are typically non-terminals → false. - // Locally in a real terminal, both are terminals → true. - // Either way, it should not panic and should be consistent. - let result = super::detect_interactive(); - let stdin_tty = std::io::IsTerminal::is_terminal(&std::io::stdin()); - let stderr_tty = std::io::IsTerminal::is_terminal(&std::io::stderr()); - assert_eq!(result, stdin_tty && stderr_tty); - } -} diff --git a/cli-engine/src/flags/introspect.rs b/cli-engine/src/flags/introspect.rs new file mode 100644 index 0000000..ef7701c --- /dev/null +++ b/cli-engine/src/flags/introspect.rs @@ -0,0 +1,114 @@ +use std::collections::BTreeSet; + +use clap::{Arg, ArgAction, Command}; + +#[must_use] +/// Derives flag names that do not consume the following token. +pub fn derive_bool_flags(command: &Command) -> BTreeSet { + let mut flags = BTreeSet::from([ + "--help".to_owned(), + "-h".to_owned(), + "--verbose".to_owned(), + "--debug".to_owned(), + ]); + collect_flag_names(command, &mut |arg, name| { + if !arg_requires_value(arg) { + flags.insert(name); + } + }); + flags +} + +#[must_use] +/// Derives flag names that consume the following token. +pub fn derive_value_flags(command: &Command) -> BTreeSet { + let mut flags = BTreeSet::new(); + collect_flag_names(command, &mut |arg, name| { + if arg_requires_value(arg) { + flags.insert(name); + } + }); + flags +} + +fn collect_flag_names(command: &Command, visit: &mut impl FnMut(&Arg, String)) { + for arg in command.get_arguments() { + if arg.is_positional() { + continue; + } + if let Some(long) = arg.get_long() { + visit(arg, format!("--{long}")); + } + if let Some(short) = arg.get_short() { + visit(arg, format!("-{short}")); + } + } + for child in command.get_subcommands() { + collect_flag_names(child, visit); + } +} + +/// Reports whether a `--debug` pattern enables a named component. +/// +/// The pattern is a comma-separated list of tokens applied left to right, so +/// later tokens override earlier ones: +/// +/// - `*` enables every component; `-*` disables every component. +/// - `name` enables that component; `-name` disables it. +/// - whitespace around tokens is ignored and matching is case-insensitive. +/// +/// An empty pattern enables nothing. Tokens that name other components are +/// ignored for the queried `component`. +/// +/// # Examples +/// +/// ``` +/// use cli_engine::debug_component_enabled; +/// +/// assert!(debug_component_enabled("*", "transport")); +/// assert!(debug_component_enabled("transport", "transport")); +/// assert!(!debug_component_enabled("*,-transport", "transport")); +/// assert!(debug_component_enabled("*,-auth", "transport")); +/// assert!(!debug_component_enabled("", "transport")); +/// ``` +#[must_use] +pub fn debug_component_enabled(pattern: &str, component: &str) -> bool { + let component = component.trim().to_ascii_lowercase(); + // Fail closed: an empty component name is never enabled, not even by `*`. + if component.is_empty() { + return false; + } + let mut enabled = false; + for raw in pattern.split(',') { + let token = raw.trim(); + if token.is_empty() { + continue; + } + let (negated, name) = token + .strip_prefix('-') + .map_or((false, token), |rest| (true, rest)); + let name = name.trim().to_ascii_lowercase(); + if name == "*" || name == component { + enabled = !negated; + } + } + enabled +} + +fn arg_requires_value(arg: &Arg) -> bool { + match arg.get_action() { + ArgAction::Set | ArgAction::Append => arg + .get_num_args() + .is_none_or(|range| range.takes_values() && range.min_values() > 0), + ArgAction::SetTrue + | ArgAction::SetFalse + | ArgAction::Count + | ArgAction::Help + | ArgAction::HelpShort + | ArgAction::HelpLong + | ArgAction::Version => false, + _ => arg + .get_num_args() + .is_some_and(|range| range.takes_values() && range.min_values() > 0), + } +} diff --git a/cli-engine/src/flags/mod.rs b/cli-engine/src/flags/mod.rs new file mode 100644 index 0000000..11203d6 --- /dev/null +++ b/cli-engine/src/flags/mod.rs @@ -0,0 +1,383 @@ +use std::io::IsTerminal; + +mod introspect; +mod register; +mod resolve; + +pub use introspect::{debug_component_enabled, derive_bool_flags, derive_value_flags}; +pub(crate) use register::{apply_pagination_args, compat_bool_value_parser}; +pub use register::{register_global_flags, register_reason_flag}; +pub use resolve::{ + app_id_env_prefix, default_output_format, extract_command_path, extract_output_format, + global_flags_from_matches, has_true_schema_flag, min_stage_env_var, output_env_var, + resolve_default_output_format, +}; + +/// Returns `true` when the process appears to be running interactively: +/// stdin and stderr are both TTYs. +/// +/// Checking stdin ensures that piped input (`echo "" | gddy ...`) is detected +/// as non-interactive. Checking stderr ensures prompts can be displayed (since +/// `inquire` renders to stderr). Stdout is intentionally not checked — a user +/// piping output (`gddy ... | jq`) still has an interactive terminal for +/// prompts. +/// +/// Used as the default for `GlobalFlags::interactive` when the user does not +/// pass `--interactive` or `--non-interactive` explicitly. +#[must_use] +pub fn detect_interactive() -> bool { + std::io::stdin().is_terminal() && std::io::stderr().is_terminal() +} + +/// Interactivity mode for a CLI invocation. +/// +/// Commands and middleware can inspect this to decide whether to prompt for +/// missing inputs, display progress spinners, or fall back to error messages +/// suitable for scripts and CI. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum InteractivityMode { + /// The user explicitly requested interactive prompts (`--interactive`), or + /// the process is running in a TTY without CI indicators. + Interactive, + /// The user explicitly disabled prompts (`--non-interactive`), or the + /// process is running in a non-TTY / CI context. + NonInteractive, +} + +impl InteractivityMode { + /// Returns `true` when prompts and interactive flows are appropriate. + #[must_use] + pub fn is_interactive(self) -> bool { + self == Self::Interactive + } +} + +impl From for InteractivityMode { + fn from(interactive: bool) -> Self { + if interactive { + Self::Interactive + } else { + Self::NonInteractive + } + } +} + +/// Parsed framework-global flags. +/// +/// Applications can add their own global flags, but these are the built-in +/// controls understood by middleware and the output pipeline. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct GlobalFlags { + /// Output format: `json`, `human`, or `toon`. + pub output_format: String, + /// Metadata verbosity selector. + pub verbose: String, + /// Whether mutating commands should short-circuit. + pub dry_run: bool, + /// Field projection. + pub fields: String, + /// Whether `fields` came from an explicit `--fields` flag on the command + /// line, rather than clap filling in a command's `default_fields` (a + /// command with `default_fields` set registers it as that flag's native + /// default, so `fields` is non-empty even when the user never typed + /// `--fields` — this is the only reliable way to tell the two apart). + pub fields_explicit: bool, + /// JMESPath per-item filter. + pub filter: String, + /// JMESPath whole-result expression. + pub expr: String, + /// Whether schema rendering was requested. + pub schema: bool, + /// User-provided command reason. + pub reason: String, + /// Raw timeout string. + pub timeout: String, + /// Debug selector. + pub debug: String, + /// Credential storage override from `--credential-store`, if supplied. + pub credential_store: Option, + /// Interactivity mode: `true` enables prompts for missing inputs, + /// `false` disables them. Auto-detected from TTY when neither flag is given. + pub interactive: bool, +} + +impl Default for GlobalFlags { + fn default() -> Self { + Self { + output_format: "json".to_owned(), + verbose: String::new(), + dry_run: false, + fields: String::new(), + fields_explicit: false, + filter: String::new(), + expr: String::new(), + schema: false, + reason: String::new(), + timeout: "0s".to_owned(), + debug: String::new(), + credential_store: None, + interactive: detect_interactive(), + } + } +} + +/// Explicit `--help` display-order values for the engine's own global flags, +/// numbered in the order they're registered below — which is meant to read +/// as their relative importance, most-used first. +/// +/// Without this, every global flag would collide with command-specific +/// ones: clap auto-assigns each unset `display_order` as "the Nth argument +/// added to this `Command`," starting the count over at 0 on every +/// `Command` it's called on — the root (where these are declared) and each +/// subcommand alike. A subcommand's own `CommandSpec::with_arg` args get +/// low counter values (0, 1, 2, ... in declaration order) from their own +/// `Command`; a global flag propagated onto that subcommand keeps the low +/// counter value it got on the *root*. Mix the two and `--help` interleaves +/// them instead of showing command-specific flags first, as a block, in the +/// order they were declared. Parking every global flag comfortably above +/// any realistic per-command arg count keeps that from happening. +/// +/// `FIELDS`, `FILTER`, and `EXPR` are `pub(crate)` because `cli.rs` +/// re-registers those three per-command (see `apply_fields_arg` and +/// `apply_filter_and_expr_examples`) with contextual help text; they must +/// reuse these same values or the override would drift out of position. +/// +/// `LIMIT` and `OFFSET` are never registered by [`register_global_flags`] +/// itself — unlike every other value here, `--limit`/`--offset` are not +/// framework-global at all; `cli.rs` registers them directly on a single +/// command's own `Command` (see `apply_pagination_args`), and only for a +/// command that opted in via `CommandSpec::with_pagination`. These two +/// constants exist purely so that per-command registration still parks the +/// flags in the same relative `--help` position other engine flags occupy. +/// +/// `REASON` and `ENV` cover the two global flags `Cli::new` registers +/// directly (conditionally, outside `register_global_flags`) rather than +/// this module's own function — `--reason` when an authorizer/auditor/ +/// activity emitter is configured, `--env` when `CliConfig.environments` is +/// set. Both are just as subject to the collision this module exists to +/// prevent, so both need an explicit value here too. +pub(crate) mod global_flag_order { + pub(crate) const HELP: usize = 1000; + pub(crate) const OUTPUT: usize = 1001; + pub(crate) const VERBOSE: usize = 1002; + pub(crate) const DRY_RUN: usize = 1003; + pub(crate) const FIELDS: usize = 1004; + pub(crate) const FILTER: usize = 1005; + pub(crate) const EXPR: usize = 1006; + pub(crate) const LIMIT: usize = 1007; + pub(crate) const OFFSET: usize = 1008; + pub(crate) const SCHEMA: usize = 1009; + pub(crate) const TIMEOUT: usize = 1010; + pub(crate) const DEBUG: usize = 1011; + pub(crate) const CREDENTIAL_STORE: usize = 1012; + pub(crate) const JSON: usize = 1013; + pub(crate) const TOON: usize = 1014; + pub(crate) const HUMAN: usize = 1015; + pub(crate) const INTERACTIVE: usize = 1016; + pub(crate) const REASON: usize = 1017; + pub(crate) const ENV: usize = 1018; +} + +#[cfg(test)] +mod tests { + use clap::Command; + + use super::{ + debug_component_enabled, min_stage_env_var, output_env_var, register_global_flags, + resolve_default_output_format, + }; + + #[test] + fn debug_component_matcher_handles_wildcards_and_negation() { + // Empty pattern enables nothing. + assert!(!debug_component_enabled("", "transport")); + // Wildcard enables everything. + assert!(debug_component_enabled("*", "transport")); + assert!(debug_component_enabled("*", "auth")); + // Bare name enables only that component. + assert!(debug_component_enabled("transport", "transport")); + assert!(!debug_component_enabled("transport", "auth")); + // Negation after a wildcard removes one component but keeps the rest. + assert!(!debug_component_enabled("*,-transport", "transport")); + assert!(debug_component_enabled("*,-auth", "transport")); + // `-*` disables everything; later tokens still win. + assert!(!debug_component_enabled("*,-*", "transport")); + assert!(debug_component_enabled("-*,transport", "transport")); + // Whitespace and case are ignored. + assert!(debug_component_enabled(" Transport , -auth ", "transport")); + // An empty component fails closed, even against a wildcard. + assert!(!debug_component_enabled("*", "")); + assert!(!debug_component_enabled("*", " ")); + } + + #[test] + fn default_output_format_follows_env_override_then_tty() { + // TTY policy when no env or config override. + assert_eq!(resolve_default_output_format(None, None, true), "human"); + assert_eq!(resolve_default_output_format(None, None, false), "json"); + // A valid env override wins over the TTY policy in both directions. + assert_eq!( + resolve_default_output_format(Some("json"), None, true), + "json" + ); + assert_eq!( + resolve_default_output_format(Some("human"), None, false), + "human" + ); + // Env override is case-insensitive (env vars are commonly upper-cased). + assert_eq!( + resolve_default_output_format(Some("JSON"), None, true), + "json" + ); + assert_eq!( + resolve_default_output_format(Some(" Human "), None, false), + "human" + ); + // Blank or unrecognized env overrides are ignored (fall back to TTY). + assert_eq!( + resolve_default_output_format(Some(" "), None, false), + "json" + ); + assert_eq!(resolve_default_output_format(Some(""), None, true), "human"); + assert_eq!( + resolve_default_output_format(Some("yaml"), None, false), + "json" + ); + assert_eq!( + resolve_default_output_format(Some("yaml"), None, true), + "human" + ); + } + + #[test] + fn default_output_format_config_override_wins_over_tty_but_not_env() { + // Config override wins over the TTY policy when there's no env override. + assert_eq!( + resolve_default_output_format(None, Some("json"), true), + "json" + ); + assert_eq!( + resolve_default_output_format(None, Some("human"), false), + "human" + ); + // Env override still wins over a config override. + assert_eq!( + resolve_default_output_format(Some("human"), Some("json"), false), + "human" + ); + // Blank or unrecognized config overrides are ignored (fall back to TTY). + assert_eq!( + resolve_default_output_format(None, Some("yaml"), true), + "human" + ); + assert_eq!( + resolve_default_output_format(None, Some("yaml"), false), + "json" + ); + } + + #[test] + fn output_env_var_is_derived_from_app_id() { + assert_eq!(output_env_var("godaddy"), "GODADDY_OUTPUT"); + assert_eq!(output_env_var("gdx"), "GDX_OUTPUT"); + assert_eq!(output_env_var("my-cli"), "MY_CLI_OUTPUT"); + } + + #[test] + fn min_stage_env_var_is_derived_from_app_id() { + assert_eq!(min_stage_env_var("godaddy"), "GODADDY_MIN_STAGE"); + assert_eq!(min_stage_env_var("gdx"), "GDX_MIN_STAGE"); + assert_eq!(min_stage_env_var("my-cli"), "MY_CLI_MIN_STAGE"); + } + + #[test] + fn short_and_long_help_flags_render_identical_output() { + let build = || { + register_global_flags(Command::new("testcli")) + .subcommand(Command::new("sub").about("A subcommand")) + }; + let help_text = |args: &[&str]| { + build() + .try_get_matches_from(args) + .expect_err("help action short-circuits parsing") + .to_string() + }; + + assert_eq!( + help_text(&["testcli", "-h"]), + help_text(&["testcli", "--help"]) + ); + assert_eq!( + help_text(&["testcli", "sub", "-h"]), + help_text(&["testcli", "sub", "--help"]) + ); + } + + #[test] + fn interactivity_mode_from_bool() { + use super::InteractivityMode; + assert_eq!( + InteractivityMode::from(true), + InteractivityMode::Interactive + ); + assert_eq!( + InteractivityMode::from(false), + InteractivityMode::NonInteractive + ); + assert!(InteractivityMode::Interactive.is_interactive()); + assert!(!InteractivityMode::NonInteractive.is_interactive()); + } + + #[test] + fn interactive_flag_parsing_explicit_interactive() { + use super::global_flags_from_matches; + let cmd = register_global_flags(Command::new("test")); + let matches = cmd + .try_get_matches_from(["test", "--interactive"]) + .expect("should parse"); + // --interactive works even when auto_interactive is false + let flags = global_flags_from_matches(&matches, "json", false); + assert!(flags.interactive); + } + + #[test] + fn interactive_flag_parsing_explicit_non_interactive() { + use super::global_flags_from_matches; + let cmd = register_global_flags(Command::new("test")); + let matches = cmd + .try_get_matches_from(["test", "--non-interactive"]) + .expect("should parse"); + // --non-interactive wins even when auto_interactive is true + let flags = global_flags_from_matches(&matches, "json", true); + assert!(!flags.interactive); + } + + #[test] + fn interactive_defaults_off_without_auto_interactive() { + use super::global_flags_from_matches; + let cmd = register_global_flags(Command::new("test")); + let matches = cmd.try_get_matches_from(["test"]).expect("should parse"); + // No explicit flag + auto_interactive=false → not interactive + let flags = global_flags_from_matches(&matches, "json", false); + assert!(!flags.interactive); + } + + #[test] + fn interactive_flag_conflicts() { + let cmd = register_global_flags(Command::new("test")); + let result = cmd.try_get_matches_from(["test", "--interactive", "--non-interactive"]); + assert!(result.is_err()); + } + + #[test] + fn detect_interactive_is_consistent_with_tty_state() { + // detect_interactive checks stdin + stderr TTY state. + // In CI (no real TTY), both are typically non-terminals → false. + // Locally in a real terminal, both are terminals → true. + // Either way, it should not panic and should be consistent. + let result = super::detect_interactive(); + let stdin_tty = std::io::IsTerminal::is_terminal(&std::io::stdin()); + let stderr_tty = std::io::IsTerminal::is_terminal(&std::io::stderr()); + assert_eq!(result, stdin_tty && stderr_tty); + } +} diff --git a/cli-engine/src/flags/register.rs b/cli-engine/src/flags/register.rs new file mode 100644 index 0000000..3ca7703 --- /dev/null +++ b/cli-engine/src/flags/register.rs @@ -0,0 +1,299 @@ +use std::io::IsTerminal; + +use clap::{Arg, ArgAction, Command, builder::ValueParser}; + +use super::global_flag_order; + +/// Registers framework-global flags on a `clap` command. +pub fn register_global_flags(command: Command) -> Command { + command + .disable_help_flag(true) + .arg( + // clap's default help arg shows an abbreviated summary for `-h` + // and the full help text for `--help`. Override it so both + // flags print the same full help everywhere; `disable_help_flag` + // propagates to every subcommand. + Arg::new("help") + .short('h') + .long("help") + .action(ArgAction::HelpLong) + .global(true) + .display_order(global_flag_order::HELP) + .help("Print help"), + ) + .arg( + Arg::new("output") + .long("output") + .short('o') + .global(true) + .display_order(global_flag_order::OUTPUT) + .value_name("FORMAT") + // This default is cosmetic, not authoritative: it's never + // actually read as a value — `global_flags_from_matches` only + // consults this arg when it was given on the command line, + // falling back to `resolve_default_output_format`'s full + // env/config/TTY precedence the rest of the time. But since + // `--help` runs in this same process, this process's own + // stdout TTY-ness is already known and stable for the whole + // run, so mirroring that one signal here (skipping the + // env-var/config-file tiers, which aren't available until a + // command actually executes) keeps what `--help` shows honest + // in the common case instead of a hardcoded, often-wrong + // `[default: json]`. + .default_value(if std::io::stdout().is_terminal() { + "human" + } else { + "json" + }) + // Only conflicts when *explicitly* given: clap's conflict + // checks ignore an arg's default value, so a bare `--json` + // with no `--output` at all is unaffected. + .conflicts_with_all(["json", "toon", "human"]) + .help( + "Output format: toon|json|human (shorthand: --json, --toon, --human); \ + defaults to human in an interactive terminal, json otherwise", + ), + ) + .arg( + Arg::new("verbose") + .long("verbose") + .global(true) + .num_args(0..=1) + .default_missing_value("all") + .value_name("FIELDS") + .display_order(global_flag_order::VERBOSE) + .help("Include metadata in output (all, or comma-separated: system,duration,args,env,identity,command,effective_args,timestamp)"), + ) + .arg( + Arg::new("dry-run") + .long("dry-run") + .global(true) + .num_args(0..=1) + .require_equals(true) + .default_missing_value("true") + .default_value("false") + .value_parser(compat_bool_value_parser()) + .display_order(global_flag_order::DRY_RUN) + .help("Preview mutations without executing"), + ) + .arg( + Arg::new("fields") + .long("fields") + .global(true) + .value_name("FIELDS") + .display_order(global_flag_order::FIELDS) + .help("Comma-separated fields to include in output (use 'all' or '*' for everything)"), + ) + .arg( + Arg::new("filter") + .long("filter") + .global(true) + .value_name("EXPR") + .display_order(global_flag_order::FILTER) + .help("Per-item JMESPath predicate for list data"), + ) + .arg( + Arg::new("expr") + .long("expr") + .global(true) + .value_name("EXPR") + .display_order(global_flag_order::EXPR) + .help("JMESPath query applied to the whole result"), + ) + .arg( + Arg::new("schema") + .long("schema") + .global(true) + .num_args(0..=1) + .require_equals(true) + .default_missing_value("true") + .default_value("false") + .value_parser(compat_bool_value_parser()) + .display_order(global_flag_order::SCHEMA) + .help("Dump output field metadata instead of running the command"), + ) + .arg( + Arg::new("timeout") + .long("timeout") + .global(true) + .allow_hyphen_values(true) + .default_value("0s") + .value_name("DURATION") + .display_order(global_flag_order::TIMEOUT) + .help("Overall command timeout (e.g. 60s, 5m); default 0s = no timeout"), + ) + .arg( + Arg::new("debug") + .long("debug") + .global(true) + .num_args(0..=1) + .default_missing_value("*") + .value_name("PATTERN") + .display_order(global_flag_order::DEBUG) + .help("Enable debug logging (comma-separated component patterns, e.g. *, transport, *,-auth)"), + ) + .arg( + Arg::new("credential-store") + .long("credential-store") + .display_order(global_flag_order::CREDENTIAL_STORE) + .global(true) + .value_name("MODE") + .value_parser(|s: &str| s.parse::()) + .help("Credential storage: auto|keyring|file (overrides env and config)"), + ) + .arg( + Arg::new("interactive") + .long("interactive") + .short('i') + .global(true) + .action(ArgAction::SetTrue) + .conflicts_with("non-interactive") + .display_order(global_flag_order::INTERACTIVE) + .help("Force interactive prompts for missing inputs (default when TTY is detected)"), + ) + .arg( + Arg::new("non-interactive") + .long("non-interactive") + .global(true) + .action(ArgAction::SetTrue) + .conflicts_with("interactive") + .hide(true) + .display_order(global_flag_order::INTERACTIVE) + .help("Disable interactive prompts; fail on missing required inputs"), + ) + .arg( + Arg::new("json") + .long("json") + .global(true) + .action(ArgAction::SetTrue) + // Mutually exclusive with the other format selectors, so + // e.g. `--json --human` together is a usage error rather + // than one silently overriding the other. + .conflicts_with_all(["toon", "human"]) + // Documented on `--output` instead of taking their own line + // in every command's already-long options list. + .hide(true) + .display_order(global_flag_order::JSON) + .help("Shorthand for --output json"), + ) + .arg( + Arg::new("toon") + .long("toon") + .global(true) + .action(ArgAction::SetTrue) + .conflicts_with_all(["json", "human"]) + .hide(true) + .display_order(global_flag_order::TOON) + .help("Shorthand for --output toon"), + ) + .arg( + Arg::new("human") + .long("human") + .global(true) + .action(ArgAction::SetTrue) + .conflicts_with_all(["json", "toon"]) + .hide(true) + .display_order(global_flag_order::HUMAN) + .help("Shorthand for --output human"), + ) +} + +/// Registers the `--reason` flag on a `clap` command. +/// +/// Not part of [`register_global_flags`]: `--reason` is only meaningful when an +/// app has registered an [`Authorizer`](crate::middleware::Authorizer), +/// [`Auditor`](crate::middleware::Auditor), or +/// [`ActivityEmitter`](crate::middleware::ActivityEmitter) to consume it (see +/// `Cli::new`'s conditional call to this function). Apps with none of those +/// configured never register this flag at all, rather than exposing a flag +/// that nothing reads. `Cli::new` only checks the eager `authz`/`auditor`/ +/// `activity` fields on `CliConfig`; installing one of these later via +/// `init_deps` does not register `--reason`, since flag registration happens +/// before `init_deps` runs. +pub fn register_reason_flag(command: Command) -> Command { + command.arg( + Arg::new("reason") + .long("reason") + .global(true) + .value_name("TEXT") + .display_order(global_flag_order::REASON) + .help("Short explanation of why this command is being run (forwarded to your authorizer, auditor, or activity emitter)"), + ) +} + +/// Registers `--limit`/`--offset` directly on one command's own `clap` +/// `Command`, for a command whose [`CommandSpec`](crate::CommandSpec) opted +/// into pagination via `with_pagination`. +pub(crate) fn apply_pagination_args( + command: Command, + default_limit: i64, + max_limit: i64, +) -> Command { + command + .arg( + Arg::new("limit") + .long("limit") + .value_parser(pagination_limit_value_parser(max_limit)) + .allow_hyphen_values(true) + .default_value(default_limit.to_string()) + .display_order(global_flag_order::LIMIT) + .help(pagination_limit_help(default_limit, max_limit)), + ) + .arg( + Arg::new("offset") + .long("offset") + .value_parser(pagination_offset_value_parser()) + .allow_hyphen_values(true) + .default_value("0") + .display_order(global_flag_order::OFFSET) + .help("Skip N items before applying limit"), + ) +} + +fn pagination_limit_help(default_limit: i64, max_limit: i64) -> String { + let mut help = format!("Max items to return (client-side, 0=all, default {default_limit}"); + if max_limit > 0 { + help.push_str(&format!(", max {max_limit}")); + } + help.push(')'); + help +} + +fn pagination_limit_value_parser(max_limit: i64) -> ValueParser { + ValueParser::new(move |raw: &str| -> Result { + let value = raw + .parse::() + .map_err(|_| format!("invalid limit value {raw:?}"))?; + if max_limit > 0 && value > max_limit { + return Err(format!("limit {value} exceeds the maximum of {max_limit}")); + } + Ok(value) + }) +} + +/// Rejects a negative `--offset` at parse time — a `clap` usage error — rather +/// than letting it reach `apply_pagination` in `output/pipeline.rs`, which +/// already rejects one, but only once the command has otherwise fully run. +fn pagination_offset_value_parser() -> ValueParser { + ValueParser::new(|raw: &str| -> Result { + let value = raw + .parse::() + .map_err(|_| format!("invalid offset value {raw:?}"))?; + if value < 0 { + return Err(format!("offset {value} must be non-negative")); + } + Ok(value) + }) +} + +pub(crate) fn compat_bool_value_parser() -> ValueParser { + ValueParser::new(parse_compat_bool) +} + +pub(super) fn parse_compat_bool(raw: &str) -> Result { + match raw { + "1" | "t" | "T" | "TRUE" | "true" | "True" => Ok(true), + "0" | "f" | "F" | "FALSE" | "false" | "False" => Ok(false), + _ => Err(format!("invalid boolean value {raw:?}")), + } +} diff --git a/cli-engine/src/flags/resolve.rs b/cli-engine/src/flags/resolve.rs new file mode 100644 index 0000000..1280015 --- /dev/null +++ b/cli-engine/src/flags/resolve.rs @@ -0,0 +1,248 @@ +use std::collections::BTreeSet; +use std::io::IsTerminal; + +use clap::ArgMatches; + +use super::register::parse_compat_bool; +use super::{GlobalFlags, detect_interactive}; + +/// Resolves the default output format when the user gave no explicit format. +/// +/// Precedence: `env_override`, then `config_override` (the `[output].format` +/// key in `config.toml`), then a TTY policy — an interactive terminal gets +/// human-friendly output, everything else (pipes, files, CI, most agents) +/// gets machine-readable JSON. Pure so it can be unit-tested without a real +/// terminal or config file. +#[must_use] +pub fn resolve_default_output_format( + env_override: Option<&str>, + config_override: Option<&str>, + is_tty: bool, +) -> String { + // Normalize case (env vars and config values are commonly upper/mixed + // case) and ignore blank or unrecognized values, so a stray or miscased + // override can't break all command output — only a valid format is + // honored, and an invalid one falls through to the next tier. + for candidate in [env_override, config_override].into_iter().flatten() { + let normalized = candidate.trim().to_ascii_lowercase(); + if crate::output::is_valid_output_format(&normalized) { + return normalized; + } + } + if is_tty { "human" } else { "json" }.to_owned() +} + +/// Sanitizes an app id into an environment-variable prefix: ASCII alphanumerics +/// are uppercased and every other character becomes `_`, e.g. `godaddy` -> +/// `GODADDY`, `my-cli` -> `MY_CLI`. +/// +/// Shared by the framework's app-scoped env vars (for example +/// [`output_env_var`] and `${PREFIX}_CREDENTIAL_STORE`) so they derive the same +/// prefix from a given app id. +#[must_use] +pub fn app_id_env_prefix(app_id: &str) -> String { + app_id + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() { + c.to_ascii_uppercase() + } else { + '_' + } + }) + .collect() +} + +/// Derives the per-application output-format override env var from an app id, +/// e.g. `godaddy` -> `GODADDY_OUTPUT`, `gdx` -> `GDX_OUTPUT`. +#[must_use] +pub fn output_env_var(app_id: &str) -> String { + format!("{}_OUTPUT", app_id_env_prefix(app_id)) +} + +/// Derives the per-application global minimum-stage override env var from an +/// app id, e.g. `godaddy` -> `GODADDY_MIN_STAGE`, `gdx` -> `GDX_MIN_STAGE`. +#[must_use] +pub fn min_stage_env_var(app_id: &str) -> String { + format!("{}_MIN_STAGE", app_id_env_prefix(app_id)) +} + +/// Computes the default output format for `app_id`, consulting the +/// `${APP_ID}_OUTPUT` env override, the `[output].format` key in +/// `config.toml`, and whether stdout is an interactive terminal. Used as the +/// fallback when no explicit `--output`/`--json`/`--toon`/`--human` is given. +/// +/// **Blocking**: this loads `config.toml` (see +/// [`ConfigFile::load`](crate::config::ConfigFile::load)), performing +/// synchronous filesystem I/O. `Cli` itself never calls this — it resolves +/// the default from the config already loaded once at `Cli::new` time +/// instead — but a consumer calling this function directly should avoid +/// doing so from a hot path or within an async executor without +/// `spawn_blocking`. +#[must_use] +pub fn default_output_format(app_id: &str) -> String { + let env = std::env::var(output_env_var(app_id)).ok(); + let file = crate::config::load(app_id); + resolve_default_output_format( + env.as_deref(), + file.output.format.as_deref(), + std::io::stdout().is_terminal(), + ) +} + +#[must_use] +/// Extracts framework-global flags from parsed `clap` matches, falling back to +/// `default_format` when the user gave no explicit output format. +pub fn global_flags_from_matches( + matches: &ArgMatches, + default_format: &str, + auto_interactive: bool, +) -> GlobalFlags { + let output_format = if matches.get_flag("toon") { + "toon".to_owned() + } else if matches.get_flag("human") { + "human".to_owned() + } else if matches.get_flag("json") { + "json".to_owned() + } else if matches.value_source("output") == Some(clap::parser::ValueSource::CommandLine) { + matches + .get_one::("output") + .cloned() + .unwrap_or_else(|| default_format.to_owned()) + } else { + default_format.to_owned() + }; + + GlobalFlags { + output_format, + verbose: matches + .get_one::("verbose") + .cloned() + .unwrap_or_default(), + dry_run: matches.get_one::("dry-run").copied().unwrap_or(false), + fields: matches + .get_one::("fields") + .cloned() + .unwrap_or_default(), + fields_explicit: matches.value_source("fields") + == Some(clap::parser::ValueSource::CommandLine), + filter: matches + .get_one::("filter") + .cloned() + .unwrap_or_default(), + expr: matches + .get_one::("expr") + .cloned() + .unwrap_or_default(), + schema: matches.get_one::("schema").copied().unwrap_or(false), + // `--reason` is only registered when an authorizer/auditor/activity + // emitter is configured. + reason: matches + .try_get_one::("reason") + .ok() + .flatten() + .cloned() + .unwrap_or_default(), + timeout: matches + .get_one::("timeout") + .cloned() + .unwrap_or_else(|| "0s".to_owned()), + debug: matches + .get_one::("debug") + .cloned() + .unwrap_or_default(), + credential_store: matches + .get_one::("credential-store") + .copied(), + interactive: if matches.get_flag("non-interactive") { + false + } else if matches.get_flag("interactive") { + true + } else if auto_interactive { + detect_interactive() + } else { + false + }, + } +} + +#[must_use] +/// Extracts output format from raw args. +/// +/// Recognizes `--output ` / `-o ` / `--output=`, +/// plus `--json`, `--toon`, and `--human` as shorthand for their respective +/// formats. Falls back to `default_format` when none is present. +pub fn extract_output_format(args: &[impl AsRef], default_format: &str) -> String { + for index in 0..args.len() { + let arg = args[index].as_ref(); + if arg == "--output" || arg == "-o" { + return args.get(index + 1).map_or_else( + || default_format.to_owned(), + |value| value.as_ref().to_owned(), + ); + } + if let Some(value) = arg.strip_prefix("--output=") { + return value.to_owned(); + } + if arg == "--json" { + return "json".to_owned(); + } + if arg == "--toon" { + return "toon".to_owned(); + } + if arg == "--human" { + return "human".to_owned(); + } + } + default_format.to_owned() +} + +#[must_use] +/// Extracts a colon-separated command path from raw args. +pub fn extract_command_path( + args: &[impl AsRef], + bool_flags: &BTreeSet, + value_flags: &BTreeSet, +) -> String { + let mut parts = Vec::new(); + let mut index = 1; + while index < args.len() { + let arg = args[index].as_ref(); + if arg == "--schema" { + index += 1; + continue; + } + if arg.starts_with('-') { + if bool_flags.contains(arg) || arg.contains('=') { + index += 1; + continue; + } + if value_flags.contains(arg) + || (index + 1 < args.len() && !args[index + 1].as_ref().starts_with('-')) + { + index += 2; + continue; + } + index += 1; + continue; + } + parts.push(arg.to_owned()); + index += 1; + } + parts.join(":") +} + +#[must_use] +/// Reports whether raw args contain a true `--schema` flag. +pub fn has_true_schema_flag(args: &[impl AsRef]) -> bool { + for arg in args { + let arg = arg.as_ref(); + if arg == "--schema" { + return true; + } + if let Some(value) = arg.strip_prefix("--schema=") { + return parse_compat_bool(value).unwrap_or(false); + } + } + false +} diff --git a/cli-engine/src/lib.rs b/cli-engine/src/lib.rs index 2cd5aff..441abff 100644 --- a/cli-engine/src/lib.rs +++ b/cli-engine/src/lib.rs @@ -104,8 +104,10 @@ pub mod middleware; pub mod module; /// Structured output envelopes, renderers, schemas, and field projection. pub mod output; -/// Search indexing for commands, guides, and extra documents. +/// Interactive terminal prompting for missing-argument recovery and +/// unknown-command correction. pub mod prompt; +/// Search indexing for commands, guides, and extra documents. pub mod search; /// Command risk tiers used by authentication, authorization, and dry-run. pub mod tier; diff --git a/cli-engine/src/middleware.rs b/cli-engine/src/middleware/mod.rs similarity index 51% rename from cli-engine/src/middleware.rs rename to cli-engine/src/middleware/mod.rs index 644e197..fad48ab 100644 --- a/cli-engine/src/middleware.rs +++ b/cli-engine/src/middleware/mod.rs @@ -1,26 +1,19 @@ -use std::{ - collections::{BTreeMap, BTreeSet}, - future::Future, - sync::Arc, - time::{Duration, Instant}, -}; +use std::{collections::BTreeMap, sync::Arc, time::Duration}; use async_trait::async_trait; use serde::{Deserialize, Serialize}; -use serde_json::{Map, Value, json}; +use serde_json::{Map, Value}; use tokio::sync::{Mutex, OnceCell}; use crate::{ - CommandResult, Credential, CredentialRequest, Dispatcher, FlagPolicy, FlagRegistry, Result, - SchemaRegistry, Tier, - error::{CliCoreError, exit_code_for_error}, - output::{ - Envelope, HumanViewRegistry, NextAction, OutputFormat, PipelineOpts, apply_pipeline, - build_error_envelope, is_valid_output_format, render_human_with_registry_selected, - unknown_fields_message, - }, + Credential, CredentialRequest, Dispatcher, FlagPolicy, FlagRegistry, Result, SchemaRegistry, + Tier, + error::CliCoreError, + output::{Envelope, HumanViewRegistry}, }; +mod run; + /// JSON object map used for command args and metadata. pub type ValueMap = Map; @@ -598,597 +591,6 @@ pub struct MiddlewareRequest<'request> { pub pagination_command: Option, } -impl Middleware { - /// Creates middleware with empty registries and default dependencies. - #[must_use] - pub fn new() -> Self { - Self::default() - } - - /// Runs the middleware chain for a command. - pub async fn run( - &self, - request: MiddlewareRequest<'_>, - command: F, - ) -> Result - where - F: FnOnce(CredentialResolver) -> Fut + Send, - Fut: Future> + Send, - Output: Into, - { - let start = Instant::now(); - let MiddlewareRequest { - meta, - command_path, - system, - user_args, - mut args, - default_fields, - view_id, - auth, - raw_output, - pagination_command, - } = request; - let no_auth = auth.is_none(); - let command_system = effective_request_system(system, command_path); - if !no_auth && !self.env.is_empty() && !args.contains_key("env") { - args.insert("env".to_owned(), Value::String(self.env.clone())); - } - - // Build a lazy resolver instead of resolving eagerly. No auth flow runs - // until a handler or authorizer actually asks for the credential, so - // commands that never use it (and `--schema`/`--dry-run`) skip auth. - let provider_name = meta - .provider() - .filter(|provider| !provider.is_empty()) - .unwrap_or(&self.default_auth_provider) - .to_owned(); - let resolved_env = meta.fixed_env().unwrap_or(&self.env).to_owned(); - let tier_text = meta - .auth_metadata - .get("tier") - .map_or("", String::as_str) - .to_owned(); - let resolver = CredentialResolver::new( - self.auth.clone(), - provider_name.clone(), - resolved_env, - command_path.to_owned(), - tier_text, - no_auth, - meta.clone(), - ); - - if no_auth - && let Some(output) = - self.render_schema_if_requested(command_path, start, &user_args, &args, "")? - { - return Ok(output); - } - - if let Some(authz) = &self.authz - && let Err(err) = authz - .authorize(command_path, &args, &resolver, &self.reason, meta.tier()) - .await - { - // An authorizer may have resolved the credential to make its - // decision; reflect whatever it resolved in audit identity. - let identity = resolver.peek().map_or("", |cred| cred.identity.as_str()); - // Classify by the error the authorizer returned: a propagated - // resolution failure is auth-typed; a policy denial is not. - let had_auth_error = err.is_auth(); - let result_tag = if had_auth_error { - "auth-error" - } else { - "denied" - }; - // Attribute auth-provider failures to the provider so telemetry can - // distinguish them from command backends. - let backend = if had_auth_error { - provider_name.as_str() - } else { - command_path - }; - self.write_audit(command_path, &args, identity, result_tag) - .await; - self.emit_activity( - command_path, - &args, - resolver.peek(), - result_tag, - backend, - &err.to_string(), - start, - ) - .await; - return self.render_error(&err, command_path, start, &user_args, &args, identity); - } - - // If the authorizer resolved the credential, include its identity in the - // schema output metadata. `peek()` never triggers resolution, so schema - // still doesn't provoke auth on its own. - let schema_identity = resolver.peek().map_or("", |cred| cred.identity.as_str()); - if let Some(output) = self.render_schema_if_requested( - command_path, - start, - &user_args, - &args, - schema_identity, - )? { - return Ok(output); - } - - if self.dry_run && meta.dry_run_prompt && !meta.handles_dry_run { - let identity = resolver.peek().map_or("", |cred| cred.identity.as_str()); - self.write_audit(command_path, &args, identity, "dry-run") - .await; - self.emit_activity( - command_path, - &args, - resolver.peek(), - "dry-run", - command_path, - "", - start, - ) - .await; - let envelope = Envelope::success( - json!({ - "command": command_path, - "action": "dry-run: would execute", - }), - command_path, - ) - .with_dry_run(); - return self.render_envelope( - envelope, - "", - "", - command_path, - start, - &user_args, - &args, - identity, - None, - false, - ); - } - - // Fail closed by default: for `Required` commands the engine resolves the - // credential before the handler runs, so a command that must be - // authenticated cannot execute unauthenticated even if its handler never - // reads the credential, and its audit/activity identity is always - // populated. `--schema`/`--dry-run` return above, so they never reach this - // point; `Optional`/`None` commands defer resolution to the handler. - if auth.is_required() - && let Err(err) = resolver.resolve().await - { - // Mirror the handler-path auth-error treatment: classify as - // `auth-error` and attribute the activity backend to the auth provider - // so telemetry can distinguish auth-provider failures from command - // backends. Resolution failed, so there is no identity to record. - self.write_audit(command_path, &args, "", "auth-error") - .await; - self.emit_activity( - command_path, - &args, - resolver.peek(), - "auth-error", - provider_name.as_str(), - &err.to_string(), - start, - ) - .await; - return self.render_error(&err, command_path, start, &user_args, &args, ""); - } - - let result = match command(resolver.clone()).await { - Ok(result) => result.into(), - Err(err) => { - // A deferred `resolve()` failure surfaces as a handler error; - // classify it as `auth-error` when the error the handler returned - // is itself auth-typed. A handler that swallows a resolution - // failure and then fails for another reason returns a non-auth - // error here, so it is not misclassified. - let identity = resolver.peek().map_or("", |cred| cred.identity.as_str()); - let (result_tag, error_system, activity_backend) = if err.is_auth() { - // Render against the command path, but attribute the activity - // backend to the auth provider so telemetry can distinguish - // auth-provider failures from command backends. - ("auth-error", command_path, provider_name.as_str()) - } else { - let system = err.system().unwrap_or(&command_system); - ("error", system, system) - }; - self.write_audit(command_path, &args, identity, result_tag) - .await; - self.emit_activity( - command_path, - &args, - resolver.peek(), - result_tag, - activity_backend, - &err.to_string(), - start, - ) - .await; - return self.render_error(&err, error_system, start, &user_args, &args, identity); - } - }; - // The handler may have resolved the credential; surface its identity. - let identity = resolver.peek().map_or("", |cred| cred.identity.as_str()); - let CommandResult { data, metadata } = result; - // A `handles_dry_run` handler that tagged its result via - // `CommandResult::with_dry_run` reports a `dry-run` outcome instead of - // `ok`, matching the generic short-circuit's audit/activity tagging. - // Gated on `self.dry_run` and `meta.handles_dry_run` too: the tag is - // handler-supplied, untrusted input, so a handler bug that sets it on - // a real (non-dry-run) run — or on a command that never opted into - // handler-driven dry-run at all (e.g. a `Tier::Read` handler that - // always runs, dry-run or not) — must not mis-tag that execution as a - // dry-run in the audit trail. - let is_dry_run = self.dry_run && meta.handles_dry_run && metadata.dry_run; - let outcome = if is_dry_run { "dry-run" } else { "ok" }; - self.write_audit(command_path, &args, identity, outcome) - .await; - self.emit_activity( - command_path, - &args, - resolver.peek(), - outcome, - &command_system, - "", - start, - ) - .await; - - let mut envelope = - Envelope::success(data, command_system).with_next_actions(metadata.next_actions); - if is_dry_run { - envelope = envelope.with_dry_run(); - } - self.render_envelope( - envelope, - default_fields, - view_id.unwrap_or_default(), - command_path, - start, - &user_args, - &args, - identity, - pagination_command.as_deref(), - raw_output && !is_dry_run, - ) - } - - #[doc(hidden)] - pub async fn run_no_auth( - &self, - meta: CommandMeta, - command_path: &str, - user_args: ValueMap, - args: ValueMap, - default_fields: &str, - command: F, - ) -> Result - where - F: FnOnce() -> Fut + Send, - Fut: Future> + Send, - { - self.run( - MiddlewareRequest { - meta, - command_path, - system: fallback_system(command_path), - user_args, - args, - default_fields, - view_id: None, - auth: AuthRequirement::None, - raw_output: false, - pagination_command: None, - }, - async move |_resolver| command().await, - ) - .await - } - - async fn write_audit(&self, command_path: &str, args: &ValueMap, identity: &str, result: &str) { - if let Some(auditor) = &self.auditor - && let Err(err) = auditor - .append(command_path, args, identity, result, &self.reason) - .await - { - tracing::warn!(command = command_path, error = %err, "audit log write failed"); - } - } - - #[allow(clippy::too_many_arguments)] - async fn emit_activity( - &self, - command_path: &str, - args: &ValueMap, - credential: Option<&Credential>, - result: &str, - backend: &str, - error: &str, - start: Instant, - ) { - let Some(activity) = &self.activity else { - return; - }; - let (identity, sub, account_type) = credential.map_or_else( - || (String::new(), String::new(), String::new()), - |credential| { - ( - credential.identity.clone(), - credential.sub.clone(), - credential.account_type.clone(), - ) - }, - ); - let duration_ms = i64::try_from(start.elapsed().as_millis()).unwrap_or(i64::MAX); - let event = ActivityEvent { - timestamp: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true), - app: self.app_id.clone(), - command: command_path.to_owned(), - env: self.env.clone(), - backend: backend.to_owned(), - identity, - sub, - account_type, - status: result.to_owned(), - error: error.to_owned(), - reason: self.reason.clone(), - args: args.clone(), - duration_ms, - meta: ValueMap::new(), - }; - if let Err(err) = activity.emit(event).await { - tracing::warn!(command = command_path, error = %err, "activity emit failed"); - } - } - - fn render_schema_if_requested( - &self, - command_path: &str, - start: Instant, - user_args: &ValueMap, - effective_args: &ValueMap, - identity: &str, - ) -> Result> { - if self.schema { - // Registered schema: dump it. Otherwise don't silently run the - // command — report that no schema exists. (We deliberately don't - // suggest "run it with --fields all" here: that would execute the - // command, which is exactly wrong for a mutation.) - let envelope = match self.schema_registry.get_by_path(command_path) { - Some(schema) => Envelope::success(schema, self.app_id.clone()), - // Shared with the `Cli::run` `--schema` bypass so both paths emit - // an identical no-schema body: the same `{command, fields}` shape - // as a real SchemaInfo response (empty `fields`) plus an additive - // `message`. - None => Envelope::success( - crate::output::no_schema_response(command_path), - self.app_id.clone(), - ), - }; - return self - .render_envelope( - envelope, - "", - "", - command_path, - start, - user_args, - effective_args, - identity, - None, - false, - ) - .map(Some); - } - Ok(None) - } - - #[allow(clippy::too_many_arguments)] - fn render_envelope( - &self, - mut envelope: Envelope, - default_fields: &str, - view_id: &str, - command_path: &str, - start: Instant, - user_args: &ValueMap, - effective_args: &ValueMap, - identity: &str, - pagination_command: Option<&str>, - raw_output: bool, - ) -> Result { - if !is_valid_output_format(&self.output_format) { - let err = CliCoreError::InvalidOutputFormat(self.output_format.clone()); - return self.render_error( - &err, - &self.app_id, - start, - user_args, - effective_args, - identity, - ); - } - if raw_output { - match &envelope.data { - Some(Value::String(text)) => { - // Guarantee exactly one trailing newline without doubling - // one the handler already included (e.g. text read from - // a file that already ends in "\n"). - let body = text.strip_suffix('\n').unwrap_or(text); - let rendered = format!("{body}\n"); - envelope.with_context( - command_path, - &self.env, - identity, - start.elapsed(), - Some(Value::Object(user_args.clone())), - Some(Value::Object(effective_args.clone())), - ); - let prepared = envelope.prepare_for_render(&self.verbose); - return Ok(MiddlewareOutput { - envelope: prepared, - rendered, - exit_code: 0, - }); - } - other => { - debug_assert!( - false, - "command {command_path:?} set raw_output but its handler returned \ - non-string data ({other:?}); rendering normally instead" - ); - } - } - } - let output_format = self.output_format.parse::()?; - // The effective field selection: an explicit `--fields` wins — - // including an explicit empty string, which keeps everything, same - // as `all`/`*` — otherwise the command's `default_fields` is the - // default. Gated on `fields_explicit` rather than - // `self.fields.is_empty()`: once a command has `default_fields` set, - // clap fills `self.fields` with that same non-empty string whether - // or not the user typed `--fields`, so emptiness can't tell "user - // explicitly cleared it" apart from "user never touched it" — only - // `value_source` (what `fields_explicit` is built from) can. The - // same selection is applied two ways: with a registered human view, - // it narrows which of the view's columns show, so the view reads - // the full payload — the data is not projected, which would - // otherwise blank out the kept columns. Everywhere else (JSON/TOON, - // or generic human output) it projects the output data. - let effective_fields = if self.fields_explicit { - self.fields.as_str() - } else { - default_fields - }; - let human_view = output_format == OutputFormat::Human && self.human_views.has_view(view_id); - // `apply_pipeline` never sees `effective_fields` for a registered - // view (`projection_fields` below is forced to `""` so the view reads - // the full payload), and the view's own column narrowing - // (`select_columns` in `human.rs`) silently skips a name with no - // matching column — the same "typo produces an empty/partial table - // instead of an error" gap `apply_pipeline`'s field validation - // closes elsewhere. So an explicit `--fields` (never a - // `default_fields` fallback — same reasoning as - // `PipelineOpts::fields_are_default`) is checked against the view's - // column catalog here instead. - if human_view - && self.fields_explicit - && let Some(columns) = self.human_views.columns(view_id) - { - let fields = effective_fields.trim(); - if !fields.is_empty() && fields != "all" && fields != "*" { - let known: BTreeSet = - columns.iter().map(|column| column.field.clone()).collect(); - let unknown: BTreeSet<&str> = fields - .split(',') - .map(str::trim) - .filter(|part| !part.is_empty() && !known.contains(*part)) - .collect(); - if !unknown.is_empty() { - let unknown: Vec<&str> = unknown.into_iter().collect(); - let err = CliCoreError::message(unknown_fields_message(&unknown, &known)); - return self.render_error( - &err, - &self.app_id, - start, - user_args, - effective_args, - identity, - ); - } - } - } - let projection_fields = if human_view { "" } else { effective_fields }; - if let Some(data) = &mut envelope.data { - let pagination = apply_pipeline( - data, - &PipelineOpts { - filter: self.filter.clone(), - limit: self.limit, - offset: self.offset, - expr: self.expr.clone(), - fields: projection_fields.to_owned(), - fields_are_default: !self.fields_explicit, - }, - )?; - if let Some(pagination) = pagination { - if pagination.has_more - && let Some(base) = pagination_command - { - let next_offset = pagination.offset + pagination.count; - envelope.next_actions.push(NextAction::new( - format!("{base} --limit {} --offset {next_offset}", pagination.limit), - format!( - "View the next page (offset {next_offset} of {} total)", - pagination.total - ), - )); - } - envelope.pagination = Some(pagination); - } - } - envelope.with_context( - command_path, - &self.env, - identity, - start.elapsed(), - Some(Value::Object(user_args.clone())), - Some(Value::Object(effective_args.clone())), - ); - let prepared = envelope.prepare_for_render(&self.verbose); - let rendered = if output_format == OutputFormat::Human { - render_human_with_registry_selected( - &prepared, - &self.human_views, - view_id, - effective_fields, - ) - } else { - crate::output::render(output_format, &prepared)? - }; - Ok(MiddlewareOutput { - envelope: prepared, - rendered, - exit_code: 0, - }) - } - - fn render_error( - &self, - err: &(dyn std::error::Error + 'static), - system: &str, - start: Instant, - user_args: &ValueMap, - effective_args: &ValueMap, - identity: &str, - ) -> Result { - let mut envelope = build_error_envelope(err, system); - envelope.with_context( - "", - &self.env, - identity, - start.elapsed(), - Some(Value::Object(user_args.clone())), - Some(Value::Object(effective_args.clone())), - ); - let prepared = envelope.prepare_for_render(&self.verbose); - let rendered = crate::output::render_format(&self.output_format, &prepared)?; - Ok(MiddlewareOutput { - envelope: prepared, - rendered, - exit_code: exit_code_for_error(err), - }) - } -} - /// Convenience helper for building a JSON object map. #[must_use] pub fn value_map(entries: impl IntoIterator, Value)>) -> ValueMap { diff --git a/cli-engine/src/middleware/run.rs b/cli-engine/src/middleware/run.rs new file mode 100644 index 0000000..83d2521 --- /dev/null +++ b/cli-engine/src/middleware/run.rs @@ -0,0 +1,607 @@ +use std::{collections::BTreeSet, future::Future, time::Instant}; + +use serde_json::{Value, json}; + +use super::{ + AuthRequirement, CommandMeta, CredentialResolver, Middleware, MiddlewareOutput, + MiddlewareRequest, ValueMap, effective_request_system, fallback_system, +}; +use crate::{ + CommandResult, Credential, Result, + error::{CliCoreError, exit_code_for_error}, + output::{ + Envelope, NextAction, OutputFormat, PipelineOpts, apply_pipeline, build_error_envelope, + is_valid_output_format, render_human_with_registry_selected, unknown_fields_message, + }, +}; + +impl Middleware { + /// Creates middleware with empty registries and default dependencies. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Runs the middleware chain for a command. + pub async fn run( + &self, + request: MiddlewareRequest<'_>, + command: F, + ) -> Result + where + F: FnOnce(CredentialResolver) -> Fut + Send, + Fut: Future> + Send, + Output: Into, + { + let start = Instant::now(); + let MiddlewareRequest { + meta, + command_path, + system, + user_args, + mut args, + default_fields, + view_id, + auth, + raw_output, + pagination_command, + } = request; + let no_auth = auth.is_none(); + let command_system = effective_request_system(system, command_path); + if !no_auth && !self.env.is_empty() && !args.contains_key("env") { + args.insert("env".to_owned(), Value::String(self.env.clone())); + } + + // Build a lazy resolver instead of resolving eagerly. No auth flow runs + // until a handler or authorizer actually asks for the credential, so + // commands that never use it (and `--schema`/`--dry-run`) skip auth. + let provider_name = meta + .provider() + .filter(|provider| !provider.is_empty()) + .unwrap_or(&self.default_auth_provider) + .to_owned(); + let resolved_env = meta.fixed_env().unwrap_or(&self.env).to_owned(); + let tier_text = meta + .auth_metadata + .get("tier") + .map_or("", String::as_str) + .to_owned(); + let resolver = CredentialResolver::new( + self.auth.clone(), + provider_name.clone(), + resolved_env, + command_path.to_owned(), + tier_text, + no_auth, + meta.clone(), + ); + + if no_auth + && let Some(output) = + self.render_schema_if_requested(command_path, start, &user_args, &args, "")? + { + return Ok(output); + } + + if let Some(authz) = &self.authz + && let Err(err) = authz + .authorize(command_path, &args, &resolver, &self.reason, meta.tier()) + .await + { + // An authorizer may have resolved the credential to make its + // decision; reflect whatever it resolved in audit identity. + let identity = resolver.peek().map_or("", |cred| cred.identity.as_str()); + // Classify by the error the authorizer returned: a propagated + // resolution failure is auth-typed; a policy denial is not. + let had_auth_error = err.is_auth(); + let result_tag = if had_auth_error { + "auth-error" + } else { + "denied" + }; + // Attribute auth-provider failures to the provider so telemetry can + // distinguish them from command backends. + let backend = if had_auth_error { + provider_name.as_str() + } else { + command_path + }; + self.write_audit(command_path, &args, identity, result_tag) + .await; + self.emit_activity( + command_path, + &args, + resolver.peek(), + result_tag, + backend, + &err.to_string(), + start, + ) + .await; + return self.render_error(&err, command_path, start, &user_args, &args, identity); + } + + // If the authorizer resolved the credential, include its identity in the + // schema output metadata. `peek()` never triggers resolution, so schema + // still doesn't provoke auth on its own. + let schema_identity = resolver.peek().map_or("", |cred| cred.identity.as_str()); + if let Some(output) = self.render_schema_if_requested( + command_path, + start, + &user_args, + &args, + schema_identity, + )? { + return Ok(output); + } + + if self.dry_run && meta.dry_run_prompt && !meta.handles_dry_run { + let identity = resolver.peek().map_or("", |cred| cred.identity.as_str()); + self.write_audit(command_path, &args, identity, "dry-run") + .await; + self.emit_activity( + command_path, + &args, + resolver.peek(), + "dry-run", + command_path, + "", + start, + ) + .await; + let envelope = Envelope::success( + json!({ + "command": command_path, + "action": "dry-run: would execute", + }), + command_path, + ) + .with_dry_run(); + return self.render_envelope( + envelope, + "", + "", + command_path, + start, + &user_args, + &args, + identity, + None, + false, + ); + } + + // Fail closed by default: for `Required` commands the engine resolves the + // credential before the handler runs, so a command that must be + // authenticated cannot execute unauthenticated even if its handler never + // reads the credential, and its audit/activity identity is always + // populated. `--schema`/`--dry-run` return above, so they never reach this + // point; `Optional`/`None` commands defer resolution to the handler. + if auth.is_required() + && let Err(err) = resolver.resolve().await + { + // Mirror the handler-path auth-error treatment: classify as + // `auth-error` and attribute the activity backend to the auth provider + // so telemetry can distinguish auth-provider failures from command + // backends. Resolution failed, so there is no identity to record. + self.write_audit(command_path, &args, "", "auth-error") + .await; + self.emit_activity( + command_path, + &args, + resolver.peek(), + "auth-error", + provider_name.as_str(), + &err.to_string(), + start, + ) + .await; + return self.render_error(&err, command_path, start, &user_args, &args, ""); + } + + let result = match command(resolver.clone()).await { + Ok(result) => result.into(), + Err(err) => { + // A deferred `resolve()` failure surfaces as a handler error; + // classify it as `auth-error` when the error the handler returned + // is itself auth-typed. A handler that swallows a resolution + // failure and then fails for another reason returns a non-auth + // error here, so it is not misclassified. + let identity = resolver.peek().map_or("", |cred| cred.identity.as_str()); + let (result_tag, error_system, activity_backend) = if err.is_auth() { + // Render against the command path, but attribute the activity + // backend to the auth provider so telemetry can distinguish + // auth-provider failures from command backends. + ("auth-error", command_path, provider_name.as_str()) + } else { + let system = err.system().unwrap_or(&command_system); + ("error", system, system) + }; + self.write_audit(command_path, &args, identity, result_tag) + .await; + self.emit_activity( + command_path, + &args, + resolver.peek(), + result_tag, + activity_backend, + &err.to_string(), + start, + ) + .await; + return self.render_error(&err, error_system, start, &user_args, &args, identity); + } + }; + // The handler may have resolved the credential; surface its identity. + let identity = resolver.peek().map_or("", |cred| cred.identity.as_str()); + let CommandResult { data, metadata } = result; + // A `handles_dry_run` handler that tagged its result via + // `CommandResult::with_dry_run` reports a `dry-run` outcome instead of + // `ok`, matching the generic short-circuit's audit/activity tagging. + // Gated on `self.dry_run` and `meta.handles_dry_run` too: the tag is + // handler-supplied, untrusted input, so a handler bug that sets it on + // a real (non-dry-run) run — or on a command that never opted into + // handler-driven dry-run at all (e.g. a `Tier::Read` handler that + // always runs, dry-run or not) — must not mis-tag that execution as a + // dry-run in the audit trail. + let is_dry_run = self.dry_run && meta.handles_dry_run && metadata.dry_run; + let outcome = if is_dry_run { "dry-run" } else { "ok" }; + self.write_audit(command_path, &args, identity, outcome) + .await; + self.emit_activity( + command_path, + &args, + resolver.peek(), + outcome, + &command_system, + "", + start, + ) + .await; + + let mut envelope = + Envelope::success(data, command_system).with_next_actions(metadata.next_actions); + if is_dry_run { + envelope = envelope.with_dry_run(); + } + self.render_envelope( + envelope, + default_fields, + view_id.unwrap_or_default(), + command_path, + start, + &user_args, + &args, + identity, + pagination_command.as_deref(), + raw_output && !is_dry_run, + ) + } + + #[doc(hidden)] + pub async fn run_no_auth( + &self, + meta: CommandMeta, + command_path: &str, + user_args: ValueMap, + args: ValueMap, + default_fields: &str, + command: F, + ) -> Result + where + F: FnOnce() -> Fut + Send, + Fut: Future> + Send, + { + self.run( + MiddlewareRequest { + meta, + command_path, + system: fallback_system(command_path), + user_args, + args, + default_fields, + view_id: None, + auth: AuthRequirement::None, + raw_output: false, + pagination_command: None, + }, + async move |_resolver| command().await, + ) + .await + } + + async fn write_audit(&self, command_path: &str, args: &ValueMap, identity: &str, result: &str) { + if let Some(auditor) = &self.auditor + && let Err(err) = auditor + .append(command_path, args, identity, result, &self.reason) + .await + { + tracing::warn!(command = command_path, error = %err, "audit log write failed"); + } + } + + #[allow(clippy::too_many_arguments)] + async fn emit_activity( + &self, + command_path: &str, + args: &ValueMap, + credential: Option<&Credential>, + result: &str, + backend: &str, + error: &str, + start: Instant, + ) { + let Some(activity) = &self.activity else { + return; + }; + let (identity, sub, account_type) = credential.map_or_else( + || (String::new(), String::new(), String::new()), + |credential| { + ( + credential.identity.clone(), + credential.sub.clone(), + credential.account_type.clone(), + ) + }, + ); + let duration_ms = i64::try_from(start.elapsed().as_millis()).unwrap_or(i64::MAX); + let event = super::ActivityEvent { + timestamp: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true), + app: self.app_id.clone(), + command: command_path.to_owned(), + env: self.env.clone(), + backend: backend.to_owned(), + identity, + sub, + account_type, + status: result.to_owned(), + error: error.to_owned(), + reason: self.reason.clone(), + args: args.clone(), + duration_ms, + meta: ValueMap::new(), + }; + if let Err(err) = activity.emit(event).await { + tracing::warn!(command = command_path, error = %err, "activity emit failed"); + } + } + + fn render_schema_if_requested( + &self, + command_path: &str, + start: Instant, + user_args: &ValueMap, + effective_args: &ValueMap, + identity: &str, + ) -> Result> { + if self.schema { + // Registered schema: dump it. Otherwise don't silently run the + // command — report that no schema exists. (We deliberately don't + // suggest "run it with --fields all" here: that would execute the + // command, which is exactly wrong for a mutation.) + let envelope = match self.schema_registry.get_by_path(command_path) { + Some(schema) => Envelope::success(schema, self.app_id.clone()), + // Shared with the `Cli::run` `--schema` bypass so both paths emit + // an identical no-schema body: the same `{command, fields}` shape + // as a real SchemaInfo response (empty `fields`) plus an additive + // `message`. + None => Envelope::success( + crate::output::no_schema_response(command_path), + self.app_id.clone(), + ), + }; + return self + .render_envelope( + envelope, + "", + "", + command_path, + start, + user_args, + effective_args, + identity, + None, + false, + ) + .map(Some); + } + Ok(None) + } + + #[allow(clippy::too_many_arguments)] + fn render_envelope( + &self, + mut envelope: Envelope, + default_fields: &str, + view_id: &str, + command_path: &str, + start: Instant, + user_args: &ValueMap, + effective_args: &ValueMap, + identity: &str, + pagination_command: Option<&str>, + raw_output: bool, + ) -> Result { + if !is_valid_output_format(&self.output_format) { + let err = CliCoreError::InvalidOutputFormat(self.output_format.clone()); + return self.render_error( + &err, + &self.app_id, + start, + user_args, + effective_args, + identity, + ); + } + if raw_output { + match &envelope.data { + Some(Value::String(text)) => { + // Guarantee exactly one trailing newline without doubling + // one the handler already included (e.g. text read from + // a file that already ends in "\n"). + let body = text.strip_suffix('\n').unwrap_or(text); + let rendered = format!("{body}\n"); + envelope.with_context( + command_path, + &self.env, + identity, + start.elapsed(), + Some(Value::Object(user_args.clone())), + Some(Value::Object(effective_args.clone())), + ); + let prepared = envelope.prepare_for_render(&self.verbose); + return Ok(MiddlewareOutput { + envelope: prepared, + rendered, + exit_code: 0, + }); + } + other => { + debug_assert!( + false, + "command {command_path:?} set raw_output but its handler returned \ + non-string data ({other:?}); rendering normally instead" + ); + } + } + } + let output_format = self.output_format.parse::()?; + // The effective field selection: an explicit `--fields` wins — + // including an explicit empty string, which keeps everything, same + // as `all`/`*` — otherwise the command's `default_fields` is the + // default. Gated on `fields_explicit` rather than + // `self.fields.is_empty()`: once a command has `default_fields` set, + // clap fills `self.fields` with that same non-empty string whether + // or not the user typed `--fields`, so emptiness can't tell "user + // explicitly cleared it" apart from "user never touched it" — only + // `value_source` (what `fields_explicit` is built from) can. The + // same selection is applied two ways: with a registered human view, + // it narrows which of the view's columns show, so the view reads + // the full payload — the data is not projected, which would + // otherwise blank out the kept columns. Everywhere else (JSON/TOON, + // or generic human output) it projects the output data. + let effective_fields = if self.fields_explicit { + self.fields.as_str() + } else { + default_fields + }; + let human_view = output_format == OutputFormat::Human && self.human_views.has_view(view_id); + // `apply_pipeline` never sees `effective_fields` for a registered + // view (`projection_fields` below is forced to `""` so the view reads + // the full payload), and the view's own column narrowing + // (`select_columns` in `human.rs`) silently skips a name with no + // matching column — the same "typo produces an empty/partial table + // instead of an error" gap `apply_pipeline`'s field validation + // closes elsewhere. So an explicit `--fields` (never a + // `default_fields` fallback — same reasoning as + // `PipelineOpts::fields_are_default`) is checked against the view's + // column catalog here instead. + if human_view + && self.fields_explicit + && let Some(columns) = self.human_views.columns(view_id) + { + let fields = effective_fields.trim(); + if !fields.is_empty() && fields != "all" && fields != "*" { + let known: BTreeSet = + columns.iter().map(|column| column.field.clone()).collect(); + let unknown: BTreeSet<&str> = fields + .split(',') + .map(str::trim) + .filter(|part| !part.is_empty() && !known.contains(*part)) + .collect(); + if !unknown.is_empty() { + let unknown: Vec<&str> = unknown.into_iter().collect(); + let err = CliCoreError::message(unknown_fields_message(&unknown, &known)); + return self.render_error( + &err, + &self.app_id, + start, + user_args, + effective_args, + identity, + ); + } + } + } + let projection_fields = if human_view { "" } else { effective_fields }; + if let Some(data) = &mut envelope.data { + let pagination = apply_pipeline( + data, + &PipelineOpts { + filter: self.filter.clone(), + limit: self.limit, + offset: self.offset, + expr: self.expr.clone(), + fields: projection_fields.to_owned(), + fields_are_default: !self.fields_explicit, + }, + )?; + if let Some(pagination) = pagination { + if pagination.has_more + && let Some(base) = pagination_command + { + let next_offset = pagination.offset + pagination.count; + envelope.next_actions.push(NextAction::new( + format!("{base} --limit {} --offset {next_offset}", pagination.limit), + format!( + "View the next page (offset {next_offset} of {} total)", + pagination.total + ), + )); + } + envelope.pagination = Some(pagination); + } + } + envelope.with_context( + command_path, + &self.env, + identity, + start.elapsed(), + Some(Value::Object(user_args.clone())), + Some(Value::Object(effective_args.clone())), + ); + let prepared = envelope.prepare_for_render(&self.verbose); + let rendered = if output_format == OutputFormat::Human { + render_human_with_registry_selected( + &prepared, + &self.human_views, + view_id, + effective_fields, + ) + } else { + crate::output::render(output_format, &prepared)? + }; + Ok(MiddlewareOutput { + envelope: prepared, + rendered, + exit_code: 0, + }) + } + + fn render_error( + &self, + err: &(dyn std::error::Error + 'static), + system: &str, + start: Instant, + user_args: &ValueMap, + effective_args: &ValueMap, + identity: &str, + ) -> Result { + let mut envelope = build_error_envelope(err, system); + envelope.with_context( + "", + &self.env, + identity, + start.elapsed(), + Some(Value::Object(user_args.clone())), + Some(Value::Object(effective_args.clone())), + ); + let prepared = envelope.prepare_for_render(&self.verbose); + let rendered = crate::output::render_format(&self.output_format, &prepared)?; + Ok(MiddlewareOutput { + envelope: prepared, + rendered, + exit_code: exit_code_for_error(err), + }) + } +} diff --git a/cli-engine/src/output/human.rs b/cli-engine/src/output/human.rs deleted file mode 100644 index bfc27f0..0000000 --- a/cli-engine/src/output/human.rs +++ /dev/null @@ -1,2199 +0,0 @@ -use std::{ - borrow::Cow, - collections::{BTreeMap, BTreeSet, HashMap}, - fmt, - io::IsTerminal, - sync::{Arc, OnceLock, RwLock}, -}; - -use serde_json::Value; - -use super::{Envelope, NextAction, NextActionParam, PaginationMeta}; - -/// Column text alignment for the human table view. -/// -/// Only affects the array/table rendering path (`render_array_with_columns` -/// via `render_table`) — property-bag rendering (`render_object_with_columns`) -/// prints `header: value` with no column widths to align, so alignment is a -/// no-op there. -#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] -pub enum Alignment { - /// Left-aligned (the default) — appropriate for text-like columns. - #[default] - Left, - /// Right-aligned — use for numeric/price columns so values line up on - /// their least-significant digit. - Right, -} - -/// Column definition for registered human table views. -/// -/// Column order is a priority order, most important first: table rendering -/// keeps this order on screen, and when the terminal is too narrow to show -/// every column, the lowest-priority (trailing) columns are hidden first. Put -/// the column a reader most needs — usually an id or name — first. -/// -/// This declared order is only the *fallback* — whenever a `--fields`/ -/// `default_fields` selection is given, its order wins instead (see -/// [`crate::output::render_human_with_registry_selected`]), for both display -/// and hide-priority. Declared order only governs output when no selection is -/// given at all. -/// -/// Construct with [`TableColumn::new`], then chain builder methods like -/// [`no_truncate`](TableColumn::no_truncate)/[`nested`](TableColumn::nested) -/// — never as a struct literal. No known consumer constructs `TableColumn` -/// via struct literal, so marking it `#[non_exhaustive]` carries no real -/// breaking impact today; going forward it means the engine can add fields -/// (as it did for `nested`) without that becoming a breaking release either. -#[derive(Clone, Debug, Eq, PartialEq)] -#[non_exhaustive] -pub struct TableColumn { - /// JSON field path. Supports simple dotted paths to reach a value nested - /// under intermediate objects, so a column can point through a wrapper - /// shape (a pagination envelope, a `Summary`, etc.). A literal field - /// name containing a `.` is not supported — this mirrors the dotted-path - /// convention `crate::output::fields` already uses for `--fields` - /// projection. - pub field: String, - /// Display header. - pub header: String, - /// When true, this column's values are never shrunk to fit the terminal - /// (still capped at `NO_TRUNCATE_MAX_WIDTH` to bound pathologically long - /// values). Use this for values that are useless when cut short, such as - /// URLs. - pub no_truncate: bool, - /// When set, and the resolved value is list-of-objects or object shaped, - /// this column renders as an indented child table or child property bag - /// instead of a one-line dump — see [`TableColumn::nested`]. `None` (the - /// default from [`TableColumn::new`]) is a complete no-op: rendering is - /// identical to a column with no opinion about nesting. - pub nested: Option>, - /// Header and cell text alignment — see [`TableColumn::align`]. - pub align: Alignment, -} - -impl TableColumn { - /// Creates a table column from a JSON field path and display header. - #[must_use] - pub fn new(field: impl Into, header: impl Into) -> Self { - Self { - field: field.into(), - header: header.into(), - no_truncate: false, - nested: None, - align: Alignment::Left, - } - } - - /// Opts this column out of terminal-width-driven shrinking. Values are - /// still capped at `NO_TRUNCATE_MAX_WIDTH`. - #[must_use] - pub fn no_truncate(mut self, value: bool) -> Self { - self.no_truncate = value; - self - } - - /// Sets this column's header and cell alignment. Defaults to - /// `Alignment::Left`; use `Alignment::Right` for numeric or price - /// columns so decimal points and digits line up instead of looking - /// ragged on the left. - #[must_use] - pub fn align(mut self, alignment: Alignment) -> Self { - self.align = alignment; - self - } - - /// Opts this column into rendering a nested list/object value as an - /// indented child table or property bag, using `columns` as that child's - /// own column definitions (which may themselves set `.nested(...)`). - /// - /// Nesting is only consulted when this column is rendered inside an - /// object property bag (top-level, or itself a nested property bag) — a - /// row cell inside an array-of-objects table always renders as a single - /// flat value, ignoring `nested`, because a table row is one monospace - /// line and can't itself contain a rendered sub-block without breaking - /// column alignment. Recursion is otherwise unbounded through the object - /// chain: a nested column's own child columns may set `.nested(...)` - /// again for a grandchild table or property bag. - #[must_use] - pub fn nested(mut self, columns: impl Into>) -> Self { - self.nested = Some(columns.into()); - self - } -} - -/// Human view definition keyed by schema id. -/// -/// `columns` order is a priority order — see [`TableColumn`]. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct HumanViewDef { - /// Schema id, usually the command path. - pub schema_id: String, - /// Columns rendered for matching object or list data, most important - /// first. - pub columns: Vec, -} - -impl HumanViewDef { - /// Creates a column-based human view for a schema id or command path. - #[must_use] - pub fn new(schema_id: impl Into, columns: impl Into>) -> Self { - Self { - schema_id: schema_id.into(), - columns: columns.into(), - } - } -} - -/// Function used to render custom human output for a JSON value. -pub type HumanViewFn = Arc String + Send + Sync>; - -/// Custom human renderer wrapper. -#[derive(Clone)] -pub struct HumanViewRenderer { - render: HumanViewFn, -} - -impl HumanViewRenderer { - /// Creates a custom renderer. - #[must_use] - pub fn new(render: impl Fn(&Value) -> String + Send + Sync + 'static) -> Self { - Self { - render: Arc::new(render), - } - } - - /// Renders data with the custom renderer. - #[must_use] - pub fn render(&self, data: &Value) -> String { - (self.render)(data) - } -} - -impl fmt::Debug for HumanViewRenderer { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter - .debug_struct("HumanViewRenderer") - .finish_non_exhaustive() - } -} - -/// Registry of human column and custom-renderer views. -#[derive(Clone, Debug, Default)] -pub struct HumanViewRegistry { - by_schema_id: BTreeMap>, - custom_by_schema_id: BTreeMap, -} - -impl HumanViewRegistry { - /// Creates an empty registry. - #[must_use] - pub fn new() -> Self { - Self::default() - } - - /// Registers a column-based human view. - pub fn register(&mut self, view: HumanViewDef) { - self.by_schema_id.insert(view.schema_id, view.columns); - } - - /// Registers a custom renderer for a schema id. - pub fn register_func( - &mut self, - schema_id: impl Into, - render: impl Fn(&Value) -> String + Send + Sync + 'static, - ) { - self.custom_by_schema_id - .insert(schema_id.into(), HumanViewRenderer::new(render)); - } - - /// Merges another registry into this one. - pub fn merge(&mut self, other: &Self) { - self.by_schema_id.extend(other.by_schema_id.clone()); - self.custom_by_schema_id - .extend(other.custom_by_schema_id.clone()); - } - - /// Returns column definitions for a schema id. - #[must_use] - pub fn columns(&self, schema_id: &str) -> Option<&[TableColumn]> { - self.by_schema_id.get(schema_id).map(Vec::as_slice) - } - - /// Returns the custom renderer for a schema id. - #[must_use] - pub fn custom(&self, schema_id: &str) -> Option<&HumanViewRenderer> { - self.custom_by_schema_id.get(schema_id) - } - - /// Whether any human view (column-based or custom) is registered for a - /// schema id. Such a view selects its own columns from the full payload, so - /// callers must not pre-project the data before handing it to the renderer. - #[must_use] - pub fn has_view(&self, schema_id: &str) -> bool { - self.by_schema_id.contains_key(schema_id) - || self.custom_by_schema_id.contains_key(schema_id) - } -} - -static GLOBAL_HUMAN_VIEW_REGISTRY: OnceLock> = OnceLock::new(); - -fn global_human_view_registry() -> &'static RwLock { - GLOBAL_HUMAN_VIEW_REGISTRY.get_or_init(|| RwLock::new(HumanViewRegistry::new())) -} - -/// Registers a process-global column view. -pub fn register_global_human_view(view: HumanViewDef) { - let mut registry = global_human_view_registry() - .write() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - registry.register(view); -} - -/// Registers a process-global custom human renderer. -pub fn register_global_human_view_func( - schema_id: impl Into, - render: impl Fn(&Value) -> String + Send + Sync + 'static, -) { - let mut registry = global_human_view_registry() - .write() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - registry.register_func(schema_id, render); -} - -/// Looks up global columns for a schema id. -#[must_use] -pub fn lookup_global_human_view_columns(schema_id: &str) -> Option> { - global_human_view_registry() - .read() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .columns(schema_id) - .map(<[TableColumn]>::to_vec) -} - -/// Looks up a global custom renderer for a schema id. -#[must_use] -pub fn lookup_global_human_view_func(schema_id: &str) -> Option { - global_human_view_registry() - .read() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .custom(schema_id) - .cloned() -} - -/// Returns a snapshot of the process-global human view registry. -#[must_use] -pub fn global_human_view_registry_snapshot() -> HumanViewRegistry { - global_human_view_registry() - .read() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .clone() -} - -/// Renders an envelope using generic human output. -/// -/// There's no field-selection concept at this entry point, so a no-view -/// array/object falls back to alphabetical key order — use -/// [`render_human_with_registry_selected`] when a `--fields`/`default_fields` -/// value is available, so its order can drive column order too. -#[must_use] -pub fn render_human(envelope: &Envelope) -> String { - render_human_with_view(envelope, None, "") -} - -/// Renders an envelope using a human view registry. -#[must_use] -pub fn render_human_with_registry(envelope: &Envelope, registry: &HumanViewRegistry) -> String { - let system = envelope - .metadata - .as_ref() - .map(|metadata| metadata.system.as_str()) - .unwrap_or_default(); - render_human_with_registry_for_schema(envelope, registry, system) -} - -/// Renders an envelope using registry entries for a specific schema id. -/// -/// Shows every column of the registered view. Use -/// [`render_human_with_registry_selected`] to narrow the columns to a field -/// selection. -#[must_use] -pub fn render_human_with_registry_for_schema( - envelope: &Envelope, - registry: &HumanViewRegistry, - schema_id: &str, -) -> String { - render_human_with_registry_selected(envelope, registry, schema_id, "") -} - -/// Renders an envelope using a registered view, narrowed to `fields`. -/// -/// `fields` uses the same comma-separated syntax as `--fields`: an empty -/// string, `all`, or `*` keeps every column; otherwise only the view columns -/// whose `field` is listed are shown. A custom view renderer receives the full -/// data and ignores `fields`. -#[must_use] -pub fn render_human_with_registry_selected( - envelope: &Envelope, - registry: &HumanViewRegistry, - schema_id: &str, - fields: &str, -) -> String { - if let Some(error) = &envelope.error { - return format!("Error: {}\n", error.message); - } - if let Some(data) = &envelope.data - && let Some(custom) = registry.custom(schema_id) - { - return custom.render(data); - } - match registry.columns(schema_id) { - Some(columns) => { - let selected = select_columns(columns, fields); - render_human_with_view(envelope, Some(&selected), fields) - } - None => render_human_with_view(envelope, None, fields), - } -} - -/// Narrows and reorders view columns to a `--fields`-style selection. An -/// empty string, `all`, or `*` keeps every column in its declared order; -/// otherwise columns are chosen and ordered by the comma-separated list -/// (deduplicated, first occurrence wins) — a name with no matching column is -/// silently skipped, so a view still only ever shows its own declared -/// fields. -fn select_columns(columns: &[TableColumn], fields: &str) -> Vec { - let fields = fields.trim(); - if fields.is_empty() || fields == "all" || fields == "*" { - return columns.to_vec(); - } - let mut seen = BTreeSet::new(); - fields - .split(',') - .map(str::trim) - .filter(|part| !part.is_empty() && seen.insert(*part)) - .filter_map(|name| columns.iter().find(|column| column.field == name).cloned()) - .collect() -} - -/// Renders an envelope using explicit table columns. -/// -/// `columns`, when `Some`, is expected to already be `--fields`-selected and -/// ordered (applied by callers such as -/// [`render_human_with_registry_selected`] before this function runs) — this -/// function does not re-apply `fields` to it. `fields` is only read here when -/// `columns` is `None`, to give the dynamically-derived, no-view column -/// catalog the same field selection and order a view would have gotten. Pass -/// `""` when no field-selection value is available. -#[must_use] -pub fn render_human_with_view( - envelope: &Envelope, - columns: Option<&[TableColumn]>, - fields: &str, -) -> String { - // Errors render on their own; success output gets the data body plus, when - // present, a "Next steps:" footer built from the envelope's next_actions - // (these otherwise appear only in JSON/TOON). - if let Some(error) = &envelope.error { - let mut out = format!("Error: {}\n", error.message); - if let Some(fix) = &envelope.fix { - out.push_str("Fix: "); - out.push_str(fix); - out.push('\n'); - } - return out; - } - let available_width = terminal_width(); - let (mut body, notes) = match &envelope.data { - None => ("(no data)\n".to_owned(), RenderNotes::default()), - Some(data) => render_data_body( - data, - columns, - fields, - available_width, - envelope.pagination.as_ref(), - ), - }; - // Footers are appended in place: the common no-footer path leaves `body` - // untouched (no realloc/copy), and non-empty content is written directly - // into it (no per-footer temporaries). - append_render_notes(&mut body, ¬es); - if !notes.pagination_shown { - // `envelope.data` already reflects the fully piped result (filter -> - // paginate -> expr -> fields), so its length is what this non-table - // path actually rendered — unlike `pagination.count`, which is only - // the pre-`--expr` slice size and can go stale once `--expr` reshapes - // the array (mirrors the same fix in `render_table`). - let shown = envelope - .data - .as_ref() - .and_then(Value::as_array) - .and_then(|items| i64::try_from(items.len()).ok()); - append_pagination_summary(&mut body, envelope.pagination.as_ref(), shown); - } - append_next_actions(&mut body, &envelope.next_actions); - body -} - -/// Render just the data portion of a success envelope (no next-steps footer). -fn render_data_body( - data: &Value, - columns: Option<&[TableColumn]>, - fields: &str, - available_width: usize, - pagination: Option<&PaginationMeta>, -) -> (String, RenderNotes) { - if let Some(columns) = columns { - return match data { - Value::Array(items) => { - render_array_with_columns(items, columns, available_width, pagination) - } - Value::Object(map) => render_object_with_columns(map, columns, available_width), - Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => { - (format!("{}\n", format_value(data)), RenderNotes::default()) - } - }; - } - match data { - Value::Array(items) => render_array(items, fields, available_width, pagination), - Value::Object(map) => { - let columns = dynamic_columns(fields, || map.keys().cloned().collect()); - render_object_with_columns(map, &columns, available_width) - } - other => ( - format!("{}\n", format_plain_value(other)), - RenderNotes::default(), - ), - } -} - -/// Builds the column catalog for data with no registered view: when `fields` -/// names specific fields (not empty/`all`/`*`), columns are derived from that -/// list, in the order given (deduplicated) — the same order source a -/// registered view's `--fields` selection uses (see [`select_columns`]). -/// Otherwise falls back to `natural_keys()` sorted alphabetically, since a -/// bare JSON object has no other order signal to offer. -fn dynamic_columns(fields: &str, natural_keys: impl FnOnce() -> Vec) -> Vec { - let fields = fields.trim(); - if fields.is_empty() || fields == "all" || fields == "*" { - let mut keys = natural_keys(); - keys.sort(); - return keys - .into_iter() - .map(|key| TableColumn::new(key.clone(), key)) - .collect(); - } - let mut seen = BTreeSet::new(); - fields - .split(',') - .map(str::trim) - .filter(|part| !part.is_empty() && seen.insert(*part)) - .map(|field| TableColumn::new(field, field)) - .collect() -} - -/// True when at least one item has a JSON number at `field`, and no item -/// with a present, non-null value at `field` holds anything else. -fn column_is_all_numeric(items: &[Value], field: &str) -> bool { - let mut saw_number = false; - for item in items { - match item - .as_object() - .and_then(|map| resolve_field_path(map, field)) - { - Some(Value::Number(_)) => saw_number = true, - Some(Value::Null) | None => {} - Some(_) => return false, - } - } - saw_number -} - -/// Appends footer hints for truncated cells and/or hidden columns to `out` -/// (a no-op when neither happened). Mirrors `append_next_actions`: writes -/// directly into `out` rather than building a separate string. -fn append_render_notes(out: &mut String, notes: &RenderNotes) { - // `--fields` only ever selects among top-level declared columns: it can - // drop a `TableColumn::nested` column entirely, but can't narrow what - // shows *inside* one. Suggesting it as a fix once any of the reported - // narrowing happened inside a nested block would be wrong — there's no - // flag that reaches that fine-grained, so `--json` is the only real - // remedy in that case. - let fields_helps = !notes.nested_narrowing; - if notes.truncated { - if fields_helps { - out.push_str( - "\nOutput truncated to fit the display width — use --fields to show fewer columns, or --json for full values.\n", - ); - } else { - out.push_str( - "\nOutput truncated to fit the display width — use --json for full values.\n", - ); - } - } - if !notes.hidden_columns.is_empty() { - let suggestion = if fields_helps { - "use --fields to choose columns, or --json for full output" - } else { - "use --json for full output" - }; - out.push_str(&format!( - "\n{} column{} hidden to fit the display width ({}) — {suggestion}.\n", - notes.hidden_columns.len(), - if notes.hidden_columns.len() == 1 { - "" - } else { - "s" - }, - notes.hidden_columns.join(", "), - )); - } -} - -/// Appends a one-line pagination summary to `out` (a no-op when the response -/// wasn't paginated). Unlike `next_actions`, this always shows the underlying -/// facts even on the last page, where there's no follow-up command to -/// suggest. -/// -/// Only a fallback: when the data rendered as a table, `render_table` already -/// merged these same facts into its `(N of M rows, ...)` footer -/// (`RenderNotes::pagination_shown` signals that to -/// [`render_human_with_view`]), so this only actually prints anything for a -/// paginated response that *didn't* render as a table (e.g. a bare array of -/// scalars) — otherwise the two would repeat the same count/offset/limit on -/// consecutive lines. -/// -/// `shown` is the caller's actual rendered item count (from `envelope.data`, -/// post-pipeline), used in place of `pagination.count` — which is only the -/// pre-`--expr` slice size and can go stale once `--expr` reshapes the array -/// after pagination ran (mirrors the same fix in `render_table`). `None` -/// means `--expr` reshaped the data into something that's no longer even an -/// array (e.g. `length(@)` turning it into a number) — pagination still ran, -/// but there's no rendered row count left to describe, so this falls back to -/// a more neutral line instead of a "Showing N of M" claim that would no -/// longer match what's actually displayed above it. -fn append_pagination_summary( - out: &mut String, - pagination: Option<&PaginationMeta>, - shown: Option, -) { - let Some(pagination) = pagination else { - return; - }; - match shown { - Some(count) => out.push_str(&format!( - "\nShowing {count} of {} (offset {}, limit {})\n", - pagination.total, pagination.offset, pagination.limit - )), - None => out.push_str(&format!( - "\n(pagination: {} total, offset {}, limit {})\n", - pagination.total, pagination.offset, pagination.limit - )), - } -} - -/// Append a "Next steps:" footer listing suggested follow-up commands to `out` -/// (a no-op when there are none). Each action shows its command template with -/// any known param values substituted into their `` (params -/// without a known value, e.g. required-only hints, are shown as-is), followed -/// by the description beneath it. Writes directly into `out` to avoid -/// per-action temporaries. -fn append_next_actions(out: &mut String, actions: &[NextAction]) { - if actions.is_empty() { - return; - } - out.push_str("\nNext steps:\n"); - for action in actions { - out.push_str(" "); - out.push_str(&substitute_known_params(&action.command, &action.params)); - out.push_str("\n "); - out.push_str(&action.description); - out.push('\n'); - } -} - -/// Fills a `NextAction` command template with any params that carry a known -/// concrete `value` — e.g. `"domain quote "` with -/// `params["domain"].value == Some("example.com")` becomes -/// `"domain quote example.com"`. A param's placeholder is its key wrapped in -/// angle brackets (``); params without a known value (required-only -/// hints) are left as literal placeholder text for the user to fill in. -/// Borrows `command` as-is (no allocation) when nothing has a known value. -fn substitute_known_params<'cmd>( - command: &'cmd str, - params: &HashMap, -) -> Cow<'cmd, str> { - let mut command = Cow::Borrowed(command); - for (key, param) in params { - if let Some(value) = ¶m.value { - let placeholder = format!("<{key}>"); - if command.contains(&placeholder) { - command = Cow::Owned(command.replace(&placeholder, value)); - } - } - } - command -} - -/// Upper bound on a `no_truncate` column's width, even though it otherwise -/// skips the normal 40-char cap. Prevents a pathologically long field value -/// (not expected in practice, but not guaranteed by any schema) from padding -/// every row and the separator line out to an unusable or memory-heavy width. -/// -/// This bounds runtime *values*, not the column *header*: width is always -/// widened back up to `column.header.len()` after the cap is applied, so a -/// header can never be truncated or misaligned even in the (unrealistic) -/// case where it exceeds `NO_TRUNCATE_MAX_WIDTH` itself. Headers are static, -/// developer-authored labels, not the pathological runtime data this cap -/// guards against. -const NO_TRUNCATE_MAX_WIDTH: usize = 4096; - -/// Space between adjacent rendered columns. Must match the gutter -/// `render_table` actually writes, since width-fitting math (how much room -/// is left for column content) has to agree with what gets printed. -const COLUMN_GUTTER: usize = 2; - -/// Indent applied to a nested table/property-bag block under a parent -/// object's field. Matches the two-space depth-step the TOON encoder already -/// uses (`crate::output::toon`'s `push_line`), for a consistent look across -/// human and TOON nested rendering. -const NESTED_INDENT: &str = " "; - -/// Detects how wide to render human-output tables and guides. -/// -/// An interactive terminal gets its live width (via `termimad`); anything -/// else (pipes, files, CI) gets a fixed `80` so non-interactive `--human` -/// output stays deterministic. Floored at `20` in case a terminal reports an -/// unusably small or zero width. -#[must_use] -pub(crate) fn terminal_width() -> usize { - if std::io::stdout().is_terminal() { - usize::from(termimad::terminal_size().0).max(20) - } else { - 80 - } -} - -/// Signals produced while rendering a table body, used to build human-output -/// footer hints. `Default` means nothing was hidden or shortened. -#[derive(Default)] -struct RenderNotes { - /// Whether any cell was shortened to fit the terminal. - truncated: bool, - /// Headers of columns dropped entirely because there wasn't room for - /// them, in their original declared/requested order (the order they - /// would have appeared in the table, had they fit) — not reverse - /// priority order. - hidden_columns: Vec, - /// Whether any of the truncation/hiding captured above happened inside a - /// nested child block (a `TableColumn::nested` column's own table or - /// property bag) rather than at this level's own top-level columns. - /// `--fields` only ever selects among top-level declared columns — it - /// can drop a nested column entirely, but can't narrow what's shown - /// *inside* one — so [`append_render_notes`] must not suggest `--fields` - /// as a fix when this is set, even though `hidden_columns`/`truncated` - /// are otherwise reported identically either way. - nested_narrowing: bool, - /// Whether the table footer already merged in the pagination summary - /// (`render_table`'s `(N of M rows, offset O, limit L)` line) — so - /// [`render_human_with_view`] doesn't also append the standalone - /// `append_pagination_summary` line and duplicate the same facts. - pagination_shown: bool, -} - -/// Chooses how many leading columns (priority order, most important first), -/// each contributing at least `min_widths[i]`, fit in `available_width` — so -/// lower-priority trailing columns can be dropped when the terminal is too -/// narrow for all of them. `min_widths[i]` should be the column's header -/// length for a column that can still shrink, or its full natural width for -/// one that can't (e.g. `no_truncate`) — using a shrinkable column's header -/// length here lets it still be counted as fitting even though its eventual -/// rendered width may be larger. Always keeps at least one column, even if -/// it alone exceeds `available_width`. -fn columns_fitting_width(min_widths: &[usize], available_width: usize) -> usize { - let mut used = 0_usize; - let mut kept = 0_usize; - for (index, &min_width) in min_widths.iter().enumerate() { - let gutter = if index == 0 { 0 } else { COLUMN_GUTTER }; - let next_used = used + gutter + min_width; - if next_used > available_width && kept > 0 { - break; - } - used = next_used; - kept += 1; - } - kept -} - -/// Fits `natural` (fully-untruncated) column widths into `available_width`. -/// -/// `no_truncate` columns are never shrunk (they keep their natural width -/// unconditionally — that's the whole point of the flag) and their width is -/// reserved out of the budget up front. The remaining columns are never -/// shrunk below their header length, and share whatever budget is left -/// beyond that, smallest-need-first, so a column that wants only a little -/// gets exactly that instead of an equal-but-wasteful split. -/// -/// Returns the fitted widths and whether any truncatable column ended up -/// narrower than its natural width (i.e. some cell will actually be cut). -fn fit_column_widths( - headers: &[usize], - natural: &[usize], - no_truncate: &[bool], - available_width: usize, -) -> (Vec, bool) { - let mut widths = natural.to_vec(); - let truncatable: Vec = (0..no_truncate.len()) - .filter(|&index| !no_truncate[index]) - .collect(); - if truncatable.is_empty() { - return (widths, false); - } - let gutters = COLUMN_GUTTER * headers.len().saturating_sub(1); - let reserved: usize = (0..no_truncate.len()) - .filter(|&index| no_truncate[index]) - .map(|index| natural[index]) - .sum(); - let budget = available_width - .saturating_sub(gutters) - .saturating_sub(reserved); - let header_floor: usize = truncatable.iter().map(|&index| headers[index]).sum(); - for &index in &truncatable { - widths[index] = headers[index]; - } - let mut leftover = budget.saturating_sub(header_floor); - let mut needy: Vec = truncatable - .iter() - .copied() - .filter(|&index| natural[index] > headers[index]) - .collect(); - needy.sort_by_key(|&index| natural[index] - headers[index]); - // Smallest-need-first, take exactly what's wanted or whatever's left, - // whichever is less. Deliberately not an even split of `leftover` across - // the remaining columns: dividing first and taking `min(wants, share)` - // can floor a small want to zero when `leftover < remaining columns`, - // denying it entirely while a later, greedier column absorbs the - // remainder — worse than just letting small wants claim what they need - // outright before anyone larger gets a turn. - for &index in &needy { - let wants = natural[index] - headers[index]; - let take = wants.min(leftover); - widths[index] += take; - leftover -= take; - } - let truncated = truncatable - .iter() - .any(|&index| widths[index] < natural[index]); - (widths, truncated) -} - -fn render_array_with_columns( - items: &[Value], - columns: &[TableColumn], - available_width: usize, - pagination: Option<&PaginationMeta>, -) -> (String, RenderNotes) { - if items.is_empty() || columns.is_empty() { - // Empty columns happens when every item is `{}` (the no-view - // dynamic catalog has no keys to show) or a view's `--fields` - // filtered out every declared column — either way there's nothing - // to build a table from, so fall back to the same message used for - // no items at all rather than rendering a blank header/rows table. - return ("(no results)\n".to_owned(), RenderNotes::default()); - } - if !items.iter().all(Value::is_object) { - return (render_array_lines(items), RenderNotes::default()); - } - // Natural widths (and rows) are computed for every original column - // before deciding what to hide: a `no_truncate` column never shrinks - // below its natural width, so the hiding decision has to know that real - // requirement — using just its header length here could keep a - // low-priority trailing column that would never have fit anyway, - // producing an overflow that hiding it would have avoided. - let header_lens: Vec = columns.iter().map(|column| column.header.len()).collect(); - let no_truncate_all: Vec = columns.iter().map(|column| column.no_truncate).collect(); - let mut natural = header_lens.clone(); - let rows: Vec> = items - .iter() - .map(|item| { - columns - .iter() - .enumerate() - .map(|(index, column)| { - let value = item - .as_object() - .and_then(|map| resolve_field_path(map, &column.field)) - .map_or_else(String::new, format_value); - let cap = if column.no_truncate { - NO_TRUNCATE_MAX_WIDTH - } else { - usize::MAX - }; - natural[index] = natural[index].max(value.len().min(cap)); - value - }) - .collect::>() - }) - .collect(); - - let min_widths: Vec = (0..columns.len()) - .map(|index| { - if no_truncate_all[index] { - natural[index] - } else { - header_lens[index] - } - }) - .collect(); - let mut kept = columns_fitting_width(&min_widths, available_width); - - // Hiding a column is preferred over truncating a cell: if the survivors - // still don't fit their natural width, keep dropping the lowest-priority - // one and re-fitting, until either everyone remaining fits in full or - // only one column is left (which always stays, however it fits). - let (fitted, truncated) = loop { - let (fitted, truncated) = fit_column_widths( - &header_lens[..kept], - &natural[..kept], - &no_truncate_all[..kept], - available_width, - ); - if !truncated || kept <= 1 { - break (fitted, truncated); - } - kept -= 1; - }; - - let hidden_columns = columns[kept..] - .iter() - .map(|column| column.header.clone()) - .collect::>(); - let columns = &columns[..kept]; - let rows: Vec> = rows - .into_iter() - .map(|row| row.into_iter().take(kept).collect()) - .collect(); - - let table = render_table( - &columns - .iter() - .map(|column| column.header.clone()) - .collect::>(), - &fitted, - &columns - .iter() - .map(|column| column.align) - .collect::>(), - &rows, - pagination, - ); - ( - table, - RenderNotes { - truncated, - hidden_columns, - nested_narrowing: false, - pagination_shown: pagination.is_some(), - }, - ) -} - -fn render_object_with_columns( - map: &serde_json::Map, - columns: &[TableColumn], - available_width: usize, -) -> (String, RenderNotes) { - if map.is_empty() || columns.is_empty() { - // Empty columns happens the same way it does in - // `render_array_with_columns`: a view's `--fields` filtered out - // every declared column. Nothing to render either way, so this - // reports the same "(no data)" a genuinely empty object gets, - // rather than an unlabeled blank line. - return ("(no data)\n".to_owned(), RenderNotes::default()); - } - let mut out = String::new(); - let mut notes = RenderNotes::default(); - for column in columns { - let value = resolve_field_path(map, &column.field); - match (&column.nested, value) { - (Some(nested_columns), Some(value)) if is_nestable(value) => { - out.push_str(&format!("{}:\n", column.header)); - let child_width = available_width.saturating_sub(NESTED_INDENT.len()); - let nested_pagination = match value { - Value::Array(_) => { - resolve_field_parent(map, &column.field).and_then(resolve_nested_pagination) - } - _ => None, - }; - let (block, child_notes) = render_nested_value( - value, - nested_columns, - child_width, - nested_pagination.as_ref(), - ); - out.push_str(&indent_block(&block, NESTED_INDENT)); - if child_notes.truncated - || !child_notes.hidden_columns.is_empty() - || child_notes.nested_narrowing - { - notes.nested_narrowing = true; - } - notes.truncated |= child_notes.truncated; - notes.hidden_columns.extend( - child_notes - .hidden_columns - .into_iter() - .map(|hidden| format!("{} > {hidden}", column.header)), - ); - } - (_, value) => { - let value_str = value.map_or_else(String::new, format_value); - out.push_str(&format!("{}: {value_str}\n", column.header)); - } - } - } - (out, notes) -} - -fn render_array( - items: &[Value], - fields: &str, - available_width: usize, - pagination: Option<&PaginationMeta>, -) -> (String, RenderNotes) { - if items.is_empty() { - return ("(no results)\n".to_owned(), RenderNotes::default()); - } - let Some(Value::Object(first_map)) = items.first() else { - return (render_array_lines(items), RenderNotes::default()); - }; - if !items.iter().all(Value::is_object) { - return (render_array_lines(items), RenderNotes::default()); - } - let columns: Vec = dynamic_columns(fields, || first_map.keys().cloned().collect()) - .into_iter() - .map(|column| { - if column_is_all_numeric(items, &column.field) { - column.align(Alignment::Right) - } else { - column - } - }) - .collect(); - render_array_with_columns(items, &columns, available_width, pagination) -} - -fn render_array_lines(items: &[Value]) -> String { - let mut out = String::new(); - for item in items { - out.push_str(&format!("{}\n", format_plain_value(item))); - } - out -} - -/// Pads `text` to `width`, on the left for `Alignment::Right` and on the -/// right otherwise — matching how the header row is padded so a column's -/// header and cells share the same alignment. -fn pad_column(text: &str, width: usize, alignment: Alignment) -> String { - match alignment { - Alignment::Left => format!("{text: format!("{text:>width$}"), - } -} - -fn render_table( - headers: &[String], - widths: &[usize], - alignments: &[Alignment], - rows: &[Vec], - pagination: Option<&PaginationMeta>, -) -> String { - let mut out = String::new(); - for (index, header) in headers.iter().enumerate() { - if index > 0 { - out.push_str(" "); - } - out.push_str(&pad_column( - &header.to_uppercase(), - widths[index], - alignments[index], - )); - } - out.push('\n'); - for (index, width) in widths.iter().enumerate() { - if index > 0 { - out.push_str(" "); - } - out.push_str(&"-".repeat(*width)); - } - out.push('\n'); - for row in rows { - for (index, value) in row.iter().enumerate() { - if index > 0 { - out.push_str(" "); - } - out.push_str(&pad_column( - &truncate(value, widths[index]), - widths[index], - alignments[index], - )); - } - out.push('\n'); - } - // Merge the pagination facts into this footer rather than letting - // `append_pagination_summary` print a second, redundant line right below - // it — both would otherwise state the same shown/total count. The shown - // count comes from `rows.len()`, not `pagination.count`: a later - // pipeline step (`--expr`) can still reshape `envelope.data` after - // pagination ran, so `rows.len()` is what's actually rendered above, - // while `total`/`offset`/`limit` stay pagination's own facts. - match pagination { - Some(pagination) => out.push_str(&format!( - "\n({} of {} rows, offset {}, limit {})\n", - rows.len(), - pagination.total, - pagination.offset, - pagination.limit - )), - None => out.push_str(&format!("\n({} rows)\n", rows.len())), - } - out -} - -/// Resolves a column's (possibly dotted) field path against an object, -/// walking down through nested objects one segment at a time — e.g. -/// `"parameters.items"` reaches `map["parameters"]["items"]`. -/// -/// Returns `None` when: `field` is empty; any segment (including a -/// leading/trailing/doubled `.`) is empty; an intermediate or leaf segment is -/// missing; or an intermediate segment's value is not an object. The leaf -/// segment's value is returned as-is whatever its `Value` variant is — -/// callers decide what to do with that. -fn resolve_field_path<'value>( - map: &'value serde_json::Map, - field: &str, -) -> Option<&'value Value> { - let mut segments = field.split('.'); - let first = segments.next()?; - if first.is_empty() { - return None; - } - let mut current = map.get(first)?; - for segment in segments { - if segment.is_empty() { - return None; - } - current = current.as_object()?.get(segment)?; - } - Some(current) -} - -/// Resolves the object that directly contains `field`'s leaf segment — e.g. -/// for `"parameters.items"`, the object at `"parameters"` (the one whose keys -/// include `"items"` as a direct child). A field with no `.` has `map` itself -/// as its parent, since the leaf is already one of `map`'s direct keys. -/// -/// Used to reach a nested array's `pagination` sibling (see -/// [`resolve_nested_pagination`]) that `resolve_field_path` alone can't see, -/// since that function only ever returns the leaf. -fn resolve_field_parent<'value>( - map: &'value serde_json::Map, - field: &str, -) -> Option<&'value serde_json::Map> { - match field.rsplit_once('.') { - None => Some(map), - Some((parent_path, _leaf)) => resolve_field_path(map, parent_path)?.as_object(), - } -} - -/// Resolves a `pagination` field on `parent` — the same object that directly -/// contains a [`TableColumn::nested`] column's array — as a [`PaginationMeta`], -/// so nested tables get the exact same `"(N of M rows, offset O, limit L)"` -/// footer a top-level paginated array gets. -fn resolve_nested_pagination(parent: &serde_json::Map) -> Option { - serde_json::from_value(parent.get("pagination")?.clone()).ok() -} - -/// Prefixes every non-empty line of `block` with `indent`, leaving blank -/// lines (e.g. the blank line before a table's `(N rows)` footer) bare so no -/// line ever carries trailing-whitespace-only indent. Round-trips a block's -/// existing single-trailing-newline convention. -fn indent_block(block: &str, indent: &str) -> String { - block - .lines() - .map(|line| { - if line.is_empty() { - line.to_owned() - } else { - format!("{indent}{line}") - } - }) - .collect::>() - .join("\n") - + "\n" -} - -/// Whether `value` is a shape [`TableColumn::nested`] can render as a child -/// block: a single object, or an array whose items are all objects (an empty -/// array trivially qualifies, rendering as an indented "no results"). Gates -/// entry into nested rendering in [`render_object_with_columns`] so a column -/// with `.nested(...)` set is a true no-op — the exact same single-line -/// `format_value` rendering an un-opted-in column would have produced — -/// whenever the runtime value doesn't actually have this shape (a scalar, or -/// an array mixing objects with non-objects). -fn is_nestable(value: &Value) -> bool { - matches!(value, Value::Object(_)) - || matches!(value, Value::Array(items) if items.iter().all(Value::is_object)) -} - -/// Renders a nested column's resolved value as a child block, reusing the -/// same renderers a top-level array/object would use, just at a narrowed -/// width. Only called once [`is_nestable`] has confirmed `value`'s shape, so -/// the array/object arms below are the only ones a real caller reaches; the -/// scalar fallback keeps this function total on its own. -fn render_nested_value( - value: &Value, - nested_columns: &[TableColumn], - available_width: usize, - pagination: Option<&PaginationMeta>, -) -> (String, RenderNotes) { - match value { - Value::Array(items) => { - render_array_with_columns(items, nested_columns, available_width, pagination) - } - Value::Object(map) => render_object_with_columns(map, nested_columns, available_width), - other => (format!("{}\n", format_value(other)), RenderNotes::default()), - } -} - -fn format_value(value: &Value) -> String { - match value { - Value::Null => String::new(), - Value::Bool(true) => "yes".to_owned(), - Value::Bool(false) => "no".to_owned(), - Value::Number(number) => format_number(number), - Value::String(value) => value.clone(), - Value::Array(items) => items - .iter() - .map(format_value) - .collect::>() - .join(", "), - Value::Object(_) => serde_json::to_string(value).unwrap_or_else(|_| "{}".to_owned()), - } -} - -fn format_plain_value(value: &Value) -> String { - match value { - Value::Null => "".to_owned(), - Value::Bool(value) => value.to_string(), - Value::Number(number) => format_number(number), - Value::String(value) => value.clone(), - Value::Array(items) => { - let values = items - .iter() - .map(format_plain_value) - .collect::>() - .join(" "); - format!("[{values}]") - } - Value::Object(object) => { - let mut pairs = object - .iter() - .map(|(key, value)| (key.clone(), value.clone())) - .collect::>(); - pairs.sort_by(|left, right| left.0.cmp(&right.0)); - let object = pairs - .into_iter() - .collect::>(); - serde_json::to_string(&Value::Object(object)).unwrap_or_else(|_| "{}".to_owned()) - } - } -} - -fn truncate(value: &str, width: usize) -> String { - if value.len() <= width { - return value.to_owned(); - } - if width <= 3 { - return value.chars().take(width).collect(); - } - let mut out = value.chars().take(width - 3).collect::(); - out.push_str("..."); - out -} - -fn format_number(number: &serde_json::Number) -> String { - number.to_string() -} - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - - #[test] - fn format_plain_value_round_trips_a_bare_string_verbatim() { - // No quoting/escaping — the exact convention `raw_output` bypass - // relies on to render a `CommandResult` string byte-for-byte. - assert_eq!( - format_plain_value(&Value::String("some\nverbatim\ntext".to_owned())), - "some\nverbatim\ntext" - ); - } - - #[test] - fn human_output_appends_next_steps_footer() { - let envelope = Envelope::success(json!({ "domain": "example.com" }), "domain") - .with_next_actions(vec![NextAction::new( - "domain purchase --quote-token --agree --confirm", - "Register at the quoted price", - )]); - let out = render_human(&envelope); - // Data still renders as before… - assert!(out.contains("domain: example.com"), "{out}"); - // …followed by a Next steps footer with the command and its description. - assert!(out.contains("\nNext steps:\n"), "{out}"); - assert!( - out.contains("domain purchase --quote-token --agree --confirm"), - "{out}" - ); - assert!(out.contains("Register at the quoted price"), "{out}"); - } - - #[test] - fn human_output_substitutes_known_next_action_params() { - let envelope = Envelope::success(json!({ "domain": "example.com" }), "domain") - .with_next_actions(vec![ - NextAction::new( - "domain purchase --quote-token --agree --confirm", - "Register at the quoted price", - ) - .with_param("quote-token", NextActionParam::value("abc-123")), - ]); - let out = render_human(&envelope); - assert!( - out.contains("domain purchase --quote-token abc-123 --agree --confirm"), - "{out}" - ); - assert!(!out.contains(""), "{out}"); - } - - #[test] - fn human_output_leaves_placeholder_without_a_known_value() { - let envelope = Envelope::success(json!({ "domain": "example.com" }), "domain") - .with_next_actions(vec![ - NextAction::new("domain quote ", "Price a registration") - .with_param("domain", NextActionParam::required()), - ]); - let out = render_human(&envelope); - assert!(out.contains("domain quote "), "{out}"); - } - - #[test] - fn human_output_has_no_footer_without_next_actions() { - let envelope = Envelope::success(json!({ "domain": "example.com" }), "domain"); - let out = render_human(&envelope); - assert!(out.contains("domain: example.com"), "{out}"); - assert!( - !out.contains("Next steps"), - "no footer when there are no actions: {out}" - ); - } - - #[test] - fn error_output_has_no_next_steps_footer() { - // An error envelope carries no next_actions and must render only the error. - let envelope = Envelope::error("ERROR", "boom", "domain"); - let out = render_human(&envelope); - assert!(out.starts_with("Error:"), "{out}"); - assert!(!out.contains("Next steps"), "{out}"); - assert!(!out.contains("Fix:"), "{out}"); - } - - #[test] - fn error_output_appends_fix_line() { - let envelope = - Envelope::error("AUTH_REQUIRED", "not logged in", "auth").with_fix("Run auth login"); - let out = render_human(&envelope); - assert_eq!(out, "Error: not logged in\nFix: Run auth login\n"); - } - - #[test] - fn no_truncate_column_keeps_long_values_intact() { - let long_url = "https://example.com/legal/agreements/registration-agreement-v2"; - assert!(long_url.len() > 40, "fixture must exceed the default cap"); - let items = vec![json!({ "title": long_url, "url": long_url })]; - let columns = vec![ - // Declared first (higher priority) so it survives hide-before- - // truncate rather than the lower-priority title column - // absorbing truncation instead — with only two columns, any - // truncation now cascades to hiding the lower-priority one. - TableColumn::new("url", "URL").no_truncate(true), - TableColumn::new("title", "Title"), - ]; - - let (out, notes) = render_array_with_columns(&items, &columns, 80, None); - - assert!( - out.contains(long_url), - "no_truncate column must keep the full value: {out}" - ); - assert!( - !out.contains("..."), - "hiding the lower-priority column avoided any truncation: {out}" - ); - assert_eq!( - notes.hidden_columns, - vec!["Title".to_owned()], - "the lower-priority truncatable column is hidden rather than shown truncated: {out}" - ); - } - - #[test] - fn no_truncate_column_still_caps_pathologically_long_values() { - let huge_value = "x".repeat(NO_TRUNCATE_MAX_WIDTH * 2); - let items = vec![json!({ "url": huge_value })]; - let columns = vec![TableColumn::new("url", "URL").no_truncate(true)]; - - let (out, _notes) = render_array_with_columns(&items, &columns, 80, None); - - assert!( - out.contains("..."), - "values far beyond the no_truncate cap should still be truncated: {out}" - ); - assert!( - !out.contains(&huge_value), - "the full pathological value should not be rendered verbatim: {out}" - ); - } - - #[test] - fn right_aligned_column_pads_header_and_cells_on_the_left() { - let items = vec![ - json!({ "period": "1 year", "price": "71.99" }), - json!({ "period": "2 years", "price": "143.99" }), - ]; - let columns = vec![ - TableColumn::new("period", "Period"), - TableColumn::new("price", "Price").align(Alignment::Right), - ]; - - let (out, _notes) = render_array_with_columns(&items, &columns, 80, None); - let mut lines = out.lines(); - let header_line = lines.next().expect("header line"); - let row_lines: Vec<&str> = lines.skip(1).take(2).collect(); - - // "PRICE" (5 chars) right-aligned in a 6-wide column ("143.99") - // leaves one leading space and no trailing space. - assert!(header_line.ends_with(" PRICE"), "{header_line}"); - assert!(row_lines[0].ends_with(" 71.99"), "{}", row_lines[0]); - assert!(row_lines[1].ends_with("143.99"), "{}", row_lines[1]); - // The unaligned leading column is untouched (still left-aligned). - assert!(header_line.starts_with("PERIOD "), "{header_line}"); - } - - #[test] - fn column_alignment_defaults_to_left() { - let items = vec![json!({ "name": "a" }), json!({ "name": "bb" })]; - let columns = vec![TableColumn::new("name", "Name")]; - - let (out, _notes) = render_array_with_columns(&items, &columns, 80, None); - let mut lines = out.lines(); - let header_line = lines.next().expect("header line"); - - assert!( - header_line.starts_with("NAME"), - "Alignment::Left is the default: {header_line}" - ); - } - - #[test] - fn column_width_never_shrinks_below_a_long_header() { - let long_header = "A Very Long Header That Exceeds The Default Width Cap"; - let items = vec![json!({ "field": "short" })]; - let columns = vec![TableColumn::new("field", long_header)]; - - // Deliberately far narrower than the header: the header must still - // render in full even though the row ends up wider than the terminal. - let (out, _notes) = render_array_with_columns(&items, &columns, 10, None); - let header_line = out.lines().next().expect("header line"); - let separator_line = out.lines().nth(1).expect("separator line"); - - assert_eq!( - header_line.len(), - separator_line.len(), - "header and separator must stay aligned even when the header alone exceeds the terminal: {out}" - ); - assert!( - header_line.len() >= long_header.len(), - "header must not be cut short: {out}" - ); - } - - #[test] - fn wide_terminal_shows_full_values_without_truncation() { - let description = "a description that is well past the old forty-character cap"; - assert!(description.len() > 40, "fixture must exceed the old cap"); - let items = vec![json!({ "id": "1", "description": description })]; - let columns = vec![ - TableColumn::new("id", "ID"), - TableColumn::new("description", "Description"), - ]; - - let (out, notes) = render_array_with_columns(&items, &columns, 200, None); - - assert!( - !notes.truncated, - "plenty of room, nothing to shorten: {out}" - ); - assert!(notes.hidden_columns.is_empty(), "{out}"); - assert!(out.contains(description), "{out}"); - assert!(!out.contains("..."), "{out}"); - } - - #[test] - fn narrow_terminal_truncates_and_reports_it() { - // A single column whose value is far longer than the terminal - // allows: there's nothing else to hide (hide-before-truncate has no - // lower-priority column to drop), so truncation is the only option - // and it must still be reported. - let description = "a description that is well past the old forty-character cap"; - let items = vec![json!({ "description": description })]; - let columns = vec![TableColumn::new("description", "Description")]; - - let (out, notes) = render_array_with_columns(&items, &columns, 20, None); - - assert!( - notes.truncated, - "narrow terminal must shorten a cell: {out}" - ); - assert!( - notes.hidden_columns.is_empty(), - "only one column exists to begin with: {out}" - ); - assert!(out.contains("..."), "{out}"); - } - - #[test] - fn narrow_terminal_hides_columns_before_truncating_any_of_the_survivors() { - // Three equally-competing columns: at this width, showing all three - // (or even two) would require truncating every survivor a little. - // Hide-before-truncate should instead cascade down to the single - // highest-priority column and show it in full. - let items = vec![json!({ "a": "x".repeat(5), "b": "x".repeat(5), "c": "x".repeat(5) })]; - let columns = vec![ - TableColumn::new("a", "A"), - TableColumn::new("b", "B"), - TableColumn::new("c", "C"), - ]; - - let (out, notes) = render_array_with_columns(&items, &columns, 10, None); - - assert!( - !notes.truncated, - "hiding B and C should leave A fully shown, untruncated: {out}" - ); - assert_eq!( - notes.hidden_columns, - vec!["B".to_owned(), "C".to_owned()], - "should cascade down to the single highest-priority column: {out}" - ); - assert!(!out.contains("..."), "{out}"); - } - - #[test] - fn overflow_hides_lowest_priority_columns_first() { - let items = vec![json!({ - "id": "1", - "name": "acme", - "status": "active", - "created_at": "2026-01-01", - })]; - let columns = vec![ - TableColumn::new("id", "ID"), - TableColumn::new("name", "Name"), - TableColumn::new("status", "Status"), - TableColumn::new("created_at", "Created At"), - ]; - - let (out, notes) = render_array_with_columns(&items, &columns, 10, None); - - assert_eq!( - notes.hidden_columns, - vec!["Status".to_owned(), "Created At".to_owned()], - "lowest-priority (trailing) columns are dropped first: {out}" - ); - let header_line = out.lines().next().expect("header line"); - assert!(header_line.contains("ID"), "{out}"); - assert!(header_line.contains("NAME"), "{out}"); - assert!(!header_line.contains("STATUS"), "{out}"); - assert!(!header_line.contains("CREATED"), "{out}"); - } - - #[test] - fn render_human_with_view_reports_hidden_columns_in_footer() { - let envelope = Envelope::success( - json!([{ - "id": "1", - "name": "acme", - "status": "active", - "region": "us-west", - "created_at": "2026-01-01", - "updated_at": "2026-01-02", - "notes": "irrelevant, lowest priority", - }]), - "resource", - ); - let columns = vec![ - TableColumn::new("id", "ID"), - TableColumn::new("name", "Name"), - TableColumn::new("status", "Status"), - TableColumn::new("region", "Region"), - TableColumn::new("created_at", "Created At"), - TableColumn::new("updated_at", "Updated At"), - // Deliberately long enough that, combined with the columns above, - // it can't fit alongside them at the fallback 80-column width. - TableColumn::new("notes", "This Is An Extremely Long Trailing Column Header"), - ]; - - // In test runs stdout is not a TTY, so `terminal_width()` deterministically - // falls back to 80 — these headers don't all fit at that width. - let out = render_human_with_view(&envelope, Some(&columns), ""); - - assert!(out.contains("hidden to fit the display width"), "{out}"); - assert!( - out.contains("This Is An Extremely Long Trailing Column Header"), - "{out}" - ); - assert!(out.contains("--fields"), "{out}"); - assert!(out.contains("--json"), "{out}"); - } - - #[test] - fn select_columns_orders_by_requested_fields_not_declared_order() { - let columns = vec![ - TableColumn::new("id", "ID"), - TableColumn::new("name", "Name"), - TableColumn::new("status", "Status"), - ]; - - let selected = select_columns(&columns, "status,id"); - - assert_eq!( - selected - .iter() - .map(|c| c.field.as_str()) - .collect::>(), - vec!["status", "id"], - "order should follow the requested fields, not declaration order" - ); - } - - #[test] - fn select_columns_dedupes_and_skips_unknown_fields() { - let columns = vec![ - TableColumn::new("id", "ID"), - TableColumn::new("name", "Name"), - TableColumn::new("status", "Status"), - ]; - - let selected = select_columns(&columns, "status,bogus,status,id"); - - assert_eq!( - selected - .iter() - .map(|c| c.field.as_str()) - .collect::>(), - vec!["status", "id"], - "duplicates collapse to first occurrence; unknown fields are dropped" - ); - } - - #[test] - fn dynamic_columns_orders_by_requested_fields() { - let columns = dynamic_columns("price1Year,domain", || { - vec![ - "domain".to_owned(), - "currency".to_owned(), - "price1Year".to_owned(), - ] - }); - - assert_eq!( - columns.iter().map(|c| c.field.as_str()).collect::>(), - vec!["price1Year", "domain"] - ); - } - - #[test] - fn dynamic_columns_falls_back_to_alphabetical_without_fields() { - let columns = dynamic_columns("", || vec!["currency".to_owned(), "domain".to_owned()]); - - assert_eq!( - columns.iter().map(|c| c.field.as_str()).collect::>(), - vec!["currency", "domain"], - "no fields signal at all: alphabetical is the only order available" - ); - } - - #[test] - fn no_view_array_rendering_right_aligns_a_column_that_is_numeric_on_every_row() { - let items = vec![ - json!({ "name": "small", "count": 3 }), - json!({ "name": "bigger", "count": 42 }), - ]; - - let (out, _notes) = render_array(&items, "name,count", 80, None); - let mut lines = out.lines(); - let header_line = lines.next().expect("header line"); - let row_lines: Vec<&str> = lines.skip(1).take(2).collect(); - - assert!(header_line.ends_with(" COUNT"), "{header_line}"); - assert!(row_lines[0].ends_with(" 3"), "{}", row_lines[0]); - assert!(row_lines[1].ends_with(" 42"), "{}", row_lines[1]); - assert!(header_line.starts_with("NAME "), "{header_line}"); - } - - #[test] - fn no_view_array_rendering_keeps_a_mixed_type_column_left_aligned() { - // Same field is a number on one row and a string on another — a - // single non-number value anywhere disqualifies the whole column, - // matching how right-aligning it would look ragged next to text. - let items = vec![json!({ "code": 1 }), json!({ "code": "default" })]; - - let (out, _notes) = render_array(&items, "", 80, None); - let header_line = out.lines().next().expect("header line"); - - assert!(header_line.starts_with("CODE"), "{header_line}"); - } - - #[test] - fn no_view_array_rendering_keeps_an_all_null_column_left_aligned() { - // No row ever has a number at this field, so there's no positive - // signal to right-align on. - let items = vec![json!({ "note": null }), json!({ "note": null })]; - - let (out, _notes) = render_array(&items, "", 80, None); - let header_line = out.lines().next().expect("header line"); - - assert!(header_line.starts_with("NOTE"), "{header_line}"); - } - - #[test] - fn no_view_array_rendering_follows_requested_field_order() { - // Reproduces the real-world `domain suggest` symptom: a command with - // no registered view whose default_fields lists `domain` first must - // not silently reorder it after `currency` just because "c" < "d". - let envelope = Envelope::success( - json!([{ "domain": "example.com", "currency": "USD", "price1Year": "12.99" }]), - "domain:suggest", - ); - let registry = HumanViewRegistry::new(); - - let rendered = render_human_with_registry_selected( - &envelope, - ®istry, - "domain:suggest", - "domain,price1Year,currency", - ); - - let header_line = rendered.lines().next().expect("header line"); - assert!(header_line.contains("DOMAIN"), "{rendered}"); - let domain_pos = header_line.find("DOMAIN").expect("domain header"); - let price_pos = header_line.find("PRICE1YEAR").expect("price1Year header"); - let currency_pos = header_line.find("CURRENCY").expect("currency header"); - assert!( - domain_pos < price_pos && price_pos < currency_pos, - "expected DOMAIN, PRICE1YEAR, CURRENCY in that order: {header_line}" - ); - } - - #[test] - fn registered_view_rendering_follows_requested_field_order() { - let mut registry = HumanViewRegistry::new(); - registry.register(HumanViewDef::new( - "things", - vec![ - TableColumn::new("id", "ID"), - TableColumn::new("name", "Name"), - TableColumn::new("status", "Status"), - ], - )); - let envelope = Envelope::success( - json!([{ "id": "1", "name": "acme", "status": "active" }]), - "things", - ); - - let rendered = - render_human_with_registry_selected(&envelope, ®istry, "things", "status,id"); - - let header_line = rendered.lines().next().expect("header line"); - assert!(!header_line.contains("NAME"), "{rendered}"); - let status_pos = header_line.find("STATUS").expect("status header"); - let id_pos = header_line.find("ID").expect("id header"); - assert!( - status_pos < id_pos, - "expected STATUS before ID per the requested field order: {header_line}" - ); - } - - #[test] - fn fit_column_widths_gives_small_wants_priority_over_larger_ones() { - // Regression: a naive `leftover / remaining` split can floor a small - // want to zero (denying a column that needed only 1 more char) - // while a much larger want absorbs that same unit and stays - // truncated anyway — net truncation is identical, but a column that - // could have been fully satisfied wasn't. - let headers = [1, 1, 1]; - let natural = [2, 2, 6]; // wants: 1, 1, 5 - let no_truncate = [false, false, false]; - - let (widths, truncated) = fit_column_widths(&headers, &natural, &no_truncate, 8); - - assert_eq!( - widths[0], natural[0], - "a column that only wanted 1 more char should get it in full: {widths:?}" - ); - assert!(truncated, "budget is still too small overall: {widths:?}"); - } - - #[test] - fn overflow_hiding_accounts_for_no_truncate_columns_true_width() { - // Regression: deciding what to hide from header length alone - // under-counts a `no_truncate` column (it never shrinks below its - // natural width), which could keep a short-header trailing column - // that would never have fit anyway — overflowing when hiding it - // would have let the row fit. - let url = "x".repeat(40); - let items = vec![json!({ "url": url, "notes": "irrelevant, lowest priority" })]; - let columns = vec![ - TableColumn::new("url", "URL").no_truncate(true), - TableColumn::new("notes", "X"), - ]; - - // Exactly enough room for the URL alone (40 chars), not enough for - // the URL plus even a 1-char trailing column and its gutter (43). - let (out, notes) = render_array_with_columns(&items, &columns, 42, None); - - assert_eq!( - notes.hidden_columns, - vec!["X".to_owned()], - "the trailing column must be hidden so the no_truncate URL column fits: {out}" - ); - let header_line = out.lines().next().expect("header line"); - assert!( - header_line.len() <= 42, - "must not overflow once the trailing column is hidden: {out}" - ); - } - - #[test] - fn render_array_with_columns_handles_no_columns_gracefully() { - // A view's `--fields` filtered out every declared column: nothing to - // build a table from, so this must report "no results" rather than - // a blank header/rows table. - let items = vec![json!({ "a": "1" })]; - let (out, notes) = render_array_with_columns(&items, &[], 80, None); - - assert_eq!(out, "(no results)\n"); - assert!(!notes.truncated, "{out}"); - assert!(notes.hidden_columns.is_empty(), "{out}"); - } - - #[test] - fn render_object_with_columns_handles_no_columns_gracefully() { - // Sibling of the array-path test above (Copilot/human review caught - // this asymmetry): a view's `--fields` filtered out every declared - // column on an object-shaped response must report "(no data)" - // rather than silently rendering an empty string. - let map = json!({ "a": "1" }); - let (out, notes) = - render_object_with_columns(map.as_object().expect("object fixture"), &[], 80); - - assert_eq!(out, "(no data)\n"); - assert!(!notes.truncated, "{out}"); - assert!(notes.hidden_columns.is_empty(), "{out}"); - } - - #[test] - fn no_view_array_of_empty_objects_reports_no_results() { - // Every item is `{}`, so the dynamic (no-view) column catalog has no - // keys to derive columns from — same "no columns" case as above, - // reached through the no-view path instead. - let items = vec![json!({}), json!({})]; - let (out, notes) = render_array(&items, "", 80, None); - - assert_eq!(out, "(no results)\n"); - assert!(notes.hidden_columns.is_empty(), "{out}"); - } - - #[test] - fn resolve_field_path_walks_dotted_wrapper_and_reports_missing_or_wrong_shape() { - let map = json!({ - "parameters": { "items": [{"name": "limit"}], "total": 1 }, - "owner": "not-an-object", - }); - let map = map.as_object().expect("object fixture"); - - assert_eq!( - resolve_field_path(map, "parameters.items"), - map.get("parameters").and_then(|value| value.get("items")) - ); - assert_eq!(resolve_field_path(map, "parameters.missing"), None); - assert_eq!( - resolve_field_path(map, "owner.name"), - None, - "intermediate value is a string, not an object" - ); - assert_eq!(resolve_field_path(map, "missing"), None); - assert_eq!(resolve_field_path(map, ""), None, "empty field"); - assert_eq!(resolve_field_path(map, ".parameters"), None, "leading dot"); - assert_eq!(resolve_field_path(map, "parameters."), None, "trailing dot"); - assert_eq!( - resolve_field_path(map, "parameters..items"), - None, - "doubled dot" - ); - } - - #[test] - fn resolve_field_parent_returns_parent_object_for_dotted_and_bare_fields() { - let map = json!({ - "parameters": { "items": [], "total": 2 }, - "owner": "not-an-object", - }); - let map = map.as_object().expect("object fixture"); - - assert_eq!( - resolve_field_parent(map, "parameters.items"), - map.get("parameters").and_then(Value::as_object) - ); - assert_eq!( - resolve_field_parent(map, "items"), - Some(map), - "a field with no dot has the object being rendered as its own parent" - ); - assert_eq!( - resolve_field_parent(map, "owner.name"), - None, - "intermediate value is a string, not an object" - ); - assert_eq!(resolve_field_parent(map, "missing.items"), None); - } - - #[test] - fn resolve_nested_pagination_deserializes_a_pagination_meta_shaped_sibling() { - let parent = json!({ - "pagination": { "total": 26, "offset": 0, "limit": 2, "count": 2, "has_more": true }, - }); - let parent = parent.as_object().expect("object fixture"); - - let meta = resolve_nested_pagination(parent).expect("pagination sibling present"); - assert_eq!( - meta, - PaginationMeta { - total: 26, - offset: 0, - limit: 2, - count: 2, - has_more: true, - } - ); - } - - #[test] - fn resolve_nested_pagination_is_none_when_the_sibling_is_absent_or_malformed() { - let no_sibling = json!({ "items": [] }); - assert_eq!( - resolve_nested_pagination(no_sibling.as_object().expect("object fixture")), - None, - "no pagination field at all" - ); - - let wrong_shape = json!({ "pagination": { "total": 26 } }); - assert_eq!( - resolve_nested_pagination(wrong_shape.as_object().expect("object fixture")), - None, - "missing required PaginationMeta fields fails to deserialize" - ); - - let not_an_object = json!({ "pagination": "26 total" }); - assert_eq!( - resolve_nested_pagination(not_an_object.as_object().expect("object fixture")), - None, - "pagination field present but not object-shaped" - ); - } - - #[test] - fn nested_array_of_objects_renders_as_indented_child_table() { - let map = json!({ - "name": "getPets", - "parameters": { - "items": [ - {"name": "limit", "in": "query"}, - {"name": "id", "in": "path"}, - ], - }, - }); - let columns = vec![ - TableColumn::new("name", "Name"), - TableColumn::new("parameters.items", "Parameters").nested(vec![ - TableColumn::new("name", "Name"), - TableColumn::new("in", "In"), - ]), - ]; - - let (out, notes) = - render_object_with_columns(map.as_object().expect("object fixture"), &columns, 80); - - assert!(out.starts_with("Name: getPets\nParameters:\n"), "{out}"); - assert!( - out.contains(" NAME"), - "child header must be indented: {out}" - ); - assert!(out.contains(" limit"), "child row must be indented: {out}"); - assert!( - !out.contains('{'), - "no raw JSON should leak into output: {out}" - ); - assert!(!notes.truncated, "{out}"); - assert!( - out.contains("(2 rows)"), - "no pagination sibling means the plain row-count footer, unchanged: {out}" - ); - } - - #[test] - fn nested_array_with_pagination_sibling_renders_pagination_style_footer() { - let map = json!({ - "name": "getPets", - "parameters": { - "items": [ - {"name": "limit", "in": "query"}, - {"name": "id", "in": "path"}, - ], - "pagination": { "total": 26, "offset": 0, "limit": 2, "count": 2, "has_more": true }, - }, - }); - let columns = vec![ - TableColumn::new("name", "Name"), - TableColumn::new("parameters.items", "Parameters").nested(vec![ - TableColumn::new("name", "Name"), - TableColumn::new("in", "In"), - ]), - ]; - - let (out, _notes) = - render_object_with_columns(map.as_object().expect("object fixture"), &columns, 80); - - assert!( - out.contains("(2 of 26 rows, offset 0, limit 2)"), - "nested table should reuse the pagination sibling's PaginationMeta facts: {out}" - ); - } - - #[test] - fn nested_array_without_pagination_sibling_keeps_the_plain_row_count_footer() { - let map = json!({ "items": [{"name": "limit"}] }); - let columns = - vec![TableColumn::new("items", "Items").nested(vec![TableColumn::new("name", "Name")])]; - - let (out, _notes) = - render_object_with_columns(map.as_object().expect("object fixture"), &columns, 80); - - assert!( - out.contains("(1 rows)"), - "no pagination sibling means no opt-in — behavior is unchanged: {out}" - ); - } - - #[test] - fn nested_array_with_malformed_pagination_sibling_keeps_the_plain_row_count_footer() { - let map = - json!({ "items": [{"name": "limit"}], "pagination": { "total": "not-a-number" } }); - let columns = - vec![TableColumn::new("items", "Items").nested(vec![TableColumn::new("name", "Name")])]; - - let (out, _notes) = - render_object_with_columns(map.as_object().expect("object fixture"), &columns, 80); - - assert!( - out.contains("(1 rows)"), - "a pagination sibling that fails to deserialize degrades to the plain footer: {out}" - ); - } - - #[test] - fn nested_child_table_narrows_and_reports_via_merged_render_notes() { - let map = json!({ - "items": [ - {"a": "x".repeat(5), "b": "x".repeat(5), "c": "x".repeat(5)}, - ], - }); - let columns = vec![TableColumn::new("items", "Items").nested(vec![ - TableColumn::new("a", "A"), - TableColumn::new("b", "B"), - TableColumn::new("c", "C"), - ])]; - - // Narrow enough to force the child table's own hide-before-truncate - // cascade (mirrors `narrow_terminal_hides_columns_before_truncating_any_of_the_survivors`). - let (out, notes) = - render_object_with_columns(map.as_object().expect("object fixture"), &columns, 12); - - assert_eq!( - notes.hidden_columns, - vec!["Items > B".to_owned(), "Items > C".to_owned()], - "hidden columns bubble up prefixed with the parent header: {out}" - ); - assert!( - notes.nested_narrowing, - "narrowing happened inside the nested child, not at this level's own columns: {out}" - ); - } - - #[test] - fn footer_does_not_suggest_fields_for_narrowing_inside_a_nested_column() { - // `--fields` only selects among top-level declared columns — it - // cannot narrow what shows *inside* a `TableColumn::nested` column. - // When a nested child's own columns get hidden, the footer must not - // claim `--fields` fixes it (regression: it used to say so - // unconditionally, misleading users into trying a flag that does - // nothing for this case — see PR review discussion). Same fixture - // shape as `render_human_with_view_reports_hidden_columns_in_footer` - // (proven to overflow the fallback 80-column width), just nested - // one level under an "items" field instead of being the top-level - // view directly. - let envelope = Envelope::success( - json!({ - "items": [{ - "id": "1", - "name": "acme", - "status": "active", - "region": "us-west", - "created_at": "2026-01-01", - "updated_at": "2026-01-02", - "notes": "irrelevant, lowest priority", - }], - }), - "thing", - ); - let columns = vec![TableColumn::new("items", "Items").nested(vec![ - TableColumn::new("id", "ID"), - TableColumn::new("name", "Name"), - TableColumn::new("status", "Status"), - TableColumn::new("region", "Region"), - TableColumn::new("created_at", "Created At"), - TableColumn::new("updated_at", "Updated At"), - TableColumn::new("notes", "This Is An Extremely Long Trailing Column Header"), - ])]; - - let out = render_human_with_view(&envelope, Some(&columns), ""); - - assert!(out.contains("hidden to fit the display width"), "{out}"); - assert!( - out.contains("Items > This Is An Extremely Long Trailing Column Header"), - "{out}" - ); - assert!( - !out.contains("use --fields"), - "must not suggest --fields as a fix when the narrowing is inside a nested column \ - (mentioning it to explain why it won't help is fine): {out}" - ); - assert!( - out.contains("--json"), - "must still point at --json as the real remedy: {out}" - ); - } - - #[test] - fn empty_nested_array_renders_no_results_indented() { - let map = json!({ "items": [] }); - let columns = vec![ - TableColumn::new("items", "Parameters").nested(vec![TableColumn::new("name", "Name")]), - ]; - - let (out, _notes) = - render_object_with_columns(map.as_object().expect("object fixture"), &columns, 80); - - assert_eq!(out, "Parameters:\n (no results)\n"); - } - - #[test] - fn nested_object_field_renders_as_indented_property_bag() { - let map = json!({ "owner": {"name": "Ada", "email": "ada@example.test"} }); - let columns = vec![TableColumn::new("owner", "Owner").nested(vec![ - TableColumn::new("name", "Name"), - TableColumn::new("email", "Email"), - ])]; - - let (out, _notes) = - render_object_with_columns(map.as_object().expect("object fixture"), &columns, 80); - - assert_eq!(out, "Owner:\n Name: Ada\n Email: ada@example.test\n"); - } - - #[test] - fn unopted_in_nested_value_still_renders_as_raw_json_line() { - // A column with no `.nested(...)` is a strict no-op even when the - // runtime value happens to be list/object shaped — locks in the - // "opt-in, never automatic" guarantee. - let map = json!({ - "parameters": {"items": [{"name": "limit"}], "total": 1}, - }); - let columns = vec![TableColumn::new("parameters", "Parameters")]; - - let (out, _notes) = - render_object_with_columns(map.as_object().expect("object fixture"), &columns, 80); - - assert_eq!( - out, - format!( - "Parameters: {}\n", - format_value(map.get("parameters").expect("parameters")) - ) - ); - assert!(out.contains('{'), "unchanged raw-JSON fallback: {out}"); - } - - #[test] - fn nested_column_is_a_no_op_when_the_value_is_not_actually_nestable() { - // A column can opt into `.nested(...)` while still receiving a - // scalar or a mixed (non-uniform) array at runtime — e.g. a field - // that's usually a list of objects but is empty/absent for this row, - // or simply the wrong shape. Rendering must stay the same flat - // `header: value` line a column with `nested: None` would have - // produced, not a `header:\n value` block — regression guard for a - // shape-drift bug where the header line alone changed to multi-line - // even though the value itself fell back to `format_value`. - let map = json!({ - "scalar": "just a string", - "mixed": ["a", {"b": 1}], - }); - let nested_columns = vec![TableColumn::new("x", "X")]; - let columns = vec![ - TableColumn::new("scalar", "Scalar").nested(nested_columns.clone()), - TableColumn::new("mixed", "Mixed").nested(nested_columns), - ]; - let unnested_columns = vec![ - TableColumn::new("scalar", "Scalar"), - TableColumn::new("mixed", "Mixed"), - ]; - - let (nested_out, _) = - render_object_with_columns(map.as_object().expect("object fixture"), &columns, 80); - let (unnested_out, _) = render_object_with_columns( - map.as_object().expect("object fixture"), - &unnested_columns, - 80, - ); - - assert_eq!( - nested_out, unnested_out, - "an opted-in column must render identically to an unopted-in one \ - when the runtime value isn't list-of-objects or object shaped" - ); - assert_eq!(nested_out, "Scalar: just a string\nMixed: a, {\"b\":1}\n"); - } -} diff --git a/cli-engine/src/output/human/body.rs b/cli-engine/src/output/human/body.rs new file mode 100644 index 0000000..fdefb7a --- /dev/null +++ b/cli-engine/src/output/human/body.rs @@ -0,0 +1,355 @@ +use serde_json::Value; + +use super::columns::{ + column_is_all_numeric, columns_fitting_width, dynamic_columns, fit_column_widths, +}; +use super::value_format::{ + format_plain_value, format_value, indent_block, is_nestable, resolve_field_parent, + resolve_field_path, resolve_nested_pagination, truncate, +}; +use super::{Alignment, RenderNotes, TableColumn}; +use crate::output::PaginationMeta; + +/// Upper bound on a `no_truncate` column's width, even though it otherwise +/// skips the normal 40-char cap. Prevents a pathologically long field value +/// (not expected in practice, but not guaranteed by any schema) from padding +/// every row and the separator line out to an unusable or memory-heavy width. +/// +/// This bounds runtime *values*, not the column *header*: width is always +/// widened back up to `column.header.len()` after the cap is applied, so a +/// header can never be truncated or misaligned even in the (unrealistic) +/// case where it exceeds `NO_TRUNCATE_MAX_WIDTH` itself. Headers are static, +/// developer-authored labels, not the pathological runtime data this cap +/// guards against. +pub(crate) const NO_TRUNCATE_MAX_WIDTH: usize = 4096; + +/// Indent applied to a nested table/property-bag block under a parent +/// object's field. Matches the two-space depth-step the TOON encoder already +/// uses (`crate::output::toon`'s `push_line`), for a consistent look across +/// human and TOON nested rendering. +const NESTED_INDENT: &str = " "; + +/// Render just the data portion of a success envelope (no next-steps footer). +pub(super) fn render_data_body( + data: &Value, + columns: Option<&[TableColumn]>, + fields: &str, + available_width: usize, + pagination: Option<&PaginationMeta>, +) -> (String, RenderNotes) { + if let Some(columns) = columns { + return match data { + Value::Array(items) => { + render_array_with_columns(items, columns, available_width, pagination) + } + Value::Object(map) => render_object_with_columns(map, columns, available_width), + Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => { + (format!("{}\n", format_value(data)), RenderNotes::default()) + } + }; + } + match data { + Value::Array(items) => render_array(items, fields, available_width, pagination), + Value::Object(map) => { + let columns = dynamic_columns(fields, || map.keys().cloned().collect()); + render_object_with_columns(map, &columns, available_width) + } + other => ( + format!("{}\n", format_plain_value(other)), + RenderNotes::default(), + ), + } +} + +pub(crate) fn render_array_with_columns( + items: &[Value], + columns: &[TableColumn], + available_width: usize, + pagination: Option<&PaginationMeta>, +) -> (String, RenderNotes) { + if items.is_empty() || columns.is_empty() { + // Empty columns happens when every item is `{}` (the no-view + // dynamic catalog has no keys to show) or a view's `--fields` + // filtered out every declared column — either way there's nothing + // to build a table from, so fall back to the same message used for + // no items at all rather than rendering a blank header/rows table. + return ("(no results)\n".to_owned(), RenderNotes::default()); + } + if !items.iter().all(Value::is_object) { + return (render_array_lines(items), RenderNotes::default()); + } + // Natural widths (and rows) are computed for every original column + // before deciding what to hide: a `no_truncate` column never shrinks + // below its natural width, so the hiding decision has to know that real + // requirement — using just its header length here could keep a + // low-priority trailing column that would never have fit anyway, + // producing an overflow that hiding it would have avoided. + let header_lens: Vec = columns.iter().map(|column| column.header.len()).collect(); + let no_truncate_all: Vec = columns.iter().map(|column| column.no_truncate).collect(); + let mut natural = header_lens.clone(); + let rows: Vec> = items + .iter() + .map(|item| { + columns + .iter() + .enumerate() + .map(|(index, column)| { + let value = item + .as_object() + .and_then(|map| resolve_field_path(map, &column.field)) + .map_or_else(String::new, format_value); + let cap = if column.no_truncate { + NO_TRUNCATE_MAX_WIDTH + } else { + usize::MAX + }; + natural[index] = natural[index].max(value.len().min(cap)); + value + }) + .collect::>() + }) + .collect(); + + let min_widths: Vec = (0..columns.len()) + .map(|index| { + if no_truncate_all[index] { + natural[index] + } else { + header_lens[index] + } + }) + .collect(); + let mut kept = columns_fitting_width(&min_widths, available_width); + + // Hiding a column is preferred over truncating a cell: if the survivors + // still don't fit their natural width, keep dropping the lowest-priority + // one and re-fitting, until either everyone remaining fits in full or + // only one column is left (which always stays, however it fits). + let (fitted, truncated) = loop { + let (fitted, truncated) = fit_column_widths( + &header_lens[..kept], + &natural[..kept], + &no_truncate_all[..kept], + available_width, + ); + if !truncated || kept <= 1 { + break (fitted, truncated); + } + kept -= 1; + }; + + let hidden_columns = columns[kept..] + .iter() + .map(|column| column.header.clone()) + .collect::>(); + let columns = &columns[..kept]; + let rows: Vec> = rows + .into_iter() + .map(|row| row.into_iter().take(kept).collect()) + .collect(); + + let table = render_table( + &columns + .iter() + .map(|column| column.header.clone()) + .collect::>(), + &fitted, + &columns + .iter() + .map(|column| column.align) + .collect::>(), + &rows, + pagination, + ); + ( + table, + RenderNotes { + truncated, + hidden_columns, + nested_narrowing: false, + pagination_shown: pagination.is_some(), + }, + ) +} + +pub(crate) fn render_object_with_columns( + map: &serde_json::Map, + columns: &[TableColumn], + available_width: usize, +) -> (String, RenderNotes) { + if map.is_empty() || columns.is_empty() { + // Empty columns happens the same way it does in + // `render_array_with_columns`: a view's `--fields` filtered out + // every declared column. Nothing to render either way, so this + // reports the same "(no data)" a genuinely empty object gets, + // rather than an unlabeled blank line. + return ("(no data)\n".to_owned(), RenderNotes::default()); + } + let mut out = String::new(); + let mut notes = RenderNotes::default(); + for column in columns { + let value = resolve_field_path(map, &column.field); + match (&column.nested, value) { + (Some(nested_columns), Some(value)) if is_nestable(value) => { + out.push_str(&format!("{}:\n", column.header)); + let child_width = available_width.saturating_sub(NESTED_INDENT.len()); + let nested_pagination = match value { + Value::Array(_) => { + resolve_field_parent(map, &column.field).and_then(resolve_nested_pagination) + } + _ => None, + }; + let (block, child_notes) = render_nested_value( + value, + nested_columns, + child_width, + nested_pagination.as_ref(), + ); + out.push_str(&indent_block(&block, NESTED_INDENT)); + if child_notes.truncated + || !child_notes.hidden_columns.is_empty() + || child_notes.nested_narrowing + { + notes.nested_narrowing = true; + } + notes.truncated |= child_notes.truncated; + notes.hidden_columns.extend( + child_notes + .hidden_columns + .into_iter() + .map(|hidden| format!("{} > {hidden}", column.header)), + ); + } + (_, value) => { + let value_str = value.map_or_else(String::new, format_value); + out.push_str(&format!("{}: {value_str}\n", column.header)); + } + } + } + (out, notes) +} + +pub(crate) fn render_array( + items: &[Value], + fields: &str, + available_width: usize, + pagination: Option<&PaginationMeta>, +) -> (String, RenderNotes) { + if items.is_empty() { + return ("(no results)\n".to_owned(), RenderNotes::default()); + } + let Some(Value::Object(first_map)) = items.first() else { + return (render_array_lines(items), RenderNotes::default()); + }; + if !items.iter().all(Value::is_object) { + return (render_array_lines(items), RenderNotes::default()); + } + let columns: Vec = dynamic_columns(fields, || first_map.keys().cloned().collect()) + .into_iter() + .map(|column| { + if column_is_all_numeric(items, &column.field) { + column.align(Alignment::Right) + } else { + column + } + }) + .collect(); + render_array_with_columns(items, &columns, available_width, pagination) +} + +fn render_array_lines(items: &[Value]) -> String { + let mut out = String::new(); + for item in items { + out.push_str(&format!("{}\n", format_plain_value(item))); + } + out +} + +/// Pads `text` to `width`, on the left for `Alignment::Right` and on the +/// right otherwise — matching how the header row is padded so a column's +/// header and cells share the same alignment. +fn pad_column(text: &str, width: usize, alignment: Alignment) -> String { + match alignment { + Alignment::Left => format!("{text: format!("{text:>width$}"), + } +} + +fn render_table( + headers: &[String], + widths: &[usize], + alignments: &[Alignment], + rows: &[Vec], + pagination: Option<&PaginationMeta>, +) -> String { + let mut out = String::new(); + for (index, header) in headers.iter().enumerate() { + if index > 0 { + out.push_str(" "); + } + out.push_str(&pad_column( + &header.to_ascii_uppercase(), + widths[index], + alignments[index], + )); + } + out.push('\n'); + for (index, width) in widths.iter().enumerate() { + if index > 0 { + out.push_str(" "); + } + out.push_str(&"-".repeat(*width)); + } + out.push('\n'); + for row in rows { + for (index, value) in row.iter().enumerate() { + if index > 0 { + out.push_str(" "); + } + out.push_str(&pad_column( + &truncate(value, widths[index]), + widths[index], + alignments[index], + )); + } + out.push('\n'); + } + // Merge the pagination facts into this footer rather than letting + // `append_pagination_summary` print a second, redundant line right below + // it — both would otherwise state the same shown/total count. The shown + // count comes from `rows.len()`, not `pagination.count`: a later + // pipeline step (`--expr`) can still reshape `envelope.data` after + // pagination ran, so `rows.len()` is what's actually rendered above, + // while `total`/`offset`/`limit` stay pagination's own facts. + match pagination { + Some(pagination) => out.push_str(&format!( + "\n({} of {} rows, offset {}, limit {})\n", + rows.len(), + pagination.total, + pagination.offset, + pagination.limit + )), + None => out.push_str(&format!("\n({} rows)\n", rows.len())), + } + out +} + +/// Renders a nested column's resolved value as a child block, reusing the +/// same renderers a top-level array/object would use, just at a narrowed +/// width. Only called once [`is_nestable`] has confirmed `value`'s shape, so +/// the array/object arms below are the only ones a real caller reaches; the +/// scalar fallback keeps this function total on its own. +fn render_nested_value( + value: &Value, + nested_columns: &[TableColumn], + available_width: usize, + pagination: Option<&PaginationMeta>, +) -> (String, RenderNotes) { + match value { + Value::Array(items) => { + render_array_with_columns(items, nested_columns, available_width, pagination) + } + Value::Object(map) => render_object_with_columns(map, nested_columns, available_width), + other => (format!("{}\n", format_value(other)), RenderNotes::default()), + } +} diff --git a/cli-engine/src/output/human/columns.rs b/cli-engine/src/output/human/columns.rs new file mode 100644 index 0000000..38a34ac --- /dev/null +++ b/cli-engine/src/output/human/columns.rs @@ -0,0 +1,158 @@ +use std::collections::BTreeSet; +use std::io::IsTerminal; + +use serde_json::Value; + +use super::TableColumn; +use super::value_format::resolve_field_path; + +/// Space between adjacent rendered columns. Must match the gutter +/// `render_table` actually writes, since width-fitting math (how much room +/// is left for column content) has to agree with what gets printed. +const COLUMN_GUTTER: usize = 2; + +/// Detects how wide to render human-output tables and guides. +/// +/// An interactive terminal gets its live width (via `termimad`); anything +/// else (pipes, files, CI) gets a fixed `80` so non-interactive `--human` +/// output stays deterministic. Floored at `20` in case a terminal reports an +/// unusably small or zero width. +#[must_use] +pub(crate) fn terminal_width() -> usize { + if std::io::stdout().is_terminal() { + usize::from(termimad::terminal_size().0).max(20) + } else { + 80 + } +} + +/// Builds the column catalog for data with no registered view: when `fields` +/// names specific fields (not empty/`all`/`*`), columns are derived from that +/// list, in the order given (deduplicated) — the same order source a +/// registered view's `--fields` selection uses (see `select_columns`). +/// Otherwise falls back to `natural_keys()` sorted alphabetically, since a +/// bare JSON object has no other order signal to offer. +pub(crate) fn dynamic_columns( + fields: &str, + natural_keys: impl FnOnce() -> Vec, +) -> Vec { + let fields = fields.trim(); + if fields.is_empty() || fields == "all" || fields == "*" { + let mut keys = natural_keys(); + keys.sort(); + return keys + .into_iter() + .map(|key| TableColumn::new(key.clone(), key)) + .collect(); + } + let mut seen = BTreeSet::new(); + fields + .split(',') + .map(str::trim) + .filter(|part| !part.is_empty() && seen.insert(*part)) + .map(|field| TableColumn::new(field, field)) + .collect() +} + +/// True when at least one item has a JSON number at `field`, and no item +/// with a present, non-null value at `field` holds anything else. +pub(crate) fn column_is_all_numeric(items: &[Value], field: &str) -> bool { + let mut saw_number = false; + for item in items { + match item + .as_object() + .and_then(|map| resolve_field_path(map, field)) + { + Some(Value::Number(_)) => saw_number = true, + Some(Value::Null) | None => {} + Some(_) => return false, + } + } + saw_number +} + +/// Chooses how many leading columns (priority order, most important first), +/// each contributing at least `min_widths[i]`, fit in `available_width` — so +/// lower-priority trailing columns can be dropped when the terminal is too +/// narrow for all of them. `min_widths[i]` should be the column's header +/// length for a column that can still shrink, or its full natural width for +/// one that can't (e.g. `no_truncate`) — using a shrinkable column's header +/// length here lets it still be counted as fitting even though its eventual +/// rendered width may be larger. Always keeps at least one column, even if +/// it alone exceeds `available_width`. +pub(crate) fn columns_fitting_width(min_widths: &[usize], available_width: usize) -> usize { + let mut used = 0_usize; + let mut kept = 0_usize; + for (index, &min_width) in min_widths.iter().enumerate() { + let gutter = if index == 0 { 0 } else { COLUMN_GUTTER }; + let next_used = used + gutter + min_width; + if next_used > available_width && kept > 0 { + break; + } + used = next_used; + kept += 1; + } + kept +} + +/// Fits `natural` (fully-untruncated) column widths into `available_width`. +/// +/// `no_truncate` columns are never shrunk (they keep their natural width +/// unconditionally — that's the whole point of the flag) and their width is +/// reserved out of the budget up front. The remaining columns are never +/// shrunk below their header length, and share whatever budget is left +/// beyond that, smallest-need-first, so a column that wants only a little +/// gets exactly that instead of an equal-but-wasteful split. +/// +/// Returns the fitted widths and whether any truncatable column ended up +/// narrower than its natural width (i.e. some cell will actually be cut). +pub(crate) fn fit_column_widths( + headers: &[usize], + natural: &[usize], + no_truncate: &[bool], + available_width: usize, +) -> (Vec, bool) { + let mut widths = natural.to_vec(); + let truncatable: Vec = (0..no_truncate.len()) + .filter(|&index| !no_truncate[index]) + .collect(); + if truncatable.is_empty() { + return (widths, false); + } + let gutters = COLUMN_GUTTER * headers.len().saturating_sub(1); + let reserved: usize = (0..no_truncate.len()) + .filter(|&index| no_truncate[index]) + .map(|index| natural[index]) + .sum(); + let budget = available_width + .saturating_sub(gutters) + .saturating_sub(reserved); + let header_floor: usize = truncatable.iter().map(|&index| headers[index]).sum(); + for &index in &truncatable { + widths[index] = headers[index]; + } + let mut leftover = budget.saturating_sub(header_floor); + let mut needy: Vec = truncatable + .iter() + .copied() + .filter(|&index| natural[index] > headers[index]) + .collect(); + needy.sort_by_key(|&index| natural[index] - headers[index]); + // Smallest-need-first, take exactly what's wanted or whatever's left, + // whichever is less. Deliberately not an even split of `leftover` across + // the remaining columns: dividing first and taking `min(wants, share)` + // can floor a small want to zero when `leftover < remaining columns`, + // denying it entirely while a later, greedier column absorbs the + // remainder — worse than just letting small wants claim what they need + // outright before anyone larger gets a turn. + for &index in &needy { + let wants = natural[index] - headers[index]; + let take = wants.min(leftover); + widths[index] += take; + leftover -= take; + } + let truncated = truncatable + .iter() + .any(|&index| widths[index] < natural[index]); + (widths, truncated) +} diff --git a/cli-engine/src/output/human/footer.rs b/cli-engine/src/output/human/footer.rs new file mode 100644 index 0000000..7a5020d --- /dev/null +++ b/cli-engine/src/output/human/footer.rs @@ -0,0 +1,130 @@ +use std::{borrow::Cow, collections::HashMap}; + +use super::RenderNotes; +use crate::output::{NextAction, NextActionParam, PaginationMeta}; + +/// Appends footer hints for truncated cells and/or hidden columns to `out` +/// (a no-op when neither happened). Mirrors `append_next_actions`: writes +/// directly into `out` rather than building a separate string. +pub(super) fn append_render_notes(out: &mut String, notes: &RenderNotes) { + // `--fields` only ever selects among top-level declared columns: it can + // drop a `TableColumn::nested` column entirely, but can't narrow what + // shows *inside* one. Suggesting it as a fix once any of the reported + // narrowing happened inside a nested block would be wrong — there's no + // flag that reaches that fine-grained, so `--json` is the only real + // remedy in that case. + let fields_helps = !notes.nested_narrowing; + if notes.truncated { + if fields_helps { + out.push_str( + "\nOutput truncated to fit the display width — use --fields to show fewer columns, or --json for full values.\n", + ); + } else { + out.push_str( + "\nOutput truncated to fit the display width — use --json for full values.\n", + ); + } + } + if !notes.hidden_columns.is_empty() { + let suggestion = if fields_helps { + "use --fields to choose columns, or --json for full output" + } else { + "use --json for full output" + }; + out.push_str(&format!( + "\n{} column{} hidden to fit the display width ({}) — {suggestion}.\n", + notes.hidden_columns.len(), + if notes.hidden_columns.len() == 1 { + "" + } else { + "s" + }, + notes.hidden_columns.join(", "), + )); + } +} + +/// Appends a one-line pagination summary to `out` (a no-op when the response +/// wasn't paginated). Unlike `next_actions`, this always shows the underlying +/// facts even on the last page, where there's no follow-up command to +/// suggest. +/// +/// Only a fallback: when the data rendered as a table, `render_table` already +/// merged these same facts into its `(N of M rows, ...)` footer +/// (`RenderNotes::pagination_shown` signals that to +/// [`render_human_with_view`](super::render_human_with_view)), so this only +/// actually prints anything for a paginated response that *didn't* render as +/// a table (e.g. a bare array of scalars) — otherwise the two would repeat +/// the same count/offset/limit on consecutive lines. +/// +/// `shown` is the caller's actual rendered item count (from `envelope.data`, +/// post-pipeline), used in place of `pagination.count` — which is only the +/// pre-`--expr` slice size and can go stale once `--expr` reshapes the array +/// after pagination ran (mirrors the same fix in `render_table`). `None` +/// means `--expr` reshaped the data into something that's no longer even an +/// array (e.g. `length(@)` turning it into a number) — pagination still ran, +/// but there's no rendered row count left to describe, so this falls back to +/// a more neutral line instead of a "Showing N of M" claim that would no +/// longer match what's actually displayed above it. +pub(super) fn append_pagination_summary( + out: &mut String, + pagination: Option<&PaginationMeta>, + shown: Option, +) { + let Some(pagination) = pagination else { + return; + }; + match shown { + Some(count) => out.push_str(&format!( + "\nShowing {count} of {} (offset {}, limit {})\n", + pagination.total, pagination.offset, pagination.limit + )), + None => out.push_str(&format!( + "\n(pagination: {} total, offset {}, limit {})\n", + pagination.total, pagination.offset, pagination.limit + )), + } +} + +/// Append a "Next steps:" footer listing suggested follow-up commands to `out` +/// (a no-op when there are none). Each action shows its command template with +/// any known param values substituted into their `` (params +/// without a known value, e.g. required-only hints, are shown as-is), followed +/// by the description beneath it. Writes directly into `out` to avoid +/// per-action temporaries. +pub(super) fn append_next_actions(out: &mut String, actions: &[NextAction]) { + if actions.is_empty() { + return; + } + out.push_str("\nNext steps:\n"); + for action in actions { + out.push_str(" "); + out.push_str(&substitute_known_params(&action.command, &action.params)); + out.push_str("\n "); + out.push_str(&action.description); + out.push('\n'); + } +} + +/// Fills a `NextAction` command template with any params that carry a known +/// concrete `value` — e.g. `"domain quote "` with +/// `params["domain"].value == Some("example.com")` becomes +/// `"domain quote example.com"`. A param's placeholder is its key wrapped in +/// angle brackets (``); params without a known value (required-only +/// hints) are left as literal placeholder text for the user to fill in. +/// Borrows `command` as-is (no allocation) when nothing has a known value. +fn substitute_known_params<'cmd>( + command: &'cmd str, + params: &HashMap, +) -> Cow<'cmd, str> { + let mut command = Cow::Borrowed(command); + for (key, param) in params { + if let Some(value) = ¶m.value { + let placeholder = format!("<{key}>"); + if command.contains(&placeholder) { + command = Cow::Owned(command.replace(&placeholder, value)); + } + } + } + command +} diff --git a/cli-engine/src/output/human/mod.rs b/cli-engine/src/output/human/mod.rs new file mode 100644 index 0000000..2d5258f --- /dev/null +++ b/cli-engine/src/output/human/mod.rs @@ -0,0 +1,472 @@ +use std::{ + fmt, + sync::{Arc, OnceLock, RwLock}, +}; + +use serde_json::Value; + +use super::Envelope; + +mod body; +mod columns; +mod footer; +#[cfg(test)] +mod tests; +mod value_format; + +use body::render_data_body; +use footer::{append_next_actions, append_pagination_summary, append_render_notes}; + +pub(crate) use columns::terminal_width; + +/// Column text alignment for the human table view. +/// +/// Only affects the array/table rendering path (`render_array_with_columns` +/// via `render_table`) — property-bag rendering (`render_object_with_columns`) +/// prints `header: value` with no column widths to align, so alignment is a +/// no-op there. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum Alignment { + /// Left-aligned (the default) — appropriate for text-like columns. + #[default] + Left, + /// Right-aligned — use for numeric/price columns so values line up on + /// their least-significant digit. + Right, +} + +/// Column definition for registered human table views. +/// +/// Column order is a priority order, most important first: table rendering +/// keeps this order on screen, and when the terminal is too narrow to show +/// every column, the lowest-priority (trailing) columns are hidden first. Put +/// the column a reader most needs — usually an id or name — first. +/// +/// This declared order is only the *fallback* — whenever a `--fields`/ +/// `default_fields` selection is given, its order wins instead (see +/// [`crate::output::render_human_with_registry_selected`]), for both display +/// and hide-priority. Declared order only governs output when no selection is +/// given at all. +/// +/// Construct with [`TableColumn::new`], then chain builder methods like +/// [`no_truncate`](TableColumn::no_truncate)/[`nested`](TableColumn::nested) +/// — never as a struct literal. No known consumer constructs `TableColumn` +/// via struct literal, so marking it `#[non_exhaustive]` carries no real +/// breaking impact today; going forward it means the engine can add fields +/// (as it did for `nested`) without that becoming a breaking release either. +#[derive(Clone, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub struct TableColumn { + /// JSON field path. Supports simple dotted paths to reach a value nested + /// under intermediate objects, so a column can point through a wrapper + /// shape (a pagination envelope, a `Summary`, etc.). A literal field + /// name containing a `.` is not supported — this mirrors the dotted-path + /// convention `crate::output::fields` already uses for `--fields` + /// projection. + pub field: String, + /// Display header. + pub header: String, + /// When true, this column's values are never shrunk to fit the terminal + /// (still capped at `NO_TRUNCATE_MAX_WIDTH` to bound pathologically long + /// values). Use this for values that are useless when cut short, such as + /// URLs. + pub no_truncate: bool, + /// When set, and the resolved value is list-of-objects or object shaped, + /// this column renders as an indented child table or child property bag + /// instead of a one-line dump — see [`TableColumn::nested`]. `None` (the + /// default from [`TableColumn::new`]) is a complete no-op: rendering is + /// identical to a column with no opinion about nesting. + pub nested: Option>, + /// Header and cell text alignment — see [`TableColumn::align`]. + pub align: Alignment, +} + +impl TableColumn { + /// Creates a table column from a JSON field path and display header. + #[must_use] + pub fn new(field: impl Into, header: impl Into) -> Self { + Self { + field: field.into(), + header: header.into(), + no_truncate: false, + nested: None, + align: Alignment::Left, + } + } + + /// Opts this column out of terminal-width-driven shrinking. Values are + /// still capped at `NO_TRUNCATE_MAX_WIDTH`. + #[must_use] + pub fn no_truncate(mut self, value: bool) -> Self { + self.no_truncate = value; + self + } + + /// Sets this column's header and cell alignment. Defaults to + /// `Alignment::Left`; use `Alignment::Right` for numeric or price + /// columns so decimal points and digits line up instead of looking + /// ragged on the left. + #[must_use] + pub fn align(mut self, alignment: Alignment) -> Self { + self.align = alignment; + self + } + + /// Opts this column into rendering a nested list/object value as an + /// indented child table or property bag, using `columns` as that child's + /// own column definitions (which may themselves set `.nested(...)`). + /// + /// Nesting is only consulted when this column is rendered inside an + /// object property bag (top-level, or itself a nested property bag) — a + /// row cell inside an array-of-objects table always renders as a single + /// flat value, ignoring `nested`, because a table row is one monospace + /// line and can't itself contain a rendered sub-block without breaking + /// column alignment. Recursion is otherwise unbounded through the object + /// chain: a nested column's own child columns may set `.nested(...)` + /// again for a grandchild table or property bag. + #[must_use] + pub fn nested(mut self, columns: impl Into>) -> Self { + self.nested = Some(columns.into()); + self + } +} + +/// Human view definition keyed by schema id. +/// +/// `columns` order is a priority order — see [`TableColumn`]. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct HumanViewDef { + /// Schema id, usually the command path. + pub schema_id: String, + /// Columns rendered for matching object or list data, most important + /// first. + pub columns: Vec, +} + +impl HumanViewDef { + /// Creates a column-based human view for a schema id or command path. + #[must_use] + pub fn new(schema_id: impl Into, columns: impl Into>) -> Self { + Self { + schema_id: schema_id.into(), + columns: columns.into(), + } + } +} + +/// Function used to render custom human output for a JSON value. +pub type HumanViewFn = Arc String + Send + Sync>; + +/// Custom human renderer wrapper. +#[derive(Clone)] +pub struct HumanViewRenderer { + render: HumanViewFn, +} + +impl HumanViewRenderer { + /// Creates a custom renderer. + #[must_use] + pub fn new(render: impl Fn(&Value) -> String + Send + Sync + 'static) -> Self { + Self { + render: Arc::new(render), + } + } + + /// Renders data with the custom renderer. + #[must_use] + pub fn render(&self, data: &Value) -> String { + (self.render)(data) + } +} + +impl fmt::Debug for HumanViewRenderer { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("HumanViewRenderer") + .finish_non_exhaustive() + } +} + +/// Registry of human column and custom-renderer views. +#[derive(Clone, Debug, Default)] +pub struct HumanViewRegistry { + by_schema_id: std::collections::BTreeMap>, + custom_by_schema_id: std::collections::BTreeMap, +} + +impl HumanViewRegistry { + /// Creates an empty registry. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Registers a column-based human view. + pub fn register(&mut self, view: HumanViewDef) { + self.by_schema_id.insert(view.schema_id, view.columns); + } + + /// Registers a custom renderer for a schema id. + pub fn register_func( + &mut self, + schema_id: impl Into, + render: impl Fn(&Value) -> String + Send + Sync + 'static, + ) { + self.custom_by_schema_id + .insert(schema_id.into(), HumanViewRenderer::new(render)); + } + + /// Merges another registry into this one. + pub fn merge(&mut self, other: &Self) { + self.by_schema_id.extend(other.by_schema_id.clone()); + self.custom_by_schema_id + .extend(other.custom_by_schema_id.clone()); + } + + /// Returns column definitions for a schema id. + #[must_use] + pub fn columns(&self, schema_id: &str) -> Option<&[TableColumn]> { + self.by_schema_id.get(schema_id).map(Vec::as_slice) + } + + /// Returns the custom renderer for a schema id. + #[must_use] + pub fn custom(&self, schema_id: &str) -> Option<&HumanViewRenderer> { + self.custom_by_schema_id.get(schema_id) + } + + /// Whether any human view (column-based or custom) is registered for a + /// schema id. Such a view selects its own columns from the full payload, so + /// callers must not pre-project the data before handing it to the renderer. + #[must_use] + pub fn has_view(&self, schema_id: &str) -> bool { + self.by_schema_id.contains_key(schema_id) + || self.custom_by_schema_id.contains_key(schema_id) + } +} + +static GLOBAL_HUMAN_VIEW_REGISTRY: OnceLock> = OnceLock::new(); + +fn global_human_view_registry() -> &'static RwLock { + GLOBAL_HUMAN_VIEW_REGISTRY.get_or_init(|| RwLock::new(HumanViewRegistry::new())) +} + +/// Registers a process-global column view. +pub fn register_global_human_view(view: HumanViewDef) { + let mut registry = global_human_view_registry() + .write() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + registry.register(view); +} + +/// Registers a process-global custom human renderer. +pub fn register_global_human_view_func( + schema_id: impl Into, + render: impl Fn(&Value) -> String + Send + Sync + 'static, +) { + let mut registry = global_human_view_registry() + .write() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + registry.register_func(schema_id, render); +} + +/// Looks up global columns for a schema id. +#[must_use] +pub fn lookup_global_human_view_columns(schema_id: &str) -> Option> { + global_human_view_registry() + .read() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .columns(schema_id) + .map(<[TableColumn]>::to_vec) +} + +/// Looks up a global custom renderer for a schema id. +#[must_use] +pub fn lookup_global_human_view_func(schema_id: &str) -> Option { + global_human_view_registry() + .read() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .custom(schema_id) + .cloned() +} + +/// Returns a snapshot of the process-global human view registry. +#[must_use] +pub fn global_human_view_registry_snapshot() -> HumanViewRegistry { + global_human_view_registry() + .read() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .clone() +} + +/// Renders an envelope using generic human output. +/// +/// There's no field-selection concept at this entry point, so a no-view +/// array/object falls back to alphabetical key order — use +/// [`render_human_with_registry_selected`] when a `--fields`/`default_fields` +/// value is available, so its order can drive column order too. +#[must_use] +pub fn render_human(envelope: &Envelope) -> String { + render_human_with_view(envelope, None, "") +} + +/// Renders an envelope using a human view registry. +#[must_use] +pub fn render_human_with_registry(envelope: &Envelope, registry: &HumanViewRegistry) -> String { + let system = envelope + .metadata + .as_ref() + .map(|metadata| metadata.system.as_str()) + .unwrap_or_default(); + render_human_with_registry_for_schema(envelope, registry, system) +} + +/// Renders an envelope using registry entries for a specific schema id. +/// +/// Shows every column of the registered view. Use +/// [`render_human_with_registry_selected`] to narrow the columns to a field +/// selection. +#[must_use] +pub fn render_human_with_registry_for_schema( + envelope: &Envelope, + registry: &HumanViewRegistry, + schema_id: &str, +) -> String { + render_human_with_registry_selected(envelope, registry, schema_id, "") +} + +/// Renders an envelope using a registered view, narrowed to `fields`. +/// +/// `fields` uses the same comma-separated syntax as `--fields`: an empty +/// string, `all`, or `*` keeps every column; otherwise only the view columns +/// whose `field` is listed are shown. A custom view renderer receives the full +/// data and ignores `fields`. +#[must_use] +pub fn render_human_with_registry_selected( + envelope: &Envelope, + registry: &HumanViewRegistry, + schema_id: &str, + fields: &str, +) -> String { + if let Some(error) = &envelope.error { + return format!("Error: {}\n", error.message); + } + if let Some(data) = &envelope.data + && let Some(custom) = registry.custom(schema_id) + { + return custom.render(data); + } + match registry.columns(schema_id) { + Some(columns) => { + let selected = select_columns(columns, fields); + render_human_with_view(envelope, Some(&selected), fields) + } + None => render_human_with_view(envelope, None, fields), + } +} + +/// Narrows and reorders view columns to a `--fields`-style selection. An +/// empty string, `all`, or `*` keeps every column in its declared order; +/// otherwise columns are chosen and ordered by the comma-separated list +/// (deduplicated, first occurrence wins) — a name with no matching column is +/// silently skipped, so a view still only ever shows its own declared +/// fields. +fn select_columns(columns: &[TableColumn], fields: &str) -> Vec { + let fields = fields.trim(); + if fields.is_empty() || fields == "all" || fields == "*" { + return columns.to_vec(); + } + let mut seen = std::collections::BTreeSet::new(); + fields + .split(',') + .map(str::trim) + .filter(|part| !part.is_empty() && seen.insert(*part)) + .filter_map(|name| columns.iter().find(|column| column.field == name).cloned()) + .collect() +} + +/// Renders an envelope using explicit table columns. +/// +/// `columns`, when `Some`, is expected to already be `--fields`-selected and +/// ordered (applied by callers such as +/// [`render_human_with_registry_selected`] before this function runs) — this +/// function does not re-apply `fields` to it. `fields` is only read here when +/// `columns` is `None`, to give the dynamically-derived, no-view column +/// catalog the same field selection and order a view would have gotten. Pass +/// `""` when no field-selection value is available. +#[must_use] +pub fn render_human_with_view( + envelope: &Envelope, + columns: Option<&[TableColumn]>, + fields: &str, +) -> String { + // Errors render on their own; success output gets the data body plus, when + // present, a "Next steps:" footer built from the envelope's next_actions + // (these otherwise appear only in JSON/TOON). + if let Some(error) = &envelope.error { + let mut out = format!("Error: {}\n", error.message); + if let Some(fix) = &envelope.fix { + out.push_str("Fix: "); + out.push_str(fix); + out.push('\n'); + } + return out; + } + let available_width = terminal_width(); + let (mut body, notes) = match &envelope.data { + None => ("(no data)\n".to_owned(), RenderNotes::default()), + Some(data) => render_data_body( + data, + columns, + fields, + available_width, + envelope.pagination.as_ref(), + ), + }; + // Footers are appended in place: the common no-footer path leaves `body` + // untouched (no realloc/copy), and non-empty content is written directly + // into it (no per-footer temporaries). + append_render_notes(&mut body, ¬es); + if !notes.pagination_shown { + // `envelope.data` already reflects the fully piped result (filter -> + // paginate -> expr -> fields), so its length is what this non-table + // path actually rendered — unlike `pagination.count`, which is only + // the pre-`--expr` slice size and can go stale once `--expr` reshapes + // the array (mirrors the same fix in `render_table`). + let shown = envelope + .data + .as_ref() + .and_then(Value::as_array) + .and_then(|items| i64::try_from(items.len()).ok()); + append_pagination_summary(&mut body, envelope.pagination.as_ref(), shown); + } + append_next_actions(&mut body, &envelope.next_actions); + body +} + +/// Signals produced while rendering a table body, used to build human-output +/// footer hints. `Default` means nothing was hidden or shortened. +#[derive(Default)] +pub(crate) struct RenderNotes { + /// Whether any cell was shortened to fit the terminal. + pub(crate) truncated: bool, + /// Headers of columns dropped entirely because there wasn't room for + /// them, in their original declared/requested order (the order they + /// would have appeared in the table, had they fit) — not reverse + /// priority order. + pub(crate) hidden_columns: Vec, + /// Whether any of the truncation/hiding captured above happened inside a + /// nested child block (a `TableColumn::nested` column's own table or + /// property bag) rather than at this level's own top-level columns. + /// `--fields` only ever selects among top-level declared columns — it + /// can drop a nested column entirely, but can't narrow what's shown + /// *inside* one — so [`append_render_notes`] must not suggest `--fields` + /// as a fix when this is set, even though `hidden_columns`/`truncated` + /// are otherwise reported identically either way. + pub(crate) nested_narrowing: bool, + /// Whether the table footer already merged in the pagination summary + /// (`render_table`'s `(N of M rows, offset O, limit L)` line) — so + /// [`render_human_with_view`] doesn't also append the standalone + /// `append_pagination_summary` line and duplicate the same facts. + pub(crate) pagination_shown: bool, +} diff --git a/cli-engine/src/output/human/tests.rs b/cli-engine/src/output/human/tests.rs new file mode 100644 index 0000000..476e16e --- /dev/null +++ b/cli-engine/src/output/human/tests.rs @@ -0,0 +1,978 @@ +use serde_json::{Value, json}; + +use super::body::{ + NO_TRUNCATE_MAX_WIDTH, render_array, render_array_with_columns, render_object_with_columns, +}; +use super::columns::{dynamic_columns, fit_column_widths}; +use super::value_format::{ + format_value, resolve_field_parent, resolve_field_path, resolve_nested_pagination, +}; +use super::{ + Alignment, HumanViewDef, HumanViewRegistry, TableColumn, render_human, + render_human_with_registry_selected, render_human_with_view, select_columns, +}; +use crate::output::{Envelope, NextAction, NextActionParam, PaginationMeta}; + +#[test] +fn format_plain_value_round_trips_a_bare_string_verbatim() { + // No quoting/escaping — the exact convention `raw_output` bypass + // relies on to render a `CommandResult` string byte-for-byte. + assert_eq!( + super::value_format::format_plain_value(&Value::String("some\nverbatim\ntext".to_owned())), + "some\nverbatim\ntext" + ); +} + +#[test] +fn human_output_appends_next_steps_footer() { + let envelope = Envelope::success(json!({ "domain": "example.com" }), "domain") + .with_next_actions(vec![NextAction::new( + "domain purchase --quote-token --agree --confirm", + "Register at the quoted price", + )]); + let out = render_human(&envelope); + // Data still renders as before… + assert!(out.contains("domain: example.com"), "{out}"); + // …followed by a Next steps footer with the command and its description. + assert!(out.contains("\nNext steps:\n"), "{out}"); + assert!( + out.contains("domain purchase --quote-token --agree --confirm"), + "{out}" + ); + assert!(out.contains("Register at the quoted price"), "{out}"); +} + +#[test] +fn human_output_substitutes_known_next_action_params() { + let envelope = Envelope::success(json!({ "domain": "example.com" }), "domain") + .with_next_actions(vec![ + NextAction::new( + "domain purchase --quote-token --agree --confirm", + "Register at the quoted price", + ) + .with_param("quote-token", NextActionParam::value("abc-123")), + ]); + let out = render_human(&envelope); + assert!( + out.contains("domain purchase --quote-token abc-123 --agree --confirm"), + "{out}" + ); + assert!(!out.contains(""), "{out}"); +} + +#[test] +fn human_output_leaves_placeholder_without_a_known_value() { + let envelope = Envelope::success(json!({ "domain": "example.com" }), "domain") + .with_next_actions(vec![ + NextAction::new("domain quote ", "Price a registration") + .with_param("domain", NextActionParam::required()), + ]); + let out = render_human(&envelope); + assert!(out.contains("domain quote "), "{out}"); +} + +#[test] +fn human_output_has_no_footer_without_next_actions() { + let envelope = Envelope::success(json!({ "domain": "example.com" }), "domain"); + let out = render_human(&envelope); + assert!(out.contains("domain: example.com"), "{out}"); + assert!( + !out.contains("Next steps"), + "no footer when there are no actions: {out}" + ); +} + +#[test] +fn error_output_has_no_next_steps_footer() { + // An error envelope carries no next_actions and must render only the error. + let envelope = Envelope::error("ERROR", "boom", "domain"); + let out = render_human(&envelope); + assert!(out.starts_with("Error:"), "{out}"); + assert!(!out.contains("Next steps"), "{out}"); + assert!(!out.contains("Fix:"), "{out}"); +} + +#[test] +fn error_output_appends_fix_line() { + let envelope = + Envelope::error("AUTH_REQUIRED", "not logged in", "auth").with_fix("Run auth login"); + let out = render_human(&envelope); + assert_eq!(out, "Error: not logged in\nFix: Run auth login\n"); +} + +#[test] +fn no_truncate_column_keeps_long_values_intact() { + let long_url = "https://example.com/legal/agreements/registration-agreement-v2"; + assert!(long_url.len() > 40, "fixture must exceed the default cap"); + let items = vec![json!({ "title": long_url, "url": long_url })]; + let columns = vec![ + // Declared first (higher priority) so it survives hide-before- + // truncate rather than the lower-priority title column + // absorbing truncation instead — with only two columns, any + // truncation now cascades to hiding the lower-priority one. + TableColumn::new("url", "URL").no_truncate(true), + TableColumn::new("title", "Title"), + ]; + + let (out, notes) = render_array_with_columns(&items, &columns, 80, None); + + assert!( + out.contains(long_url), + "no_truncate column must keep the full value: {out}" + ); + assert!( + !out.contains("..."), + "hiding the lower-priority column avoided any truncation: {out}" + ); + assert_eq!( + notes.hidden_columns, + vec!["Title".to_owned()], + "the lower-priority truncatable column is hidden rather than shown truncated: {out}" + ); +} + +#[test] +fn no_truncate_column_still_caps_pathologically_long_values() { + let huge_value = "x".repeat(NO_TRUNCATE_MAX_WIDTH * 2); + let items = vec![json!({ "url": huge_value })]; + let columns = vec![TableColumn::new("url", "URL").no_truncate(true)]; + + let (out, _notes) = render_array_with_columns(&items, &columns, 80, None); + + assert!( + out.contains("..."), + "values far beyond the no_truncate cap should still be truncated: {out}" + ); + assert!( + !out.contains(&huge_value), + "the full pathological value should not be rendered verbatim: {out}" + ); +} + +#[test] +fn right_aligned_column_pads_header_and_cells_on_the_left() { + let items = vec![ + json!({ "period": "1 year", "price": "71.99" }), + json!({ "period": "2 years", "price": "143.99" }), + ]; + let columns = vec![ + TableColumn::new("period", "Period"), + TableColumn::new("price", "Price").align(Alignment::Right), + ]; + + let (out, _notes) = render_array_with_columns(&items, &columns, 80, None); + let mut lines = out.lines(); + let header_line = lines.next().expect("header line"); + let row_lines: Vec<&str> = lines.skip(1).take(2).collect(); + + // "PRICE" (5 chars) right-aligned in a 6-wide column ("143.99") + // leaves one leading space and no trailing space. + assert!(header_line.ends_with(" PRICE"), "{header_line}"); + assert!(row_lines[0].ends_with(" 71.99"), "{}", row_lines[0]); + assert!(row_lines[1].ends_with("143.99"), "{}", row_lines[1]); + // The unaligned leading column is untouched (still left-aligned). + assert!(header_line.starts_with("PERIOD "), "{header_line}"); +} + +#[test] +fn column_alignment_defaults_to_left() { + let items = vec![json!({ "name": "a" }), json!({ "name": "bb" })]; + let columns = vec![TableColumn::new("name", "Name")]; + + let (out, _notes) = render_array_with_columns(&items, &columns, 80, None); + let mut lines = out.lines(); + let header_line = lines.next().expect("header line"); + + assert!( + header_line.starts_with("NAME"), + "Alignment::Left is the default: {header_line}" + ); +} + +#[test] +fn column_width_never_shrinks_below_a_long_header() { + let long_header = "A Very Long Header That Exceeds The Default Width Cap"; + let items = vec![json!({ "field": "short" })]; + let columns = vec![TableColumn::new("field", long_header)]; + + // Deliberately far narrower than the header: the header must still + // render in full even though the row ends up wider than the terminal. + let (out, _notes) = render_array_with_columns(&items, &columns, 10, None); + let header_line = out.lines().next().expect("header line"); + let separator_line = out.lines().nth(1).expect("separator line"); + + assert_eq!( + header_line.len(), + separator_line.len(), + "header and separator must stay aligned even when the header alone exceeds the terminal: {out}" + ); + assert!( + header_line.len() >= long_header.len(), + "header must not be cut short: {out}" + ); +} + +#[test] +fn wide_terminal_shows_full_values_without_truncation() { + let description = "a description that is well past the old forty-character cap"; + assert!(description.len() > 40, "fixture must exceed the old cap"); + let items = vec![json!({ "id": "1", "description": description })]; + let columns = vec![ + TableColumn::new("id", "ID"), + TableColumn::new("description", "Description"), + ]; + + let (out, notes) = render_array_with_columns(&items, &columns, 200, None); + + assert!( + !notes.truncated, + "plenty of room, nothing to shorten: {out}" + ); + assert!(notes.hidden_columns.is_empty(), "{out}"); + assert!(out.contains(description), "{out}"); + assert!(!out.contains("..."), "{out}"); +} + +#[test] +fn narrow_terminal_truncates_and_reports_it() { + // A single column whose value is far longer than the terminal + // allows: there's nothing else to hide (hide-before-truncate has no + // lower-priority column to drop), so truncation is the only option + // and it must still be reported. + let description = "a description that is well past the old forty-character cap"; + let items = vec![json!({ "description": description })]; + let columns = vec![TableColumn::new("description", "Description")]; + + let (out, notes) = render_array_with_columns(&items, &columns, 20, None); + + assert!( + notes.truncated, + "narrow terminal must shorten a cell: {out}" + ); + assert!( + notes.hidden_columns.is_empty(), + "only one column exists to begin with: {out}" + ); + assert!(out.contains("..."), "{out}"); +} + +#[test] +fn narrow_terminal_hides_columns_before_truncating_any_of_the_survivors() { + // Three equally-competing columns: at this width, showing all three + // (or even two) would require truncating every survivor a little. + // Hide-before-truncate should instead cascade down to the single + // highest-priority column and show it in full. + let items = vec![json!({ "a": "x".repeat(5), "b": "x".repeat(5), "c": "x".repeat(5) })]; + let columns = vec![ + TableColumn::new("a", "A"), + TableColumn::new("b", "B"), + TableColumn::new("c", "C"), + ]; + + let (out, notes) = render_array_with_columns(&items, &columns, 10, None); + + assert!( + !notes.truncated, + "hiding B and C should leave A fully shown, untruncated: {out}" + ); + assert_eq!( + notes.hidden_columns, + vec!["B".to_owned(), "C".to_owned()], + "should cascade down to the single highest-priority column: {out}" + ); + assert!(!out.contains("..."), "{out}"); +} + +#[test] +fn overflow_hides_lowest_priority_columns_first() { + let items = vec![json!({ + "id": "1", + "name": "acme", + "status": "active", + "created_at": "2026-01-01", + })]; + let columns = vec![ + TableColumn::new("id", "ID"), + TableColumn::new("name", "Name"), + TableColumn::new("status", "Status"), + TableColumn::new("created_at", "Created At"), + ]; + + let (out, notes) = render_array_with_columns(&items, &columns, 10, None); + + assert_eq!( + notes.hidden_columns, + vec!["Status".to_owned(), "Created At".to_owned()], + "lowest-priority (trailing) columns are dropped first: {out}" + ); + let header_line = out.lines().next().expect("header line"); + assert!(header_line.contains("ID"), "{out}"); + assert!(header_line.contains("NAME"), "{out}"); + assert!(!header_line.contains("STATUS"), "{out}"); + assert!(!header_line.contains("CREATED"), "{out}"); +} + +#[test] +fn render_human_with_view_reports_hidden_columns_in_footer() { + let envelope = Envelope::success( + json!([{ + "id": "1", + "name": "acme", + "status": "active", + "region": "us-west", + "created_at": "2026-01-01", + "updated_at": "2026-01-02", + "notes": "irrelevant, lowest priority", + }]), + "resource", + ); + let columns = vec![ + TableColumn::new("id", "ID"), + TableColumn::new("name", "Name"), + TableColumn::new("status", "Status"), + TableColumn::new("region", "Region"), + TableColumn::new("created_at", "Created At"), + TableColumn::new("updated_at", "Updated At"), + // Deliberately long enough that, combined with the columns above, + // it can't fit alongside them at the fallback 80-column width. + TableColumn::new("notes", "This Is An Extremely Long Trailing Column Header"), + ]; + + // In test runs stdout is not a TTY, so `terminal_width()` deterministically + // falls back to 80 — these headers don't all fit at that width. + let out = render_human_with_view(&envelope, Some(&columns), ""); + + assert!(out.contains("hidden to fit the display width"), "{out}"); + assert!( + out.contains("This Is An Extremely Long Trailing Column Header"), + "{out}" + ); + assert!(out.contains("--fields"), "{out}"); + assert!(out.contains("--json"), "{out}"); +} + +#[test] +fn select_columns_orders_by_requested_fields_not_declared_order() { + let columns = vec![ + TableColumn::new("id", "ID"), + TableColumn::new("name", "Name"), + TableColumn::new("status", "Status"), + ]; + + let selected = select_columns(&columns, "status,id"); + + assert_eq!( + selected + .iter() + .map(|c| c.field.as_str()) + .collect::>(), + vec!["status", "id"], + "order should follow the requested fields, not declaration order" + ); +} + +#[test] +fn select_columns_dedupes_and_skips_unknown_fields() { + let columns = vec![ + TableColumn::new("id", "ID"), + TableColumn::new("name", "Name"), + TableColumn::new("status", "Status"), + ]; + + let selected = select_columns(&columns, "status,bogus,status,id"); + + assert_eq!( + selected + .iter() + .map(|c| c.field.as_str()) + .collect::>(), + vec!["status", "id"], + "duplicates collapse to first occurrence; unknown fields are dropped" + ); +} + +#[test] +fn dynamic_columns_orders_by_requested_fields() { + let columns = dynamic_columns("price1Year,domain", || { + vec![ + "domain".to_owned(), + "currency".to_owned(), + "price1Year".to_owned(), + ] + }); + + assert_eq!( + columns.iter().map(|c| c.field.as_str()).collect::>(), + vec!["price1Year", "domain"] + ); +} + +#[test] +fn dynamic_columns_falls_back_to_alphabetical_without_fields() { + let columns = dynamic_columns("", || vec!["currency".to_owned(), "domain".to_owned()]); + + assert_eq!( + columns.iter().map(|c| c.field.as_str()).collect::>(), + vec!["currency", "domain"], + "no fields signal at all: alphabetical is the only order available" + ); +} + +#[test] +fn no_view_array_rendering_right_aligns_a_column_that_is_numeric_on_every_row() { + let items = vec![ + json!({ "name": "small", "count": 3 }), + json!({ "name": "bigger", "count": 42 }), + ]; + + let (out, _notes) = render_array(&items, "name,count", 80, None); + let mut lines = out.lines(); + let header_line = lines.next().expect("header line"); + let row_lines: Vec<&str> = lines.skip(1).take(2).collect(); + + assert!(header_line.ends_with(" COUNT"), "{header_line}"); + assert!(row_lines[0].ends_with(" 3"), "{}", row_lines[0]); + assert!(row_lines[1].ends_with(" 42"), "{}", row_lines[1]); + assert!(header_line.starts_with("NAME "), "{header_line}"); +} + +#[test] +fn no_view_array_rendering_keeps_a_mixed_type_column_left_aligned() { + // Same field is a number on one row and a string on another — a + // single non-number value anywhere disqualifies the whole column, + // matching how right-aligning it would look ragged next to text. + let items = vec![json!({ "code": 1 }), json!({ "code": "default" })]; + + let (out, _notes) = render_array(&items, "", 80, None); + let header_line = out.lines().next().expect("header line"); + + assert!(header_line.starts_with("CODE"), "{header_line}"); +} + +#[test] +fn no_view_array_rendering_keeps_an_all_null_column_left_aligned() { + // No row ever has a number at this field, so there's no positive + // signal to right-align on. + let items = vec![json!({ "note": null }), json!({ "note": null })]; + + let (out, _notes) = render_array(&items, "", 80, None); + let header_line = out.lines().next().expect("header line"); + + assert!(header_line.starts_with("NOTE"), "{header_line}"); +} + +#[test] +fn no_view_array_rendering_follows_requested_field_order() { + // Reproduces the real-world `domain suggest` symptom: a command with + // no registered view whose default_fields lists `domain` first must + // not silently reorder it after `currency` just because "c" < "d". + let envelope = Envelope::success( + json!([{ "domain": "example.com", "currency": "USD", "price1Year": "12.99" }]), + "domain:suggest", + ); + let registry = HumanViewRegistry::new(); + + let rendered = render_human_with_registry_selected( + &envelope, + ®istry, + "domain:suggest", + "domain,price1Year,currency", + ); + + let header_line = rendered.lines().next().expect("header line"); + assert!(header_line.contains("DOMAIN"), "{rendered}"); + let domain_pos = header_line.find("DOMAIN").expect("domain header"); + let price_pos = header_line.find("PRICE1YEAR").expect("price1Year header"); + let currency_pos = header_line.find("CURRENCY").expect("currency header"); + assert!( + domain_pos < price_pos && price_pos < currency_pos, + "expected DOMAIN, PRICE1YEAR, CURRENCY in that order: {header_line}" + ); +} + +#[test] +fn registered_view_rendering_follows_requested_field_order() { + let mut registry = HumanViewRegistry::new(); + registry.register(HumanViewDef::new( + "things", + vec![ + TableColumn::new("id", "ID"), + TableColumn::new("name", "Name"), + TableColumn::new("status", "Status"), + ], + )); + let envelope = Envelope::success( + json!([{ "id": "1", "name": "acme", "status": "active" }]), + "things", + ); + + let rendered = render_human_with_registry_selected(&envelope, ®istry, "things", "status,id"); + + let header_line = rendered.lines().next().expect("header line"); + assert!(!header_line.contains("NAME"), "{rendered}"); + let status_pos = header_line.find("STATUS").expect("status header"); + let id_pos = header_line.find("ID").expect("id header"); + assert!( + status_pos < id_pos, + "expected STATUS before ID per the requested field order: {header_line}" + ); +} + +#[test] +fn fit_column_widths_gives_small_wants_priority_over_larger_ones() { + // Regression: a naive `leftover / remaining` split can floor a small + // want to zero (denying a column that needed only 1 more char) + // while a much larger want absorbs that same unit and stays + // truncated anyway — net truncation is identical, but a column that + // could have been fully satisfied wasn't. + let headers = [1, 1, 1]; + let natural = [2, 2, 6]; // wants: 1, 1, 5 + let no_truncate = [false, false, false]; + + let (widths, truncated) = fit_column_widths(&headers, &natural, &no_truncate, 8); + + assert_eq!( + widths[0], natural[0], + "a column that only wanted 1 more char should get it in full: {widths:?}" + ); + assert!(truncated, "budget is still too small overall: {widths:?}"); +} + +#[test] +fn overflow_hiding_accounts_for_no_truncate_columns_true_width() { + // Regression: deciding what to hide from header length alone + // under-counts a `no_truncate` column (it never shrinks below its + // natural width), which could keep a short-header trailing column + // that would never have fit anyway — overflowing when hiding it + // would have let the row fit. + let url = "x".repeat(40); + let items = vec![json!({ "url": url, "notes": "irrelevant, lowest priority" })]; + let columns = vec![ + TableColumn::new("url", "URL").no_truncate(true), + TableColumn::new("notes", "X"), + ]; + + // Exactly enough room for the URL alone (40 chars), not enough for + // the URL plus even a 1-char trailing column and its gutter (43). + let (out, notes) = render_array_with_columns(&items, &columns, 42, None); + + assert_eq!( + notes.hidden_columns, + vec!["X".to_owned()], + "the trailing column must be hidden so the no_truncate URL column fits: {out}" + ); + let header_line = out.lines().next().expect("header line"); + assert!( + header_line.len() <= 42, + "must not overflow once the trailing column is hidden: {out}" + ); +} + +#[test] +fn render_array_with_columns_handles_no_columns_gracefully() { + // A view's `--fields` filtered out every declared column: nothing to + // build a table from, so this must report "no results" rather than + // a blank header/rows table. + let items = vec![json!({ "a": "1" })]; + let (out, notes) = render_array_with_columns(&items, &[], 80, None); + + assert_eq!(out, "(no results)\n"); + assert!(!notes.truncated, "{out}"); + assert!(notes.hidden_columns.is_empty(), "{out}"); +} + +#[test] +fn render_object_with_columns_handles_no_columns_gracefully() { + // Sibling of the array-path test above (Copilot/human review caught + // this asymmetry): a view's `--fields` filtered out every declared + // column on an object-shaped response must report "(no data)" + // rather than silently rendering an empty string. + let map = json!({ "a": "1" }); + let (out, notes) = + render_object_with_columns(map.as_object().expect("object fixture"), &[], 80); + + assert_eq!(out, "(no data)\n"); + assert!(!notes.truncated, "{out}"); + assert!(notes.hidden_columns.is_empty(), "{out}"); +} + +#[test] +fn no_view_array_of_empty_objects_reports_no_results() { + // Every item is `{}`, so the dynamic (no-view) column catalog has no + // keys to derive columns from — same "no columns" case as above, + // reached through the no-view path instead. + let items = vec![json!({}), json!({})]; + let (out, notes) = render_array(&items, "", 80, None); + + assert_eq!(out, "(no results)\n"); + assert!(notes.hidden_columns.is_empty(), "{out}"); +} + +#[test] +fn resolve_field_path_walks_dotted_wrapper_and_reports_missing_or_wrong_shape() { + let map = json!({ + "parameters": { "items": [{"name": "limit"}], "total": 1 }, + "owner": "not-an-object", + }); + let map = map.as_object().expect("object fixture"); + + assert_eq!( + resolve_field_path(map, "parameters.items"), + map.get("parameters").and_then(|value| value.get("items")) + ); + assert_eq!(resolve_field_path(map, "parameters.missing"), None); + assert_eq!( + resolve_field_path(map, "owner.name"), + None, + "intermediate value is a string, not an object" + ); + assert_eq!(resolve_field_path(map, "missing"), None); + assert_eq!(resolve_field_path(map, ""), None, "empty field"); + assert_eq!(resolve_field_path(map, ".parameters"), None, "leading dot"); + assert_eq!(resolve_field_path(map, "parameters."), None, "trailing dot"); + assert_eq!( + resolve_field_path(map, "parameters..items"), + None, + "doubled dot" + ); +} + +#[test] +fn resolve_field_parent_returns_parent_object_for_dotted_and_bare_fields() { + let map = json!({ + "parameters": { "items": [], "total": 2 }, + "owner": "not-an-object", + }); + let map = map.as_object().expect("object fixture"); + + assert_eq!( + resolve_field_parent(map, "parameters.items"), + map.get("parameters").and_then(Value::as_object) + ); + assert_eq!( + resolve_field_parent(map, "items"), + Some(map), + "a field with no dot has the object being rendered as its own parent" + ); + assert_eq!( + resolve_field_parent(map, "owner.name"), + None, + "intermediate value is a string, not an object" + ); + assert_eq!(resolve_field_parent(map, "missing.items"), None); +} + +#[test] +fn resolve_nested_pagination_deserializes_a_pagination_meta_shaped_sibling() { + let parent = json!({ + "pagination": { "total": 26, "offset": 0, "limit": 2, "count": 2, "has_more": true }, + }); + let parent = parent.as_object().expect("object fixture"); + + let meta = resolve_nested_pagination(parent).expect("pagination sibling present"); + assert_eq!( + meta, + PaginationMeta { + total: 26, + offset: 0, + limit: 2, + count: 2, + has_more: true, + } + ); +} + +#[test] +fn resolve_nested_pagination_is_none_when_the_sibling_is_absent_or_malformed() { + let no_sibling = json!({ "items": [] }); + assert_eq!( + resolve_nested_pagination(no_sibling.as_object().expect("object fixture")), + None, + "no pagination field at all" + ); + + let wrong_shape = json!({ "pagination": { "total": 26 } }); + assert_eq!( + resolve_nested_pagination(wrong_shape.as_object().expect("object fixture")), + None, + "missing required PaginationMeta fields fails to deserialize" + ); + + let not_an_object = json!({ "pagination": "26 total" }); + assert_eq!( + resolve_nested_pagination(not_an_object.as_object().expect("object fixture")), + None, + "pagination field present but not object-shaped" + ); +} + +#[test] +fn nested_array_of_objects_renders_as_indented_child_table() { + let map = json!({ + "name": "getPets", + "parameters": { + "items": [ + {"name": "limit", "in": "query"}, + {"name": "id", "in": "path"}, + ], + }, + }); + let columns = vec![ + TableColumn::new("name", "Name"), + TableColumn::new("parameters.items", "Parameters").nested(vec![ + TableColumn::new("name", "Name"), + TableColumn::new("in", "In"), + ]), + ]; + + let (out, notes) = + render_object_with_columns(map.as_object().expect("object fixture"), &columns, 80); + + assert!(out.starts_with("Name: getPets\nParameters:\n"), "{out}"); + assert!( + out.contains(" NAME"), + "child header must be indented: {out}" + ); + assert!(out.contains(" limit"), "child row must be indented: {out}"); + assert!( + !out.contains('{'), + "no raw JSON should leak into output: {out}" + ); + assert!(!notes.truncated, "{out}"); + assert!( + out.contains("(2 rows)"), + "no pagination sibling means the plain row-count footer, unchanged: {out}" + ); +} + +#[test] +fn nested_array_with_pagination_sibling_renders_pagination_style_footer() { + let map = json!({ + "name": "getPets", + "parameters": { + "items": [ + {"name": "limit", "in": "query"}, + {"name": "id", "in": "path"}, + ], + "pagination": { "total": 26, "offset": 0, "limit": 2, "count": 2, "has_more": true }, + }, + }); + let columns = vec![ + TableColumn::new("name", "Name"), + TableColumn::new("parameters.items", "Parameters").nested(vec![ + TableColumn::new("name", "Name"), + TableColumn::new("in", "In"), + ]), + ]; + + let (out, _notes) = + render_object_with_columns(map.as_object().expect("object fixture"), &columns, 80); + + assert!( + out.contains("(2 of 26 rows, offset 0, limit 2)"), + "nested table should reuse the pagination sibling's PaginationMeta facts: {out}" + ); +} + +#[test] +fn nested_array_without_pagination_sibling_keeps_the_plain_row_count_footer() { + let map = json!({ "items": [{"name": "limit"}] }); + let columns = + vec![TableColumn::new("items", "Items").nested(vec![TableColumn::new("name", "Name")])]; + + let (out, _notes) = + render_object_with_columns(map.as_object().expect("object fixture"), &columns, 80); + + assert!( + out.contains("(1 rows)"), + "no pagination sibling means no opt-in — behavior is unchanged: {out}" + ); +} + +#[test] +fn nested_array_with_malformed_pagination_sibling_keeps_the_plain_row_count_footer() { + let map = json!({ "items": [{"name": "limit"}], "pagination": { "total": "not-a-number" } }); + let columns = + vec![TableColumn::new("items", "Items").nested(vec![TableColumn::new("name", "Name")])]; + + let (out, _notes) = + render_object_with_columns(map.as_object().expect("object fixture"), &columns, 80); + + assert!( + out.contains("(1 rows)"), + "a pagination sibling that fails to deserialize degrades to the plain footer: {out}" + ); +} + +#[test] +fn nested_child_table_narrows_and_reports_via_merged_render_notes() { + let map = json!({ + "items": [ + {"a": "x".repeat(5), "b": "x".repeat(5), "c": "x".repeat(5)}, + ], + }); + let columns = vec![TableColumn::new("items", "Items").nested(vec![ + TableColumn::new("a", "A"), + TableColumn::new("b", "B"), + TableColumn::new("c", "C"), + ])]; + + // Narrow enough to force the child table's own hide-before-truncate + // cascade (mirrors `narrow_terminal_hides_columns_before_truncating_any_of_the_survivors`). + let (out, notes) = + render_object_with_columns(map.as_object().expect("object fixture"), &columns, 12); + + assert_eq!( + notes.hidden_columns, + vec!["Items > B".to_owned(), "Items > C".to_owned()], + "hidden columns bubble up prefixed with the parent header: {out}" + ); + assert!( + notes.nested_narrowing, + "narrowing happened inside the nested child, not at this level's own columns: {out}" + ); +} + +#[test] +fn footer_does_not_suggest_fields_for_narrowing_inside_a_nested_column() { + // `--fields` only selects among top-level declared columns — it + // cannot narrow what shows *inside* a `TableColumn::nested` column. + // When a nested child's own columns get hidden, the footer must not + // claim `--fields` fixes it (regression: it used to say so + // unconditionally, misleading users into trying a flag that does + // nothing for this case — see PR review discussion). Same fixture + // shape as `render_human_with_view_reports_hidden_columns_in_footer` + // (proven to overflow the fallback 80-column width), just nested + // one level under an "items" field instead of being the top-level + // view directly. + let envelope = Envelope::success( + json!({ + "items": [{ + "id": "1", + "name": "acme", + "status": "active", + "region": "us-west", + "created_at": "2026-01-01", + "updated_at": "2026-01-02", + "notes": "irrelevant, lowest priority", + }], + }), + "thing", + ); + let columns = vec![TableColumn::new("items", "Items").nested(vec![ + TableColumn::new("id", "ID"), + TableColumn::new("name", "Name"), + TableColumn::new("status", "Status"), + TableColumn::new("region", "Region"), + TableColumn::new("created_at", "Created At"), + TableColumn::new("updated_at", "Updated At"), + TableColumn::new("notes", "This Is An Extremely Long Trailing Column Header"), + ])]; + + let out = render_human_with_view(&envelope, Some(&columns), ""); + + assert!(out.contains("hidden to fit the display width"), "{out}"); + assert!( + out.contains("Items > This Is An Extremely Long Trailing Column Header"), + "{out}" + ); + assert!( + !out.contains("use --fields"), + "must not suggest --fields as a fix when the narrowing is inside a nested column \ + (mentioning it to explain why it won't help is fine): {out}" + ); + assert!( + out.contains("--json"), + "must still point at --json as the real remedy: {out}" + ); +} + +#[test] +fn empty_nested_array_renders_no_results_indented() { + let map = json!({ "items": [] }); + let columns = vec![ + TableColumn::new("items", "Parameters").nested(vec![TableColumn::new("name", "Name")]), + ]; + + let (out, _notes) = + render_object_with_columns(map.as_object().expect("object fixture"), &columns, 80); + + assert_eq!(out, "Parameters:\n (no results)\n"); +} + +#[test] +fn nested_object_field_renders_as_indented_property_bag() { + let map = json!({ "owner": {"name": "Ada", "email": "ada@example.test"} }); + let columns = vec![TableColumn::new("owner", "Owner").nested(vec![ + TableColumn::new("name", "Name"), + TableColumn::new("email", "Email"), + ])]; + + let (out, _notes) = + render_object_with_columns(map.as_object().expect("object fixture"), &columns, 80); + + assert_eq!(out, "Owner:\n Name: Ada\n Email: ada@example.test\n"); +} + +#[test] +fn unopted_in_nested_value_still_renders_as_raw_json_line() { + // A column with no `.nested(...)` is a strict no-op even when the + // runtime value happens to be list/object shaped — locks in the + // "opt-in, never automatic" guarantee. + let map = json!({ + "parameters": {"items": [{"name": "limit"}], "total": 1}, + }); + let columns = vec![TableColumn::new("parameters", "Parameters")]; + + let (out, _notes) = + render_object_with_columns(map.as_object().expect("object fixture"), &columns, 80); + + assert_eq!( + out, + format!( + "Parameters: {}\n", + format_value(map.get("parameters").expect("parameters")) + ) + ); + assert!(out.contains('{'), "unchanged raw-JSON fallback: {out}"); +} + +#[test] +fn nested_column_is_a_no_op_when_the_value_is_not_actually_nestable() { + // A column can opt into `.nested(...)` while still receiving a + // scalar or a mixed (non-uniform) array at runtime — e.g. a field + // that's usually a list of objects but is empty/absent for this row, + // or simply the wrong shape. Rendering must stay the same flat + // `header: value` line a column with `nested: None` would have + // produced, not a `header:\n value` block — regression guard for a + // shape-drift bug where the header line alone changed to multi-line + // even though the value itself fell back to `format_value`. + let map = json!({ + "scalar": "just a string", + "mixed": ["a", {"b": 1}], + }); + let nested_columns = vec![TableColumn::new("x", "X")]; + let columns = vec![ + TableColumn::new("scalar", "Scalar").nested(nested_columns.clone()), + TableColumn::new("mixed", "Mixed").nested(nested_columns), + ]; + let unnested_columns = vec![ + TableColumn::new("scalar", "Scalar"), + TableColumn::new("mixed", "Mixed"), + ]; + + let (nested_out, _) = + render_object_with_columns(map.as_object().expect("object fixture"), &columns, 80); + let (unnested_out, _) = render_object_with_columns( + map.as_object().expect("object fixture"), + &unnested_columns, + 80, + ); + + assert_eq!( + nested_out, unnested_out, + "an opted-in column must render identically to an unopted-in one \ + when the runtime value isn't list-of-objects or object shaped" + ); + assert_eq!(nested_out, "Scalar: just a string\nMixed: a, {\"b\":1}\n"); +} diff --git a/cli-engine/src/output/human/value_format.rs b/cli-engine/src/output/human/value_format.rs new file mode 100644 index 0000000..d04d6e2 --- /dev/null +++ b/cli-engine/src/output/human/value_format.rs @@ -0,0 +1,150 @@ +use serde_json::Value; + +use crate::output::PaginationMeta; + +/// Resolves a column's (possibly dotted) field path against an object, +/// walking down through nested objects one segment at a time — e.g. +/// `"parameters.items"` reaches `map["parameters"]["items"]`. +/// +/// Returns `None` when: `field` is empty; any segment (including a +/// leading/trailing/doubled `.`) is empty; an intermediate or leaf segment is +/// missing; or an intermediate segment's value is not an object. The leaf +/// segment's value is returned as-is whatever its `Value` variant is — +/// callers decide what to do with that. +pub(crate) fn resolve_field_path<'value>( + map: &'value serde_json::Map, + field: &str, +) -> Option<&'value Value> { + let mut segments = field.split('.'); + let first = segments.next()?; + if first.is_empty() { + return None; + } + let mut current = map.get(first)?; + for segment in segments { + if segment.is_empty() { + return None; + } + current = current.as_object()?.get(segment)?; + } + Some(current) +} + +/// Resolves the object that directly contains `field`'s leaf segment — e.g. +/// for `"parameters.items"`, the object at `"parameters"` (the one whose keys +/// include `"items"` as a direct child). A field with no `.` has `map` itself +/// as its parent, since the leaf is already one of `map`'s direct keys. +/// +/// Used to reach a nested array's `pagination` sibling (see +/// [`resolve_nested_pagination`]) that `resolve_field_path` alone can't see, +/// since that function only ever returns the leaf. +pub(crate) fn resolve_field_parent<'value>( + map: &'value serde_json::Map, + field: &str, +) -> Option<&'value serde_json::Map> { + match field.rsplit_once('.') { + None => Some(map), + Some((parent_path, _leaf)) => resolve_field_path(map, parent_path)?.as_object(), + } +} + +/// Resolves a `pagination` field on `parent` — the same object that directly +/// contains a `TableColumn::nested` column's array — as a [`PaginationMeta`], +/// so nested tables get the exact same `"(N of M rows, offset O, limit L)"` +/// footer a top-level paginated array gets. +pub(crate) fn resolve_nested_pagination( + parent: &serde_json::Map, +) -> Option { + serde_json::from_value(parent.get("pagination")?.clone()).ok() +} + +/// Prefixes every non-empty line of `block` with `indent`, leaving blank +/// lines (e.g. the blank line before a table's `(N rows)` footer) bare so no +/// line ever carries trailing-whitespace-only indent. Round-trips a block's +/// existing single-trailing-newline convention. +pub(crate) fn indent_block(block: &str, indent: &str) -> String { + block + .lines() + .map(|line| { + if line.is_empty() { + line.to_owned() + } else { + format!("{indent}{line}") + } + }) + .collect::>() + .join("\n") + + "\n" +} + +/// Whether `value` is a shape `TableColumn::nested` can render as a child +/// block: a single object, or an array whose items are all objects (an empty +/// array trivially qualifies, rendering as an indented "no results"). Gates +/// entry into nested rendering so a column with `.nested(...)` set is a true +/// no-op — the exact same single-line `format_value` rendering an un-opted-in +/// column would have produced — whenever the runtime value doesn't actually +/// have this shape (a scalar, or an array mixing objects with non-objects). +pub(crate) fn is_nestable(value: &Value) -> bool { + matches!(value, Value::Object(_)) + || matches!(value, Value::Array(items) if items.iter().all(Value::is_object)) +} + +pub(crate) fn format_value(value: &Value) -> String { + match value { + Value::Null => String::new(), + Value::Bool(true) => "yes".to_owned(), + Value::Bool(false) => "no".to_owned(), + Value::Number(number) => format_number(number), + Value::String(value) => value.clone(), + Value::Array(items) => items + .iter() + .map(format_value) + .collect::>() + .join(", "), + Value::Object(_) => serde_json::to_string(value).unwrap_or_else(|_| "{}".to_owned()), + } +} + +pub(crate) fn format_plain_value(value: &Value) -> String { + match value { + Value::Null => "".to_owned(), + Value::Bool(value) => value.to_string(), + Value::Number(number) => format_number(number), + Value::String(value) => value.clone(), + Value::Array(items) => { + let values = items + .iter() + .map(format_plain_value) + .collect::>() + .join(" "); + format!("[{values}]") + } + Value::Object(object) => { + let mut pairs = object + .iter() + .map(|(key, value)| (key.clone(), value.clone())) + .collect::>(); + pairs.sort_by(|left, right| left.0.cmp(&right.0)); + let object = pairs + .into_iter() + .collect::>(); + serde_json::to_string(&Value::Object(object)).unwrap_or_else(|_| "{}".to_owned()) + } + } +} + +pub(crate) fn truncate(value: &str, width: usize) -> String { + if value.len() <= width { + return value.to_owned(); + } + if width <= 3 { + return value.chars().take(width).collect(); + } + let mut out = value.chars().take(width - 3).collect::(); + out.push_str("..."); + out +} + +fn format_number(number: &serde_json::Number) -> String { + number.to_string() +} diff --git a/cli-engine/src/prompt.rs b/cli-engine/src/prompt.rs index 2b768af..01b5e41 100644 --- a/cli-engine/src/prompt.rs +++ b/cli-engine/src/prompt.rs @@ -288,10 +288,16 @@ pub fn confirm_command_correction( #[derive(Debug)] pub enum RecoveryResult { /// Successfully prompted for all missing values; `args` has the augmented list. - Recovered { args: Vec }, + Recovered { + /// The augmented argument list, with prompted values filled in. + args: Vec, + }, /// User cancelled mid-prompt; `resume` is the command to resume with /// already-supplied flags. - Cancelled { resume: String }, + Cancelled { + /// The command to resume with, including already-supplied flags. + resume: String, + }, } /// Determine interactivity from raw args (before full clap parse). diff --git a/cli-engine/src/transport/client.rs b/cli-engine/src/transport/client/methods.rs similarity index 71% rename from cli-engine/src/transport/client.rs rename to cli-engine/src/transport/client/methods.rs index bb98eb4..3714483 100644 --- a/cli-engine/src/transport/client.rs +++ b/cli-engine/src/transport/client/methods.rs @@ -1,10 +1,4 @@ -use std::{ - collections::BTreeMap, - io::Write, - path::Path, - sync::{Arc, OnceLock, RwLock}, - time::Duration, -}; +use std::{collections::BTreeMap, io::Write, path::Path, sync::Arc}; use bytes::Bytes; use reqwest::{Method, StatusCode, header}; @@ -12,301 +6,11 @@ use serde::{Serialize, de::DeserializeOwned}; use serde_json::Value; use tokio::time; -use super::{AuthInjector, Error}; -use crate::{CliCoreError, Result}; - -const MAX_RETRIES: usize = 3; -const BASE_BACKOFF: Duration = Duration::from_millis(500); -const BUILTIN_DEFAULT_USER_AGENT: &str = "cli/dev"; -static DEFAULT_USER_AGENT: OnceLock> = OnceLock::new(); - -/// Sets the process-wide default user-agent for outbound requests. -/// -/// Applies to subsequently created [`HttpClient`] values (those that do not set -/// their own via [`HttpClientBuilder::user_agent`]) and to the engine's other -/// outbound token traffic that reads this default — the PKCE provider's -/// token/refresh requests and the client-credentials injector. A per-client -/// user-agent still overrides it for that client. -pub fn set_default_user_agent(user_agent: impl Into) { - let lock = - DEFAULT_USER_AGENT.get_or_init(|| RwLock::new(BUILTIN_DEFAULT_USER_AGENT.to_owned())); - if let Ok(mut current) = lock.write() { - *current = user_agent.into(); - } -} - -/// Returns the process-wide default user-agent set via -/// [`set_default_user_agent`], or the builtin default when none was set. -/// -/// Used by [`HttpClientBuilder`] and by the engine's OAuth token requests so -/// that all outbound traffic carries the same user-agent. -pub(crate) fn default_user_agent() -> String { - DEFAULT_USER_AGENT - .get_or_init(|| RwLock::new(BUILTIN_DEFAULT_USER_AGENT.to_owned())) - .read() - .map_or_else( - |_| BUILTIN_DEFAULT_USER_AGENT.to_owned(), - |value| value.clone(), - ) -} - -/// Serializes unit tests that mutate the process-wide default user-agent so -/// they cannot observe one another's writes. Integration tests in -/// `tests/foundation.rs` run in a separate binary and use their own lock. -#[cfg(test)] -pub(crate) static UA_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); - -/// Restores the process-wide default user-agent to the builtin on drop, so a -/// panicking assertion in a test that mutates it cannot leak the value into -/// later tests in this binary. Declare it after acquiring [`UA_TEST_LOCK`] so -/// the reset runs while the lock is still held. -#[cfg(test)] -pub(crate) struct RestoreDefaultUserAgent; - -#[cfg(test)] -impl Drop for RestoreDefaultUserAgent { - fn drop(&mut self) { - set_default_user_agent(BUILTIN_DEFAULT_USER_AGENT); - } -} - -static DEFAULT_TRANSPORT_LOGGER: OnceLock>> = OnceLock::new(); - -fn default_transport_logger_lock() -> &'static RwLock> { - DEFAULT_TRANSPORT_LOGGER.get_or_init(|| RwLock::new(Arc::new(NoopTransportLogger))) -} - -/// Sets the process-wide default transport logger for outbound HTTP traffic. -/// -/// Applies to subsequently created [`HttpClient`] values (those that do not set -/// their own via [`HttpClientBuilder::logger`]) and to the free -/// [`super::debug_log_reqwest_request`] / [`super::debug_log_reqwest_response`] -/// helpers used by code that talks to `reqwest` directly. -/// -/// The CLI installs a logger from this setter when `--debug` selects the -/// `transport` component, so command handlers get request/response diagnostics -/// without any per-command wiring. A per-client logger still overrides it for -/// that client. -pub fn set_default_transport_logger(logger: Arc) { - // Recover from a poisoned lock (a panic while a writer held it) instead of - // silently doing nothing, which would leave a stale logger installed and - // make `--debug transport` appear ineffective. - let mut current = default_transport_logger_lock() - .write() - .unwrap_or_else(std::sync::PoisonError::into_inner); - *current = logger; -} - -/// Returns the process-wide default transport logger set via -/// [`set_default_transport_logger`], or a [`NoopTransportLogger`] when none was -/// set. -#[must_use] -pub fn default_transport_logger() -> Arc { - default_transport_logger_lock() - .read() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .clone() -} - -/// Logs a `reqwest::Request` to the process-wide default transport logger. -/// -/// This is the bridge for code that talks to `reqwest` directly — bare clients -/// or progenitor-generated clients that cannot use [`HttpClient`] — so a single -/// `--debug`-controlled trace can still cover them. Captures the request method, -/// URL, headers, and in-memory body. Pairs with [`debug_log_reqwest_response`]. -/// It is a no-op (no header clone or body copy) unless an enabled logger has -/// been installed via [`set_default_transport_logger`]. -pub fn debug_log_reqwest_request(request: &reqwest::Request) { - let logger = default_transport_logger(); - if !logger.enabled() { - return; - } - logger.debug(&TransportLogEvent { - message: "http request", - fields: BTreeMap::from([ - ("method".to_owned(), request.method().as_str().to_owned()), - ("url".to_owned(), request.url().as_str().to_owned()), - ]), - headers: Some(header_pairs(request.headers())), - body: request - .body() - .and_then(reqwest::Body::as_bytes) - .map(<[u8]>::to_vec), - }); -} - -/// Logs an HTTP response (status, headers, body) to the process-wide default -/// transport logger. -/// -/// Companion to [`debug_log_reqwest_request`] for `reqwest`-direct call sites. -/// The caller passes the already-read response body. It is a no-op (no header -/// clone or body copy) unless an enabled logger has been installed via -/// [`set_default_transport_logger`]. -pub fn debug_log_reqwest_response(status: StatusCode, headers: &header::HeaderMap, body: &[u8]) { - let logger = default_transport_logger(); - if !logger.enabled() { - return; - } - logger.debug(&TransportLogEvent { - message: "http response", - fields: BTreeMap::from([("status".to_owned(), status.as_u16().to_string())]), - headers: Some(header_pairs(headers)), - body: Some(body.to_vec()), - }); -} - -#[derive(serde::Deserialize)] -struct GraphQlError { - message: String, -} - -#[derive(Default, serde::Deserialize)] -struct GraphQlEnvelope { - data: Option, - #[serde(default)] - errors: Vec, -} - -/// Structured debug event emitted by [`TransportLogger`]. -/// -/// `message` and `fields` are the stable breadcrumb surface (method, url, -/// status, retry attempt). `headers` and `body` carry the raw, un-redacted -/// request or response payload when one is available; loggers that print these -/// (such as [`StderrTransportLogger`](super::StderrTransportLogger)) are -/// responsible for redacting sensitive headers. -#[derive(Clone, Debug, Default)] -pub struct TransportLogEvent { - /// Event name such as `http request` or `retrying request`. - pub message: &'static str, - /// Stable event fields. - pub fields: BTreeMap, - /// Raw header name/value pairs for the request or response, when known. - pub headers: Option>, - /// Raw request or response body bytes, when captured. Streaming and - /// byte-download responses omit this and report a `body_bytes` field - /// instead to avoid buffering large payloads into the log. - pub body: Option>, -} - -/// Debug logger interface for transport events. -pub trait TransportLogger: Send + Sync + std::fmt::Debug { - /// Records one debug event. - fn debug(&self, event: &TransportLogEvent); - - /// Whether this logger records anything. - /// - /// Defaults to `true`. The transport checks this before capturing request - /// and response headers/bodies, so a logger that returns `false` (such as - /// [`NoopTransportLogger`]) keeps the common non-debug path free of those - /// clones. - fn enabled(&self) -> bool { - true - } -} - -/// Logger that intentionally drops transport events. -#[derive(Clone, Debug, Default)] -pub struct NoopTransportLogger; - -impl TransportLogger for NoopTransportLogger { - fn debug(&self, _event: &TransportLogEvent) {} - - fn enabled(&self) -> bool { - false - } -} - -/// Authenticated HTTP client for CLI command implementations. -/// -/// The client covers the transport behavior command authors usually need: auth -/// injection, JSON request/response helpers, structured HTTP errors, -/// idempotent retries, ETag helpers, raw streaming helpers, multipart helpers, -/// and GraphQL envelope decoding. -#[derive(Clone, Debug)] -pub struct HttpClient { - base: reqwest::Client, - base_url: String, - auth: Arc, - user_agent: String, - default_headers: BTreeMap, - logger: Arc, -} - -/// Builder for [`HttpClient`]. -#[derive(Clone, Debug)] -pub struct HttpClientBuilder { - base_url: String, - auth: Arc, - user_agent: String, - default_headers: BTreeMap, - logger: Arc, -} - -impl HttpClientBuilder { - /// Creates a builder with a base URL and auth injector. - #[must_use] - pub fn new(base_url: impl Into, auth: Arc) -> Self { - Self { - base_url: base_url.into(), - auth, - user_agent: default_user_agent(), - default_headers: BTreeMap::new(), - logger: default_transport_logger(), - } - } - - /// Sets the user-agent for this client. - #[must_use] - pub fn user_agent(mut self, user_agent: impl Into) -> Self { - self.user_agent = user_agent.into(); - self - } - - /// Alias for [`HttpClientBuilder::user_agent`] for migration readability. - #[must_use] - pub fn with_user_agent(self, user_agent: impl Into) -> Self { - self.user_agent(user_agent) - } - - /// Sets headers sent on every request. - #[must_use] - pub fn default_headers(mut self, headers: BTreeMap) -> Self { - self.default_headers = headers; - self - } - - /// Alias for [`HttpClientBuilder::default_headers`] for migration readability. - #[must_use] - pub fn with_default_headers(self, headers: BTreeMap) -> Self { - self.default_headers(headers) - } - - /// Sets the transport debug logger. - #[must_use] - pub fn logger(mut self, logger: Arc) -> Self { - self.logger = logger; - self - } - - /// Alias for [`HttpClientBuilder::logger`] for migration readability. - #[must_use] - pub fn with_logger(self, logger: Arc) -> Self { - self.logger(logger) - } - - /// Builds the client. - #[must_use] - pub fn build(self) -> HttpClient { - HttpClient { - base: reqwest::Client::new(), - base_url: self.base_url, - auth: self.auth, - user_agent: self.user_agent, - default_headers: self.default_headers, - logger: self.logger, - } - } -} +use super::{ + BASE_BACKOFF, GraphQlEnvelope, HttpClient, HttpClientBuilder, MAX_RETRIES, TransportLogEvent, + header_pairs, is_idempotent, parse_error_body, retryable_status, +}; +use crate::{CliCoreError, Result, transport::AuthInjector}; impl HttpClient { /// Creates a client builder. @@ -1230,55 +934,3 @@ impl HttpClient { } } } - -/// Converts a `reqwest` header map into owned name/value pairs for logging. -/// -/// Header values that are not valid UTF-8 are rendered as a byte-count -/// placeholder rather than dropped, so the trace still shows the header exists. -fn header_pairs(headers: &header::HeaderMap) -> Vec<(String, String)> { - headers - .iter() - .map(|(name, value)| { - let value = value.to_str().map_or_else( - |_| format!("<{} non-utf8 bytes>", value.as_bytes().len()), - str::to_owned, - ); - (name.as_str().to_owned(), value) - }) - .collect() -} - -/// Converts a non-success HTTP response into the shared transport error shape. -/// -/// If the response body already contains an API-style error document, the -/// service message is preserved and the HTTP status is normalized into the -/// error code. Otherwise the method, path, status, and response body are folded -/// into a readable fallback message. -pub async fn parse_error_response(response: reqwest::Response, method: &str, path: &str) -> Error { - let status = response.status(); - let body = response.text().await.unwrap_or_default(); - parse_error_body(status, &body, method, path) -} - -fn parse_error_body(status: StatusCode, body: &str, method: &str, path: &str) -> Error { - if let Ok(mut api_error) = serde_json::from_str::(body) - && !api_error.message.is_empty() - { - api_error.code = format!("HTTP_{}", status.as_u16()); - return api_error; - } - Error { - code: format!("HTTP_{}", status.as_u16()), - message: format!("{} {}: {} {}", method, path, status.as_u16(), body), - system: String::new(), - request_id: String::new(), - } -} - -fn retryable_status(method: Method, status: StatusCode) -> bool { - status == StatusCode::TOO_MANY_REQUESTS || (status.is_server_error() && is_idempotent(&method)) -} - -fn is_idempotent(method: &Method) -> bool { - matches!(*method, Method::GET | Method::HEAD | Method::DELETE) -} diff --git a/cli-engine/src/transport/client/mod.rs b/cli-engine/src/transport/client/mod.rs new file mode 100644 index 0000000..7d34c41 --- /dev/null +++ b/cli-engine/src/transport/client/mod.rs @@ -0,0 +1,360 @@ +use std::{ + collections::BTreeMap, + sync::{Arc, OnceLock, RwLock}, + time::Duration, +}; + +use reqwest::{StatusCode, header}; +use serde_json::Value; + +use super::{AuthInjector, Error}; + +mod methods; + +const MAX_RETRIES: usize = 3; +const BASE_BACKOFF: Duration = Duration::from_millis(500); +const BUILTIN_DEFAULT_USER_AGENT: &str = "cli/dev"; +static DEFAULT_USER_AGENT: OnceLock> = OnceLock::new(); + +/// Sets the process-wide default user-agent for outbound requests. +/// +/// Applies to subsequently created [`HttpClient`] values (those that do not set +/// their own via [`HttpClientBuilder::user_agent`]) and to the engine's other +/// outbound token traffic that reads this default — the PKCE provider's +/// token/refresh requests and the client-credentials injector. A per-client +/// user-agent still overrides it for that client. +pub fn set_default_user_agent(user_agent: impl Into) { + let lock = + DEFAULT_USER_AGENT.get_or_init(|| RwLock::new(BUILTIN_DEFAULT_USER_AGENT.to_owned())); + if let Ok(mut current) = lock.write() { + *current = user_agent.into(); + } +} + +/// Returns the process-wide default user-agent set via +/// [`set_default_user_agent`], or the builtin default when none was set. +/// +/// Used by [`HttpClientBuilder`] and by the engine's OAuth token requests so +/// that all outbound traffic carries the same user-agent. +pub(crate) fn default_user_agent() -> String { + DEFAULT_USER_AGENT + .get_or_init(|| RwLock::new(BUILTIN_DEFAULT_USER_AGENT.to_owned())) + .read() + .map_or_else( + |_| BUILTIN_DEFAULT_USER_AGENT.to_owned(), + |value| value.clone(), + ) +} + +/// Serializes unit tests that mutate the process-wide default user-agent so +/// they cannot observe one another's writes. Integration tests in +/// `tests/foundation.rs` run in a separate binary and use their own lock. +#[cfg(test)] +pub(crate) static UA_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +/// Restores the process-wide default user-agent to the builtin on drop, so a +/// panicking assertion in a test that mutates it cannot leak the value into +/// later tests in this binary. Declare it after acquiring [`UA_TEST_LOCK`] so +/// the reset runs while the lock is still held. +#[cfg(test)] +pub(crate) struct RestoreDefaultUserAgent; + +#[cfg(test)] +impl Drop for RestoreDefaultUserAgent { + fn drop(&mut self) { + set_default_user_agent(BUILTIN_DEFAULT_USER_AGENT); + } +} + +static DEFAULT_TRANSPORT_LOGGER: OnceLock>> = OnceLock::new(); + +fn default_transport_logger_lock() -> &'static RwLock> { + DEFAULT_TRANSPORT_LOGGER.get_or_init(|| RwLock::new(Arc::new(NoopTransportLogger))) +} + +/// Sets the process-wide default transport logger for outbound HTTP traffic. +/// +/// Applies to subsequently created [`HttpClient`] values (those that do not set +/// their own via [`HttpClientBuilder::logger`]) and to the free +/// [`super::debug_log_reqwest_request`] / [`super::debug_log_reqwest_response`] +/// helpers used by code that talks to `reqwest` directly. +/// +/// The CLI installs a logger from this setter when `--debug` selects the +/// `transport` component, so command handlers get request/response diagnostics +/// without any per-command wiring. A per-client logger still overrides it for +/// that client. +pub fn set_default_transport_logger(logger: Arc) { + // Recover from a poisoned lock (a panic while a writer held it) instead of + // silently doing nothing, which would leave a stale logger installed and + // make `--debug transport` appear ineffective. + let mut current = default_transport_logger_lock() + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner); + *current = logger; +} + +/// Returns the process-wide default transport logger set via +/// [`set_default_transport_logger`], or a [`NoopTransportLogger`] when none was +/// set. +#[must_use] +pub fn default_transport_logger() -> Arc { + default_transport_logger_lock() + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone() +} + +/// Logs a `reqwest::Request` to the process-wide default transport logger. +/// +/// This is the bridge for code that talks to `reqwest` directly — bare clients +/// or progenitor-generated clients that cannot use [`HttpClient`] — so a single +/// `--debug`-controlled trace can still cover them. Captures the request method, +/// URL, headers, and in-memory body. Pairs with [`debug_log_reqwest_response`]. +/// It is a no-op (no header clone or body copy) unless an enabled logger has +/// been installed via [`set_default_transport_logger`]. +pub fn debug_log_reqwest_request(request: &reqwest::Request) { + let logger = default_transport_logger(); + if !logger.enabled() { + return; + } + logger.debug(&TransportLogEvent { + message: "http request", + fields: BTreeMap::from([ + ("method".to_owned(), request.method().as_str().to_owned()), + ("url".to_owned(), request.url().as_str().to_owned()), + ]), + headers: Some(header_pairs(request.headers())), + body: request + .body() + .and_then(reqwest::Body::as_bytes) + .map(<[u8]>::to_vec), + }); +} + +/// Logs an HTTP response (status, headers, body) to the process-wide default +/// transport logger. +/// +/// Companion to [`debug_log_reqwest_request`] for `reqwest`-direct call sites. +/// The caller passes the already-read response body. It is a no-op (no header +/// clone or body copy) unless an enabled logger has been installed via +/// [`set_default_transport_logger`]. +pub fn debug_log_reqwest_response(status: StatusCode, headers: &header::HeaderMap, body: &[u8]) { + let logger = default_transport_logger(); + if !logger.enabled() { + return; + } + logger.debug(&TransportLogEvent { + message: "http response", + fields: BTreeMap::from([("status".to_owned(), status.as_u16().to_string())]), + headers: Some(header_pairs(headers)), + body: Some(body.to_vec()), + }); +} + +#[derive(serde::Deserialize)] +struct GraphQlError { + message: String, +} + +#[derive(Default, serde::Deserialize)] +struct GraphQlEnvelope { + data: Option, + #[serde(default)] + errors: Vec, +} + +/// Structured debug event emitted by [`TransportLogger`]. +/// +/// `message` and `fields` are the stable breadcrumb surface (method, url, +/// status, retry attempt). `headers` and `body` carry the raw, un-redacted +/// request or response payload when one is available; loggers that print these +/// (such as [`StderrTransportLogger`](super::StderrTransportLogger)) are +/// responsible for redacting sensitive headers. +#[derive(Clone, Debug, Default)] +pub struct TransportLogEvent { + /// Event name such as `http request` or `retrying request`. + pub message: &'static str, + /// Stable event fields. + pub fields: BTreeMap, + /// Raw header name/value pairs for the request or response, when known. + pub headers: Option>, + /// Raw request or response body bytes, when captured. Streaming and + /// byte-download responses omit this and report a `body_bytes` field + /// instead to avoid buffering large payloads into the log. + pub body: Option>, +} + +/// Debug logger interface for transport events. +pub trait TransportLogger: Send + Sync + std::fmt::Debug { + /// Records one debug event. + fn debug(&self, event: &TransportLogEvent); + + /// Whether this logger records anything. + /// + /// Defaults to `true`. The transport checks this before capturing request + /// and response headers/bodies, so a logger that returns `false` (such as + /// [`NoopTransportLogger`]) keeps the common non-debug path free of those + /// clones. + fn enabled(&self) -> bool { + true + } +} + +/// Logger that intentionally drops transport events. +#[derive(Clone, Debug, Default)] +pub struct NoopTransportLogger; + +impl TransportLogger for NoopTransportLogger { + fn debug(&self, _event: &TransportLogEvent) {} + + fn enabled(&self) -> bool { + false + } +} + +/// Authenticated HTTP client for CLI command implementations. +/// +/// The client covers the transport behavior command authors usually need: auth +/// injection, JSON request/response helpers, structured HTTP errors, +/// idempotent retries, ETag helpers, raw streaming helpers, multipart helpers, +/// and GraphQL envelope decoding. +#[derive(Clone, Debug)] +pub struct HttpClient { + base: reqwest::Client, + base_url: String, + auth: Arc, + user_agent: String, + default_headers: BTreeMap, + logger: Arc, +} + +/// Builder for [`HttpClient`]. +#[derive(Clone, Debug)] +pub struct HttpClientBuilder { + base_url: String, + auth: Arc, + user_agent: String, + default_headers: BTreeMap, + logger: Arc, +} + +impl HttpClientBuilder { + /// Creates a builder with a base URL and auth injector. + #[must_use] + pub fn new(base_url: impl Into, auth: Arc) -> Self { + Self { + base_url: base_url.into(), + auth, + user_agent: default_user_agent(), + default_headers: BTreeMap::new(), + logger: default_transport_logger(), + } + } + + /// Sets the user-agent for this client. + #[must_use] + pub fn user_agent(mut self, user_agent: impl Into) -> Self { + self.user_agent = user_agent.into(); + self + } + + /// Alias for [`HttpClientBuilder::user_agent`] for migration readability. + #[must_use] + pub fn with_user_agent(self, user_agent: impl Into) -> Self { + self.user_agent(user_agent) + } + + /// Sets headers sent on every request. + #[must_use] + pub fn default_headers(mut self, headers: BTreeMap) -> Self { + self.default_headers = headers; + self + } + + /// Alias for [`HttpClientBuilder::default_headers`] for migration readability. + #[must_use] + pub fn with_default_headers(self, headers: BTreeMap) -> Self { + self.default_headers(headers) + } + + /// Sets the transport debug logger. + #[must_use] + pub fn logger(mut self, logger: Arc) -> Self { + self.logger = logger; + self + } + + /// Alias for [`HttpClientBuilder::logger`] for migration readability. + #[must_use] + pub fn with_logger(self, logger: Arc) -> Self { + self.logger(logger) + } + + /// Builds the client. + #[must_use] + pub fn build(self) -> HttpClient { + HttpClient { + base: reqwest::Client::new(), + base_url: self.base_url, + auth: self.auth, + user_agent: self.user_agent, + default_headers: self.default_headers, + logger: self.logger, + } + } +} + +/// Converts a `reqwest` header map into owned name/value pairs for logging. +/// +/// Header values that are not valid UTF-8 are rendered as a byte-count +/// placeholder rather than dropped, so the trace still shows the header exists. +fn header_pairs(headers: &header::HeaderMap) -> Vec<(String, String)> { + headers + .iter() + .map(|(name, value)| { + let value = value.to_str().map_or_else( + |_| format!("<{} non-utf8 bytes>", value.as_bytes().len()), + str::to_owned, + ); + (name.as_str().to_owned(), value) + }) + .collect() +} + +/// Converts a non-success HTTP response into the shared transport error shape. +/// +/// If the response body already contains an API-style error document, the +/// service message is preserved and the HTTP status is normalized into the +/// error code. Otherwise the method, path, status, and response body are folded +/// into a readable fallback message. +pub async fn parse_error_response(response: reqwest::Response, method: &str, path: &str) -> Error { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + parse_error_body(status, &body, method, path) +} + +fn parse_error_body(status: StatusCode, body: &str, method: &str, path: &str) -> Error { + if let Ok(mut api_error) = serde_json::from_str::(body) + && !api_error.message.is_empty() + { + api_error.code = format!("HTTP_{}", status.as_u16()); + return api_error; + } + Error { + code: format!("HTTP_{}", status.as_u16()), + message: format!("{} {}: {} {}", method, path, status.as_u16(), body), + system: String::new(), + request_id: String::new(), + } +} + +fn retryable_status(method: reqwest::Method, status: StatusCode) -> bool { + status == StatusCode::TOO_MANY_REQUESTS || (status.is_server_error() && is_idempotent(&method)) +} + +fn is_idempotent(method: &reqwest::Method) -> bool { + matches!( + *method, + reqwest::Method::GET | reqwest::Method::HEAD | reqwest::Method::DELETE + ) +}