use std::fs;
use std::path::{Path, PathBuf};
use rahti_build::{Backend, sha256};
use crate::VERSION;
use crate::new::{Ledger, project_files, write_config};
const NEVER_REWRITTEN: [&str; 1] = ["Cargo.toml"];
pub fn run(args: &[&str]) -> Result<(), String> {
let mut dry_run = false;
for arg in args {
match *arg {
"--dry-run" | "-n" => dry_run = true,
other => return Err(format!("`{other}` is not an option of `upgrade`.")),
}
}
let root = PathBuf::from(".");
let config = Config::read(&root)?;
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 {
report(&plan, &config, true);
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}"))?;
}
}
write_config(&root, config.tailwind, config.db, config.ws, &ledger)?;
report(&plan, &config, false);
Ok(())
}
#[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, dry_run: 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} — dependencies are yours to manage");
}
let changed = plan.updated.len() + plan.added.len();
println!();
if dry_run {
println!(
" {changed} file(s) would change, {} already current, {} yours.\n",
plan.current.len(),
plan.yours.len()
);
println!(" Run `cargo rahti upgrade` 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 let Some(backend) = config.db
&& !config.has_sea_orm
{
println!(
" This project is configured for {}, and Cargo.toml has no `sea-orm`.\n \
Dependencies stay yours, so add them:\n",
backend.label()
);
println!(
" cargo add sea-orm@2 --no-default-features \\\n \
--features macros,runtime-tokio-rustls,{}",
backend.feature()
);
println!(
" cargo add sea-orm-migration@2 --no-default-features \\\n \
--features runtime-tokio-rustls,{}\n",
backend.feature()
);
}
if config.ws && !config.has_ws_feature {
println!(
" This project is configured for WebSockets, and the `rahti` line in\n \
Cargo.toml does not name the `ws` feature. Dependencies stay yours,\n \
so add it:\n"
);
println!(" rahti = {{ version = \"0.0.2\", features = [\"ws\"] }}\n");
}
if config.from != VERSION {
println!(
" This project was created with cargo-rahti {}.\n",
config.from
);
}
}
struct Config {
name: String,
tailwind: bool,
db: Option<Backend>,
has_sea_orm: bool,
ws: bool,
has_ws_feature: 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,
has_sea_orm: manifest
.lines()
.any(|l| l.trim_start().starts_with("sea-orm")),
ws: value.get("ws").and_then(|v| v.as_bool()).unwrap_or(false),
has_ws_feature: manifest
.lines()
.find(|l| l.trim_start().starts_with("rahti ="))
.is_some_and(|l| l.contains("\"ws\"")),
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;