netsuke-build 0.1.0-beta3

A YAML-powered Ninja/Jinja hybrid build system.
//! File creation helpers for the Ninja runner.
//! Handles temporary build files and writes to capability-based directories.

use crate::localization::{self, keys};
use crate::runner::NinjaContent;
use anyhow::{Context, Result as AnyResult, anyhow};
use camino::{Utf8Path, Utf8PathBuf};
use cap_std::{ambient_authority, fs as cap_fs};
use std::io;
use std::io::Write;
use std::path::Path;
use tempfile::{Builder, NamedTempFile, TempPath};
use tracing::info;

/// Own a temporary Ninja file and expose its UTF-8 path to the runner.
///
/// Retain [`TempPath`] solely for automatic cleanup. Every runner caller uses
/// [`Self::as_path`], which maintains the Ninja invocation chain's UTF-8 path
/// invariant after the platform temp-file adapter creates the file.
pub struct TempNinjaFile {
    /// Temporary-file lease that deletes the file when dropped.
    _lease: TempPath,
    /// UTF-8 path passed to Ninja.
    path: Utf8PathBuf,
}

impl TempNinjaFile {
    /// Return the temporary Ninja file path as a UTF-8 path.
    #[must_use]
    pub fn as_path(&self) -> &Utf8Path {
        &self.path
    }
}

/// Return `true` when `path` is the CLI sentinel indicating "write to stdout".
#[must_use]
pub fn is_stdout_path(path: &Path) -> bool {
    path.as_os_str() == "-"
}

/// Materialize `content` as a temporary Ninja file with no open writer handle.
///
/// Returning [`TempNinjaFile`] retains automatic cleanup while releasing the
/// writer before Ninja reopens the file by path. Windows otherwise rejects
/// Ninja's read while the original `NamedTempFile` handle remains open.
///
/// # Errors
///
/// Returns an error when the temporary path is not valid UTF-8 or when file
/// creation, writing, flushing, or synchronization fails.
pub fn create_temp_ninja_file(content: &NinjaContent) -> AnyResult<TempNinjaFile> {
    let mut tmp = Builder::new()
        .prefix("netsuke.")
        .suffix(".ninja")
        .tempfile()
        .context(localization::message(keys::RUNNER_IO_CREATE_TEMP_FILE))?;
    tmp.write_all(content.as_str().as_bytes())
        .context(localization::message(keys::RUNNER_IO_WRITE_TEMP_NINJA))?;
    tmp.flush()
        .context(localization::message(keys::RUNNER_IO_FLUSH_TEMP_NINJA))?;
    sync_temp_ninja_file(&tmp).context(localization::message(keys::RUNNER_IO_SYNC_TEMP_NINJA))?;
    let lease = tmp.into_temp_path();
    let path = Utf8PathBuf::from_path_buf(lease.to_path_buf()).map_err(|path| {
        anyhow!(
            localization::message(keys::RUNNER_IO_NON_UTF8_PATH)
                .with_arg("path", path.display().to_string())
                .to_string()
        )
    })?;
    info!("Wrote temporary Ninja file to {path}");
    Ok(TempNinjaFile {
        _lease: lease,
        path,
    })
}

mod ambient_sync {
    //! Ambient-authority durability boundary for the temporary Ninja file.
    //!
    //! `tempfile` places the file in the ambient system temporary directory, so
    //! no `cap_std::fs::Dir` handle covers it and the already-open file
    //! descriptor is the narrowest authority available for the sync. This module
    //! is deliberately the only part of `file_io` outside the capability policy;
    //! it is named in `dylint.toml` under `[no_std_fs_operations]
    //! excluded_paths` so the rest of the module stays enforced.

    use super::{NamedTempFile, io};

    /// Sync a temporary Ninja file to disk before handing its path to Ninja.
    pub(super) fn sync_temp_ninja_file(tmp: &NamedTempFile) -> io::Result<()> {
        tmp.as_file().sync_all()
    }
}

use ambient_sync::sync_temp_ninja_file;

/// Write `content` to `path` under `dir`, creating parent directories.
///
/// # Errors
///
/// Returns an error when the parent directories or file cannot be created, or
/// when writing, flushing, or synchronizing fails.
pub fn write_text_file_utf8(dir: &cap_fs::Dir, path: &Utf8Path, content: &str) -> AnyResult<()> {
    if let Some(parent) = path.parent().filter(|p| !p.as_str().is_empty()) {
        dir.create_dir_all(parent.as_str()).with_context(|| {
            localization::message(keys::RUNNER_IO_CREATE_PARENT_DIR)
                .with_arg("path", parent.as_str())
        })?;
    }
    let mut file = dir.create(path.as_str()).with_context(|| {
        localization::message(keys::RUNNER_IO_CREATE_NINJA_FILE).with_arg("path", path.as_str())
    })?;
    file.write_all(content.as_bytes()).with_context(|| {
        localization::message(keys::RUNNER_IO_WRITE_NINJA_FILE).with_arg("path", path.as_str())
    })?;
    file.flush().with_context(|| {
        localization::message(keys::RUNNER_IO_FLUSH_NINJA_FILE).with_arg("path", path.as_str())
    })?;
    file.sync_all().with_context(|| {
        localization::message(keys::RUNNER_IO_SYNC_NINJA_FILE).with_arg("path", path.as_str())
    })?;
    Ok(())
}

/// Split `path` into a capability-scoped directory and a relative remainder.
///
/// Relative paths open the current directory; absolute paths are anchored on
/// the deepest pre-existing ancestor directory.
///
/// # Errors
///
/// Returns an error when the current directory (for a relative path) or no
/// pre-existing ancestor directory (for an absolute path) can be opened, or
/// when the relative remainder cannot be derived from the selected base.
fn derive_dir_and_relative(path: &Utf8Path) -> AnyResult<(cap_fs::Dir, Utf8PathBuf)> {
    if path.is_relative() {
        let dir = cap_fs::Dir::open_ambient_dir(".", ambient_authority())
            .context(localization::message(keys::RUNNER_IO_OPEN_AMBIENT_DIR))?;
        return Ok((dir, path.to_owned()));
    }

    let mut ancestors = path.ancestors();
    ancestors.next();
    let (base, dir) = ancestors
        .find_map(|candidate| {
            cap_fs::Dir::open_ambient_dir(candidate.as_str(), ambient_authority())
                .ok()
                .map(|dir| (candidate.to_owned(), dir))
        })
        .ok_or_else(|| {
            anyhow!(
                localization::message(keys::RUNNER_IO_NO_EXISTING_ANCESTOR)
                    .with_arg("path", path.as_str())
                    .to_string()
            )
        })?;
    let relative = path
        .strip_prefix(&base)
        .context(localization::message(keys::RUNNER_IO_DERIVE_RELATIVE_PATH))?
        .to_owned();
    Ok((dir, relative))
}

/// Write Ninja `content` to `path`, creating missing parent directories.
///
/// # Errors
///
/// Returns an error when the path is not valid UTF-8 or the write fails.
pub fn write_ninja_file(path: &Path, content: &NinjaContent) -> AnyResult<()> {
    write_text_file(path, content.as_str())?;
    Ok(())
}

/// Write `content` to `path`, creating missing parent directories.
///
/// # Errors
///
/// Returns an error when the path is not valid UTF-8 or the write fails.
pub fn write_text_file(path: &Path, content: &str) -> AnyResult<()> {
    let utf8_path = Utf8Path::from_path(path).ok_or_else(|| {
        anyhow!(
            localization::message(keys::RUNNER_IO_NON_UTF8_PATH)
                .with_arg("path", path.display().to_string())
                .to_string()
        )
    })?;
    let (dir, relative) = derive_dir_and_relative(utf8_path)?;
    write_text_file_utf8(&dir, &relative, content)?;
    info!("Wrote file to {utf8_path}");
    Ok(())
}

/// Return whether `err` signals a closed reader on the other end.
fn is_broken_pipe(err: &io::Error) -> bool {
    err.kind() == io::ErrorKind::BrokenPipe
}

/// Write `buf` in full, swallowing only broken-pipe failures.
fn write_all_ignoring_broken_pipe(writer: &mut impl Write, buf: &[u8]) -> io::Result<()> {
    match writer.write_all(buf) {
        Ok(()) => Ok(()),
        Err(err) if is_broken_pipe(&err) => Ok(()),
        Err(err) => Err(err),
    }
}

/// Flush `writer`, swallowing only broken-pipe failures.
fn flush_ignoring_broken_pipe(writer: &mut impl Write) -> io::Result<()> {
    match writer.flush() {
        Ok(()) => Ok(()),
        Err(err) if is_broken_pipe(&err) => Ok(()),
        Err(err) => Err(err),
    }
}

/// Write Ninja `content` to stdout, tolerating a closed pipe.
///
/// # Errors
///
/// Returns an error when the stdout write fails other than through a broken
/// pipe.
pub fn write_ninja_stdout(content: &NinjaContent) -> AnyResult<()> {
    write_text_stdout(content.as_str())
}

/// Write `content` to stdout, tolerating a closed pipe.
///
/// # Errors
///
/// Returns an error when the stdout write fails other than through a broken
/// pipe.
pub fn write_text_stdout(content: &str) -> AnyResult<()> {
    let mut stdout = io::stdout().lock();
    write_all_ignoring_broken_pipe(&mut stdout, content.as_bytes())
        .context(localization::message(keys::RUNNER_IO_WRITE_STDOUT))?;
    flush_ignoring_broken_pipe(&mut stdout)
        .context(localization::message(keys::RUNNER_IO_FLUSH_STDOUT))?;
    Ok(())
}

#[cfg(test)]
mod tests {
    //! Unit tests for temporary Ninja file creation and file writing helpers.

    use super::*;
    use crate::runner::NinjaContent;
    use anyhow::{Context, Result, ensure};
    use camino::Utf8PathBuf;
    use cap_std::{ambient_authority, fs as cap_fs};
    use rstest::rstest;
    use test_support::fs as test_fs;

    /// Verify the temporary Ninja file can be reopened after its writer drops.
    #[test]
    fn create_temp_ninja_file_releases_writer_before_external_read() -> Result<()> {
        let content = NinjaContent::new(String::from("rule cc"));
        let captured_path = {
            let file = create_temp_ninja_file(&content)?;
            let path = file.as_path().as_std_path().to_path_buf();

            // Ninja reads the file by path, so no writer handle may remain open.
            let parent = path.parent().context("find temporary file parent")?;
            let name = path.file_name().context("find temporary file name")?;
            let directory = cap_fs::Dir::open_ambient_dir(parent, ambient_authority())
                .context("open temporary file parent")?;
            drop(
                directory
                    .open(name)
                    .context("open temporary Ninja file through an external handle")?,
            );
            let written = test_fs::read_to_string(&path).context("read temp file")?;
            ensure!(
                written == content.as_str(),
                "reopened file contents '{written}' did not match '{expected}'",
                expected = content.as_str()
            );

            let observed_len = test_fs::file_len(&path).context("query temp file metadata")?;
            ensure!(
                observed_len == content.as_str().len() as u64,
                "expected size {} but observed {}",
                content.as_str().len(),
                observed_len
            );
            let temp_display = path.display().to_string();
            let has_ninja_ext = path
                .extension()
                .and_then(|ext| ext.to_str())
                .is_some_and(|ext| ext.eq_ignore_ascii_case("ninja"));
            ensure!(
                has_ninja_ext,
                "temporary path should end with .ninja: {temp_display}"
            );
            path
        };

        ensure!(
            !captured_path.exists(),
            "temporary Ninja file should be removed after its wrapper drops: {}",
            captured_path.display()
        );

        Ok(())
    }

    #[rstest]
    #[case("-", true)]
    #[case("out.ninja", false)]
    #[case("./-", false)]
    fn is_stdout_path_detects_dash(#[case] candidate: &str, #[case] expected: bool) {
        let path = Path::new(candidate);
        assert_eq!(
            is_stdout_path(path),
            expected,
            "unexpected result for {candidate}"
        );
    }

    #[test]
    fn write_text_file_utf8_creates_parent_directories() -> Result<()> {
        let temp = tempfile::tempdir().context("create temp dir")?;
        let dir = cap_fs::Dir::open_ambient_dir(temp.path(), ambient_authority())
            .context("open temp dir")?;
        let nested = Utf8PathBuf::from("nested/build.ninja");
        let content = "build all: phony";

        write_text_file_utf8(&dir, &nested, content)?;

        let nested_path = temp.path().join("nested").join("build.ninja");
        let written = test_fs::read_to_string(&nested_path).context("read nested file")?;
        ensure!(
            written == content,
            "nested file contents '{written}' did not match '{content}'"
        );
        let parent = nested_path.parent().context("determine parent path")?;
        ensure!(
            parent.exists(),
            "expected parent directory {} to exist",
            parent.display()
        );
        Ok(())
    }

    #[test]
    fn write_ninja_file_handles_absolute_paths() -> Result<()> {
        let temp = tempfile::tempdir().context("create temp dir")?;
        let nested = temp.path().join("nested").join("build.ninja");
        let content = NinjaContent::new(String::from("build all: phony"));

        write_ninja_file(&nested, &content)?;

        let written = test_fs::read_to_string(&nested).context("read nested file")?;
        ensure!(
            written == content.as_str(),
            "absolute path file contents '{written}' did not match '{expected}'",
            expected = content.as_str()
        );
        let parent = nested.parent().context("determine parent path")?;
        ensure!(
            parent.exists(),
            "expected parent directory {} to exist",
            parent.display()
        );
        Ok(())
    }
}