cargo-rahti 0.0.8

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
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
//! `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.
//!
//! The scaffold's questions do not end at `new`: a project that started
//! without a database or WebSockets can gain either here. A flag — `--db`,
//! `--ws`, the same spellings `new` takes — says yes outright; an interactive
//! run is asked about whatever the project does not have, defaulting to no; a
//! dry run changes nothing and so asks nothing. The feature's files are
//! written, the choice is recorded in `rahti.config.json`, and the two files
//! that are never regenerated — `Cargo.toml` and `.env` — are amended in
//! place by the smallest edit that makes the new files compile and run: see
//! `wiring.rs`, which is also where the reason a printed instruction is not
//! good enough is written down. The CSS engine is deliberately not offered:
//! switching it rewrites an authored stylesheet, which is a migration, not an
//! addition.

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;

/// Dependency versions are cargo's business, not the scaffold's.
///
/// Regenerating this from the template 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 the template's copy is never written over it.
///
/// Not the same thing as never being touched: a feature this run added needs
/// a dependency, and `wiring` appends that one line without disturbing the
/// rest of the file.
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)?;

    // Features are added the way `new` adds them: a flag says yes and skips
    // the question, an interactive run is asked about whatever the project
    // does not have, and anywhere with nobody to answer takes the default —
    // no. A dry run never asks: it changes nothing, so it has no question to
    // put; flags still count, so `--dry-run --ws` previews the addition.
    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) {
            // 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 {
        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}"))?;
        }
    }

    // After the files, because this is what makes them compile: `src/db.rs`
    // was just written and it opens with `use sea_orm::…`.
    let wiring = wire(&root, &config, false)?;

    // A manifest that was the scaffold's before this run is still the
    // scaffold's after it: the dependency added above was added by this
    // command, and leaving the old hash in the ledger would have the next run
    // read its own work as an edit of the author's and say so.
    //
    // A manifest the author had already changed keeps its recorded hash and
    // stays theirs, which is the answer it was before.
    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));
    }

    // Written even when nothing else was, because `createdWith` moves with
    // the tool that last touched the project.
    rewrite_config(&root, &ledger, added_db, added_ws)?;

    report(&plan, &config, &wiring, false, added_db, added_ws);
    Ok(())
}

// ------------------------------------------------------------------- wiring

/// What the manifest and `.env` needed to match the config, and what could
/// not be done.
#[derive(Default)]
struct Wiring {
    /// Dependency lines added to `[dependencies]`, as written.
    dependencies: Vec<String>,
    /// The `ws` feature folded into the `rahti` dependency.
    ws_feature: bool,
    /// The files `DATABASE_URL` was added to.
    env: Vec<String>,
    /// The amended manifest, when there was one to amend. Handed back so the
    /// ledger can record it: the edit was the scaffold's, and a hash left
    /// describing the file as it was would have the next run report this
    /// command's own work as the author's.
    manifest: Option<Vec<u8>>,
    /// What the author has to do by hand, and why.
    manual: Vec<Manual>,
}

/// One thing this could not do, with the line that does it.
struct Manual {
    reason: String,
    line: String,
}

impl Wiring {
    fn is_empty(&self) -> bool {
        self.dependencies.is_empty() && !self.ws_feature && self.env.is_empty()
    }
}

/// Bring `Cargo.toml` and `.env` up to what `rahti.config.json` says.
///
/// Driven by the config rather than by what this run added, so it repairs as
/// well as completes: a project whose config already says `db` and whose
/// manifest has no `sea-orm` — the state an earlier version of this command
/// left behind, or a hand-edited config — is fixed by running `upgrade`
/// again. Every edit is skipped when it is already there, so that is safe to
/// do any number of times.
///
/// A dry run computes all of this and writes none of it.
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());
    }

    // Only files that are there. A `.env` the author deleted is a decision —
    // the same one an absent scaffold file records — and a `.env.example`
    // invented here would carry a connection string and none of the session
    // settings a clone also has to fill in.
    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(&current, 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)
}

// ------------------------------------------------------------------ options

/// What the command line said.
///
/// The feature flags are the ones `new` takes, with the same meaning: each
/// adds something, and leaving it out is how you say no — the question an
/// interactive run asks defaults the same way.
struct Options {
    dry_run: bool,
    /// `--db [backend]`: add a database, if the project has none.
    db: Option<Backend>,
    /// `--ws`: add WebSockets, if the project does not have them.
    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,
                // A bare `--db` is SQLite, the same short form `new` has.
                "--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 })
    }
}

// ------------------------------------------------------------------- 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,
    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}"))?;

    // 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 = 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}"))
}

/// The keys a feature added this run writes into the config, in the shape
/// `new` writes them.
///
/// Inserted immediately above `"scaffold"` — the one key guaranteed to be
/// there, since `Config::read` refused the project without it — so the rest
/// of the file stays the author's, byte for byte. JSON does not care about
/// the order, and a reader finds the keys beside the ledger that ships them.
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,
    }
}

/// 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,
    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");
    }

    // The files that were amended rather than replaced, named by the one
    // thing that changed in each. A line here is why the project compiles
    // after gaining a feature, so it is reported beside the writes and not
    // as a footnote.
    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!();

    // What could not be wired, with the line that does it. Printed in both
    // modes: a dry run that hid this would promise an upgrade that lands
    // cleanly and then not deliver one.
    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()
        );
        // A feature previewed by flag has to be named again to land: the
        // apply run is a separate process with no memory of this one.
        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()
        );
    }

    // The manifest changed under a build that has already run, and cargo will
    // not have noticed on its own.
    if !wiring.is_empty() {
        println!("  Cargo.toml or .env changed, so the next build fetches what is new:\n");
        println!("    cargo check\n");
    }

    // A pristine `src/main.rs` was just rewritten to connect at startup; an
    // edited one was left alone, and without these lines the database files
    // written above are not even compiled.
    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"
        );
    }

    // A backend that needs a server has to be pointed at one. The line the
    // wiring wrote is an example, and for anything but SQLite it is a wrong
    // one until somebody edits it.
    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
        );
    }
}

// ------------------------------------------------------------------- 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>,
    /// `"ws": true` in the config: this project uses WebSockets.
    ws: 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,
            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(),
        })
    }
}

/// `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;