metrowrap 0.2.1

A mwcc wrapper
Documentation
// SPDX-FileCopyrightText: © 2026 TTKB, LLC
// SPDX-License-Identifier: BSD-3-CLAUSE
use std::fs;
use std::path::{Component, Path, PathBuf};

pub struct MakeRule {
    pub target: String,
    pub source: Option<String>,
    pub includes: Vec<String>,
}

impl MakeRule {
    pub fn new(data: &[u8], use_wibo: bool) -> Result<Self, Box<dyn std::error::Error>> {
        // We assume dependency files generated by MWCC only contain ASCII/UTF-8 compatible paths.
        // from_utf8_lossy handles this cleanly without external encoding dependencies.
        let raw_str = String::from_utf8_lossy(data);
        let rule_str = raw_str.replace("\\\n", " ").replace("\\\r\n", " ");

        let parts: Vec<&str> = rule_str.splitn(2, ": ").collect();

        if parts.len() < 2 {
            return Err("No valid dependency list".into());
        }

        let target = path_from_wibo(parts[0]).to_string_lossy().to_string();
        let remaining = parts[1];

        let mut files: Vec<String> = remaining
            .split_whitespace()
            .map(|s| s.to_string())
            .collect();

        if use_wibo {
            files = files
                .into_iter()
                .map(|p| path_from_wibo(&p).to_string_lossy().to_string())
                .collect();
        }

        let source = if !files.is_empty() {
            Some(files.remove(0))
        } else {
            None
        };

        Ok(MakeRule {
            target,
            source,
            includes: files,
        })
    }

    pub fn as_str(&self) -> String {
        let mut rule = format!("{}: ", self.target);
        if let Some(src) = &self.source {
            rule.push_str(src);
            rule.push(' ');
        }
        for include in &self.includes {
            rule.push_str(&format!("\\\n\t{} ", include));
        }
        rule.push('\n');
        rule
    }
}

pub fn path_from_wibo(path_str: &str) -> PathBuf {
    let normalized = normalize_wibo_prefix(path_str);
    let path = PathBuf::from(&normalized);
    if path.is_file() {
        return path;
    }
    resolve_actual_path(&path)
}

fn normalize_wibo_prefix(path_str: &str) -> String {
    let mut normalized = path_str.replace('\\', "/");
    if normalized.starts_with("//?/") {
        normalized = normalized[4..].to_string();
    }
    if normalized.to_lowercase().starts_with("z:/") {
        normalized = normalized[2..].to_string();
    }
    normalized
}

fn resolve_actual_path(path: &Path) -> PathBuf {
    let mut resolved_path = PathBuf::new();
    for component in path.components() {
        match component {
            Component::RootDir => resolved_path.push("/"),
            Component::Normal(os_name) => {
                let actual_name = resolve_component(&resolved_path, os_name.to_str().unwrap_or(""));
                resolved_path.push(actual_name);
            }
            Component::CurDir => {}
            Component::ParentDir => {
                resolved_path.pop();
            }
            Component::Prefix(_) => {
                // Should be handled by the drive letter logic above
            }
        }
    }
    resolved_path
}

fn resolve_component(base: &Path, name: &str) -> String {
    let exact_candidate = base.join(name);
    if exact_candidate.exists() {
        return name.to_string();
    }

    let search_dir = if base.as_os_str().is_empty() {
        Path::new(".")
    } else {
        base
    };

    let name_to_find = name.to_lowercase();
    if let Ok(entries) = fs::read_dir(search_dir) {
        for entry in entries.flatten() {
            if let Some(entry_name_str) = entry.file_name().to_str() {
                if entry_name_str.to_lowercase() == name_to_find {
                    return entry_name_str.to_string();
                }
            }
        }
    }

    // If no match found, use the original name and hope for the best
    // (it might be a file that hasn't been created yet)
    name.to_string()
}

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

    #[test]
    fn test_make_rule_parsing() {
        let data = b"target.o: source.c include.h";
        let rule = MakeRule::new(data, false).unwrap();

        assert_eq!(rule.target, "target.o");
        assert_eq!(rule.source, Some("source.c".to_string()));
        assert_eq!(rule.includes[0], "include.h");
    }

    #[test]
    fn test_no_dependencies() {
        let data = b"target.o: ";
        let rule = MakeRule::new(data, false).unwrap();

        assert_eq!(rule.target, "target.o");
        assert!(rule.includes.is_empty());
    }

    #[test]
    fn test_make_rule_empty() {
        let empty_data = b"";
        let rule = MakeRule::new(empty_data, false);
        assert!(rule.is_err());
    }

    #[test]
    fn test_make_rule_simple() {
        // Simulating a wibo-encoded dependency string
        let wibo_make_rule = b"Z:\\tmp\\test_dir\\result.o: test.c \r\n";

        let rule = MakeRule::new(wibo_make_rule, true).expect("Failed to parse wibo rule");

        // The path_from_wibo logic should convert Z:\ to /
        assert_eq!(rule.target, "/tmp/test_dir/result.o");
        assert_eq!(rule.source, Some("test.c".to_string()));
        assert!(rule.includes.is_empty());

        let output = rule.as_str();
        assert!(output.contains("/tmp/test_dir/result.o: test.c"));
    }

    #[test]
    fn test_make_rule_with_includes() {
        let wibo_make_rule = b"Z:\\tmp\\result.o: test2.c \\\r\n\tZ:\\home\\user\\decl.h \\\r\n\t\\\\?\\Z:\\home\\user\\lib.h \r\n";

        let rule = MakeRule::new(wibo_make_rule, true).expect("Failed to parse complex rule");

        assert_eq!(rule.source, Some("test2.c".to_string()));
        assert_eq!(rule.includes.len(), 2);
        assert_eq!(rule.includes[0], "/home/user/decl.h");
        assert_eq!(rule.includes[1], "/home/user/lib.h");
        assert_eq!(
            rule.as_str(),
            "/tmp/result.o: test2.c \\\n\t/home/user/decl.h \\\n\t/home/user/lib.h \n"
        );
    }

    #[test]
    fn test_unix_deps() {
        let unix_data = b"test.o: test.c test.h";
        let rule = MakeRule::new(unix_data, false).expect("Failed to parse unix rule");

        assert_eq!(rule.target, "test.o");
        assert_eq!(rule.includes[0], "test.h");
    }

    #[test]
    fn test_path_from_wibo_translation() {
        // Test the helper function directly
        assert_eq!(
            path_from_wibo("Z:\\home\\user\\file.c"),
            PathBuf::from("/home/user/file.c")
        );
        assert_eq!(
            path_from_wibo("\\\\?\\Z:\\home\\user\\file.c"),
            PathBuf::from("/home/user/file.c")
        );
        assert_eq!(
            path_from_wibo("relative\\path.h"),
            PathBuf::from("relative/path.h")
        );
    }

    #[test]
    fn test_path_from_wibo_case_insensitive() {
        // "src/makerule.rs" exists. Let's ask for "sRc/MaKeRule.RS"
        let path = path_from_wibo("sRc/MaKeRule.RS");
        // Because resolve_component searches the directory case-insensitively,
        // it should find "src" and then "makerule.rs" if they exist.
        // During tests, src/makerule.rs exists relative to cargo workspace.
        // However, on Mac/Windows, the OS is case-insensitive, so it matches early
        // and returns the original string. We verify it didn't crash.
        assert!(path.to_string_lossy().contains("akerule"));

        // If something completely non-existent is provided
        let bad_path = path_from_wibo("nOnExiStEnT/fIlE.c");
        assert_eq!(bad_path, PathBuf::from("nOnExiStEnT/fIlE.c"));
    }

    #[test]
    fn test_path_from_wibo_curdir() {
        // Test handling of '.' Component::CurDir
        let path = path_from_wibo("./src/./makerule.rs");
        // path.is_file() triggers true on test systems directly
        assert_eq!(path, PathBuf::from("./src/./makerule.rs"));
    }

    #[test]
    fn test_path_from_wibo_parentdir() {
        // Test handling of '..' Component::ParentDir
        let path = path_from_wibo("src/../src/makerule.rs");
        // Depending on existing files, this might resolve directly.
        assert_eq!(path, PathBuf::from("src/../src/makerule.rs"));
    }

    #[test]
    fn test_path_from_wibo_prefix() {
        // Component::Prefix is often captured by PathBuf on Windows when using C:
        // On Unix, it evaluates differently, but we shouldn't crash.
        let path = path_from_wibo("C:\\Test\\path");
        // Windows Prefix handling in components
        // "C:\Test\path" will have a prefix component on Windows, but on Mac/Linux
        // it will just be Component::Normal("C:").
        // To trigger a Prefix component reliably on Unix in `Path`, we can't easily,
        // but we ensure the code doesn't crash given Windows input formatting.
        assert!(
            path.to_string_lossy()
                .to_string()
                .to_lowercase()
                .contains("test")
        );
    }
}