rproj 0.20.1

Guided bootstrap-to-game-dev CLI for Roblox: takes a fresh Windows PC to a working Roblox/Luau setup, then scaffolds projects on it
//! `rproj configure [key]` - configure a project tool or route to the
//! machine-wide Rojo project template editor.
//!
//! Entirely driven by `catalog::tool_settings`: this module knows how to
//! render a `SettingSpec` and how to write the two `ConfigTarget` kinds,
//! and nothing about any specific tool or setting.

use std::path::Path;

use anyhow::{Context, Result, ensure};
use inquire::{Confirm, CustomType, Select};
use serde_json::{Value, json};

use crate::catalog::tool_settings::{
    self, CONFIGURABLE_TOOLS, ConfigTarget, ConfigurableTool, SettingKind, SettingSpec,
};
use crate::ui;

mod editor;
mod session;
pub(super) use editor::open_in;
use session::EditSession;

pub fn run(key: Option<&str>) -> Result<()> {
    if key == Some("project") {
        return super::project_template::run();
    }
    let project_dir = std::env::current_dir()?;

    let tool = match key {
        Some(key) => tool_settings::find(key).with_context(|| {
            format!(
                "no configurable tool called `{key}`. Available: {}",
                CONFIGURABLE_TOOLS
                    .iter()
                    .map(|t| t.key)
                    .chain(std::iter::once("project"))
                    .collect::<Vec<_>>()
                    .join(", ")
            )
        })?,
        None => match pick_target()? {
            Some(tool) => tool,
            None => return super::project_template::run(),
        },
    };

    configure_tool(&project_dir, tool)
}

pub(super) fn run_tools_in(project_dir: &Path) -> Result<()> {
    let options: Vec<&str> = CONFIGURABLE_TOOLS.iter().map(|tool| tool.key).collect();
    let key = Select::new("Configure which tool?", options).prompt()?;
    let tool = tool_settings::find(key).context("selected tool is not configurable")?;
    configure_tool(project_dir, tool)
}

fn configure_tool(project_dir: &Path, tool: &'static ConfigurableTool) -> Result<()> {
    println!(
        "\n{} - {}\n{}\n",
        tool.display_name, tool.summary, tool.docs_url
    );
    println!(
        "Enter keeps what this project already uses. Settings are written to {}.\n",
        target_description(&tool.target)
    );

    let mut session = EditSession::load(project_dir, tool)?;
    for (index, setting) in tool.settings.iter().enumerate() {
        if let Some(value) = ask(setting, session.current[index].as_ref())? {
            session.set(index, value);
        }
    }
    if session.save()? {
        ui::ok(&format!("wrote {}", target_description(&tool.target)));
    } else {
        ui::ok("no settings changed");
    }
    Ok(())
}

fn pick_target() -> Result<Option<&'static ConfigurableTool>> {
    let mut options: Vec<String> = CONFIGURABLE_TOOLS
        .iter()
        .map(|t| format!("{}{}{}", t.key, ui::OPTION_SEPARATOR, t.display_name))
        .collect();
    options.push(format!(
        "project{}Default project tree for future projects",
        ui::OPTION_SEPARATOR
    ));
    let picked = Select::new("What do you want to configure?", options)
        .with_formatter(&ui::compact_select_answer)
        .prompt()?;
    let key = ui::option_key(&picked);
    if key == "project" {
        Ok(None)
    } else {
        Ok(Some(
            tool_settings::find(key).context("internal: picked an unknown tool")?,
        ))
    }
}

fn target_description(target: &ConfigTarget) -> String {
    match target {
        ConfigTarget::ProjectToml { filename } => (*filename).to_string(),
        ConfigTarget::VsCodeSettings => ".vscode/settings.json".to_string(),
    }
}

/// Prompts for one setting. The description is printed above the prompt
/// rather than crammed into it, so a long explanation stays readable and
/// the question line itself remains short.
///
/// `current` is what the project's config file already says, and takes
/// precedence over the catalog default - a walkthrough that proposes
/// reverting your own settings is worse than no walkthrough.
fn ask(setting: &SettingSpec, current: Option<&Value>) -> Result<Option<Value>> {
    crate::diagnostics::event("prompt.setting", setting.display_key());
    println!("{}\n  {}", setting.display_key(), setting.description);

    let current = if current.is_some_and(|value| !can_prompt(&setting.kind, value)) {
        println!("  The current value is outside this guided editor's supported choices.");
        if !Confirm::new("  Replace the existing value?")
            .with_default(false)
            .prompt()?
        {
            crate::diagnostics::event("setting.kept", setting.display_key());
            println!();
            return Ok(None);
        }
        None
    } else {
        current
    };

    let value = match &setting.kind {
        SettingKind::Bool { default } => {
            let default = current.and_then(Value::as_bool).unwrap_or(*default);
            json!(Confirm::new("  Enable?").with_default(default).prompt()?)
        }
        SettingKind::Integer { default } => {
            let default = current.and_then(Value::as_i64).unwrap_or(*default);
            json!(
                CustomType::<i64>::new("  Value:")
                    .with_default(default)
                    .prompt()?
            )
        }
        SettingKind::Choice { default, options } => {
            let labels: Vec<String> = options
                .iter()
                .map(|o| format!("{}{}{}", o.value, ui::OPTION_SEPARATOR, o.explanation))
                .collect();
            let selected = current.and_then(Value::as_str).unwrap_or(default);
            let start = options
                .iter()
                .position(|o| o.value == selected)
                .or_else(|| options.iter().position(|o| o.value == *default))
                .unwrap_or(0);
            let picked = Select::new("  Value:", labels)
                .with_starting_cursor(start)
                .with_formatter(&ui::compact_select_answer)
                .prompt()?;
            json!(ui::option_key(&picked))
        }
    };

    crate::diagnostics::event(
        "choice.setting",
        format!("{}={value}", setting.display_key()),
    );
    println!();
    Ok(Some(value))
}

fn can_prompt(kind: &SettingKind, value: &Value) -> bool {
    match kind {
        SettingKind::Bool { .. } => value.is_boolean(),
        SettingKind::Integer { .. } => value.as_i64().is_some(),
        SettingKind::Choice { options, .. } => options
            .iter()
            .any(|option| value.as_str() == Some(option.value)),
    }
}

fn checked_toml_merge(existing: &str, answers: &[(&SettingSpec, Value)]) -> Result<String> {
    let mut expected = toml::from_str::<toml::Table>(existing)
        .context("could not parse current TOML; fix it and re-run configure")?;
    for (setting, value) in answers {
        let table = match setting.section {
            Some(section) => expected
                .entry(section)
                .or_insert_with(|| toml::Value::Table(toml::Table::new()))
                .as_table_mut()
                .with_context(|| format!("{section} is not a TOML table"))?,
            None => &mut expected,
        };
        table.insert(setting.key.into(), toml::Value::try_from(value)?);
    }
    let merged = tool_settings::merge_toml(existing, answers);
    let actual = toml::from_str::<toml::Table>(&merged)
        .context("cannot safely edit this TOML layout; edit the file manually")?;
    // The legacy line writer must not change content inside multiline values or other tables.
    ensure!(
        actual == expected,
        "cannot safely edit this TOML layout without changing other values; edit the file manually"
    );
    Ok(merged)
}

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

    #[test]
    fn prompt_support_is_typed_and_does_not_treat_unknown_values_as_missing() {
        let boolean = SettingKind::Bool { default: true };
        let integer = SettingKind::Integer { default: 4 };
        assert!(can_prompt(&boolean, &json!(false)));
        assert!(!can_prompt(&boolean, &json!("false")));
        assert!(can_prompt(&integer, &json!(i64::MAX)));
        for value in [json!(4.5), json!(u64::MAX), Value::Null, json!([4])] {
            assert!(!can_prompt(&integer, &value));
        }
        let selene = tool_settings::find("selene").unwrap();
        assert!(can_prompt(&selene.settings[0].kind, &json!("roblox")));
        assert!(!can_prompt(
            &selene.settings[0].kind,
            &json!("roblox+custom")
        ));
        for value in ["['custom']", "{ future = true }", "nan", "1979-05-27"] {
            let current = tool_settings::current_toml_values(selene, &format!("std = {value}"));
            assert!(current[0].is_some(), "{value} must not become absent");
        }
    }

    #[test]
    fn merge_validation_rejects_valid_toml_with_unintended_semantic_changes() {
        let selene = tool_settings::find("selene").unwrap();
        let setting = &selene.settings[0];
        let existing = "std = \"roblox\"\nnotes = '''\nstd = \"example\"\n'''\n";
        let answers = [(setting, json!("roblox+testez"))];
        let unchecked = tool_settings::merge_toml(existing, &answers);
        assert!(toml::from_str::<toml::Table>(&unchecked).is_ok());
        assert!(checked_toml_merge(existing, &answers).is_err());
        assert!(checked_toml_merge("std = \"roblox\"\n", &answers).is_ok());
    }
}