cargo-rahti 0.0.2

Create and maintain Rahti projects: cargo rahti new, cargo rahti upgrade.
//! `cargo rahti upgrade` — bring a project's scaffolded files up to date.
//!
//! The framework's own files drift: a bug is fixed in the root layout, the
//! client runtime gains a version, `build.rs` learns a new call. A project
//! scaffolded last year has the old ones and no way to tell which of its
//! files are still the scaffold's and which it has made its own.
//!
//! That is what the ledger in `rahti.config.json` answers. Every file is
//! classified by comparing the bytes on disk with the hash recorded when the
//! scaffold wrote it:
//!
//! - the hashes agree — nobody has touched it, so it is ours to replace;
//! - they disagree — the author edited it, so it is theirs and is left alone;
//! - the file is gone — the author deleted it, which is also an answer.
//!
//! Only the first is written to. An upgrade that cannot tell these apart has
//! to choose between never fixing anything and overwriting work, and both are
//! the wrong answer.

use std::fs;
use std::path::{Path, PathBuf};

use rahti_build::{Backend, sha256};

use crate::VERSION;
use crate::new::{Ledger, project_files, write_config};

/// Dependency versions are cargo's business, not the scaffold's.
///
/// Rewriting this would undo whatever the author added, and — for a project
/// created with `--local` — would replace a working path dependency with a
/// version that may not be published. It stays in the ledger, because knowing
/// whether it is pristine is still worth something, but it is never written.
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) {
            // Known to the ledger and still on disk: whose is it?
            (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));
                }
            }
            // Recorded, but gone. Deleting a file is a decision, and putting
            // it back is not this command's to make.
            (Some(_), None) => plan.deleted.push(path.clone()),
            // Not recorded: a file a newer Rahti adds to the project. If
            // something is already there under that name it is not ours.
            (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}"))?;
        }
    }

    // Written even when nothing else was, because `createdWith` moves with
    // the tool that last touched the project.
    write_config(&root, config.tailwind, config.db, config.ws, &ledger)?;

    report(&plan, &config, false);
    Ok(())
}

// --------------------------------------------------------------------- plan

#[derive(Default)]
struct Plan {
    /// Ours, out of date, rewritten.
    updated: Vec<String>,
    /// New in this version of Rahti, created.
    added: Vec<String>,
    /// Ours and already current.
    current: Vec<String>,
    /// The author's, left alone.
    yours: Vec<String>,
    /// Recorded but no longer on disk.
    deleted: Vec<String>,
    /// Ours and out of date, but never rewritten by policy.
    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()
        );
    }

    // The one thing an upgrade cannot do for you.
    //
    // `Cargo.toml` is never rewritten — dependencies are the author's, and a
    // `--local` project would have its path dependency replaced by a version
    // that may not be published. So a project whose config says it has a
    // database but whose manifest does not is left with files that will not
    // compile, and the only useful thing to do about it is say so precisely.
    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()
        );
        // Pinned to the major version the scaffold writes and the convention
        // documentation describes. An unpinned `cargo add` would take whatever
        // is newest, which is how a project ends up on an ORM its docs do not
        // match.
        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()
        );
    }

    // The same shape as the SeaORM note: the config says WebSockets, the
    // manifest does not carry the feature, and the manifest is never
    // rewritten — so say precisely what to add.
    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
        );
    }
}

// ------------------------------------------------------------------- config

/// What `upgrade` needs from the project, gathered in one place.
struct Config {
    /// The cargo package name, which the templates put in the page title.
    /// Read from `Cargo.toml`, which is where a package's name lives — the
    /// scaffold does not keep a second copy to disagree with it.
    name: String,
    tailwind: bool,
    /// The backend `rahti.config.json` records, so an upgrade regenerates the
    /// files this project actually has rather than the ones a default project
    /// would.
    db: Option<Backend>,
    /// Whether `Cargo.toml` already has SeaORM in it. Read rather than
    /// assumed, because this is the one thing an upgrade cannot fix: the
    /// manifest is never rewritten, so a project that gained a database by
    /// hand-editing its config has to be told what is missing.
    has_sea_orm: bool,
    /// `"ws": true` in the config: this project uses WebSockets.
    ws: bool,
    /// Whether the `rahti` line in `Cargo.toml` names the `ws` feature —
    /// checked for the same reason as `has_sea_orm`.
    has_ws_feature: bool,
    /// A `path` dependency on a Rahti checkout, recovered from `Cargo.toml`
    /// so a project made with `--local` stays that way.
    local: Option<String>,
    ledger: Ledger,
    /// `createdWith`, for the closing note.
    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}"))?;

        // The same refusal the build makes, for the same reason: a newer
        // schema may mean something different by a key this version reads.
        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\"."
                ));
            }
            // A `db` object with no backend named is SQLite, the same reading
            // the build makes. No `db` object at all is no database.
            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(),
        })
    }
}

/// `name = "..."` from the `[package]` table.
///
/// Read by hand: pulling in a TOML parser to find one string in a file cargo
/// has already validated is a poor trade.
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
}

/// The checkout a `--local` project points at, so an upgrade regenerates the
/// same path dependency instead of a published version that may not exist.
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;