Skip to main content

bynk/
new.rs

1//! `bynk new` — scaffold a new project.
2//!
3//! The zero-to-one step of the driver arc `doctor → new → dev` (proposal
4//! v0.58): it writes a **complete, runnable** single-context HTTP service that
5//! `bynk dev` serves unmodified. Unlike `dev`, `new` shells nothing, compiles
6//! nothing, and reads no network — it is pure, offline file-writing, so it
7//! works before `bynkc`, Node, or `wrangler` are installed (D4).
8//!
9//! The starter, manifest, `.gitignore` and `.gitattributes` are **embedded**
10//! via `include_str!` (the first-party precedent, ADR 0086): each template carries a
11//! [`PLACEHOLDER`] identifier substituted for the project name at write time.
12//! A standing test (`tests/new.rs`) renders the starter with a non-default name
13//! and asserts it compiles and is `bynk-fmt`-clean, so the scaffold can never
14//! rot into something that doesn't build.
15
16use std::fs;
17use std::io;
18use std::path::{Path, PathBuf};
19use std::process::ExitCode;
20
21/// The sentinel identifier in the embedded templates, replaced by the project
22/// name when the scaffold is written. Chosen as a legal Bynk identifier so each
23/// template is itself parseable, and distinctive enough that a plain
24/// substring substitution only ever hits the intended occurrences.
25pub const PLACEHOLDER: &str = "appname";
26
27const STARTER_BYNK: &str = include_str!("templates/starter.bynk");
28const BYNK_TOML: &str = include_str!("templates/bynk.toml");
29const GITIGNORE: &str = include_str!("templates/gitignore");
30/// #1763: `*.bynk text eol=lf`, so a Windows checkout keeps `fmt`'s LF form.
31const GITATTRIBUTES: &str = include_str!("templates/gitattributes");
32
33/// Directory entries that don't count as "non-empty" for the clobber check
34/// (D5): VCS metadata and OS cruft a freshly-`mkdir`ed or `git init`ed
35/// directory commonly carries. Mirrors `cargo`'s look-the-other-way set.
36const SCAFFOLD_IGNORES: &[&str] = &[
37    ".git",
38    ".gitattributes",
39    ".gitignore",
40    ".hg",
41    ".hgignore",
42    ".svn",
43    ".DS_Store",
44];
45
46/// Parsed `bynk new` arguments.
47#[derive(Debug, Clone)]
48pub struct NewOptions {
49    /// Directory to create for the new project.
50    pub path: PathBuf,
51    /// `--name` override for the project / context identifier. Defaults to
52    /// `path`'s final component.
53    pub name: Option<String>,
54}
55
56/// Scaffold a new project: derive & validate the name, refuse to clobber, write
57/// the tree, and print next steps. Returns a non-zero exit (touching nothing)
58/// on an underivable/invalid name or a non-empty target.
59pub fn run(opts: &NewOptions) -> ExitCode {
60    let name = match opts.name.clone().or_else(|| derive_name(&opts.path)) {
61        Some(name) => name,
62        None => {
63            eprint!("{}", cannot_derive_message(&display(&opts.path)));
64            return ExitCode::FAILURE;
65        }
66    };
67
68    if !is_legal_name(&name) {
69        eprint!("{}", invalid_name_message(&name));
70        return ExitCode::FAILURE;
71    }
72
73    match target_is_nonempty(&opts.path) {
74        Ok(true) => {
75            eprint!("{}", clobber_message(&display(&opts.path)));
76            return ExitCode::FAILURE;
77        }
78        Ok(false) => {}
79        Err(e) => {
80            eprintln!("bynk: cannot inspect `{}`: {e}", display(&opts.path));
81            return ExitCode::FAILURE;
82        }
83    }
84
85    if let Err(e) = write_scaffold(&opts.path, &name) {
86        eprintln!("bynk: failed to write the scaffold: {e}");
87        return ExitCode::FAILURE;
88    }
89
90    print!("{}", next_steps_message(&display(&opts.path)));
91    ExitCode::SUCCESS
92}
93
94/// The project name implied by a target path: its final component. `None` when
95/// the path has no final component (e.g. `.` or `/`), in which case `--name` is
96/// required.
97fn derive_name(path: &Path) -> Option<String> {
98    path.file_name().map(|s| s.to_string_lossy().into_owned())
99}
100
101/// Is `name` a legal Bynk identifier — a single, dotless `Ident`? Answered by
102/// the real lexer rather than a hand-rolled regex, so it tracks the language
103/// exactly: a dash, dot, leading digit, or reserved keyword all yield something
104/// other than one lone `Ident` token and are rejected.
105pub fn is_legal_name(name: &str) -> bool {
106    match bynk_syntax::lexer::tokenize(name) {
107        Ok(tokens) => tokens.len() == 1 && tokens[0].kind == bynk_syntax::lexer::TokenKind::Ident,
108        Err(_) => false,
109    }
110}
111
112/// Render an embedded template for `name` by substituting [`PLACEHOLDER`].
113pub fn render(template: &str, name: &str) -> String {
114    template.replace(PLACEHOLDER, name)
115}
116
117/// The rendered starter source for `name` — the `context <name>` HTTP service
118/// written to `src/<name>.bynk`.
119pub fn starter_source(name: &str) -> String {
120    render(STARTER_BYNK, name)
121}
122
123/// Does the target exist and hold anything that isn't [`SCAFFOLD_IGNORES`]
124/// cruft? A missing or cruft-only directory is fine to scaffold into (D5).
125fn target_is_nonempty(target: &Path) -> io::Result<bool> {
126    let entries = match fs::read_dir(target) {
127        Ok(entries) => entries,
128        Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(false),
129        Err(e) => return Err(e),
130    };
131    for entry in entries {
132        let entry = entry?;
133        let name = entry.file_name();
134        if !SCAFFOLD_IGNORES.contains(&name.to_string_lossy().as_ref()) {
135            return Ok(true);
136        }
137    }
138    Ok(false)
139}
140
141/// Create the directory tree and write the four files. Never overwrites: the
142/// clobber check has already cleared the target of everything except
143/// [`SCAFFOLD_IGNORES`] cruft. `.gitignore` and `.gitattributes` (#1763) are
144/// the members of that set the scaffold also writes — a hand-written one (e.g.
145/// in a `git init`ed target) passes the clobber check, so we write ours only
146/// when none is present rather than clobbering the user's.
147fn write_scaffold(target: &Path, name: &str) -> io::Result<()> {
148    let src_dir = target.join("src");
149    fs::create_dir_all(&src_dir)?;
150    fs::write(target.join("bynk.toml"), render(BYNK_TOML, name))?;
151    let gitignore = target.join(".gitignore");
152    if !gitignore.exists() {
153        fs::write(gitignore, render(GITIGNORE, name))?;
154    }
155    // #1763: likewise, never over the user's own.
156    let gitattributes = target.join(".gitattributes");
157    if !gitattributes.exists() {
158        fs::write(gitattributes, render(GITATTRIBUTES, name))?;
159    }
160    fs::write(src_dir.join(format!("{name}.bynk")), starter_source(name))?;
161    Ok(())
162}
163
164fn display(path: &Path) -> String {
165    path.display().to_string()
166}
167
168// ---------------------------------------------------------------------------
169// Output surface — pinned by goldens (proposal §5). Built here as pure
170// functions so the tests can assert them without touching the filesystem.
171// ---------------------------------------------------------------------------
172
173/// The success "next steps" message, printed to stdout.
174pub fn next_steps_message(dir: &str) -> String {
175    format!(
176        "Created a new Bynk project in `{dir}`.\n\
177         \n\
178         Next steps:\n  \
179         cd {dir}\n  \
180         bynk dev          # build and serve it locally\n\
181         \n\
182         New to Bynk? `bynk doctor` checks your toolchain is ready.\n"
183    )
184}
185
186/// The failure message for a name that isn't a legal Bynk identifier.
187pub fn invalid_name_message(name: &str) -> String {
188    format!(
189        "bynk: `{name}` isn't a valid Bynk name.\n      \
190         A name must be a single identifier — a letter followed by letters, \
191         digits, or underscores (no dashes or dots).\n      \
192         Pass `--name <ident>` to choose the project's identifier.\n"
193    )
194}
195
196/// The failure message when a name can't be derived from the path.
197pub fn cannot_derive_message(path: &str) -> String {
198    format!(
199        "bynk: couldn't derive a project name from `{path}`.\n      \
200         Pass `--name <ident>` to name the project.\n"
201    )
202}
203
204/// The failure message when the target exists and isn't empty (D5).
205pub fn clobber_message(dir: &str) -> String {
206    format!(
207        "bynk: `{dir}` already exists and isn't empty — refusing to overwrite.\n      \
208         Choose a different path, or empty that directory first.\n"
209    )
210}