netsuke-build 0.1.0-beta1

A YAML-powered Ninja/Jinja hybrid build system.
//! Manifest loading helpers.
//!
//! This module parses a `Netsukefile` without relying on a global Jinja
//! preprocessing pass. The YAML is parsed first and Jinja expressions are
//! evaluated only within string values or the `foreach` and `when` keys. It
//! exposes `env()` to read environment variables and `glob()` to expand
//! filesystem patterns during template evaluation. Both helpers fail fast when
//! inputs are missing or patterns are invalid.
//!
//! Consumers interact with the intermediate manifest through the re-exported
//! [`ManifestValue`] and [`ManifestMap`] aliases. Diagnostics wrap manifest
//! identifiers in [`ManifestName`] and YAML source strings in
//! [`ManifestSource`] so callers pass domain-specific types instead of raw
//! strings.
//!
//! The optional `vars` section must deserialize into a JSON object with string
//! keys. YAML manifests that use non-string keys (for example integers) now
//! fail with a [`ManifestError::Parse`] diagnostic, matching the Jinja context
//! semantics and preventing ambiguous variable lookup.

use crate::{
    ast::NetsukeManifest,
    localization::{self, keys},
    stdlib::{NetworkPolicy, StdlibConfig},
};
use anyhow::{Context, Result};
use minijinja::{Environment, UndefinedBehavior, value::Value};
use serde::de::Error as _;
use std::{path::Path, sync::Arc};

mod diagnostics;
mod expand;
mod glob;
mod hints;
mod jinja_macros;
mod render;

/// JSON representation of a manifest node after YAML and Jinja evaluation.
pub type ManifestValue = serde_json::Value;
/// JSON object mapping string keys to manifest values.
pub type ManifestMap = serde_json::Map<String, ManifestValue>;

pub use diagnostics::{
    ManifestError, ManifestName, ManifestSource, map_data_error, map_yaml_error,
};
pub use env_reader::{EnvReadError, EnvReader, process_env_reader};
pub use glob::glob_paths;

pub(crate) use expand::expand_foreach;
pub use render::render_manifest;

use self::{env_reader::env_var_with, jinja_macros::register_manifest_macros};
use workspace::open_manifest_workspace;

/// Stages in the manifest-loading sub-pipeline.
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum ManifestLoadStage {
    /// Read raw manifest content from the filesystem.
    ManifestIngestion,
    /// Parse raw YAML into a `serde_json::Value` tree.
    InitialYamlParsing,
    /// Expand `foreach` and `when` template directives.
    TemplateExpansion,
    /// Deserialize and render string fields into typed manifest data.
    FinalRendering,
}

/// Invoke the stage callback when present.
fn notify_stage(
    on_stage: &mut Option<&mut dyn FnMut(ManifestLoadStage)>,
    stage: ManifestLoadStage,
) {
    if let Some(cb) = on_stage.as_mut() {
        cb(stage);
    }
}

/// Parse a manifest string using Jinja for value templating.
///
/// The input YAML must be valid on its own. Jinja expressions are evaluated
/// only inside recognised string fields and the `foreach` and `when` keys.
///
/// # Errors
///
/// Returns an error if YAML parsing or Jinja evaluation fails.
/// Inputs to a manifest parse, bundled to keep the parameter list bounded.
struct ManifestParse<'a> {
    /// Name reported in diagnostics.
    name: &'a ManifestName,
    /// Optional stdlib configuration override.
    stdlib_config: Option<StdlibConfig>,
    /// Environment reader backing the `env()` helper.
    env_reader: &'a EnvReader,
}

fn from_str_named(
    yaml: &str,
    parse: ManifestParse<'_>,
    on_stage: &mut Option<&mut dyn FnMut(ManifestLoadStage)>,
) -> Result<NetsukeManifest> {
    let ManifestParse {
        name,
        stdlib_config,
        env_reader,
    } = parse;
    notify_stage(on_stage, ManifestLoadStage::InitialYamlParsing);
    let mut doc: ManifestValue =
        serde_saphyr::from_str(yaml).map_err(|e| ManifestError::Parse {
            source: map_yaml_error(e, &ManifestSource::from(yaml), name),
            message: localization::message(keys::MANIFEST_PARSE),
        })?;

    let mut jinja = Environment::new();
    jinja.set_undefined_behavior(UndefinedBehavior::Strict);
    // Expose custom helpers to templates.
    let reader = Arc::clone(env_reader);
    jinja.add_function("env", move |var_name: String| {
        env_var_with(&var_name, |key| reader(key))
    });
    jinja.add_function("glob", |pattern: String| glob_paths(&pattern));
    let _stdlib_state = match stdlib_config {
        Some(config) => crate::stdlib::register_with_config(&mut jinja, config),
        None => crate::stdlib::register(&mut jinja),
    }?;

    if let Some(vars_value) = doc.get("vars") {
        let vars = vars_value
            .as_object()
            .cloned()
            .ok_or_else(|| ManifestError::Parse {
                source: map_data_error(
                    serde_json::Error::custom(
                        localization::message(keys::MANIFEST_VARS_NOT_OBJECT).to_string(),
                    ),
                    name,
                ),
                message: localization::message(keys::MANIFEST_PARSE),
            })?;
        for (key, value) in vars {
            jinja.add_global(key, Value::from_serialize(value));
        }
    }

    notify_stage(on_stage, ManifestLoadStage::TemplateExpansion);
    register_manifest_macros(&doc, &mut jinja)?;

    expand_foreach(&mut doc, &jinja)?;

    notify_stage(on_stage, ManifestLoadStage::FinalRendering);
    let manifest: NetsukeManifest =
        serde_json::from_value(doc).map_err(|e| ManifestError::Parse {
            source: map_data_error(e, name),
            message: localization::message(keys::MANIFEST_PARSE),
        })?;

    render_manifest(manifest, &jinja)
}

/// Parse a manifest string using Jinja for value templating.
///
/// The input YAML must be valid on its own. Jinja expressions are evaluated
/// only inside recognised string fields and the `foreach` and `when` keys.
///
/// # Errors
///
/// Returns an error if YAML parsing or Jinja evaluation fails.
pub fn from_str(yaml: &str) -> Result<NetsukeManifest> {
    from_str_with_env(yaml, &process_env_reader())
}

/// Parse a manifest string with an explicit environment reader.
///
/// Lets a caller — in practice a test — drive the `env()` helper without
/// touching the process environment.
///
/// # Errors
///
/// Returns an error if YAML parsing or Jinja evaluation fails.
///
/// # Examples
///
/// ```
/// use netsuke::{
///     ast::Recipe,
///     manifest::{EnvReadError, EnvReader, from_str_with_env},
/// };
/// use std::sync::Arc;
///
/// let reader: EnvReader = Arc::new(|name| match name {
///     "PROFILE" => Ok("release".to_owned()),
///     _ => Err(EnvReadError::NotPresent),
/// });
/// let yaml = concat!(
///     "netsuke_version: 1.0.0\n",
///     "targets:\n",
///     "  - name: build\n",
///     "    command: echo {{ env('PROFILE') }}\n",
/// );
/// let manifest = from_str_with_env(yaml, &reader).expect("parse manifest");
///
/// assert!(matches!(
///     &manifest.targets[0].recipe,
///     Recipe::Command { command } if command == "echo release"
/// ));
/// ```
pub fn from_str_with_env(yaml: &str, env_reader: &EnvReader) -> Result<NetsukeManifest> {
    from_str_named(
        yaml,
        ManifestParse {
            name: &ManifestName::new("Netsukefile"),
            stdlib_config: None,
            env_reader,
        },
        &mut None,
    )
}

/// Load a [`NetsukeManifest`] from the given file path.
///
/// # Errors
///
/// Returns an error if the file cannot be read or the YAML fails to parse.
pub fn from_path(path: impl AsRef<Path>) -> Result<NetsukeManifest> {
    from_path_with_policy(path, NetworkPolicy::default(), None)
}

/// Load a [`NetsukeManifest`] from the given file path using an explicit
/// network policy and an optional stage callback.
///
/// The callback, when provided, is invoked in order for each manifest stage.
///
/// # Errors
///
/// Returns an error if the file cannot be read or the YAML fails to parse.
///
/// # Examples
///
/// ```rust,ignore
/// use netsuke::manifest;
/// use netsuke::stdlib::NetworkPolicy;
///
/// let policy = NetworkPolicy::default();
/// let manifest = manifest::from_path_with_policy("Netsukefile", policy, None);
/// assert!(manifest.is_ok());
/// ```
pub fn from_path_with_policy(
    path: impl AsRef<Path>,
    policy: NetworkPolicy,
    on_stage: Option<&mut dyn FnMut(ManifestLoadStage)>,
) -> Result<NetsukeManifest> {
    from_path_with_policy_and_env(path, policy, &process_env_reader(), on_stage)
}

/// Load a manifest with explicit network policy and environment reader.
///
/// This adapter boundary lets callers supply deterministic manifest variables
/// without mutating the process environment.
///
/// # Errors
///
/// Returns an error if the manifest cannot be read, rendered, or parsed.
///
/// # Examples
///
/// ```
/// use netsuke::{
///     ast::Recipe,
///     manifest::{EnvReadError, EnvReader, from_path_with_policy_and_env},
///     stdlib::NetworkPolicy,
/// };
/// use std::{io::Write, sync::Arc};
///
/// let mut file = tempfile::NamedTempFile::new().expect("create manifest");
/// write!(
///     file,
///     "netsuke_version: 1.0.0\ntargets:\n  - name: build\n    command: echo {{{{ env('PROFILE') }}}}\n"
/// )
/// .expect("write manifest");
/// let reader: EnvReader = Arc::new(|name| match name {
///     "PROFILE" => Ok("offline".to_owned()),
///     _ => Err(EnvReadError::NotPresent),
/// });
/// let policy = NetworkPolicy::default().deny_all_hosts();
/// let manifest = from_path_with_policy_and_env(file.path(), policy, &reader, None)
///     .expect("load manifest without network access");
///
/// assert!(matches!(
///     &manifest.targets[0].recipe,
///     Recipe::Command { command } if command == "echo offline"
/// ));
/// ```
pub fn from_path_with_policy_and_env(
    path: impl AsRef<Path>,
    policy: NetworkPolicy,
    env_reader: &EnvReader,
    mut on_stage: Option<&mut dyn FnMut(ManifestLoadStage)>,
) -> Result<NetsukeManifest> {
    notify_stage(&mut on_stage, ManifestLoadStage::ManifestIngestion);
    let path_ref = path.as_ref();
    let workspace = open_manifest_workspace(path_ref)?;
    let data = workspace
        .dir
        .read_to_string(&workspace.manifest_file)
        .with_context(|| {
            localization::message(keys::MANIFEST_READ_FAILED)
                .with_arg("path", path_ref.display().to_string())
        })?;
    let name = ManifestName::new(path_ref.display().to_string());
    let config = StdlibConfig::new(workspace.dir)?
        .with_workspace_root_path(workspace.root)?
        .with_network_policy(policy);
    from_str_named(
        &data,
        ManifestParse {
            name: &name,
            stdlib_config: Some(config),
            env_reader,
        },
        &mut on_stage,
    )
}

mod env_reader;
mod workspace;

#[cfg(test)]
mod tests;