dcr 0.8.4

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::cli::build;
use crate::config::{FILE_DCR_TEST_H, FILE_TEST_C, flags};
use crate::core::build_config::Config;
use crate::utils::build::{
    get_config_opt, get_config_str, get_language_with_profile, get_list_with_profile,
    get_string_with_profile, resolve_compiler, resolve_pkg_config_flags,
};
use crate::utils::fs::{find_project_root, with_dir};
use crate::utils::log::error;
use crate::utils::text::{BOLD_CYAN, BOLD_GREEN, BOLD_RED, RESET, colored, printc};
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;

const BOLD_BLUE: &str = "\x1b[1m\x1b[94m";

/// Runs project tests and prints a unified testsuite report.
pub fn test(args: &[String]) -> i32 {
    let mut init_header = false;
    let mut profile = "debug";
    for arg in args {
        if arg == "--help" {
            printc("USAGE:", BOLD_GREEN);
            printc("    dcr test [--init] [--debug | --release]", BOLD_CYAN);
            println!();
            printc("ALIASES:", BOLD_GREEN);
            println!("    dcr tests");
            println!();
            printc("DESCRIPTION:", BOLD_GREEN);
            println!("    Runs project tests and prints a unified testsuite report.");
            println!();
            printc("OPTIONS:", BOLD_GREEN);
            println!("    --init            Create tests/dcr_test.h in current project");
            println!("    --debug           Build and run tests with debug profile (default)");
            println!("    --release         Build and run tests with release profile");
            return 0;
        }
        if arg == "--init" {
            init_header = true;
            continue;
        }
        if arg == "--debug" || arg == "--release" {
            profile = arg.trim_start_matches("--");
            continue;
        }
        error("Unknown argument");
        return 1;
    }

    let start_dir = match std::env::current_dir() {
        Ok(dir) => dir,
        Err(_) => {
            error("Failed to determine current directory");
            return 1;
        }
    };
    let root = match find_project_root(&start_dir) {
        Ok(Some(dir)) => dir,
        Ok(None) => {
            error("dcr.toml file not found");
            return 1;
        }
        Err(_) => {
            error("Failed to find project root");
            return 1;
        }
    };

    if init_header {
        match with_dir(&root, ensure_test_header) {
            Ok(()) => {}
            Err(msg) => {
                error(&msg);
                return 1;
            }
        }
    }

    match with_dir(&root, || run_testsuite(profile)) {
        Ok(code) => code,
        Err(msg) => {
            error(&msg);
            1
        }
    }
}

/// Executes the testsuite for the given build profile.
fn run_testsuite(profile: &str) -> Result<i32, String> {
    if build::build(&[format!("--{profile}")]) != 0 {
        return Ok(1);
    }

    let config = Config::open("./dcr.toml").map_err(|_| "Failed to read dcr.toml".to_string())?;
    let name = get_config_str(&config, "package.name");
    let language = get_language_with_profile(&config, profile)?;
    let compiler_cfg = get_string_with_profile(&config, "compiler", profile);
    let standard = get_string_with_profile(&config, "standard", profile);
    let package_type = get_config_str(&config, "package.type");
    let build_kind = get_string_with_profile(&config, "kind", profile);
    let tc_cc = get_config_opt(&config, "toolchain.cc");
    let tc_cxx = get_config_opt(&config, "toolchain.cxx");
    let tc_as = get_config_opt(&config, "toolchain.as");
    let compiler = resolve_compiler(
        &language,
        &compiler_cfg,
        tc_cc.as_deref(),
        tc_cxx.as_deref(),
        tc_as.as_deref(),
    );

    let mut cflags: Vec<String> = flags(profile)
        .unwrap_or(&[])
        .iter()
        .map(|v| v.to_string())
        .collect();
    cflags.extend(get_list_with_profile(&config, "cflags", profile));
    let ldflags = get_list_with_profile(&config, "ldflags", profile);
    let pkg_configs = get_list_with_profile(&config, "pkg_config", profile);
    let (cflags, ldflags) = resolve_pkg_config_flags(&pkg_configs, &cflags, &ldflags)?;
    let includes = test_include_dirs(&config, profile);
    let test_sources = collect_test_sources()?;
    if test_sources.is_empty() {
        return Err("tests/test.c not found; run 'dcr test --init' first".to_string());
    }

    let link_project_lib =
        package_type == "lib" || build_kind == "staticlib" || build_kind == "sharedlib";
    let mut stdout = String::new();
    let mut declared = Vec::new();
    let mut suite_success = true;

    fs::create_dir_all("./tests/target")
        .map_err(|e| format!("Failed to create tests/target: {e}"))?;
    for source in &test_sources {
        declared.extend(extract_test_names_path(source));
        let stem = source
            .file_stem()
            .and_then(|v| v.to_str())
            .unwrap_or("test");
        let obj_path = Path::new("./tests/target").join(format!("{stem}.o"));
        let bin_path =
            Path::new("./tests/target").join(format!("{stem}{}", std::env::consts::EXE_SUFFIX));

        compile_test_source(&compiler, &standard, &cflags, &includes, source, &obj_path)?;
        link_test_binary(
            &compiler,
            &ldflags,
            link_project_lib.then_some(name.as_str()),
            &obj_path,
            &bin_path,
        )?;

        let out = Command::new(&bin_path)
            .output()
            .map_err(|_| format!("Failed to run `{}`", bin_path.display()))?;
        stdout.push_str(&String::from_utf8_lossy(&out.stdout));
        let stderr = String::from_utf8_lossy(&out.stderr);
        if !stderr.trim().is_empty() {
            eprint!("{}", stderr);
        }
        if !out.status.success() {
            suite_success = false;
        }
    }

    let mut pass = 0;
    let mut skip = 0;
    let mut fail = 0;
    let mut parsed_any = false;

    for line in stdout.lines() {
        let line = line.trim();
        if let Some(name) = line.strip_prefix("[PASS] ") {
            println!("{} {}", colored("[PASS]", BOLD_GREEN), name);
            pass += 1;
            parsed_any = true;
            continue;
        }
        if let Some(name) = line.strip_prefix("[SKIP] ") {
            println!("{} {}", colored("[SKIP]", BOLD_BLUE), name);
            skip += 1;
            parsed_any = true;
            continue;
        }
        if let Some(name) = line.strip_prefix("[FAIL] ") {
            println!("{} {}", colored("[FAIL]", BOLD_RED), name);
            fail += 1;
            parsed_any = true;
            continue;
        }
        if let Some((status, name)) = line.split_once('\t') {
            match status {
                "PASS" => {
                    println!("{} {}", colored("[PASS]", BOLD_GREEN), name);
                    pass += 1;
                    parsed_any = true;
                }
                "SKIP" => {
                    println!("{} {}", colored("[SKIP]", BOLD_BLUE), name);
                    skip += 1;
                    parsed_any = true;
                }
                "FAIL" => {
                    println!("{} {}", colored("[FAIL]", BOLD_RED), name);
                    fail += 1;
                    parsed_any = true;
                }
                _ => {}
            }
        }
    }

    if !parsed_any {
        if suite_success {
            for name in &declared {
                println!("{} {}", colored("[PASS]", BOLD_GREEN), name);
            }
            pass = declared.len() as i32;
        } else {
            println!("{} testsuite", colored("[FAIL]", BOLD_RED));
            fail = 1;
        }
    }

    let total = if parsed_any || declared.is_empty() {
        pass + skip + fail
    } else {
        declared.len() as i32
    };

    println!();
    println!("{}", colored("=====================", BOLD_GREEN));
    println!("{}", colored("  Testsuite summary  ", BOLD_GREEN));
    println!("{}", colored("=====================", BOLD_GREEN));
    println!("TOTAL: {}", total);
    print_field("PASS", pass, BOLD_GREEN);
    print_field("SKIP", skip, BOLD_BLUE);
    print_field("FAIL", fail, BOLD_RED);
    println!("{}", colored("=====================", BOLD_GREEN));

    if fail > 0 || !suite_success {
        return Ok(1);
    }
    Ok(0)
}

/// Collects all .c source files from the tests/ directory.
fn collect_test_sources() -> Result<Vec<PathBuf>, String> {
    let tests_dir = Path::new("./tests");
    if !tests_dir.is_dir() {
        return Ok(Vec::new());
    }
    let mut sources = Vec::new();
    for entry in fs::read_dir(tests_dir).map_err(|e| format!("Failed to read tests/: {e}"))? {
        let path = entry
            .map_err(|e| format!("Failed to read tests/: {e}"))?
            .path();
        if path.extension().and_then(|v| v.to_str()) == Some("c") {
            sources.push(path);
        }
    }
    sources.sort();
    Ok(sources)
}

/// Returns a list of include directories for test compilation.
fn test_include_dirs(config: &Config, profile: &str) -> Vec<String> {
    let mut dirs = vec![
        "./tests".to_string(),
        "./src".to_string(),
        "./target/include".to_string(),
    ];
    dirs.extend(get_list_with_profile(config, "include", profile));
    dirs
}

/// Compiles a single test source file to an object file.
fn compile_test_source(
    compiler: &str,
    standard: &str,
    cflags: &[String],
    includes: &[String],
    source: &Path,
    obj_path: &Path,
) -> Result<(), String> {
    let mut cmd = Command::new(compiler);
    cmd.arg("-c").arg(source).arg("-o").arg(obj_path);
    if !standard.trim().is_empty() {
        cmd.arg(format!("-std={}", standard.trim()));
    }
    for flag in cflags {
        cmd.arg(flag);
    }
    for dir in includes {
        cmd.arg(format!("-I{dir}"));
    }
    let output = cmd
        .output()
        .map_err(|e| format!("Failed to compile {}: {e}", source.display()))?;
    print_command_output(&output);
    if !output.status.success() {
        return Err(format!("Compilation of {} failed", source.display()));
    }
    Ok(())
}

/// Links the test object file into an executable binary.
fn link_test_binary(
    compiler: &str,
    ldflags: &[String],
    project_lib: Option<&str>,
    obj_path: &Path,
    bin_path: &Path,
) -> Result<(), String> {
    let mut cmd = Command::new(compiler);
    cmd.arg(obj_path);
    if let Some(name) = project_lib {
        cmd.arg("-Ltarget/lib").arg(format!("-l{name}"));
    }
    for flag in ldflags {
        cmd.arg(flag);
    }
    cmd.arg("-o").arg(bin_path);
    let output = cmd
        .output()
        .map_err(|e| format!("Failed to link {}: {e}", bin_path.display()))?;
    print_command_output(&output);
    if !output.status.success() {
        return Err(format!("Linking of {} failed", bin_path.display()));
    }
    Ok(())
}

/// Prints the stdout and stderr from a command execution.
fn print_command_output(output: &std::process::Output) {
    if !output.stdout.is_empty() {
        print!("{}", String::from_utf8_lossy(&output.stdout));
    }
    if !output.stderr.is_empty() {
        eprint!("{}", String::from_utf8_lossy(&output.stderr));
    }
}

/// Creates the default test files (dcr_test.h and test.c) in the tests/ directory.
fn ensure_test_header() -> Result<(), String> {
    fs::create_dir_all("./tests").map_err(|_| "Failed to create tests/".to_string())?;
    fs::write("./tests/dcr_test.h", FILE_DCR_TEST_H)
        .map_err(|_| "Failed to write tests/dcr_test.h".to_string())?;
    println!("Created tests/dcr_test.h");
    fs::write("./tests/test.c", FILE_TEST_C)
        .map_err(|_| "Failed to write tests/test.c".to_string())?;
    println!("Created tests/test.c");
    Ok(())
}

/// Extracts test case names from a C source file by parsing TEST_CASE macros.
fn extract_test_names_path(path: &Path) -> Vec<String> {
    let Ok(content) = fs::read_to_string(path) else {
        return Vec::new();
    };
    let mut names = Vec::new();
    for line in content.lines() {
        let trimmed = line.trim();
        if let Some(rest) = trimmed.strip_prefix("TEST_CASE(")
            && let Some(name) = rest.strip_suffix("),")
        {
            names.push(name.to_string());
        }
    }
    names
}

/// Prints a test result field with color coding.
fn print_field(label: &str, value: i32, color: &str) {
    if value == 0 {
        println!("{}:  {}", label, value);
    } else {
        println!("{}{}:  {}{}", color, label, value, RESET);
    }
}