netsuke-build 0.1.0-beta3

A YAML-powered Ninja/Jinja hybrid build system.
//! Layered CLI configuration schema.
//!
//! [`CliConfig`] is the single typed schema used for configuration discovery
//! and merging. It captures global CLI settings plus per-subcommand defaults
//! under the `cmds` namespace.
use camino::Utf8PathBuf;
use ortho_config::{OrthoConfig, OrthoResult, PostMergeContext, PostMergeHook};
use serde::{Deserialize, Serialize};
use std::fmt;
use std::str::FromStr;

use super::validation::validation_error;
use crate::host_pattern::HostPattern;

#[path = "policy_definitions.rs"]
pub(super) mod policy_definitions;

pub(super) use policy_definitions::{
    ACCESSIBILITY_POLICY_DEFINITIONS, COLOUR_POLICY_DEFINITIONS, EMOJI_POLICY_DEFINITIONS,
    PROGRESS_POLICY_DEFINITIONS,
};
use policy_definitions::{definition_for, parse_policy};

/// Required non-interactive execution setting.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct NoInput(bool);

impl NoInput {
    /// Return whether interactive input is disabled.
    #[must_use]
    pub const fn is_enabled(self) -> bool {
        self.0
    }
}

impl Default for NoInput {
    fn default() -> Self {
        Self(true)
    }
}

/// Colour-output policy accepted by layered configuration.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "kebab-case")]
pub enum ColourPolicy {
    /// Follow the host environment.
    #[default]
    Auto,
    /// Force colour output on when available.
    Always,
    /// Force colour output off.
    Never,
}

impl fmt::Display for ColourPolicy {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        definition_for(*self, &COLOUR_POLICY_DEFINITIONS).map_or(Err(fmt::Error), |definition| {
            f.write_str(definition.spelling)
        })
    }
}

impl FromStr for ColourPolicy {
    type Err = String;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        parse_policy(s, &COLOUR_POLICY_DEFINITIONS)
            .ok_or_else(|| format!("invalid color policy '{s}'"))
    }
}

/// Progress rendering policy.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "kebab-case")]
pub enum ProgressPolicy {
    /// Follow Netsuke's default progress behaviour.
    #[default]
    Auto,
    /// Force progress rendering on.
    Always,
    /// Disable progress rendering.
    Never,
}

impl fmt::Display for ProgressPolicy {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        definition_for(*self, &PROGRESS_POLICY_DEFINITIONS).map_or(Err(fmt::Error), |definition| {
            f.write_str(definition.spelling)
        })
    }
}

impl FromStr for ProgressPolicy {
    type Err = String;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        parse_policy(s, &PROGRESS_POLICY_DEFINITIONS)
            .ok_or_else(|| format!("invalid progress policy '{s}'"))
    }
}

/// Emoji rendering policy.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "kebab-case")]
pub enum EmojiPolicy {
    /// Follow the host environment and accessibility mode.
    #[default]
    Auto,
    /// Force emoji glyphs on.
    Always,
    /// Disable emoji glyphs.
    Never,
}

impl fmt::Display for EmojiPolicy {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        definition_for(*self, &EMOJI_POLICY_DEFINITIONS).map_or(Err(fmt::Error), |definition| {
            f.write_str(definition.spelling)
        })
    }
}

impl FromStr for EmojiPolicy {
    type Err = String;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        parse_policy(s, &EMOJI_POLICY_DEFINITIONS)
            .ok_or_else(|| format!("invalid emoji policy '{s}'"))
    }
}

/// Accessible-output policy.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "kebab-case")]
pub enum AccessibilityPolicy {
    /// Follow terminal and environment detection.
    #[default]
    Auto,
    /// Force accessible output on.
    On,
    /// Force accessible output off.
    Off,
}

impl fmt::Display for AccessibilityPolicy {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        definition_for(*self, &ACCESSIBILITY_POLICY_DEFINITIONS)
            .map_or(Err(fmt::Error), |definition| {
                f.write_str(definition.spelling)
            })
    }
}

impl FromStr for AccessibilityPolicy {
    type Err = String;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        parse_policy(s, &ACCESSIBILITY_POLICY_DEFINITIONS)
            .ok_or_else(|| format!("invalid accessibility policy '{s}'"))
    }
}

/// Layered defaults for the `build` subcommand.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct BuildConfig {
    /// Default targets used when the user does not pass any targets.
    #[serde(default)]
    pub targets: Vec<String>,
}

/// Subcommand-specific layered defaults.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct CommandConfigs {
    /// Configuration that applies only to the `build` subcommand.
    #[serde(default)]
    pub build: BuildConfig,
}

/// Authoritative schema for layered CLI configuration.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, OrthoConfig)]
#[ortho_config(prefix = "NETSUKE", post_merge_hook)]
pub struct CliConfig {
    /// Path to the Netsuke manifest file to use.
    #[ortho_config(default = default_manifest_path())]
    pub file: Utf8PathBuf,

    /// Set the number of parallel build jobs.
    pub jobs: Option<usize>,

    /// Enable verbose diagnostic logging and completion timing summaries.
    #[ortho_config(default = false)]
    pub verbose: bool,

    /// Locale tag for CLI copy (for example: en-US, es-ES).
    pub locale: Option<String>,

    /// Additional URL schemes allowed for the `fetch` helper.
    #[ortho_config(merge_strategy = "append")]
    #[serde(default)]
    pub fetch_allow_scheme: Vec<String>,

    /// Hostnames permitted when default deny is enabled.
    #[ortho_config(merge_strategy = "append")]
    #[serde(default)]
    pub fetch_allow_host: Vec<HostPattern>,

    /// Hostnames that are always blocked.
    #[ortho_config(merge_strategy = "append")]
    #[serde(default)]
    pub fetch_block_host: Vec<HostPattern>,

    /// Deny all hosts by default; only allow the declared allowlist.
    #[ortho_config(default = false)]
    pub fetch_default_deny: bool,

    /// Emit machine-readable JSON output.
    #[ortho_config(default = false)]
    pub json: bool,

    /// Never read interactive input.
    #[ortho_config(skip_cli)]
    pub no_input: NoInput,

    /// Preferred colour policy.
    #[ortho_config(skip_cli)]
    pub color: ColourPolicy,

    /// Preferred emoji policy.
    #[ortho_config(skip_cli)]
    pub emoji: EmojiPolicy,

    /// Preferred progress policy.
    #[ortho_config(skip_cli)]
    pub progress: ProgressPolicy,

    /// Preferred accessibility policy.
    #[ortho_config(skip_cli)]
    pub accessibility: AccessibilityPolicy,

    /// Compatibility alias for default build targets at the config root.
    #[ortho_config(merge_strategy = "append")]
    #[serde(default)]
    pub default_targets: Vec<String>,

    /// Per-subcommand defaults.
    #[ortho_config(skip_cli)]
    #[serde(default)]
    pub cmds: CommandConfigs,
}

impl Default for CliConfig {
    fn default() -> Self {
        Self {
            file: Self::default_manifest_path(),
            jobs: None,
            verbose: false,
            locale: None,
            fetch_allow_scheme: Vec::new(),
            fetch_allow_host: Vec::new(),
            fetch_block_host: Vec::new(),
            fetch_default_deny: false,
            json: false,
            no_input: NoInput::default(),
            color: ColourPolicy::Auto,
            emoji: EmojiPolicy::Auto,
            progress: ProgressPolicy::Auto,
            accessibility: AccessibilityPolicy::Auto,
            default_targets: Vec::new(),
            cmds: CommandConfigs::default(),
        }
    }
}

impl CliConfig {
    /// Return the default manifest file path used when no file is supplied.
    pub(super) fn default_manifest_path() -> Utf8PathBuf {
        default_manifest_path()
    }
}

/// Maximum number of parallel build jobs accepted by the CLI.
const MAX_JOBS: usize = super::validation::MAX_JOBS;

/// Fixed reason reported when merged configuration enables interactive input.
pub(crate) const NO_INPUT_VALIDATION_REASON: &str =
    "no_input = false is unsupported because Netsuke has no interactive mode";
/// Return whether `jobs` falls outside the accepted range.
const fn jobs_out_of_bounds(jobs: usize) -> bool {
    jobs == 0 || jobs > MAX_JOBS
}

impl PostMergeHook for CliConfig {
    fn post_merge(&mut self, _ctx: &PostMergeContext) -> OrthoResult<()> {
        validate_manifest_path(self)?;
        validate_non_interactive(self)?;
        validate_jobs(self)?;
        Ok(())
    }
}

/// Return the default manifest file path, `Netsukefile` in the working directory.
fn default_manifest_path() -> Utf8PathBuf {
    Utf8PathBuf::from("Netsukefile")
}

/// Verify that the merged manifest path remains valid UTF-8.
///
/// The `Utf8PathBuf` field makes this invariant structural for file and
/// environment layers. The final validation keeps the post-merge seam
/// explicit, so any future untyped source is rejected at configuration
/// composition rather than later during runner setup.
///
/// # Errors
///
/// Returns a validation error when the merged manifest path is not UTF-8.
fn validate_manifest_path(config: &CliConfig) -> OrthoResult<()> {
    if config.file.as_std_path().to_str().is_some() {
        Ok(())
    } else {
        Err(validation_error("file", "manifest path is not valid UTF-8"))
    }
}

/// Validate that non-interactive mode stays enabled after merging.
///
/// Netsuke has no interactive mode, so a merged `no_input = false` is
/// unsupported and rejected by the post-merge hook.
///
/// # Errors
///
/// Returns a validation error when `no_input` resolves to false.
fn validate_non_interactive(config: &CliConfig) -> OrthoResult<()> {
    if config.no_input.is_enabled() {
        Ok(())
    } else {
        Err(validation_error("no_input", NO_INPUT_VALIDATION_REASON))
    }
}

/// Validate that the merged job count falls within the supported range.
///
/// # Errors
///
/// Returns a validation error when `jobs` is zero or greater than `MAX_JOBS`.
fn validate_jobs(config: &CliConfig) -> OrthoResult<()> {
    let Some(jobs) = config.jobs else {
        return Ok(());
    };
    if jobs_out_of_bounds(jobs) {
        return Err(validation_error(
            "jobs",
            &format!("jobs = {jobs} is out of range; must be between 1 and {MAX_JOBS}"),
        ));
    }
    Ok(())
}

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