netsuke-build 0.1.0-beta3

A YAML-powered Ninja/Jinja hybrid build system.
//! Canonicalize injected bases before relative glob compilation.
//!
//! This module owns only the filesystem-to-path conversion at the injected
//! base seam. Pattern preparation owns joining and escaping the resulting
//! path, while the walker owns opening its literal prefix.

use super::{
    GlobPattern, diagnostics,
    errors::{GlobErrorContext, GlobErrorType, create_glob_error},
    escape::escape_glob_literal_path,
};
use camino::{Utf8Path, Utf8PathBuf};
use minijinja::Error;
use std::{
    sync::{Mutex, MutexGuard},
    time::Instant,
};

/// Resolve an injected base for a relative pattern to a canonical UTF-8 path.
///
/// A workspace reached through a symbolic link must still expand relative
/// globs. `dunce` retains canonicalization while simplifying safe Windows
/// verbatim disk prefixes, which the `glob` crate deliberately does not
/// enumerate.
///
/// # Errors
///
/// Propagates canonicalization and UTF-8 conversion failures as
/// [`GlobErrorType::IoError`].
pub(super) fn resolve_relative_glob_base(
    base: &Utf8Path,
) -> std::result::Result<Utf8PathBuf, Error> {
    resolve_relative_glob_base_for_template(base).map_err(super::GlobExpansionFailure::into_error)
}

/// Resolve an injected base while retaining a bounded preparation failure.
fn resolve_relative_glob_base_for_template(
    base: &Utf8Path,
) -> std::result::Result<Utf8PathBuf, super::GlobExpansionFailure> {
    let canonical = dunce::canonicalize(base.as_std_path()).map_err(|error| {
        super::GlobExpansionFailure::BaseCanonicalization(create_base_error(
            base,
            error.to_string(),
        ))
    })?;
    Utf8PathBuf::from_path_buf(canonical).map_err(|path| {
        super::GlobExpansionFailure::Utf8Conversion(create_base_error(
            base,
            format!("canonical base path is not valid UTF-8: {}", path.display()),
        ))
    })
}

/// Build a glob I/O error describing an unusable injected base.
fn create_base_error(base: &Utf8Path, detail: String) -> Error {
    create_glob_error(
        &GlobErrorContext {
            pattern: base.to_string(),
            error_char: char::from(0),
            position: 0,
            error_type: GlobErrorType::IoError,
        },
        Some(detail),
    )
}

/// Cache a manifest parse's injected glob base after its first relative use.
///
/// This type belongs only to the manifest parse boundary. Direct
/// [`super::glob_paths`] callers continue to provide their optional base per
/// query; Jinja's closure owns one cache so multiple relative `glob()` calls
/// do not repeat filesystem canonicalization.
pub(in crate::manifest) struct GlobBaseCache {
    /// Base supplied by the manifest workspace, before filesystem preparation.
    base: Option<Utf8PathBuf>,
    /// Successfully canonicalized base retained for the rest of the parse.
    resolved: Mutex<Option<Utf8PathBuf>>,
}

impl GlobBaseCache {
    /// Create an empty cache around an optional manifest workspace base.
    pub(in crate::manifest) const fn new(base: Option<Utf8PathBuf>) -> Self {
        Self {
            base,
            resolved: Mutex::new(None),
        }
    }

    /// Classify a pattern and parse context for template expansion telemetry.
    ///
    /// The returned values are the closed `base_mode` label set used only by
    /// manifest-template expansion diagnostics. Absolute patterns bypass the
    /// configured base and therefore remain distinct from relative patterns.
    pub(super) fn mode(&self, pattern: &Utf8Path) -> &'static str {
        if pattern.is_absolute() {
            "absolute_pattern"
        } else if self.base.is_some() {
            "relative_with_base"
        } else {
            "relative_without_base"
        }
    }

    /// Resolve and retain the configured base when one is available.
    ///
    /// # Errors
    ///
    /// Returns the canonicalization error from the injected base on its first
    /// relative use.
    fn resolve(&self) -> std::result::Result<Option<Utf8PathBuf>, super::GlobExpansionFailure> {
        let _span = tracing::debug_span!("manifest.glob_base", operation = "resolve").entered();
        let Some(base) = self.base.as_deref() else {
            diagnostics::record_base_cache_bypass();
            return Ok(None);
        };
        let cached = self
            .lock_resolved(base)
            .map_err(super::GlobExpansionFailure::BaseCanonicalization)?
            .clone();
        if cached.is_some() {
            diagnostics::record_base_cache_hit();
            return Ok(cached);
        }

        let started = Instant::now();
        let canonical = match resolve_relative_glob_base_for_template(base) {
            Ok(canonical) => {
                diagnostics::record_base_cache_miss(started.elapsed());
                canonical
            }
            Err(error) => {
                diagnostics::record_base_cache_error(started.elapsed());
                return Err(error);
            }
        };
        let mut resolved = self
            .lock_resolved(base)
            .map_err(super::GlobExpansionFailure::BaseCanonicalization)?;
        if let Some(published) = resolved.as_ref() {
            return Ok(Some(published.clone()));
        }
        resolved.replace(canonical.clone());
        Ok(Some(canonical))
    }

    /// Lock the resolved-base cache and preserve a contextual poisoning error.
    fn lock_resolved(
        &self,
        base: &Utf8Path,
    ) -> std::result::Result<MutexGuard<'_, Option<Utf8PathBuf>>, Error> {
        self.resolved.lock().map_err(|error| {
            create_glob_error(
                &GlobErrorContext {
                    pattern: base.to_string(),
                    error_char: char::from(0),
                    position: 0,
                    error_type: GlobErrorType::IoError,
                },
                Some(format!("manifest glob-base cache lock poisoned: {error}")),
            )
        })
    }
}

/// Hold a validated glob pattern, search text, and optional rebase path.
///
/// Pattern preparation lives beside base resolution because both constructors
/// decide whether a relative pattern needs a filesystem-prepared base.
pub(super) struct PreparedGlob {
    /// Validated pattern and its normalised spelling.
    pub(super) pattern: GlobPattern,
    /// Search text handed to `glob_with`, owned only when it embeds a base.
    search: Option<String>,
    /// Canonicalized base stripped from matches, present exactly when relative.
    pub(super) strip: Option<Utf8PathBuf>,
}

impl PreparedGlob {
    /// Prepare `pattern` and any relative injected `base` for filesystem matching.
    ///
    /// Canonicalizes a relative injected base through
    /// [`resolve_relative_glob_base`] before embedding its escaped literal
    /// spelling into the search text.
    ///
    /// # Errors
    ///
    /// Returns an error when the pattern fails brace validation or when the
    /// injected base cannot be canonicalized.
    pub(super) fn new(pattern: &str, base: Option<&Utf8Path>) -> std::result::Result<Self, Error> {
        let pattern_state = GlobPattern::new(pattern)?;
        let normalized = pattern_state.normalized();
        let resolved_base = match base {
            Some(dir) if !Utf8Path::new(normalized).is_absolute() => {
                Some(resolve_relative_glob_base(dir)?)
            }
            _ => None,
        };
        Ok(Self::from_pattern_and_base(pattern_state, resolved_base))
    }

    /// Prepare `pattern` using a manifest parse's cached injected base.
    ///
    /// # Errors
    ///
    /// Returns an error when the pattern fails brace validation or its first
    /// relative use cannot canonicalize the injected base.
    #[cfg(test)]
    pub(super) fn new_with_base_cache(
        pattern: &str,
        base: &GlobBaseCache,
    ) -> std::result::Result<Self, Error> {
        Self::new_with_base_cache_for_template(pattern, base)
            .map_err(super::GlobExpansionFailure::into_error)
    }

    /// Prepare `pattern` while retaining a bounded template failure outcome.
    pub(super) fn new_with_base_cache_for_template(
        pattern: &str,
        base: &GlobBaseCache,
    ) -> std::result::Result<Self, super::GlobExpansionFailure> {
        let pattern_state =
            GlobPattern::new(pattern).map_err(super::GlobExpansionFailure::InvalidPattern)?;
        let resolved_base = (!Utf8Path::new(pattern_state.normalized()).is_absolute())
            .then(|| base.resolve())
            .transpose()?
            .flatten();
        Ok(Self::from_pattern_and_base(pattern_state, resolved_base))
    }

    /// Build glob search text from a validated pattern and resolved base.
    fn from_pattern_and_base(pattern: GlobPattern, base: Option<Utf8PathBuf>) -> Self {
        let (search, strip) = base.map_or_else(
            || (None, None),
            |dir| {
                let escaped = escape_glob_literal_path(&dir);
                let separator = std::path::MAIN_SEPARATOR;
                (
                    Some(format!("{escaped}{separator}{}", pattern.normalized())),
                    Some(dir),
                )
            },
        );
        Self {
            pattern,
            search,
            strip,
        }
    }

    /// Borrow the compiled search, reusing the normalized pattern when unbased.
    pub(super) fn search(&self) -> &str {
        self.search
            .as_deref()
            .unwrap_or_else(|| self.pattern.normalized())
    }
}