tovuk 0.1.54

Deploy Rust backends, static frontends, and fullstack apps to Tovuk.
use super::{
    args::CliOptions,
    constants::WALK_EXCLUDED_DIRS,
    errors::{Result, agent_error},
};
use serde_json::Value;
use std::{
    env,
    fmt::Write as _,
    fs,
    path::Path,
    process::{Command, Stdio},
};
use walkdir::{DirEntry, WalkDir};

pub(crate) fn ensure_directory(dir: &Path) -> Result<()> {
    if dir.is_dir() {
        Ok(())
    } else {
        Err(agent_error(
            "missing_project",
            "Project directory does not exist.",
            "Run Tovuk from the root of a Rust project or pass the project path.",
            false,
        ))
    }
}

pub(crate) fn walk_project_files(project_dir: &Path, mut visit: impl FnMut(&Path, &str)) {
    for entry in WalkDir::new(project_dir)
        .into_iter()
        .filter_entry(|entry| !is_walk_excluded_entry(entry))
        .flatten()
    {
        if !entry.file_type().is_file() {
            continue;
        }
        if let Ok(relative) = entry.path().strip_prefix(project_dir) {
            let relative = relative.to_string_lossy().replace('\\', "/");
            visit(entry.path(), &relative);
        }
    }
}

pub(crate) fn is_walk_excluded_entry(entry: &DirEntry) -> bool {
    entry
        .file_name()
        .to_str()
        .is_some_and(|name| entry.depth() > 0 && WALK_EXCLUDED_DIRS.contains(&name))
}

pub(crate) fn has_command(command: &str) -> bool {
    env::var_os("PATH").is_some_and(|paths| {
        env::split_paths(&paths).any(|directory| {
            let candidate = directory.join(command);
            if cfg!(windows) {
                candidate.is_file() || directory.join(format!("{command}.exe")).is_file()
            } else {
                candidate.is_file()
            }
        })
    })
}

pub(crate) fn is_safe_relative_path(value: &str) -> bool {
    !value.is_empty()
        && !Path::new(value).is_absolute()
        && !value.contains('\\')
        && value
            .split('/')
            .all(|part| !part.is_empty() && part != "." && part != "..")
}

pub(crate) fn is_safe_relative_directory(value: &str) -> bool {
    value == "." || is_safe_relative_path(value)
}

pub(crate) fn read_package_json(project_dir: &Path) -> Option<Value> {
    let source = fs::read_to_string(project_dir.join("package.json")).ok()?;
    serde_json::from_str(&source).ok()
}

pub(crate) fn service_name_from_dir(project_dir: &Path) -> String {
    service_name_from_value(
        project_dir
            .file_name()
            .and_then(|value| value.to_str())
            .unwrap_or_default(),
    )
    .unwrap_or_else(|| "api".to_owned())
}

pub(crate) fn service_name_from_cargo(project_dir: &Path) -> Option<String> {
    let source = fs::read_to_string(project_dir.join("Cargo.toml")).ok()?;
    let name = source.lines().find_map(|line| {
        let line = line.trim();
        if !line.starts_with("name") {
            return None;
        }
        line.split('"').nth(1).map(str::to_owned)
    })?;
    service_name_from_value(&name)
}

pub(crate) fn service_name_from_package(project_dir: &Path) -> Option<String> {
    let manifest = read_package_json(project_dir)?;
    let name = manifest.get("name")?.as_str()?;
    service_name_from_value(name)
}

pub(crate) fn service_name_from_value(value: &str) -> Option<String> {
    let mut result = String::new();
    let mut last_dash = false;
    for character in value.to_ascii_lowercase().chars() {
        if character.is_ascii_alphanumeric() {
            result.push(character);
            last_dash = false;
        } else if !last_dash {
            result.push('-');
            last_dash = true;
        }
        if result.len() >= 48 {
            break;
        }
    }
    let result = result.trim_matches('-').to_owned();
    if result.is_empty() {
        None
    } else {
        Some(result)
    }
}

pub(crate) fn is_dns_safe_name(value: &str) -> bool {
    if value.is_empty() || value.len() > 48 {
        return false;
    }
    let bytes = value.as_bytes();
    if !bytes.first().is_some_and(u8::is_ascii_alphanumeric)
        || !bytes.last().is_some_and(u8::is_ascii_alphanumeric)
    {
        return false;
    }
    value
        .bytes()
        .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
}

pub(crate) fn path_relative(path: &Path, root: &Path) -> String {
    path.strip_prefix(root)
        .ok()
        .filter(|relative| !relative.as_os_str().is_empty())
        .map_or_else(
            || ".".to_owned(),
            |relative| relative.to_string_lossy().replace('\\', "/"),
        )
}

pub(crate) fn open_url(url: &str) {
    let mut command = if cfg!(target_os = "macos") {
        let mut command = Command::new("open");
        command.arg(url);
        command
    } else if cfg!(windows) {
        let mut command = Command::new("cmd");
        command.args(["/C", "start", "", url]);
        command
    } else {
        let mut command = Command::new("xdg-open");
        command.arg(url);
        command
    };
    let _ignore = command
        .stdin(Stdio::null())
        .stdout(Stdio::null())
        .stderr(Stdio::null())
        .status();
}

pub(crate) fn progress(cli: &CliOptions, message: &str) {
    if cli.output.json {
        eprintln!("{message}");
    } else {
        println!("{message}");
    }
}

pub(crate) fn encode_component(value: &str) -> String {
    let mut output = String::new();
    for byte in value.bytes() {
        if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') {
            output.push(char::from(byte));
        } else {
            let _ = write!(output, "%{byte:02X}");
        }
    }
    output
}

pub(crate) fn string_field(value: &Value, key: &str) -> String {
    value
        .get(key)
        .and_then(Value::as_str)
        .unwrap_or_default()
        .to_owned()
}

pub(crate) fn number_field(value: &Value, key: &str) -> u64 {
    value.get(key).and_then(Value::as_u64).unwrap_or_else(|| {
        value
            .get(key)
            .and_then(Value::as_i64)
            .and_then(|value| u64::try_from(value).ok())
            .unwrap_or(0)
    })
}

pub(crate) fn string_alias(value: &Value, aliases: &[&str]) -> String {
    aliases
        .iter()
        .find_map(|alias| value.get(alias).and_then(Value::as_str))
        .unwrap_or_default()
        .to_owned()
}

pub(crate) fn number_alias(value: &Value, aliases: &[&str]) -> Option<u64> {
    aliases
        .iter()
        .find_map(|alias| value.get(alias).and_then(Value::as_u64))
}

pub(crate) fn nested_string(value: &Value, path: &[&str]) -> String {
    let mut cursor = value;
    for part in path {
        cursor = cursor.get(part).unwrap_or(&Value::Null);
    }
    cursor.as_str().unwrap_or_default().to_owned()
}