cargo-rahti 0.0.4

Create and maintain Rahti projects: cargo rahti new, cargo rahti upgrade.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
//! `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};

/// 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.
    rewrite_config(&root, &ledger)?;

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

// ------------------------------------------------------------------- rewrite

/// Update `rahti.config.json` in place, touching only what the upgrade owns.
///
/// The config has two authors, and almost all of it belongs to the other one:
/// the port, the app directory, a pinned CLI hash, `minify` — settings this
/// command never asked about and has no answer for. `new` writes the whole
/// file because there is nothing to preserve yet; an upgrade that did the
/// same would reset every one of those to the scaffold's defaults, quietly
/// undoing configuration in the name of refreshing files. So the file's text
/// is kept as it stands, and the two keys the scaffold owns — `createdWith`
/// and the ledger — are replaced where they sit.
fn rewrite_config(root: &Path, ledger: &Ledger) -> 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}"))?;

    // A config that never had the key — hand-written, most likely — is left
    // without one rather than having a key invented into someone else's file.
    let raw = replace_string_value(&raw, "createdWith", VERSION).unwrap_or(raw);
    let raw = replace_scaffold(&raw, ledger)?;

    fs::write(&path, raw).map_err(|e| format!("cannot write rahti.config.json: {e}"))
}

/// The string value of `key` replaced with `value`, everything else untouched.
///
/// `None` when the key is not in the file. Found by text rather than parsed
/// and re-serialized, because a parser round trip rewrites the whole file —
/// reordering keys and dropping blank lines — and the point of this function
/// is that the file stays the author's.
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..]))
}

/// The `scaffold` object replaced with the ledger as it now stands.
///
/// The object holds only string values, so the first `}` after the opening
/// brace closes it — there is nothing nested to skip. The replacement is
/// written in the shape `new` writes, which keeps an untouched config
/// byte-identical across the round trip.
fn replace_scaffold(raw: &str, ledger: &Ledger) -> Result<String, String> {
    // Unreachable in practice: `Config::read` refused the project already if
    // the ledger was missing. Kept as an error rather than a panic because
    // the file may have changed between that read and this write.
    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..]))
}

// --------------------------------------------------------------------- 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.4\", 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;