Skip to main content

xianyun_cli/
hooks.rs

1//! The `caproni` root command's `Before`/`After` hook equivalents.
2//!
3//! Ported from `cmd/factory.go`'s `createBeforeHook`/`createAfterHook`.
4//! `urfave/cli/v3`'s `Before` hook runs once before any subcommand's
5//! `Action`, resolving the effective config file path and installing a
6//! logger; `clap` has no direct equivalent (there is no single "before
7//! dispatch" callback slot on a derived `Parser`), so this module exposes
8//! the same two concerns as plain functions that [`super::commands::dispatch`]
9//! calls explicitly before matching on the subcommand.
10//!
11//! Deviation from Go: [`before_hook`] covers only config-file resolution
12//! (exactly what `TestBeforeHook` asserts on), not logger setup. Go's
13//! `createBeforeHook` closure does both in one step, but that closure is
14//! only ever invoked once per real process. This port's `before_hook` is
15//! unit-tested directly (see `tests/factory.rs`), and
16//! `xianyun_executor::logging`'s `tracing::Dispatch` can only be
17//! installed as the process-wide default once -- calling it from
18//! `before_hook` would make the second in-process test call fail. Logger
19//! initialization is therefore split out into [`init_logging`], which
20//! `dispatch()` calls once per real invocation and which tolerates being
21//! called more than once (by ignoring the "already set" error) rather
22//! than panicking, so it stays safe to call from tests too.
23
24use xianyun_core::config::global::{GlobalConfig, find_config_file};
25use xianyun_executor::logging::cliflags::LoggingArgs;
26
27use crate::Cli;
28
29/// Checks the `CAPRONI_NO_CACHE` compatibility alias for `--no-cache`/
30/// `XIANYUN_NO_CACHE` (which `clap`'s `env` attribute on `Cli::no_cache`
31/// already handles natively -- `clap` only supports one env var name per
32/// arg, so the alias is checked here instead). Any non-empty value
33/// counts as set, matching `clap`'s own bool-env-var truthiness for this
34/// arg.
35fn caproni_no_cache_alias_set() -> bool {
36    std::env::var("CAPRONI_NO_CACHE").is_ok_and(|v| !v.is_empty())
37}
38
39/// Ported from `shouldLoadConfig`.
40///
41/// `command_name` is `None` when no subcommand has been resolved yet
42/// (mirrors Go's `args == nil` case, which always loads); `Some("")` is
43/// unreachable in this port since `clap` requires a subcommand, but is
44/// accepted for parity with Go's skip list.
45#[must_use]
46pub fn should_load_config(command_name: Option<&str>) -> bool {
47    match command_name {
48        None => true,
49        Some(name) => !matches!(name, "" | "h" | "help" | "version"),
50    }
51}
52
53/// Ported from `createBeforeHook`'s config-resolution half: if no
54/// `--config-file` was given explicitly and the resolved subcommand needs
55/// one, searches parent directories for `caproni.yaml`
56/// ([`find_config_file`]).
57///
58/// # Errors
59///
60/// Returns an error if config-file discovery fails (no `--config-file`
61/// given, and no `caproni.yaml` found in any parent directory).
62pub fn before_hook(cli: &Cli, command_name: Option<&str>) -> anyhow::Result<GlobalConfig> {
63    let mut global_config = GlobalConfig::new(None);
64    global_config.no_caching = cli.no_cache || caproni_no_cache_alias_set();
65
66    if let Some(path) = &cli.config_file {
67        global_config.config_file.clone_from(path);
68    } else if should_load_config(command_name) {
69        global_config.config_file =
70            find_config_file().map_err(|err| anyhow::anyhow!("finding config file: {err}"))?;
71    }
72
73    Ok(global_config)
74}
75
76/// Builds [`LoggingArgs`] from the top-level `--log-*`/`--debug` flags
77/// stored on [`Cli`], filling in `LoggingArgs`'s own defaults for any
78/// flag the user didn't pass (mirroring `clap`'s `default_value`
79/// handling for a struct that isn't `#[command(flatten)]`d into `Cli`
80/// directly -- see `PORT_NOTES.md` for why `Cli`'s logging flags are
81/// plain `Option<...>` fields instead).
82fn logging_args_from_cli(cli: &Cli) -> LoggingArgs {
83    let defaults = LoggingArgs::default();
84
85    LoggingArgs {
86        log_format: cli.log_format.clone().unwrap_or(defaults.log_format),
87        log_level: cli.log_level.clone().unwrap_or(defaults.log_level),
88        log_color: cli
89            .log_color
90            .map(|c| c.to_string())
91            .unwrap_or(defaults.log_color),
92        log_output: cli.log_output.clone().unwrap_or(defaults.log_output),
93        log_source: cli.log_source,
94        debug: cli.debug,
95    }
96}
97
98/// Resolves `Cli`'s `--log-*`/`--debug` flags into a
99/// [`logging::Config`](xianyun_executor::logging::Config), without
100/// installing anything. Shared by [`init_logging`] (which builds the
101/// process-wide default dispatcher from it) and `commands::run`, which
102/// needs the same resolved config to build a second, TUI-routed
103/// dispatcher scoped to its reloader tasks -- see that module's doc
104/// comment on `build_reloader_run_fn` for why a *second* dispatch is
105/// needed at all (in short: `tracing`'s global default is set-once,
106/// unlike Go's freely-reassignable `slog.SetDefault`, so routing output
107/// through the log-filter TUI for `run`'s lifetime only is a scoped
108/// override, not a global swap).
109///
110/// # Errors
111///
112/// Returns an error if the logging configuration itself is invalid
113/// (unparseable `--log-level`/`--log-color`).
114pub(crate) fn resolve_logging_config(
115    cli: &Cli,
116) -> anyhow::Result<xianyun_executor::logging::Config> {
117    let logging_args = logging_args_from_cli(cli);
118
119    xianyun_executor::logging::cliflags::parse_config(&logging_args)
120        .map_err(|err| anyhow::anyhow!("parsing logging config: {err}"))
121}
122
123/// Ported from `createBeforeHook`'s logger-setup half. Installs a
124/// `tracing` dispatcher built from `Cli`'s logging flags as the
125/// process-wide default.
126///
127/// Tolerates being called more than once per process (each call after
128/// the first is a silent no-op) rather than erroring or panicking, since
129/// `tracing::dispatcher::set_global_default` can only succeed once --
130/// this keeps the function safe to call from tests that run multiple
131/// commands in the same process.
132///
133/// # Errors
134///
135/// Returns an error if the logging configuration itself is invalid
136/// (unparseable `--log-level`/`--log-color`) or the configured output
137/// can't be opened.
138pub fn init_logging(cli: &Cli) -> anyhow::Result<()> {
139    let config = resolve_logging_config(cli)?;
140
141    let (dispatch, _writer) = xianyun_executor::logging::new_logger(&config)
142        .map_err(|err| anyhow::anyhow!("creating logger: {err}"))?;
143
144    // Ignore "a global default trace dispatcher has already been set":
145    // see the doc comment above for why this must be tolerated.
146    let _ = tracing::dispatcher::set_global_default(dispatch);
147
148    Ok(())
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154
155    #[test]
156    fn should_load_config_matches_go_skip_list() {
157        assert!(should_load_config(None));
158        assert!(should_load_config(Some("up")));
159        assert!(!should_load_config(Some("")));
160        assert!(!should_load_config(Some("h")));
161        assert!(!should_load_config(Some("help")));
162        assert!(!should_load_config(Some("version")));
163    }
164}