bot-forge 1.0.2

Rust CLI for installing agent skills and developer tools from configurable forms.
Documentation
use std::env;
use std::ffi::OsStr;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};

/// Update a variable in the current process environment.
///
/// Rust 2024 makes environment mutation explicitly unsafe because another thread
/// must not read or mutate the process environment concurrently. Bot Forge only
/// performs these updates during its foreground execution lifecycle; callers that
/// mutate variables from tests hold `execution::TEST_ENV_LOCK`.
pub(crate) fn set_process_var<K, V>(key: K, value: V)
where
    K: AsRef<OsStr>,
    V: AsRef<OsStr>,
{
    // SAFETY: callers perform process-environment updates in the serialized
    // foreground lifecycle; tests additionally hold TEST_ENV_LOCK.
    unsafe { env::set_var(key, value) }
}

/// Remove a variable from the current process environment.
#[cfg(test)]
pub(crate) fn remove_process_var<K>(key: K)
where
    K: AsRef<OsStr>,
{
    // SAFETY: see [`set_process_var`].
    unsafe { env::remove_var(key) }
}

pub(crate) fn home_dir() -> PathBuf {
    if cfg!(windows) {
        env::var_os("USERPROFILE")
            .filter(|value| !value.is_empty())
            .or_else(|| env::var_os("HOME").filter(|value| !value.is_empty()))
    } else {
        env::var_os("HOME")
            .filter(|value| !value.is_empty())
            .or_else(|| env::var_os("USERPROFILE").filter(|value| !value.is_empty()))
    }
    .map(PathBuf::from)
    .unwrap_or_else(|| PathBuf::from("."))
}

pub(crate) fn resolve_command(command: &str) -> PathBuf {
    let command_path = PathBuf::from(command);
    if command_path.components().count() > 1 {
        return command_path;
    }
    if let Some(path) = env::var_os("PATH") {
        for directory in env::split_paths(&path) {
            #[cfg(windows)]
            if command_path.extension().is_none() {
                let path_ext =
                    env::var("PATHEXT").unwrap_or_else(|_| ".COM;.EXE;.BAT;.CMD".to_string());
                for extension in path_ext
                    .split(';')
                    .map(str::trim)
                    .filter(|extension| !extension.is_empty())
                {
                    let candidate =
                        directory.join(format!("{command}{}", extension.to_ascii_lowercase()));
                    if candidate.is_file() {
                        return candidate;
                    }
                }
            }
            let candidate = directory.join(command);
            if candidate.is_file() {
                return candidate;
            }
        }
    }
    if cfg!(target_os = "macos") && command == "brew" {
        for candidate in ["/opt/homebrew/bin/brew", "/usr/local/bin/brew"] {
            let candidate = PathBuf::from(candidate);
            if candidate.is_file() {
                return candidate;
            }
        }
    }
    command_path
}

pub(crate) fn now_secs() -> u64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|duration| duration.as_secs())
        .unwrap_or(0)
}

pub(crate) fn exe_name(bin: &str) -> String {
    if cfg!(windows) && !bin.ends_with(".exe") {
        format!("{bin}.exe")
    } else {
        bin.to_string()
    }
}

pub(crate) fn valid_storage_id(value: &str) -> bool {
    !value.is_empty()
        && value.len() <= 160
        && value != "."
        && value != ".."
        && value
            .bytes()
            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
}

pub(crate) fn valid_sha256(value: &str) -> bool {
    value.len() == 64
        && value
            .bytes()
            .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
}

pub(crate) fn valid_config_id(value: &str) -> bool {
    !value.is_empty()
        && value.len() <= 96
        && value.bytes().enumerate().all(|(index, byte)| {
            byte.is_ascii_lowercase()
                || byte.is_ascii_digit()
                || (byte == b'-'
                    && index > 0
                    && index + 1 < value.len()
                    && value.as_bytes()[index - 1] != b'-')
        })
}

pub(crate) fn source_name(source: &str) -> String {
    source
        .trim_end_matches(".git")
        .replace('\\', "/")
        .rsplit('/')
        .next()
        .filter(|name| !name.is_empty())
        .unwrap_or("manual")
        .to_string()
}

pub(crate) fn looks_like_git(source: &str) -> bool {
    source.ends_with(".git")
        || source.starts_with("http://")
        || source.starts_with("https://")
        || source.starts_with("ssh://")
        || source.starts_with("git@")
}

pub(crate) fn shell_quote(path: &Path) -> String {
    shell_quote_str(&path.display().to_string())
}

pub(crate) fn shell_quote_str(value: &str) -> String {
    if cfg!(windows) {
        format!("\"{}\"", value.replace('"', "\\\""))
    } else {
        format!("'{}'", value.replace('\'', "'\\''"))
    }
}

pub(crate) fn first_line(value: String) -> String {
    value.lines().next().unwrap_or("").trim().to_string()
}

pub(crate) fn fnv1a(value: &str) -> u64 {
    let mut hash = 0xcbf29ce484222325u64;
    for byte in value.as_bytes() {
        hash ^= u64::from(*byte);
        hash = hash.wrapping_mul(0x100000001b3);
    }
    hash
}

pub(crate) fn unquote(value: &str) -> &str {
    value
        .strip_prefix('"')
        .and_then(|value| value.strip_suffix('"'))
        .unwrap_or(value)
}

#[cfg(test)]
mod tests {
    use crate::execution::TEST_ENV_LOCK;
    use std::env;
    use std::ffi::OsString;
    use std::path::PathBuf;

    use crate::util::{home_dir, remove_process_var, set_process_var};

    fn restore(name: &str, value: Option<OsString>) {
        match value {
            Some(value) => set_process_var(name, value),
            None => remove_process_var(name),
        }
    }

    #[test]
    fn home_directory_uses_native_platform_variable_first() {
        let _guard = TEST_ENV_LOCK.lock().unwrap();
        let home = env::var_os("HOME");
        let userprofile = env::var_os("USERPROFILE");
        set_process_var("HOME", "/native-home");
        set_process_var("USERPROFILE", "/windows-home");
        assert_eq!(
            home_dir(),
            PathBuf::from(if cfg!(windows) {
                "/windows-home"
            } else {
                "/native-home"
            })
        );
        restore("HOME", home);
        restore("USERPROFILE", userprofile);
    }
}