Skip to main content

cli_engine/
middleware.rs

1use std::{
2    collections::BTreeMap,
3    future::Future,
4    sync::Arc,
5    time::{Duration, Instant},
6};
7
8use async_trait::async_trait;
9use serde::{Deserialize, Serialize};
10use serde_json::{Map, Value, json};
11use tokio::sync::OnceCell;
12
13use crate::{
14    CommandResult, Credential, Dispatcher, Result, SchemaRegistry, Tier,
15    error::{CliCoreError, exit_code_for_error},
16    output::{
17        Envelope, HumanViewRegistry, OutputFormat, PipelineOpts, apply_pipeline,
18        build_error_envelope, is_valid_output_format, render_human_with_registry_for_schema,
19    },
20};
21
22/// JSON object map used for command args and metadata.
23pub type ValueMap = Map<String, Value>;
24
25/// Per-command metadata consumed by middleware.
26///
27/// Command specs build this metadata automatically. Applications can also
28/// adjust it through `CliConfig::meta_resolver`.
29#[derive(Clone, Debug, Default, Eq, PartialEq)]
30pub struct CommandMeta {
31    /// Whether `--dry-run` should short-circuit command business logic.
32    pub dry_run_prompt: bool,
33    /// Provider-specific auth metadata.
34    pub auth_metadata: BTreeMap<String, String>,
35    /// OAuth-style scopes derived from `auth_metadata["scopes"]`.
36    pub scopes: Vec<String>,
37}
38
39impl CommandMeta {
40    /// Returns the selected auth provider, if one is present.
41    #[must_use]
42    pub fn provider(&self) -> Option<&str> {
43        self.auth_metadata.get("provider").map(String::as_str)
44    }
45
46    /// Returns the risk tier, defaulting to [`Tier::Read`].
47    #[must_use]
48    pub fn tier(&self) -> Tier {
49        self.auth_metadata
50            .get("tier")
51            .and_then(|value| value.parse::<Tier>().ok())
52            .unwrap_or(Tier::Read)
53    }
54
55    /// Returns a fixed auth environment override, if present.
56    #[must_use]
57    pub fn fixed_env(&self) -> Option<&str> {
58        self.auth_metadata.get("fixed_env").map(String::as_str)
59    }
60}
61
62/// Declares whether a command requires an authenticated credential.
63///
64/// This is the policy that the engine enforces; it is separate from the
65/// *mechanism* of resolution (see [`CredentialResolver`]). The default is
66/// [`Required`](AuthRequirement::Required), which fails closed: the engine
67/// resolves the credential before the handler runs, so a command that should be
68/// gated behind authentication cannot execute unauthenticated even if its
69/// handler never reads the credential, and audit/activity identity is always
70/// populated for it.
71///
72/// `--schema` and `--dry-run` short-circuit before the engine resolves a
73/// `Required` credential, so they never trigger an authentication flow on their
74/// own regardless of requirement.
75#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
76#[non_exhaustive]
77pub enum AuthRequirement {
78    /// The engine resolves the credential before the handler runs (fail-closed).
79    ///
80    /// A failure to resolve is rendered as an `auth-error` and the handler never
81    /// runs. This is the default.
82    #[default]
83    Required,
84    /// Resolution is deferred to the handler.
85    ///
86    /// The engine does not resolve a credential on the command's behalf; the
87    /// handler (or an authorizer) triggers the auth flow only by calling
88    /// [`CredentialResolver::resolve`]/[`try_resolve`](CredentialResolver::try_resolve).
89    /// Use for commands that behave differently when authenticated but must still
90    /// run when the user is logged out.
91    Optional,
92    /// The command never authenticates and has no credential.
93    ///
94    /// Equivalent to the legacy `no_auth(true)` marker: default-env injection is
95    /// suppressed and [`CredentialResolver::resolve`] returns an error.
96    None,
97}
98
99impl AuthRequirement {
100    /// Returns `true` when the command never authenticates.
101    #[must_use]
102    pub fn is_none(self) -> bool {
103        matches!(self, Self::None)
104    }
105
106    /// Returns `true` when the engine must resolve the credential before the handler runs.
107    #[must_use]
108    pub fn is_required(self) -> bool {
109        matches!(self, Self::Required)
110    }
111
112    /// Returns `true` when resolution is deferred to the handler.
113    #[must_use]
114    pub fn is_optional(self) -> bool {
115        matches!(self, Self::Optional)
116    }
117}
118
119/// Resolves the credential for a single command invocation, memoizing the result.
120///
121/// Resolution — including any interactive browser/OAuth flow — runs at most once:
122/// a handler and an authorizer that both ask share a single resolution, and the
123/// engine resolves it up front for [`AuthRequirement::Required`] commands. For
124/// [`Optional`](AuthRequirement::Optional) commands resolution is deferred until a
125/// handler or authorizer calls [`resolve`](Self::resolve) or
126/// [`try_resolve`](Self::try_resolve), and `--schema`/`--dry-run` short-circuit
127/// before any resolution happens.
128///
129/// The resolved credential is memoized: a handler and an authorizer that both
130/// ask share a single resolution. Clones share the same underlying state, so the
131/// engine can observe (via [`peek`](Self::peek)) whatever a handler resolved.
132#[derive(Clone)]
133pub struct CredentialResolver {
134    inner: Arc<ResolverInner>,
135}
136
137#[derive(Debug)]
138struct ResolverInner {
139    auth: Dispatcher,
140    provider: String,
141    env: String,
142    command_path: String,
143    tier: String,
144    no_auth: bool,
145    cell: OnceCell<Credential>,
146}
147
148impl std::fmt::Debug for CredentialResolver {
149    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
150        formatter
151            .debug_struct("CredentialResolver")
152            .field("provider", &self.inner.provider)
153            .field("env", &self.inner.env)
154            .field("no_auth", &self.inner.no_auth)
155            .field("resolved", &self.inner.cell.get().is_some())
156            .finish_non_exhaustive()
157    }
158}
159
160impl CredentialResolver {
161    fn new(
162        auth: Dispatcher,
163        provider: String,
164        env: String,
165        command_path: String,
166        tier: String,
167        no_auth: bool,
168    ) -> Self {
169        Self {
170            inner: Arc::new(ResolverInner {
171                auth,
172                provider,
173                env,
174                command_path,
175                tier,
176                no_auth,
177                cell: OnceCell::new(),
178            }),
179        }
180    }
181
182    /// Resolves the credential, memoizing the result after the first success.
183    ///
184    /// # Errors
185    ///
186    /// Returns an error when the command is marked [`no_auth`](crate::CommandSpec::no_auth)
187    /// (such commands have no credential), or when the auth provider fails to
188    /// produce one.
189    pub async fn resolve(&self) -> Result<Credential> {
190        if self.inner.no_auth {
191            return Err(CliCoreError::message(
192                "command is marked no_auth and has no credential",
193            ));
194        }
195        let inner = &self.inner;
196        let credential = inner
197            .cell
198            .get_or_try_init(async || {
199                inner
200                    .auth
201                    .get_credential(
202                        &inner.provider,
203                        &inner.env,
204                        &inner.command_path,
205                        &inner.tier,
206                    )
207                    .await
208                    // Mark resolution failures so the engine can classify them as
209                    // `auth-error` based on the error a handler actually returns,
210                    // rather than tracking a separate side-channel flag that could
211                    // go stale if the handler swallows the failure.
212                    .map_err(|source| auth_resolution_error(&inner.provider, source))
213            })
214            .await?;
215        Ok(credential.clone())
216    }
217
218    /// Resolves the credential when one is available.
219    ///
220    /// Returns `Ok(None)` for no-auth commands, `Ok(Some(_))` on success, and
221    /// propagates the provider error on failure. Use this for commands whose
222    /// auth is genuinely optional; most commands should call
223    /// [`resolve`](Self::resolve) instead.
224    ///
225    /// # Errors
226    ///
227    /// Propagates the auth provider error when resolution is attempted and fails.
228    pub async fn try_resolve(&self) -> Result<Option<Credential>> {
229        if self.inner.no_auth {
230            return Ok(None);
231        }
232        self.resolve().await.map(Some)
233    }
234
235    /// Returns the memoized credential without triggering resolution.
236    ///
237    /// Yields `None` until something resolves the credential. Used by the engine
238    /// to record identity in audit/activity output after a handler runs.
239    #[must_use]
240    pub fn peek(&self) -> Option<&Credential> {
241        self.inner.cell.get()
242    }
243}
244
245/// Marks a credential-resolution failure so its auth origin is detectable via
246/// [`CliCoreError::is_auth`], leaving errors that are already auth-typed
247/// unchanged. Display is preserved except for the `auth: provider …:` prefix that
248/// the [`AuthProvider`](CliCoreError::AuthProvider) wrapper adds.
249fn auth_resolution_error(provider: &str, source: CliCoreError) -> CliCoreError {
250    match source {
251        auth @ (CliCoreError::MissingAuthProvider(_) | CliCoreError::AuthProvider { .. }) => auth,
252        other => CliCoreError::AuthProvider {
253            provider: provider.to_owned(),
254            source: Box::new(other),
255        },
256    }
257}
258
259#[async_trait]
260/// Authorization hook called before business logic.
261///
262/// The authorizer receives a [`CredentialResolver`] rather than an
263/// already-resolved credential so authorization remains lazy: an authorizer that
264/// does not need identity never triggers a credential/auth flow. Call
265/// [`CredentialResolver::try_resolve`] only when a decision actually depends on
266/// the credential.
267pub trait Authorizer: Send + Sync + std::fmt::Debug {
268    /// Verifies whether `command_path` may run with the provided args, reason, and tier.
269    async fn authorize(
270        &self,
271        command_path: &str,
272        args: &ValueMap,
273        credential: &CredentialResolver,
274        reason: &str,
275        tier: Tier,
276    ) -> Result<()>;
277}
278
279#[async_trait]
280/// Audit hook called for success, error, denied, auth-error, and dry-run outcomes.
281pub trait Auditor: Send + Sync + std::fmt::Debug {
282    /// Appends an audit record.
283    async fn append(
284        &self,
285        command_path: &str,
286        args: &ValueMap,
287        identity: &str,
288        result: &str,
289        reason: &str,
290    ) -> Result<()>;
291}
292
293#[async_trait]
294/// Activity hook for structured command lifecycle events.
295pub trait ActivityEmitter: Send + Sync + std::fmt::Debug {
296    /// Emits one completed command event.
297    async fn emit(&self, event: ActivityEvent) -> Result<()>;
298}
299
300/// Structured activity event emitted after command execution paths.
301#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
302pub struct ActivityEvent {
303    /// UTC timestamp in RFC3339 seconds format.
304    pub timestamp: String,
305    /// CLI application id.
306    pub app: String,
307    /// Colon-separated command path.
308    pub command: String,
309    /// Selected environment.
310    pub env: String,
311    /// Backend/system id.
312    pub backend: String,
313    /// Human identity from the resolved credential.
314    pub identity: String,
315    /// Subject identifier from the resolved credential.
316    pub sub: String,
317    /// Account type from the resolved credential.
318    pub account_type: String,
319    /// Outcome such as `ok`, `error`, `denied`, `auth-error`, or `dry-run`.
320    pub status: String,
321    /// Error message for failed outcomes.
322    pub error: String,
323    /// User-provided reason.
324    pub reason: String,
325    /// Effective command args.
326    pub args: ValueMap,
327    /// Command duration in milliseconds.
328    pub duration_ms: i64,
329    /// Reserved extension metadata.
330    pub meta: ValueMap,
331}
332
333/// Cross-cutting command execution state and dependencies.
334///
335/// Middleware is intentionally a plain, cloneable struct so tests and command
336/// handlers can inspect what will be used for a run. Application setup usually
337/// mutates it through `CliConfig` hooks or `ModuleContext`.
338#[derive(Clone, Debug, Default)]
339pub struct Middleware {
340    /// Optional authorization provider.
341    pub authz: Option<Arc<dyn Authorizer>>,
342    /// Auth provider dispatcher.
343    pub auth: Dispatcher,
344    /// Optional audit sink.
345    pub auditor: Option<Arc<dyn Auditor>>,
346    /// Optional activity sink.
347    pub activity: Option<Arc<dyn ActivityEmitter>>,
348    /// Application id used in output metadata.
349    pub app_id: String,
350    /// Fallback auth provider for commands without an explicit provider.
351    pub default_auth_provider: String,
352    /// Output format: `json`, `human`, or `toon`.
353    pub output_format: String,
354    /// Selected environment.
355    pub env: String,
356    /// Metadata verbosity selector.
357    pub verbose: String,
358    /// Whether mutating commands should short-circuit.
359    pub dry_run: bool,
360    /// User field projection.
361    pub fields: String,
362    /// JMESPath per-item list predicate.
363    pub filter: String,
364    /// JMESPath whole-result expression.
365    pub expr: String,
366    /// Client-side page size.
367    pub limit: i64,
368    /// Client-side page offset.
369    pub offset: i64,
370    /// User reason passed to authorization and audit.
371    pub reason: String,
372    /// Whether schema rendering was requested.
373    pub schema: bool,
374    /// Optional command deadline.
375    pub timeout: Option<Duration>,
376    /// Debug selector, interpreted by applications.
377    pub debug: String,
378    /// Search query, interpreted before command execution.
379    pub search: String,
380    /// Output schema registry.
381    pub schema_registry: SchemaRegistry,
382    /// Human output view registry.
383    pub human_views: HumanViewRegistry,
384}
385
386/// Rendered result produced by middleware.
387#[derive(Clone, Debug, PartialEq)]
388pub struct MiddlewareOutput {
389    /// Prepared output envelope.
390    pub envelope: Envelope,
391    /// Rendered output string.
392    pub rendered: String,
393    /// Process-style exit code.
394    pub exit_code: i32,
395}
396
397/// Inputs for one middleware-managed command execution.
398#[derive(Clone, Debug, PartialEq)]
399pub struct MiddlewareRequest<'request> {
400    /// Per-command metadata used by authentication, authorization, dry-run, audit, and activity.
401    pub meta: CommandMeta,
402    /// Colon-separated command path.
403    pub command_path: &'request str,
404    /// Backend/system id used in output metadata and generic error attribution.
405    pub system: &'request str,
406    /// Arguments explicitly supplied by the user.
407    pub user_args: ValueMap,
408    /// Effective arguments, including defaults.
409    pub args: ValueMap,
410    /// Default field projection when `--fields` is absent.
411    pub default_fields: &'request str,
412    /// Authentication requirement enforced by the engine for this command.
413    pub auth: AuthRequirement,
414}
415
416impl Middleware {
417    /// Creates middleware with empty registries and default dependencies.
418    #[must_use]
419    pub fn new() -> Self {
420        Self::default()
421    }
422
423    /// Runs the middleware chain for a command.
424    pub async fn run<F, Fut, Output>(
425        &self,
426        request: MiddlewareRequest<'_>,
427        command: F,
428    ) -> Result<MiddlewareOutput>
429    where
430        F: FnOnce(CredentialResolver) -> Fut + Send,
431        Fut: Future<Output = Result<Output>> + Send,
432        Output: Into<CommandResult>,
433    {
434        let start = Instant::now();
435        let MiddlewareRequest {
436            meta,
437            command_path,
438            system,
439            user_args,
440            mut args,
441            default_fields,
442            auth,
443        } = request;
444        let no_auth = auth.is_none();
445        let command_system = effective_request_system(system, command_path);
446        if !no_auth && !self.env.is_empty() && !args.contains_key("env") {
447            args.insert("env".to_owned(), Value::String(self.env.clone()));
448        }
449
450        // Build a lazy resolver instead of resolving eagerly. No auth flow runs
451        // until a handler or authorizer actually asks for the credential, so
452        // commands that never use it (and `--schema`/`--dry-run`) skip auth.
453        let provider_name = meta
454            .provider()
455            .filter(|provider| !provider.is_empty())
456            .unwrap_or(&self.default_auth_provider)
457            .to_owned();
458        let resolved_env = meta.fixed_env().unwrap_or(&self.env).to_owned();
459        let tier_text = meta
460            .auth_metadata
461            .get("tier")
462            .map_or("", String::as_str)
463            .to_owned();
464        let resolver = CredentialResolver::new(
465            self.auth.clone(),
466            provider_name.clone(),
467            resolved_env,
468            command_path.to_owned(),
469            tier_text,
470            no_auth,
471        );
472
473        if no_auth
474            && let Some(output) =
475                self.render_schema_if_requested(command_path, start, &user_args, &args, "")?
476        {
477            return Ok(output);
478        }
479
480        if let Some(authz) = &self.authz
481            && let Err(err) = authz
482                .authorize(command_path, &args, &resolver, &self.reason, meta.tier())
483                .await
484        {
485            // An authorizer may have resolved the credential to make its
486            // decision; reflect whatever it resolved in audit identity.
487            let identity = resolver.peek().map_or("", |cred| cred.identity.as_str());
488            // Classify by the error the authorizer returned: a propagated
489            // resolution failure is auth-typed; a policy denial is not.
490            let had_auth_error = err.is_auth();
491            let result_tag = if had_auth_error {
492                "auth-error"
493            } else {
494                "denied"
495            };
496            // Attribute auth-provider failures to the provider so telemetry can
497            // distinguish them from command backends.
498            let backend = if had_auth_error {
499                provider_name.as_str()
500            } else {
501                command_path
502            };
503            self.write_audit(command_path, &args, identity, result_tag)
504                .await;
505            self.emit_activity(
506                command_path,
507                &args,
508                resolver.peek(),
509                result_tag,
510                backend,
511                &err.to_string(),
512                start,
513            )
514            .await;
515            return self.render_error(&err, command_path, start, &user_args, &args, identity);
516        }
517
518        // If the authorizer resolved the credential, include its identity in the
519        // schema output metadata. `peek()` never triggers resolution, so schema
520        // still doesn't provoke auth on its own.
521        let schema_identity = resolver.peek().map_or("", |cred| cred.identity.as_str());
522        if let Some(output) = self.render_schema_if_requested(
523            command_path,
524            start,
525            &user_args,
526            &args,
527            schema_identity,
528        )? {
529            return Ok(output);
530        }
531
532        if self.dry_run && meta.dry_run_prompt {
533            let identity = resolver.peek().map_or("", |cred| cred.identity.as_str());
534            self.write_audit(command_path, &args, identity, "dry-run")
535                .await;
536            self.emit_activity(
537                command_path,
538                &args,
539                resolver.peek(),
540                "dry-run",
541                command_path,
542                "",
543                start,
544            )
545            .await;
546            let envelope = Envelope::success(
547                json!({
548                    "command": command_path,
549                    "action": "dry-run: would execute",
550                }),
551                command_path,
552            )
553            .with_dry_run();
554            return self.render_envelope(
555                envelope,
556                "",
557                command_path,
558                start,
559                &user_args,
560                &args,
561                identity,
562            );
563        }
564
565        // Fail closed by default: for `Required` commands the engine resolves the
566        // credential before the handler runs, so a command that must be
567        // authenticated cannot execute unauthenticated even if its handler never
568        // reads the credential, and its audit/activity identity is always
569        // populated. `--schema`/`--dry-run` return above, so they never reach this
570        // point; `Optional`/`None` commands defer resolution to the handler.
571        if auth.is_required()
572            && let Err(err) = resolver.resolve().await
573        {
574            // Mirror the handler-path auth-error treatment: classify as
575            // `auth-error` and attribute the activity backend to the auth provider
576            // so telemetry can distinguish auth-provider failures from command
577            // backends. Resolution failed, so there is no identity to record.
578            self.write_audit(command_path, &args, "", "auth-error")
579                .await;
580            self.emit_activity(
581                command_path,
582                &args,
583                resolver.peek(),
584                "auth-error",
585                provider_name.as_str(),
586                &err.to_string(),
587                start,
588            )
589            .await;
590            return self.render_error(&err, command_path, start, &user_args, &args, "");
591        }
592
593        let result = match command(resolver.clone()).await {
594            Ok(result) => result.into(),
595            Err(err) => {
596                // A deferred `resolve()` failure surfaces as a handler error;
597                // classify it as `auth-error` when the error the handler returned
598                // is itself auth-typed. A handler that swallows a resolution
599                // failure and then fails for another reason returns a non-auth
600                // error here, so it is not misclassified.
601                let identity = resolver.peek().map_or("", |cred| cred.identity.as_str());
602                let (result_tag, error_system, activity_backend) = if err.is_auth() {
603                    // Render against the command path, but attribute the activity
604                    // backend to the auth provider so telemetry can distinguish
605                    // auth-provider failures from command backends.
606                    ("auth-error", command_path, provider_name.as_str())
607                } else {
608                    let system = err.system().unwrap_or(&command_system);
609                    ("error", system, system)
610                };
611                self.write_audit(command_path, &args, identity, result_tag)
612                    .await;
613                self.emit_activity(
614                    command_path,
615                    &args,
616                    resolver.peek(),
617                    result_tag,
618                    activity_backend,
619                    &err.to_string(),
620                    start,
621                )
622                .await;
623                return self.render_error(&err, error_system, start, &user_args, &args, identity);
624            }
625        };
626        // The handler may have resolved the credential; surface its identity.
627        let identity = resolver.peek().map_or("", |cred| cred.identity.as_str());
628        self.write_audit(command_path, &args, identity, "ok").await;
629        self.emit_activity(
630            command_path,
631            &args,
632            resolver.peek(),
633            "ok",
634            &command_system,
635            "",
636            start,
637        )
638        .await;
639
640        let CommandResult { data, metadata } = result;
641        self.render_envelope(
642            Envelope::success(data, command_system).with_next_actions(metadata.next_actions),
643            default_fields,
644            command_path,
645            start,
646            &user_args,
647            &args,
648            identity,
649        )
650    }
651
652    #[doc(hidden)]
653    pub async fn run_no_auth<F, Fut>(
654        &self,
655        meta: CommandMeta,
656        command_path: &str,
657        user_args: ValueMap,
658        args: ValueMap,
659        default_fields: &str,
660        command: F,
661    ) -> Result<MiddlewareOutput>
662    where
663        F: FnOnce() -> Fut + Send,
664        Fut: Future<Output = Result<CommandResult>> + Send,
665    {
666        self.run(
667            MiddlewareRequest {
668                meta,
669                command_path,
670                system: fallback_system(command_path),
671                user_args,
672                args,
673                default_fields,
674                auth: AuthRequirement::None,
675            },
676            async move |_resolver| command().await,
677        )
678        .await
679    }
680
681    async fn write_audit(&self, command_path: &str, args: &ValueMap, identity: &str, result: &str) {
682        if let Some(auditor) = &self.auditor
683            && let Err(err) = auditor
684                .append(command_path, args, identity, result, &self.reason)
685                .await
686        {
687            tracing::warn!(command = command_path, error = %err, "audit log write failed");
688        }
689    }
690
691    #[allow(clippy::too_many_arguments)]
692    async fn emit_activity(
693        &self,
694        command_path: &str,
695        args: &ValueMap,
696        credential: Option<&Credential>,
697        result: &str,
698        backend: &str,
699        error: &str,
700        start: Instant,
701    ) {
702        let Some(activity) = &self.activity else {
703            return;
704        };
705        let (identity, sub, account_type) = credential.map_or_else(
706            || (String::new(), String::new(), String::new()),
707            |credential| {
708                (
709                    credential.identity.clone(),
710                    credential.sub.clone(),
711                    credential.account_type.clone(),
712                )
713            },
714        );
715        let duration_ms = i64::try_from(start.elapsed().as_millis()).unwrap_or(i64::MAX);
716        let event = ActivityEvent {
717            timestamp: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
718            app: self.app_id.clone(),
719            command: command_path.to_owned(),
720            env: self.env.clone(),
721            backend: backend.to_owned(),
722            identity,
723            sub,
724            account_type,
725            status: result.to_owned(),
726            error: error.to_owned(),
727            reason: self.reason.clone(),
728            args: args.clone(),
729            duration_ms,
730            meta: ValueMap::new(),
731        };
732        if let Err(err) = activity.emit(event).await {
733            tracing::warn!(command = command_path, error = %err, "activity emit failed");
734        }
735    }
736
737    fn render_schema_if_requested(
738        &self,
739        command_path: &str,
740        start: Instant,
741        user_args: &ValueMap,
742        effective_args: &ValueMap,
743        identity: &str,
744    ) -> Result<Option<MiddlewareOutput>> {
745        if self.schema
746            && let Some(schema) = self.schema_registry.get_by_path(command_path)
747        {
748            return self
749                .render_envelope(
750                    Envelope::success(schema, self.app_id.clone()),
751                    "",
752                    command_path,
753                    start,
754                    user_args,
755                    effective_args,
756                    identity,
757                )
758                .map(Some);
759        }
760        Ok(None)
761    }
762
763    #[allow(clippy::too_many_arguments)]
764    fn render_envelope(
765        &self,
766        mut envelope: Envelope,
767        default_fields: &str,
768        command_path: &str,
769        start: Instant,
770        user_args: &ValueMap,
771        effective_args: &ValueMap,
772        identity: &str,
773    ) -> Result<MiddlewareOutput> {
774        if !is_valid_output_format(&self.output_format) {
775            let err = CliCoreError::InvalidOutputFormat(self.output_format.clone());
776            return self.render_error(
777                &err,
778                &self.app_id,
779                start,
780                user_args,
781                effective_args,
782                identity,
783            );
784        }
785        let output_format = self.output_format.parse::<OutputFormat>()?;
786        let mut fields = if self.fields.is_empty() {
787            default_fields
788        } else {
789            &self.fields
790        };
791        if output_format == OutputFormat::Human && self.fields.is_empty() {
792            fields = "";
793        }
794        if let Some(data) = &mut envelope.data {
795            let pagination = apply_pipeline(
796                data,
797                &PipelineOpts {
798                    filter: self.filter.clone(),
799                    limit: self.limit,
800                    offset: self.offset,
801                    expr: self.expr.clone(),
802                    fields: fields.to_owned(),
803                },
804            )?;
805            if let Some(pagination) = pagination
806                && let Some(metadata) = &mut envelope.metadata
807            {
808                metadata.pagination = Some(pagination);
809            }
810        }
811        envelope.with_context(
812            command_path,
813            &self.env,
814            identity,
815            start.elapsed(),
816            Some(Value::Object(user_args.clone())),
817            Some(Value::Object(effective_args.clone())),
818        );
819        let system = envelope
820            .metadata
821            .as_ref()
822            .map(|metadata| metadata.system.as_str())
823            .unwrap_or_default()
824            .to_owned();
825        let prepared = envelope.prepare_for_render(&self.verbose);
826        let rendered = if output_format == OutputFormat::Human {
827            render_human_with_registry_for_schema(&prepared, &self.human_views, &system)
828        } else {
829            crate::output::render(output_format, &prepared)?
830        };
831        Ok(MiddlewareOutput {
832            envelope: prepared,
833            rendered,
834            exit_code: 0,
835        })
836    }
837
838    fn render_error(
839        &self,
840        err: &(dyn std::error::Error + 'static),
841        system: &str,
842        start: Instant,
843        user_args: &ValueMap,
844        effective_args: &ValueMap,
845        identity: &str,
846    ) -> Result<MiddlewareOutput> {
847        let mut envelope = build_error_envelope(err, system);
848        envelope.with_context(
849            "",
850            &self.env,
851            identity,
852            start.elapsed(),
853            Some(Value::Object(user_args.clone())),
854            Some(Value::Object(effective_args.clone())),
855        );
856        let prepared = envelope.prepare_for_render(&self.verbose);
857        let rendered = crate::output::render_format(&self.output_format, &prepared)?;
858        Ok(MiddlewareOutput {
859            envelope: prepared,
860            rendered,
861            exit_code: exit_code_for_error(err),
862        })
863    }
864}
865
866/// Convenience helper for building a JSON object map.
867#[must_use]
868pub fn value_map(entries: impl IntoIterator<Item = (impl Into<String>, Value)>) -> ValueMap {
869    entries
870        .into_iter()
871        .map(|(key, value)| (key.into(), value))
872        .collect()
873}
874
875fn effective_request_system(system: &str, command_path: &str) -> String {
876    if system.is_empty() {
877        return fallback_system(command_path).to_owned();
878    }
879    system.to_owned()
880}
881
882fn fallback_system(command_path: &str) -> &str {
883    command_path
884        .split_once(':')
885        .map_or(command_path, |(system, _)| system)
886}
887
888impl From<CliCoreError> for Value {
889    fn from(error: CliCoreError) -> Self {
890        Value::String(error.to_string())
891    }
892}