gdsuite 0.1.3

Workflow CLI for Godot projects: installs and coordinates gdtoolkit, gdtest, gdhealth and gdtypecheck
use std::{fs, path::Path};

use anyhow::{Context, Result, anyhow};
use globwalk::GlobWalkerBuilder;

use crate::{config::GdsuiteConfig, tooling::ProjectContext};

pub fn migrate_project(context: &ProjectContext, cli_godot: Option<&Path>) -> Result<()> {
    context.ensure_dirs()?;
    let (config, warnings) = migrate_suite_config(context, cli_godot)?;
    rewrite_entrypoints(context)?;
    for warning in warnings {
        println!("warning: {warning}");
    }
    println!("migrate complete");
    config.write(&context.config_path)?;
    Ok(())
}

fn migrate_suite_config(
    context: &ProjectContext,
    cli_godot: Option<&Path>,
) -> Result<(GdsuiteConfig, Vec<String>)> {
    let body = if context.config_path.exists() {
        fs::read_to_string(&context.config_path)
            .with_context(|| format!("failed to read {}", context.config_path.display()))?
    } else {
        String::new()
    };
    let value = if body.trim().is_empty() {
        toml::Value::Table(Default::default())
    } else {
        body.parse()
            .with_context(|| format!("failed to parse {}", context.config_path.display()))?
    };

    let mut config = GdsuiteConfig::default();
    let warnings = collect_migration_warnings(context, &value);

    if let Some(project) = value.get("project").and_then(|value| value.as_table()) {
        if let Some(root) = project.get("root").and_then(|value| value.as_str()) {
            config.project.root = root.to_string();
        }
        if let Some(godot_exe) = project.get("godot_exe").and_then(|value| value.as_str()) {
            config.project.godot_exe = Some(godot_exe.into());
        }
    }
    if let Some(tools) = value.get("tools").and_then(|value| value.as_table()) {
        if let Some(version) = tools.get("gdtoolkit").and_then(|value| value.as_str()) {
            config.tools.gdtoolkit = version.to_string();
        }
        if let Some(source) = tools.get("gdtest_source").and_then(|value| value.as_str()) {
            config.tools.gdtest_source = source.into();
        }
        if let Some(source) = tools
            .get("gdhealth_source")
            .and_then(|value| value.as_str())
        {
            config.tools.gdhealth_source = source.into();
        }
        if let Some(source) = tools
            .get("gdtypecheck_source")
            .and_then(|value| value.as_str())
        {
            config.tools.gdtypecheck_source = source.into();
        }
    }
    if let Some(commands) = value.get("commands").and_then(|value| value.as_table()) {
        if let Some(target) = commands
            .get("default_test_target")
            .and_then(|value| value.as_str())
        {
            config.commands.default_test_target = target.to_string();
        }
        if let Some(subcommand) = commands
            .get("default_health_subcommand")
            .and_then(|value| value.as_str())
        {
            config.commands.default_health_subcommand = subcommand.to_string();
        }
    }
    if let Some(reports) = value.get("reports").and_then(|value| value.as_table()) {
        if let Some(default) = reports.get("default").and_then(|value| value.as_str()) {
            config.reports.default = default.to_string();
        }
        if let Some(dir) = reports
            .get("artifacts_dir")
            .and_then(|value| value.as_str())
        {
            config.reports.artifacts_dir = dir.into();
        }
    }
    if let Some(check) = value.get("check").and_then(|value| value.as_table())
        && let Some(steps) = check.get("steps")
    {
        config.check.steps = steps.clone().try_into().map_err(|_| {
            anyhow!(
                "failed to parse [check].steps in {}",
                context.config_path.display()
            )
        })?;
    }

    if let Some(path) = cli_godot {
        config.project.godot_exe = Some(path.to_path_buf());
    }

    Ok((config, warnings))
}

fn collect_migration_warnings(context: &ProjectContext, value: &toml::Value) -> Vec<String> {
    let mut warnings = Vec::new();
    let Some(table) = value.as_table() else {
        return warnings;
    };

    if table.contains_key("lint") {
        warnings.push(
            "Moved out of suite contract: create `gdlintrc` with your lint rules.".to_string(),
        );
    }
    if table.contains_key("gdtest") {
        warnings.push(
            "Moved out of suite contract: create `gdtest.toml` with your gdtest settings."
                .to_string(),
        );
    }
    if table.contains_key("gdhealth") {
        warnings.push(
            "Moved out of suite contract: create `gdhealth.toml` with your gdhealth settings."
                .to_string(),
        );
    }
    if table.contains_key("health") {
        warnings.push(
            "Moved out of suite contract: remove suite-owned health settings and use gdhealth native config or explicit CLI args.".to_string(),
        );
    }
    if table.contains_key("fix") {
        warnings
            .push("Moved out of suite contract: suite-owned lint fixers were removed.".to_string());
    }
    if table.contains_key("typecheck") {
        warnings.push(
            "Moved out of suite contract: install and configure the delegated typecheck tool directly.".to_string(),
        );
    }
    if !path_exists_any(context, &["gdformatrc", ".gdformatrc"]) {
        warnings.push(
            "Moved out of suite contract: create `gdformatrc` if you need formatter settings."
                .to_string(),
        );
    }
    warnings
}

fn path_exists_any(context: &ProjectContext, paths: &[&str]) -> bool {
    paths.iter().any(|path| context.root.join(path).exists())
}

fn rewrite_entrypoints(context: &ProjectContext) -> Result<()> {
    rewrite_globbed_files(
        context,
        &[".github/workflows/**/*.yml", ".github/workflows/**/*.yaml"],
    )?;
    rewrite_globbed_files(context, &["README.md", "docs/**/*.md"])?;
    Ok(())
}

fn rewrite_globbed_files(context: &ProjectContext, patterns: &[&str]) -> Result<()> {
    let walker = GlobWalkerBuilder::from_patterns(&context.root, patterns)
        .follow_links(false)
        .build()
        .context("failed to build migration file search")?;

    for entry in walker.filter_map(Result::ok) {
        let path = entry.path();
        let original = fs::read_to_string(path)
            .with_context(|| format!("failed to read {}", path.display()))?;
        let updated = if path
            .extension()
            .and_then(|ext| ext.to_str())
            .is_some_and(|ext| ext.eq_ignore_ascii_case("md"))
        {
            rewrite_markdown_entrypoints(&original)
        } else {
            rewrite_command_lines(&original)
        };
        if updated != original {
            fs::write(path, updated)
                .with_context(|| format!("failed to write {}", path.display()))?;
        }
    }

    Ok(())
}

fn rewrite_markdown_entrypoints(contents: &str) -> String {
    let mut output = Vec::new();
    let mut in_code_block = false;

    for line in contents.lines() {
        let trimmed = line.trim_start();
        if trimmed.starts_with("```") {
            in_code_block = !in_code_block;
            output.push(line.to_string());
            continue;
        }

        let rewritten = if in_code_block || looks_like_command_line(trimmed) {
            rewrite_command_lines(line)
        } else {
            rewrite_inline_code_spans(line)
        };
        output.push(rewritten);
    }

    let mut body = output.join("\n");
    if contents.ends_with('\n') {
        body.push('\n');
    }
    body
}

fn rewrite_inline_code_spans(line: &str) -> String {
    line.replace("`gdhealth health`", "`gdsuite health`")
        .replace("`gdtest`", "`gdsuite test`")
}

fn rewrite_command_lines(line: &str) -> String {
    let after_gdhealth = rewrite_command(line, &["gdhealth", "health"], &["gdsuite", "health"]);
    rewrite_command(&after_gdhealth, &["gdtest"], &["gdsuite", "test"])
}

fn rewrite_command(contents: &str, source: &[&str], target: &[&str]) -> String {
    let source_text = source.join(" ");
    let target_text = target.join(" ");
    let mut output = String::with_capacity(contents.len());
    let mut index = 0usize;

    while index < contents.len() {
        if contents[index..].starts_with(&source_text)
            && command_boundary(contents, index, source_text.len())
        {
            output.push_str(&target_text);
            index += source_text.len();
        } else {
            let ch = contents[index..].chars().next().unwrap();
            output.push(ch);
            index += ch.len_utf8();
        }
    }

    output
}

fn looks_like_command_line(trimmed: &str) -> bool {
    trimmed.starts_with("gdtest ")
        || trimmed == "gdtest"
        || trimmed.starts_with("gdhealth ")
        || trimmed.starts_with("- run:")
        || trimmed.starts_with("run:")
        || trimmed.starts_with("$ ")
}

fn command_boundary(text: &str, start: usize, len: usize) -> bool {
    let prev = text[..start].chars().next_back();
    let next = text[start + len..].chars().next();
    is_command_left_boundary(prev) && is_command_right_boundary(next)
}

fn is_command_left_boundary(ch: Option<char>) -> bool {
    ch.is_none_or(|byte| !is_command_name_char(byte))
}

fn is_command_right_boundary(ch: Option<char>) -> bool {
    ch.is_none_or(|byte| !is_command_name_char(byte))
}

fn is_command_name_char(byte: char) -> bool {
    byte.is_ascii_alphanumeric() || matches!(byte, '/' | '_' | '-' | '.')
}