modde-cli 0.2.1

CLI interface for modde
use std::collections::BTreeMap;
use std::fmt::Write as _;
use std::fs;
use std::io::{self, Write};
use std::path::Path;

use anyhow::{Context, Result};
use modde_games::registry::GAME_REGISTRY;
use modde_games::tools::{ToolSelectOption, ToolSettingKind, all_tools};

#[derive(Debug, Clone, PartialEq)]
enum SchemaType {
    Bool,
    TriStateBool,
    Int,
    Float,
    Text,
    Path,
    Enum(Vec<String>),
}

#[derive(Debug, Clone, PartialEq)]
struct ExportedSettingSpec {
    ty: SchemaType,
    description: String,
    advanced: bool,
    default: Option<String>,
    min: Option<f64>,
    max: Option<f64>,
    step: Option<f64>,
}

pub fn handle_export(out_path: &Path) -> Result<()> {
    let rendered = render_schema();

    if out_path == Path::new("-") {
        let mut stdout = io::stdout().lock();
        stdout
            .write_all(rendered.as_bytes())
            .context("failed to write Nix tool schema to stdout")?;
        return Ok(());
    }

    let out_dir = out_path.parent().unwrap_or_else(|| Path::new("."));
    if let Some(parent) = out_path.parent()
        && !parent.as_os_str().is_empty()
    {
        fs::create_dir_all(parent).with_context(|| {
            format!(
                "failed to create parent directory for {}",
                out_path.display()
            )
        })?;
    }

    fs::write(out_path, rendered)
        .with_context(|| format!("failed to write {}", out_path.display()))?;

    write_auxiliary_exports(out_dir)?;
    Ok(())
}

fn write_auxiliary_exports(out_dir: &Path) -> Result<()> {
    let optiscaler_profiles_path = out_dir.join("optiscaler-profiles.nix");
    let release_supporting_tools_path = out_dir.join("release-supporting-tools.nix");
    fs::write(&optiscaler_profiles_path, render_optiscaler_profiles())
        .with_context(|| format!("failed to write {}", optiscaler_profiles_path.display()))?;
    fs::write(
        &release_supporting_tools_path,
        render_release_supporting_tools(),
    )
    .with_context(|| {
        format!(
            "failed to write {}",
            release_supporting_tools_path.display()
        )
    })?;
    Ok(())
}

fn render_schema() -> String {
    let tool_specs: BTreeMap<String, BTreeMap<String, ExportedSettingSpec>> = all_tools()
        .iter()
        .map(|tool| {
            let settings = tool
                .settings_schema()
                .into_iter()
                .filter_map(export_setting_spec)
                .map(|(key, spec)| (key.to_string(), spec))
                .collect();
            (tool.tool_id().to_string(), settings)
        })
        .collect();

    let mut out = String::new();
    out.push_str("# Generated by `modde dev export-tool-schema`. Do not edit by hand.\n");
    out.push_str("{\n");

    for (tool_id, specs) in &tool_specs {
        let _ = writeln!(out, "  {} = {{", render_ident(tool_id));
        for (key, spec) in specs {
            let _ = writeln!(out, "    {} = {{", render_ident(key));
            let _ = writeln!(
                out,
                "      type = {};",
                render_string(schema_type_name(&spec.ty))
            );
            let _ = writeln!(
                out,
                "      default = {};",
                render_default(spec.default.as_deref())
            );
            let _ = writeln!(
                out,
                "      description = {};",
                render_string(&spec.description)
            );
            let _ = writeln!(
                out,
                "      advanced = {};",
                if spec.advanced { "true" } else { "false" }
            );
            if let SchemaType::Enum(values) = &spec.ty {
                let joined = values
                    .iter()
                    .map(|value| render_string(value))
                    .collect::<Vec<_>>()
                    .join(" ");
                let _ = writeln!(out, "      values = [ {joined} ];");
            }
            if let Some(min) = spec.min {
                let _ = writeln!(out, "      min = {};", render_number(min));
            }
            if let Some(max) = spec.max {
                let _ = writeln!(out, "      max = {};", render_number(max));
            }
            if let Some(step) = spec.step {
                let _ = writeln!(out, "      step = {};", render_number(step));
            }
            out.push_str("    };\n");
        }
        out.push_str("  };\n");
    }

    out.push_str("}\n");
    out
}

fn render_optiscaler_profiles() -> String {
    let mut profiles = BTreeMap::<String, Vec<String>>::new();
    for game in GAME_REGISTRY {
        let ids = game
            .optiscaler_profiles
            .iter()
            .map(|profile| profile.id.to_string())
            .collect::<Vec<_>>();
        if !ids.is_empty() {
            profiles.insert(game.game_id.to_string(), ids);
        }
    }

    let mut out = String::new();
    out.push_str("# Generated by `modde dev export-tool-schema`. Do not edit by hand.\n");
    out.push_str("{\n");
    for (game_id, profile_ids) in profiles {
        let _ = writeln!(out, "  {} = [", render_ident(&game_id));
        for profile_id in profile_ids {
            let _ = writeln!(out, "    {}", render_string(&profile_id));
        }
        out.push_str("  ];\n");
    }
    out.push_str("}\n");
    out
}

fn render_release_supporting_tools() -> String {
    let mut tool_ids = all_tools()
        .iter()
        .filter(|tool| tool.supports_releases())
        .map(|tool| tool.tool_id().to_string())
        .collect::<Vec<_>>();
    tool_ids.sort();

    let mut out = String::new();
    out.push_str("# Generated by `modde dev export-tool-schema`. Do not edit by hand.\n");
    out.push_str("[\n");
    for tool_id in tool_ids {
        let _ = writeln!(out, "  {}", render_string(&tool_id));
    }
    out.push_str("]\n");
    out
}

fn export_setting_spec(
    spec: modde_games::tools::ToolSettingSpec,
) -> Option<(&'static str, ExportedSettingSpec)> {
    if should_skip_setting(spec.key) {
        return None;
    }

    let (ty, min, max, step) = match spec.kind {
        ToolSettingKind::Bool => (SchemaType::Bool, None, None, None),
        ToolSettingKind::TriStateBool => (SchemaType::TriStateBool, None, None, None),
        ToolSettingKind::Text => (SchemaType::Text, None, None, None),
        ToolSettingKind::Path => (SchemaType::Path, None, None, None),
        ToolSettingKind::Select { options } => (
            SchemaType::Enum(render_select_values(&options)),
            None,
            None,
            None,
        ),
        ToolSettingKind::Number { min, max, step } => (
            infer_number_type(min, max, step),
            Some(min),
            Some(max),
            Some(step),
        ),
        ToolSettingKind::ReadOnly => return None,
    };

    Some((
        spec.key,
        ExportedSettingSpec {
            ty,
            description: spec.description.to_string(),
            advanced: spec.advanced,
            default: None,
            min,
            max,
            step,
        },
    ))
}

fn should_skip_setting(key: &str) -> bool {
    key == "optiscaler_profile" || key.starts_with('_') || key.starts_with("release_")
}

fn render_select_values(options: &[ToolSelectOption]) -> Vec<String> {
    options.iter().map(|option| option.value.clone()).collect()
}

fn infer_number_type(min: f64, max: f64, step: f64) -> SchemaType {
    if is_integral_number(min) && is_integral_number(max) && is_integral_number(step) {
        SchemaType::Int
    } else {
        SchemaType::Float
    }
}

fn is_integral_number(value: f64) -> bool {
    value.fract() == 0.0
}

fn schema_type_name(schema_type: &SchemaType) -> &'static str {
    match schema_type {
        SchemaType::Bool => "bool",
        SchemaType::TriStateBool => "tri_state_bool",
        SchemaType::Int => "int",
        SchemaType::Float => "float",
        SchemaType::Text => "text",
        SchemaType::Path => "path",
        SchemaType::Enum(_) => "enum",
    }
}

fn render_default(default: Option<&str>) -> String {
    match default {
        Some(value) => render_string(value),
        None => "null".to_string(),
    }
}

fn render_ident(value: &str) -> String {
    render_string(value)
}

fn render_string(value: &str) -> String {
    let escaped = value
        .replace('\\', "\\\\")
        .replace('"', "\\\"")
        .replace('\n', "\\n")
        .replace('\r', "\\r")
        .replace('\t', "\\t");
    format!("\"{escaped}\"")
}

fn render_number(value: f64) -> String {
    if is_integral_number(value) {
        format!("{value:.0}")
    } else {
        let mut rendered = format!("{value}");
        if !rendered.contains('.') && !rendered.contains('e') && !rendered.contains('E') {
            rendered.push_str(".0");
        }
        rendered
    }
}

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

    #[test]
    fn exporter_is_deterministic() {
        assert_eq!(render_schema(), render_schema());
    }

    #[test]
    fn exporter_skips_reserved_and_read_only_keys() {
        let schema = render_schema();
        assert!(!schema.contains("\"_game_id\""));
        assert!(!schema.contains("\"release_tag\""));
        assert!(!schema.contains("\"optiscaler_profile\""));
        assert!(!schema.contains("\"wrapper\""));
        assert!(!schema.contains("\"derived_executable_dir\""));
    }
}