netsuke-build 0.1.0-beta3

A YAML-powered Ninja/Jinja hybrid build system.
//! Unit snapshot tests for the `netsuke help targets` renderer.
//!
//! The fixture manifest mirrors the issue's suggested shape: actions and
//! targets with descriptions, manifest defaults, and one entry whose
//! description is missing so the empty-column representation is pinned.

use super::*;
use crate::ast::{NetsukeManifest, Target};
use crate::cli_localization::build_localizer;
use crate::localization::set_localizer_for_tests;
use crate::manifest;
use crate::snapshot_test_support::{
    help_targets_json_snapshot_settings, snapshot_settings, theme_prefs,
};
use crate::theme::ThemePreference;
use anyhow::{Context, Result};
use insta::{Settings, assert_snapshot};
use proptest::prelude::*;
use semver::Version;
use std::sync::{Arc, Mutex, TryLockError, mpsc};
use std::thread;
use std::time::{Duration, Instant};
use test_support::fluent::normalize_fluent_isolates;
use test_support::{localizer::LOCALIZER_TEST_LOCK, localizer_test_lock};

/// Parse the fixed fixture manifest used by the catalogue snapshots.
fn fixture_manifest() -> Result<NetsukeManifest> {
    let yaml = r#"netsuke_version: "1.0.0"
actions:
  - name: lint
    description: Run rustdoc, Clippy, and Whitaker
    command: cargo clippy --all-targets --all-features -- -D warnings
  - name: test
    description: Run unit, behavioural, UI, and documentation tests
    command: cargo test
  - name: undocumented
    command: echo hi
targets:
  - name: target/release/catnap
    description: Build the optimized release binary
    command: cargo build --release
  - name: plain
    command: echo plain
defaults:
  - lint
  - test
"#;
    let mut manifest = manifest::from_str(yaml)?;
    let conditional_action = manifest
        .actions
        .get_mut(1)
        .context("help target fixture should contain a test action")?;
    conditional_action.conditional = true;
    Ok(manifest)
}

/// Acquire the localizer test lock, recovering from poisoning the way the
/// test-support fixtures do, so one failing snapshot cannot cascade into the
/// tests that follow it.
fn localizer_lock() -> std::sync::MutexGuard<'static, ()> {
    localizer_test_lock().unwrap_or_else(std::sync::PoisonError::into_inner)
}

/// Probe the localizer lock until the contention assertion can report a result.
fn localizer_lock_is_available_before_timeout() -> bool {
    let deadline = Instant::now() + Duration::from_secs(5);
    while Instant::now() < deadline {
        if localizer_lock_is_available() {
            return true;
        }
        thread::sleep(Duration::from_millis(10));
    }
    localizer_lock_is_available()
}

/// Return whether the localizer test lock can be acquired without blocking.
fn localizer_lock_is_available() -> bool {
    let lock = LOCALIZER_TEST_LOCK.get_or_init(|| Mutex::new(()));
    match lock.try_lock() {
        Ok(guard) => {
            drop(guard);
            true
        }
        Err(TryLockError::Poisoned(error)) => {
            drop(error.into_inner());
            true
        }
        Err(TryLockError::WouldBlock) => false,
    }
}

/// Send the bounded contention result without waiting for the test receiver.
fn send_localizer_lock_result(sender: &mpsc::SyncSender<bool>) -> Result<()> {
    sender
        .try_send(localizer_lock_is_available_before_timeout())
        .map_err(|error| {
            anyhow::anyhow!("localizer contender could not report its result: {error}")
        })
}

/// Run one catalogue snapshot: install the locale, render through the closure,
/// and bind the snapshot assertion.
///
/// The assertion name is passed at runtime, so all four snapshot tests share
/// this setup while keeping their distinct snapshot files.
fn catalogue_snapshot(
    locale: &str,
    snapshot_name: &str,
    settings: &Settings,
    render: impl FnOnce(&NetsukeManifest) -> Result<String>,
) -> Result<()> {
    let manifest = fixture_manifest()?;
    let rendered = render_catalogue_with_locale(locale, &manifest, render)?;
    settings.bind(|| {
        assert_snapshot!(snapshot_name, rendered);
    });
    Ok(())
}

/// Render and assert a text catalogue snapshot with a selected theme.
fn text_catalogue_snapshot_with_theme(
    locale: &str,
    snapshot_name: &str,
    theme: ThemePreference,
) -> Result<()> {
    catalogue_snapshot(
        locale,
        snapshot_name,
        &snapshot_settings("help_targets"),
        |manifest| {
            Ok(normalize_fluent_isolates(&render_text(
                &build_catalogue(manifest),
                theme_prefs(theme),
            )))
        },
    )
}

/// Render a catalogue while holding the localizer lock only for its global
/// localization dependency.
fn render_catalogue_with_locale(
    locale: &str,
    manifest: &NetsukeManifest,
    render: impl FnOnce(&NetsukeManifest) -> Result<String>,
) -> Result<String> {
    let _lock = localizer_lock();
    let _guard = set_localizer_for_tests(Arc::from(build_localizer(Some(locale))));
    render(manifest)
}

#[test]
fn catalogue_rendering_releases_localizer_lock_before_snapshot_work() -> Result<()> {
    let manifest = fixture_manifest()?;
    let rendered = render_catalogue_with_locale("en-US", &manifest, |parsed_manifest| {
        render_json(&build_catalogue(parsed_manifest))
    })?;
    let (acquired, confirmed) = mpsc::sync_channel(1);
    thread::scope(|scope| -> Result<()> {
        let contender = scope.spawn(|| send_localizer_lock_result(&acquired));
        let contender_acquired = confirmed
            .recv_timeout(Duration::from_secs(5))
            .context("localizer contender should acquire the lock before snapshot work")?;
        contender
            .join()
            .map_err(|_| anyhow::anyhow!("localizer contender should complete"))??;
        anyhow::ensure!(
            contender_acquired,
            "localizer contender should acquire the lock before snapshot work"
        );
        Ok(())
    })?;
    anyhow::ensure!(
        rendered.contains("\"command\": \"help-targets\""),
        "rendered catalogue should remain available after localizer contention"
    );
    Ok(())
}

/// Snapshot the English Unicode help-target catalogue.
#[test]
fn text_catalogue_snapshot() -> Result<()> {
    text_catalogue_snapshot_with_theme("en-US", "text_catalogue", ThemePreference::Unicode)
}

/// Snapshot the English ASCII help-target catalogue.
#[test]
fn accessible_catalogue_snapshot() -> Result<()> {
    text_catalogue_snapshot_with_theme("en-US", "accessible_catalogue", ThemePreference::Ascii)
}

/// Snapshot the Spanish Unicode help-target catalogue.
#[test]
fn localized_catalogue_snapshot() -> Result<()> {
    text_catalogue_snapshot_with_theme(
        "es-ES",
        "localized_catalogue_es_es",
        ThemePreference::Unicode,
    )
}

/// Snapshot the JSON help-target catalogue.
#[test]
fn json_catalogue_snapshot() -> Result<()> {
    catalogue_snapshot(
        "en-US",
        "json_catalogue",
        &help_targets_json_snapshot_settings(),
        |manifest| render_json(&build_catalogue(manifest)),
    )
}

#[test]
fn text_catalogue_escapes_terminal_control_characters() -> Result<()> {
    let mut manifest = fixture_manifest()?;
    let action = manifest
        .actions
        .first_mut()
        .context("help target fixture should contain an action")?;
    action.name = crate::ast::StringOrList::String(
        "line\nnext\t\u{001B}[31mred\u{009B}m\u{202E}reordered".to_owned(),
    );
    action.description = Some("description\r\nwith\tcontrols\u{0007}\u{202E}".to_owned());

    let output = render_text(
        &build_catalogue(&manifest),
        theme_prefs(ThemePreference::Unicode),
    );

    anyhow::ensure!(
        output.contains("line\\nnext\\t\\u{1b}[31mred\\u{9b}m\\u{202e}reordered"),
        "name controls should be visible escapes: {output:?}"
    );
    anyhow::ensure!(
        output.contains("description\\r\\nwith\\tcontrols\\u{7}\\u{202e}"),
        "description controls should be visible escapes: {output:?}"
    );
    anyhow::ensure!(
        !output.contains('\r')
            && !output.contains('\u{001B}')
            && !output.contains('\u{009B}')
            && !output.contains('\u{202E}'),
        "text output must not contain terminal control characters: {output:?}"
    );
    anyhow::ensure!(
        output.lines().count() == 8,
        "escaped newlines must not create additional catalogue rows: {output:?}"
    );
    Ok(())
}

#[test]
fn text_catalogue_escapes_cc_range_boundaries() -> Result<()> {
    let mut manifest = fixture_manifest()?;
    let action = manifest
        .actions
        .first_mut()
        .context("help target fixture should contain an action")?;
    action.name = crate::ast::StringOrList::String(
        "start\0unit\u{001F}delete\u{007F}application\u{009F}end".to_owned(),
    );

    let output = render_text(
        &build_catalogue(&manifest),
        theme_prefs(ThemePreference::Unicode),
    );

    for escaped in ["\\u{0}", "\\u{1f}", "\\u{7f}", "\\u{9f}"] {
        anyhow::ensure!(
            output.contains(escaped),
            "text catalogue should escape Cc boundary {escaped}: {output:?}"
        );
    }
    for control in ['\0', '\u{001F}', '\u{007F}', '\u{009F}'] {
        anyhow::ensure!(
            !output.contains(control),
            "text catalogue must not contain Cc boundary {control:?}: {output:?}"
        );
    }
    Ok(())
}

/// Generate target metadata with at least one name, allowing actions and
/// targets to exercise scalar/list flattening through the same catalogue path.
fn target_metadata() -> impl Strategy<Value = (Vec<String>, Option<String>, bool)> {
    (
        proptest::collection::vec("[a-z]{1,8}", 1..4),
        prop_oneof![Just(None), "[A-Za-z ]{0,20}".prop_map(Some)],
        any::<bool>(),
    )
}

/// Build a simple target because catalogue construction depends only on names,
/// descriptions, and action categorization.
fn catalogue_target(
    names: Vec<String>,
    description: Option<String>,
    phony: bool,
    conditional: bool,
) -> Target {
    Target {
        name: crate::ast::StringOrList::List(names),
        recipe: crate::ast::Recipe::Command {
            command: crate::ast::StringOrList::String("true".to_owned()),
        },
        sources: crate::ast::StringOrList::Empty,
        deps: crate::ast::StringOrList::Empty,
        dependency_order: crate::ast::DependencyOrder::Parallel,
        order_only_deps: crate::ast::StringOrList::Empty,
        vars: crate::ast::Vars::default(),
        phony,
        always: false,
        conditional,
        description,
    }
}

proptest! {
    /// Catalogue construction preserves declaration order, expands every name,
    /// retains metadata, and preserves conditional entries injected directly
    /// by this test because manifest discovery cannot evaluate their `when`
    /// expressions.
    #[test]
    fn catalogue_preserves_order_names_metadata_and_defaults(
        actions in proptest::collection::vec(target_metadata(), 0..5),
        targets in proptest::collection::vec(target_metadata(), 0..5),
        default_flags in proptest::collection::vec(any::<bool>(), 0..64),
    ) {
        let declared_names: Vec<String> = actions
            .iter()
            .chain(&targets)
            .flat_map(|(names, _, _)| names.iter().cloned())
            .collect();
        let defaults = if declared_names.is_empty() {
            Vec::new()
        } else {
            declared_names
                .iter()
                .zip(default_flags)
                .filter(|(_, is_default)| *is_default)
                .map(|(name, _)| name.clone())
                .collect()
        };
        let manifest = NetsukeManifest {
            netsuke_version: Version::new(1, 0, 0),
            vars: crate::ast::Vars::default(),
            macros: Vec::new(),
            rules: Vec::new(),
            actions: actions
                .iter()
                .cloned()
                .map(|(names, description, conditional)| {
                    catalogue_target(names, description, true, conditional)
                })
                .collect(),
            targets: targets
                .iter()
                .cloned()
                .map(|(names, description, conditional)| {
                    catalogue_target(names, description, false, conditional)
                })
                .collect(),
            defaults: defaults.clone(),
        };
        let default_names = &defaults;
        let expected: Vec<(String, Option<String>, bool, bool, bool)> = actions
            .iter()
            .map(|(names, description, conditional)| (names, description, true, conditional))
            .chain(
                targets
                    .iter()
                    .map(|(names, description, conditional)| (names, description, false, conditional)),
            )
            .flat_map(|(names, description, is_action, conditional)| {
                names.iter().cloned().map(move |name| {
                    let is_default = default_names.contains(&name);
                    (name, description.clone(), is_action, is_default, *conditional)
                })
            })
            .collect();
        let actual: Vec<(String, Option<String>, bool, bool, bool)> = build_catalogue(&manifest)
            .into_iter()
            .map(|entry| (
                entry.name,
                entry.description.as_deref().map(str::to_owned),
                entry.is_action,
                entry.is_default,
                entry.conditional,
            ))
            .collect();

        prop_assert_eq!(actual, expected);
    }
}