use std::fs;
use std::path::{Path, PathBuf};
use rahti_build::{Backend, sha256};
use crate::VERSION;
use crate::new::{Ledger, backend_of, project_files};
use crate::prompt::{choose, confirm};
use crate::templates as t;
use crate::wiring;
const NEVER_REWRITTEN: [&str; 1] = ["Cargo.toml"];
pub fn run(args: &[&str]) -> Result<(), String> {
let options = Options::parse(args)?;
let root = PathBuf::from(".");
let mut config = Config::read(&root)?;
let added_db = match (config.db, options.db) {
(Some(_), _) => None,
(None, Some(backend)) => Some(backend),
(None, None) if !options.dry_run && confirm("Add a database?") => Some(backend_of(
&choose("Which one?", &["sqlite", "postgres", "mysql"]),
)?),
(None, None) => None,
};
if added_db.is_some() {
config.db = added_db;
}
let added_ws = !config.ws && (options.ws || (!options.dry_run && confirm("Add WebSockets?")));
if added_ws {
config.ws = true;
}
let dry_run = options.dry_run;
let files = project_files(
&config.name,
config.tailwind,
config.db,
config.ws,
config.local.as_deref(),
);
let mut plan = Plan::default();
let mut ledger = config.ledger.clone();
for (path, wanted) in &files {
let full = root.join(path);
let recorded = config.ledger.get(path);
let actual = fs::read(&full).ok();
match (recorded, actual) {
(Some(hash), Some(bytes)) => {
if &sha256::hex(&bytes) != hash {
plan.yours.push(path.clone());
} else if bytes == *wanted {
plan.current.push(path.clone());
} else if NEVER_REWRITTEN.contains(&path.as_str()) {
plan.skipped.push(path.clone());
} else {
plan.updated.push(path.clone());
ledger.insert(path.clone(), sha256::hex(wanted));
}
}
(Some(_), None) => plan.deleted.push(path.clone()),
(None, actual) => {
if actual.is_some() {
plan.yours.push(path.clone());
} else if NEVER_REWRITTEN.contains(&path.as_str()) {
plan.skipped.push(path.clone());
} else {
plan.added.push(path.clone());
ledger.insert(path.clone(), sha256::hex(wanted));
}
}
}
}
if dry_run {
let wiring = wire(&root, &config, true)?;
report(&plan, &config, &wiring, true, added_db, added_ws);
return Ok(());
}
for (path, contents) in &files {
if plan.updated.contains(path) || plan.added.contains(path) {
let full = root.join(path);
if let Some(parent) = full.parent() {
fs::create_dir_all(parent)
.map_err(|e| format!("cannot create {}: {e}", parent.display()))?;
}
fs::write(&full, contents).map_err(|e| format!("cannot write {path}: {e}"))?;
}
}
let wiring = wire(&root, &config, false)?;
let pristine = plan.current.contains(&"Cargo.toml".to_string())
|| plan.skipped.contains(&"Cargo.toml".to_string());
if let Some(bytes) = &wiring.manifest
&& pristine
{
ledger.insert("Cargo.toml".to_string(), sha256::hex(bytes));
}
rewrite_config(&root, &ledger, added_db, added_ws)?;
report(&plan, &config, &wiring, false, added_db, added_ws);
Ok(())
}
#[derive(Default)]
struct Wiring {
dependencies: Vec<String>,
ws_feature: bool,
env: Vec<String>,
manifest: Option<Vec<u8>>,
manual: Vec<Manual>,
}
struct Manual {
reason: String,
line: String,
}
impl Wiring {
fn is_empty(&self) -> bool {
self.dependencies.is_empty() && !self.ws_feature && self.env.is_empty()
}
}
fn wire(root: &Path, config: &Config, dry_run: bool) -> Result<Wiring, String> {
let mut wiring = Wiring::default();
let path = root.join("Cargo.toml");
let original = fs::read_to_string(&path).map_err(|e| format!("cannot read Cargo.toml: {e}"))?;
let mut manifest = original.clone();
if let Some(backend) = config.db {
for (name, line) in t::sea_orm_dependencies(backend) {
if let Some(amended) = wiring::with_dependency(&manifest, name, &line) {
manifest = amended;
wiring.dependencies.push(line);
}
}
}
if config.ws {
match wiring::with_ws_feature(&manifest) {
Ok(Some(amended)) => {
manifest = amended;
wiring.ws_feature = true;
}
Ok(None) => {}
Err(reason) => wiring.manual.push(Manual {
reason,
line: "rahti = { version = \"…\", features = [\"ws\"] }".to_string(),
}),
}
}
if manifest != original {
if !dry_run {
fs::write(&path, &manifest).map_err(|e| format!("cannot write Cargo.toml: {e}"))?;
}
wiring.manifest = Some(manifest.into_bytes());
}
if let Some(backend) = config.db {
for name in [".env", ".env.example"] {
let full = root.join(name);
let Ok(current) = fs::read_to_string(&full) else {
continue;
};
if let Some(amended) = wiring::with_database_url(¤t, backend) {
if !dry_run {
fs::write(&full, amended).map_err(|e| format!("cannot write {name}: {e}"))?;
}
wiring.env.push(name.to_string());
}
}
if !root.join(".env").exists() {
wiring.manual.push(Manual {
reason: "this project has no `.env`".to_string(),
line: format!("DATABASE_URL=\"{}\"", t::database_url(backend)),
});
}
}
Ok(wiring)
}
struct Options {
dry_run: bool,
db: Option<Backend>,
ws: bool,
}
impl Options {
fn parse(args: &[&str]) -> Result<Self, String> {
let mut dry_run = false;
let mut db: Option<Backend> = None;
let mut ws = false;
let mut rest = args.iter();
while let Some(arg) = rest.next() {
match *arg {
"--dry-run" | "-n" => dry_run = true,
"--ws" => ws = true,
"--db" => {
let named = rest.clone().next().filter(|a| !a.starts_with('-'));
match named {
Some(name) => {
rest.next();
db = Some(backend_of(name)?);
}
None => db = Some(Backend::Sqlite),
}
}
other => return Err(format!("`{other}` is not an option of `upgrade`.")),
}
}
Ok(Options { dry_run, db, ws })
}
}
fn rewrite_config(
root: &Path,
ledger: &Ledger,
added_db: Option<Backend>,
added_ws: bool,
) -> Result<(), String> {
let path = root.join("rahti.config.json");
let raw =
fs::read_to_string(&path).map_err(|e| format!("cannot read rahti.config.json: {e}"))?;
let raw = replace_string_value(&raw, "createdWith", VERSION).unwrap_or(raw);
let raw = record_features(raw, added_db, added_ws);
let raw = replace_scaffold(&raw, ledger)?;
fs::write(&path, raw).map_err(|e| format!("cannot write rahti.config.json: {e}"))
}
fn record_features(raw: String, added_db: Option<Backend>, added_ws: bool) -> String {
let mut block = String::new();
if added_ws {
block.push_str("\"ws\": true,\n\n ");
}
if let Some(backend) = added_db {
block.push_str(&format!(
"\"db\": {{\n \"backend\": \"{}\",\n \"models\": \"src/models\",\n \
\"migrations\": \"src/migrations\"\n }},\n\n ",
backend.label()
));
}
if block.is_empty() {
return raw;
}
match raw.find("\"scaffold\"") {
Some(at) => format!("{}{block}{}", &raw[..at], &raw[at..]),
None => raw,
}
}
fn replace_string_value(raw: &str, key: &str, value: &str) -> Option<String> {
let needle = format!("\"{key}\"");
let at = raw.find(&needle)?;
let after = at + needle.len();
let colon = after + raw[after..].find(':')?;
let open = colon + raw[colon..].find('"')?;
let close = open + 1 + raw[open + 1..].find('"')?;
Some(format!("{}\"{value}\"{}", &raw[..open], &raw[close + 1..]))
}
fn replace_scaffold(raw: &str, ledger: &Ledger) -> Result<String, String> {
let missing = || {
"rahti.config.json has no scaffold ledger to rewrite.\n \
Was the file edited while the upgrade ran?"
.to_string()
};
let at = raw.find("\"scaffold\"").ok_or_else(missing)?;
let open = at + raw[at..].find('{').ok_or_else(missing)?;
let close = open + raw[open..].find('}').ok_or_else(missing)?;
let mut block = String::from("{\n");
let mut entries = ledger.iter().peekable();
while let Some((path, hash)) = entries.next() {
let comma = if entries.peek().is_some() { "," } else { "" };
block.push_str(&format!(" \"{path}\": \"{hash}\"{comma}\n"));
}
block.push_str(" }");
Ok(format!("{}{block}{}", &raw[..open], &raw[close + 1..]))
}
#[derive(Default)]
struct Plan {
updated: Vec<String>,
added: Vec<String>,
current: Vec<String>,
yours: Vec<String>,
deleted: Vec<String>,
skipped: Vec<String>,
}
fn report(
plan: &Plan,
config: &Config,
wiring: &Wiring,
dry_run: bool,
added_db: Option<Backend>,
added_ws: bool,
) {
fn verb<'a>(dry_run: bool, past: &'a str, future: &'a str) -> &'a str {
if dry_run { future } else { past }
}
let verb = |past, future| verb(dry_run, past, future);
println!();
for path in &plan.updated {
println!(" {} {path}", verb("updated", "would update"));
}
for path in &plan.added {
println!(" {} {path}", verb("added", "would add"));
}
for path in &plan.yours {
println!(" kept {path} — yours, left as it is");
}
for path in &plan.deleted {
println!(" absent {path} — you removed it, so it stays removed");
}
for path in &plan.skipped {
println!(" skipped {path} — the template's copy is not yours to take");
}
for line in &wiring.dependencies {
let name = line.split_whitespace().next().unwrap_or(line);
println!(" {} Cargo.toml — {name}", verb("wired ", "would wire"));
}
if wiring.ws_feature {
println!(
" {} Cargo.toml — the `ws` feature on rahti",
verb("wired ", "would wire")
);
}
for name in &wiring.env {
println!(" {} {name} — DATABASE_URL", verb("wired ", "would wire"));
}
let changed = plan.updated.len() + plan.added.len();
println!();
for item in &wiring.manual {
println!(
" {}, so this is yours to add:\n\n {}\n",
item.reason, item.line
);
}
if dry_run {
println!(
" {changed} file(s) would change, {} already current, {} yours.\n",
plan.current.len(),
plan.yours.len()
);
let mut apply = String::from("cargo rahti upgrade");
if let Some(backend) = added_db {
apply.push_str(&format!(" --db {}", backend.label()));
}
if added_ws {
apply.push_str(" --ws");
}
println!(" Run `{apply}` to apply.\n");
return;
}
if changed == 0 {
println!(" Already up to date with cargo-rahti {VERSION}.\n");
} else {
println!(" Upgraded to cargo-rahti {VERSION} — {changed} file(s) changed.\n");
}
if !plan.yours.is_empty() {
println!(
" {} file(s) you had edited were left alone. If a page misbehaves\n \
after this, compare them against a fresh `cargo rahti new`.\n",
plan.yours.len()
);
}
if !wiring.is_empty() {
println!(" Cargo.toml or .env changed, so the next build fetches what is new:\n");
println!(" cargo check\n");
}
if added_db.is_some() && plan.yours.contains(&"src/main.rs".to_string()) {
println!(
" src/main.rs is yours, so wire the database in yourself: declare\n \
`mod db;`, `mod migrations;` and `mod models;`, and call\n \
`db::connect().await` at the top of `main` — see\n \
docs/conventions/database.md.\n"
);
}
if let Some(backend) = added_db
&& backend != Backend::Sqlite
&& wiring.env.iter().any(|name| name == ".env")
{
println!(
" DATABASE_URL in `.env` is an example. Point it at your {} server\n \
before the next run.\n",
backend.label()
);
}
if config.from != VERSION {
println!(
" This project was created with cargo-rahti {}.\n",
config.from
);
}
}
struct Config {
name: String,
tailwind: bool,
db: Option<Backend>,
ws: bool,
local: Option<String>,
ledger: Ledger,
from: String,
}
impl Config {
fn read(root: &Path) -> Result<Self, String> {
let path = root.join("rahti.config.json");
let raw = fs::read_to_string(&path).map_err(|e| {
format!(
"no rahti.config.json here ({e}).\n \
Run this from the root of a Rahti project."
)
})?;
let value: serde_json::Value = serde_json::from_str(&raw)
.map_err(|e| format!("rahti.config.json is not valid JSON: {e}"))?;
if let Some(schema) = value.get("schema").and_then(|v| v.as_i64())
&& schema > 1
{
return Err(format!(
"this project is written for config schema {schema}, and this \
cargo-rahti understands 1.\n \
Update it with `cargo install cargo-rahti`."
));
}
let tailwind = value
.get("css")
.and_then(|c| c.get("engine"))
.and_then(|e| e.as_str())
.map(|e| e == "tailwind")
.unwrap_or(true);
let ledger: Ledger = value
.get("scaffold")
.and_then(|s| s.as_object())
.map(|table| {
table
.iter()
.filter_map(|(k, v)| Some((k.clone(), v.as_str()?.to_string())))
.collect()
})
.unwrap_or_default();
if ledger.is_empty() {
return Err(
"rahti.config.json records no scaffolded files, so there is nothing \
this can safely replace.\n \
A project created before the ledger existed has to be upgraded by hand."
.to_string(),
);
}
let manifest = fs::read_to_string(root.join("Cargo.toml"))
.map_err(|e| format!("cannot read Cargo.toml: {e}"))?;
let db = match value
.get("db")
.and_then(|d| d.get("backend"))
.and_then(|b| b.as_str())
{
Some("sqlite") => Some(Backend::Sqlite),
Some("postgres") => Some(Backend::Postgres),
Some("mysql") => Some(Backend::MySql),
Some(other) => {
return Err(format!(
"rahti.config.json names `{other}` as db.backend, which is not \
a backend.\n \
Use \"sqlite\", \"postgres\" or \"mysql\"."
));
}
None if value.get("db").is_some_and(|d| d.is_object()) => Some(Backend::Sqlite),
None => None,
};
Ok(Config {
name: package_name(&manifest)
.ok_or("Cargo.toml has no [package] name")?
.to_string(),
tailwind,
db,
ws: value.get("ws").and_then(|v| v.as_bool()).unwrap_or(false),
local: local_checkout(&manifest),
ledger,
from: value
.get("createdWith")
.and_then(|v| v.as_str())
.unwrap_or("an unknown version")
.to_string(),
})
}
}
fn package_name(manifest: &str) -> Option<&str> {
let mut in_package = false;
for line in manifest.lines() {
let line = line.trim();
if line.starts_with('[') {
in_package = line == "[package]";
continue;
}
if in_package && let Some(rest) = line.strip_prefix("name") {
return rest.split('"').nth(1);
}
}
None
}
fn local_checkout(manifest: &str) -> Option<String> {
let line = manifest
.lines()
.find(|l| l.trim_start().starts_with("rahti ="))?;
let path = line.split("path = \"").nth(1)?.split('"').next()?;
path.strip_suffix("/crates/rahti").map(str::to_string)
}
#[cfg(test)]
#[path = "tests/upgrade.rs"]
mod tests;