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;
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
));
}
let tailwind = options.tailwind || confirm("Use Tailwind CSS?");
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,
};
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(())
}
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."
)),
}
}
pub fn project_files(
name: &str,
tailwind: bool,
db: Option<Backend>,
ws: bool,
local: Option<&str>,
) -> Vec<(String, Vec<u8>)> {
let rahti = match local {
Some(path) => format!(
"{{ path = \"{}/crates/rahti\" }}",
path.replace('\\', "/").trim_end_matches('/')
),
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()),
(
"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(),
));
}
files
}
const UNTRACKED: [(&str, &str); 1] = [("public/css/styles.css", "")];
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();
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)?;
}
dir(root, "src/components")?;
Ok(())
}
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");
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 {
out.push_str(",\n \"version\": \"4.3.3\",\n \"download\": true");
}
out.push_str("\n },\n\n");
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}"))
}
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()))
}
struct Options {
name: String,
tailwind: bool,
db: Option<Backend>,
ws: bool,
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,
"--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,
})
}
}
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}");
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");
}
}