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 std::io;
use std::io::Write;
use std::path::{Path, PathBuf};

/// Resolves the path to the user's home directory across different operating systems.
///
/// Checks the `HOME` environment variable (Unix-like systems) first,
/// then falls back to `USERPROFILE` (Windows).
pub fn home_dir() -> Option<PathBuf> {
    if let Ok(home) = std::env::var("HOME") {
        return Some(PathBuf::from(home));
    }
    if let Ok(profile) = std::env::var("USERPROFILE") {
        return Some(PathBuf::from(profile));
    }
    None
}

/// Converts a byte slice into a lowercase hexadecimal string.
pub fn to_hex(bytes: &[u8]) -> String {
    let mut out = String::with_capacity(bytes.len() * 2);
    for b in bytes {
        out.push_str(&format!("{:02x}", b));
    }
    out
}

/// Lists all entry names (files and directories) inside a target directory.
#[allow(dead_code)]
pub fn check_dir(dir: Option<&str>) -> io::Result<Vec<String>> {
    let path: PathBuf = match dir {
        None | Some(".") | Some("./") => std::env::current_dir()?,
        Some(value) => std::env::current_dir()?.join(value),
    };

    let mut items = Vec::new();
    for entry in std::fs::read_dir(path)? {
        let entry = entry?;
        items.push(entry.file_name().to_string_lossy().to_string());
    }

    Ok(items)
}

/// Searches upwards from a starting path to locate the root directory containing `dcr.toml`.
pub fn find_project_root(start: &Path) -> io::Result<Option<PathBuf>> {
    let mut current = start.to_path_buf();
    loop {
        if current.join("dcr.toml").is_file() {
            return Ok(Some(current));
        }
        if !current.pop() {
            break;
        }
    }
    Ok(None)
}

/// Executes a closure inside a specified directory and restores the original cwd afterwards.
///
/// # Parameters
/// - `dir`: Directory to `cd` into for the duration of `f`.
/// - `f`: Work to run with that cwd (its `Result` is returned as-is).
///
/// # Returns
/// The closure's result, or an error if getting/changing directory fails.
/// Cwd is restored even when `f` returns `Err`.
pub fn with_dir<F, T>(dir: &Path, f: F) -> Result<T, String>
where
    F: FnOnce() -> Result<T, String>,
{
    let prev = std::env::current_dir().map_err(|_| "Failed to get current dir".to_string())?;
    std::env::set_current_dir(dir).map_err(|_| "Failed to change directory".to_string())?;
    let result = f();
    // Restore previous working directory regardless of closure result.
    let _ = std::env::set_current_dir(prev);
    result
}

/// Strips Windows verbatim/extended-length prefixes (e.g. `\\?\`, `\\.\`) and standardizes slashes.
///
/// Normalize path separators for cross-platform safety and removes platform-specific extended prefixes.
pub fn strip_verbatim_prefix(path: PathBuf) -> PathBuf {
    let s = path.to_string_lossy();
    let mut cleaned = s.replace('\\', "/");
    if let Some(rest) = cleaned.strip_prefix("//?/") {
        cleaned = rest.to_string();
    } else if let Some(rest) = cleaned.strip_prefix("//./") {
        cleaned = rest.to_string();
    } else if let Some(rest) = cleaned.strip_prefix(r"\\?\") {
        cleaned = rest.replace('\\', "/");
    } else if let Some(rest) = cleaned.strip_prefix(r"\\.\") {
        cleaned = rest.replace('\\', "/");
    }
    if cleaned.len() >= 3 && cleaned.as_bytes()[0] == b'/' && cleaned.as_bytes()[2] == b':' {
        cleaned = cleaned[1..].to_string();
    }
    if cfg!(windows) {
        PathBuf::from(cleaned.replace('/', "\\"))
    } else {
        PathBuf::from(cleaned)
    }
}

/// Canonicalizes a path to its absolute form and cleans up Windows extended-length prefixes.
///
/// Falls back to joining relative paths with the current working directory if canonicalization fails.
pub fn canonicalize_path(path: &Path) -> PathBuf {
    let raw = std::fs::canonicalize(path).unwrap_or_else(|_| {
        if path.is_absolute() {
            path.to_path_buf()
        } else {
            std::env::current_dir()
                .map(|cwd| cwd.join(path))
                .unwrap_or_else(|_| path.to_path_buf())
        }
    });
    strip_verbatim_prefix(raw)
}

/// Resolves a path relative to a root directory and returns a canonicalized absolute path.
pub fn absolute_join(root: &Path, path: &Path) -> PathBuf {
    let joined = if path.is_absolute() {
        path.to_path_buf()
    } else {
        root.join(path)
    };
    canonicalize_path(&joined)
}

/// Ensures the `.dcr` internal metadata directory and its `ide` subfolder exist within a project root.
pub fn ensure_dcr_dir(root: &Path) -> io::Result<PathBuf> {
    let dcr = root.join(".dcr");
    std::fs::create_dir_all(dcr.join("ide"))?;
    Ok(dcr)
}

/// Writes `contents` via a `.tmp.<pid>` file then renames into place (best-effort replace).
///
/// # Parameters
/// - `path`: Final destination path (parent dirs are created).
/// - `contents`: Bytes to write.
///
/// # Returns
/// `Ok(())` after a successful rename (with fallbacks if the target already exists).
pub fn atomic_write(path: &Path, contents: impl AsRef<[u8]>) -> io::Result<()> {
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent)?;
    }
    let pid = std::process::id();
    let file_name = path
        .file_name()
        .map(|n| n.to_string_lossy().into_owned())
        .unwrap_or_else(|| "tmp".to_string());
    let tmp = path.with_file_name(format!("{file_name}.tmp.{pid}"));
    {
        let mut f = std::fs::File::create(&tmp)?;
        f.write_all(contents.as_ref())?;
        let _ = f.sync_all();
    }
    match std::fs::rename(&tmp, path) {
        Ok(()) => Ok(()),
        Err(e) => {
            // Handle cross-device moves or existing file replacement fallbacks.
            if path.exists() {
                let _ = std::fs::remove_file(path);
                match std::fs::rename(&tmp, path) {
                    Ok(()) => Ok(()),
                    Err(e2) => {
                        let _ = std::fs::remove_file(&tmp);
                        Err(e2)
                    }
                }
            } else {
                let _ = std::fs::remove_file(&tmp);
                Err(e)
            }
        }
    }
}

/// Ensures that `.gitignore` exists in the project directory and includes the `.dcr/` output rule.
///
/// If `.gitignore` does not exist, creates one with standard `/target` and `.dcr/` entries.
pub fn ensure_gitignore_has_dcr(project_dir: &Path) -> io::Result<()> {
    let path = project_dir.join(".gitignore");
    if path.exists() {
        let content = std::fs::read_to_string(&path)?;
        let has = content.lines().any(|l| {
            let t = l.trim();
            t == ".dcr/" || t == ".dcr" || t == "/.dcr/" || t == "/.dcr"
        });
        if !has {
            let mut new_content = content;
            if !new_content.ends_with('\n') && !new_content.is_empty() {
                new_content.push('\n');
            }
            new_content.push_str(".dcr/\n");
            std::fs::write(&path, new_content)?;
        }
    } else {
        std::fs::write(&path, "/target\n.dcr/\n")?;
    }
    Ok(())
}

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

    #[test]
    fn canonicalize_path_absolute() {
        let cwd = std::env::current_dir().unwrap();
        let got = canonicalize_path(&cwd);
        assert!(got.is_absolute());
    }

    #[test]
    fn canonicalize_path_relative_fallback() {
        let p = Path::new("does-not-exist-xyz-12345");
        let got = canonicalize_path(p);
        assert!(got.is_absolute());
    }

    #[test]
    fn atomic_write_roundtrip() {
        let dir = std::env::temp_dir().join(format!("dcr_atomic_{}", std::process::id()));
        let _ = fs::remove_dir_all(&dir);
        fs::create_dir_all(&dir).unwrap();
        let path = dir.join("out.json");
        atomic_write(&path, b"hello").unwrap();
        assert_eq!(fs::read_to_string(&path).unwrap(), "hello");
        atomic_write(&path, b"world").unwrap();
        assert_eq!(fs::read_to_string(&path).unwrap(), "world");
        let _ = fs::remove_dir_all(&dir);
    }

    #[test]
    fn ensure_dcr_dir_creates_ide() {
        let dir = std::env::temp_dir().join(format!("dcr_dir_{}", std::process::id()));
        let _ = fs::remove_dir_all(&dir);
        fs::create_dir_all(&dir).unwrap();
        let dcr = ensure_dcr_dir(&dir).unwrap();
        assert!(dcr.is_dir());
        assert!(dcr.join("ide").is_dir());
        let _ = fs::remove_dir_all(&dir);
    }
}