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\""));
}
}