esmeril 0.2.0

Scaffold, validate, format, audit and build Roblox Luau projects from the terminal
use std::path::Path;

use crate::template;

pub struct FixResult {
    pub created: Vec<String>,
    pub needs_attention: Vec<String>,
}

pub fn apply(root: &Path) -> anyhow::Result<FixResult> {
    let name = project_name(root);
    let lib = is_lib(root);
    let project_exists = root.join("default.project.json").exists();
    let files = template::files(&name, false, lib);

    let mut created = Vec::new();
    let mut needs_attention = Vec::new();
    for (rel, content) in &files {
        if project_exists && (rel == "default.project.json" || rel.starts_with("src/")) {
            continue;
        }
        let path = root.join(rel);
        if path.exists() {
            if config_invalid(&path) {
                needs_attention.push(rel.clone());
            }
            continue;
        }
        template::write_one(root, rel, content)?;
        created.push(rel.clone());
    }

    let mut paths = crate::check::project_paths(root);
    paths.sort();
    paths.dedup();
    for p in paths {
        let dir = root.join(&p);
        if !dir.is_dir() {
            let entry = format!("{p}/init.lua");
            template::write_one(root, &entry, "return {}\n")?;
            created.push(entry);
        }
    }

    Ok(FixResult {
        created,
        needs_attention,
    })
}

fn project_name(root: &Path) -> String {
    if let Ok(text) = std::fs::read_to_string(root.join("default.project.json"))
        && let Ok(value) = serde_json::from_str::<serde_json::Value>(&text)
        && let Some(name) = value.get("name").and_then(|n| n.as_str())
    {
        return name.to_string();
    }
    root.file_name()
        .and_then(|s| s.to_str())
        .unwrap_or("game")
        .to_string()
}

pub(crate) fn is_lib(root: &Path) -> bool {
    if let Ok(text) = std::fs::read_to_string(root.join("default.project.json"))
        && let Ok(value) = serde_json::from_str::<serde_json::Value>(&text)
        && let Some(tree) = value.get("tree").and_then(|t| t.as_object())
        && tree.len() == 1
        && tree.contains_key("$path")
    {
        return true;
    }
    root.join("src/init.lua").exists() && !root.join("src/server").exists()
}

fn config_invalid(path: &Path) -> bool {
    let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
    let Ok(text) = std::fs::read_to_string(path) else {
        return false;
    };
    match name {
        "default.project.json" | ".luaurc" => {
            serde_json::from_str::<serde_json::Value>(&text).is_err()
        }
        ".selene.toml" | "stylua.toml" | "aftman.toml" | "wally.toml" => {
            toml::from_str::<toml::Value>(&text).is_err()
        }
        _ => false,
    }
}

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

    fn temp_root(name: &str) -> PathBuf {
        let dir = std::env::temp_dir().join(format!("esmeril-fix-{name}-{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&dir);
        std::fs::create_dir_all(&dir).unwrap();
        dir
    }

    #[test]
    fn fix_upgrades_a_bare_dir_to_an_a_grade() {
        let dir = temp_root("bare");
        std::fs::write(dir.join("README.md"), "hello").unwrap();
        let fix = apply(&dir).unwrap();
        assert!(!fix.created.is_empty());
        assert!(fix.needs_attention.is_empty());
        let report = crate::check::inspect(&dir);
        assert_eq!(report.score, 100);
        assert_eq!(report.grade, 'A');
    }

    #[test]
    fn fix_creates_missing_path_dirs() {
        let dir = temp_root("paths");
        std::fs::write(
            dir.join("default.project.json"),
            r#"{"name": "g", "tree": { "a": { "$path": "src/custom" } }}"#,
        )
        .unwrap();
        let fix = apply(&dir).unwrap();
        assert!(fix.created.contains(&"src/custom/init.lua".to_string()));
        assert!(dir.join("src/custom/init.lua").exists());
    }

    #[test]
    fn fix_reports_invalid_configs_without_touching_them() {
        let dir = temp_root("invalid");
        std::fs::write(dir.join(".selene.toml"), "not toml =").unwrap();
        let fix = apply(&dir).unwrap();
        assert!(fix.needs_attention.contains(&".selene.toml".to_string()));
        assert!(!fix.created.contains(&".selene.toml".to_string()));
        assert_eq!(
            std::fs::read_to_string(dir.join(".selene.toml")).unwrap(),
            "not toml ="
        );
    }

    #[test]
    fn fix_is_idempotent() {
        let dir = temp_root("idem");
        apply(&dir).unwrap();
        let fix = apply(&dir).unwrap();
        assert!(fix.created.is_empty());
        assert!(fix.needs_attention.is_empty());
    }
}