Skip to main content

hyperi_rustlib/cli/
app.rs

1// Project:   hyperi-rustlib
2// File:      src/cli/app.rs
3// Purpose:   DfeApp trait and standard lifecycle runner
4// Language:  Rust
5//
6// License:   BUSL-1.1
7// Copyright: (c) 2026 HYPERI PTY LIMITED
8
9//! Application trait and lifecycle runner for DFE services.
10//!
11//! Provides the standard startup sequence: parse → log → config → dispatch.
12//!
13//! ## Example
14//!
15//! ```rust,ignore
16//! use hyperi_rustlib::cli::{CommonArgs, DfeApp, CliError, VersionInfo, run_app};
17//!
18//! struct MyApp { common: CommonArgs }
19//!
20//! impl DfeApp for MyApp {
21//!     type Config = MyConfig;
22//!
23//!     fn name(&self) -> &str { "my-service" }
24//!     fn env_prefix(&self) -> &str { "MY_SERVICE" }
25//!     fn version_info(&self) -> VersionInfo {
26//!         VersionInfo::new("my-service", env!("CARGO_PKG_VERSION"))
27//!     }
28//!     fn common_args(&self) -> &CommonArgs { &self.common }
29//!     fn load_config(&self, path: Option<&str>) -> Result<MyConfig, CliError> { todo!() }
30//!     async fn run_service(&self, config: MyConfig) -> Result<(), CliError> { todo!() }
31//! }
32//! ```
33
34use std::fmt::Debug;
35
36use serde::de::DeserializeOwned;
37
38use super::error::CliError;
39use super::version::VersionInfo;
40use super::{CommonArgs, StandardCommand, output};
41
42/// Trait for DFE service applications.
43///
44/// Implement this trait to get the standard CLI lifecycle for free.
45/// The 80% common behaviour (logging, config, metrics, version) is handled
46/// by `run_app()`. Your app provides the 20% (config type, service logic).
47pub trait DfeApp: Sized {
48    /// Application-specific configuration type.
49    type Config: DeserializeOwned + Debug + Send + Sync;
50
51    /// Service name (e.g. "dfe-loader").
52    fn name(&self) -> &str;
53
54    /// Environment variable prefix for config cascade (e.g. "DFE_LOADER").
55    fn env_prefix(&self) -> &str;
56
57    /// Version information for this service.
58    fn version_info(&self) -> VersionInfo;
59
60    /// Access the common CLI arguments.
61    fn common_args(&self) -> &CommonArgs;
62
63    /// Resolve the active subcommand.
64    ///
65    /// Returns `None` to default to `StandardCommand::Run`.
66    fn command(&self) -> Option<&StandardCommand> {
67        None
68    }
69
70    /// Load application configuration from the given path (or defaults).
71    ///
72    /// # Errors
73    ///
74    /// Returns `CliError` if configuration cannot be loaded or parsed.
75    fn load_config(&self, path: Option<&str>) -> Result<Self::Config, CliError>;
76
77    /// Run the main service loop.
78    ///
79    /// Called after logging, config, and [`ServiceRuntime`](super::ServiceRuntime)
80    /// are initialised. The runtime contains all common infrastructure (metrics,
81    /// memory guard, shutdown token, worker pool, scaling pressure). Apps just
82    /// use it -- no boilerplate needed.
83    ///
84    /// # Errors
85    ///
86    /// Returns `CliError` if the service encounters a fatal error.
87    fn run_service(
88        &self,
89        config: Self::Config,
90        runtime: super::ServiceRuntime,
91    ) -> impl std::future::Future<Output = Result<(), CliError>> + Send;
92
93    /// Provide scaling pressure components for KEDA autoscaling.
94    ///
95    /// Override to register app-specific scaling signals (buffer depth,
96    /// consumer lag, error rate, etc.). The default returns an empty vec.
97    #[cfg(feature = "scaling")]
98    fn scaling_components(&self, _config: &Self::Config) -> Vec<crate::ScalingComponent> {
99        vec![]
100    }
101
102    /// Register all metrics for this service.
103    ///
104    /// Called by `metrics-manifest` and `generate-artefacts` subcommands to
105    /// capture the full metric catalogue without starting the service.
106    /// The default implementation is a no-op. Override to register
107    /// `DfeMetrics`, metric groups, and app-specific metrics.
108    #[cfg(any(feature = "metrics", feature = "otel-metrics"))]
109    fn register_metrics(&self, _manager: &crate::metrics::MetricsManager) {}
110
111    /// Build the deployment contract for this service.
112    ///
113    /// Called by `generate-artefacts` to produce container specs, health
114    /// endpoints, KEDA config, and metrics manifest. The default returns
115    /// `None`. Override to provide a contract.
116    #[cfg(feature = "deployment")]
117    fn deployment_contract(&self) -> Option<crate::deployment::DeploymentContract> {
118        None
119    }
120}
121
122/// Drive the standard DFE service lifecycle.
123///
124/// Handles subcommand dispatch:
125/// - `run` (default): init logger → load config → run service
126/// - `version`: print version info and exit
127/// - `config-check`: load config, validate, print summary
128///
129/// # Errors
130///
131/// Returns `CliError` if any lifecycle step fails.
132pub async fn run_app<A: DfeApp>(app: A) -> Result<(), CliError> {
133    let command = app.command().cloned().unwrap_or(StandardCommand::Run);
134    let args = app.common_args();
135
136    match command {
137        StandardCommand::Version => {
138            let info = app.version_info();
139            println!("{info}");
140            Ok(())
141        }
142
143        StandardCommand::ConfigCheck => {
144            // Initialise logger for config-check output
145            init_logger(args)?;
146
147            let config_path = args.config.as_deref();
148            match app.load_config(config_path) {
149                Ok(config) => {
150                    output::print_success("configuration is valid");
151                    if !args.quiet {
152                        eprintln!();
153                        output::print_kv("service", &app.name());
154                        output::print_kv("config", &config_path.unwrap_or("(defaults)"));
155                        output::print_kv("log_level", &args.effective_log_level());
156                        output::print_kv("log_format", &args.log_format);
157                        output::print_kv("metrics_addr", &args.metrics_addr);
158                        eprintln!();
159                        // Mask the Debug dump before printing: configs hold
160                        // ENV-sourced secrets in plain `String` fields, so
161                        // `{config:#?}` would print them in clear text.
162                        // Without the `logger` feature the consumer has opted
163                        // out of every sensitive-field defence anyway, so an
164                        // unmasked print here is consistent.
165                        let raw = format!("{config:#?}");
166                        #[cfg(feature = "logger")]
167                        let masked = {
168                            let default_fields = crate::logger::default_sensitive_fields();
169                            let patterns: Vec<&str> =
170                                default_fields.iter().map(String::as_str).collect();
171                            crate::logger::mask_sensitive_string(&raw, &patterns)
172                        };
173                        #[cfg(not(feature = "logger"))]
174                        let masked = raw;
175                        eprintln!("  config: {masked}");
176                    }
177                    Ok(())
178                }
179                Err(e) => {
180                    output::print_error(&format!("configuration invalid: {e}"));
181                    Err(e)
182                }
183            }
184        }
185
186        #[cfg(any(feature = "metrics", feature = "otel-metrics"))]
187        StandardCommand::MetricsManifest => {
188            let mgr = crate::metrics::MetricsManager::new(app.name());
189            app.register_metrics(&mgr);
190            let manifest = mgr.registry().manifest();
191            println!(
192                "{}",
193                serde_json::to_string_pretty(&manifest)
194                    .map_err(|e| CliError::Service(format!("JSON serialisation failed: {e}")))?
195            );
196            Ok(())
197        }
198        #[cfg(not(any(feature = "metrics", feature = "otel-metrics")))]
199        StandardCommand::MetricsManifest => {
200            output::print_error("metrics feature not enabled -- no manifest available");
201            Err(CliError::Service("metrics feature not enabled".into()))
202        }
203
204        StandardCommand::GenerateArtefacts(ref artefact_args) => {
205            generate_artefacts(&app, artefact_args)?;
206            Ok(())
207        }
208
209        StandardCommand::Run => {
210            let version_info = app.version_info();
211            init_logger_for_service(args, app.name(), &version_info.version)?;
212
213            tracing::info!(
214                service = app.name(),
215                version = version_info.version,
216                "starting service"
217            );
218
219            // Populate the global config cascade BEFORE load_config + the
220            // ServiceRuntime build, so every `from_cascade()` subsystem
221            // (governor, worker pool, batch engine, scaling) reads the app's
222            // real config instead of silently defaulting. Guarded by the
223            // try_get() check so apps that already call config::setup()
224            // themselves (e.g. those needing setup_async for Postgres) are not
225            // double-initialised -- setup() returns Err(AlreadyInitialised)
226            // otherwise.
227            #[cfg(feature = "config")]
228            if crate::config::try_get().is_none() {
229                let opts = crate::config::ConfigOptions {
230                    env_prefix: app.env_prefix().to_string(),
231                    app_name: Some(app.name().to_string()),
232                    config_file: args.config.as_deref().map(std::path::PathBuf::from),
233                    ..Default::default()
234                };
235                if let Err(e) = crate::config::setup(opts) {
236                    tracing::warn!(
237                        error = %e,
238                        "config cascade setup failed; from_cascade subsystems will use defaults"
239                    );
240                }
241            }
242
243            let config_path = args.config.as_deref();
244            let config = app.load_config(config_path)?;
245
246            tracing::debug!(?config, "configuration loaded");
247
248            // Build ServiceRuntime -- all common infrastructure for free
249            let commit = option_env!("GIT_COMMIT").unwrap_or("unknown");
250            let runtime = super::ServiceRuntime::build(
251                app.name(),
252                app.env_prefix(),
253                &args.metrics_addr,
254                &version_info.version,
255                commit,
256                #[cfg(feature = "scaling")]
257                app.scaling_components(&config),
258            )
259            .await?;
260
261            app.run_service(config, runtime).await
262        }
263
264        #[cfg(feature = "top")]
265        StandardCommand::Top(ref top_args) => {
266            let top_config = crate::top::TopConfig::from_args(top_args);
267            crate::top::run_top(&top_config).map_err(|e| CliError::Service(e.to_string()))
268        }
269    }
270}
271
272/// Initialise the logger from CLI arguments.
273#[cfg(feature = "logger")]
274fn init_logger(args: &CommonArgs) -> Result<(), CliError> {
275    let opts = args.to_logger_options()?;
276    crate::logger::setup(opts)?;
277    Ok(())
278}
279
280/// Initialise the logger with service name and version injected into JSON output.
281#[cfg(feature = "logger")]
282fn init_logger_for_service(
283    args: &CommonArgs,
284    service_name: &str,
285    service_version: &str,
286) -> Result<(), CliError> {
287    let opts = args.to_logger_options()?;
288    crate::logger::setup(crate::logger::LoggerOptions {
289        service_name: Some(service_name.to_string()),
290        service_version: Some(service_version.to_string()),
291        ..opts
292    })?;
293    Ok(())
294}
295
296/// Initialise the logger from CLI arguments (no-op without logger feature).
297#[cfg(not(feature = "logger"))]
298fn init_logger(_args: &CommonArgs) -> Result<(), CliError> {
299    Ok(())
300}
301
302/// Initialise the logger with service name and version (no-op without logger feature).
303#[cfg(not(feature = "logger"))]
304fn init_logger_for_service(
305    _args: &CommonArgs,
306    _service_name: &str,
307    _service_version: &str,
308) -> Result<(), CliError> {
309    Ok(())
310}
311
312/// Generate all CI artefacts for this service.
313///
314/// Produces metrics manifest, deployment contract, and container spec
315/// in the output directory. Files are deterministic -- running twice produces
316/// identical output (no timestamps that change between runs).
317fn generate_artefacts<A: DfeApp>(
318    app: &A,
319    args: &super::commands::GenerateArtefactsArgs,
320) -> Result<(), CliError> {
321    let output_dir = std::path::Path::new(&args.output_dir);
322    std::fs::create_dir_all(output_dir)
323        .map_err(|e| CliError::Service(format!("failed to create output dir: {e}")))?;
324
325    let mut generated: Vec<String> = Vec::new();
326
327    // Metrics manifest
328    #[cfg(any(feature = "metrics", feature = "otel-metrics"))]
329    {
330        let mgr = crate::metrics::MetricsManager::new(app.name());
331        app.register_metrics(&mgr);
332        let manifest = mgr.registry().manifest();
333        let path = output_dir.join("metrics-manifest.json");
334        let json = serde_json::to_string_pretty(&manifest)
335            .map_err(|e| CliError::Service(format!("metrics manifest JSON failed: {e}")))?;
336        std::fs::write(&path, &json)
337            .map_err(|e| CliError::Service(format!("failed to write {}: {e}", path.display())))?;
338        generated.push(format!(
339            "metrics-manifest.json ({} metrics)",
340            manifest.metrics.len()
341        ));
342    }
343
344    // Deployment contract + container manifest
345    #[cfg(feature = "deployment")]
346    let deployment_contract = app.deployment_contract();
347    #[cfg(feature = "deployment")]
348    if deployment_contract.is_none() {
349        output::print_warn(&format!(
350            "DfeApp::deployment_contract() returned None for `{}` -- \
351             only metrics-manifest.json will be generated. \
352             Implement the trait hook to emit deployment-contract.json, \
353             container-manifest.json, and Dockerfile.runtime.",
354            app.name()
355        ));
356    }
357    #[cfg(feature = "deployment")]
358    if let Some(contract) = deployment_contract {
359        // Full deployment contract (secrets, KEDA, Helm, everything)
360        let path = output_dir.join("deployment-contract.json");
361        let json = serde_json::to_string_pretty(&contract)
362            .map_err(|e| CliError::Service(format!("deployment contract JSON failed: {e}")))?;
363        std::fs::write(&path, &json)
364            .map_err(|e| CliError::Service(format!("failed to write {}: {e}", path.display())))?;
365        generated.push("deployment-contract.json".to_string());
366
367        // Container manifest (minimal subset for CI image builds)
368        let cm_path = output_dir.join("container-manifest.json");
369        let cm_json = crate::deployment::generate::generate_container_manifest(&contract)
370            .map_err(|e| CliError::Service(format!("container manifest failed: {e}")))?;
371        std::fs::write(&cm_path, &cm_json).map_err(|e| {
372            CliError::Service(format!("failed to write {}: {e}", cm_path.display()))
373        })?;
374        generated.push("container-manifest.json".to_string());
375
376        // Runtime stage Dockerfile fragment (for CI composition)
377        let rt_path = output_dir.join("Dockerfile.runtime");
378        let rt_content = crate::deployment::generate::generate_runtime_stage(&contract);
379        std::fs::write(&rt_path, &rt_content).map_err(|e| {
380            CliError::Service(format!("failed to write {}: {e}", rt_path.display()))
381        })?;
382        generated.push("Dockerfile.runtime".to_string());
383
384        // ArgoCD Application CR (default generation -- ArgoCD is the
385        // standard CD tool across the fleet).
386        let argo_path = output_dir.join("argocd-application.yaml");
387        let argo_cfg = crate::deployment::ArgocdConfig {
388            repo_url: crate::deployment::argocd_repo_url_from_cascade(&contract.app_name),
389            ..Default::default()
390        };
391        let argo_content =
392            crate::deployment::generate::generate_argocd_application(&contract, &argo_cfg, None);
393        std::fs::write(&argo_path, &argo_content).map_err(|e| {
394            CliError::Service(format!("failed to write {}: {e}", argo_path.display()))
395        })?;
396        generated.push("argocd-application.yaml".to_string());
397    }
398
399    if generated.is_empty() {
400        output::print_warn("no artefacts generated (no metrics or deployment features enabled)");
401    } else {
402        output::print_success(&format!(
403            "generated {} artefact(s) in {}",
404            generated.len(),
405            output_dir.display()
406        ));
407        for name in &generated {
408            output::print_kv("  wrote", name);
409        }
410    }
411
412    Ok(())
413}
414
415#[cfg(test)]
416mod tests {
417    use super::*;
418
419    #[test]
420    fn test_standard_command_default_is_run() {
421        // When command() returns None, run_app defaults to Run
422        let cmd = StandardCommand::Run;
423        assert!(matches!(cmd, StandardCommand::Run));
424    }
425}