nwnrs 0.0.1

Command-line inspection, conversion, packing, unpacking, and NWScript tooling for Neverwinter Nights resources
Documentation
use std::{
    ffi::OsStr,
    fs::{self, File},
    io::{self, Write},
    path::{Path, PathBuf},
    time::{SystemTime, UNIX_EPOCH},
};

use nwnrs_types::prelude::*;

#[derive(Clone, Copy, PartialEq, Eq)]
pub(crate) enum Kind {
    Gff,
    Ncs,
    Ssf,
    Tlk,
    TwoDa,
    Model,
    Texture,
    Erf,
    Key,
}

pub(crate) struct DirEntryInfo {
    pub(crate) file_name: String,
    pub(crate) path:      PathBuf,
}

pub(crate) fn detect_kind(path: &Path) -> Option<Kind> {
    let extension = path.extension()?.to_str()?.to_ascii_lowercase();
    Some(match extension.as_str() {
        "gff" | "are" | "bic" | "dlg" | "git" | "ifo" | "itp" | "jrl" | "utc" | "utd" | "ute"
        | "uti" | "utm" | "utp" | "uts" | "utt" | "utw" => Kind::Gff,
        "ncs" => Kind::Ncs,
        "mdl" => Kind::Model,
        "dds" | "plt" | "tga" => Kind::Texture,
        "ssf" => Kind::Ssf,
        "tlk" => Kind::Tlk,
        "2da" => Kind::TwoDa,
        "erf" | "hak" | "mod" | "nwm" => Kind::Erf,
        "key" => Kind::Key,
        _ => return None,
    })
}

pub(crate) fn unpacked_raw_target(destination: &Path, file_name: &str, extension: &str) -> PathBuf {
    if matches!(extension, "ncs" | "nss") {
        destination.join(extension).join(file_name)
    } else {
        destination.join(file_name)
    }
}

pub(crate) fn is_project_control_file(path: &Path) -> bool {
    nwnrs_nwpkg::is_project_control_file(path)
}

pub(crate) fn parse_key_version(value: &str) -> Result<key::KeyBifVersion, String> {
    match value.to_ascii_uppercase().as_str() {
        "V1" => Ok(key::KeyBifVersion::V1),
        "E1" => Ok(key::KeyBifVersion::E1),
        _ => Err(format!("unsupported key data version: {value}")),
    }
}

pub(crate) fn parse_erf_version(value: &str) -> Result<erf::ErfVersion, String> {
    match value.to_ascii_uppercase().as_str() {
        "V1" => Ok(erf::ErfVersion::V1),
        "E1" => Ok(erf::ErfVersion::E1),
        _ => Err(format!("unsupported erf data version: {value}")),
    }
}

pub(crate) fn parse_algorithm(value: &str) -> Result<compressedbuf::Algorithm, String> {
    match value.to_ascii_lowercase().as_str() {
        "none" => Ok(compressedbuf::Algorithm::None),
        "zlib" => Ok(compressedbuf::Algorithm::Zlib),
        "zstd" => Ok(compressedbuf::Algorithm::Zstd),
        _ => Err(format!("unsupported compression algorithm: {value}")),
    }
}

pub(crate) fn exo_compression_from_algorithm(
    algorithm: compressedbuf::Algorithm,
) -> exo::ExoResFileCompressionType {
    match algorithm {
        compressedbuf::Algorithm::None => exo::ExoResFileCompressionType::None,
        _ => exo::ExoResFileCompressionType::CompressedBuf,
    }
}

pub(crate) fn ensure_target_dir_ready(path: &Path, force: bool) -> Result<(), String> {
    if path.exists() {
        if !path.is_dir() {
            return Err(format!("target is not a directory: {}", path.display()));
        }
        if !force
            && fs::read_dir(path)
                .map_err(|error| format!("failed to read {}: {error}", path.display()))?
                .next()
                .is_some()
        {
            return Err("target directory not empty; aborting for your own safety".to_string());
        }
    } else {
        fs::create_dir_all(path)
            .map_err(|error| format!("failed to create {}: {error}", path.display()))?;
    }
    Ok(())
}

pub(crate) fn ensure_output_file_ready(path: &Path, force: bool) -> Result<(), String> {
    if path.exists() && !force {
        return Err(format!(
            "output file exists; use --force to overwrite: {}",
            path.display()
        ));
    }
    if let Some(parent) = path.parent()
        && !parent.as_os_str().is_empty()
    {
        fs::create_dir_all(parent)
            .map_err(|error| format!("failed to create {}: {error}", parent.display()))?;
    }
    Ok(())
}

pub(crate) fn sorted_dir_entries(dir: &Path) -> Result<Vec<DirEntryInfo>, String> {
    let mut entries = fs::read_dir(dir)
        .map_err(|error| format!("failed to read {}: {error}", dir.display()))?
        .map(|entry| {
            let entry =
                entry.map_err(|error| format!("failed to read {}: {error}", dir.display()))?;
            let file_name = entry.file_name().to_string_lossy().into_owned();
            Ok(DirEntryInfo {
                file_name,
                path: entry.path(),
            })
        })
        .collect::<Result<Vec<_>, String>>()?;
    entries.sort_by(|lhs, rhs| {
        lhs.file_name
            .to_ascii_lowercase()
            .cmp(&rhs.file_name.to_ascii_lowercase())
    });
    Ok(entries)
}

pub(crate) fn should_skip_top_level_dir(path: &Path) -> bool {
    matches!(
        path.file_name().and_then(OsStr::to_str),
        Some(".git" | ".svn")
    )
}

pub(crate) fn entry_is_dir(path: &Path, no_symlinks: bool) -> Result<bool, String> {
    let meta = fs::symlink_metadata(path)
        .map_err(|error| format!("failed to stat {}: {error}", path.display()))?;
    if meta.file_type().is_symlink() {
        if no_symlinks {
            return Ok(false);
        }
        return Ok(path.is_dir());
    }
    Ok(meta.is_dir())
}

pub(crate) fn entry_is_file(path: &Path, no_symlinks: bool) -> Result<bool, String> {
    let meta = fs::symlink_metadata(path)
        .map_err(|error| format!("failed to stat {}: {error}", path.display()))?;
    if meta.file_type().is_symlink() {
        if no_symlinks {
            return Ok(false);
        }
        return Ok(path.is_file());
    }
    Ok(meta.is_file())
}

pub(crate) fn infer_erf_type(path: &Path, explicit: Option<&str>) -> String {
    if let Some(value) = explicit {
        let mut type_name = value.to_ascii_uppercase();
        type_name.truncate(4);
        while type_name.len() < 4 {
            type_name.push(' ');
        }
        return type_name;
    }

    let ext = path
        .extension()
        .and_then(OsStr::to_str)
        .unwrap_or("")
        .to_ascii_uppercase();
    let inferred = match ext.as_str() {
        "" => "ERF".to_string(),
        "NWM" => "MOD".to_string(),
        other => other.chars().take(4).collect::<String>(),
    };

    let mut padded = inferred.trim().to_string();
    while padded.len() < 4 {
        padded.push(' ');
    }
    padded
}

pub(crate) fn write_lines<I>(path: &Path, lines: I) -> Result<(), String>
where
    I: IntoIterator<Item = String>,
{
    let mut file = File::create(path)
        .map_err(|error| format!("failed to create {}: {error}", path.display()))?;
    for line in lines {
        writeln!(file, "{line}")
            .map_err(|error| format!("failed to write {}: {error}", path.display()))?;
    }
    Ok(())
}

pub(crate) fn write_stdout_line(message: &str) -> Result<(), String> {
    let mut stdout = io::stdout();
    writeln!(stdout, "{message}").map_err(|error| format!("failed to write stdout: {error}"))
}

pub(crate) fn file_name_string(path: &str) -> Option<String> {
    Path::new(path)
        .file_name()
        .and_then(OsStr::to_str)
        .map(ToOwned::to_owned)
}

pub(crate) fn current_build_date() -> (u32, u32) {
    let now = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap_or_default();
    let days = i64::try_from(now.as_secs() / 86_400).unwrap_or(i64::MAX);
    let (year, month, day) = civil_from_days(days);
    let build_day = ordinal_day(year, month, day);
    (u32::try_from(year).unwrap_or(0), build_day)
}

fn civil_from_days(days_since_epoch: i64) -> (i32, u32, u32) {
    let z = days_since_epoch + 719_468;
    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
    let doe = z - era * 146_097;
    let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
    let y = yoe + era * 400;
    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
    let mp = (5 * doy + 2) / 153;
    let d = doy - (153 * mp + 2) / 5 + 1;
    let m = mp + if mp < 10 { 3 } else { -9 };
    let year = y + i64::from(m <= 2);
    (
        i32::try_from(year).unwrap_or(i32::MAX),
        u32::try_from(m).unwrap_or(0),
        u32::try_from(d).unwrap_or(0),
    )
}

fn ordinal_day(year: i32, month: u32, day: u32) -> u32 {
    const DAYS_BEFORE_MONTH: [u32; 12] = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334];
    let leap = is_leap_year(year) && month > 2;
    let month_index = usize::try_from(month.saturating_sub(1)).unwrap_or(0);
    DAYS_BEFORE_MONTH.get(month_index).copied().unwrap_or(0) + day + u32::from(leap)
}

fn is_leap_year(year: i32) -> bool {
    (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
}