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
//! `cargo rahti new <name>` — write a project, and record what was written.
//!
//! The recording is the part that matters beyond today. Every file this
//! creates goes into `rahti.config.json` under `scaffold`, with its SHA-256
//! at the moment it was written. A later `upgrade` compares: a file whose
//! hash still matches is the scaffold's own and may be rewritten or removed
//! silently, and one whose hash has changed is the author's and is left alone
//! with a note. Without the ledger an upgrade has to choose between never
//! tidying anything up and destroying work, and both are wrong.

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

use rahti_build::{Backend, sha256};

use crate::VERSION;
use crate::prompt::{choose, confirm};
use crate::templates as t;

#[cfg(test)]
#[path = "tests/new.rs"]
mod tests;

/// The files written, in the order they were written, each with its hash.
///
/// Ordered rather than hashed so the config is byte-identical for the same
/// choices — a scaffold whose output reshuffles between runs makes every
/// diff unreadable and every test flaky.
pub type Ledger = BTreeMap<String, String>;

pub fn run(args: &[&str]) -> Result<(), String> {
    let options = Options::parse(args)?;

    let root = PathBuf::from(&options.name);
    if root.exists() {
        return Err(format!(
            "`{}` already exists. Choose another name, or remove it first.",
            options.name
        ));
    }

    // A flag only ever says yes, so it short-circuits the question. Without
    // it there is nothing to skip: an interactive run asks, and anything else
    // gets the answer that omitting the flag already means.
    let tailwind = options.tailwind || confirm("Use Tailwind CSS?");

    // Asked as one question rather than two — "a database?" then "which?" —
    // because the second answer is the whole of the first.
    let db = match options.db {
        Some(backend) => Some(backend),
        None if confirm("Use a database?") => Some(backend_of(&choose(
            "Which one?",
            &["sqlite", "postgres", "mysql"],
        ))?),
        None => None,
    };

    // WebSockets are a cargo feature of `rahti`, so the choice has to be made
    // while the manifest is being written — turning it on later is one line
    // in Cargo.toml, but the scaffold should not make anyone learn which.
    let ws = options.ws || confirm("Use WebSockets?");

    let mut ledger = Ledger::new();
    write_project(&root, &options, tailwind, db, ws, &mut ledger)?;
    write_config(&root, tailwind, db, ws, &ledger)?;

    report(&options.name, tailwind, db, ws);
    Ok(())
}

/// A backend by the name it is written by, in the config and on the flag.
/// Shared with `upgrade`, whose `--db` takes the same names.
pub(crate) fn backend_of(name: &str) -> Result<Backend, String> {
    match name {
        "sqlite" => Ok(Backend::Sqlite),
        "postgres" => Ok(Backend::Postgres),
        "mysql" => Ok(Backend::MySql),
        other => Err(format!(
            "`{other}` is not a backend. Use sqlite, postgres or mysql."
        )),
    }
}

/// Every file the scaffold owns, as bytes, without writing anything.
///
/// Produced rather than written so `upgrade` can diff against exactly what
/// `new` would have written today. Two copies of this list would drift, and
/// the drift would show up as an upgrade quietly skipping a file it should
/// have fixed.
pub fn project_files(
    name: &str,
    tailwind: bool,
    db: Option<Backend>,
    ws: bool,
    local: Option<&str>,
) -> Vec<(String, Vec<u8>)> {
    let rahti = match local {
        // A path dependency, for working on the framework itself. Absolute,
        // because the generated project is rarely a sibling of the checkout.
        Some(path) => format!(
            "{{ path = \"{}/crates/rahti\" }}",
            path.replace('\\', "/").trim_end_matches('/')
        ),
        // Pinned to all three components, not `"0.0"`. Under cargo's semver
        // rules every `0.0.x` is its own incompatible line, so `"0.0"` would
        // let a project drift onto a release the framework never promised it,
        // and the framework ships in lockstep.
        None => "\"0.0.8\"".to_string(),
    };

    let mut files: Vec<(String, Vec<u8>)> = vec![
        (
            "Cargo.toml".into(),
            t::cargo_toml(name, &rahti, db, ws).into_bytes(),
        ),
        ("build.rs".into(), t::BUILD_RS.into()),
        (".cargo/config.toml".into(), t::CARGO_CONFIG.into()),
        (".gitignore".into(), t::gitignore(db.is_some()).into_bytes()),
        ("src/main.rs".into(), t::main_rs(db.is_some()).into_bytes()),
        ("src/app/layout.rs".into(), t::layout_rs(name).into_bytes()),
        (
            "src/app/page.rs".into(),
            t::page_rs(name, tailwind).into_bytes(),
        ),
        (
            "src/app/globals.css".into(),
            if tailwind {
                t::GLOBALS_TAILWIND
            } else {
                t::GLOBALS_PLAIN
            }
            .into(),
        ),
        (
            "public/js/main.js".into(),
            t::main_js(tailwind).into_bytes(),
        ),
        (
            "public/js/pp-reactive-v2.min.js".into(),
            t::PP_RUNTIME.into(),
        ),
        ("public/favicon.ico".into(), t::FAVICON.into()),
        // The framework's own account of itself. Rahti is not in anyone's
        // training data, so a project without these files gives a coding
        // agent nothing to read — and the guide is for people just as much.
        (
            "AGENTS.md".into(),
            t::agents_md(name, db.is_some(), ws).into_bytes(),
        ),
        ("CLAUDE.md".into(), t::CLAUDE_MD.into()),
    ];

    for (doc, contents) in t::CORE_DOCS {
        files.push((format!("docs/conventions/{doc}"), contents.into()));
    }

    if ws {
        files.push((
            "docs/conventions/websockets.md".into(),
            t::DOC_WEBSOCKETS.into(),
        ));
    }

    if tailwind {
        files.push((
            "public/js/tailwind-merge.mjs".into(),
            t::TAILWIND_MERGE.into(),
        ));
    }

    if db.is_some() {
        files.push((
            "docs/conventions/database.md".into(),
            t::DOC_DATABASE.into(),
        ));
        files.push(("src/db.rs".into(), t::DB_RS.into()));
        files.push(("src/models/todo.rs".into(), t::MODEL_TODO_RS.into()));
        files.push((
            "src/migrations/m20260101_000001_create_todo.rs".into(),
            t::MIGRATION_TODO.into(),
        ));
    }

    // `.env` and `.env.example` are deliberately not here — see `UNTRACKED`.
    files
}

/// Files the scaffold creates but does not own afterwards.
///
/// The stylesheet is committed, so a clone with no Tailwind CLI still serves
/// CSS — which means it has to exist before the first build. From that build
/// onward it belongs to `rahti-build`, so it is deliberately kept out of the
/// ledger: a hash taken now is wrong by the time anyone could compare it, and
/// an upgrade would read that as "the author edited this" for the rest of the
/// project's life.
/// `.env` joins it for a different reason with the same shape: it is written
/// once so `cargo run` works immediately, and from then on it holds a real
/// connection string. Tracking it would have `upgrade` compare a hash against
/// somebody's credentials and report them as an edited scaffold file forever
/// — and, worse, would make `.env` a file the scaffold believes it may
/// rewrite.
///
/// `.env.example` is untracked for a third reason. It is committed, so it
/// looks like a scaffold file — but it carries this project's generated
/// `AUTH_COOKIE_NAME`, and the generator runs afresh on every call. Tracked,
/// `upgrade` would rewrite it with a *different* cookie name each time and
/// silently disagree with the `.env` beside it. Written once, it stays the
/// account of what this project's clones have to fill in.
const UNTRACKED: [(&str, &str); 1] = [("public/css/styles.css", "")];

/// Everything but the config, which needs the ledger this fills in.
fn write_project(
    root: &Path,
    options: &Options,
    tailwind: bool,
    db: Option<Backend>,
    ws: bool,
    ledger: &mut Ledger,
) -> Result<(), String> {
    for (path, contents) in project_files(&options.name, tailwind, db, ws, options.local.as_deref())
    {
        binary(root, &path, &contents, ledger)?;
    }

    let mut untracked: Vec<(String, Vec<u8>)> = UNTRACKED
        .iter()
        .map(|(path, contents)| ((*path).to_string(), contents.as_bytes().to_vec()))
        .collect();

    // Generated once and used for both files, so the cookie name in the
    // example is the cookie name the project actually runs on. The secret is
    // the one thing that differs: `.env` gets the real key, and the committed
    // example gets a placeholder the runtime knows to refuse.
    //
    // Written whether or not the project has a database: `AUTH_SECRET` is not
    // a database setting, and a project whose `.env` only appeared when you
    // chose SQLite would be a trap.
    let values = t::EnvValues::generate();
    untracked.push((".env".to_string(), t::env(db, &values, false).into_bytes()));
    untracked.push((
        ".env.example".to_string(),
        t::env(db, &values, true).into_bytes(),
    ));

    for (path, contents) in untracked {
        let mut throwaway = Ledger::new();
        binary(root, &path, &contents, &mut throwaway)?;
    }

    // `src/components/` is where `rahti-build` writes a generated `mod.rs`,
    // and `src/main.rs` declares the module unconditionally — so the
    // directory has to exist before the first build, empty or not.
    dir(root, "src/components")?;

    Ok(())
}

/// `rahti.config.json`, written last because it describes everything above.
pub fn write_config(
    root: &Path,
    tailwind: bool,
    db: Option<Backend>,
    ws: bool,
    ledger: &Ledger,
) -> Result<(), String> {
    let engine = if tailwind { "tailwind" } else { "plain" };

    let mut out = String::new();
    out.push_str("{\n");
    out.push_str("  \"$schema\": \"https://rahti.dev/schema/1.json\",\n");
    out.push_str("  \"schema\": 1,\n");
    out.push_str(&format!("  \"createdWith\": \"{VERSION}\",\n\n"));

    out.push_str("  \"app\": {\n    \"dir\": \"src/app\",\n    \"public\": \"public\"\n  },\n\n");
    out.push_str("  \"server\": {\n    \"host\": \"127.0.0.1\",\n    \"port\": 3000\n  },\n\n");

    // Recorded only when chosen, like `db`: absence is the answer, and an
    // `upgrade` reads this to know the manifest carries rahti's `ws` feature.
    // The build itself never needs the key — a `#[socket]` in the tree is
    // what wires the endpoint.
    if ws {
        out.push_str("  \"ws\": true,\n\n");
    }

    out.push_str("  \"css\": {\n");
    out.push_str(&format!("    \"engine\": \"{engine}\",\n"));
    out.push_str("    \"entry\": \"src/app/globals.css\",\n");
    out.push_str("    \"output\": \"public/css/styles.css\"");
    if tailwind {
        // Only the Tailwind engine reads these, and a plain project carrying
        // a pinned compiler version it never runs invites the question of
        // why it is there.
        out.push_str(",\n    \"version\": \"4.3.3\",\n    \"download\": true");
    }
    out.push_str("\n  },\n\n");

    // Only when there is one. An app with no database carrying an empty `db`
    // object invites the question of what it is for, and the build reads its
    // absence as the answer rather than as a gap.
    //
    // The connection string is deliberately not here: it is a credential, and
    // this file is committed. `src/db.rs` reads DATABASE_URL from `.env`.
    if let Some(backend) = db {
        out.push_str("  \"db\": {\n");
        out.push_str(&format!("    \"backend\": \"{}\",\n", backend.label()));
        out.push_str("    \"models\": \"src/models\",\n");
        out.push_str("    \"migrations\": \"src/migrations\"\n");
        out.push_str("  },\n\n");
    }

    out.push_str("  \"scaffold\": {\n");
    let mut entries = ledger.iter().peekable();
    while let Some((path, hash)) = entries.next() {
        let comma = if entries.peek().is_some() { "," } else { "" };
        out.push_str(&format!("    \"{path}\": \"{hash}\"{comma}\n"));
    }
    out.push_str("  }\n}\n");

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

// ------------------------------------------------------------------ writing

/// Write one file and record it.
///
/// Hashing the bytes rather than the template means the ledger describes what
/// is on disk, which is the only thing a later comparison can be against.
fn binary(root: &Path, path: &str, contents: &[u8], ledger: &mut Ledger) -> Result<(), String> {
    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}"))?;

    ledger.insert(path.to_string(), sha256::hex(contents));
    Ok(())
}

fn dir(root: &Path, path: &str) -> Result<(), String> {
    let full = root.join(path);
    fs::create_dir_all(&full).map_err(|e| format!("cannot create {}: {e}", full.display()))
}

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

/// What the command line said.
///
/// Every optional feature is one flag meaning "add this", and its absence
/// means "do not". One spelling per answer, so there is no pair of opposing
/// flags to reconcile when both are given.
struct Options {
    name: String,
    /// `--tailwind` was given. False means it was not, which is the answer.
    tailwind: bool,
    /// `--db [backend]` was given, and which backend it named.
    db: Option<Backend>,
    /// `--ws` was given: the project gets rahti's `ws` feature and the
    /// `#[socket]` attribute with it.
    ws: bool,
    /// A Rahti checkout to depend on by path, for framework development.
    local: Option<String>,
}

impl Options {
    fn parse(args: &[&str]) -> Result<Self, String> {
        let mut name: Option<String> = None;
        let mut tailwind = false;
        let mut db: Option<Backend> = None;
        let mut ws = false;
        let mut local: Option<String> = None;

        let mut rest = args.iter();
        while let Some(arg) = rest.next() {
            match *arg {
                "--tailwind" => tailwind = true,
                "--ws" => ws = true,
                // A bare `--db` is SQLite: it is the backend that needs no
                // server running, so it is the one worth having as the short
                // form.
                "--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),
                    }
                }
                "--local" => {
                    local = Some(
                        rest.next()
                            .ok_or("--local needs a path to a Rahti checkout")?
                            .to_string(),
                    );
                }
                other if other.starts_with('-') => {
                    return Err(format!("`{other}` is not an option of `new`."));
                }
                other if name.is_none() => name = Some(other.to_string()),
                other => return Err(format!("unexpected argument `{other}`")),
            }
        }

        let name = name.ok_or("`new` needs a project name: cargo rahti new my-app")?;
        check_name(&name)?;

        Ok(Options {
            name,
            tailwind,
            db,
            ws,
            local,
        })
    }
}

/// The name becomes a cargo package and a directory, so refuse here what one
/// of those would refuse later with a worse message.
fn check_name(name: &str) -> Result<(), String> {
    if name.is_empty() {
        return Err("the project name is empty".to_string());
    }

    let valid = name
        .chars()
        .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_');
    if !valid {
        return Err(format!(
            "`{name}` cannot be a cargo package name.\n       \
             Use letters, digits, `-` and `_`."
        ));
    }

    if name.chars().next().is_some_and(|c| c.is_ascii_digit()) {
        return Err(format!(
            "`{name}` starts with a digit, which cargo will not accept."
        ));
    }

    Ok(())
}

fn report(name: &str, tailwind: bool, db: Option<Backend>, ws: bool) {
    let engine = if tailwind {
        "Tailwind CSS"
    } else {
        "plain CSS"
    };
    let sockets = if ws { ", with WebSockets" } else { "" };

    match db {
        Some(backend) => println!(
            "\n  Created `{name}`, styled with {engine}, on {}{sockets}.\n",
            backend.label()
        ),
        None => println!("\n  Created `{name}`, styled with {engine}{sockets}.\n"),
    }

    println!("    cd {name}");

    // A backend that needs a server has to be pointed at one before the first
    // run, and the run that fails without this line fails at startup with a
    // connection error — readable, but avoidable.
    if db.is_some_and(|b| b != Backend::Sqlite) {
        println!("    # then set DATABASE_URL in .env");
    }

    println!("    cargo run\n");
    println!("  Then open http://127.0.0.1:3000 and edit src/app/page.rs.\n");
    println!("  `cargo dev` runs the same server but rebuilds and restarts it on");
    println!("  every edit, and the open tab reloads itself. It needs cargo-watch");
    println!("  installed once: `cargo install cargo-watch`.\n");
    println!("  AGENTS.md is the project guide, and docs/conventions/ documents the");
    println!("  framework — written for coding agents and the people beside them,");
    println!("  and kept current by `cargo rahti upgrade`.\n");

    if ws {
        println!("  WebSockets are on: `rahti` carries its `ws` feature, so mark an");
        println!("  async function `#[socket]` beside the page whose script opens it,");
        println!("  give it a final `socket: rahti::ws::Socket` parameter, and connect");
        println!("  from the browser with `pp.socket(\"name\", {{}}, {{ onMessage }})`.\n");
    }

    if db.is_some() {
        println!("  The database is in src/models/ — one file per table — with the");
        println!("  migrations that create them in src/migrations/. Both are wired up");
        println!("  from their contents, so adding a file is the whole of adding a");
        println!("  table. See docs/conventions/database.md.\n");
    }
}