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(),
}
}
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")?;
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());
}
}