dcr 0.7.0

DCR is a utility for managing C/C++ projects in a Cargo-like style.
// DCR — Cargo-like C/C++ project manager.
//
// Copyright (C) 2026 Dexoron (Bezotechestvo Vladimir) <main@dexoron.su>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program.  If not, see <https://www.gnu.org/licenses/>.

use crate::core::config::Config;
use crate::utils::log::warn;

pub struct VersionInfo {
    pub full: String,
    pub major: String,
    pub minor: String,
    pub patch: String,
    pub suffix: String,
    pub suffix_dash: String,
}

pub fn parse_version_info(version: &str) -> VersionInfo {
    let mut full = version.trim().to_string();
    if full.is_empty() {
        full = "0.0.0".to_string();
    }
    let full_clone = full.clone();
    let (base, suffix) = match full_clone.split_once('-') {
        Some((head, tail)) => (head, tail),
        None => (full_clone.as_str(), ""),
    };
    let mut parts = base.split('.');
    let major = parts.next().unwrap_or("0").to_string();
    let minor = parts.next().unwrap_or("0").to_string();
    let patch = parts.next().unwrap_or("0").to_string();
    let suffix_dash = if suffix.is_empty() {
        "".to_string()
    } else {
        format!("-{suffix}")
    };
    VersionInfo {
        full,
        major,
        minor,
        patch,
        suffix: suffix.to_string(),
        suffix_dash,
    }
}

pub fn normalize_target_os(target: &str) -> &str {
    match target {
        "linux" => "x86_64-unknown-linux-gnu",
        "macos" => "x86_64-apple-darwin",
        "windows" => "x86_64-pc-windows-msvc",
        _ if target.contains('-') => target,
        _ => {
            warn(&format!(
                "Unknown target '{}', using as-is. Supported short names: linux, macos, windows",
                target
            ));
            target
        }
    }
}

pub fn get_config_str(config: &Config, key: &str) -> String {
    config
        .get(key)
        .and_then(|v| v.as_str())
        .unwrap_or("")
        .to_string()
}

pub fn profile_table<'a>(config: &'a Config, profile: &str) -> Option<&'a toml::value::Table> {
    config
        .get("build")
        .and_then(|v| v.as_table())
        .and_then(|b| b.get(profile))
        .and_then(|v| v.as_table())
}

pub fn get_config_opt(config: &Config, key: &str) -> Option<String> {
    let value = config.get(key)?.as_str()?;
    let trimmed = value.trim();
    if trimmed.is_empty() {
        None
    } else {
        Some(trimmed.to_string())
    }
}

pub fn get_string_with_profile(config: &Config, field: &str, profile: &str) -> String {
    let base = get_config_str(config, &format!("build.{field}"));
    let Some(table) = profile_table(config, profile) else {
        return base;
    };
    let value = table.get(field).and_then(|v| v.as_str()).unwrap_or("");
    let trimmed = value.trim();
    if trimmed.is_empty() {
        base
    } else {
        trimmed.to_string()
    }
}

pub fn get_config_list(config: &Config, key: &str) -> Vec<String> {
    config
        .get(key)
        .and_then(|v| v.as_array())
        .map(|arr| {
            arr.iter()
                .filter_map(|v| v.as_str())
                .map(|s| s.to_string())
                .collect()
        })
        .unwrap_or_default()
}

pub fn get_list_with_profile(config: &Config, field: &str, profile: &str) -> Vec<String> {
    let mut out = get_config_list(config, &format!("build.{field}"));
    if let Some(table) = profile_table(config, profile)
        && let Some(extra) = table.get(field).and_then(|v| v.as_array())
    {
        out.extend(
            extra
                .iter()
                .filter_map(|v| v.as_str())
                .map(|s| s.to_string()),
        );
    }
    out
}

pub fn get_bool_with_profile(config: &Config, field: &str, profile: &str, default: bool) -> bool {
    let base = config
        .get(&format!("build.{field}"))
        .and_then(|v| v.as_bool());
    let profile_val =
        profile_table(config, profile).and_then(|t| t.get(field).and_then(|v| v.as_bool()));
    profile_val.or(base).unwrap_or(default)
}

pub fn get_language_with_profile(config: &Config, profile: &str) -> Result<String, String> {
    if let Some(table) = profile_table(config, profile)
        && let Some(value) = table.get("language")
    {
        return parse_language_value(value, "build.language");
    }
    match config.get("build.language") {
        Some(v) => parse_language_value(v, "build.language"),
        None => Err("build.language is missing".to_string()),
    }
}

pub fn get_language_with_profile_or_default(config: &Config, profile: &str) -> String {
    get_language_with_profile(config, profile).unwrap_or_else(|_| "c".to_string())
}

pub fn parse_language_value(value: &toml::Value, key: &str) -> Result<String, String> {
    if let Some(s) = value.as_str() {
        let trimmed = s.trim();
        if trimmed.is_empty() {
            return Err(format!("{key} is empty"));
        }
        return Ok(trimmed.to_string());
    }
    let arr = value
        .as_array()
        .ok_or_else(|| format!("{key} must be string or array of strings"))?;
    let mut parts = Vec::new();
    for item in arr {
        let s = item
            .as_str()
            .ok_or_else(|| format!("{key} must be string or array of strings"))?;
        let trimmed = s.trim();
        if trimmed.is_empty() {
            return Err(format!("{key} contains empty value"));
        }
        parts.push(trimmed.to_string());
    }
    if parts.is_empty() {
        return Err(format!("{key} is empty"));
    }
    Ok(parts.join(","))
}

pub fn resolve_compiler(
    language: &str,
    compiler: &str,
    tc_cc: Option<&str>,
    tc_cxx: Option<&str>,
    tc_as: Option<&str>,
) -> String {
    let lang = primary_language(language);
    env_override_compiler(&lang)
        .or_else(|| toolchain_override_compiler(&lang, tc_cc, tc_cxx, tc_as))
        .unwrap_or_else(|| compiler.to_string())
}

fn env_override_compiler(lang: &str) -> Option<String> {
    if let Ok(value) = std::env::var("DCR_COMPILER") {
        let trimmed = value.trim();
        if !trimmed.is_empty() {
            return Some(trimmed.to_string());
        }
    }
    if lang == "asm" {
        if let Ok(value) = std::env::var("DCR_AS") {
            let trimmed = value.trim();
            if !trimmed.is_empty() {
                return Some(trimmed.to_string());
            }
        }
        return None;
    }
    if (lang == "c++" || lang == "cpp" || lang == "cxx")
        && let Ok(value) = std::env::var("DCR_CXX")
    {
        let trimmed = value.trim();
        if !trimmed.is_empty() {
            return Some(trimmed.to_string());
        }
    }
    if let Ok(value) = std::env::var("DCR_CC") {
        let trimmed = value.trim();
        if !trimmed.is_empty() {
            return Some(trimmed.to_string());
        }
    }
    None
}

fn toolchain_override_compiler(
    lang: &str,
    tc_cc: Option<&str>,
    tc_cxx: Option<&str>,
    tc_as: Option<&str>,
) -> Option<String> {
    if lang == "asm" {
        return tc_as.map(|v| v.to_string());
    }
    if (lang == "c++" || lang == "cpp" || lang == "cxx")
        && let Some(v) = tc_cxx
    {
        return Some(v.to_string());
    }
    tc_cc.map(|v| v.to_string())
}

pub fn primary_language(language: &str) -> String {
    let parts: Vec<String> = language
        .split(',')
        .map(|p| p.trim().to_lowercase())
        .filter(|p| !p.is_empty())
        .collect();
    for p in &parts {
        if p == "c++" || p == "cpp" || p == "cxx" {
            return p.clone();
        }
    }
    if parts.iter().any(|p| p == "c") {
        return "c".to_string();
    }
    if parts.iter().any(|p| p == "asm") {
        return "asm".to_string();
    }
    language.to_lowercase()
}

pub fn resolve_tool(env_key: &str, fallback: Option<&str>) -> Option<String> {
    if let Ok(value) = std::env::var(env_key) {
        let trimmed = value.trim();
        if !trimmed.is_empty() {
            return Some(trimmed.to_string());
        }
    }
    fallback.map(|v| v.to_string())
}

pub fn resolve_pkg_config_flags(
    pkgs: &[String],
    base_cflags: &[String],
    base_ldflags: &[String],
) -> Result<(Vec<String>, Vec<String>), String> {
    let mut cflags = base_cflags.to_vec();
    let mut ldflags = base_ldflags.to_vec();
    for pkg in pkgs {
        let c_out = run_pkg_config(pkg, "--cflags")?;
        let l_out = run_pkg_config(pkg, "--libs")?;
        cflags.extend(split_flags(&c_out));
        ldflags.extend(split_flags(&l_out));
    }
    Ok((cflags, ldflags))
}

pub fn resolve_pkg_config_flags_lossy(
    pkgs: &[String],
    base_cflags: &[String],
    base_ldflags: &[String],
) -> (Vec<String>, Vec<String>) {
    match resolve_pkg_config_flags(pkgs, base_cflags, base_ldflags) {
        Ok(flags) => flags,
        Err(err) => {
            eprintln!("Warning: {err}");
            (base_cflags.to_vec(), base_ldflags.to_vec())
        }
    }
}

fn run_pkg_config(pkg: &str, arg: &str) -> Result<String, String> {
    let output = std::process::Command::new("pkg-config")
        .arg(arg)
        .arg(pkg)
        .output()
        .map_err(|err| format!("Failed to run pkg-config: {err}"))?;
    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr);
        return Err(format!("pkg-config failed for {pkg}: {stderr}"));
    }
    Ok(String::from_utf8_lossy(&output.stdout).to_string())
}

fn split_flags(value: &str) -> Vec<String> {
    let mut out = Vec::new();
    let mut current = String::new();
    let mut chars = value.chars().peekable();
    let mut quote: Option<char> = None;
    while let Some(ch) = chars.next() {
        if let Some(q) = quote {
            if ch == q {
                quote = None;
            } else {
                current.push(ch);
            }
            continue;
        }
        match ch {
            '\'' | '"' => quote = Some(ch),
            '\\' => {
                if let Some(next) = chars.next() {
                    current.push(next);
                }
            }
            c if c.is_whitespace() => {
                if !current.is_empty() {
                    out.push(std::mem::take(&mut current));
                }
            }
            c => current.push(c),
        }
    }
    if !current.is_empty() {
        out.push(current);
    }
    out
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn normalize_target_short_names() {
        assert_eq!(normalize_target_os("linux"), "x86_64-unknown-linux-gnu");
        assert_eq!(normalize_target_os("macos"), "x86_64-apple-darwin");
        assert_eq!(normalize_target_os("windows"), "x86_64-pc-windows-msvc");
        assert_eq!(
            normalize_target_os("x86_64-unknown-linux-gnu"),
            "x86_64-unknown-linux-gnu"
        );
        assert_eq!(normalize_target_os("unknown"), "unknown");
    }

    #[test]
    fn parse_version_parts() {
        let info = parse_version_info("1.2.3-beta");
        assert_eq!(info.full, "1.2.3-beta");
        assert_eq!(info.major, "1");
        assert_eq!(info.minor, "2");
        assert_eq!(info.patch, "3");
        assert_eq!(info.suffix, "beta");
        assert_eq!(info.suffix_dash, "-beta");
    }
}