Skip to main content

a2acli/
lib.rs

1// Copyright AGNTCY Contributors (https://github.com/agntcy)
2// SPDX-License-Identifier: Apache-2.0
3use std::collections::{HashMap, HashSet};
4use std::ffi::OsString;
5use std::path::{Path, PathBuf};
6use std::sync::Arc;
7use std::time::Duration;
8
9use a2a::*;
10use a2a_client::auth::AuthInterceptor;
11use a2a_client::{A2AClient, A2AClientFactory, BoxStream};
12use clap::parser::ValueSource;
13use clap::{ArgMatches, Args, CommandFactory, FromArgMatches, Parser, Subcommand, ValueEnum};
14use futures::StreamExt;
15use reqwest::{Client, RequestBuilder};
16use serde::Serialize;
17use thiserror::Error;
18
19#[derive(Debug, Clone, Parser, PartialEq, Eq)]
20#[command(
21    name = "a2acli",
22    version,
23    about = "Standalone A2A client CLI",
24    after_help = "\
25Default behavior (SPEC.md §6.5):
26  - Transport: the agent card's first supported interface (--transport overrides)
27  - Task completion: wait until terminal/interrupted state (--async returns immediately)
28  - Output: human-readable text (-o/--output json switches to protocol JSON)
29  - Protocol version: highest shared 1.x, from the agent card (--a2a-version pins it)
30  - Transport security: TLS verification on (--insecure disables it, with a warning)"
31)]
32pub struct Cli {
33    /// Load configuration from an explicit .env file in place of the local
34    /// .env (found by walking up from the working directory). Real
35    /// environment variables still take precedence over it (§6.5).
36    #[arg(long, global = true)]
37    pub config: Option<String>,
38
39    /// The agent to talk to, as an Agent Card reference (§7.2): a bare host
40    /// or origin (the well-known path is appended), a full card URL (used
41    /// as-is), or a local file path (`file://…` or a plain path).
42    #[arg(
43        short = 'a',
44        long = "agent-card",
45        global = true,
46        value_name = "REF",
47        env = "A2ACLI_AGENT_CARD"
48    )]
49    pub agent_card: Option<String>,
50
51    /// Connect straight to an agent interface URL, skipping Agent Card
52    /// resolution (§7.2). MUST be used with exactly one --transport, since
53    /// there is no card to name the binding. Mutually exclusive with
54    /// --agent-card.
55    #[arg(
56        short = 'e',
57        long,
58        global = true,
59        value_name = "REF",
60        env = "A2ACLI_ENDPOINT"
61    )]
62    pub endpoint: Option<String>,
63
64    /// Deprecated alias for the bare-origin form of --agent-card, kept so
65    /// pre-#178 invocations keep working. Hidden: `--agent-card` is the
66    /// flag the specification defines.
67    #[arg(
68        long,
69        global = true,
70        hide = true,
71        default_value = "http://localhost:3000",
72        env = "A2ACLI_BASE_URL"
73    )]
74    pub base_url: String,
75
76    /// Client transport preference, repeatable and ordered (highest first):
77    /// e.g. `--transport jsonrpc --transport rest`. Overrides the agent
78    /// card's own preference order; a binding the card doesn't offer is
79    /// skipped.
80    #[arg(
81        long,
82        global = true,
83        value_enum,
84        env = "A2ACLI_TRANSPORT",
85        value_delimiter = ','
86    )]
87    pub transport: Vec<Binding>,
88
89    /// Bearer token attached to the agent-card fetch and client calls.
90    /// Protocol version to signal to the server on every request (§13.2).
91    /// Absent this flag the version is negotiated down to the highest one
92    /// both a2acli and the agent's selected interface declare. Must be
93    /// 1.x: A2A reads an empty or pre-1.0 value as 0.3.
94    #[arg(long = "a2a-version", global = true, env = "A2ACLI_A2A_VERSION")]
95    pub a2a_version: Option<String>,
96
97    #[arg(long, global = true, env = "A2ACLI_BEARER")]
98    pub bearer: Option<String>,
99
100    /// API key attached to the agent-card fetch and client calls (as an
101    /// `X-API-Key` header — a2acli does not yet read the agent card's
102    /// declared security scheme to place it elsewhere per A2A §4.5.2).
103    #[arg(long = "api-key", global = true, env = "A2ACLI_API_KEY")]
104    pub api_key: Option<String>,
105
106    /// Add an A2A service parameter (a transport-level key-value pair, e.g.
107    /// a header or gRPC metadata) — general-purpose, not authentication-
108    /// specific. Distinct from `--metadata`, which travels in the request
109    /// payload.
110    #[arg(long = "svc-param", global = true, value_parser = parse_header)]
111    pub svc_params: Vec<HeaderArg>,
112
113    /// Disable TLS certificate verification for the negotiated transport.
114    /// Development only — always prints a warning, and never disables
115    /// verification silently.
116    #[arg(long, global = true, env = "A2ACLI_INSECURE")]
117    pub insecure: bool,
118
119    /// Verbose diagnostics to stderr: request/response timing and outcome
120    /// for each call. Never includes credential material (bearer token,
121    /// API key, or any --svc-param value), and that redaction cannot be
122    /// defeated by this or any other verbosity flag.
123    #[arg(long, global = true, env = "A2ACLI_DEBUG")]
124    pub debug: bool,
125
126    /// Optional tenant forwarded to A2A requests that support it. Overrides
127    /// the routing tenant the selected Agent Card interface may itself
128    /// declare (A2A §8.3.2); omit this to use the interface's own value,
129    /// if it has one.
130    #[arg(long, global = true, env = "A2ACLI_TENANT")]
131    pub tenant: Option<String>,
132
133    /// Output format: human-readable `text` (default) or the protocol's own
134    /// `json` types. Cardinality (one document vs. JSONL) follows `--stream`,
135    /// not this flag (§11.3).
136    #[arg(
137        short = 'o',
138        long,
139        global = true,
140        value_enum,
141        default_value_t = OutputFormat::Text,
142        env = "A2ACLI_OUTPUT"
143    )]
144    pub output: OutputFormat,
145
146    /// With `-o json` (and no `--stream`), emit compact JSON instead of
147    /// pretty-printed JSON. Has no effect on `-o text` or on `--stream`
148    /// JSONL, which is always one compact object per line regardless of
149    /// this flag (§11.3).
150    #[arg(long, global = true, env = "A2ACLI_COMPACT")]
151    pub compact: bool,
152
153    /// Do not wait: return the task identifiers immediately instead of blocking
154    /// until the task reaches a terminal or interrupted state (the default).
155    ///
156    /// Note: the spec lists `--return-immediately` as an OPTIONAL alias for
157    /// this flag, but that name is already this CLI's flag for the distinct
158    /// protocol-level `SendMessageConfiguration.return_immediately` (a
159    /// request to the *agent* to return early on queued work, not a
160    /// client-side "don't poll" instruction) — so it is deliberately not
161    /// reused here to avoid conflating the two.
162    #[arg(
163        long = "async",
164        alias = "no-wait",
165        global = true,
166        conflicts_with = "wait",
167        env = "A2ACLI_ASYNC"
168    )]
169    pub async_mode: bool,
170
171    /// Explicitly block until the task reaches a terminal or interrupted state.
172    /// This is already the default for `send`; on `task get` it turns the
173    /// one-shot read into a poll loop.
174    #[arg(
175        long,
176        global = true,
177        conflicts_with = "async_mode",
178        env = "A2ACLI_WAIT"
179    )]
180    pub wait: bool,
181
182    /// Delay between polls while waiting for a task to settle (e.g. "2s", "500ms").
183    #[arg(long, global = true, value_parser = parse_duration, default_value = "2s", env = "A2ACLI_POLL_INTERVAL")]
184    pub poll_interval: Duration,
185
186    /// Overall time budget for a blocking wait before reporting a timeout (e.g. "30s", "2m").
187    #[arg(long, global = true, value_parser = parse_duration, default_value = "30s", env = "A2ACLI_TIMEOUT")]
188    pub timeout: Duration,
189
190    #[command(subcommand)]
191    pub command: Command,
192}
193
194#[derive(Debug, Clone, Subcommand, PartialEq, Eq)]
195pub enum Command {
196    /// Agent Card operations.
197    Card {
198        #[command(subcommand)]
199        command: CardCommand,
200    },
201    /// Configuration inspection.
202    Config {
203        #[command(subcommand)]
204        command: ConfigCommand,
205    },
206    /// Send a message to start or continue an interaction.
207    Send(MessageCommand),
208    /// Task operations.
209    Task {
210        #[command(subcommand)]
211        command: TaskCommand,
212    },
213}
214
215#[derive(Debug, Clone, Subcommand, PartialEq, Eq)]
216pub enum CardCommand {
217    /// Fetch and print the Agent Card. Pass --extended for the authenticated extended card.
218    Get(CardGetCommand),
219}
220
221#[derive(Debug, Clone, Subcommand, PartialEq, Eq)]
222pub enum ConfigCommand {
223    /// Print each effective setting and the source it resolved from
224    /// (flag, environment variable, local .env file, global .env file, or
225    /// built-in default). Read-only: credential values are redacted, and
226    /// this command never edits configuration itself (§8.3).
227    Show,
228}
229
230#[derive(Debug, Clone, Args, PartialEq, Eq)]
231pub struct CardGetCommand {
232    /// Fetch the authenticated extended agent card instead of the public one.
233    #[arg(long)]
234    pub extended: bool,
235}
236
237#[derive(Debug, Clone, Subcommand, PartialEq, Eq)]
238pub enum TaskCommand {
239    /// Fetch a task by ID.
240    Get(TaskLookupCommand),
241    /// List tasks with optional filters.
242    List(ListTasksCommand),
243    /// Cancel a task by ID.
244    Cancel(TaskIdCommand),
245    /// Subscribe to task updates and print each event as it arrives.
246    Subscribe(TaskIdCommand),
247    /// Manage push notification configs for a task.
248    PushConfig {
249        #[command(subcommand)]
250        command: PushConfigCommand,
251    },
252}
253
254#[derive(Debug, Clone, Args, PartialEq, Eq)]
255pub struct MessageCommand {
256    /// Text payload to send as the user message. Shorthand for a single
257    /// --text-part when no other part flags are given; mutually exclusive
258    /// with --text-part/--file-part/--data-part.
259    pub text: Option<String>,
260
261    /// Add a text part. Repeatable and order-preserving alongside
262    /// --file-part/--data-part.
263    #[arg(long = "text-part")]
264    pub text_parts: Vec<String>,
265
266    /// Add a file part. A local path is inlined as bytes; a URL is carried
267    /// by reference and never fetched by the CLI. Repeatable.
268    #[arg(long = "file-part")]
269    pub file_parts: Vec<String>,
270
271    /// Add a structured JSON data part, read from a file path, parsed from
272    /// an inline JSON string, or "-" to read JSON from stdin. Repeatable.
273    #[arg(long = "data-part")]
274    pub data_parts: Vec<String>,
275
276    /// Media type for the part flag immediately preceding it (--file-part or
277    /// --data-part). Usage error if it follows no part flag.
278    #[arg(long = "media-type")]
279    pub media_types: Vec<String>,
280
281    /// Optional context identifier to continue an existing conversation.
282    #[arg(long, env = "A2ACLI_CONTEXT_ID")]
283    pub context_id: Option<String>,
284
285    /// Optional task identifier to continue an existing task.
286    #[arg(long, env = "A2ACLI_TASK_ID")]
287    pub task_id: Option<String>,
288
289    /// Ask the server to include up to this many history items in task responses.
290    #[arg(long)]
291    pub history_length: Option<i32>,
292
293    /// Accepted output mode, for example text/plain or application/json.
294    #[arg(long = "accept-output")]
295    pub accepted_output_modes: Vec<String>,
296
297    /// Ask the server to return immediately when it supports queued work.
298    #[arg(long)]
299    pub return_immediately: bool,
300
301    /// Use the streaming send operation and print each event as it arrives.
302    #[arg(long)]
303    pub stream: bool,
304}
305
306#[derive(Debug, Clone, Args, PartialEq, Eq)]
307pub struct TaskLookupCommand {
308    /// Task identifier.
309    pub id: String,
310
311    /// Ask the server to include up to this many history items.
312    #[arg(long)]
313    pub history_length: Option<i32>,
314}
315
316#[derive(Debug, Clone, Args, PartialEq, Eq)]
317pub struct ListTasksCommand {
318    /// Filter by context identifier.
319    #[arg(long)]
320    pub context_id: Option<String>,
321
322    /// Filter by task state.
323    #[arg(long, value_enum)]
324    pub status: Option<TaskStateArg>,
325
326    /// Requested page size.
327    #[arg(long)]
328    pub page_size: Option<i32>,
329
330    /// Page token from a previous response.
331    #[arg(long)]
332    pub page_token: Option<String>,
333
334    /// Ask the server to include up to this many history items per task.
335    #[arg(long)]
336    pub history_length: Option<i32>,
337
338    /// Ask the server to include artifacts in the listed tasks.
339    #[arg(long)]
340    pub include_artifacts: bool,
341}
342
343#[derive(Debug, Clone, Args, PartialEq, Eq)]
344pub struct TaskIdCommand {
345    /// Task identifier.
346    pub id: String,
347}
348
349#[derive(Debug, Clone, Subcommand, PartialEq, Eq)]
350pub enum PushConfigCommand {
351    /// Create a push notification config for a task.
352    Create(CreatePushConfigCommand),
353    /// Fetch a push notification config by ID.
354    Get(PushConfigIdCommand),
355    /// List push notification configs for a task.
356    List(ListPushConfigsCommand),
357    /// Delete a push notification config by ID.
358    Delete(PushConfigIdCommand),
359}
360
361#[derive(Debug, Clone, Args, PartialEq, Eq)]
362pub struct CreatePushConfigCommand {
363    /// Task identifier.
364    pub task_id: String,
365
366    /// Callback URL that will receive push notifications.
367    pub url: String,
368
369    /// Optional push config identifier.
370    #[arg(long = "config-id")]
371    pub config_id: Option<String>,
372
373    /// Optional push notification token.
374    #[arg(long)]
375    pub token: Option<String>,
376
377    /// Optional authentication scheme, for example Bearer.
378    #[arg(long = "auth-scheme")]
379    pub auth_scheme: Option<String>,
380
381    /// Optional authentication credentials.
382    #[arg(long = "auth-credentials")]
383    pub auth_credentials: Option<String>,
384}
385
386#[derive(Debug, Clone, Args, PartialEq, Eq)]
387pub struct PushConfigIdCommand {
388    /// Task identifier.
389    pub task_id: String,
390
391    /// Push config identifier.
392    pub id: String,
393}
394
395#[derive(Debug, Clone, Args, PartialEq, Eq)]
396pub struct ListPushConfigsCommand {
397    /// Task identifier.
398    pub task_id: String,
399
400    /// Requested page size.
401    #[arg(long)]
402    pub page_size: Option<i32>,
403
404    /// Page token from a previous response.
405    #[arg(long)]
406    pub page_token: Option<String>,
407}
408
409#[derive(Debug, Clone, PartialEq, Eq)]
410pub struct HeaderArg {
411    pub name: String,
412    pub value: String,
413}
414
415/// Output format (§6.5, §11.2, §11.3). `Text` is the default, human-readable
416/// floor; `Json` emits the protocol's own response types, one document or
417/// JSONL depending on `--stream`.
418#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
419pub enum OutputFormat {
420    Text,
421    Json,
422}
423
424#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
425pub enum Binding {
426    Jsonrpc,
427    /// The A2A HTTP+JSON binding — spelled `rest` on the command line to
428    /// match the reference implementation's `--transport` vocabulary.
429    #[value(name = "rest")]
430    Rest,
431}
432
433impl Binding {
434    fn protocol(self) -> &'static str {
435        match self {
436            Binding::Jsonrpc => TRANSPORT_PROTOCOL_JSONRPC,
437            Binding::Rest => TRANSPORT_PROTOCOL_HTTP_JSON,
438        }
439    }
440}
441
442#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
443pub enum TaskStateArg {
444    Unspecified,
445    Submitted,
446    Working,
447    Completed,
448    Failed,
449    Canceled,
450    InputRequired,
451    Rejected,
452    AuthRequired,
453}
454
455impl From<TaskStateArg> for TaskState {
456    fn from(value: TaskStateArg) -> Self {
457        match value {
458            TaskStateArg::Unspecified => TaskState::Unspecified,
459            TaskStateArg::Submitted => TaskState::Submitted,
460            TaskStateArg::Working => TaskState::Working,
461            TaskStateArg::Completed => TaskState::Completed,
462            TaskStateArg::Failed => TaskState::Failed,
463            TaskStateArg::Canceled => TaskState::Canceled,
464            TaskStateArg::InputRequired => TaskState::InputRequired,
465            TaskStateArg::Rejected => TaskState::Rejected,
466            TaskStateArg::AuthRequired => TaskState::AuthRequired,
467        }
468    }
469}
470
471#[derive(Debug, Error)]
472pub enum CliError {
473    #[error(transparent)]
474    A2A(#[from] A2AError),
475    #[error("http request failed: {0}")]
476    Http(#[from] reqwest::Error),
477    #[error("failed to serialize output: {0}")]
478    Json(#[from] serde_json::Error),
479    #[error("invalid input: {0}")]
480    InvalidInput(String),
481    #[error("failed to read {path}: {source}")]
482    ReadFile {
483        path: String,
484        #[source]
485        source: std::io::Error,
486    },
487    #[error("timed out after {timeout:?} waiting for task {task_id} to settle")]
488    Timeout { task_id: String, timeout: Duration },
489    /// A local Agent Card file that couldn't be read — the file-path
490    /// counterpart of a non-2xx card fetch.
491    #[error("failed to read agent card file {path}: {source}")]
492    CardFile {
493        path: String,
494        #[source]
495        source: std::io::Error,
496    },
497    /// A local Agent Card file that was read but isn't an Agent Card — the
498    /// counterpart of a response body that won't deserialize.
499    #[error("agent card file is not a valid agent card: {0}")]
500    CardInvalid(String),
501    /// A malformed invocation, as clap describes it. §11.4 counts a
502    /// malformed flag among the CLI-local failures that must still be
503    /// machine-readable, so clap's prose becomes the envelope's message
504    /// rather than being printed in its place.
505    #[error("{0}")]
506    Usage(String),
507}
508
509/// A2A §5.3: where an agent publishes its card, appended to a reference
510/// that names only a host or origin.
511const WELL_KNOWN_AGENT_CARD_PATH: &str = "/.well-known/agent-card.json";
512
513/// The Appendix B error envelope: the one result shape this specification
514/// defines of its own, for a failure that never reached the protocol.
515#[derive(Debug, Clone, Serialize)]
516struct ErrorEnvelope {
517    error: ErrorDetail,
518}
519
520#[derive(Debug, Clone, Serialize)]
521struct ErrorDetail {
522    code: String,
523    message: String,
524    #[serde(skip_serializing_if = "Option::is_none")]
525    hint: Option<String>,
526    #[serde(rename = "a2aCode", skip_serializing_if = "Option::is_none")]
527    a2a_code: Option<i64>,
528}
529
530impl CliError {
531    /// The exit status this failure maps to (§11.6, Appendix D). `0` is
532    /// never returned here — success never constructs a `CliError`.
533    pub fn exit_code(&self) -> i32 {
534        match self {
535            CliError::A2A(_) => 1,
536            CliError::Http(error) => http_error_exit_code(error),
537            CliError::Json(_) => 1,
538            CliError::InvalidInput(_) => 2,
539            CliError::ReadFile { .. } => 2,
540            CliError::Timeout { .. } => 5,
541            // Appendix D: the same statuses the HTTP card path uses, so a
542            // caller branching on the exit code needn't know whether the
543            // card came from a URL or a file.
544            CliError::CardFile { .. } => 3,
545            CliError::CardInvalid(_) => 1,
546            CliError::Usage(_) => 2,
547        }
548    }
549
550    /// The Appendix B error envelope for this failure: a protocol failure
551    /// carries the A2A error name unchanged (§11.4); a CLI-local failure
552    /// carries an `A2ACLI_ERR_*` symbol from Appendix D.
553    fn envelope(&self) -> ErrorEnvelope {
554        let (code, hint, a2a_code) = match self {
555            CliError::A2A(error) => (
556                error_reason(error.code).to_string(),
557                None,
558                Some(error.code as i64),
559            ),
560            CliError::Http(error) => http_error_code_and_hint(error),
561            CliError::Json(_) => ("A2ACLI_ERR_INTERNAL".to_string(), None, None),
562            CliError::InvalidInput(_) => ("A2ACLI_ERR_USAGE".to_string(), None, None),
563            CliError::ReadFile { .. } => (
564                "A2ACLI_ERR_USAGE".to_string(),
565                Some(
566                    "check that the --file-part/--data-part path exists and is readable"
567                        .to_string(),
568                ),
569                None,
570            ),
571            CliError::CardFile { .. } => (
572                "A2ACLI_ERR_CARD_NOT_FOUND".to_string(),
573                Some("check that the --agent-card path exists and is readable".to_string()),
574                None,
575            ),
576            CliError::Usage(_) => (
577                "A2ACLI_ERR_USAGE".to_string(),
578                Some("run `a2acli --help`, or `a2acli <command> --help`".to_string()),
579                None,
580            ),
581            CliError::CardInvalid(_) => (
582                "A2ACLI_ERR_CARD_INVALID".to_string(),
583                Some("the agent card did not match the expected schema".to_string()),
584                None,
585            ),
586            CliError::Timeout { .. } => (
587                "A2ACLI_ERR_TIMEOUT".to_string(),
588                Some(
589                    "increase --timeout/--poll-interval, or check the task later with `task get <id> --wait`"
590                        .to_string(),
591                ),
592                None,
593            ),
594        };
595
596        ErrorEnvelope {
597            error: ErrorDetail {
598                code,
599                message: self.to_string(),
600                hint,
601                a2a_code,
602            },
603        }
604    }
605
606    /// Print the Appendix B error envelope to stderr as one compact JSON
607    /// line, in every output mode: §11.4 requires errors to be
608    /// machine-readable unconditionally, not only under `-o json`.
609    pub fn report(&self) {
610        match serde_json::to_string(&self.envelope()) {
611            Ok(json) => eprintln!("{json}"),
612            Err(_) => eprintln!(
613                "{{\"error\":{{\"code\":\"A2ACLI_ERR_INTERNAL\",\"message\":{:?}}}}}",
614                self.to_string()
615            ),
616        }
617    }
618}
619
620/// `reqwest::Error` from resolving the Agent Card (the only place it's
621/// produced) classified against Appendix D: a connection/DNS/TLS failure is
622/// `UNREACHABLE`; a non-2xx response is `CARD_NOT_FOUND` (or `AUTH_FAILED`
623/// for 401/403); a body that failed to deserialize as an `AgentCard` is
624/// `CARD_INVALID`.
625fn http_error_code_and_hint(error: &reqwest::Error) -> (String, Option<String>, Option<i64>) {
626    if error.is_decode() {
627        return (
628            "A2ACLI_ERR_CARD_INVALID".to_string(),
629            Some("the agent card did not match the expected schema".to_string()),
630            None,
631        );
632    }
633
634    if let Some(status) = error.status() {
635        if status.as_u16() == 401 || status.as_u16() == 403 {
636            return (
637                "A2ACLI_ERR_AUTH_FAILED".to_string(),
638                Some("credentials were rejected fetching the agent card".to_string()),
639                None,
640            );
641        }
642        return (
643            "A2ACLI_ERR_CARD_NOT_FOUND".to_string(),
644            Some("check --agent-card/--base-url resolves to a valid agent".to_string()),
645            None,
646        );
647    }
648
649    (
650        "A2ACLI_ERR_UNREACHABLE".to_string(),
651        Some("check --agent-card/--endpoint and network connectivity".to_string()),
652        None,
653    )
654}
655
656fn http_error_exit_code(error: &reqwest::Error) -> i32 {
657    if error.is_decode() {
658        return 1;
659    }
660    if let Some(status) = error.status() {
661        return if status.as_u16() == 401 || status.as_u16() == 403 {
662            4
663        } else {
664            3
665        };
666    }
667    3
668}
669
670/// Parse `args` the same way the real binary parses `std::env::args_os()`, then
671/// run the resulting command. Kept separate from [`run`] because recovering
672/// the interleaved order of `send`'s repeatable part flags
673/// (`--text-part`/`--file-part`/`--data-part`/`--media-type`) needs the raw
674/// [`ArgMatches`], which `Cli::parse()` alone discards.
675pub async fn run_args<I, T>(args: I) -> Result<(), CliError>
676where
677    I: IntoIterator<Item = T>,
678    T: Into<OsString>,
679{
680    let args: Vec<OsString> = args.into_iter().map(Into::into).collect();
681    // Must happen before parsing: this is how a local/global .env value
682    // becomes visible to clap's own `env = "A2ACLI_..."` resolution (§8.3).
683    let dotenv_provenance = load_dotenv_config(&args);
684    // `try_get_matches_from` rather than `get_matches_from`: the latter
685    // prints clap's prose and exits inside clap, which §11.4 forbids for a
686    // malformed flag — that is a CLI-local failure and must still be
687    // machine-readable.
688    let matches = match Cli::command().try_get_matches_from(args) {
689        Ok(matches) => matches,
690        Err(error) => return Err(clap_error_to_cli_error(error)),
691    };
692    let cli = Cli::from_arg_matches(&matches).expect("matches were produced by Cli::command()");
693    run(cli, &matches, &dotenv_provenance).await
694}
695
696/// clap reports `--help` and `--version` through the same `Err` channel as a
697/// parse failure, so they have to be told apart: those are successful
698/// requests for output (`A2ACLI_CLI_001`) and keep going to stdout with exit
699/// `0`, while everything else is a usage error carrying the Appendix B
700/// envelope.
701fn clap_error_to_cli_error(error: clap::Error) -> CliError {
702    use clap::error::ErrorKind;
703
704    if matches!(
705        error.kind(),
706        ErrorKind::DisplayHelp | ErrorKind::DisplayVersion
707    ) {
708        // Not failures: the caller asked for this output (A2ACLI_CLI_001).
709        // Delegating to clap's own `exit()` keeps them byte-for-byte what
710        // `get_matches_from` produced, since that is just
711        // `try_get_matches_from(..).unwrap_or_else(|e| e.exit())`.
712        error.exit();
713    }
714
715    // `DisplayHelpOnMissingArgumentOrSubcommand` — a bare `a2acli`, or a
716    // group like `a2acli task` with no subcommand — is *not* in
717    // A2ACLI_CLI_001's list of output requests: clap already treats it as a
718    // usage failure and exits 2. So it carries the envelope like every
719    // other usage error rather than being the one that prints prose, and
720    // the hint points at `--help` for the usage text it replaces.
721
722    CliError::Usage(flatten_clap_message(&error.render().to_string()))
723}
724
725/// clap renders a multi-line message: the error line, a blank line, then a
726/// usage block. Keep the part that names what was wrong, as one line, since
727/// §11.4 wants a single JSON object and the usage block is guidance the
728/// `hint` already points at.
729fn flatten_clap_message(rendered: &str) -> String {
730    // Each line is trimmed on both sides before joining: clap indents
731    // continuation lines, and keeping that indentation would leave runs of
732    // spaces inside the JSON message.
733    let first_block: Vec<&str> = rendered
734        .lines()
735        .map(str::trim)
736        .take_while(|line| !line.is_empty())
737        .collect();
738    let joined = first_block.join(" ");
739    let message = if joined.is_empty() {
740        rendered.trim()
741    } else {
742        joined.as_str()
743    };
744    message
745        .strip_prefix("error: ")
746        .unwrap_or(message)
747        .to_string()
748}
749
750pub async fn run(
751    cli: Cli,
752    matches: &ArgMatches,
753    dotenv_provenance: &HashMap<String, ConfigSource>,
754) -> Result<(), CliError> {
755    if cli.debug {
756        // --debug: verbose diagnostics to stderr (§7.2). try_init() rather
757        // than init() because a process embedding `run` more than once
758        // (e.g. this crate's own in-process tests) would otherwise panic
759        // on a second global-subscriber install.
760        let _ = tracing_subscriber::fmt()
761            .with_writer(std::io::stderr)
762            .with_target(false)
763            .try_init();
764    }
765
766    // §11.6: a bad flag is reported before any network work, so a usage
767    // error never depends on a reachable agent.
768    validate_a2a_version(cli.a2a_version.as_deref())?;
769
770    match &cli.command {
771        Command::Card { command } => run_card_command(&cli, matches, command).await?,
772        Command::Config { command } => {
773            run_config_command(&cli, matches, dotenv_provenance, command)?
774        }
775        Command::Send(command) => {
776            let send_matches = matches
777                .subcommand_matches("send")
778                .expect("send subcommand matches present when cli.command is Command::Send");
779            let parts = resolve_message_parts(send_matches, command)?;
780            let ResolvedClient { client, tenant } = resolve_client(&cli, matches).await?;
781            let request = build_send_message_request(command, parts, tenant.clone());
782
783            if command.stream {
784                match client.send_streaming_message(&request).await {
785                    Ok(stream) => {
786                        consume_stream(client, stream, &cli).await?;
787                    }
788                    Err(error) if error.code == a2a::error_code::UNSUPPORTED_OPERATION => {
789                        // The agent doesn't advertise the streaming
790                        // capability — fall back to a one-shot send plus
791                        // polling rather than hanging (TASK_POLL_004). Any
792                        // *other* error opening the stream is a real
793                        // failure and must be reported as such, not masked
794                        // by a silent retry.
795                        let response = send_and_maybe_wait(
796                            client,
797                            &request,
798                            tenant,
799                            cli.async_mode,
800                            cli.poll_interval,
801                            cli.timeout,
802                        )
803                        .await?;
804                        response.warn_outcome();
805                        print_output(&response, &cli)?;
806                    }
807                    Err(error) => {
808                        let _ = client.destroy().await;
809                        return Err(error.into());
810                    }
811                }
812            } else {
813                let response = send_and_maybe_wait(
814                    client,
815                    &request,
816                    tenant,
817                    cli.async_mode,
818                    cli.poll_interval,
819                    cli.timeout,
820                )
821                .await?;
822                response.warn_outcome();
823                print_output(&response, &cli)?;
824            }
825        }
826        Command::Task { command } => run_task_command(&cli, matches, command).await?,
827    }
828
829    Ok(())
830}
831
832/// Send a message and, unless `--async` was given, block until the resulting
833/// task reaches a terminal or interrupted state (§6.5/§9.3 default-wait
834/// behavior). A `Message`-only response has no task to wait on and is
835/// returned as soon as it arrives. Always destroys `client` before returning.
836async fn send_and_maybe_wait<T: a2a_client::Transport>(
837    client: A2AClient<T>,
838    request: &SendMessageRequest,
839    tenant: Option<String>,
840    async_mode: bool,
841    poll_interval: Duration,
842    timeout: Duration,
843) -> Result<SendMessageResponse, CliError> {
844    let result = client.send_message(request).await;
845    let response = match result {
846        Ok(response) => response,
847        Err(error) => {
848            let _ = client.destroy().await;
849            return Err(error.into());
850        }
851    };
852
853    match response {
854        SendMessageResponse::Task(task) => {
855            let task =
856                settle_task(client, task, !async_mode, tenant, poll_interval, timeout).await?;
857            Ok(SendMessageResponse::Task(task))
858        }
859        SendMessageResponse::Message(message) => {
860            client.destroy().await?;
861            Ok(SendMessageResponse::Message(message))
862        }
863    }
864}
865
866/// If `wait` is true and `task` has not already settled, poll until it
867/// reaches a terminal or interrupted state (or `timeout` expires). Destroys
868/// `client` before returning either way.
869async fn settle_task<T: a2a_client::Transport>(
870    client: A2AClient<T>,
871    task: Task,
872    wait: bool,
873    tenant: Option<String>,
874    poll_interval: Duration,
875    timeout: Duration,
876) -> Result<Task, CliError> {
877    let outcome = if wait && !is_settled(&task.status.state) {
878        wait_for_task_settled(&client, &task.id, tenant, poll_interval, timeout).await
879    } else {
880        Ok(task)
881    };
882
883    match outcome {
884        Ok(task) => {
885            client.destroy().await?;
886            Ok(task)
887        }
888        Err(error) => {
889            let _ = client.destroy().await;
890            Err(error)
891        }
892    }
893}
894
895/// Poll `task get` until the task reaches a terminal or interrupted state
896/// (§9.1/§9.3), sleeping `poll_interval` between attempts and giving up with
897/// [`CliError::Timeout`] once `timeout` has elapsed. `TASK_STATE_UNSPECIFIED`
898/// is treated as neither terminal nor interrupted, so it keeps polling.
899async fn wait_for_task_settled<T: a2a_client::Transport>(
900    client: &A2AClient<T>,
901    task_id: &str,
902    tenant: Option<String>,
903    poll_interval: Duration,
904    timeout: Duration,
905) -> Result<Task, CliError> {
906    let deadline = tokio::time::Instant::now() + timeout;
907    loop {
908        let task = client
909            .get_task(&GetTaskRequest {
910                id: task_id.to_string(),
911                history_length: None,
912                tenant: tenant.clone(),
913            })
914            .await?;
915
916        if is_settled(&task.status.state) {
917            return Ok(task);
918        }
919
920        let now = tokio::time::Instant::now();
921        if now >= deadline {
922            return Err(CliError::Timeout {
923                task_id: task_id.to_string(),
924                timeout,
925            });
926        }
927
928        tokio::time::sleep(poll_interval.min(deadline.saturating_duration_since(now))).await;
929    }
930}
931
932/// §6.6/§11.6: a turn the CLI conducted and reported exits `0` even when the
933/// agent did not succeed, so the exit status alone cannot tell a caller
934/// that. Name a non-success or paused outcome on stderr instead — in every
935/// output mode, like the error envelope (§11.4), and without touching
936/// stdout, which stays the payload (§11.1).
937///
938/// The four states named are exactly the ones `A2ACLI_EXIT_002` enumerates.
939/// `CANCELED` is deliberately not among them: after `task cancel` it is the
940/// outcome the caller asked for, so warning about it would be noise.
941fn task_outcome_note(state: &TaskState) -> Option<&'static str> {
942    match state {
943        TaskState::Failed => Some("the agent reported it FAILED"),
944        TaskState::Rejected => Some("the agent REJECTED it"),
945        TaskState::InputRequired => {
946            Some("it is paused at INPUT_REQUIRED and needs a reply to continue")
947        }
948        TaskState::AuthRequired => {
949            Some("it is paused at AUTH_REQUIRED and needs credentials to continue")
950        }
951        _ => None,
952    }
953}
954
955fn warn_task_outcome(task_id: &str, state: &TaskState) {
956    if let Some(note) = task_outcome_note(state) {
957        eprintln!("warning: task {task_id}: {note}");
958    }
959}
960
961/// Something the CLI reports that may carry a task outcome. A trait rather
962/// than a call at each print site so a new reporting path cannot quietly
963/// skip the warning — it has to satisfy the bound.
964trait TaskOutcome {
965    fn warn_outcome(&self);
966}
967
968impl TaskOutcome for Task {
969    fn warn_outcome(&self) {
970        warn_task_outcome(&self.id, &self.status.state);
971    }
972}
973
974impl TaskOutcome for SendMessageResponse {
975    fn warn_outcome(&self) {
976        match self {
977            SendMessageResponse::Task(task) => task.warn_outcome(),
978            // A `Message`-only reply created no task, so there is no task
979            // outcome to report (SEND_003).
980            SendMessageResponse::Message(_) => {}
981        }
982    }
983}
984
985impl TaskOutcome for StreamResponse {
986    fn warn_outcome(&self) {
987        match self {
988            StreamResponse::Task(task) => task.warn_outcome(),
989            StreamResponse::StatusUpdate(event) => {
990                warn_task_outcome(&event.task_id, &event.status.state);
991            }
992            StreamResponse::Message(_) | StreamResponse::ArtifactUpdate(_) => {}
993        }
994    }
995}
996
997/// A task in a terminal (`COMPLETED`/`FAILED`/`CANCELED`/`REJECTED`) or
998/// interrupted (`INPUT_REQUIRED`/`AUTH_REQUIRED`) state needs no more polling.
999fn is_settled(state: &TaskState) -> bool {
1000    state.is_terminal() || matches!(state, TaskState::InputRequired | TaskState::AuthRequired)
1001}
1002
1003async fn run_card_command(
1004    cli: &Cli,
1005    matches: &ArgMatches,
1006    command: &CardCommand,
1007) -> Result<(), CliError> {
1008    match command {
1009        CardCommand::Get(command) => {
1010            if command.extended {
1011                let ResolvedClient { client, tenant } = resolve_client(cli, matches).await?;
1012                let result = client
1013                    .get_extended_agent_card(&GetExtendedAgentCardRequest { tenant })
1014                    .await;
1015                let card = finish_client_call(client, result).await?;
1016                print_output(&card, cli)?;
1017            } else {
1018                let card = resolve_agent_card(cli, matches).await?;
1019                print_output(&card, cli)?;
1020            }
1021        }
1022    }
1023
1024    Ok(())
1025}
1026
1027async fn run_task_command(
1028    cli: &Cli,
1029    matches: &ArgMatches,
1030    command: &TaskCommand,
1031) -> Result<(), CliError> {
1032    match command {
1033        TaskCommand::Get(command) => {
1034            let ResolvedClient { client, tenant } = resolve_client(cli, matches).await?;
1035            let result = client
1036                .get_task(&GetTaskRequest {
1037                    id: command.id.clone(),
1038                    history_length: command.history_length,
1039                    tenant: tenant.clone(),
1040                })
1041                .await;
1042            let task = match result {
1043                Ok(task) => task,
1044                Err(error) => {
1045                    let _ = client.destroy().await;
1046                    return Err(error.into());
1047                }
1048            };
1049            let task = settle_task(
1050                client,
1051                task,
1052                cli.wait,
1053                tenant,
1054                cli.poll_interval,
1055                cli.timeout,
1056            )
1057            .await?;
1058            task.warn_outcome();
1059            print_output(&task, cli)?;
1060        }
1061        TaskCommand::List(command) => {
1062            let ResolvedClient { client, tenant } = resolve_client(cli, matches).await?;
1063            let result = client
1064                .list_tasks(&ListTasksRequest {
1065                    context_id: command.context_id.clone(),
1066                    status: command.status.map(TaskState::from),
1067                    page_size: command.page_size,
1068                    page_token: command.page_token.clone(),
1069                    history_length: command.history_length,
1070                    status_timestamp_after: None,
1071                    include_artifacts: command.include_artifacts.then_some(true),
1072                    tenant,
1073                })
1074                .await;
1075            let response = finish_client_call(client, result).await?;
1076            print_output(&response, cli)?;
1077        }
1078        TaskCommand::Cancel(command) => {
1079            let ResolvedClient { client, tenant } = resolve_client(cli, matches).await?;
1080            let result = client
1081                .cancel_task(&CancelTaskRequest {
1082                    id: command.id.clone(),
1083                    metadata: None,
1084                    tenant,
1085                })
1086                .await;
1087            let task = finish_client_call(client, result).await?;
1088            task.warn_outcome();
1089            print_output(&task, cli)?;
1090        }
1091        TaskCommand::Subscribe(command) => {
1092            let ResolvedClient { client, tenant } = resolve_client(cli, matches).await?;
1093            let stream = client
1094                .subscribe_to_task(&SubscribeToTaskRequest {
1095                    id: command.id.clone(),
1096                    tenant,
1097                })
1098                .await?;
1099            consume_stream(client, stream, cli).await?;
1100        }
1101        TaskCommand::PushConfig { command } => {
1102            run_push_config_command(cli, matches, command).await?;
1103        }
1104    }
1105
1106    Ok(())
1107}
1108
1109/// Reconstruct `send`'s message parts in the order the caller typed them,
1110/// binding each `--media-type` to the part flag immediately preceding it.
1111///
1112/// `clap`'s derived `MessageCommand` struct only exposes each repeatable flag
1113/// (`--text-part`, `--file-part`, `--data-part`, `--media-type`) as its own
1114/// `Vec<String>`, which loses the relative order *across* flags. Recovering
1115/// that order needs the raw [`ArgMatches`] for the `send` subcommand:
1116/// `indices_of` reports, for a given arg id, the token index of each value in
1117/// the original command line, which sorts back into the order the caller
1118/// wrote them (§10.2: "the part flags are repeatable and order-preserving").
1119fn resolve_message_parts(
1120    matches: &ArgMatches,
1121    command: &MessageCommand,
1122) -> Result<Vec<Part>, CliError> {
1123    enum Kind {
1124        Text,
1125        File,
1126        Data,
1127    }
1128    enum Token<'a> {
1129        Part(Kind, &'a str),
1130        MediaType(&'a str),
1131    }
1132
1133    let mut tokens: Vec<(usize, Token<'_>)> = Vec::new();
1134    if let Some(indices) = matches.indices_of("text_parts") {
1135        tokens.extend(
1136            indices
1137                .zip(command.text_parts.iter())
1138                .map(|(index, value)| (index, Token::Part(Kind::Text, value.as_str()))),
1139        );
1140    }
1141    if let Some(indices) = matches.indices_of("file_parts") {
1142        tokens.extend(
1143            indices
1144                .zip(command.file_parts.iter())
1145                .map(|(index, value)| (index, Token::Part(Kind::File, value.as_str()))),
1146        );
1147    }
1148    if let Some(indices) = matches.indices_of("data_parts") {
1149        tokens.extend(
1150            indices
1151                .zip(command.data_parts.iter())
1152                .map(|(index, value)| (index, Token::Part(Kind::Data, value.as_str()))),
1153        );
1154    }
1155
1156    if !command.media_types.is_empty() && tokens.is_empty() {
1157        // A lone --media-type with no other part flag at all must not be
1158        // silently dropped by the "no part flags given" branch below.
1159        return Err(CliError::InvalidInput(
1160            "--media-type must immediately follow a --text-part/--file-part/--data-part"
1161                .to_string(),
1162        ));
1163    }
1164
1165    if tokens.is_empty() {
1166        // No part flags given: the positional text (if any) is shorthand for
1167        // a single text part. A message with no content at all is a usage
1168        // error, not a silently empty parts array.
1169        return match command.text.as_deref() {
1170            Some(text) => Ok(vec![Part::text(text)]),
1171            None => Err(CliError::InvalidInput(
1172                "message must have at least one part: pass text, or use \
1173                 --text-part/--file-part/--data-part"
1174                    .to_string(),
1175            )),
1176        };
1177    }
1178
1179    if command.text.is_some() {
1180        return Err(CliError::InvalidInput(
1181            "the positional message text cannot be combined with \
1182             --text-part/--file-part/--data-part; pass it as --text-part instead"
1183                .to_string(),
1184        ));
1185    }
1186
1187    if let Some(indices) = matches.indices_of("media_types") {
1188        tokens.extend(
1189            indices
1190                .zip(command.media_types.iter())
1191                .map(|(index, value)| (index, Token::MediaType(value.as_str()))),
1192        );
1193    }
1194    tokens.sort_by_key(|(index, _)| *index);
1195
1196    let mut parts: Vec<Part> = Vec::new();
1197    for (_, token) in tokens {
1198        match token {
1199            Token::Part(kind, value) => {
1200                let part = match kind {
1201                    Kind::Text => Part::text(value),
1202                    Kind::File => build_file_part(value)?,
1203                    Kind::Data => build_data_part(value)?,
1204                };
1205                parts.push(part);
1206            }
1207            Token::MediaType(media_type) => match parts.last_mut() {
1208                Some(part) => part.media_type = Some(media_type.to_string()),
1209                None => {
1210                    return Err(CliError::InvalidInput(
1211                        "--media-type must immediately follow a --text-part/--file-part/--data-part"
1212                            .to_string(),
1213                    ));
1214                }
1215            },
1216        }
1217    }
1218
1219    Ok(parts)
1220}
1221
1222/// A local filesystem path becomes an inline, base64-encoded file part; a URL
1223/// is carried by reference and never fetched by the CLI (§10.2).
1224fn build_file_part(value: &str) -> Result<Part, CliError> {
1225    if value.contains("://") {
1226        return Ok(Part::url(value));
1227    }
1228
1229    let bytes = std::fs::read(value).map_err(|source| CliError::ReadFile {
1230        path: value.to_string(),
1231        source,
1232    })?;
1233    let mut part = Part::raw(bytes);
1234    part.filename = std::path::Path::new(value)
1235        .file_name()
1236        .map(|name| name.to_string_lossy().into_owned());
1237    Ok(part)
1238}
1239
1240/// `--data-part <path|->` reads JSON from a file path or, for `-`, from
1241/// stdin; anything else is parsed as an inline JSON string (§10.2).
1242/// Whether a `read_to_string` failure means "this string doesn't name a
1243/// readable file here" — so `--data-part` should go on to try parsing it as
1244/// inline JSON — rather than "a real file failed to read", which must be
1245/// reported.
1246///
1247/// This can't just test for `NotFound`: Windows rejects a path containing
1248/// characters it doesn't allow (`{`, `"`, `:` — precisely what inline JSON
1249/// looks like) with `InvalidFilename` *before* ever looking for the file, so
1250/// matching only `NotFound` would turn every inline-JSON `--data-part` into
1251/// a read error there while working fine on Unix, where those are all legal
1252/// filename characters.
1253fn means_not_a_readable_path(kind: std::io::ErrorKind) -> bool {
1254    matches!(
1255        kind,
1256        std::io::ErrorKind::NotFound
1257            | std::io::ErrorKind::InvalidFilename
1258            | std::io::ErrorKind::InvalidInput
1259    )
1260}
1261
1262fn build_data_part(value: &str) -> Result<Part, CliError> {
1263    if value == "-" {
1264        use std::io::Read;
1265        let mut buffer = String::new();
1266        std::io::stdin()
1267            .read_to_string(&mut buffer)
1268            .map_err(|source| CliError::ReadFile {
1269                path: "<stdin>".to_string(),
1270                source,
1271            })?;
1272        return parse_data_part_json(&buffer, "<stdin>");
1273    }
1274
1275    match std::fs::read_to_string(value) {
1276        Ok(text) => return parse_data_part_json(&text, value),
1277        Err(source) if !means_not_a_readable_path(source.kind()) => {
1278            // A real file that couldn't be read (permission denied, a
1279            // directory, invalid UTF-8, ...) — that's a failure to
1280            // surface, not a signal to fall back to inline-JSON parsing.
1281            return Err(CliError::ReadFile {
1282                path: value.to_string(),
1283                source,
1284            });
1285        }
1286        Err(_) => {} // doesn't name a readable file — try `value` as JSON below
1287    }
1288
1289    serde_json::from_str(value).map(Part::data).map_err(|_| {
1290        CliError::InvalidInput(format!(
1291            "--data-part must be a file path, \"-\" for stdin, or inline JSON: {value}"
1292        ))
1293    })
1294}
1295
1296/// Parse `text` (the content of a `--data-part` file or stdin) as JSON,
1297/// mapping a parse failure to a usage error naming `source` rather than the
1298/// generic internal `CliError::Json` — the caller supplied this content, so
1299/// a malformed payload is their input to fix, not the tool's own failure.
1300fn parse_data_part_json(text: &str, source: &str) -> Result<Part, CliError> {
1301    serde_json::from_str(text).map(Part::data).map_err(|error| {
1302        CliError::InvalidInput(format!("--data-part: invalid JSON in {source}: {error}"))
1303    })
1304}
1305
1306/// Parse a duration like "2s", "500ms", "1m", or a bare number of seconds.
1307fn parse_duration(input: &str) -> Result<Duration, String> {
1308    let trimmed = input.trim();
1309    let (magnitude, unit) = if let Some(value) = trimmed.strip_suffix("ms") {
1310        (value, "ms")
1311    } else if let Some(value) = trimmed.strip_suffix('s') {
1312        (value, "s")
1313    } else if let Some(value) = trimmed.strip_suffix('m') {
1314        (value, "m")
1315    } else {
1316        (trimmed, "s")
1317    };
1318
1319    let magnitude: f64 = magnitude
1320        .trim()
1321        .parse()
1322        .map_err(|_| format!("invalid duration: {input}"))?;
1323    if magnitude < 0.0 || !magnitude.is_finite() {
1324        return Err(format!("duration must be a non-negative number: {input}"));
1325    }
1326
1327    let seconds = match unit {
1328        "ms" => magnitude / 1000.0,
1329        "m" => magnitude * 60.0,
1330        _ => magnitude,
1331    };
1332    Ok(Duration::from_secs_f64(seconds))
1333}
1334
1335fn build_send_message_request(
1336    command: &MessageCommand,
1337    parts: Vec<Part>,
1338    tenant: Option<String>,
1339) -> SendMessageRequest {
1340    let mut message = Message::new(Role::User, parts);
1341    message.context_id = command.context_id.clone();
1342    message.task_id = command.task_id.clone();
1343
1344    let configuration = if command.history_length.is_some()
1345        || !command.accepted_output_modes.is_empty()
1346        || command.return_immediately
1347    {
1348        Some(SendMessageConfiguration {
1349            accepted_output_modes: (!command.accepted_output_modes.is_empty())
1350                .then_some(command.accepted_output_modes.clone()),
1351            task_push_notification_config: None,
1352            history_length: command.history_length,
1353            return_immediately: command.return_immediately.then_some(true),
1354        })
1355    } else {
1356        None
1357    };
1358
1359    SendMessageRequest {
1360        message,
1361        configuration,
1362        metadata: None,
1363        tenant,
1364    }
1365}
1366
1367fn build_push_notification_config(
1368    command: &CreatePushConfigCommand,
1369) -> Result<TaskPushNotificationConfig, CliError> {
1370    if command.auth_credentials.is_some() && command.auth_scheme.is_none() {
1371        return Err(CliError::InvalidInput(
1372            "--auth-credentials requires --auth-scheme".to_string(),
1373        ));
1374    }
1375
1376    let authentication = command
1377        .auth_scheme
1378        .clone()
1379        .map(|scheme| AuthenticationInfo {
1380            scheme,
1381            credentials: command.auth_credentials.clone(),
1382        });
1383
1384    Ok(TaskPushNotificationConfig {
1385        task_id: String::new(),
1386        url: command.url.clone(),
1387        id: command.config_id.clone(),
1388        token: command.token.clone(),
1389        authentication,
1390        tenant: None,
1391    })
1392}
1393
1394async fn run_push_config_command(
1395    cli: &Cli,
1396    matches: &ArgMatches,
1397    command: &PushConfigCommand,
1398) -> Result<(), CliError> {
1399    match command {
1400        PushConfigCommand::Create(command) => {
1401            let ResolvedClient { client, tenant } = resolve_client(cli, matches).await?;
1402            let mut config = build_push_notification_config(command)?;
1403            config.task_id = command.task_id.clone();
1404            config.tenant = tenant;
1405            let result = client.create_push_config(&config).await;
1406            let response = finish_client_call(client, result).await?;
1407            print_output(&response, cli)?;
1408        }
1409        PushConfigCommand::Get(command) => {
1410            let ResolvedClient { client, tenant } = resolve_client(cli, matches).await?;
1411            let result = client
1412                .get_push_config(&GetTaskPushNotificationConfigRequest {
1413                    task_id: command.task_id.clone(),
1414                    id: command.id.clone(),
1415                    tenant,
1416                })
1417                .await;
1418            let response = finish_client_call(client, result).await?;
1419            print_output(&response, cli)?;
1420        }
1421        PushConfigCommand::List(command) => {
1422            let ResolvedClient { client, tenant } = resolve_client(cli, matches).await?;
1423            let result = client
1424                .list_push_configs(&ListTaskPushNotificationConfigsRequest {
1425                    task_id: command.task_id.clone(),
1426                    page_size: command.page_size,
1427                    page_token: command.page_token.clone(),
1428                    tenant,
1429                })
1430                .await;
1431            let response = finish_client_call(client, result).await?;
1432            print_output(&response, cli)?;
1433        }
1434        PushConfigCommand::Delete(command) => {
1435            let ResolvedClient { client, tenant } = resolve_client(cli, matches).await?;
1436            let result = client
1437                .delete_push_config(&DeleteTaskPushNotificationConfigRequest {
1438                    task_id: command.task_id.clone(),
1439                    id: command.id.clone(),
1440                    tenant,
1441                })
1442                .await;
1443            finish_client_call(client, result).await?;
1444            print_output(
1445                &PushConfigDeleted {
1446                    deleted: true,
1447                    task_id: command.task_id.clone(),
1448                    id: command.id.clone(),
1449                },
1450                cli,
1451            )?;
1452        }
1453    }
1454
1455    Ok(())
1456}
1457
1458// ---------------------------------------------------------------------
1459// Configuration (§8.3): .env discovery/precedence and `config show`.
1460// ---------------------------------------------------------------------
1461
1462/// Where an effective setting's value came from (§8.3's precedence order,
1463/// highest first): an explicit flag, a real environment variable, a local
1464/// `.env` file, a global `.env` file, or the tool's built-in default.
1465#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1466pub enum ConfigSource {
1467    Flag,
1468    EnvVar,
1469    LocalFile,
1470    GlobalFile,
1471    Default,
1472}
1473
1474impl ConfigSource {
1475    fn label(self) -> &'static str {
1476        match self {
1477            ConfigSource::Flag => "flag",
1478            ConfigSource::EnvVar => "environment variable",
1479            ConfigSource::LocalFile => "local .env file",
1480            ConfigSource::GlobalFile => "global .env file",
1481            ConfigSource::Default => "built-in default",
1482        }
1483    }
1484}
1485
1486/// Load `.env` configuration and inject any `A2ACLI_*` key that isn't
1487/// already a real environment variable, so clap's own `env = "..."`
1488/// resolution on [`Cli`]'s fields sees it — this MUST run before
1489/// `Cli::command().get_matches_from(args)`. Returns which keys were
1490/// injected from which file, for `config show` to report accurately.
1491///
1492/// Precedence (§6.5, §8.3): a real environment variable always wins over
1493/// either file; a local `.env` (or the file named by `--config`) wins over
1494/// the global `~/.config/a2a-cli/.env`.
1495fn load_dotenv_config(args: &[OsString]) -> HashMap<String, ConfigSource> {
1496    // Snapshot which A2ACLI_* keys are *real* environment variables before
1497    // any file-based injection, so a lower-precedence file's value is never
1498    // mistaken for "already set" once a higher-precedence file has run.
1499    let real_env_keys: HashSet<String> = std::env::vars_os()
1500        .filter_map(|(key, _)| key.into_string().ok())
1501        .filter(|key| key.starts_with("A2ACLI_"))
1502        .collect();
1503
1504    let mut provenance = HashMap::new();
1505
1506    if let Some(global_path) = global_config_path() {
1507        apply_dotenv_file(
1508            &global_path,
1509            ConfigSource::GlobalFile,
1510            &real_env_keys,
1511            &mut provenance,
1512        );
1513    }
1514
1515    let local_path = find_flag_value(args, "--config")
1516        .map(PathBuf::from)
1517        .or_else(find_local_dotenv);
1518    if let Some(local_path) = local_path {
1519        apply_dotenv_file(
1520            &local_path,
1521            ConfigSource::LocalFile,
1522            &real_env_keys,
1523            &mut provenance,
1524        );
1525    }
1526
1527    provenance
1528}
1529
1530/// Look for `--flag value` or `--flag=value` in a raw, unparsed argv —
1531/// needed to find `--config` before `Cli` itself has been parsed.
1532fn find_flag_value(args: &[OsString], flag: &str) -> Option<String> {
1533    let prefix = format!("{flag}=");
1534    for (index, arg) in args.iter().enumerate() {
1535        let Some(arg) = arg.to_str() else { continue };
1536        if arg == flag {
1537            return args.get(index + 1)?.to_str().map(str::to_string);
1538        }
1539        if let Some(value) = arg.strip_prefix(&prefix) {
1540            return Some(value.to_string());
1541        }
1542    }
1543    None
1544}
1545
1546/// `~/.config/a2a-cli/.env`, honoring `$XDG_CONFIG_HOME`.
1547fn global_config_path() -> Option<PathBuf> {
1548    if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME").filter(|value| !value.is_empty()) {
1549        return Some(PathBuf::from(xdg).join("a2a-cli").join(".env"));
1550    }
1551    let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE"))?;
1552    Some(
1553        PathBuf::from(home)
1554            .join(".config")
1555            .join("a2a-cli")
1556            .join(".env"),
1557    )
1558}
1559
1560/// A local `.env`, discovered the way `git` discovers its configuration:
1561/// walking up from the working directory (§8.3).
1562fn find_local_dotenv() -> Option<PathBuf> {
1563    let mut dir = std::env::current_dir().ok()?;
1564    loop {
1565        let candidate = dir.join(".env");
1566        if candidate.is_file() {
1567            return Some(candidate);
1568        }
1569        if !dir.pop() {
1570            return None;
1571        }
1572    }
1573}
1574
1575fn apply_dotenv_file(
1576    path: &Path,
1577    source: ConfigSource,
1578    real_env_keys: &HashSet<String>,
1579    provenance: &mut HashMap<String, ConfigSource>,
1580) {
1581    let Ok(contents) = std::fs::read_to_string(path) else {
1582        return;
1583    };
1584    warn_if_world_readable(path);
1585
1586    for (key, value) in parse_dotenv(&contents) {
1587        if !key.starts_with("A2ACLI_") || real_env_keys.contains(&key) {
1588            continue;
1589        }
1590        // SAFETY: this runs synchronously, at the very start of `run_args`,
1591        // before any async work or additional threads have been spawned by
1592        // this process — nothing else can be concurrently reading or
1593        // writing the environment at this point.
1594        unsafe {
1595            std::env::set_var(&key, &value);
1596        }
1597        provenance.insert(key, source);
1598    }
1599}
1600
1601/// §8.3: "MUST NOT store secrets in world-readable files". `a2acli` never
1602/// writes a `.env` file itself, but it can at least warn when one it reads
1603/// is readable by users other than its owner.
1604#[cfg(unix)]
1605fn warn_if_world_readable(path: &Path) {
1606    use std::os::unix::fs::PermissionsExt;
1607    let Ok(metadata) = std::fs::metadata(path) else {
1608        return;
1609    };
1610    let mode = metadata.permissions().mode();
1611    if mode & 0o077 != 0 {
1612        eprintln!(
1613            "warning: {} is readable by other users (mode {:o}); it may contain credentials — \
1614             consider `chmod 600 {}`",
1615            path.display(),
1616            mode & 0o777,
1617            path.display()
1618        );
1619    }
1620}
1621
1622#[cfg(not(unix))]
1623fn warn_if_world_readable(_path: &Path) {}
1624
1625/// Parse `.env` (dotenv) syntax: `KEY=value`, one per line. Blank lines and
1626/// `#` comments are ignored, a leading `export ` is tolerated, and one
1627/// layer of surrounding single or double quotes is stripped (§8.3).
1628fn parse_dotenv(contents: &str) -> Vec<(String, String)> {
1629    contents
1630        .lines()
1631        .filter_map(|line| {
1632            let line = line.trim();
1633            if line.is_empty() || line.starts_with('#') {
1634                return None;
1635            }
1636            let line = line.strip_prefix("export ").unwrap_or(line);
1637            let (key, value) = line.split_once('=')?;
1638            let key = key.trim().to_string();
1639            let value = value.trim();
1640            let value = ["\"", "'"]
1641                .iter()
1642                .find_map(|quote| {
1643                    value
1644                        .strip_prefix(quote)
1645                        .and_then(|rest| rest.strip_suffix(quote))
1646                })
1647                .unwrap_or(value);
1648            Some((key, value.to_string()))
1649        })
1650        .collect()
1651}
1652
1653#[derive(Debug, Clone, Serialize)]
1654struct ConfigSetting {
1655    name: String,
1656    value: String,
1657    source: String,
1658}
1659
1660#[derive(Debug, Clone, Serialize)]
1661struct ConfigSettings {
1662    settings: Vec<ConfigSetting>,
1663}
1664
1665impl TextRender for ConfigSettings {
1666    fn render_text(&self) -> String {
1667        let mut out = TextOutput::default();
1668        for setting in &self.settings {
1669            out.field(
1670                &setting.name,
1671                format!("{} (source: {})", setting.value, setting.source),
1672            );
1673        }
1674        out.finish()
1675    }
1676}
1677
1678fn redact_secret(value: &Option<String>) -> String {
1679    match value {
1680        Some(_) => "(set, redacted)".to_string(),
1681        None => "(not set)".to_string(),
1682    }
1683}
1684
1685fn run_config_command(
1686    cli: &Cli,
1687    matches: &ArgMatches,
1688    dotenv_provenance: &HashMap<String, ConfigSource>,
1689    command: &ConfigCommand,
1690) -> Result<(), CliError> {
1691    match command {
1692        ConfigCommand::Show => {
1693            let settings = effective_config_settings(cli, matches, dotenv_provenance);
1694            print_output(&settings, cli)?;
1695        }
1696    }
1697    Ok(())
1698}
1699
1700/// Enumerate every §6.5/§8.3 configurable setting with its current value
1701/// and the source it resolved from, for `config show`.
1702fn effective_config_settings(
1703    cli: &Cli,
1704    matches: &ArgMatches,
1705    dotenv_provenance: &HashMap<String, ConfigSource>,
1706) -> ConfigSettings {
1707    let mut settings = Vec::new();
1708    let mut add = |id: &str, name: &str, value: String, env_key: &str| {
1709        let source = match matches.value_source(id) {
1710            Some(ValueSource::CommandLine) => ConfigSource::Flag,
1711            Some(ValueSource::EnvVariable) => dotenv_provenance
1712                .get(env_key)
1713                .copied()
1714                .unwrap_or(ConfigSource::EnvVar),
1715            _ => ConfigSource::Default,
1716        };
1717        settings.push(ConfigSetting {
1718            name: name.to_string(),
1719            value,
1720            source: source.label().to_string(),
1721        });
1722    };
1723
1724    add(
1725        "agent_card",
1726        "agent-card",
1727        cli.agent_card
1728            .clone()
1729            .unwrap_or_else(|| "(not set)".to_string()),
1730        "A2ACLI_AGENT_CARD",
1731    );
1732    add(
1733        "endpoint",
1734        "endpoint",
1735        cli.endpoint
1736            .clone()
1737            .unwrap_or_else(|| "(not set)".to_string()),
1738        "A2ACLI_ENDPOINT",
1739    );
1740    add(
1741        "base_url",
1742        "base-url",
1743        cli.base_url.clone(),
1744        "A2ACLI_BASE_URL",
1745    );
1746    add(
1747        "transport",
1748        "transport",
1749        if cli.transport.is_empty() {
1750            "(agent card's own order)".to_string()
1751        } else {
1752            cli.transport
1753                .iter()
1754                .map(|binding| format!("{binding:?}").to_lowercase())
1755                .collect::<Vec<_>>()
1756                .join(",")
1757        },
1758        "A2ACLI_TRANSPORT",
1759    );
1760    add(
1761        "a2a_version",
1762        "a2a-version",
1763        cli.a2a_version.clone().unwrap_or_else(|| {
1764            format!("(negotiated from the agent card; a2acli supports {VERSION})")
1765        }),
1766        "A2ACLI_A2A_VERSION",
1767    );
1768    add(
1769        "bearer",
1770        "bearer",
1771        redact_secret(&cli.bearer),
1772        "A2ACLI_BEARER",
1773    );
1774    add(
1775        "api_key",
1776        "api-key",
1777        redact_secret(&cli.api_key),
1778        "A2ACLI_API_KEY",
1779    );
1780    add(
1781        "insecure",
1782        "insecure",
1783        cli.insecure.to_string(),
1784        "A2ACLI_INSECURE",
1785    );
1786    add("debug", "debug", cli.debug.to_string(), "A2ACLI_DEBUG");
1787    add(
1788        "tenant",
1789        "tenant",
1790        cli.tenant
1791            .clone()
1792            .unwrap_or_else(|| "(not set)".to_string()),
1793        "A2ACLI_TENANT",
1794    );
1795    add(
1796        "output",
1797        "output",
1798        format!("{:?}", cli.output).to_lowercase(),
1799        "A2ACLI_OUTPUT",
1800    );
1801    add(
1802        "compact",
1803        "compact",
1804        cli.compact.to_string(),
1805        "A2ACLI_COMPACT",
1806    );
1807    add(
1808        "async_mode",
1809        "async",
1810        cli.async_mode.to_string(),
1811        "A2ACLI_ASYNC",
1812    );
1813    add("wait", "wait", cli.wait.to_string(), "A2ACLI_WAIT");
1814    add(
1815        "poll_interval",
1816        "poll-interval",
1817        format!("{:?}", cli.poll_interval),
1818        "A2ACLI_POLL_INTERVAL",
1819    );
1820    add(
1821        "timeout",
1822        "timeout",
1823        format!("{:?}", cli.timeout),
1824        "A2ACLI_TIMEOUT",
1825    );
1826
1827    // Where the card is actually fetched from, given the above: the one
1828    // line that answers "which agent am I talking to?" without the reader
1829    // having to apply the precedence rules themselves.
1830    settings.push(ConfigSetting {
1831        name: "resolved-card".to_string(),
1832        value: match &cli.endpoint {
1833            Some(endpoint) => format!("(none; --endpoint {endpoint})"),
1834            None => {
1835                match normalize_card_reference(cli.agent_card.as_deref().unwrap_or(&cli.base_url)) {
1836                    CardReference::File(path) => format!("file://{}", path.display()),
1837                    CardReference::Url(url) => url,
1838                }
1839            }
1840        },
1841        source: "derived".to_string(),
1842    });
1843
1844    ConfigSettings { settings }
1845}
1846
1847/// A negotiated client, plus the tenant to attach to every subsequent
1848/// request on it: the caller's explicit `--tenant` when given, otherwise
1849/// (per A2A §8.3.2) the routing tenant the *selected* Agent Card interface
1850/// itself declares, if any (TX_003).
1851struct ResolvedClient {
1852    client: A2AClient<Box<dyn a2a_client::Transport>>,
1853    tenant: Option<String>,
1854}
1855
1856/// §13.2: the A2A protocol version, as a `(major, minor)` pair. A2A
1857/// versions are `major.minor`; a trailing patch component is tolerated and
1858/// ignored so a card declaring `1.0.2` still negotiates.
1859fn parse_protocol_version(value: &str) -> Option<(u32, u32)> {
1860    let mut parts = value.trim().split('.');
1861    let major = parts.next()?.parse().ok()?;
1862    let minor = match parts.next() {
1863        Some(minor) => minor.parse().ok()?,
1864        None => 0,
1865    };
1866    Some((major, minor))
1867}
1868
1869/// The version this build of a2acli speaks.
1870fn supported_protocol_version() -> (u32, u32) {
1871    parse_protocol_version(VERSION).unwrap_or((1, 0))
1872}
1873
1874/// What the negotiation settled on, and why — kept together so the caller
1875/// can report a downgrade rather than applying one silently (§13.2).
1876struct NegotiatedVersion {
1877    version: String,
1878    /// Set when the effective version is not simply this build's own, so
1879    /// the reason can be surfaced on stderr.
1880    note: Option<String>,
1881}
1882
1883/// §13.2: an explicit `--a2a-version` is signaled as given — the caller
1884/// asked for it. Absent one, negotiate down to the highest version both
1885/// a2acli and the agent's selected interface declare, bounded to 1.x and
1886/// never below 1.0, since A2A reads a pre-1.0 value as 0.3 and a downgrade
1887/// into 0.x semantics is exactly what §13.2 forbids.
1888fn negotiate_a2a_version(
1889    explicit: Option<&str>,
1890    declared: &str,
1891    supported: (u32, u32),
1892) -> NegotiatedVersion {
1893    // An explicit version is signaled exactly as the caller wrote it. It has
1894    // already been rejected unless 1.x by `validate_a2a_version`, which runs
1895    // before any network work so a bad flag needs no reachable agent.
1896    let supported_label = format!("{}.{}", supported.0, supported.1);
1897
1898    if let Some(requested) = explicit {
1899        let note = (parse_protocol_version(requested) != Some(supported)).then(|| {
1900            format!("signaling A2A-Version {requested} (a2acli supports {supported_label})")
1901        });
1902        return NegotiatedVersion {
1903            version: requested.to_string(),
1904            note,
1905        };
1906    }
1907
1908    let Some(declared_version) = parse_protocol_version(declared) else {
1909        return NegotiatedVersion {
1910            version: supported_label.clone(),
1911            note: Some(format!(
1912                "agent card declares an unparseable protocol version ({declared}); \
1913                 signaling {supported_label}"
1914            )),
1915        };
1916    };
1917
1918    // Below 1.0, or a different major: there is nothing to negotiate within
1919    // 1.x, so hold the floor rather than following the card down.
1920    if declared_version.0 != 1 {
1921        return NegotiatedVersion {
1922            version: supported_label.clone(),
1923            note: Some(format!(
1924                "agent card declares protocol version {declared}, which is outside 1.x; \
1925                 signaling {supported_label} rather than negotiating below 1.0"
1926            )),
1927        };
1928    }
1929
1930    if declared_version < supported {
1931        let version = format!("{}.{}", declared_version.0, declared_version.1);
1932        return NegotiatedVersion {
1933            note: Some(format!(
1934                "negotiated A2A-Version down to {version} (agent card declares {version}, \
1935                 a2acli supports {supported_label})"
1936            )),
1937            version,
1938        };
1939    }
1940
1941    NegotiatedVersion {
1942        version: supported_label,
1943        note: None,
1944    }
1945}
1946
1947/// §13.2 / §11.6: reject a `--a2a-version` a2acli will not signal, before
1948/// any network work — a bad flag is a usage error and must not require a
1949/// reachable agent to report. A2A reads an empty or pre-1.0 value as 0.3, so
1950/// anything outside 1.x is refused rather than quietly downgraded.
1951fn validate_a2a_version(value: Option<&str>) -> Result<(), CliError> {
1952    let Some(requested) = value else {
1953        return Ok(());
1954    };
1955    match parse_protocol_version(requested) {
1956        Some((1, _)) => Ok(()),
1957        Some(_) => Err(CliError::InvalidInput(format!(
1958            "--a2a-version must be 1.x, got {requested}: A2A reads an empty or pre-1.0 \
1959             version as 0.3, and a2acli never signals below 1.0"
1960        ))),
1961        None => Err(CliError::InvalidInput(format!(
1962            "--a2a-version must be a version like 1.0, got {requested}"
1963        ))),
1964    }
1965}
1966
1967/// Sets `A2A-Version` to exactly the negotiated version, replacing the
1968/// client library's default rather than appending to it: §13.2 requires one
1969/// explicit version per request, and two values would leave which one
1970/// applies undefined.
1971struct VersionInterceptor {
1972    version: String,
1973}
1974
1975#[async_trait::async_trait]
1976impl a2a_client::middleware::CallInterceptor for VersionInterceptor {
1977    async fn before(
1978        &self,
1979        _method: &str,
1980        params: &mut a2a_client::ServiceParams,
1981    ) -> Result<(), A2AError> {
1982        params.insert(SVC_PARAM_VERSION.to_string(), vec![self.version.clone()]);
1983        Ok(())
1984    }
1985}
1986
1987/// How the caller named the agent (§7.2), validated once so the two forms
1988/// can't disagree further down: an Agent Card reference to resolve, or a
1989/// single interface to connect to directly.
1990#[derive(Debug, Clone, PartialEq, Eq)]
1991enum AgentSelection {
1992    Card(String),
1993    Endpoint { url: String, binding: Binding },
1994}
1995
1996fn resolve_agent_selection(cli: &Cli, matches: &ArgMatches) -> Result<AgentSelection, CliError> {
1997    match (&cli.agent_card, &cli.endpoint) {
1998        (Some(_), Some(_)) => Err(CliError::InvalidInput(
1999            "--endpoint and --agent-card are mutually exclusive: --endpoint names an interface \
2000             directly, so there is no card to resolve"
2001                .to_string(),
2002        )),
2003        // §7.2: with no card to declare the binding, the caller must name
2004        // exactly one transport — zero leaves the protocol ambiguous, and
2005        // more than one asks for a preference order over a single interface.
2006        (None, Some(url)) => match cli.transport.as_slice() {
2007            [binding] => Ok(AgentSelection::Endpoint {
2008                url: url.clone(),
2009                binding: *binding,
2010            }),
2011            transports => Err(CliError::InvalidInput(format!(
2012                "--endpoint requires exactly one --transport (got {}): there is no agent card \
2013                 to declare the interface's protocol binding",
2014                transports.len()
2015            ))),
2016        },
2017        (Some(reference), None) => Ok(AgentSelection::Card(reference.clone())),
2018        (None, None) => Ok(AgentSelection::Card(base_url_card_reference(cli, matches))),
2019    }
2020}
2021
2022/// A one-interface card standing in for the interface `--endpoint` named.
2023/// Synthesizing keeps transport selection, tenant handling and credential
2024/// attachment on a single path rather than growing a second card-less one.
2025fn synthesized_endpoint_card(url: &str, binding: Binding) -> AgentCard {
2026    AgentCard {
2027        name: url.to_string(),
2028        description: "synthesized from --endpoint; no agent card was resolved".to_string(),
2029        version: VERSION.to_string(),
2030        supported_interfaces: vec![AgentInterface::new(url, binding.protocol())],
2031        capabilities: AgentCapabilities::default(),
2032        default_input_modes: vec![],
2033        default_output_modes: vec![],
2034        skills: vec![],
2035        provider: None,
2036        documentation_url: None,
2037        icon_url: None,
2038        security_schemes: None,
2039        security_requirements: None,
2040        signatures: None,
2041    }
2042}
2043
2044async fn resolve_client(cli: &Cli, matches: &ArgMatches) -> Result<ResolvedClient, CliError> {
2045    let card = resolve_agent_card(cli, matches).await?;
2046
2047    let mut builder = A2AClientFactory::builder();
2048    if !cli.transport.is_empty() {
2049        let preferred = cli
2050            .transport
2051            .iter()
2052            .map(|binding| binding.protocol().to_string())
2053            .collect();
2054        builder = builder.preferred_bindings(preferred);
2055    }
2056    if cli.insecure {
2057        let insecure = build_insecure_reqwest_client()?;
2058        builder = builder
2059            .register(Arc::new(a2a_client::jsonrpc::JsonRpcTransportFactory::new(
2060                Some(insecure.clone()),
2061            )))
2062            .register(Arc::new(a2a_client::rest::RestTransportFactory::new(Some(
2063                insecure,
2064            ))));
2065    }
2066    // Collected rather than handed to the builder, because the version
2067    // interceptor can only be built once the selected interface is known
2068    // (§13.2 negotiates against what *that* interface declares) and
2069    // `with_interceptors` sets the whole list.
2070    let mut interceptors: Vec<Arc<dyn a2a_client::middleware::CallInterceptor>> = Vec::new();
2071    if let Some(token) = &cli.bearer {
2072        interceptors.push(Arc::new(AuthInterceptor::bearer(token.clone())));
2073    }
2074    if let Some(api_key) = &cli.api_key {
2075        interceptors.push(Arc::new(AuthInterceptor::custom(
2076            "X-API-Key",
2077            api_key.clone(),
2078        )));
2079    }
2080    for param in &cli.svc_params {
2081        interceptors.push(Arc::new(AuthInterceptor::custom(
2082            param.name.clone(),
2083            param.value.clone(),
2084        )));
2085    }
2086    if cli.debug {
2087        interceptors.push(Arc::new(a2a_client::middleware::LoggingInterceptor));
2088    }
2089
2090    let factory = builder.build();
2091    let (client, interface) = factory.create_from_card_with_interface(&card).await?;
2092
2093    let negotiated = negotiate_a2a_version(
2094        cli.a2a_version.as_deref(),
2095        &interface.protocol_version,
2096        supported_protocol_version(),
2097    );
2098    // §13.2: no silent downgrade. Whenever the effective version isn't
2099    // simply this build's own, say so.
2100    if let Some(note) = &negotiated.note {
2101        eprintln!("warning: {note}");
2102    }
2103    interceptors.push(Arc::new(VersionInterceptor {
2104        version: negotiated.version,
2105    }));
2106
2107    // Not `cli.tenant.or(interface.tenant)`: A2A §8.3.2 rule 4 and SPEC.md
2108    // §13.1 require the tenant to be "exactly the value declared in the
2109    // selected AgentInterface entry", so an explicit --tenant must not
2110    // displace a declared one. a2a-client applies the declared value itself,
2111    // overriding what is passed here, so --tenant reaches the wire only
2112    // where the card declares none (#199).
2113    //
2114    // `create_from_card_with_interface` stays: #191's version negotiation
2115    // needs the selected interface's protocol_version.
2116    let tenant = cli.tenant.clone();
2117    Ok(ResolvedClient {
2118        client: client.with_interceptors(interceptors),
2119        tenant,
2120    })
2121}
2122
2123/// An `--agent-card` reference resolved to where the card actually lives
2124/// (§7.2, §10.1). The three surface forms collapse to two destinations: a
2125/// local file, or an HTTP(S) URL.
2126#[derive(Debug, Clone, PartialEq, Eq)]
2127enum CardReference {
2128    File(PathBuf),
2129    Url(String),
2130}
2131
2132/// Whether `reference` names a local file rather than a host or URL, using
2133/// the same rule as the reference implementation: an explicitly
2134/// path-shaped prefix, or any string that happens to name something on
2135/// disk. Checked before the host forms so a relative path never gets an
2136/// `https://` prefix stapled to it.
2137fn looks_like_file_path(reference: &str) -> bool {
2138    reference.starts_with('/')
2139        || reference.starts_with("./")
2140        || reference.starts_with("../")
2141        // Windows shapes. Without these a path that doesn't exist *yet*
2142        // (a typo, a file not written) matches no prefix, falls through to
2143        // the host forms, and is fetched as `https://C:\…` — so the error
2144        // reported is "unreachable" rather than "no such card file".
2145        || reference.starts_with('\\')
2146        || reference.starts_with(".\\")
2147        || reference.starts_with("..\\")
2148        || has_windows_drive_prefix(reference)
2149        || std::fs::metadata(reference).is_ok()
2150}
2151
2152/// Whether `reference` starts with a Windows drive-letter root (`C:\`,
2153/// `C:/`). The drive letter must be exactly one character before the colon,
2154/// so a `host:port` is never mistaken for a path — not `localhost:3000`,
2155/// and not even a single-letter host like `a:3000`, whose third byte is a
2156/// digit rather than a separator.
2157fn has_windows_drive_prefix(reference: &str) -> bool {
2158    let bytes = reference.as_bytes();
2159    bytes.len() >= 3
2160        && bytes[0].is_ascii_alphabetic()
2161        && bytes[1] == b':'
2162        && (bytes[2] == b'\\' || bytes[2] == b'/')
2163}
2164
2165/// Whether `host` (with or without a port) is loopback, which decides the
2166/// scheme a bare host gets: `http://` for loopback, `https://` otherwise —
2167/// a local development agent is rarely served over TLS, and defaulting it
2168/// to `https://` would make the common case fail.
2169fn is_loopback_host(host: &str) -> bool {
2170    // An unbracketed IPv6 literal's colons are not port separators, so try
2171    // the whole reference as an address before splitting on `:` — otherwise
2172    // `::1` would be truncated to `::` and read as non-loopback.
2173    if let Ok(address) = host.parse::<std::net::IpAddr>() {
2174        return address.is_loopback();
2175    }
2176
2177    let host = match host.strip_prefix('[') {
2178        // Bracketed IPv6, with or without a port: `[::1]`, `[::1]:9000`.
2179        Some(rest) => rest.split_once(']').map_or(rest, |(inside, _)| inside),
2180        // Anything else: a trailing `:…` is a port.
2181        None => host.split_once(':').map_or(host, |(head, _)| head),
2182    };
2183
2184    host == "localhost"
2185        || host
2186            .parse::<std::net::IpAddr>()
2187            .is_ok_and(|address| address.is_loopback())
2188}
2189
2190/// Normalize an Agent Card reference to the place the card is fetched from
2191/// (§10.1): a bare host or origin gets the well-known path appended, a full
2192/// card URL is used as-is, and a local path (`file://…` or plain) resolves
2193/// to a file on disk.
2194fn normalize_card_reference(reference: &str) -> CardReference {
2195    if let Some(rest) = reference.strip_prefix("file://") {
2196        let path = match rest.strip_prefix('/') {
2197            // `file:///C:/card.json` is the canonical Windows form: the
2198            // slash before the drive letter belongs to the URL, not to the
2199            // path. On Unix there is no drive letter and the leading slash
2200            // is kept, since it is the root.
2201            Some(without_slash) if has_windows_drive_prefix(without_slash) => without_slash,
2202            _ => rest,
2203        };
2204        return CardReference::File(PathBuf::from(path));
2205    }
2206
2207    if !reference.contains("://") {
2208        if looks_like_file_path(reference) {
2209            return CardReference::File(PathBuf::from(reference));
2210        }
2211        let scheme = if is_loopback_host(reference) {
2212            "http"
2213        } else {
2214            "https"
2215        };
2216        return CardReference::Url(append_well_known_path(&format!("{scheme}://{reference}")));
2217    }
2218
2219    CardReference::Url(append_well_known_path(reference))
2220}
2221
2222/// A reference that names only a host or origin gets the well-known path;
2223/// one that already carries a path is a full card URL and is left alone.
2224fn append_well_known_path(url: &str) -> String {
2225    let trimmed = url.trim_end_matches('/');
2226    let has_path = trimmed
2227        .split_once("://")
2228        .is_some_and(|(_, rest)| rest.contains('/'));
2229    if has_path {
2230        url.to_string()
2231    } else {
2232        format!("{trimmed}{WELL_KNOWN_AGENT_CARD_PATH}")
2233    }
2234}
2235
2236/// The reference from the deprecated `--base-url`, used only when
2237/// `--agent-card` was not given. Warns when `--base-url` was passed
2238/// explicitly, so the alias is discoverable as deprecated without breaking
2239/// anything; leaving it at its built-in default is not a deprecated
2240/// invocation and says nothing.
2241fn base_url_card_reference(cli: &Cli, matches: &ArgMatches) -> String {
2242    if matches!(
2243        matches.value_source("base_url"),
2244        Some(ValueSource::CommandLine)
2245    ) {
2246        eprintln!(
2247            "warning: --base-url is deprecated; use --agent-card, which also accepts a full \
2248             card URL or a local file path"
2249        );
2250    }
2251    cli.base_url.clone()
2252}
2253
2254async fn resolve_agent_card(cli: &Cli, matches: &ArgMatches) -> Result<AgentCard, CliError> {
2255    warn_if_insecure_with_credentials(cli);
2256
2257    let reference = match resolve_agent_selection(cli, matches)? {
2258        AgentSelection::Card(reference) => reference,
2259        AgentSelection::Endpoint { url, binding } => {
2260            return Ok(synthesized_endpoint_card(&url, binding));
2261        }
2262    };
2263
2264    match normalize_card_reference(&reference) {
2265        CardReference::File(path) => read_agent_card_file(&path),
2266        CardReference::Url(url) => {
2267            let client = if cli.insecure {
2268                build_insecure_reqwest_client()?
2269            } else {
2270                Client::new()
2271            };
2272            let request = apply_request_auth(client.get(url), cli);
2273            let response = request.send().await?.error_for_status()?;
2274            Ok(response.json::<AgentCard>().await?)
2275        }
2276    }
2277}
2278
2279/// Read an Agent Card from disk, classified per Appendix D: a file that
2280/// isn't there or can't be read is `CARD_NOT_FOUND` (the card was not
2281/// found where the caller pointed), while a file that is there but isn't a
2282/// card is `CARD_INVALID` — the same split the HTTP path makes between a
2283/// non-2xx response and a body that won't deserialize.
2284fn read_agent_card_file(path: &Path) -> Result<AgentCard, CliError> {
2285    let text = std::fs::read_to_string(path).map_err(|source| CliError::CardFile {
2286        path: path.display().to_string(),
2287        source,
2288    })?;
2289    serde_json::from_str(&text)
2290        .map_err(|error| CliError::CardInvalid(format!("{}: {error}", path.display())))
2291}
2292
2293fn apply_request_auth(mut request: RequestBuilder, cli: &Cli) -> RequestBuilder {
2294    if let Some(token) = &cli.bearer {
2295        request = request.bearer_auth(token);
2296    }
2297    if let Some(api_key) = &cli.api_key {
2298        request = request.header("X-API-Key", api_key);
2299    }
2300    for param in &cli.svc_params {
2301        request = request.header(&param.name, &param.value);
2302    }
2303    request
2304}
2305
2306/// `--insecure` disables TLS certificate verification (development only).
2307#[cfg(any(feature = "rustls-tls", feature = "native-tls"))]
2308fn build_insecure_reqwest_client() -> Result<Client, CliError> {
2309    Client::builder()
2310        .danger_accept_invalid_certs(true)
2311        .build()
2312        .map_err(CliError::Http)
2313}
2314
2315/// No TLS backend compiled in: there is no certificate verification to
2316/// disable, so `--insecure` is a no-op rather than a build error.
2317#[cfg(not(any(feature = "rustls-tls", feature = "native-tls")))]
2318fn build_insecure_reqwest_client() -> Result<Client, CliError> {
2319    Client::builder().build().map_err(CliError::Http)
2320}
2321
2322/// §12.1/AUTH_003: never disable TLS verification silently. Warns whenever
2323/// `--insecure` is set, and names the specific risk when a credential would
2324/// also be sent over that connection.
2325fn warn_if_insecure_with_credentials(cli: &Cli) {
2326    if !cli.insecure {
2327        return;
2328    }
2329    let sends_credentials = cli.bearer.is_some() || cli.api_key.is_some();
2330    if sends_credentials {
2331        eprintln!(
2332            "warning: --insecure disables TLS certificate verification, and a credential \
2333             (--bearer/--api-key) is being sent over this connection — use only against a \
2334             trusted development endpoint"
2335        );
2336    } else {
2337        eprintln!(
2338            "warning: --insecure disables TLS certificate verification — use only against a \
2339             trusted development endpoint"
2340        );
2341    }
2342}
2343
2344/// Renders `text` mode (§11.2): one `Label: value` field per line, stable
2345/// labels across invocations, no terminal control sequences. Content the
2346/// field form can't carry on one line (a rendered artifact, a formatted data
2347/// part) is a *block*: its own `Label:` line, the content, then a blank
2348/// line, never interleaved with field lines.
2349trait TextRender {
2350    fn render_text(&self) -> String;
2351}
2352
2353/// Accumulates field lines and blocks for [`TextRender`] impls, then joins
2354/// them with a single trailing newline trimmed off (`println!` adds it back).
2355#[derive(Default)]
2356struct TextOutput {
2357    lines: Vec<String>,
2358}
2359
2360impl TextOutput {
2361    fn field(&mut self, label: &str, value: impl std::fmt::Display) -> &mut Self {
2362        self.lines.push(format!("{label}: {value}"));
2363        self
2364    }
2365
2366    fn raw(&mut self, line: impl Into<String>) -> &mut Self {
2367        self.lines.push(line.into());
2368        self
2369    }
2370
2371    /// A block: its own `Label:` line, `content` (each of its lines emitted
2372    /// as-is), then a blank line closing it.
2373    fn block(&mut self, label: &str, content: &str) -> &mut Self {
2374        self.lines.push(format!("{label}:"));
2375        self.lines
2376            .extend(content.lines().map(|line| line.to_string()));
2377        self.lines.push(String::new());
2378        self
2379    }
2380
2381    fn finish(self) -> String {
2382        let mut text = self.lines.join("\n");
2383        while text.ends_with('\n') {
2384            text.pop();
2385        }
2386        text
2387    }
2388}
2389
2390/// Short-form label for a task state (§9.1); the wire form
2391/// (`TASK_STATE_COMPLETED`, ...) is what `-o json` emits, not this.
2392fn task_state_label(state: &TaskState) -> &'static str {
2393    match state {
2394        TaskState::Unspecified => "UNSPECIFIED",
2395        TaskState::Submitted => "SUBMITTED",
2396        TaskState::Working => "WORKING",
2397        TaskState::Completed => "COMPLETED",
2398        TaskState::Failed => "FAILED",
2399        TaskState::Canceled => "CANCELED",
2400        TaskState::InputRequired => "INPUT_REQUIRED",
2401        TaskState::Rejected => "REJECTED",
2402        TaskState::AuthRequired => "AUTH_REQUIRED",
2403    }
2404}
2405
2406/// Render one message part: a text part as readable text, a data part as
2407/// formatted JSON, a file part by name/media type/size (§10.2, §10.3) —
2408/// never dumped as raw structure, and never silently discarded.
2409fn render_part(out: &mut TextOutput, part: &Part) {
2410    match &part.content {
2411        PartContent::Text(text) => {
2412            out.block("Text", text);
2413        }
2414        PartContent::Data(value) => {
2415            let pretty = serde_json::to_string_pretty(value)
2416                .unwrap_or_else(|_| serde_json::Value::Null.to_string());
2417            out.block("Data", &pretty);
2418        }
2419        PartContent::Raw(bytes) => {
2420            let name = part.filename.as_deref().unwrap_or("(unnamed)");
2421            match &part.media_type {
2422                Some(media_type) => out.field(
2423                    "File",
2424                    format!("{name} ({media_type}, {} bytes)", bytes.len()),
2425                ),
2426                None => out.field("File", format!("{name} ({} bytes)", bytes.len())),
2427            };
2428        }
2429        PartContent::Url(url) => {
2430            match &part.media_type {
2431                Some(media_type) => out.field("File", format!("{url} ({media_type})")),
2432                None => out.field("File", url),
2433            };
2434        }
2435    }
2436}
2437
2438fn render_message_parts(out: &mut TextOutput, message: &Message) {
2439    if message.parts.is_empty() {
2440        out.field("Parts", "(none)");
2441        return;
2442    }
2443    for part in &message.parts {
2444        render_part(out, part);
2445    }
2446}
2447
2448impl TextRender for AgentCard {
2449    fn render_text(&self) -> String {
2450        let mut out = TextOutput::default();
2451        out.field("Name", &self.name)
2452            .field("Description", &self.description)
2453            .field("Version", &self.version)
2454            .field("Streaming", self.capabilities.streaming.unwrap_or(false))
2455            .field(
2456                "Push Notifications",
2457                self.capabilities.push_notifications.unwrap_or(false),
2458            )
2459            .field(
2460                "Extended Card",
2461                self.capabilities.extended_agent_card.unwrap_or(false),
2462            );
2463
2464        let interfaces = if self.supported_interfaces.is_empty() {
2465            "  (none)".to_string()
2466        } else {
2467            self.supported_interfaces
2468                .iter()
2469                .map(|interface| {
2470                    format!(
2471                        "  - {} {} (v{})",
2472                        interface.protocol_binding, interface.url, interface.protocol_version
2473                    )
2474                })
2475                .collect::<Vec<_>>()
2476                .join("\n")
2477        };
2478        out.block("Interfaces", &interfaces);
2479        out.field("Skills", self.skills.len());
2480        out.finish()
2481    }
2482}
2483
2484impl TextRender for Task {
2485    fn render_text(&self) -> String {
2486        let mut out = TextOutput::default();
2487        out.field("Task ID", &self.id)
2488            .field("Context ID", &self.context_id)
2489            .field("State", task_state_label(&self.status.state));
2490
2491        if let Some(message) = &self.status.message {
2492            render_message_parts(&mut out, message);
2493        }
2494
2495        if let Some(artifacts) = &self.artifacts {
2496            for artifact in artifacts {
2497                let label = artifact.name.as_deref().unwrap_or(&artifact.artifact_id);
2498                out.raw(format!("Artifact: {label}"));
2499                for part in &artifact.parts {
2500                    render_part(&mut out, part);
2501                }
2502            }
2503        }
2504
2505        if matches!(
2506            self.status.state,
2507            TaskState::InputRequired | TaskState::AuthRequired
2508        ) {
2509            out.field(
2510                "Resume with",
2511                format!("a2acli send --task-id {} \"<reply>\"", self.id),
2512            );
2513        }
2514
2515        out.finish()
2516    }
2517}
2518
2519impl TextRender for Message {
2520    fn render_text(&self) -> String {
2521        let mut out = TextOutput::default();
2522        if let Some(context_id) = &self.context_id {
2523            out.field("Context ID", context_id);
2524        }
2525        if let Some(task_id) = &self.task_id {
2526            out.field("Task ID", task_id);
2527        }
2528        render_message_parts(&mut out, self);
2529        out.finish()
2530    }
2531}
2532
2533impl TextRender for SendMessageResponse {
2534    fn render_text(&self) -> String {
2535        match self {
2536            SendMessageResponse::Task(task) => task.render_text(),
2537            SendMessageResponse::Message(message) => message.render_text(),
2538        }
2539    }
2540}
2541
2542impl TextRender for ListTasksResponse {
2543    fn render_text(&self) -> String {
2544        let mut out = TextOutput::default();
2545        out.field("Total", self.total_size)
2546            .field("Page Size", self.page_size);
2547        if !self.next_page_token.is_empty() {
2548            out.field("Next Page Token", &self.next_page_token);
2549        }
2550        if self.tasks.is_empty() {
2551            out.field("Tasks", "(none)");
2552        } else {
2553            for task in &self.tasks {
2554                out.block("Task", &task.render_text());
2555            }
2556        }
2557        out.finish()
2558    }
2559}
2560
2561impl TextRender for TaskPushNotificationConfig {
2562    fn render_text(&self) -> String {
2563        let mut out = TextOutput::default();
2564        if let Some(id) = &self.id {
2565            out.field("Config ID", id);
2566        }
2567        out.field("Task ID", &self.task_id).field("URL", &self.url);
2568        if let Some(token) = &self.token {
2569            out.field("Token", token);
2570        }
2571        if let Some(auth) = &self.authentication {
2572            out.field("Auth Scheme", &auth.scheme);
2573        }
2574        out.finish()
2575    }
2576}
2577
2578impl TextRender for ListTaskPushNotificationConfigsResponse {
2579    fn render_text(&self) -> String {
2580        let mut out = TextOutput::default();
2581        if let Some(token) = &self.next_page_token {
2582            out.field("Next Page Token", token);
2583        }
2584        if self.configs.is_empty() {
2585            out.field("Configs", "(none)");
2586        } else {
2587            for config in &self.configs {
2588                out.block("Push Config", &config.render_text());
2589            }
2590        }
2591        out.finish()
2592    }
2593}
2594
2595/// The result of `task push-config delete`, replacing an ad hoc
2596/// `serde_json::json!` value so it can carry both a `-o json` shape and a
2597/// `-o text` rendering like every other response type.
2598#[derive(Debug, Clone, Serialize)]
2599#[serde(rename_all = "camelCase")]
2600struct PushConfigDeleted {
2601    deleted: bool,
2602    task_id: String,
2603    id: String,
2604}
2605
2606impl TextRender for PushConfigDeleted {
2607    fn render_text(&self) -> String {
2608        let mut out = TextOutput::default();
2609        out.field("Deleted", self.deleted)
2610            .field("Task ID", &self.task_id)
2611            .field("Config ID", &self.id);
2612        out.finish()
2613    }
2614}
2615
2616impl TextRender for StreamResponse {
2617    fn render_text(&self) -> String {
2618        match self {
2619            StreamResponse::Task(task) => task.render_text(),
2620            StreamResponse::Message(message) => message.render_text(),
2621            StreamResponse::StatusUpdate(event) => {
2622                let mut out = TextOutput::default();
2623                out.field("Task ID", &event.task_id)
2624                    .field("Context ID", &event.context_id)
2625                    .field("State", task_state_label(&event.status.state));
2626                if matches!(
2627                    event.status.state,
2628                    TaskState::InputRequired | TaskState::AuthRequired
2629                ) {
2630                    out.field(
2631                        "Resume with",
2632                        format!("a2acli send --task-id {} \"<reply>\"", event.task_id),
2633                    );
2634                }
2635                out.finish()
2636            }
2637            StreamResponse::ArtifactUpdate(event) => {
2638                let mut out = TextOutput::default();
2639                out.field("Task ID", &event.task_id)
2640                    .field("Context ID", &event.context_id);
2641                let label = event
2642                    .artifact
2643                    .name
2644                    .as_deref()
2645                    .unwrap_or(&event.artifact.artifact_id);
2646                out.raw(format!("Artifact: {label}"));
2647                for part in &event.artifact.parts {
2648                    render_part(&mut out, part);
2649                }
2650                out.finish()
2651            }
2652        }
2653    }
2654}
2655
2656/// Print `value` in the caller-selected format: `text` (default, §11.2) or
2657/// `-o json` as one pretty/compact document per §11.3 (never JSONL — that
2658/// form is only used by [`consume_stream`] under `--stream`).
2659fn print_output<T: Serialize + TextRender>(value: &T, cli: &Cli) -> Result<(), CliError> {
2660    match cli.output {
2661        OutputFormat::Text => {
2662            println!("{}", value.render_text());
2663            Ok(())
2664        }
2665        OutputFormat::Json => print_json(value, cli.compact),
2666    }
2667}
2668
2669fn print_json<T: Serialize>(value: &T, compact: bool) -> Result<(), CliError> {
2670    if compact {
2671        println!("{}", serde_json::to_string(value)?);
2672    } else {
2673        println!("{}", serde_json::to_string_pretty(value)?);
2674    }
2675    Ok(())
2676}
2677
2678fn parse_header(input: &str) -> Result<HeaderArg, String> {
2679    let (name, value) = input
2680        .split_once(':')
2681        .ok_or_else(|| "header must be in NAME:VALUE format".to_string())?;
2682
2683    let name = name.trim();
2684    let value = value.trim();
2685
2686    if name.is_empty() {
2687        return Err("header name cannot be empty".to_string());
2688    }
2689
2690    Ok(HeaderArg {
2691        name: name.to_string(),
2692        value: value.to_string(),
2693    })
2694}
2695
2696async fn finish_client_call<T: a2a_client::Transport, V>(
2697    client: A2AClient<T>,
2698    result: Result<V, A2AError>,
2699) -> Result<V, CliError> {
2700    match result {
2701        Ok(value) => {
2702            client.destroy().await?;
2703            Ok(value)
2704        }
2705        Err(error) => {
2706            let _ = client.destroy().await;
2707            Err(error.into())
2708        }
2709    }
2710}
2711
2712/// Consume a streamed event sequence to completion, printing each event as
2713/// it arrives. Under `-o json`, always emits JSONL — one complete, compact
2714/// object per line, flushed as produced — regardless of `--compact`, which
2715/// only affects the single-document form (§11.3). Under `-o text`, each
2716/// event is rendered in the same field-and-block form as a one-shot result.
2717async fn consume_stream<T: a2a_client::Transport, V: Serialize + TextRender + TaskOutcome>(
2718    client: A2AClient<T>,
2719    mut stream: BoxStream<'static, Result<V, A2AError>>,
2720    cli: &Cli,
2721) -> Result<(), CliError> {
2722    loop {
2723        match stream.next().await {
2724            Some(Ok(value)) => {
2725                value.warn_outcome();
2726                let printed = match cli.output {
2727                    OutputFormat::Text => {
2728                        println!("{}", value.render_text());
2729                        Ok(())
2730                    }
2731                    OutputFormat::Json => serde_json::to_string(&value)
2732                        .map(|line| println!("{line}"))
2733                        .map_err(CliError::from),
2734                };
2735                if let Err(error) = printed {
2736                    let _ = client.destroy().await;
2737                    return Err(error);
2738                }
2739            }
2740            Some(Err(error)) => {
2741                let _ = client.destroy().await;
2742                return Err(error.into());
2743            }
2744            None => {
2745                client.destroy().await?;
2746                return Ok(());
2747            }
2748        }
2749    }
2750}
2751
2752#[cfg(test)]
2753mod tests {
2754    use super::*;
2755    use a2a_client::{ServiceParams, Transport};
2756    use a2a_server::jsonrpc::jsonrpc_router;
2757    use a2a_server::rest::rest_router;
2758    use a2a_server::{
2759        RequestHandler, ServiceParams as HandlerServiceParams, WELL_KNOWN_AGENT_CARD_PATH,
2760    };
2761    use async_trait::async_trait;
2762    use axum::routing::get;
2763    use axum::{Json, Router};
2764    use futures::stream;
2765    use reqwest::header;
2766    use serde::ser;
2767    use std::collections::BTreeMap;
2768    use std::sync::Mutex;
2769    use tokio::net::TcpListener;
2770
2771    struct TestTransport {
2772        destroy_error: Option<A2AError>,
2773    }
2774
2775    #[async_trait]
2776    impl Transport for TestTransport {
2777        async fn send_message(
2778            &self,
2779            _params: &ServiceParams,
2780            _req: &SendMessageRequest,
2781        ) -> Result<SendMessageResponse, A2AError> {
2782            Err(A2AError::unsupported_operation("unused"))
2783        }
2784
2785        async fn send_streaming_message(
2786            &self,
2787            _params: &ServiceParams,
2788            _req: &SendMessageRequest,
2789        ) -> Result<BoxStream<'static, Result<StreamResponse, A2AError>>, A2AError> {
2790            Err(A2AError::unsupported_operation("unused"))
2791        }
2792
2793        async fn get_task(
2794            &self,
2795            _params: &ServiceParams,
2796            _req: &GetTaskRequest,
2797        ) -> Result<Task, A2AError> {
2798            Err(A2AError::unsupported_operation("unused"))
2799        }
2800
2801        async fn list_tasks(
2802            &self,
2803            _params: &ServiceParams,
2804            _req: &ListTasksRequest,
2805        ) -> Result<ListTasksResponse, A2AError> {
2806            Err(A2AError::unsupported_operation("unused"))
2807        }
2808
2809        async fn cancel_task(
2810            &self,
2811            _params: &ServiceParams,
2812            _req: &CancelTaskRequest,
2813        ) -> Result<Task, A2AError> {
2814            Err(A2AError::unsupported_operation("unused"))
2815        }
2816
2817        async fn subscribe_to_task(
2818            &self,
2819            _params: &ServiceParams,
2820            _req: &SubscribeToTaskRequest,
2821        ) -> Result<BoxStream<'static, Result<StreamResponse, A2AError>>, A2AError> {
2822            Err(A2AError::unsupported_operation("unused"))
2823        }
2824
2825        async fn create_push_config(
2826            &self,
2827            _params: &ServiceParams,
2828            _req: &TaskPushNotificationConfig,
2829        ) -> Result<TaskPushNotificationConfig, A2AError> {
2830            Err(A2AError::unsupported_operation("unused"))
2831        }
2832
2833        async fn get_push_config(
2834            &self,
2835            _params: &ServiceParams,
2836            _req: &GetTaskPushNotificationConfigRequest,
2837        ) -> Result<TaskPushNotificationConfig, A2AError> {
2838            Err(A2AError::unsupported_operation("unused"))
2839        }
2840
2841        async fn list_push_configs(
2842            &self,
2843            _params: &ServiceParams,
2844            _req: &ListTaskPushNotificationConfigsRequest,
2845        ) -> Result<ListTaskPushNotificationConfigsResponse, A2AError> {
2846            Err(A2AError::unsupported_operation("unused"))
2847        }
2848
2849        async fn delete_push_config(
2850            &self,
2851            _params: &ServiceParams,
2852            _req: &DeleteTaskPushNotificationConfigRequest,
2853        ) -> Result<(), A2AError> {
2854            Err(A2AError::unsupported_operation("unused"))
2855        }
2856
2857        async fn get_extended_agent_card(
2858            &self,
2859            _params: &ServiceParams,
2860            _req: &GetExtendedAgentCardRequest,
2861        ) -> Result<AgentCard, A2AError> {
2862            Err(A2AError::unsupported_operation("unused"))
2863        }
2864
2865        async fn destroy(&self) -> Result<(), A2AError> {
2866            match &self.destroy_error {
2867                Some(error) => Err(error.clone()),
2868                None => Ok(()),
2869            }
2870        }
2871    }
2872
2873    struct FailingSerialize;
2874
2875    impl Serialize for FailingSerialize {
2876        fn serialize<S>(&self, _serializer: S) -> Result<S::Ok, S::Error>
2877        where
2878            S: serde::Serializer,
2879        {
2880            Err(ser::Error::custom("serialize failed"))
2881        }
2882    }
2883
2884    impl TextRender for FailingSerialize {
2885        fn render_text(&self) -> String {
2886            String::new()
2887        }
2888    }
2889
2890    impl TaskOutcome for FailingSerialize {
2891        fn warn_outcome(&self) {}
2892    }
2893
2894    fn make_test_client(destroy_error: Option<A2AError>) -> A2AClient<TestTransport> {
2895        A2AClient::new(TestTransport { destroy_error })
2896    }
2897
2898    #[derive(Default)]
2899    struct RunTestState {
2900        tasks: Mutex<BTreeMap<String, Task>>,
2901        push_configs: Mutex<BTreeMap<(String, String), TaskPushNotificationConfig>>,
2902    }
2903
2904    struct RunTestHandler {
2905        state: Arc<RunTestState>,
2906        extended_card: AgentCard,
2907    }
2908
2909    struct RunTestServer {
2910        base_url: String,
2911        handle: tokio::task::JoinHandle<()>,
2912    }
2913
2914    impl Drop for RunTestServer {
2915        fn drop(&mut self) {
2916            self.handle.abort();
2917        }
2918    }
2919
2920    impl RunTestServer {
2921        async fn spawn() -> Self {
2922            let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
2923            let base_url = format!("http://{}", listener.local_addr().unwrap());
2924            let state = Arc::new(RunTestState::default());
2925
2926            state.tasks.lock().unwrap().insert(
2927                "task-1".to_string(),
2928                make_fixture_task("task-1", "ctx-1", TaskState::Completed, "seeded result"),
2929            );
2930
2931            let public_card = make_fixture_card(&base_url, "Fixture Agent");
2932            let extended_card = make_fixture_card(&base_url, "Fixture Agent (extended)");
2933            let handler = Arc::new(RunTestHandler {
2934                state,
2935                extended_card,
2936            });
2937
2938            let card = public_card.clone();
2939            let app = Router::new()
2940                .route(
2941                    WELL_KNOWN_AGENT_CARD_PATH,
2942                    get(move || {
2943                        let card = card.clone();
2944                        async move { Json(card) }
2945                    }),
2946                )
2947                .nest("/jsonrpc", jsonrpc_router(handler.clone()))
2948                .nest("/rest", rest_router(handler));
2949
2950            let handle = tokio::spawn(async move {
2951                axum::serve(listener, app).await.unwrap();
2952            });
2953
2954            RunTestServer { base_url, handle }
2955        }
2956    }
2957
2958    #[async_trait]
2959    impl RequestHandler for RunTestHandler {
2960        async fn send_message(
2961            &self,
2962            _params: &HandlerServiceParams,
2963            req: SendMessageRequest,
2964        ) -> Result<SendMessageResponse, A2AError> {
2965            let task_id = req
2966                .message
2967                .task_id
2968                .clone()
2969                .unwrap_or_else(|| "task-send".to_string());
2970            let context_id = req
2971                .message
2972                .context_id
2973                .clone()
2974                .unwrap_or_else(|| "ctx-send".to_string());
2975            let text = req.message.text().unwrap_or_default();
2976            if text == "send-error" {
2977                return Err(A2AError::invalid_request("send failed"));
2978            }
2979
2980            let task = make_fixture_task(
2981                &task_id,
2982                &context_id,
2983                TaskState::Completed,
2984                &format!("Echo: {text}"),
2985            );
2986            self.state
2987                .tasks
2988                .lock()
2989                .unwrap()
2990                .insert(task_id, task.clone());
2991            Ok(SendMessageResponse::Task(task))
2992        }
2993
2994        async fn send_streaming_message(
2995            &self,
2996            _params: &HandlerServiceParams,
2997            req: SendMessageRequest,
2998        ) -> Result<BoxStream<'static, Result<StreamResponse, A2AError>>, A2AError> {
2999            let task_id = req
3000                .message
3001                .task_id
3002                .clone()
3003                .unwrap_or_else(|| "task-stream".to_string());
3004            let context_id = req
3005                .message
3006                .context_id
3007                .clone()
3008                .unwrap_or_else(|| "ctx-stream".to_string());
3009            let text = req.message.text().unwrap_or_default();
3010            if text == "stream-error" {
3011                return Ok(Box::pin(stream::once(async {
3012                    Err(A2AError::internal("stream failed"))
3013                })));
3014            }
3015
3016            let task = make_fixture_task(
3017                &task_id,
3018                &context_id,
3019                TaskState::Completed,
3020                &format!("Echo: {text}"),
3021            );
3022            self.state
3023                .tasks
3024                .lock()
3025                .unwrap()
3026                .insert(task_id.clone(), task.clone());
3027
3028            Ok(Box::pin(stream::iter(vec![
3029                Ok(StreamResponse::StatusUpdate(TaskStatusUpdateEvent {
3030                    task_id,
3031                    context_id,
3032                    status: TaskStatus {
3033                        state: TaskState::Working,
3034                        message: None,
3035                        timestamp: None,
3036                    },
3037                    metadata: None,
3038                })),
3039                Ok(StreamResponse::Task(task)),
3040            ])))
3041        }
3042
3043        async fn get_task(
3044            &self,
3045            _params: &HandlerServiceParams,
3046            req: GetTaskRequest,
3047        ) -> Result<Task, A2AError> {
3048            self.state
3049                .tasks
3050                .lock()
3051                .unwrap()
3052                .get(&req.id)
3053                .cloned()
3054                .ok_or_else(|| A2AError::task_not_found(&req.id))
3055        }
3056
3057        async fn list_tasks(
3058            &self,
3059            _params: &HandlerServiceParams,
3060            req: ListTasksRequest,
3061        ) -> Result<ListTasksResponse, A2AError> {
3062            if req.context_id.as_deref() == Some("error") {
3063                return Err(A2AError::invalid_params("list failed"));
3064            }
3065
3066            let tasks = self
3067                .state
3068                .tasks
3069                .lock()
3070                .unwrap()
3071                .values()
3072                .filter(|task| {
3073                    req.context_id
3074                        .as_ref()
3075                        .map(|context_id| task.context_id == *context_id)
3076                        .unwrap_or(true)
3077                })
3078                .filter(|task| {
3079                    req.status
3080                        .as_ref()
3081                        .map(|status| task.status.state == *status)
3082                        .unwrap_or(true)
3083                })
3084                .cloned()
3085                .collect();
3086
3087            Ok(ListTasksResponse {
3088                tasks,
3089                next_page_token: String::new(),
3090                page_size: 0,
3091                total_size: 0,
3092            })
3093        }
3094
3095        async fn cancel_task(
3096            &self,
3097            _params: &HandlerServiceParams,
3098            req: CancelTaskRequest,
3099        ) -> Result<Task, A2AError> {
3100            let mut tasks = self.state.tasks.lock().unwrap();
3101            let task = tasks
3102                .get(&req.id)
3103                .cloned()
3104                .ok_or_else(|| A2AError::task_not_found(&req.id))?;
3105            let canceled =
3106                make_fixture_task(&task.id, &task.context_id, TaskState::Canceled, "canceled");
3107            tasks.insert(req.id, canceled.clone());
3108            Ok(canceled)
3109        }
3110
3111        async fn subscribe_to_task(
3112            &self,
3113            _params: &HandlerServiceParams,
3114            req: SubscribeToTaskRequest,
3115        ) -> Result<BoxStream<'static, Result<StreamResponse, A2AError>>, A2AError> {
3116            if req.id == "stream-error" {
3117                return Ok(Box::pin(stream::once(async {
3118                    Err(A2AError::internal("stream failed"))
3119                })));
3120            }
3121
3122            let task = self
3123                .state
3124                .tasks
3125                .lock()
3126                .unwrap()
3127                .get(&req.id)
3128                .cloned()
3129                .ok_or_else(|| A2AError::task_not_found(&req.id))?;
3130
3131            Ok(Box::pin(stream::iter(vec![
3132                Ok(StreamResponse::StatusUpdate(TaskStatusUpdateEvent {
3133                    task_id: req.id.clone(),
3134                    context_id: task.context_id.clone(),
3135                    status: TaskStatus {
3136                        state: TaskState::Working,
3137                        message: None,
3138                        timestamp: None,
3139                    },
3140                    metadata: None,
3141                })),
3142                Ok(StreamResponse::Task(task)),
3143            ])))
3144        }
3145
3146        async fn create_push_config(
3147            &self,
3148            _params: &HandlerServiceParams,
3149            req: TaskPushNotificationConfig,
3150        ) -> Result<TaskPushNotificationConfig, A2AError> {
3151            if !self.state.tasks.lock().unwrap().contains_key(&req.task_id) {
3152                return Err(A2AError::task_not_found(&req.task_id));
3153            }
3154
3155            let config_id = req.id.clone().unwrap_or_else(|| "generated".to_string());
3156            self.state
3157                .push_configs
3158                .lock()
3159                .unwrap()
3160                .insert((req.task_id.clone(), config_id), req.clone());
3161            Ok(req)
3162        }
3163
3164        async fn get_push_config(
3165            &self,
3166            _params: &HandlerServiceParams,
3167            req: GetTaskPushNotificationConfigRequest,
3168        ) -> Result<TaskPushNotificationConfig, A2AError> {
3169            self.state
3170                .push_configs
3171                .lock()
3172                .unwrap()
3173                .get(&(req.task_id.clone(), req.id.clone()))
3174                .cloned()
3175                .ok_or_else(|| A2AError::task_not_found(&req.task_id))
3176        }
3177
3178        async fn list_push_configs(
3179            &self,
3180            _params: &HandlerServiceParams,
3181            req: ListTaskPushNotificationConfigsRequest,
3182        ) -> Result<ListTaskPushNotificationConfigsResponse, A2AError> {
3183            if req.task_id == "missing" {
3184                return Err(A2AError::task_not_found(&req.task_id));
3185            }
3186
3187            let configs = self
3188                .state
3189                .push_configs
3190                .lock()
3191                .unwrap()
3192                .values()
3193                .filter(|config| config.task_id == req.task_id)
3194                .cloned()
3195                .collect();
3196
3197            Ok(ListTaskPushNotificationConfigsResponse {
3198                configs,
3199                next_page_token: None,
3200            })
3201        }
3202
3203        async fn delete_push_config(
3204            &self,
3205            _params: &HandlerServiceParams,
3206            req: DeleteTaskPushNotificationConfigRequest,
3207        ) -> Result<(), A2AError> {
3208            let deleted = self
3209                .state
3210                .push_configs
3211                .lock()
3212                .unwrap()
3213                .remove(&(req.task_id.clone(), req.id.clone()));
3214            if deleted.is_none() {
3215                return Err(A2AError::task_not_found(&req.task_id));
3216            }
3217            Ok(())
3218        }
3219
3220        async fn get_extended_agent_card(
3221            &self,
3222            _params: &HandlerServiceParams,
3223            req: GetExtendedAgentCardRequest,
3224        ) -> Result<AgentCard, A2AError> {
3225            if req.tenant.as_deref() == Some("error") {
3226                return Err(A2AError::unsupported_operation("extended card denied"));
3227            }
3228
3229            Ok(self.extended_card.clone())
3230        }
3231    }
3232
3233    fn make_fixture_card(base_url: &str, name: &str) -> AgentCard {
3234        AgentCard {
3235            name: name.to_string(),
3236            description: "CLI unit-test fixture".to_string(),
3237            version: VERSION.to_string(),
3238            supported_interfaces: vec![
3239                AgentInterface::new(format!("{base_url}/jsonrpc"), TRANSPORT_PROTOCOL_JSONRPC),
3240                AgentInterface::new(format!("{base_url}/rest"), TRANSPORT_PROTOCOL_HTTP_JSON),
3241            ],
3242            capabilities: AgentCapabilities {
3243                streaming: Some(true),
3244                push_notifications: Some(true),
3245                extensions: None,
3246                extended_agent_card: Some(true),
3247            },
3248            default_input_modes: vec!["text/plain".to_string()],
3249            default_output_modes: vec!["text/plain".to_string()],
3250            skills: vec![],
3251            provider: None,
3252            documentation_url: None,
3253            icon_url: None,
3254            security_schemes: None,
3255            security_requirements: None,
3256            signatures: None,
3257        }
3258    }
3259
3260    fn make_fixture_task(task_id: &str, context_id: &str, state: TaskState, text: &str) -> Task {
3261        Task {
3262            id: task_id.to_string(),
3263            context_id: context_id.to_string(),
3264            status: TaskStatus {
3265                state,
3266                message: Some(Message {
3267                    message_id: format!("msg-{task_id}"),
3268                    context_id: Some(context_id.to_string()),
3269                    task_id: Some(task_id.to_string()),
3270                    role: Role::Agent,
3271                    parts: vec![Part::text(text)],
3272                    metadata: None,
3273                    extensions: None,
3274                    reference_task_ids: None,
3275                }),
3276                timestamp: None,
3277            },
3278            artifacts: None,
3279            history: None,
3280            metadata: None,
3281        }
3282    }
3283
3284    fn build_args(base_url: &str, args: &[&str]) -> Vec<String> {
3285        let mut argv = vec![
3286            "a2acli".to_string(),
3287            "--agent-card".to_string(),
3288            base_url.to_string(),
3289        ];
3290        argv.extend(args.iter().map(|arg| (*arg).to_string()));
3291        argv
3292    }
3293
3294    /// Parse and run `a2acli` exactly as the real binary does, so the
3295    /// `ArgMatches`-derived part ordering (`resolve_message_parts`) is
3296    /// exercised the same way in tests as in production.
3297    async fn run_test_cli(base_url: &str, args: &[&str]) -> Result<(), CliError> {
3298        run_args(build_args(base_url, args)).await
3299    }
3300
3301    async fn unused_base_url() -> String {
3302        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
3303        let addr = listener.local_addr().unwrap();
3304        drop(listener);
3305        format!("http://{addr}")
3306    }
3307
3308    fn assert_unsupported_operation<T>(result: Result<T, A2AError>) {
3309        match result {
3310            Ok(_) => panic!("expected unsupported operation error"),
3311            Err(err) => assert_eq!(err.code, a2a::error_code::UNSUPPORTED_OPERATION),
3312        }
3313    }
3314
3315    #[test]
3316    fn test_parse_header() {
3317        let header = parse_header("Authorization: Bearer token").unwrap();
3318        assert_eq!(header.name, "Authorization");
3319        assert_eq!(header.value, "Bearer token");
3320    }
3321
3322    #[test]
3323    fn test_parse_header_requires_separator() {
3324        let err = parse_header("Authorization").unwrap_err();
3325        assert_eq!(err, "header must be in NAME:VALUE format");
3326    }
3327
3328    #[test]
3329    fn test_parse_header_requires_name() {
3330        let err = parse_header(": value").unwrap_err();
3331        assert_eq!(err, "header name cannot be empty");
3332    }
3333
3334    #[test]
3335    fn test_build_send_message_request_populates_optional_fields() {
3336        let request = build_send_message_request(
3337            &MessageCommand {
3338                text: Some("hello".to_string()),
3339                text_parts: Vec::new(),
3340                file_parts: Vec::new(),
3341                data_parts: Vec::new(),
3342                media_types: Vec::new(),
3343                context_id: Some("ctx-1".to_string()),
3344                task_id: Some("task-1".to_string()),
3345                history_length: Some(4),
3346                accepted_output_modes: vec!["text/plain".to_string()],
3347                return_immediately: true,
3348                stream: false,
3349            },
3350            vec![Part::text("hello")],
3351            Some("tenant-1".to_string()),
3352        );
3353
3354        assert_eq!(request.message.text(), Some("hello"));
3355        assert_eq!(request.message.context_id.as_deref(), Some("ctx-1"));
3356        assert_eq!(request.message.task_id.as_deref(), Some("task-1"));
3357        assert_eq!(request.tenant.as_deref(), Some("tenant-1"));
3358        assert_eq!(
3359            request
3360                .configuration
3361                .as_ref()
3362                .and_then(|config| config.history_length),
3363            Some(4)
3364        );
3365        assert_eq!(
3366            request
3367                .configuration
3368                .as_ref()
3369                .and_then(|config| config.return_immediately),
3370            Some(true)
3371        );
3372    }
3373
3374    #[test]
3375    fn test_build_send_message_request_without_optional_fields() {
3376        let request = build_send_message_request(
3377            &MessageCommand {
3378                text: Some("hello".to_string()),
3379                text_parts: Vec::new(),
3380                file_parts: Vec::new(),
3381                data_parts: Vec::new(),
3382                media_types: Vec::new(),
3383                context_id: None,
3384                task_id: None,
3385                history_length: None,
3386                accepted_output_modes: Vec::new(),
3387                return_immediately: false,
3388                stream: false,
3389            },
3390            vec![Part::text("hello")],
3391            None,
3392        );
3393
3394        assert_eq!(request.message.text(), Some("hello"));
3395        assert!(request.configuration.is_none());
3396        assert!(request.tenant.is_none());
3397    }
3398
3399    #[test]
3400    fn test_cli_parse_send_command() {
3401        let cli = Cli::try_parse_from([
3402            "a2acli",
3403            "--transport",
3404            "jsonrpc",
3405            "--svc-param",
3406            "X-Test:123",
3407            "send",
3408            "hello",
3409            "--history-length",
3410            "2",
3411        ])
3412        .unwrap();
3413
3414        assert_eq!(cli.transport, vec![Binding::Jsonrpc]);
3415        assert_eq!(cli.svc_params.len(), 1);
3416        assert!(matches!(cli.command, Command::Send(_)));
3417    }
3418
3419    #[test]
3420    fn test_cli_parse_push_config_create_command() {
3421        let cli = Cli::try_parse_from([
3422            "a2acli",
3423            "task",
3424            "push-config",
3425            "create",
3426            "task-1",
3427            "https://example.com/callback",
3428            "--config-id",
3429            "cfg-1",
3430            "--token",
3431            "tok-1",
3432            "--auth-scheme",
3433            "Bearer",
3434            "--auth-credentials",
3435            "secret",
3436        ])
3437        .unwrap();
3438
3439        match cli.command {
3440            Command::Task {
3441                command:
3442                    TaskCommand::PushConfig {
3443                        command: PushConfigCommand::Create(command),
3444                    },
3445            } => {
3446                assert_eq!(command.task_id, "task-1");
3447                assert_eq!(command.url, "https://example.com/callback");
3448                assert_eq!(command.config_id.as_deref(), Some("cfg-1"));
3449                assert_eq!(command.token.as_deref(), Some("tok-1"));
3450                assert_eq!(command.auth_scheme.as_deref(), Some("Bearer"));
3451                assert_eq!(command.auth_credentials.as_deref(), Some("secret"));
3452            }
3453            other => panic!("unexpected command: {other:?}"),
3454        }
3455    }
3456
3457    #[test]
3458    fn test_build_push_notification_config() {
3459        let config = build_push_notification_config(&CreatePushConfigCommand {
3460            task_id: "task-1".to_string(),
3461            url: "https://example.com/callback".to_string(),
3462            config_id: Some("cfg-1".to_string()),
3463            token: Some("tok-1".to_string()),
3464            auth_scheme: Some("Bearer".to_string()),
3465            auth_credentials: Some("secret".to_string()),
3466        })
3467        .unwrap();
3468
3469        assert_eq!(config.id.as_deref(), Some("cfg-1"));
3470        assert_eq!(config.token.as_deref(), Some("tok-1"));
3471        assert_eq!(
3472            config
3473                .authentication
3474                .as_ref()
3475                .map(|auth| auth.scheme.as_str()),
3476            Some("Bearer")
3477        );
3478        assert_eq!(
3479            config
3480                .authentication
3481                .as_ref()
3482                .and_then(|auth| auth.credentials.as_deref()),
3483            Some("secret")
3484        );
3485    }
3486
3487    #[test]
3488    fn test_build_push_notification_config_requires_auth_scheme() {
3489        let err = build_push_notification_config(&CreatePushConfigCommand {
3490            task_id: "task-1".to_string(),
3491            url: "https://example.com/callback".to_string(),
3492            config_id: None,
3493            token: None,
3494            auth_scheme: None,
3495            auth_credentials: Some("secret".to_string()),
3496        })
3497        .unwrap_err();
3498
3499        assert!(matches!(err, CliError::InvalidInput(_)));
3500    }
3501
3502    #[test]
3503    fn test_build_push_notification_config_without_authentication() {
3504        let config = build_push_notification_config(&CreatePushConfigCommand {
3505            task_id: "task-1".to_string(),
3506            url: "https://example.com/callback".to_string(),
3507            config_id: None,
3508            token: None,
3509            auth_scheme: None,
3510            auth_credentials: None,
3511        })
3512        .unwrap();
3513
3514        assert_eq!(config.url, "https://example.com/callback");
3515        assert!(config.authentication.is_none());
3516    }
3517
3518    #[test]
3519    fn test_binding_protocols() {
3520        assert_eq!(Binding::Jsonrpc.protocol(), TRANSPORT_PROTOCOL_JSONRPC);
3521        assert_eq!(Binding::Rest.protocol(), TRANSPORT_PROTOCOL_HTTP_JSON);
3522    }
3523
3524    #[test]
3525    fn test_apply_request_auth_builds_headers() {
3526        let cli = Cli::try_parse_from([
3527            "a2acli",
3528            "--bearer",
3529            "secret",
3530            "--api-key",
3531            "key-123",
3532            "--svc-param",
3533            "X-Test: 123",
3534            "card",
3535            "get",
3536        ])
3537        .unwrap();
3538
3539        let request = apply_request_auth(Client::new().get("http://example.com"), &cli)
3540            .build()
3541            .unwrap();
3542
3543        assert_eq!(
3544            request.headers().get(header::AUTHORIZATION).unwrap(),
3545            "Bearer secret"
3546        );
3547        assert_eq!(request.headers().get("X-API-Key").unwrap(), "key-123");
3548        assert_eq!(request.headers().get("X-Test").unwrap(), "123");
3549    }
3550
3551    #[tokio::test]
3552    async fn test_finish_client_call_propagates_destroy_error() {
3553        let err = finish_client_call(
3554            make_test_client(Some(A2AError::internal("destroy failed"))),
3555            Ok(serde_json::json!({ "ok": true })),
3556        )
3557        .await
3558        .unwrap_err();
3559
3560        assert!(matches!(err, CliError::A2A(_)));
3561    }
3562
3563    fn json_cli() -> Cli {
3564        parse_with_matches(&["--output", "json", "task", "get", "unused"]).0
3565    }
3566
3567    #[tokio::test]
3568    async fn test_consume_stream_reports_json_error() {
3569        let stream = Box::pin(stream::once(async { Ok(FailingSerialize) }));
3570        let err = consume_stream(make_test_client(None), stream, &json_cli())
3571            .await
3572            .unwrap_err();
3573
3574        assert!(matches!(err, CliError::Json(_)));
3575    }
3576
3577    #[tokio::test]
3578    async fn test_consume_stream_propagates_destroy_error_on_completion() {
3579        let stream = Box::pin(stream::empty::<Result<Task, A2AError>>());
3580        let err = consume_stream(
3581            make_test_client(Some(A2AError::internal("destroy failed"))),
3582            stream,
3583            &json_cli(),
3584        )
3585        .await
3586        .unwrap_err();
3587
3588        assert!(matches!(err, CliError::A2A(_)));
3589    }
3590
3591    #[tokio::test]
3592    async fn test_test_transport_methods_return_unused_errors() {
3593        let transport = TestTransport {
3594            destroy_error: None,
3595        };
3596        let params = ServiceParams::new();
3597
3598        assert_unsupported_operation(
3599            transport
3600                .send_message(
3601                    &params,
3602                    &SendMessageRequest {
3603                        message: Message::new(Role::User, vec![Part::text("hello")]),
3604                        configuration: None,
3605                        metadata: None,
3606                        tenant: None,
3607                    },
3608                )
3609                .await,
3610        );
3611        assert_unsupported_operation(
3612            transport
3613                .send_streaming_message(
3614                    &params,
3615                    &SendMessageRequest {
3616                        message: Message::new(Role::User, vec![Part::text("hello")]),
3617                        configuration: None,
3618                        metadata: None,
3619                        tenant: None,
3620                    },
3621                )
3622                .await,
3623        );
3624        assert_unsupported_operation(
3625            transport
3626                .get_task(
3627                    &params,
3628                    &GetTaskRequest {
3629                        id: "task-1".to_string(),
3630                        history_length: None,
3631                        tenant: None,
3632                    },
3633                )
3634                .await,
3635        );
3636        assert_unsupported_operation(
3637            transport
3638                .list_tasks(
3639                    &params,
3640                    &ListTasksRequest {
3641                        context_id: None,
3642                        status: None,
3643                        page_size: None,
3644                        page_token: None,
3645                        history_length: None,
3646                        status_timestamp_after: None,
3647                        include_artifacts: None,
3648                        tenant: None,
3649                    },
3650                )
3651                .await,
3652        );
3653        assert_unsupported_operation(
3654            transport
3655                .cancel_task(
3656                    &params,
3657                    &CancelTaskRequest {
3658                        id: "task-1".to_string(),
3659                        metadata: None,
3660                        tenant: None,
3661                    },
3662                )
3663                .await,
3664        );
3665        assert_unsupported_operation(
3666            transport
3667                .subscribe_to_task(
3668                    &params,
3669                    &SubscribeToTaskRequest {
3670                        id: "task-1".to_string(),
3671                        tenant: None,
3672                    },
3673                )
3674                .await,
3675        );
3676        assert_unsupported_operation(
3677            transport
3678                .create_push_config(
3679                    &params,
3680                    &TaskPushNotificationConfig {
3681                        task_id: "task-1".to_string(),
3682                        url: "https://example.com/callback".to_string(),
3683                        id: Some("cfg-1".to_string()),
3684                        token: None,
3685                        authentication: None,
3686                        tenant: None,
3687                    },
3688                )
3689                .await,
3690        );
3691        assert_unsupported_operation(
3692            transport
3693                .get_push_config(
3694                    &params,
3695                    &GetTaskPushNotificationConfigRequest {
3696                        task_id: "task-1".to_string(),
3697                        id: "cfg-1".to_string(),
3698                        tenant: None,
3699                    },
3700                )
3701                .await,
3702        );
3703        assert_unsupported_operation(
3704            transport
3705                .list_push_configs(
3706                    &params,
3707                    &ListTaskPushNotificationConfigsRequest {
3708                        task_id: "task-1".to_string(),
3709                        page_size: None,
3710                        page_token: None,
3711                        tenant: None,
3712                    },
3713                )
3714                .await,
3715        );
3716        assert_unsupported_operation(
3717            transport
3718                .delete_push_config(
3719                    &params,
3720                    &DeleteTaskPushNotificationConfigRequest {
3721                        task_id: "task-1".to_string(),
3722                        id: "cfg-1".to_string(),
3723                        tenant: None,
3724                    },
3725                )
3726                .await,
3727        );
3728        assert_unsupported_operation(
3729            transport
3730                .get_extended_agent_card(&params, &GetExtendedAgentCardRequest { tenant: None })
3731                .await,
3732        );
3733    }
3734
3735    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3736    async fn test_run_executes_all_commands_in_lib_tests() {
3737        let server = RunTestServer::spawn().await;
3738
3739        run_test_cli(&server.base_url, &["card", "get"])
3740            .await
3741            .unwrap();
3742        run_test_cli(
3743            &server.base_url,
3744            &["--compact", "card", "get", "--extended"],
3745        )
3746        .await
3747        .unwrap();
3748        run_test_cli(
3749            &server.base_url,
3750            &[
3751                "--transport",
3752                "jsonrpc",
3753                "--bearer",
3754                "secret",
3755                "--svc-param",
3756                "X-Test: 123",
3757                "send",
3758                "hello from unit test",
3759                "--task-id",
3760                "task-send",
3761                "--context-id",
3762                "ctx-send",
3763            ],
3764        )
3765        .await
3766        .unwrap();
3767        run_test_cli(
3768            &server.base_url,
3769            &[
3770                "--compact",
3771                "send",
3772                "streaming request",
3773                "--stream",
3774                "--task-id",
3775                "task-stream",
3776                "--context-id",
3777                "ctx-stream",
3778            ],
3779        )
3780        .await
3781        .unwrap();
3782        run_test_cli(&server.base_url, &["task", "get", "task-send"])
3783            .await
3784            .unwrap();
3785        run_test_cli(
3786            &server.base_url,
3787            &[
3788                "--compact",
3789                "task",
3790                "list",
3791                "--context-id",
3792                "ctx-send",
3793                "--status",
3794                "completed",
3795            ],
3796        )
3797        .await
3798        .unwrap();
3799        run_test_cli(&server.base_url, &["task", "cancel", "task-send"])
3800            .await
3801            .unwrap();
3802        run_test_cli(
3803            &server.base_url,
3804            &["--compact", "task", "subscribe", "task-stream"],
3805        )
3806        .await
3807        .unwrap();
3808        run_test_cli(
3809            &server.base_url,
3810            &[
3811                "task",
3812                "push-config",
3813                "create",
3814                "task-1",
3815                "https://example.com/callback",
3816                "--config-id",
3817                "cfg-1",
3818            ],
3819        )
3820        .await
3821        .unwrap();
3822        run_test_cli(
3823            &server.base_url,
3824            &["--compact", "task", "push-config", "get", "task-1", "cfg-1"],
3825        )
3826        .await
3827        .unwrap();
3828        run_test_cli(
3829            &server.base_url,
3830            &["--compact", "task", "push-config", "list", "task-1"],
3831        )
3832        .await
3833        .unwrap();
3834        run_test_cli(
3835            &server.base_url,
3836            &["task", "push-config", "delete", "task-1", "cfg-1"],
3837        )
3838        .await
3839        .unwrap();
3840    }
3841
3842    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3843    async fn test_run_surfaces_errors_in_lib_tests() {
3844        let server = RunTestServer::spawn().await;
3845
3846        let err = run_test_cli(
3847            &server.base_url,
3848            &["card", "get", "--extended", "--tenant", "error"],
3849        )
3850        .await
3851        .unwrap_err();
3852        assert!(matches!(
3853            err,
3854            CliError::A2A(error) if error.code == a2a::error_code::UNSUPPORTED_OPERATION
3855        ));
3856
3857        let err = run_test_cli(&server.base_url, &["send", "send-error"])
3858            .await
3859            .unwrap_err();
3860        assert!(matches!(
3861            err,
3862            CliError::A2A(error) if error.code == a2a::error_code::INVALID_REQUEST
3863        ));
3864
3865        let err = run_test_cli(&server.base_url, &["task", "list", "--context-id", "error"])
3866            .await
3867            .unwrap_err();
3868        assert!(matches!(
3869            err,
3870            CliError::A2A(error) if error.code == a2a::error_code::INVALID_PARAMS
3871        ));
3872
3873        let err = run_test_cli(
3874            &server.base_url,
3875            &["--compact", "send", "stream-error", "--stream"],
3876        )
3877        .await
3878        .unwrap_err();
3879        assert!(matches!(
3880            err,
3881            CliError::A2A(error) if error.code == a2a::error_code::INTERNAL_ERROR
3882        ));
3883
3884        let err = run_test_cli(
3885            &server.base_url,
3886            &["--compact", "task", "subscribe", "stream-error"],
3887        )
3888        .await
3889        .unwrap_err();
3890        assert!(matches!(
3891            err,
3892            CliError::A2A(error) if error.code == a2a::error_code::INTERNAL_ERROR
3893        ));
3894
3895        let err = run_test_cli(
3896            &server.base_url,
3897            &[
3898                "task",
3899                "push-config",
3900                "create",
3901                "missing",
3902                "https://example.com/callback",
3903                "--config-id",
3904                "cfg-missing",
3905            ],
3906        )
3907        .await
3908        .unwrap_err();
3909        assert!(matches!(
3910            err,
3911            CliError::A2A(error) if error.code == a2a::error_code::TASK_NOT_FOUND
3912        ));
3913
3914        let err = run_test_cli(
3915            &server.base_url,
3916            &["task", "push-config", "list", "missing"],
3917        )
3918        .await
3919        .unwrap_err();
3920        assert!(matches!(
3921            err,
3922            CliError::A2A(error) if error.code == a2a::error_code::TASK_NOT_FOUND
3923        ));
3924
3925        let base_url = unused_base_url().await;
3926        let err = run_test_cli(&base_url, &["card", "get"]).await.unwrap_err();
3927        assert!(matches!(err, CliError::Http(_)));
3928    }
3929
3930    #[test]
3931    fn test_task_state_conversion() {
3932        let cases = [
3933            (TaskStateArg::Unspecified, TaskState::Unspecified),
3934            (TaskStateArg::Submitted, TaskState::Submitted),
3935            (TaskStateArg::Working, TaskState::Working),
3936            (TaskStateArg::Completed, TaskState::Completed),
3937            (TaskStateArg::Failed, TaskState::Failed),
3938            (TaskStateArg::Canceled, TaskState::Canceled),
3939            (TaskStateArg::InputRequired, TaskState::InputRequired),
3940            (TaskStateArg::Rejected, TaskState::Rejected),
3941            (TaskStateArg::AuthRequired, TaskState::AuthRequired),
3942        ];
3943
3944        for (input, expected) in cases {
3945            assert_eq!(TaskState::from(input), expected);
3946        }
3947    }
3948
3949    #[test]
3950    fn test_means_not_a_readable_path_classification() {
3951        use std::io::ErrorKind;
3952
3953        // "Doesn't name a readable file here" — --data-part goes on to try
3954        // the value as inline JSON. InvalidFilename is the Windows answer
3955        // for a path containing `{`, `"` or `:`, i.e. inline JSON itself,
3956        // and is why this can't just test for NotFound.
3957        for kind in [
3958            ErrorKind::NotFound,
3959            ErrorKind::InvalidFilename,
3960            ErrorKind::InvalidInput,
3961        ] {
3962            assert!(
3963                means_not_a_readable_path(kind),
3964                "{kind:?} should fall through to inline-JSON parsing"
3965            );
3966        }
3967
3968        // A real file that failed to read — must be reported, not masked.
3969        for kind in [
3970            ErrorKind::PermissionDenied,
3971            ErrorKind::IsADirectory,
3972            ErrorKind::InvalidData,
3973        ] {
3974            assert!(
3975                !means_not_a_readable_path(kind),
3976                "{kind:?} should be surfaced as a read error"
3977            );
3978        }
3979    }
3980
3981    #[test]
3982    fn test_parse_duration_variants() {
3983        assert_eq!(parse_duration("2s").unwrap(), Duration::from_secs(2));
3984        assert_eq!(parse_duration("500ms").unwrap(), Duration::from_millis(500));
3985        assert_eq!(parse_duration("1m").unwrap(), Duration::from_secs(60));
3986        assert_eq!(parse_duration("30").unwrap(), Duration::from_secs(30));
3987        assert_eq!(parse_duration("1.5s").unwrap(), Duration::from_millis(1500));
3988    }
3989
3990    #[test]
3991    fn test_parse_duration_rejects_invalid_input() {
3992        assert!(parse_duration("banana").is_err());
3993        assert!(parse_duration("-1s").is_err());
3994    }
3995
3996    #[test]
3997    fn test_is_settled_classifies_task_states() {
3998        let settled = [
3999            TaskState::Completed,
4000            TaskState::Failed,
4001            TaskState::Canceled,
4002            TaskState::Rejected,
4003            TaskState::InputRequired,
4004            TaskState::AuthRequired,
4005        ];
4006        for state in settled {
4007            assert!(is_settled(&state), "{state:?} should be settled");
4008        }
4009
4010        let unsettled = [
4011            TaskState::Unspecified,
4012            TaskState::Submitted,
4013            TaskState::Working,
4014        ];
4015        for state in unsettled {
4016            assert!(!is_settled(&state), "{state:?} should not be settled");
4017        }
4018    }
4019
4020    fn parse_with_matches(args: &[&str]) -> (Cli, ArgMatches) {
4021        let mut argv = vec!["a2acli".to_string()];
4022        argv.extend(args.iter().map(|arg| (*arg).to_string()));
4023        let matches = Cli::command().try_get_matches_from(argv).unwrap();
4024        let cli = Cli::from_arg_matches(&matches).unwrap();
4025        (cli, matches)
4026    }
4027
4028    fn send_command(cli: &Cli) -> &MessageCommand {
4029        match &cli.command {
4030            Command::Send(command) => command,
4031            other => panic!("expected Command::Send, got {other:?}"),
4032        }
4033    }
4034
4035    #[test]
4036    fn test_resolve_message_parts_preserves_interleaved_order_and_media_type() {
4037        let (cli, matches) = parse_with_matches(&[
4038            "send",
4039            "--text-part",
4040            "hello",
4041            "--file-part",
4042            "https://example.com/doc.pdf",
4043            "--media-type",
4044            "application/pdf",
4045            "--data-part",
4046            r#"{"k":1}"#,
4047        ]);
4048        let send_matches = matches.subcommand_matches("send").unwrap();
4049        let parts = resolve_message_parts(send_matches, send_command(&cli)).unwrap();
4050
4051        assert_eq!(parts.len(), 3);
4052        assert_eq!(parts[0].as_text(), Some("hello"));
4053        assert!(parts[0].media_type.is_none());
4054        assert!(matches!(
4055            &parts[1].content,
4056            PartContent::Url(url) if url == "https://example.com/doc.pdf"
4057        ));
4058        assert_eq!(parts[1].media_type.as_deref(), Some("application/pdf"));
4059        assert!(matches!(&parts[2].content, PartContent::Data(_)));
4060        assert!(parts[2].media_type.is_none());
4061    }
4062
4063    #[test]
4064    fn test_resolve_message_parts_positional_text_is_shorthand() {
4065        let (cli, matches) = parse_with_matches(&["send", "hello"]);
4066        let send_matches = matches.subcommand_matches("send").unwrap();
4067        let parts = resolve_message_parts(send_matches, send_command(&cli)).unwrap();
4068
4069        assert_eq!(parts.len(), 1);
4070        assert_eq!(parts[0].as_text(), Some("hello"));
4071    }
4072
4073    #[test]
4074    fn test_resolve_message_parts_rejects_media_type_without_preceding_part() {
4075        let (cli, matches) =
4076            parse_with_matches(&["send", "--media-type", "text/plain", "--text-part", "hi"]);
4077        let send_matches = matches.subcommand_matches("send").unwrap();
4078        let err = resolve_message_parts(send_matches, send_command(&cli)).unwrap_err();
4079        assert!(matches!(err, CliError::InvalidInput(_)));
4080    }
4081
4082    #[test]
4083    fn test_resolve_message_parts_rejects_positional_text_with_part_flags() {
4084        let (cli, matches) = parse_with_matches(&["send", "hello", "--text-part", "world"]);
4085        let send_matches = matches.subcommand_matches("send").unwrap();
4086        let err = resolve_message_parts(send_matches, send_command(&cli)).unwrap_err();
4087        assert!(matches!(err, CliError::InvalidInput(_)));
4088    }
4089
4090    #[test]
4091    fn test_cli_error_exit_codes_and_envelope_codes() {
4092        let a2a = CliError::A2A(A2AError::task_not_found("t-1"));
4093        assert_eq!(a2a.exit_code(), 1);
4094        let envelope = a2a.envelope();
4095        assert_eq!(envelope.error.code, "TASK_NOT_FOUND");
4096        assert_eq!(envelope.error.a2a_code, Some(-32001));
4097
4098        let usage = CliError::InvalidInput("bad flag".to_string());
4099        assert_eq!(usage.exit_code(), 2);
4100        assert_eq!(usage.envelope().error.code, "A2ACLI_ERR_USAGE");
4101        assert!(usage.envelope().error.a2a_code.is_none());
4102
4103        let read_file = CliError::ReadFile {
4104            path: "missing.bin".to_string(),
4105            source: std::io::Error::new(std::io::ErrorKind::NotFound, "not found"),
4106        };
4107        assert_eq!(read_file.exit_code(), 2);
4108        assert_eq!(read_file.envelope().error.code, "A2ACLI_ERR_USAGE");
4109
4110        let timeout = CliError::Timeout {
4111            task_id: "t-2".to_string(),
4112            timeout: Duration::from_secs(30),
4113        };
4114        assert_eq!(timeout.exit_code(), 5);
4115        assert_eq!(timeout.envelope().error.code, "A2ACLI_ERR_TIMEOUT");
4116
4117        let json = CliError::Json(serde_json::from_str::<serde_json::Value>("{").unwrap_err());
4118        assert_eq!(json.exit_code(), 1);
4119        assert_eq!(json.envelope().error.code, "A2ACLI_ERR_INTERNAL");
4120    }
4121
4122    #[test]
4123    fn test_cli_error_report_prints_one_json_line_to_stderr() {
4124        // report() itself only writes to the real process stderr, which
4125        // isn't capturable here; assert on the envelope it serializes
4126        // instead, since that's the part the contract cares about.
4127        let error = CliError::A2A(A2AError::unsupported_operation("nope"));
4128        let json = serde_json::to_string(&error.envelope()).unwrap();
4129        assert!(json.contains("\"code\":\"UNSUPPORTED_OPERATION\""));
4130        assert!(!json.contains('\n'));
4131    }
4132
4133    #[test]
4134    fn test_agent_card_render_text() {
4135        let card = AgentCard {
4136            name: "Fixture".to_string(),
4137            description: "desc".to_string(),
4138            version: VERSION.to_string(),
4139            supported_interfaces: vec![AgentInterface::new(
4140                "http://host/jsonrpc",
4141                TRANSPORT_PROTOCOL_JSONRPC,
4142            )],
4143            capabilities: AgentCapabilities {
4144                streaming: Some(true),
4145                push_notifications: Some(false),
4146                extensions: None,
4147                extended_agent_card: None,
4148            },
4149            default_input_modes: vec![],
4150            default_output_modes: vec![],
4151            skills: vec![],
4152            provider: None,
4153            documentation_url: None,
4154            icon_url: None,
4155            security_schemes: None,
4156            security_requirements: None,
4157            signatures: None,
4158        };
4159
4160        let text = card.render_text();
4161        assert!(text.contains("Name: Fixture"));
4162        assert!(text.contains("Streaming: true"));
4163        assert!(text.contains("Push Notifications: false"));
4164        assert!(text.contains("Extended Card: false"));
4165        assert!(text.contains("Interfaces:"));
4166        assert!(text.contains("http://host/jsonrpc"));
4167        assert!(text.contains("Skills: 0"));
4168    }
4169
4170    #[test]
4171    fn test_message_render_text_renders_every_part_kind() {
4172        let mut file_part = Part::raw(vec![1, 2, 3]);
4173        file_part.filename = Some("report.bin".to_string());
4174        file_part.media_type = Some("application/octet-stream".to_string());
4175
4176        let message = Message::new(
4177            Role::Agent,
4178            vec![
4179                Part::text("hello"),
4180                Part::data(serde_json::json!({"k": 1})),
4181                file_part,
4182            ],
4183        );
4184
4185        let text = message.render_text();
4186        assert!(text.contains("Text:\nhello"));
4187        assert!(text.contains("Data:"));
4188        assert!(text.contains("\"k\": 1"));
4189        assert!(text.contains("File: report.bin (application/octet-stream, 3 bytes)"));
4190    }
4191
4192    /// §10.3: a part whose media type the agent didn't declare still has to
4193    /// render — by name and size for inline bytes, by URL for a referenced
4194    /// file — rather than being dropped because the media type is missing.
4195    #[test]
4196    fn test_render_part_without_media_type_falls_back_to_name_and_url() {
4197        let message = Message::new(
4198            Role::Agent,
4199            vec![
4200                Part::raw(vec![7; 12]),
4201                Part::raw(vec![1, 2]).with_filename("notes.txt"),
4202                Part::url("https://example.com/spec.pdf"),
4203                Part::url("https://example.com/report.csv").with_media_type("text/csv"),
4204            ],
4205        );
4206
4207        let text = message.render_text();
4208        // No filename and no media type: neither is invented, and the part
4209        // is still accounted for.
4210        assert!(text.contains("File: (unnamed) (12 bytes)"), "{text}");
4211        assert!(text.contains("File: notes.txt (2 bytes)"), "{text}");
4212        assert!(
4213            text.contains("File: https://example.com/spec.pdf"),
4214            "{text}"
4215        );
4216        assert!(
4217            text.contains("File: https://example.com/report.csv (text/csv)"),
4218            "{text}"
4219        );
4220    }
4221
4222    /// A message with no parts renders an explicit `(none)` rather than
4223    /// nothing at all, so `text` output never leaves the reader unsure
4224    /// whether content was omitted or absent.
4225    #[test]
4226    fn test_message_render_text_marks_an_empty_part_list() {
4227        let message = Message {
4228            message_id: "msg-1".to_string(),
4229            context_id: Some("ctx-1".to_string()),
4230            task_id: Some("task-1".to_string()),
4231            role: Role::Agent,
4232            parts: vec![],
4233            metadata: None,
4234            extensions: None,
4235            reference_task_ids: None,
4236        };
4237
4238        let text = message.render_text();
4239        assert!(text.contains("Context ID: ctx-1"), "{text}");
4240        assert!(text.contains("Task ID: task-1"), "{text}");
4241        assert!(text.contains("Parts: (none)"), "{text}");
4242    }
4243
4244    /// A `send` that answers with a bare `Message` instead of a `Task`
4245    /// renders through the message form (§10.2) — the response enum must not
4246    /// have a task-shaped rendering as its only arm.
4247    #[test]
4248    fn test_send_message_response_renders_the_message_arm() {
4249        let message = Message::new(Role::Agent, vec![Part::text("no task needed")]);
4250        let response = SendMessageResponse::Message(message.clone());
4251
4252        assert_eq!(response.render_text(), message.render_text());
4253        assert!(response.render_text().contains("no task needed"));
4254    }
4255
4256    /// A card that declares no compatible interface renders `(none)` under
4257    /// `Interfaces:` — `card get` is exactly the command you reach for to
4258    /// find out *why* a transport couldn't be selected, so an empty list is
4259    /// the case it most needs to state plainly.
4260    #[test]
4261    fn test_agent_card_render_text_marks_an_empty_interface_list() {
4262        let card = AgentCard {
4263            name: "Bare".to_string(),
4264            description: "no interfaces".to_string(),
4265            version: VERSION.to_string(),
4266            supported_interfaces: vec![],
4267            capabilities: AgentCapabilities {
4268                streaming: None,
4269                push_notifications: None,
4270                extensions: None,
4271                extended_agent_card: None,
4272            },
4273            default_input_modes: vec![],
4274            default_output_modes: vec![],
4275            skills: vec![],
4276            provider: None,
4277            documentation_url: None,
4278            icon_url: None,
4279            security_schemes: None,
4280            security_requirements: None,
4281            signatures: None,
4282        };
4283
4284        let text = card.render_text();
4285        assert!(text.contains("Interfaces:\n  (none)"), "{text}");
4286        // Absent capabilities read as `false`, never as a missing line.
4287        assert!(text.contains("Streaming: false"), "{text}");
4288    }
4289
4290    /// §10.3: artifacts are rendered per artifact, labeled by name when the
4291    /// agent gave one and by artifact id when it didn't, with their parts
4292    /// rendered underneath — never summarized as a count.
4293    #[test]
4294    fn test_task_render_text_lists_artifacts_and_resume_hint() {
4295        let mut task = make_fixture_task(
4296            "task-art",
4297            "ctx-art",
4298            TaskState::InputRequired,
4299            "which region?",
4300        );
4301        task.artifacts = Some(vec![
4302            Artifact {
4303                artifact_id: "art-1".to_string(),
4304                name: Some("summary".to_string()),
4305                description: None,
4306                parts: vec![Part::text("all clear")],
4307                metadata: None,
4308                extensions: None,
4309            },
4310            Artifact {
4311                artifact_id: "art-2".to_string(),
4312                name: None,
4313                description: None,
4314                parts: vec![Part::data(serde_json::json!({"rows": 3}))],
4315                metadata: None,
4316                extensions: None,
4317            },
4318        ]);
4319
4320        let text = task.render_text();
4321        assert!(text.contains("Artifact: summary"), "{text}");
4322        assert!(text.contains("all clear"), "{text}");
4323        // Unnamed artifact falls back to its id rather than rendering blank.
4324        assert!(text.contains("Artifact: art-2"), "{text}");
4325        assert!(text.contains("\"rows\": 3"), "{text}");
4326        // §9.2: an interrupted task always carries the resume command.
4327        assert!(
4328            text.contains("Resume with: a2acli send --task-id task-art \"<reply>\""),
4329            "{text}"
4330        );
4331    }
4332
4333    /// §9.1: every task state has a distinct short label, and none of them
4334    /// renders as the protocol's wire spelling — that form belongs to
4335    /// `-o json` only.
4336    #[test]
4337    fn test_task_state_label_covers_every_state() {
4338        let cases = [
4339            (TaskState::Unspecified, "UNSPECIFIED"),
4340            (TaskState::Submitted, "SUBMITTED"),
4341            (TaskState::Working, "WORKING"),
4342            (TaskState::Completed, "COMPLETED"),
4343            (TaskState::Failed, "FAILED"),
4344            (TaskState::Canceled, "CANCELED"),
4345            (TaskState::InputRequired, "INPUT_REQUIRED"),
4346            (TaskState::Rejected, "REJECTED"),
4347            (TaskState::AuthRequired, "AUTH_REQUIRED"),
4348        ];
4349
4350        for (state, expected) in cases {
4351            assert_eq!(task_state_label(&state), expected);
4352            assert!(!expected.starts_with("TASK_STATE_"));
4353        }
4354    }
4355
4356    #[test]
4357    fn test_list_tasks_render_text_empty_and_paged() {
4358        let empty = ListTasksResponse {
4359            tasks: vec![],
4360            next_page_token: "next-token".to_string(),
4361            page_size: 10,
4362            total_size: 0,
4363        };
4364
4365        let text = empty.render_text();
4366        assert!(text.contains("Total: 0"), "{text}");
4367        assert!(text.contains("Page Size: 10"), "{text}");
4368        assert!(text.contains("Next Page Token: next-token"), "{text}");
4369        assert!(text.contains("Tasks: (none)"), "{text}");
4370
4371        let populated = ListTasksResponse {
4372            tasks: vec![make_fixture_task(
4373                "task-1",
4374                "ctx-1",
4375                TaskState::Completed,
4376                "done",
4377            )],
4378            next_page_token: String::new(),
4379            page_size: 10,
4380            total_size: 1,
4381        };
4382
4383        let text = populated.render_text();
4384        // An empty page token is omitted rather than rendered as a blank
4385        // field, so `text` output never suggests there is a next page.
4386        assert!(!text.contains("Next Page Token"), "{text}");
4387        assert!(text.contains("Task:"), "{text}");
4388        assert!(text.contains("Task ID: task-1"), "{text}");
4389    }
4390
4391    #[test]
4392    fn test_push_config_render_text_includes_token_and_auth_scheme() {
4393        let config = TaskPushNotificationConfig {
4394            url: "https://example.com/hook".to_string(),
4395            id: Some("cfg-1".to_string()),
4396            task_id: "task-1".to_string(),
4397            token: Some("tok-1".to_string()),
4398            authentication: Some(AuthenticationInfo {
4399                scheme: "Bearer".to_string(),
4400                credentials: Some("super-secret".to_string()),
4401            }),
4402            tenant: None,
4403        };
4404
4405        let text = config.render_text();
4406        assert!(text.contains("Config ID: cfg-1"), "{text}");
4407        assert!(text.contains("Task ID: task-1"), "{text}");
4408        assert!(text.contains("URL: https://example.com/hook"), "{text}");
4409        assert!(text.contains("Token: tok-1"), "{text}");
4410        assert!(text.contains("Auth Scheme: Bearer"), "{text}");
4411        // The scheme is useful; the credential behind it is never echoed.
4412        assert!(!text.contains("super-secret"), "{text}");
4413    }
4414
4415    #[test]
4416    fn test_list_push_configs_render_text_empty_and_paged() {
4417        let empty = ListTaskPushNotificationConfigsResponse {
4418            configs: vec![],
4419            next_page_token: Some("next-token".to_string()),
4420        };
4421
4422        let text = empty.render_text();
4423        assert!(text.contains("Next Page Token: next-token"), "{text}");
4424        assert!(text.contains("Configs: (none)"), "{text}");
4425
4426        let populated = ListTaskPushNotificationConfigsResponse {
4427            configs: vec![TaskPushNotificationConfig {
4428                url: "https://example.com/hook".to_string(),
4429                id: Some("cfg-1".to_string()),
4430                task_id: "task-1".to_string(),
4431                token: None,
4432                authentication: None,
4433                tenant: None,
4434            }],
4435            next_page_token: None,
4436        };
4437
4438        let text = populated.render_text();
4439        assert!(!text.contains("Next Page Token"), "{text}");
4440        assert!(text.contains("Push Config:"), "{text}");
4441        assert!(text.contains("Config ID: cfg-1"), "{text}");
4442    }
4443
4444    #[test]
4445    fn test_push_config_deleted_render_text() {
4446        let deleted = PushConfigDeleted {
4447            deleted: true,
4448            task_id: "task-1".to_string(),
4449            id: "cfg-1".to_string(),
4450        };
4451
4452        let text = deleted.render_text();
4453        assert!(text.contains("Deleted: true"), "{text}");
4454        assert!(text.contains("Task ID: task-1"), "{text}");
4455        assert!(text.contains("Config ID: cfg-1"), "{text}");
4456    }
4457
4458    /// Under `--stream` each event is rendered on its own, so the update
4459    /// events need renderings of their own — a status update that pauses the
4460    /// task still has to carry the resume command (§9.2), and an artifact
4461    /// update still has to show the artifact's parts (§10.3).
4462    #[test]
4463    fn test_stream_response_render_text_covers_update_events() {
4464        let status = StreamResponse::StatusUpdate(TaskStatusUpdateEvent {
4465            task_id: "task-s".to_string(),
4466            context_id: "ctx-s".to_string(),
4467            status: TaskStatus {
4468                state: TaskState::AuthRequired,
4469                message: None,
4470                timestamp: None,
4471            },
4472            metadata: None,
4473        });
4474
4475        let text = status.render_text();
4476        assert!(text.contains("Task ID: task-s"), "{text}");
4477        assert!(text.contains("Context ID: ctx-s"), "{text}");
4478        assert!(text.contains("State: AUTH_REQUIRED"), "{text}");
4479        assert!(
4480            text.contains("Resume with: a2acli send --task-id task-s \"<reply>\""),
4481            "{text}"
4482        );
4483
4484        // A state that isn't interrupted gets no resume line to act on.
4485        let working = StreamResponse::StatusUpdate(TaskStatusUpdateEvent {
4486            task_id: "task-s".to_string(),
4487            context_id: "ctx-s".to_string(),
4488            status: TaskStatus {
4489                state: TaskState::Working,
4490                message: None,
4491                timestamp: None,
4492            },
4493            metadata: None,
4494        });
4495        assert!(!working.render_text().contains("Resume with"));
4496
4497        let artifact = StreamResponse::ArtifactUpdate(TaskArtifactUpdateEvent {
4498            task_id: "task-s".to_string(),
4499            context_id: "ctx-s".to_string(),
4500            artifact: Artifact {
4501                artifact_id: "art-9".to_string(),
4502                name: None,
4503                description: None,
4504                parts: vec![Part::text("chunk one")],
4505                metadata: None,
4506                extensions: None,
4507            },
4508            append: None,
4509            last_chunk: None,
4510            metadata: None,
4511        });
4512
4513        let text = artifact.render_text();
4514        assert!(text.contains("Task ID: task-s"), "{text}");
4515        assert!(text.contains("Artifact: art-9"), "{text}");
4516        assert!(text.contains("chunk one"), "{text}");
4517
4518        // A stream may also carry whole tasks and messages, which render
4519        // through their own forms rather than a stream-specific one.
4520        let task = make_fixture_task("task-s", "ctx-s", TaskState::Completed, "done");
4521        assert_eq!(
4522            StreamResponse::Task(task.clone()).render_text(),
4523            task.render_text()
4524        );
4525        let message = Message::new(Role::Agent, vec![Part::text("interim")]);
4526        assert_eq!(
4527            StreamResponse::Message(message.clone()).render_text(),
4528            message.render_text()
4529        );
4530    }
4531
4532    fn text_cli() -> Cli {
4533        parse_with_matches(&["task", "get", "unused"]).0
4534    }
4535
4536    /// `text` mode renders through [`TextRender`] and never through
4537    /// `Serialize`, so a value that cannot be serialized still streams — the
4538    /// mirror of `test_consume_stream_reports_json_error`.
4539    #[tokio::test]
4540    async fn test_consume_stream_text_mode_does_not_serialize() {
4541        let stream = Box::pin(stream::once(async { Ok(FailingSerialize) }));
4542        consume_stream(make_test_client(None), stream, &text_cli())
4543            .await
4544            .unwrap();
4545    }
4546
4547    /// Malformed JSON in a `--data-part` file is the caller's input to fix,
4548    /// so it surfaces as a usage error naming the source — not as the
4549    /// generic internal `CliError::Json`.
4550    #[test]
4551    fn test_parse_data_part_json_rejects_malformed_content() {
4552        let error = parse_data_part_json("{not json", "payload.json").unwrap_err();
4553
4554        assert_eq!(error.exit_code(), 2);
4555        let message = error.to_string();
4556        assert!(message.contains("payload.json"), "{message}");
4557        assert!(matches!(error, CliError::InvalidInput(_)));
4558    }
4559
4560    /// A server that answers the well-known Agent Card path with exactly one
4561    /// canned response and serves nothing else — enough to drive Appendix
4562    /// D's classification of a card fetch, which needs a real
4563    /// `reqwest::Error` (the type has no public constructor, so the
4564    /// classification can't be asserted on a synthetic one).
4565    struct CardOnlyServer {
4566        base_url: String,
4567        handle: tokio::task::JoinHandle<()>,
4568    }
4569
4570    impl Drop for CardOnlyServer {
4571        fn drop(&mut self) {
4572            self.handle.abort();
4573        }
4574    }
4575
4576    impl CardOnlyServer {
4577        async fn spawn(status: axum::http::StatusCode, body: &'static str) -> Self {
4578            let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
4579            let base_url = format!("http://{}", listener.local_addr().unwrap());
4580            let app = Router::new().route(
4581                WELL_KNOWN_AGENT_CARD_PATH,
4582                get(move || async move {
4583                    (
4584                        status,
4585                        [(header::CONTENT_TYPE, "application/json")],
4586                        body.to_string(),
4587                    )
4588                }),
4589            );
4590            let handle = tokio::spawn(async move {
4591                axum::serve(listener, app).await.unwrap();
4592            });
4593
4594            CardOnlyServer { base_url, handle }
4595        }
4596    }
4597
4598    /// Appendix D: a card fetch distinguishes "the agent isn't there" from
4599    /// "the agent refused you" from "that isn't an agent card", because the
4600    /// three need different fixes — and each maps to its own exit status
4601    /// (§11.6) so a script can branch on it.
4602    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
4603    async fn test_card_fetch_failures_are_classified_per_appendix_d() {
4604        let not_found = CardOnlyServer::spawn(axum::http::StatusCode::NOT_FOUND, "{}").await;
4605        let error = run_test_cli(&not_found.base_url, &["card", "get"])
4606            .await
4607            .unwrap_err();
4608        assert_eq!(error.envelope().error.code, "A2ACLI_ERR_CARD_NOT_FOUND");
4609        assert_eq!(error.exit_code(), 3);
4610        assert!(error.envelope().error.hint.is_some());
4611
4612        for status in [
4613            axum::http::StatusCode::UNAUTHORIZED,
4614            axum::http::StatusCode::FORBIDDEN,
4615        ] {
4616            let denied = CardOnlyServer::spawn(status, "{}").await;
4617            let error = run_test_cli(&denied.base_url, &["card", "get"])
4618                .await
4619                .unwrap_err();
4620            assert_eq!(
4621                error.envelope().error.code,
4622                "A2ACLI_ERR_AUTH_FAILED",
4623                "status {status}"
4624            );
4625            assert_eq!(error.exit_code(), 4, "status {status}");
4626        }
4627
4628        // 200, but the body isn't an Agent Card: the agent answered, so this
4629        // is neither unreachable nor a missing card.
4630        let invalid =
4631            CardOnlyServer::spawn(axum::http::StatusCode::OK, r#"{"not":"a card"}"#).await;
4632        let error = run_test_cli(&invalid.base_url, &["card", "get"])
4633            .await
4634            .unwrap_err();
4635        assert_eq!(error.envelope().error.code, "A2ACLI_ERR_CARD_INVALID");
4636        assert_eq!(error.exit_code(), 1);
4637
4638        // An unreachable agent stays distinct from all of the above.
4639        let base_url = unused_base_url().await;
4640        let error = run_test_cli(&base_url, &["card", "get"]).await.unwrap_err();
4641        assert_eq!(error.envelope().error.code, "A2ACLI_ERR_UNREACHABLE");
4642        assert_eq!(error.exit_code(), 3);
4643    }
4644
4645    #[test]
4646    fn test_parse_dotenv_basic() {
4647        let parsed = parse_dotenv("A2ACLI_BEARER=secret\nA2ACLI_TRANSPORT=jsonrpc,rest\n");
4648        assert_eq!(
4649            parsed,
4650            vec![
4651                ("A2ACLI_BEARER".to_string(), "secret".to_string()),
4652                ("A2ACLI_TRANSPORT".to_string(), "jsonrpc,rest".to_string()),
4653            ]
4654        );
4655    }
4656
4657    #[test]
4658    fn test_parse_dotenv_ignores_blank_lines_and_comments() {
4659        let parsed = parse_dotenv("\n# a comment\n   \nA2ACLI_TENANT=acme\n# another comment\n");
4660        assert_eq!(
4661            parsed,
4662            vec![("A2ACLI_TENANT".to_string(), "acme".to_string())]
4663        );
4664    }
4665
4666    #[test]
4667    fn test_parse_dotenv_tolerates_export_prefix() {
4668        let parsed = parse_dotenv("export A2ACLI_BASE_URL=https://agent.example.com\n");
4669        assert_eq!(
4670            parsed,
4671            vec![(
4672                "A2ACLI_BASE_URL".to_string(),
4673                "https://agent.example.com".to_string()
4674            )]
4675        );
4676    }
4677
4678    #[test]
4679    fn test_parse_dotenv_strips_one_layer_of_quotes() {
4680        let parsed = parse_dotenv("A2ACLI_BEARER=\"Bearer token\"\nA2ACLI_TENANT='acme'\n");
4681        assert_eq!(
4682            parsed,
4683            vec![
4684                ("A2ACLI_BEARER".to_string(), "Bearer token".to_string()),
4685                ("A2ACLI_TENANT".to_string(), "acme".to_string()),
4686            ]
4687        );
4688    }
4689
4690    #[test]
4691    fn test_find_flag_value_supports_space_and_equals_forms() {
4692        let space = vec![
4693            OsString::from("a2acli"),
4694            OsString::from("--config"),
4695            OsString::from("prod.env"),
4696        ];
4697        assert_eq!(
4698            find_flag_value(&space, "--config"),
4699            Some("prod.env".to_string())
4700        );
4701
4702        let equals = vec![
4703            OsString::from("a2acli"),
4704            OsString::from("--config=prod.env"),
4705        ];
4706        assert_eq!(
4707            find_flag_value(&equals, "--config"),
4708            Some("prod.env".to_string())
4709        );
4710
4711        let absent = vec![OsString::from("a2acli"), OsString::from("send")];
4712        assert_eq!(find_flag_value(&absent, "--config"), None);
4713    }
4714
4715    #[test]
4716    fn test_config_source_labels() {
4717        assert_eq!(ConfigSource::Flag.label(), "flag");
4718        assert_eq!(ConfigSource::EnvVar.label(), "environment variable");
4719        assert_eq!(ConfigSource::LocalFile.label(), "local .env file");
4720        assert_eq!(ConfigSource::GlobalFile.label(), "global .env file");
4721        assert_eq!(ConfigSource::Default.label(), "built-in default");
4722    }
4723
4724    #[test]
4725    fn test_redact_secret() {
4726        assert_eq!(
4727            redact_secret(&Some("secret".to_string())),
4728            "(set, redacted)"
4729        );
4730        assert_eq!(redact_secret(&None), "(not set)");
4731    }
4732
4733    /// §10.1's three reference forms, and the scheme a bare host gets. These
4734    /// mirror the reference implementation's `normalize` in
4735    /// `internal/flagparse/urlorpath.go`: loopback gets `http://` because a
4736    /// local development agent is rarely served over TLS, everything else
4737    /// gets `https://`, and a reference that already carries a path is a
4738    /// full card URL rather than an origin.
4739    #[test]
4740    fn test_normalize_card_reference_host_forms() {
4741        let cases = [
4742            // Bare origin -> well-known path appended.
4743            (
4744                "example.com",
4745                "https://example.com/.well-known/agent-card.json",
4746            ),
4747            (
4748                "http://example.com",
4749                "http://example.com/.well-known/agent-card.json",
4750            ),
4751            (
4752                "https://example.com/",
4753                "https://example.com/.well-known/agent-card.json",
4754            ),
4755            // Loopback -> http, since a dev agent is rarely behind TLS.
4756            (
4757                "localhost:3000",
4758                "http://localhost:3000/.well-known/agent-card.json",
4759            ),
4760            (
4761                "127.0.0.1:8080",
4762                "http://127.0.0.1:8080/.well-known/agent-card.json",
4763            ),
4764            (
4765                "[::1]:9000",
4766                "http://[::1]:9000/.well-known/agent-card.json",
4767            ),
4768            // Already carries a path -> a full card URL, used as-is.
4769            (
4770                "https://example.com/custom/card.json",
4771                "https://example.com/custom/card.json",
4772            ),
4773            (
4774                "http://example.com/agents/a/card",
4775                "http://example.com/agents/a/card",
4776            ),
4777        ];
4778
4779        for (reference, expected) in cases {
4780            assert_eq!(
4781                normalize_card_reference(reference),
4782                CardReference::Url(expected.to_string()),
4783                "reference {reference}"
4784            );
4785        }
4786    }
4787
4788    #[test]
4789    fn test_normalize_card_reference_file_forms() {
4790        // `file://` is explicit and needs no filesystem check.
4791        assert_eq!(
4792            normalize_card_reference("file:///tmp/card.json"),
4793            CardReference::File(PathBuf::from("/tmp/card.json"))
4794        );
4795        // A path-shaped reference is a path even when nothing is there, so a
4796        // typo reports a missing file rather than being fetched over HTTPS.
4797        for reference in ["/no/such/card.json", "./card.json", "../card.json"] {
4798            assert_eq!(
4799                normalize_card_reference(reference),
4800                CardReference::File(PathBuf::from(reference)),
4801                "reference {reference}"
4802            );
4803        }
4804    }
4805
4806    /// Windows path shapes, asserted as plain strings so they are checked on
4807    /// every platform. macOS and Linux cannot reproduce the divergence — a
4808    /// Unix path starts with `/` and is caught by the first prefix — so only
4809    /// the Windows CI job would otherwise notice a regression here, which is
4810    /// exactly how this was found (a2aproject/a2a-rs#190).
4811    #[test]
4812    fn test_windows_path_shapes_are_recognized_as_files() {
4813        for reference in [
4814            r"C:\Users\me\card.json",
4815            r"c:/Users/me/card.json",
4816            r"\\server\share\card.json",
4817            r".\card.json",
4818            r"..\card.json",
4819        ] {
4820            assert!(looks_like_file_path(reference), "{reference}");
4821            assert_eq!(
4822                normalize_card_reference(reference),
4823                CardReference::File(PathBuf::from(reference)),
4824                "{reference}"
4825            );
4826        }
4827
4828        // A `host:port` is not a drive root, however short the host: the
4829        // byte after the colon is a digit, not a path separator.
4830        for reference in ["localhost:3000", "a:3000", "example.com:443"] {
4831            assert!(!has_windows_drive_prefix(reference), "{reference}");
4832            assert!(!looks_like_file_path(reference), "{reference}");
4833        }
4834
4835        // `file:///C:/…`: the slash before the drive letter is URL syntax,
4836        // not part of the path.
4837        assert_eq!(
4838            normalize_card_reference("file:///C:/Users/me/card.json"),
4839            CardReference::File(PathBuf::from("C:/Users/me/card.json"))
4840        );
4841        // On Unix the same leading slash *is* the root, and is kept.
4842        assert_eq!(
4843            normalize_card_reference("file:///tmp/card.json"),
4844            CardReference::File(PathBuf::from("/tmp/card.json"))
4845        );
4846    }
4847
4848    /// A bare name that happens to exist on disk is treated as a file, the
4849    /// same way the reference implementation stats the reference before
4850    /// falling back to the host forms.
4851    #[test]
4852    fn test_normalize_card_reference_prefers_an_existing_file_over_a_host() {
4853        let dir = std::env::temp_dir().join(format!("a2acli-norm-{}", std::process::id()));
4854        std::fs::create_dir_all(&dir).unwrap();
4855        let path = dir.join("example.com");
4856        std::fs::write(&path, "{}").unwrap();
4857
4858        let previous = std::env::current_dir().unwrap();
4859        std::env::set_current_dir(&dir).unwrap();
4860        let resolved = normalize_card_reference("example.com");
4861        std::env::set_current_dir(previous).unwrap();
4862
4863        assert_eq!(resolved, CardReference::File(PathBuf::from("example.com")));
4864        let _ = std::fs::remove_dir_all(&dir);
4865    }
4866
4867    #[test]
4868    fn test_is_loopback_host() {
4869        for host in [
4870            "localhost",
4871            "localhost:3000",
4872            "127.0.0.1",
4873            "127.0.0.1:80",
4874            "::1",
4875            "[::1]:9000",
4876        ] {
4877            assert!(is_loopback_host(host), "expected loopback: {host}");
4878        }
4879        for host in [
4880            "example.com",
4881            "example.com:443",
4882            "8.8.8.8",
4883            "2606:4700::1111",
4884        ] {
4885            assert!(!is_loopback_host(host), "expected non-loopback: {host}");
4886        }
4887    }
4888
4889    #[test]
4890    fn test_card_file_errors_map_to_appendix_d_codes() {
4891        let missing = CliError::CardFile {
4892            path: "/no/such/card.json".to_string(),
4893            source: std::io::Error::new(std::io::ErrorKind::NotFound, "not found"),
4894        };
4895        assert_eq!(missing.envelope().error.code, "A2ACLI_ERR_CARD_NOT_FOUND");
4896        assert_eq!(missing.exit_code(), 3);
4897
4898        let invalid = CliError::CardInvalid("card.json: missing field `name`".to_string());
4899        assert_eq!(invalid.envelope().error.code, "A2ACLI_ERR_CARD_INVALID");
4900        assert_eq!(invalid.exit_code(), 1);
4901    }
4902
4903    /// The §7.2 selection matrix, in one place: the two flags are exclusive,
4904    /// `--endpoint` needs exactly one transport, and absent both the
4905    /// deprecated `--base-url` still supplies the reference.
4906    #[test]
4907    fn test_resolve_agent_selection_matrix() {
4908        let (cli, matches) = parse_with_matches(&["--agent-card", "example.com", "card", "get"]);
4909        assert_eq!(
4910            resolve_agent_selection(&cli, &matches).unwrap(),
4911            AgentSelection::Card("example.com".to_string())
4912        );
4913
4914        // Neither flag: falls back to --base-url at its built-in default,
4915        // and does not warn, because nothing deprecated was passed.
4916        let (cli, matches) = parse_with_matches(&["card", "get"]);
4917        assert_eq!(
4918            resolve_agent_selection(&cli, &matches).unwrap(),
4919            AgentSelection::Card("http://localhost:3000".to_string())
4920        );
4921
4922        // --base-url explicitly: still honored (the alias keeps working).
4923        let (cli, matches) = parse_with_matches(&["--base-url", "http://host:1", "card", "get"]);
4924        assert_eq!(
4925            resolve_agent_selection(&cli, &matches).unwrap(),
4926            AgentSelection::Card("http://host:1".to_string())
4927        );
4928
4929        let (cli, matches) = parse_with_matches(&[
4930            "--endpoint",
4931            "http://host/jsonrpc",
4932            "--transport",
4933            "jsonrpc",
4934            "card",
4935            "get",
4936        ]);
4937        assert_eq!(
4938            resolve_agent_selection(&cli, &matches).unwrap(),
4939            AgentSelection::Endpoint {
4940                url: "http://host/jsonrpc".to_string(),
4941                binding: Binding::Jsonrpc,
4942            }
4943        );
4944
4945        // --endpoint with zero and with two transports: both usage errors.
4946        for transports in [vec![], vec!["jsonrpc", "rest"]] {
4947            let mut args = vec!["--endpoint", "http://host/jsonrpc"];
4948            for transport in &transports {
4949                args.push("--transport");
4950                args.push(transport);
4951            }
4952            args.extend(["card", "get"]);
4953            let (cli, matches) = parse_with_matches(&args);
4954            let error = resolve_agent_selection(&cli, &matches).unwrap_err();
4955            assert_eq!(
4956                error.exit_code(),
4957                2,
4958                "with {} transport(s)",
4959                transports.len()
4960            );
4961            assert!(error.to_string().contains("exactly one --transport"));
4962        }
4963
4964        let (cli, matches) = parse_with_matches(&[
4965            "--agent-card",
4966            "example.com",
4967            "--endpoint",
4968            "http://host/jsonrpc",
4969            "--transport",
4970            "jsonrpc",
4971            "card",
4972            "get",
4973        ]);
4974        let error = resolve_agent_selection(&cli, &matches).unwrap_err();
4975        assert_eq!(error.exit_code(), 2);
4976        assert!(error.to_string().contains("mutually exclusive"));
4977    }
4978
4979    /// `--endpoint` stands in a one-interface card so the rest of the client
4980    /// path is unchanged; it must carry exactly the named interface and
4981    /// declare no capabilities it cannot vouch for.
4982    #[test]
4983    fn test_synthesized_endpoint_card_carries_only_the_named_interface() {
4984        let card = synthesized_endpoint_card("http://host/rest", Binding::Rest);
4985
4986        assert_eq!(card.supported_interfaces.len(), 1);
4987        assert_eq!(card.supported_interfaces[0].url, "http://host/rest");
4988        assert_eq!(
4989            card.supported_interfaces[0].protocol_binding,
4990            Binding::Rest.protocol()
4991        );
4992        assert!(card.description.contains("no agent card was resolved"));
4993        // No card was read, so nothing is advertised.
4994        assert_eq!(card.capabilities.streaming, None);
4995        assert!(card.skills.is_empty());
4996    }
4997
4998    #[test]
4999    fn test_parse_protocol_version() {
5000        assert_eq!(parse_protocol_version("1"), Some((1, 0)));
5001        assert_eq!(parse_protocol_version("1.0"), Some((1, 0)));
5002        assert_eq!(parse_protocol_version("1.2"), Some((1, 2)));
5003        // A patch component is tolerated and ignored: A2A versions are
5004        // major.minor, and a card declaring 1.0.2 must still negotiate.
5005        assert_eq!(parse_protocol_version("1.0.2"), Some((1, 0)));
5006        assert_eq!(parse_protocol_version(" 1.1 "), Some((1, 1)));
5007        assert_eq!(parse_protocol_version("nonsense"), None);
5008        assert_eq!(parse_protocol_version(""), None);
5009        assert_eq!(parse_protocol_version("1.x"), None);
5010    }
5011
5012    /// §13.2: anything outside 1.x is refused rather than downgraded,
5013    /// because A2A reads an empty or pre-1.0 version as 0.3.
5014    #[test]
5015    fn test_validate_a2a_version() {
5016        for accepted in [None, Some("1"), Some("1.0"), Some("1.7")] {
5017            assert!(
5018                validate_a2a_version(accepted).is_ok(),
5019                "should accept {accepted:?}"
5020            );
5021        }
5022
5023        for rejected in ["0.3", "0.9", "2.0", "nonsense", ""] {
5024            let error = validate_a2a_version(Some(rejected)).unwrap_err();
5025            assert_eq!(error.exit_code(), 2, "rejecting {rejected}");
5026            assert_eq!(error.envelope().error.code, "A2ACLI_ERR_USAGE");
5027        }
5028
5029        // The pre-1.0 refusal explains itself rather than just failing.
5030        let error = validate_a2a_version(Some("0.3")).unwrap_err();
5031        assert!(error.to_string().contains("must be 1.x"), "{error}");
5032    }
5033
5034    #[test]
5035    fn test_negotiate_a2a_version_uses_an_explicit_flag_verbatim() {
5036        // Explicit and equal to what this build speaks: nothing to report.
5037        let negotiated = negotiate_a2a_version(Some("1.0"), "1.0", (1, 0));
5038        assert_eq!(negotiated.version, "1.0");
5039        assert!(negotiated.note.is_none());
5040
5041        // Explicit and different: used as written, and said out loud, since
5042        // §13.2 forbids a silent change of the signaled version.
5043        let negotiated = negotiate_a2a_version(Some("1.3"), "1.0", (1, 0));
5044        assert_eq!(negotiated.version, "1.3");
5045        assert!(negotiated.note.unwrap().contains("1.3"));
5046    }
5047
5048    /// Absent a flag, negotiate down to the highest version both sides
5049    /// declare — `supported` is a parameter precisely so this branch is
5050    /// exercisable while this build's own VERSION is still 1.0.
5051    #[test]
5052    fn test_negotiate_a2a_version_takes_the_highest_shared_1x() {
5053        // Agent declares less than we support: follow it down, and say so.
5054        let negotiated = negotiate_a2a_version(None, "1.1", (1, 3));
5055        assert_eq!(negotiated.version, "1.1");
5056        assert!(negotiated.note.unwrap().contains("negotiated"));
5057
5058        // Agent declares more than we support: hold at ours, nothing to say.
5059        let negotiated = negotiate_a2a_version(None, "1.5", (1, 3));
5060        assert_eq!(negotiated.version, "1.3");
5061        assert!(negotiated.note.is_none());
5062
5063        // Equal: no note.
5064        let negotiated = negotiate_a2a_version(None, "1.3", (1, 3));
5065        assert_eq!(negotiated.version, "1.3");
5066        assert!(negotiated.note.is_none());
5067    }
5068
5069    /// The 1.0 floor: a card declaring a pre-1.0 or non-1.x version does not
5070    /// drag the tool into 0.3 semantics, and the refusal is reported.
5071    #[test]
5072    fn test_negotiate_a2a_version_never_goes_below_the_1x_floor() {
5073        for declared in ["0.3", "0.9", "2.0"] {
5074            let negotiated = negotiate_a2a_version(None, declared, (1, 0));
5075            assert_eq!(negotiated.version, "1.0", "declared {declared}");
5076            let note = negotiated.note.unwrap_or_default();
5077            assert!(note.contains("outside 1.x"), "declared {declared}: {note}");
5078        }
5079
5080        // An unparseable declaration is also not a reason to downgrade.
5081        let negotiated = negotiate_a2a_version(None, "not-a-version", (1, 0));
5082        assert_eq!(negotiated.version, "1.0");
5083        assert!(negotiated.note.unwrap().contains("unparseable"));
5084    }
5085
5086    /// EXIT_002 enumerates exactly four outcomes worth naming. `COMPLETED`
5087    /// is success and `CANCELED` is what `task cancel` was asked to produce,
5088    /// so neither says anything; the remaining states are mid-flight.
5089    #[test]
5090    fn test_task_outcome_note_names_only_non_success_and_paused_states() {
5091        for (state, expected) in [
5092            (TaskState::Failed, "FAILED"),
5093            (TaskState::Rejected, "REJECTED"),
5094            (TaskState::InputRequired, "INPUT_REQUIRED"),
5095            (TaskState::AuthRequired, "AUTH_REQUIRED"),
5096        ] {
5097            let note =
5098                task_outcome_note(&state).unwrap_or_else(|| panic!("{state:?} should be named"));
5099            assert!(note.contains(expected), "{state:?} -> {note}");
5100        }
5101
5102        for state in [
5103            TaskState::Completed,
5104            TaskState::Canceled,
5105            TaskState::Submitted,
5106            TaskState::Working,
5107            TaskState::Unspecified,
5108        ] {
5109            assert!(
5110                task_outcome_note(&state).is_none(),
5111                "{state:?} should stay silent"
5112            );
5113        }
5114    }
5115
5116    /// The warning is derived from the response's own shape, so a reply that
5117    /// created no task has no outcome to name.
5118    #[test]
5119    fn test_task_outcome_is_read_from_the_reported_value() {
5120        // A `Message`-only reply created no task (SEND_003).
5121        let message = Message::new(Role::Agent, vec![Part::text("hi")]);
5122        assert!(
5123            task_outcome_note(&TaskState::Completed).is_none(),
5124            "sanity: COMPLETED is silent"
5125        );
5126        SendMessageResponse::Message(message.clone()).warn_outcome();
5127        StreamResponse::Message(message).warn_outcome();
5128
5129        // A task-bearing response reads the task's state.
5130        let task = make_fixture_task("t-1", "c-1", TaskState::Failed, "broke");
5131        assert_eq!(
5132            task_outcome_note(&task.status.state),
5133            Some("the agent reported it FAILED")
5134        );
5135        SendMessageResponse::Task(task.clone()).warn_outcome();
5136        StreamResponse::Task(task).warn_outcome();
5137
5138        // A streamed status update reads the event's state.
5139        StreamResponse::StatusUpdate(TaskStatusUpdateEvent {
5140            task_id: "t-2".to_string(),
5141            context_id: "c-2".to_string(),
5142            status: TaskStatus {
5143                state: TaskState::AuthRequired,
5144                message: None,
5145                timestamp: None,
5146            },
5147            metadata: None,
5148        })
5149        .warn_outcome();
5150    }
5151
5152    /// clap renders "error: …" then a blank line then a usage block, and
5153    /// indents continuation lines. The envelope carries one line, so the
5154    /// first block is joined and the redundant `error: ` prefix dropped —
5155    /// the envelope already says it is an error.
5156    #[test]
5157    fn test_flatten_clap_message() {
5158        let rendered = "error: unexpected argument '--nope' found\n\nUsage: a2acli [OPTIONS]\n";
5159        assert_eq!(
5160            flatten_clap_message(rendered),
5161            "unexpected argument '--nope' found"
5162        );
5163
5164        // Continuation lines are trimmed before joining, so no run of
5165        // spaces survives into the JSON.
5166        let rendered = "error: the following required arguments were not provided:\n  <ID>\n\nUsage: a2acli task get <ID>\n";
5167        assert_eq!(
5168            flatten_clap_message(rendered),
5169            "the following required arguments were not provided: <ID>"
5170        );
5171
5172        // A message with no blank line, and one with nothing but a usage
5173        // block, both still produce something.
5174        assert_eq!(flatten_clap_message("error: bare"), "bare");
5175        assert_eq!(flatten_clap_message("\n\nUsage: a2acli"), "Usage: a2acli");
5176    }
5177
5178    #[test]
5179    fn test_usage_error_maps_to_the_appendix_d_usage_code() {
5180        let error = CliError::Usage("unexpected argument '--nope' found".to_string());
5181        assert_eq!(error.exit_code(), 2);
5182        let envelope = error.envelope();
5183        assert_eq!(envelope.error.code, "A2ACLI_ERR_USAGE");
5184        assert_eq!(envelope.error.message, "unexpected argument '--nope' found");
5185        assert!(envelope.error.hint.unwrap().contains("--help"));
5186    }
5187}