use std::collections::BTreeSet;
use std::fs;
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use inquire::Confirm;
use serde_json::json;
use crate::catalog::quality_checks::{ci_workflow, render_check};
use crate::catalog::tool_settings::{self, SettingSpec};
use crate::catalog::wally_packages;
use crate::config::{PackageWorkflow, ProjectConfig};
use crate::steps::{gitignore, quality, testez, vscode};
use crate::ui;
struct Rewrite {
relative: String,
contents: String,
reason: &'static str,
creating: bool,
}
pub fn run(assume_yes: bool) -> Result<()> {
let project_dir = std::env::current_dir().context("failed to read current directory")?;
if !project_dir.join("default.project.json").exists() {
bail!(
"no default.project.json here - `rproj upgrade` updates an existing project, \
run it from inside one"
);
}
let Some(project) = ProjectConfig::load_from(&project_dir)? else {
bail!(
"no rproj.toml here - `rproj upgrade` needs the package list it records to know \
what this project's config should say. Projects scaffolded by `rproj new` have one"
);
};
let packages: BTreeSet<String> = project.packages.iter().cloned().collect();
let workflow = project.package_workflow;
let testez_selected = packages.contains("testez");
let rewrites = plan(&project_dir, &project, &packages, workflow, testez_selected)?;
if rewrites.is_empty() {
ui::ok("already up to date - every generated file matches this version of rproj");
} else {
println!("\nThese generated files would change:\n");
for rewrite in &rewrites {
let verb = if rewrite.creating { "create" } else { "update" };
println!(" {verb} {}", rewrite.relative);
ui::detail(rewrite.reason);
}
println!(
"\nNot touched: stylua.toml, default.project.json, wally.toml, rokit.toml, src/.\n\
Your own selene lint levels are kept; only std, mixed_table and exclude are set.\n"
);
if !assume_yes && !Confirm::new("Apply these changes?").with_default(true).prompt()? {
ui::skip("nothing written");
return Ok(());
}
ui::section(&format!("Upgrading {}", project_dir.display()));
for rewrite in &rewrites {
let path = project_dir.join(&rewrite.relative);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(&path, &rewrite.contents)
.with_context(|| format!("failed to write {}", path.display()))?;
ui::ok(&format!("wrote {}", rewrite.relative));
}
}
gitignore::ensure_entries(&project_dir)?;
quality::ensure_luaurc(&project_dir)?;
if testez_selected {
testez::ensure_tests_luaurc(&project_dir)?;
}
Ok(())
}
fn plan(
project_dir: &Path,
project: &ProjectConfig,
packages: &BTreeSet<String>,
workflow: PackageWorkflow,
testez_selected: bool,
) -> Result<Vec<Rewrite>> {
let mut rewrites = Vec::new();
if let Some(contents) = selene_config(project_dir, packages, workflow, testez_selected)? {
push(&mut rewrites, project_dir, "selene.toml", contents, "std, mixed_table and exclude follow this project's packages and workflow")?;
}
let settings = vscode::merged_settings(project_dir, &vscode::project_settings(workflow))?;
push(&mut rewrites, project_dir, ".vscode/settings.json", settings, "editor settings rproj manages; your other keys are kept")?;
if let Some(contents) = render_check(&project.tools_at_creation, testez_selected) {
push(&mut rewrites, project_dir, ".lute/check.luau", contents, "the generated quality gate")?;
let has_server_packages =
workflow == PackageWorkflow::Wally && wally_packages::has_server_realm(packages);
push(&mut rewrites, project_dir, ".github/workflows/ci.yml", ci_workflow(workflow, has_server_packages), "the generated CI workflow")?;
}
if testez_selected {
push(&mut rewrites, project_dir, "testez.yml", testez::TESTEZ_STD.to_string(), "selene's TestEZ standard library")?;
push(&mut rewrites, project_dir, "testez-companion.toml", testez::companion_config(), "TestEZ Companion's test roots")?;
}
Ok(rewrites)
}
fn selene_config(
project_dir: &Path,
packages: &BTreeSet<String>,
workflow: PackageWorkflow,
testez_selected: bool,
) -> Result<Option<String>> {
let path = project_dir.join("selene.toml");
let Ok(existing) = fs::read_to_string(&path) else {
return Ok(tool_settings::default_toml("selene", &overrides(packages, testez_selected))
.map(|config| tool_settings::insert_top_level(&config, vendored_exclude(workflow))));
};
let tool = tool_settings::find("selene").context("selene missing from the catalog")?;
let managed: Vec<(&SettingSpec, serde_json::Value)> = overrides(packages, testez_selected)
.into_iter()
.filter_map(|(key, value)| {
tool.settings.iter().find(|s| s.key == key).map(|s| (s, json!(value)))
})
.collect();
let mut updated = tool_settings::merge_toml(&existing, &managed);
if !existing.lines().any(|line| line.trim_start().starts_with("exclude")) {
updated = tool_settings::insert_top_level(&updated, vendored_exclude(workflow));
}
Ok((updated != existing).then_some(updated))
}
fn overrides(packages: &BTreeSet<String>, testez_selected: bool) -> Vec<(&'static str, &'static str)> {
let mut overrides = vec![("std", if testez_selected { "roblox+testez" } else { "roblox" })];
if wally_packages::allows_mixed_tables(packages) {
overrides.push(("mixed_table", "allow"));
}
overrides
}
fn vendored_exclude(workflow: PackageWorkflow) -> &'static str {
match workflow {
PackageWorkflow::Wally => r#"exclude = ["Packages/**", "ServerPackages/**"]"#,
PackageWorkflow::GitSubmodules => r#"exclude = ["modules/submodules/**"]"#,
}
}
fn push(
rewrites: &mut Vec<Rewrite>,
project_dir: &Path,
relative: &str,
contents: String,
reason: &'static str,
) -> Result<()> {
let path: PathBuf = project_dir.join(relative);
let current = fs::read_to_string(&path).ok();
if current.as_deref() == Some(contents.as_str()) {
return Ok(());
}
rewrites.push(Rewrite {
relative: relative.to_string(),
contents,
reason,
creating: current.is_none(),
});
Ok(())
}