netsuke-build 0.1.0-beta3

A YAML-powered Ninja/Jinja hybrid build system.
//! Configuration-load orchestration and its elapsed-time seam.
//!
//! This module keeps the measured startup interval bounded by diagnostic-mode
//! resolution and configuration merging while allowing tests to supply a
//! deterministic elapsed time.

use clap::ArgMatches;
use monotony::MonotonicClock;
use netsuke::cli;
use std::{
    io::{self, Write},
    process::ExitCode,
    time::Duration,
};

use super::{
    DiagMode, StartupWriter, diagnostic_json, observability, set_tracing_filter,
    settle_startup_diagnostics, startup_filter,
};

/// Cached diagnostic resolution carried into the full configuration merge.
struct ResolvedDiagnosticMode {
    /// The diagnostic mode resolved during startup.
    mode: DiagMode,
    /// File layers preserved from the shared discovery pass.
    discovered_layers: cli::DiscoveredLayers,
}

/// Dependencies that define one configuration-load attempt.
///
/// This context is private to startup orchestration; it groups the parsed
/// inputs that must remain within the measured configuration-load interval.
pub(super) struct ConfigurationLoadContext<'a, E>
where
    E: cli::ConfigEnvProvider,
{
    /// CLI values parsed before configuration was discovered.
    pub(super) parsed_cli: &'a cli::Cli,
    /// Clap argument matches backing the parsed CLI values.
    pub(super) matches: &'a ArgMatches,
    /// Diagnostic mode used when startup configuration resolution fails.
    pub(super) startup_mode: DiagMode,
    /// Writes startup diagnostics before tracing is configured.
    pub(super) startup_writer: &'a StartupWriter,
    /// Environment provider consulted during configuration discovery and merge.
    pub(super) config_env: &'a E,
}

/// Resolve diagnostic mode and merge configuration while recording one metric.
///
/// The measured interval starts immediately before diagnostic-mode resolution
/// and ends after either that resolution or the full configuration merge.
///
/// # Errors
///
/// Returns a failure [`ExitCode`] when diagnostic-mode resolution or the
/// subsequent configuration merge fails.
pub(super) fn resolve_configuration<E>(
    context: &ConfigurationLoadContext<'_, E>,
    clock: &impl MonotonicClock,
) -> Result<cli::Cli, std::process::ExitCode>
where
    E: cli::ConfigEnvProvider,
{
    let started_at = clock.now();
    let resolution = match resolve_json_mode_or_exit(context, clock) {
        Ok(mode) => mode,
        Err(code) => {
            record_config_load_metrics(clock.now().duration_since(started_at), false);
            settle_startup_diagnostics(context.startup_writer, context.startup_mode);
            return Err(code);
        }
    };
    // The effective mode is known here, before configuration is merged, so the
    // startup warning reaches the user ahead of any configuration processing.
    settle_startup_diagnostics(context.startup_writer, resolution.mode);
    let merged_cli = match merge_cli_or_exit(context, resolution, clock) {
        Ok(merged) => merged,
        Err(code) => {
            record_config_load_metrics(clock.now().duration_since(started_at), false);
            return Err(code);
        }
    };
    record_config_load_metrics(clock.now().duration_since(started_at), true);
    Ok(merged_cli)
}

/// Report a configuration error and return its failure [`ExitCode`].
///
/// JSON mode emits a valid, stable diagnostic document with its serialization
/// fallback. Human mode logs only bounded `operation` and `error_category`
/// fields structurally before writing the user-facing error to stderr.
pub(super) fn config_err_to_exit(
    err: &(dyn std::error::Error + 'static),
    mode: DiagMode,
    operation: &'static str,
) -> ExitCode {
    if mode.is_json() {
        diagnostic_json::emit_or_fallback(diagnostic_json::render_error_json(err))
    } else {
        tracing::error!(
            operation,
            error_category = observability::classify_error(err),
            "configuration load failed"
        );
        drop(writeln!(io::stderr(), "{err}"));
        ExitCode::FAILURE
    }
}

/// Resolve JSON mode and cache discovered configuration layers for the merge.
///
/// The diagnostic-mode phase is timed with the injected [`MonotonicClock`].
/// Resolution failures select the fallback mode's filter and return a failure
/// [`ExitCode`] through [`config_err_to_exit`].
///
/// # Errors
///
/// Returns a failure [`ExitCode`] when config discovery or JSON preference
/// resolution fails.
fn resolve_json_mode_or_exit<E>(
    context: &ConfigurationLoadContext<'_, E>,
    clock: &impl MonotonicClock,
) -> Result<ResolvedDiagnosticMode, ExitCode>
where
    E: cli::ConfigEnvProvider,
{
    let discovery_started = clock.now();
    match observability::record_config_load(observability::ConfigLoadPhase::DiagMode, clock, || {
        let (result, outcome) = cli::resolve_json_and_layers_outcome_with_env(
            context.parsed_cli,
            context.matches,
            context.config_env,
        );
        match result {
            Ok(is_json_enabled) => Ok((is_json_enabled, outcome)),
            Err(error) => Err(Box::new((error, outcome))),
        }
    }) {
        Ok((is_json_enabled, outcome)) => {
            cli::record_discovery_outcome(clock, discovery_started, &outcome);
            let mode = DiagMode::from_json_enabled(is_json_enabled);
            set_tracing_filter(startup_filter(mode, context.parsed_cli.verbose));
            outcome.emit_diagnostics();
            Ok(ResolvedDiagnosticMode {
                mode,
                discovered_layers: outcome.into_layers(),
            })
        }
        Err(error_and_outcome) => {
            let (err, outcome) = *error_and_outcome;
            cli::record_discovery_outcome(clock, discovery_started, &outcome);
            let fallback_filter = startup_filter(context.startup_mode, context.parsed_cli.verbose);
            set_tracing_filter(fallback_filter);
            outcome.emit_diagnostics();
            Err(config_err_to_exit(
                err.as_ref(),
                context.startup_mode,
                observability::DIAG_MODE_OPERATION,
            ))
        }
    }
}

/// Merge CLI values with the layers cached during diagnostic-mode resolution.
///
/// The merge is timed with the injected [`MonotonicClock`], applies the default
/// command, and maps configuration failures to a failure [`ExitCode`].
///
/// # Errors
///
/// Returns a failure [`ExitCode`] when merging the discovered layers with the
/// CLI values fails.
fn merge_cli_or_exit<E>(
    context: &ConfigurationLoadContext<'_, E>,
    resolution: ResolvedDiagnosticMode,
    clock: &impl MonotonicClock,
) -> Result<cli::Cli, ExitCode>
where
    E: cli::ConfigEnvProvider,
{
    observability::record_config_load(observability::ConfigLoadPhase::Merge, clock, || {
        let input = cli::CachedMergeInput::new(
            context.parsed_cli,
            context.matches,
            context.config_env,
            resolution.discovered_layers,
        );
        let (merged, events) = cli::merge_with_cached_file_layers_with_observer(input);
        let mut observer = cli::TracingMergeObserver;
        for event in events {
            cli::MergeObserver::observe(&mut observer, event);
        }
        merged
    })
    .map(cli::Cli::with_default_command)
    .map_err(|err| {
        config_err_to_exit(
            err.as_ref(),
            resolution.mode,
            observability::MERGE_OPERATION,
        )
    })
}

/// Emit the configuration-load metrics for one startup attempt.
///
/// Recording goes through the `metrics` façade, backed by the application's
/// in-process `DebuggingRecorder`.
fn record_config_load_metrics(elapsed: Duration, succeeded: bool) {
    let outcome = if succeeded { "success" } else { "failure" };
    metrics::histogram!(observability::STARTUP_CONFIG_LOAD_DURATION).record(elapsed.as_secs_f64());
    metrics::counter!(observability::STARTUP_CONFIG_LOAD_COUNTER, "outcome" => outcome)
        .increment(1);
}

#[cfg(test)]
#[path = "config_load_metrics_tests.rs"]
mod config_load_metrics_tests;