Skip to main content

bock_codegen/
scaffold.rs

1//! Project-mode scaffolding framework + `bock.project` config-table parsing.
2//!
3//! This module is the **foundation** for project-mode output (spec §20.6.2).
4//! It provides two things:
5//!
6//! 1. **Config parsing + validation.** [`ScaffoldConfig::from_project_toml`]
7//!    parses the per-target `[targets.<T>]` (*deep*) and
8//!    `[targets.<T>.scaffolding]` (*shallow*) tables in `bock.project`, and
9//!    validates every supplied value against the v1-supported variant matrix
10//!    in §20.6.2. An unknown value is a [`ScaffoldError`] that names the
11//!    documented options for that target. Fields left unset stay `None` —
12//!    target-appropriate defaults are applied later (by S6 per-target
13//!    scaffolders), not here.
14//!
15//! 2. **The [`Scaffolder`] abstraction.** A [`Scaffolder`] takes the parsed
16//!    per-target [`TargetScaffoldConfig`] plus the already-emitted module tree
17//!    and produces *additional* [`OutputFile`]s — manifests, README
18//!    first-contact instructions, formatter/linter configs, transpiled test
19//!    files. [`scaffolder_for`] returns the per-target implementation.
20//!
21//! **Mode gating lives in the build driver, not here.** `bock build` runs the
22//! scaffolder **only in the default (project) mode**, never under
23//! `--source-only` (spec §20.6.2: source mode emits "no manifests,
24//! scaffolding, or entry-point wiring").
25//!
26//! ## Scope (S5 framework → S6a manifests → S6b enrichment)
27//!
28//! S5 shipped the *framework* + *config plumbing* + *mode gating*; S6a relocated
29//! the minimal run-affordance manifests here. **S6b** (this stage) fills each
30//! per-target [`Scaffolder`] body with the full project-mode scaffolding:
31//!
32//! - a **rich manifest** referencing the selected/default **test framework**
33//!   (`package.json` + `tsconfig.json`; `pyproject.toml`; `Cargo.toml`; `go.mod`),
34//! - a **formatter config** where applicable (Prettier for js/ts unless
35//!   `formatter=none`; Black/Ruff in `pyproject.toml`; rustfmt/gofmt are
36//!   universal/always-on, no config),
37//! - an **opt-in linter config** (shallow — emitted ONLY when
38//!   `[targets.<T>.scaffolding].linter` is set),
39//! - a **README first-contact** honoring the package-manager hint.
40//!
41//! Unset fields take the §20.6.2 **target-appropriate defaults**: js/ts →
42//! Vitest + Prettier + npm; python → pytest + Black + pip; rust → cargo test +
43//! rustfmt; go → stdlib testing + gofmt.
44//!
45//! **Deferred to S7** (NOT done here): the transpiled `@test` *files*
46//! (Vitest/Jest/pytest/`cargo test`/`go test` test code) and the formatter-clean
47//! `--check` release gate. S6b only sets up manifests/configs that *reference*
48//! the framework.
49//!
50//! The v1-supported variant matrix (§20.6.2):
51//!
52//! | Target | Test framework        | Formatter                  | Linter             | Package manager  |
53//! |--------|-----------------------|----------------------------|--------------------|------------------|
54//! | js     | vitest (def), jest    | prettier (def), none       | eslint             | npm (def), pnpm, yarn |
55//! | ts     | vitest (def), jest    | prettier (def), none       | eslint             | npm (def), pnpm, yarn |
56//! | python | pytest (def), unittest| black (def), ruff, none    | ruff, pylint       | pip (def), poetry, uv |
57//! | rust   | (cargo test, universal)| (rustfmt, universal)      | clippy             | (cargo only)     |
58//! | go     | (stdlib, universal)   | (gofmt, universal)         | golangci-lint      | (go mod only)    |
59//!
60//! Rust/Go formatters and test frameworks are universal and always-on, so
61//! `test_framework`/`formatter` are not user-selectable for those targets;
62//! supplying them is a validation error.
63
64use std::path::Path;
65
66use serde::Deserialize;
67
68use crate::generator::OutputFile;
69
70/// Canonical target ids the config tables key on, matching
71/// [`crate::profile::TargetProfile`] ids and `bock build` target selection.
72pub const SCAFFOLD_TARGETS: &[&str] = &["js", "ts", "python", "rust", "go"];
73
74/// An error parsing or validating the `bock.project` scaffolding config.
75///
76/// Validation errors carry the offending field, value, target, and the list of
77/// documented options for that target (§20.6.2) so the build driver can render
78/// a message that points the user at the valid choices.
79#[derive(Debug, Clone, PartialEq, Eq)]
80pub enum ScaffoldError {
81    /// The document was not valid TOML, or a config field had the wrong type.
82    Parse(String),
83    /// A config field was set to a value outside the documented options.
84    UnknownValue {
85        /// Target id (`"js"`, `"python"`, …).
86        target: String,
87        /// Config field name (`"test_framework"`, `"formatter"`, `"linter"`,
88        /// `"package_manager"`).
89        field: String,
90        /// The unsupported value the user supplied.
91        value: String,
92        /// Documented valid options for this field on this target (§20.6.2).
93        options: Vec<&'static str>,
94    },
95    /// A field was supplied for a target where it is not user-configurable
96    /// (e.g. Rust/Go `formatter`/`test_framework`, which are universal).
97    NotConfigurable {
98        /// Target id.
99        target: String,
100        /// Config field name.
101        field: String,
102        /// Why it is not configurable, for the error message.
103        reason: &'static str,
104    },
105}
106
107impl std::fmt::Display for ScaffoldError {
108    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
109        match self {
110            Self::Parse(msg) => write!(f, "bock.project parse error: {msg}"),
111            Self::UnknownValue {
112                target,
113                field,
114                value,
115                options,
116            } => write!(
117                f,
118                "unknown `{field}` value `{value}` for target `{target}`; \
119                 supported options: {}",
120                options.join(", ")
121            ),
122            Self::NotConfigurable {
123                target,
124                field,
125                reason,
126            } => write!(
127                f,
128                "`{field}` is not configurable for target `{target}`: {reason}"
129            ),
130        }
131    }
132}
133
134impl std::error::Error for ScaffoldError {}
135
136/// Typed, *validated* per-target scaffolding configuration.
137///
138/// Every field is `Option` — `None` means "left unset, apply the
139/// target-appropriate default downstream" (S6). A `Some` value has already
140/// passed the §20.6.2 matrix validation in [`ScaffoldConfig::from_project_toml`].
141#[derive(Debug, Clone, Default, PartialEq, Eq)]
142pub struct TargetScaffoldConfig {
143    /// Deep config: `test_framework` (`[targets.<T>]`). Affects test codegen.
144    pub test_framework: Option<String>,
145    /// Deep config: `formatter` (`[targets.<T>]`). Affects emitted code style.
146    pub formatter: Option<String>,
147    /// Deep config: `package` (`[targets.<T>]`). Overrides default package
148    /// name normalization (Python `package`, etc.).
149    pub package: Option<String>,
150    /// Deep config: Go `module` path (`[targets.go]`).
151    pub module: Option<String>,
152    /// Shallow config: `linter` (`[targets.<T>.scaffolding]`). Adds a config
153    /// file only.
154    pub linter: Option<String>,
155    /// Shallow config: `package_manager` (`[targets.<T>.scaffolding]`).
156    /// Affects README hints only.
157    pub package_manager: Option<String>,
158}
159
160/// Validated scaffolding configuration for all five built-in targets.
161///
162/// Parse with [`ScaffoldConfig::from_project_toml`]; look a target up with
163/// [`ScaffoldConfig::target`].
164#[derive(Debug, Clone, Default, PartialEq, Eq)]
165pub struct ScaffoldConfig {
166    js: TargetScaffoldConfig,
167    ts: TargetScaffoldConfig,
168    python: TargetScaffoldConfig,
169    rust: TargetScaffoldConfig,
170    go: TargetScaffoldConfig,
171}
172
173impl ScaffoldConfig {
174    /// Returns the validated config for `target` (`"js"`, `"ts"`, `"python"`,
175    /// `"rust"`, `"go"`; aliases `"javascript"`/`"typescript"`/`"py"`/`"rs"`/
176    /// `"golang"` accepted), or `None` for an unknown target id.
177    #[must_use]
178    pub fn target(&self, target: &str) -> Option<&TargetScaffoldConfig> {
179        match target {
180            "js" | "javascript" => Some(&self.js),
181            "ts" | "typescript" => Some(&self.ts),
182            "python" | "py" => Some(&self.python),
183            "rust" | "rs" => Some(&self.rust),
184            "go" | "golang" => Some(&self.go),
185            _ => None,
186        }
187    }
188
189    /// Parses + validates the per-target tables in a `bock.project` document.
190    ///
191    /// Recognizes `[targets.<T>]` (deep) and `[targets.<T>.scaffolding]`
192    /// (shallow) for the five built-in targets and validates each supplied
193    /// value against the §20.6.2 matrix. A missing `[targets]` table (or a
194    /// missing per-target table) yields all-`None` config — defaults are
195    /// applied later, not here.
196    ///
197    /// # Errors
198    ///
199    /// - [`ScaffoldError::Parse`] when the document is not valid TOML or a
200    ///   field has the wrong type.
201    /// - [`ScaffoldError::UnknownValue`] when a field is set to a value
202    ///   outside the documented options for that target.
203    /// - [`ScaffoldError::NotConfigurable`] when a universal field (Rust/Go
204    ///   `formatter`/`test_framework`) is supplied.
205    pub fn from_project_toml(source: &str) -> Result<Self, ScaffoldError> {
206        Ok(Self::from_project_toml_with_name(source)?.0)
207    }
208
209    /// Like [`Self::from_project_toml`] but also returns the `[project] name`
210    /// from the same document, if present. Saves callers (the build driver) a
211    /// second TOML parse and keeps the `toml`/`serde` dependency in this crate.
212    ///
213    /// # Errors
214    ///
215    /// Same as [`Self::from_project_toml`].
216    pub fn from_project_toml_with_name(
217        source: &str,
218    ) -> Result<(Self, Option<String>), ScaffoldError> {
219        let doc: ProjectDoc =
220            toml::from_str(source).map_err(|e| ScaffoldError::Parse(e.to_string()))?;
221        let raw = doc.targets.unwrap_or_default();
222        let name = doc.project.and_then(|p| p.name);
223
224        let config = Self {
225            js: validate_target("js", raw.js)?,
226            ts: validate_target("ts", raw.ts)?,
227            python: validate_target("python", raw.python)?,
228            rust: validate_target("rust", raw.rust)?,
229            go: validate_target("go", raw.go)?,
230        };
231        Ok((config, name))
232    }
233}
234
235// ── §20.6.2 v1 variant matrix ───────────────────────────────────────────────
236//
237// The codegen package owns the supported-options list per target (§20.6.2:
238// "The codegen package owns the supported-options list per target; the spec
239// carries the v1 matrix"). These constants are that authoritative list.
240
241/// `test_framework` options per target. Empty = universal/not user-selectable.
242fn test_framework_options(target: &str) -> &'static [&'static str] {
243    match target {
244        "js" | "ts" => &["vitest", "jest"],
245        "python" => &["pytest", "unittest"],
246        _ => &[], // rust/go: universal
247    }
248}
249
250/// `formatter` options per target. Empty = universal/not user-selectable.
251fn formatter_options(target: &str) -> &'static [&'static str] {
252    match target {
253        "js" | "ts" => &["prettier", "none"],
254        "python" => &["black", "ruff", "none"],
255        _ => &[], // rust/go: universal (rustfmt/gofmt always-on)
256    }
257}
258
259/// `linter` (shallow) options per target.
260fn linter_options(target: &str) -> &'static [&'static str] {
261    match target {
262        "js" | "ts" => &["eslint"],
263        "python" => &["ruff", "pylint"],
264        "rust" => &["clippy"],
265        "go" => &["golangci-lint"],
266        _ => &[],
267    }
268}
269
270/// `package_manager` (shallow) options per target. Empty = single fixed
271/// manager (cargo / go mod), so the field is not user-selectable.
272fn package_manager_options(target: &str) -> &'static [&'static str] {
273    match target {
274        "js" | "ts" => &["npm", "pnpm", "yarn"],
275        "python" => &["pip", "poetry", "uv"],
276        _ => &[], // rust (cargo only) / go (go mod only)
277    }
278}
279
280/// Validate a single field against its option list, normalizing to lowercase.
281/// `None` field stays `None`. A universal field (empty option list) supplied a
282/// value is [`ScaffoldError::NotConfigurable`].
283fn validate_field(
284    target: &str,
285    field: &str,
286    value: Option<String>,
287    options: &[&'static str],
288    universal_reason: &'static str,
289) -> Result<Option<String>, ScaffoldError> {
290    let Some(value) = value else {
291        return Ok(None);
292    };
293    if options.is_empty() {
294        return Err(ScaffoldError::NotConfigurable {
295            target: target.to_string(),
296            field: field.to_string(),
297            reason: universal_reason,
298        });
299    }
300    let normalized = value.to_ascii_lowercase();
301    if options.contains(&normalized.as_str()) {
302        Ok(Some(normalized))
303    } else {
304        Err(ScaffoldError::UnknownValue {
305            target: target.to_string(),
306            field: field.to_string(),
307            value,
308            options: options.to_vec(),
309        })
310    }
311}
312
313/// Validate one target's raw deep+shallow tables into a [`TargetScaffoldConfig`].
314fn validate_target(
315    target: &str,
316    raw: Option<RawTarget>,
317) -> Result<TargetScaffoldConfig, ScaffoldError> {
318    let Some(raw) = raw else {
319        return Ok(TargetScaffoldConfig::default());
320    };
321    let scaffolding = raw.scaffolding.unwrap_or_default();
322
323    let test_framework = validate_field(
324        target,
325        "test_framework",
326        raw.test_framework,
327        test_framework_options(target),
328        "test framework is universal (cargo test / go test) and always-on",
329    )?;
330    let formatter = validate_field(
331        target,
332        "formatter",
333        raw.formatter,
334        formatter_options(target),
335        "formatter is universal (rustfmt / gofmt) and always-on",
336    )?;
337    let linter = validate_field(
338        target,
339        "linter",
340        scaffolding.linter,
341        linter_options(target),
342        "no linter is configurable for this target",
343    )?;
344    let package_manager = validate_field(
345        target,
346        "package_manager",
347        scaffolding.package_manager,
348        package_manager_options(target),
349        "package manager is fixed for this target (cargo / go mod)",
350    )?;
351
352    Ok(TargetScaffoldConfig {
353        test_framework,
354        formatter,
355        // `package` / `module` are free-form identifiers, not enum-validated.
356        package: raw.package,
357        module: raw.module,
358        linter,
359        package_manager,
360    })
361}
362
363// ── Raw TOML shapes (deserialization targets) ───────────────────────────────
364
365#[derive(Debug, Deserialize, Default)]
366struct ProjectDoc {
367    project: Option<RawProject>,
368    targets: Option<RawTargets>,
369}
370
371#[derive(Debug, Deserialize, Default)]
372struct RawProject {
373    name: Option<String>,
374}
375
376#[derive(Debug, Deserialize, Default)]
377struct RawTargets {
378    js: Option<RawTarget>,
379    ts: Option<RawTarget>,
380    python: Option<RawTarget>,
381    rust: Option<RawTarget>,
382    go: Option<RawTarget>,
383}
384
385#[derive(Debug, Deserialize, Default)]
386struct RawTarget {
387    test_framework: Option<String>,
388    formatter: Option<String>,
389    package: Option<String>,
390    module: Option<String>,
391    scaffolding: Option<RawScaffolding>,
392}
393
394#[derive(Debug, Deserialize, Default)]
395struct RawScaffolding {
396    linter: Option<String>,
397    package_manager: Option<String>,
398}
399
400// ── Scaffolder abstraction ──────────────────────────────────────────────────
401
402/// Context handed to a [`Scaffolder`]: the validated per-target config plus the
403/// module tree already emitted by codegen.
404///
405/// `emitted` is the set of source [`OutputFile`]s produced by
406/// [`crate::CodeGenerator::generate_project`] for this target — the scaffolder
407/// inspects it (entry file, module paths, run-affordance manifests already
408/// present) to decide what additional files to add. `project_name` is the
409/// `[project] name` from `bock.project` when known.
410pub struct ScaffoldContext<'a> {
411    /// Target id (`"js"`, `"ts"`, `"python"`, `"rust"`, `"go"`).
412    pub target: &'a str,
413    /// Validated per-target config (deep + shallow).
414    pub config: &'a TargetScaffoldConfig,
415    /// Source files already emitted for this target.
416    pub emitted: &'a [OutputFile],
417    /// Project name from `[project] name`, if available.
418    pub project_name: Option<&'a str>,
419}
420
421/// Produces project-mode scaffolding files for one target.
422///
423/// A [`Scaffolder`] adds files *alongside* the emitted source tree — manifests,
424/// README, formatter/linter configs, transpiled tests. It runs **only in
425/// project mode** (never `--source-only`); the build driver enforces that.
426///
427/// Returned [`OutputFile`]s use paths relative to the target build root
428/// (`build/<target>/`), the same convention as
429/// [`crate::CodeGenerator::generate_project`]. A scaffolder must **not** emit a
430/// file at a path the codegen tree already occupies (e.g. the run-affordance
431/// `Cargo.toml`/`go.mod`/`package.json`); [`run_scaffolder`] drops any such
432/// collisions defensively, but scaffolders should avoid them.
433pub trait Scaffolder {
434    /// The target id this scaffolder serves.
435    fn target_id(&self) -> &'static str;
436
437    /// Produce the additional scaffolding files for `ctx`.
438    ///
439    /// # Errors
440    ///
441    /// Returns [`ScaffoldError`] if scaffolding cannot be produced (e.g. an
442    /// internally inconsistent config). S5 stubs do not error.
443    fn scaffold(&self, ctx: &ScaffoldContext<'_>) -> Result<Vec<OutputFile>, ScaffoldError>;
444}
445
446/// Returns the [`Scaffolder`] for `target`, or `None` for an unknown target id.
447///
448/// Each per-target scaffolder owns the project-mode output for its target: a
449/// rich manifest (the `Cargo.toml` / `go.mod` / `package.json` (+ `tsconfig.json`
450/// for TS) / `pyproject.toml`) referencing the selected/default test framework,
451/// a formatter config where applicable, an opt-in linter config, and a README
452/// first-contact (S6b). The transpiled `@test` *files* and the formatter-clean
453/// release gate are a later milestone (S7).
454#[must_use]
455pub fn scaffolder_for(target: &str) -> Option<Box<dyn Scaffolder>> {
456    match target {
457        "js" | "javascript" => Some(Box::new(JsScaffolder { target_id: "js" })),
458        "ts" | "typescript" => Some(Box::new(JsScaffolder { target_id: "ts" })),
459        "python" | "py" => Some(Box::new(PythonScaffolder)),
460        "rust" | "rs" => Some(Box::new(RustScaffolder)),
461        "go" | "golang" => Some(Box::new(GoScaffolder)),
462        _ => None,
463    }
464}
465
466/// Run the per-target scaffolder and return its files, dropping any that
467/// collide with paths the codegen tree already emitted.
468///
469/// This is the single entry point the build driver calls in project mode. It
470/// guards the run-affordance manifests (already emitted by codegen) against
471/// accidental clobbering by a scaffolder stub.
472///
473/// # Errors
474///
475/// Propagates [`ScaffoldError`] from the scaffolder, or returns
476/// [`ScaffoldError::Parse`] if `target` has no registered scaffolder.
477pub fn run_scaffolder(
478    target: &str,
479    config: &TargetScaffoldConfig,
480    emitted: &[OutputFile],
481    project_name: Option<&str>,
482) -> Result<Vec<OutputFile>, ScaffoldError> {
483    let scaffolder = scaffolder_for(target).ok_or_else(|| {
484        ScaffoldError::Parse(format!("no scaffolder registered for target `{target}`"))
485    })?;
486    let ctx = ScaffoldContext {
487        target,
488        config,
489        emitted,
490        project_name,
491    };
492    let mut files = scaffolder.scaffold(&ctx)?;
493    files.retain(|f| !emitted.iter().any(|e| e.path == f.path));
494    Ok(files)
495}
496
497// ── §20.6.2 defaults + shared scaffolding helpers ────────────────────────────
498//
499// S6b enriches the S6a run-affordance manifests into the full project-mode
500// scaffolding (spec §20.6.2): a rich manifest referencing the selected/default
501// test framework, a formatter config where applicable, an opt-in linter config
502// (shallow), and a README first-contact honoring the package-manager hint. The
503// per-target *defaults* below apply when a field is left unset in `bock.project`
504// (parsed/validated in S5). The actual transpiled `@test` files and the
505// formatter-clean `--check` gate are S7, not here.
506
507/// The marker the emitted Rust source carries when the crate needs `tokio` as a
508/// dependency.
509///
510/// `tokio` is required by two distinct emitted-code paths, both of which write
511/// fully-qualified `tokio::` paths into the `.rs` tree:
512///
513/// * **concurrency** — a `Channel`/`spawn` program pulls in the shared
514///   `src/bock_runtime.rs` runtime, which is `tokio::sync::mpsc`-backed; and
515/// * **bare host `sleep`** — a `sleep(..)` with no installed `Clock` handler
516///   lowers to `tokio::time::sleep(..)` under a `#[tokio::main]` async `fn main`
517///   (the host-default time path). This path emits NO `bock_runtime.rs`, so a
518///   `bock_runtime.rs`-presence check alone misses it (Q-rust-host-sleep-tokio-dep).
519///
520/// Scanning the emitted `.rs` source for this substring catches both paths
521/// uniformly: any program whose Rust output references `tokio` needs the
522/// dependency, and a program that references none gets a dependency-free
523/// `Cargo.toml` so `cargo run` stays fast. See [`rust_emits_tokio`].
524const RUST_TOKIO_MARKER: &str = "tokio::";
525
526/// Whether the emitted Rust source tree uses `tokio` (and so the scaffolded
527/// `Cargo.toml` must carry the dependency).
528///
529/// True iff any emitted `.rs` file references [`RUST_TOKIO_MARKER`]. This covers
530/// both the `Channel`/`spawn` concurrency runtime and the bare host-`sleep`
531/// (`tokio::time::sleep`) path; a program that uses neither emits no `tokio::`
532/// path and gets a dependency-free crate.
533fn rust_emits_tokio(emitted: &[OutputFile]) -> bool {
534    emitted.iter().any(|f| {
535        f.path.extension().and_then(|e| e.to_str()) == Some("rs")
536            && f.content.contains(RUST_TOKIO_MARKER)
537    })
538}
539
540/// The default project name when `[project] name` is absent from `bock.project`.
541const DEFAULT_PROJECT_NAME: &str = "bock-app";
542
543/// Normalize a project name to an npm-package-name-safe slug: lowercase,
544/// non-alphanumerics collapsed to `-`, leading/trailing `-` trimmed. Empty
545/// input falls back to [`DEFAULT_PROJECT_NAME`]. Used for `package.json` `name`
546/// and the Go module path.
547fn npm_name(project_name: Option<&str>) -> String {
548    let raw = project_name.unwrap_or(DEFAULT_PROJECT_NAME);
549    let slug: String = raw
550        .chars()
551        .map(|c| {
552            if c.is_ascii_alphanumeric() {
553                c.to_ascii_lowercase()
554            } else {
555                '-'
556            }
557        })
558        .collect();
559    let trimmed = slug.trim_matches('-');
560    if trimmed.is_empty() {
561        DEFAULT_PROJECT_NAME.to_string()
562    } else {
563        // Collapse runs of `-` to a single `-`.
564        let mut out = String::with_capacity(trimmed.len());
565        let mut last_dash = false;
566        for c in trimmed.chars() {
567            if c == '-' {
568                if !last_dash {
569                    out.push(c);
570                }
571                last_dash = true;
572            } else {
573                out.push(c);
574                last_dash = false;
575            }
576        }
577        out
578    }
579}
580
581/// Normalize a project name to a Python/PEP 503 distribution name: like
582/// [`npm_name`] but the import-safe module form uses `_` separators. This is the
583/// `[project] name` for `pyproject.toml` (PEP 503 allows `-`); the importable
584/// package uses `_`. Returns `(dist_name, module_name)`.
585fn python_names(project_name: Option<&str>, package_override: Option<&str>) -> (String, String) {
586    let dist = npm_name(project_name);
587    let module = package_override
588        .map(str::to_string)
589        .unwrap_or_else(|| dist.replace('-', "_"));
590    (dist, module)
591}
592
593/// The effective test framework for `target` given its validated config,
594/// applying the §20.6.2 target-appropriate default for any unset choice.
595///
596/// This is the single source of truth the build driver uses to pick which
597/// idiom [`crate::CodeGenerator::generate_tests`] emits, so the transpiled test
598/// files match the scaffolded manifest (e.g. a `package.json` with the Jest
599/// dev-dependency gets Jest-shaped test files). Returns:
600/// - js/ts: `"vitest"` (default) or `"jest"`,
601/// - python: `"pytest"` (default) or `"unittest"`,
602/// - rust: `"cargo"` (universal), go: `"go"` (universal).
603#[must_use]
604pub fn effective_test_framework(target: &str, cfg: &TargetScaffoldConfig) -> String {
605    match target {
606        "js" | "javascript" | "ts" | "typescript" => js_test_framework(cfg).to_string(),
607        "python" | "py" => py_test_framework(cfg).to_string(),
608        "rust" | "rs" => "cargo".to_string(),
609        "go" | "golang" => "go".to_string(),
610        _ => String::new(),
611    }
612}
613
614/// The JS/TS test-framework default per §20.6.2 (Vitest), honoring an explicit
615/// `test_framework` choice (`vitest` | `jest`).
616fn js_test_framework(cfg: &TargetScaffoldConfig) -> &str {
617    cfg.test_framework.as_deref().unwrap_or("vitest")
618}
619
620/// The JS/TS package-manager default per §20.6.2 (npm), honoring an explicit
621/// `[scaffolding].package_manager` choice (`npm` | `pnpm` | `yarn`).
622fn js_package_manager(cfg: &TargetScaffoldConfig) -> &str {
623    cfg.package_manager.as_deref().unwrap_or("npm")
624}
625
626/// Whether the JS/TS formatter (Prettier) is enabled. Default is `prettier`;
627/// `formatter = "none"` disables it (no `.prettierrc` emitted).
628fn js_prettier_enabled(cfg: &TargetScaffoldConfig) -> bool {
629    !matches!(cfg.formatter.as_deref(), Some("none"))
630}
631
632/// The Python test-framework default per §20.6.2 (pytest), honoring an explicit
633/// choice (`pytest` | `unittest`).
634fn py_test_framework(cfg: &TargetScaffoldConfig) -> &str {
635    cfg.test_framework.as_deref().unwrap_or("pytest")
636}
637
638/// The Python formatter default per §20.6.2 (Black), honoring an explicit choice
639/// (`black` | `ruff` | `none`).
640fn py_formatter(cfg: &TargetScaffoldConfig) -> &str {
641    cfg.formatter.as_deref().unwrap_or("black")
642}
643
644/// The Python package-manager default per §20.6.2 (pip), honoring an explicit
645/// choice (`pip` | `poetry` | `uv`).
646fn py_package_manager(cfg: &TargetScaffoldConfig) -> &str {
647    cfg.package_manager.as_deref().unwrap_or("pip")
648}
649
650// ── JS / TS scaffolder ───────────────────────────────────────────────────────
651
652/// JS/TS scaffolder: emits a rich `package.json` (name/version/`type:module`/
653/// test script + the selected test-framework dev-dependency), a `tsconfig.json`
654/// for TS, an opt-in Prettier config (`.prettierrc.json`, unless
655/// `formatter=none`), an opt-in ESLint config (`.eslintrc.json`, only when
656/// `[scaffolding].linter = "eslint"`), and a README first-contact honoring the
657/// package-manager hint. `target_id` distinguishes `"js"` from `"ts"`: TS adds
658/// `tsconfig.json` and `typescript` as a dev-dependency; the emitted run tree is
659/// the same ESM `.js`.
660struct JsScaffolder {
661    target_id: &'static str,
662}
663
664impl JsScaffolder {
665    fn is_ts(&self) -> bool {
666        self.target_id == "ts"
667    }
668
669    /// Build the `package.json`: name, version, `"type":"module"` (so
670    /// `node main.js` resolves the emitted ESM tree), a `test` script for the
671    /// selected framework, and the matching dev-dependencies. The `"type":
672    /// "module"` field is load-bearing for the toolchain run plan and must stay.
673    fn package_json(&self, ctx: &ScaffoldContext<'_>) -> String {
674        let name = npm_name(ctx.project_name);
675        let framework = js_test_framework(ctx.config);
676        let test_script = match framework {
677            "jest" => "jest",
678            _ => "vitest run",
679        };
680        let mut dev_deps: Vec<(&str, &str)> = match framework {
681            "jest" => vec![("jest", "^29.0.0")],
682            _ => vec![("vitest", "^2.0.0")],
683        };
684        if self.is_ts() {
685            // ≥ 5.7 for `rewriteRelativeImportExtensions` (see `tsconfig_json`):
686            // the emitted tree imports via `.ts` specifiers, which that flag both
687            // accepts and rewrites to `.js` on emit.
688            dev_deps.push(("typescript", "^5.7.0"));
689            // Give real users the full Node global/type surface after `npm
690            // install` (`process`, `Buffer`, etc.). NOTE: the conformance harness
691            // and `bock build` project-mode validation never run `npm install`, so
692            // this dev-dep alone does NOT fix the no-`node_modules` `tsc --noEmit`
693            // path — the vendored `node-globals.d.ts` ambient shim (emitted in
694            // `scaffold`) handles that (Q-ts-print-scaffold-types).
695            dev_deps.push(("@types/node", "^22.0.0"));
696        }
697        if js_prettier_enabled(ctx.config) {
698            dev_deps.push(("prettier", "^3.0.0"));
699        }
700        if ctx.config.linter.as_deref() == Some("eslint") {
701            dev_deps.push(("eslint", "^9.0.0"));
702        }
703        // Stable (sorted) dev-dependency order so the manifest is deterministic.
704        dev_deps.sort_by(|a, b| a.0.cmp(b.0));
705
706        let mut s = String::new();
707        s.push_str("{\n");
708        s.push_str(&format!("  \"name\": \"{name}\",\n"));
709        s.push_str("  \"version\": \"0.1.0\",\n");
710        s.push_str("  \"private\": true,\n");
711        s.push_str("  \"type\": \"module\",\n");
712        s.push_str("  \"scripts\": {\n");
713        s.push_str(&format!("    \"test\": \"{test_script}\"\n"));
714        s.push_str("  },\n");
715        s.push_str("  \"devDependencies\": {\n");
716        for (i, (dep, ver)) in dev_deps.iter().enumerate() {
717            let comma = if i + 1 < dev_deps.len() { "," } else { "" };
718            s.push_str(&format!("    \"{dep}\": \"{ver}\"{comma}\n"));
719        }
720        s.push_str("  }\n");
721        s.push_str("}\n");
722        s
723    }
724
725    /// The `tsconfig.json` for the TS target: modern ESM output (NodeNext module
726    /// resolution, strict). The emitted `.ts` source tree imports sibling modules
727    /// and the shared runtime via explicit `.ts` specifiers, which serves both run
728    /// plans:
729    ///
730    /// * `node --experimental-strip-types main.ts` resolves the `.ts` specifiers
731    ///   verbatim (the type-stripping loader never rewrites `.js`→`.ts`, so a
732    ///   `.js` specifier would `ERR_MODULE_NOT_FOUND` — see the examples-exec
733    ///   audit and `ts.rs::emit_esm_imports`);
734    /// * `tsc -p .` accepts the `.ts` specifiers via `rewriteRelativeImportExtensions`,
735    ///   which also rewrites them to `.js` in the *emitted* JS so the conformance
736    ///   harness's `tsc -p .` → `node main.js` plan still runs (see
737    ///   `bock-build::toolchain`). `tsc --noEmit -p .` likewise type-checks clean.
738    ///
739    /// `rewriteRelativeImportExtensions` requires TypeScript ≥ 5.7 (pinned in the
740    /// emitted `package.json`).
741    ///
742    /// The transpiled test file (`bock.test.ts`, S7) is `exclude`d from this
743    /// config's compile scope: it `import`s the test framework (`vitest`), which
744    /// is only present after `npm install`, so a build-time `tsc --noEmit -p .`
745    /// (project-mode validation, see `bock-build::toolchain`) would otherwise fail
746    /// with `TS2307: Cannot find module 'vitest'`. The test file is exercised by
747    /// the framework (`npm test` → Vitest), which installs `vitest` first and
748    /// discovers test files via its own glob — Vitest does not rely on this
749    /// `include`/`exclude` to find them, so excluding it from `tsc`'s scope does
750    /// not affect the test run. This mirrors the per-file validation loop, which
751    /// skips the transpiled test files (`bock-cli::build::is_transpiled_test_file`).
752    fn tsconfig_json() -> String {
753        "{\n  \"compilerOptions\": {\n    \"target\": \"ES2022\",\n    \
754         \"module\": \"NodeNext\",\n    \"moduleResolution\": \"NodeNext\",\n    \
755         \"rewriteRelativeImportExtensions\": true,\n    \
756         \"strict\": true,\n    \"esModuleInterop\": true,\n    \
757         \"skipLibCheck\": true,\n    \"forceConsistentCasingInFileNames\": true\n  },\n  \
758         \"include\": [\"**/*.ts\"],\n  \"exclude\": [\"bock.test.ts\"]\n}\n"
759            .to_string()
760    }
761
762    /// A baseline Prettier config (`.prettierrc.json`). Emitted unless
763    /// `formatter = "none"`. Kept minimal so it agrees with Bock's emitted JS/TS
764    /// style (§20.6.2 codegen-formatter agreement; the `--check` gate is S7).
765    fn prettierrc() -> String {
766        "{\n  \"semi\": true,\n  \"singleQuote\": false\n}\n".to_string()
767    }
768
769    /// A baseline flat ESLint config (`eslint.config.js`). Emitted only when
770    /// `[scaffolding].linter = "eslint"` (shallow, opt-in). Minimal so it does
771    /// not conflict with Bock's generated patterns.
772    fn eslint_config() -> String {
773        "export default [\n  {\n    languageOptions: { ecmaVersion: \"latest\", \
774         sourceType: \"module\" },\n    rules: {}\n  }\n];\n"
775            .to_string()
776    }
777
778    fn readme(&self, ctx: &ScaffoldContext<'_>) -> String {
779        let name = npm_name(ctx.project_name);
780        let pm = js_package_manager(ctx.config);
781        let framework = js_test_framework(ctx.config);
782        let lang = if self.is_ts() {
783            "TypeScript"
784        } else {
785            "JavaScript"
786        };
787        let install = match pm {
788            "pnpm" => "pnpm install",
789            "yarn" => "yarn",
790            _ => "npm install",
791        };
792        let test_cmd = match pm {
793            "pnpm" => "pnpm test",
794            "yarn" => "yarn test",
795            _ => "npm test",
796        };
797        let run_cmd = if self.is_ts() {
798            // Build by project (`tsc -p .`) so the scaffolded `tsconfig.json`
799            // applies — `rewriteRelativeImportExtensions` rewrites the `.ts`
800            // import specifiers to `.js` on emit, so `node main.js` runs the
801            // emitted tree. (For a no-build run, `node --experimental-strip-types
802            // main.ts` executes the `.ts` source directly.)
803            "tsc -p . && node main.js"
804        } else {
805            "node main.js"
806        };
807        format!(
808            "# {name}\n\n\
809             {lang} output generated by [Bock](https://bock-lang.org) (project mode).\n\n\
810             ## Run\n\n\
811             ```sh\n{install}\n{run_cmd}\n```\n\n\
812             ## Test\n\n\
813             Tests are generated as {framework} tests:\n\n\
814             ```sh\n{test_cmd}\n```\n\n\
815             > Generated by `bock build`. Re-running the build regenerates this output.\n"
816        )
817    }
818}
819
820impl Scaffolder for JsScaffolder {
821    fn target_id(&self) -> &'static str {
822        self.target_id
823    }
824
825    fn scaffold(&self, ctx: &ScaffoldContext<'_>) -> Result<Vec<OutputFile>, ScaffoldError> {
826        let mut files = vec![
827            out_file("package.json", self.package_json(ctx)),
828            out_file("README.md", self.readme(ctx)),
829        ];
830        if self.is_ts() {
831            files.push(out_file("tsconfig.json", Self::tsconfig_json()));
832            // Vendored ambient shim for Node globals (Q-ts-print-scaffold-types).
833            // Bare `print(...)` lowers to `process.stdout.write(...)` on TS;
834            // `process` is a Node global not in TypeScript's bundled libs, so a
835            // build-time `tsc --noEmit -p .` (project-mode validation, and the
836            // conformance harness) fails with `TS2591: Cannot find name 'process'`
837            // because neither path runs `npm install` to materialize
838            // `@types/node`. `declare var process: any;` resolves `process` with
839            // no `node_modules` present AND coexists with the real `@types/node`
840            // once installed (it neither conflicts with nor narrows it — unlike a
841            // structural decl / `NodeJS.Process` merge / `typeRoots` stub, all of
842            // which regressed `process.argv`). This file matches the existing
843            // `"include": ["**/*.ts"]` glob, so no `tsconfig.json` change is needed.
844            files.push(out_file(
845                "node-globals.d.ts",
846                "declare var process: any;\n".to_string(),
847            ));
848        }
849        if js_prettier_enabled(ctx.config) {
850            files.push(out_file(".prettierrc.json", Self::prettierrc()));
851        }
852        if ctx.config.linter.as_deref() == Some("eslint") {
853            files.push(out_file("eslint.config.js", Self::eslint_config()));
854        }
855        Ok(files)
856    }
857}
858
859// ── Python scaffolder ────────────────────────────────────────────────────────
860
861/// Python scaffolder: emits a `pyproject.toml` (project metadata + the selected
862/// test framework's config + the Black/Ruff formatter config), an opt-in Ruff
863/// linter config (only when `[scaffolding].linter` is set), and a README
864/// first-contact honoring the package-manager hint (pip | Poetry | uv). The
865/// per-module Python tree still runs directly (`python3 main.py`); the
866/// `pyproject.toml` is inert for running and carries metadata/tooling config.
867struct PythonScaffolder;
868
869impl PythonScaffolder {
870    fn pyproject(ctx: &ScaffoldContext<'_>) -> String {
871        let (dist, _module) = python_names(ctx.project_name, ctx.config.package.as_deref());
872        let framework = py_test_framework(ctx.config);
873        let formatter = py_formatter(ctx.config);
874
875        let mut s = String::new();
876        s.push_str("[project]\n");
877        s.push_str(&format!("name = \"{dist}\"\n"));
878        s.push_str("version = \"0.1.0\"\n");
879        s.push_str("requires-python = \">=3.9\"\n\n");
880
881        // Test framework: pytest gets a `[tool.pytest.ini_options]`; unittest is
882        // stdlib and needs no config (the README documents `python -m unittest`).
883        s.push_str("[build-system]\nrequires = [\"setuptools\"]\nbuild-backend = \"setuptools.build_meta\"\n\n");
884        s.push_str(&format!(
885            "[dependency-groups]\n# Test framework: {framework}\ndev = [\n"
886        ));
887        match framework {
888            "unittest" => s.push_str("]\n"),
889            _ => s.push_str("  \"pytest>=8.0\",\n]\n"),
890        }
891        if framework == "pytest" {
892            s.push_str("\n[tool.pytest.ini_options]\ntestpaths = [\".\"]\n");
893        }
894
895        // Formatter config: Black or Ruff format. `none` emits no formatter
896        // section.
897        match formatter {
898            "black" => {
899                s.push_str("\n[tool.black]\nline-length = 88\ntarget-version = [\"py39\"]\n");
900            }
901            "ruff" => {
902                s.push_str("\n[tool.ruff]\nline-length = 88\n\n[tool.ruff.format]\nquote-style = \"double\"\n");
903            }
904            _ => {}
905        }
906
907        // Linter config (shallow, opt-in): ruff check or pylint.
908        match ctx.config.linter.as_deref() {
909            Some("ruff") => {
910                // Ruff's lint section; coexists with a `[tool.ruff.format]` block.
911                if formatter != "ruff" {
912                    s.push_str("\n[tool.ruff]\nline-length = 88\n");
913                }
914                s.push_str("\n[tool.ruff.lint]\nselect = [\"E\", \"F\"]\n");
915            }
916            Some("pylint") => {
917                s.push_str(
918                    "\n[tool.pylint.main]\n# Baseline Pylint config; add rules as needed.\n",
919                );
920            }
921            _ => {}
922        }
923        s
924    }
925
926    fn readme(ctx: &ScaffoldContext<'_>) -> String {
927        let (dist, _module) = python_names(ctx.project_name, ctx.config.package.as_deref());
928        let pm = py_package_manager(ctx.config);
929        let framework = py_test_framework(ctx.config);
930        let install = match pm {
931            "poetry" => "poetry install",
932            "uv" => "uv sync",
933            _ => "pip install -e .",
934        };
935        let test_cmd = match (pm, framework) {
936            (_, "unittest") => "python -m unittest".to_string(),
937            ("poetry", _) => "poetry run pytest".to_string(),
938            ("uv", _) => "uv run pytest".to_string(),
939            _ => "pytest".to_string(),
940        };
941        format!(
942            "# {dist}\n\n\
943             Python output generated by [Bock](https://bock-lang.org) (project mode).\n\n\
944             ## Run\n\n\
945             ```sh\npython3 main.py\n```\n\n\
946             ## Test\n\n\
947             Tests are generated as {framework} tests:\n\n\
948             ```sh\n{install}\n{test_cmd}\n```\n\n\
949             > Generated by `bock build`. Re-running the build regenerates this output.\n"
950        )
951    }
952}
953
954impl Scaffolder for PythonScaffolder {
955    fn target_id(&self) -> &'static str {
956        "python"
957    }
958
959    fn scaffold(&self, ctx: &ScaffoldContext<'_>) -> Result<Vec<OutputFile>, ScaffoldError> {
960        Ok(vec![
961            out_file("pyproject.toml", Self::pyproject(ctx)),
962            out_file("README.md", Self::readme(ctx)),
963        ])
964    }
965}
966
967// ── Rust scaffolder ──────────────────────────────────────────────────────────
968
969/// Rust scaffolder: emits a `Cargo.toml` (`[package]` metadata + `[[bin]]` at
970/// `src/main.rs`, `tokio` only when the emitted Rust references it — the
971/// `Channel`/`spawn` concurrency runtime or the bare host-`sleep`
972/// `tokio::time::sleep` path), an opt-in `clippy.toml` (only when
973/// `[scaffolding].linter =
974/// "clippy"`), and a README first-contact. rustfmt is universal/always-on, so no
975/// formatter config is emitted (the formatter-clean gate is S7). cargo test is
976/// the universal test framework: the README documents `cargo test` and the
977/// transpiled test files (S7) land under the same crate.
978struct RustScaffolder;
979
980impl RustScaffolder {
981    /// Render the `Cargo.toml`. `tokio` (with the features the emitted async/
982    /// concurrency code needs) is included only when the emitted Rust references
983    /// it — a `Channel`/`spawn` program (the concurrency runtime) or a bare host
984    /// `sleep(..)` (`tokio::time::sleep` under `#[tokio::main]`) — so a program
985    /// that uses neither has no dependencies and `cargo run` stays fast. The
986    /// `time` feature backs `tokio::time::sleep`, `rt-multi-thread`/`macros` back
987    /// `#[tokio::main]`, and `sync` backs the channel runtime. The crate name is
988    /// `bock_app` (a fixed, Rust-identifier-safe
989    /// name — the emitted `src/main.rs` and the toolchain run plan reference the
990    /// `bock_app` bin), independent of the project name.
991    fn cargo_toml(needs_tokio: bool) -> String {
992        // An explicit empty `[workspace]` table makes the generated crate its own
993        // workspace root. Without it, `cargo` walks up the directory tree looking
994        // for a parent `[workspace]`; when the build output lands inside a parent
995        // Cargo workspace (e.g. the Bock repo's own `temp/` scratch tree, or any
996        // user repo that is itself a workspace) cargo errors with "current package
997        // believes it's in a workspace when it's not". Declaring an empty
998        // `[workspace]` opts the generated crate out of any enclosing workspace so
999        // `cargo build`/`cargo run` work regardless of where the output is placed.
1000        let mut s = String::from(
1001            "[package]\n\
1002             name = \"bock_app\"\n\
1003             version = \"0.1.0\"\n\
1004             edition = \"2021\"\n\n\
1005             [[bin]]\n\
1006             name = \"bock_app\"\n\
1007             path = \"src/main.rs\"\n\n\
1008             [workspace]\n",
1009        );
1010        if needs_tokio {
1011            s.push_str(
1012                "\n[dependencies]\n\
1013                 tokio = { version = \"1\", features = [\"rt-multi-thread\", \"macros\", \"sync\", \"time\"] }\n",
1014            );
1015        }
1016        s
1017    }
1018
1019    fn readme(ctx: &ScaffoldContext<'_>) -> String {
1020        let name = npm_name(ctx.project_name);
1021        format!(
1022            "# {name}\n\n\
1023             Rust output generated by [Bock](https://bock-lang.org) (project mode).\n\n\
1024             ## Run\n\n\
1025             ```sh\ncargo run\n```\n\n\
1026             ## Test\n\n\
1027             Tests are generated as `cargo test` tests:\n\n\
1028             ```sh\ncargo test\n```\n\n\
1029             Formatting is `rustfmt` (`cargo fmt`); the output is rustfmt-clean.\n\n\
1030             > Generated by `bock build`. Re-running the build regenerates this output.\n"
1031        )
1032    }
1033}
1034
1035impl Scaffolder for RustScaffolder {
1036    fn target_id(&self) -> &'static str {
1037        "rust"
1038    }
1039
1040    fn scaffold(&self, ctx: &ScaffoldContext<'_>) -> Result<Vec<OutputFile>, ScaffoldError> {
1041        let needs_tokio = rust_emits_tokio(ctx.emitted);
1042        let mut files = vec![
1043            out_file("Cargo.toml", Self::cargo_toml(needs_tokio)),
1044            out_file("README.md", Self::readme(ctx)),
1045        ];
1046        // Shallow, opt-in Clippy config.
1047        if ctx.config.linter.as_deref() == Some("clippy") {
1048            files.push(out_file(
1049                "clippy.toml",
1050                "# Baseline Clippy config; add lint thresholds as needed.\n".to_string(),
1051            ));
1052        }
1053        Ok(files)
1054    }
1055}
1056
1057// ── Go scaffolder ────────────────────────────────────────────────────────────
1058
1059/// Go scaffolder: emits a `go.mod` (module path + go version — the module path
1060/// honors `[targets.go] module` when set, else a slug of the project name), an
1061/// opt-in `.golangci.yml` (only when `[scaffolding].linter = "golangci-lint"`),
1062/// and a README first-contact. gofmt is universal/always-on (no config; the
1063/// formatter-clean gate is S7). `go test` is the universal test framework.
1064struct GoScaffolder;
1065
1066impl GoScaffolder {
1067    /// The Go module path: an explicit `[targets.go] module` when set, else a
1068    /// name slug. A bare slug (no `/`) is a valid local module path for
1069    /// `go run .`. The go version is conservative (1.21) so the output builds on
1070    /// a wide range of installed toolchains.
1071    fn go_mod(ctx: &ScaffoldContext<'_>) -> String {
1072        let module = ctx
1073            .config
1074            .module
1075            .clone()
1076            .unwrap_or_else(|| npm_name(ctx.project_name).replace('-', "_"));
1077        format!("module {module}\n\ngo 1.21\n")
1078    }
1079
1080    fn readme(ctx: &ScaffoldContext<'_>) -> String {
1081        let name = npm_name(ctx.project_name);
1082        format!(
1083            "# {name}\n\n\
1084             Go output generated by [Bock](https://bock-lang.org) (project mode).\n\n\
1085             ## Run\n\n\
1086             ```sh\ngo run .\n```\n\n\
1087             ## Test\n\n\
1088             Tests are generated as `go test` tests (stdlib `testing`):\n\n\
1089             ```sh\ngo test ./...\n```\n\n\
1090             Formatting is `gofmt` (`go fmt ./...`); the output is gofmt-clean.\n\n\
1091             > Generated by `bock build`. Re-running the build regenerates this output.\n"
1092        )
1093    }
1094}
1095
1096impl Scaffolder for GoScaffolder {
1097    fn target_id(&self) -> &'static str {
1098        "go"
1099    }
1100
1101    fn scaffold(&self, ctx: &ScaffoldContext<'_>) -> Result<Vec<OutputFile>, ScaffoldError> {
1102        let mut files = vec![
1103            out_file("go.mod", Self::go_mod(ctx)),
1104            out_file("README.md", Self::readme(ctx)),
1105        ];
1106        // Shallow, opt-in golangci-lint config.
1107        if ctx.config.linter.as_deref() == Some("golangci-lint") {
1108            files.push(out_file(
1109                ".golangci.yml",
1110                "# Baseline golangci-lint config; enable linters as needed.\nlinters:\n  enable:\n    - gofmt\n".to_string(),
1111            ));
1112        }
1113        Ok(files)
1114    }
1115}
1116
1117/// Construct an [`OutputFile`] at a build-root-relative `path` with `content`
1118/// and no source map (scaffolding files are not transpiled source).
1119fn out_file(path: &str, content: String) -> OutputFile {
1120    OutputFile {
1121        path: Path::new(path).to_path_buf(),
1122        content,
1123        source_map: None,
1124    }
1125}
1126
1127#[cfg(test)]
1128mod tests {
1129    use super::*;
1130
1131    // ── Defaults / empty ────────────────────────────────────────────────────
1132
1133    #[test]
1134    fn no_targets_table_yields_all_none() {
1135        let cfg = ScaffoldConfig::from_project_toml("[project]\nname = \"x\"\n").unwrap();
1136        for t in SCAFFOLD_TARGETS {
1137            let tc = cfg.target(t).unwrap();
1138            assert_eq!(*tc, TargetScaffoldConfig::default(), "target {t}");
1139        }
1140    }
1141
1142    #[test]
1143    fn empty_document_yields_default() {
1144        let cfg = ScaffoldConfig::from_project_toml("").unwrap();
1145        assert_eq!(cfg, ScaffoldConfig::default());
1146    }
1147
1148    #[test]
1149    fn empty_per_target_table_is_all_none() {
1150        let cfg = ScaffoldConfig::from_project_toml("[targets.js]\n").unwrap();
1151        assert_eq!(*cfg.target("js").unwrap(), TargetScaffoldConfig::default());
1152    }
1153
1154    #[test]
1155    fn unset_fields_stay_none_when_some_are_set() {
1156        let cfg =
1157            ScaffoldConfig::from_project_toml("[targets.js]\ntest_framework = \"jest\"\n").unwrap();
1158        let js = cfg.target("js").unwrap();
1159        assert_eq!(js.test_framework.as_deref(), Some("jest"));
1160        assert!(js.formatter.is_none());
1161        assert!(js.linter.is_none());
1162        assert!(js.package_manager.is_none());
1163    }
1164
1165    // ── Aliases ──────────────────────────────────────────────────────────────
1166
1167    #[test]
1168    fn target_lookup_accepts_aliases() {
1169        let cfg =
1170            ScaffoldConfig::from_project_toml("[targets.python]\nformatter = \"black\"\n").unwrap();
1171        assert_eq!(
1172            cfg.target("py").unwrap().formatter.as_deref(),
1173            Some("black")
1174        );
1175        assert!(cfg.target("javascript").is_some());
1176        assert!(cfg.target("typescript").is_some());
1177        assert!(cfg.target("rs").is_some());
1178        assert!(cfg.target("golang").is_some());
1179        assert!(cfg.target("nope").is_none());
1180    }
1181
1182    // ── Valid deep config, all targets ───────────────────────────────────────
1183
1184    #[test]
1185    fn js_ts_valid_deep_and_shallow() {
1186        let src = r#"
1187[targets.js]
1188test_framework = "jest"
1189formatter = "none"
1190[targets.js.scaffolding]
1191linter = "eslint"
1192package_manager = "pnpm"
1193
1194[targets.ts]
1195test_framework = "vitest"
1196formatter = "prettier"
1197[targets.ts.scaffolding]
1198linter = "eslint"
1199package_manager = "yarn"
1200"#;
1201        let cfg = ScaffoldConfig::from_project_toml(src).unwrap();
1202        let js = cfg.target("js").unwrap();
1203        assert_eq!(js.test_framework.as_deref(), Some("jest"));
1204        assert_eq!(js.formatter.as_deref(), Some("none"));
1205        assert_eq!(js.linter.as_deref(), Some("eslint"));
1206        assert_eq!(js.package_manager.as_deref(), Some("pnpm"));
1207        let ts = cfg.target("ts").unwrap();
1208        assert_eq!(ts.test_framework.as_deref(), Some("vitest"));
1209        assert_eq!(ts.formatter.as_deref(), Some("prettier"));
1210        assert_eq!(ts.package_manager.as_deref(), Some("yarn"));
1211    }
1212
1213    #[test]
1214    fn python_valid_deep_and_shallow() {
1215        let src = r#"
1216[targets.python]
1217package = "my_app"
1218test_framework = "unittest"
1219formatter = "ruff"
1220[targets.python.scaffolding]
1221linter = "pylint"
1222package_manager = "uv"
1223"#;
1224        let cfg = ScaffoldConfig::from_project_toml(src).unwrap();
1225        let py = cfg.target("python").unwrap();
1226        assert_eq!(py.package.as_deref(), Some("my_app"));
1227        assert_eq!(py.test_framework.as_deref(), Some("unittest"));
1228        assert_eq!(py.formatter.as_deref(), Some("ruff"));
1229        assert_eq!(py.linter.as_deref(), Some("pylint"));
1230        assert_eq!(py.package_manager.as_deref(), Some("uv"));
1231    }
1232
1233    #[test]
1234    fn rust_go_free_form_and_universal_linters() {
1235        let src = r#"
1236[targets.go]
1237module = "github.com/user/my-app"
1238[targets.go.scaffolding]
1239linter = "golangci-lint"
1240
1241[targets.rust.scaffolding]
1242linter = "clippy"
1243"#;
1244        let cfg = ScaffoldConfig::from_project_toml(src).unwrap();
1245        assert_eq!(
1246            cfg.target("go").unwrap().module.as_deref(),
1247            Some("github.com/user/my-app")
1248        );
1249        assert_eq!(
1250            cfg.target("go").unwrap().linter.as_deref(),
1251            Some("golangci-lint")
1252        );
1253        assert_eq!(
1254            cfg.target("rust").unwrap().linter.as_deref(),
1255            Some("clippy")
1256        );
1257    }
1258
1259    #[test]
1260    fn value_normalized_to_lowercase() {
1261        let cfg = ScaffoldConfig::from_project_toml(
1262            "[targets.python]\nformatter = \"Black\"\ntest_framework = \"PyTest\"\n",
1263        )
1264        .unwrap();
1265        let py = cfg.target("python").unwrap();
1266        assert_eq!(py.formatter.as_deref(), Some("black"));
1267        assert_eq!(py.test_framework.as_deref(), Some("pytest"));
1268    }
1269
1270    // ── Unknown-value validation errors (one per field/target class) ─────────
1271
1272    #[test]
1273    fn unknown_js_test_framework_errors_with_options() {
1274        let err = ScaffoldConfig::from_project_toml("[targets.js]\ntest_framework = \"mocha\"\n")
1275            .unwrap_err();
1276        match &err {
1277            ScaffoldError::UnknownValue {
1278                target,
1279                field,
1280                value,
1281                options,
1282            } => {
1283                assert_eq!(target, "js");
1284                assert_eq!(field, "test_framework");
1285                assert_eq!(value, "mocha");
1286                assert_eq!(options, &vec!["vitest", "jest"]);
1287            }
1288            other => panic!("expected UnknownValue, got {other:?}"),
1289        }
1290        // The Display points at the documented options.
1291        let msg = err.to_string();
1292        assert!(msg.contains("mocha"));
1293        assert!(msg.contains("vitest"));
1294        assert!(msg.contains("jest"));
1295    }
1296
1297    #[test]
1298    fn unknown_python_formatter_errors() {
1299        let err = ScaffoldConfig::from_project_toml("[targets.python]\nformatter = \"yapf\"\n")
1300            .unwrap_err();
1301        assert!(matches!(
1302            err,
1303            ScaffoldError::UnknownValue { ref field, .. } if field == "formatter"
1304        ));
1305        let msg = err.to_string();
1306        assert!(msg.contains("black"));
1307        assert!(msg.contains("ruff"));
1308        assert!(msg.contains("none"));
1309    }
1310
1311    #[test]
1312    fn unknown_js_formatter_errors() {
1313        let err = ScaffoldConfig::from_project_toml("[targets.js]\nformatter = \"standard\"\n")
1314            .unwrap_err();
1315        assert!(matches!(err, ScaffoldError::UnknownValue { .. }));
1316    }
1317
1318    #[test]
1319    fn unknown_linter_errors() {
1320        let err =
1321            ScaffoldConfig::from_project_toml("[targets.js.scaffolding]\nlinter = \"jshint\"\n")
1322                .unwrap_err();
1323        match err {
1324            ScaffoldError::UnknownValue { field, options, .. } => {
1325                assert_eq!(field, "linter");
1326                assert_eq!(options, vec!["eslint"]);
1327            }
1328            other => panic!("expected UnknownValue, got {other:?}"),
1329        }
1330    }
1331
1332    #[test]
1333    fn unknown_python_linter_errors() {
1334        let err = ScaffoldConfig::from_project_toml(
1335            "[targets.python.scaffolding]\nlinter = \"flake8\"\n",
1336        )
1337        .unwrap_err();
1338        assert!(matches!(
1339            err,
1340            ScaffoldError::UnknownValue { ref options, .. }
1341                if options == &vec!["ruff", "pylint"]
1342        ));
1343    }
1344
1345    #[test]
1346    fn unknown_package_manager_errors() {
1347        let err = ScaffoldConfig::from_project_toml(
1348            "[targets.python.scaffolding]\npackage_manager = \"conda\"\n",
1349        )
1350        .unwrap_err();
1351        match err {
1352            ScaffoldError::UnknownValue { field, options, .. } => {
1353                assert_eq!(field, "package_manager");
1354                assert_eq!(options, vec!["pip", "poetry", "uv"]);
1355            }
1356            other => panic!("expected UnknownValue, got {other:?}"),
1357        }
1358    }
1359
1360    // ── NotConfigurable: universal fields ────────────────────────────────────
1361
1362    #[test]
1363    fn rust_formatter_not_configurable() {
1364        let err = ScaffoldConfig::from_project_toml("[targets.rust]\nformatter = \"rustfmt\"\n")
1365            .unwrap_err();
1366        match err {
1367            ScaffoldError::NotConfigurable { target, field, .. } => {
1368                assert_eq!(target, "rust");
1369                assert_eq!(field, "formatter");
1370            }
1371            other => panic!("expected NotConfigurable, got {other:?}"),
1372        }
1373    }
1374
1375    #[test]
1376    fn go_test_framework_not_configurable() {
1377        let err = ScaffoldConfig::from_project_toml("[targets.go]\ntest_framework = \"testify\"\n")
1378            .unwrap_err();
1379        assert!(matches!(
1380            err,
1381            ScaffoldError::NotConfigurable { ref field, .. } if field == "test_framework"
1382        ));
1383    }
1384
1385    #[test]
1386    fn rust_package_manager_not_configurable() {
1387        let err = ScaffoldConfig::from_project_toml(
1388            "[targets.rust.scaffolding]\npackage_manager = \"cargo\"\n",
1389        )
1390        .unwrap_err();
1391        assert!(matches!(
1392            err,
1393            ScaffoldError::NotConfigurable { ref field, .. } if field == "package_manager"
1394        ));
1395    }
1396
1397    #[test]
1398    fn go_package_manager_not_configurable() {
1399        let err = ScaffoldConfig::from_project_toml(
1400            "[targets.go.scaffolding]\npackage_manager = \"go\"\n",
1401        )
1402        .unwrap_err();
1403        assert!(matches!(err, ScaffoldError::NotConfigurable { .. }));
1404    }
1405
1406    // ── Parse errors ─────────────────────────────────────────────────────────
1407
1408    #[test]
1409    fn invalid_toml_is_parse_error() {
1410        let err = ScaffoldConfig::from_project_toml("not = valid = toml").unwrap_err();
1411        assert!(matches!(err, ScaffoldError::Parse(_)));
1412    }
1413
1414    #[test]
1415    fn wrong_field_type_is_parse_error() {
1416        let err =
1417            ScaffoldConfig::from_project_toml("[targets.js]\ntest_framework = 42\n").unwrap_err();
1418        assert!(matches!(err, ScaffoldError::Parse(_)));
1419    }
1420
1421    #[test]
1422    fn unrelated_sections_are_ignored() {
1423        // `[ai]`, `[strictness]`, etc. coexist without affecting target parsing.
1424        let src = r#"
1425[project]
1426name = "demo"
1427[strictness]
1428default = "development"
1429[ai]
1430provider = "anthropic"
1431[targets.js]
1432formatter = "prettier"
1433"#;
1434        let cfg = ScaffoldConfig::from_project_toml(src).unwrap();
1435        assert_eq!(
1436            cfg.target("js").unwrap().formatter.as_deref(),
1437            Some("prettier")
1438        );
1439    }
1440
1441    // ── Scaffolder framework ─────────────────────────────────────────────────
1442
1443    #[test]
1444    fn scaffolder_registered_for_all_targets() {
1445        for t in SCAFFOLD_TARGETS {
1446            assert!(scaffolder_for(t).is_some(), "scaffolder for {t}");
1447        }
1448        assert!(scaffolder_for("nope").is_none());
1449    }
1450
1451    /// Find the scaffolded file at `rel` in `files`, or panic with the set of
1452    /// emitted paths (so a missing/renamed file fails loudly).
1453    fn file<'a>(files: &'a [OutputFile], rel: &str) -> &'a OutputFile {
1454        files
1455            .iter()
1456            .find(|f| f.path == Path::new(rel))
1457            .unwrap_or_else(|| {
1458                let paths: Vec<String> =
1459                    files.iter().map(|f| f.path.display().to_string()).collect();
1460                panic!("no scaffolded `{rel}`; emitted: {paths:?}")
1461            })
1462    }
1463
1464    /// True iff any emitted file is at `rel`.
1465    fn has(files: &[OutputFile], rel: &str) -> bool {
1466        files.iter().any(|f| f.path == Path::new(rel))
1467    }
1468
1469    // ── Name normalization ───────────────────────────────────────────────────
1470
1471    #[test]
1472    fn npm_name_slugs_and_defaults() {
1473        assert_eq!(npm_name(Some("My App")), "my-app");
1474        assert_eq!(npm_name(Some("Foo__Bar!!")), "foo-bar");
1475        assert_eq!(npm_name(Some("--weird--")), "weird");
1476        assert_eq!(npm_name(Some("")), DEFAULT_PROJECT_NAME);
1477        assert_eq!(npm_name(None), DEFAULT_PROJECT_NAME);
1478    }
1479
1480    #[test]
1481    fn python_names_dist_and_module() {
1482        let (dist, module) = python_names(Some("My App"), None);
1483        assert_eq!(dist, "my-app");
1484        assert_eq!(module, "my_app");
1485        // A `package` override wins for the module form.
1486        let (_dist, module) = python_names(Some("my-app"), Some("custom_pkg"));
1487        assert_eq!(module, "custom_pkg");
1488    }
1489
1490    // ── Rust ───────────────────────────────────────────────────────────────
1491
1492    #[test]
1493    fn rust_scaffolder_emits_cargo_toml_and_readme() {
1494        let cfg = TargetScaffoldConfig::default();
1495        let files = run_scaffolder("rust", &cfg, &[], Some("my-app")).unwrap();
1496        let cargo = file(&files, "Cargo.toml");
1497        assert!(cargo.content.contains("[package]"));
1498        assert!(cargo.content.contains("name = \"bock_app\""));
1499        assert!(cargo.content.contains("path = \"src/main.rs\""));
1500        // Empty `[workspace]` table opts the crate out of any enclosing Cargo
1501        // workspace so the build works wherever the output is placed.
1502        assert!(cargo.content.contains("[workspace]"));
1503        // No concurrency runtime emitted ⇒ no tokio dependency.
1504        assert!(!cargo.content.contains("tokio"));
1505        let readme = file(&files, "README.md");
1506        assert!(readme.content.contains("# my-app"));
1507        assert!(readme.content.contains("cargo run"));
1508        assert!(readme.content.contains("cargo test"));
1509        // rustfmt universal/always-on: no formatter config file.
1510        assert!(!has(&files, "rustfmt.toml"));
1511        // No linter unless opted in.
1512        assert!(!has(&files, "clippy.toml"));
1513    }
1514
1515    #[test]
1516    fn rust_scaffolder_adds_tokio_when_runtime_present() {
1517        // The `Channel`/`spawn` concurrency runtime is `tokio::sync::mpsc`-backed,
1518        // so its emitted `bock_runtime.rs` references `tokio::` — the trigger.
1519        let cfg = TargetScaffoldConfig::default();
1520        let emitted = vec![OutputFile {
1521            path: Path::new("src/bock_runtime.rs").to_path_buf(),
1522            content: "let (tx, rx) = tokio::sync::mpsc::unbounded_channel();".into(),
1523            source_map: None,
1524        }];
1525        let files = run_scaffolder("rust", &cfg, &emitted, None).unwrap();
1526        let cargo = file(&files, "Cargo.toml");
1527        assert!(cargo.content.contains("[dependencies]"));
1528        assert!(cargo.content.contains("tokio"));
1529    }
1530
1531    #[test]
1532    fn rust_scaffolder_adds_tokio_for_bare_host_sleep() {
1533        // Q-rust-host-sleep-tokio-dep: a bare host `sleep(..)` (no `Clock`
1534        // handler) lowers to `tokio::time::sleep(..)` under `#[tokio::main]` in
1535        // `src/main.rs` and emits NO `bock_runtime.rs`. Scanning the emitted Rust
1536        // for `tokio::` still triggers the dependency so the crate compiles.
1537        let cfg = TargetScaffoldConfig::default();
1538        let emitted = vec![OutputFile {
1539            path: Path::new("src/main.rs").to_path_buf(),
1540            content: "#[tokio::main]\nasync fn main() -> () {\n    \
1541                      tokio::time::sleep(std::time::Duration::from_nanos((0i64) as u64)).await;\n}\n"
1542                .into(),
1543            source_map: None,
1544        }];
1545        let files = run_scaffolder("rust", &cfg, &emitted, None).unwrap();
1546        let cargo = file(&files, "Cargo.toml");
1547        assert!(cargo.content.contains("[dependencies]"));
1548        assert!(cargo.content.contains("tokio"));
1549        // The `time` feature is what backs `tokio::time::sleep`.
1550        assert!(cargo.content.contains("\"time\""));
1551    }
1552
1553    #[test]
1554    fn rust_scaffolder_no_tokio_when_unused() {
1555        // A non-async, non-concurrent program references no `tokio::` path, so the
1556        // crate stays dependency-free and `cargo run` is fast.
1557        let cfg = TargetScaffoldConfig::default();
1558        let emitted = vec![OutputFile {
1559            path: Path::new("src/main.rs").to_path_buf(),
1560            content: "fn main() {\n    println!(\"{}\", \"hi\");\n}\n".into(),
1561            source_map: None,
1562        }];
1563        let files = run_scaffolder("rust", &cfg, &emitted, None).unwrap();
1564        let cargo = file(&files, "Cargo.toml");
1565        assert!(!cargo.content.contains("tokio"));
1566        assert!(!cargo.content.contains("[dependencies]"));
1567    }
1568
1569    #[test]
1570    fn rust_clippy_config_only_when_opted_in() {
1571        let cfg = TargetScaffoldConfig {
1572            linter: Some("clippy".into()),
1573            ..Default::default()
1574        };
1575        let files = run_scaffolder("rust", &cfg, &[], None).unwrap();
1576        assert!(has(&files, "clippy.toml"), "clippy linter opted in");
1577    }
1578
1579    // ── Go ───────────────────────────────────────────────────────────────────
1580
1581    #[test]
1582    fn go_scaffolder_emits_go_mod_and_readme() {
1583        let cfg = TargetScaffoldConfig::default();
1584        let files = run_scaffolder("go", &cfg, &[], Some("my-app")).unwrap();
1585        let go_mod = file(&files, "go.mod");
1586        // Module path defaults to a module-safe slug of the project name.
1587        assert!(go_mod.content.contains("module my_app"));
1588        assert!(go_mod.content.contains("go 1.21"));
1589        let readme = file(&files, "README.md");
1590        assert!(readme.content.contains("go run ."));
1591        assert!(readme.content.contains("go test"));
1592        assert!(!has(&files, ".golangci.yml"));
1593    }
1594
1595    #[test]
1596    fn go_module_path_honors_config() {
1597        let cfg = TargetScaffoldConfig {
1598            module: Some("github.com/user/my-app".into()),
1599            ..Default::default()
1600        };
1601        let files = run_scaffolder("go", &cfg, &[], Some("ignored")).unwrap();
1602        assert!(file(&files, "go.mod")
1603            .content
1604            .contains("module github.com/user/my-app"));
1605    }
1606
1607    #[test]
1608    fn go_golangci_config_only_when_opted_in() {
1609        let cfg = TargetScaffoldConfig {
1610            linter: Some("golangci-lint".into()),
1611            ..Default::default()
1612        };
1613        let files = run_scaffolder("go", &cfg, &[], None).unwrap();
1614        assert!(has(&files, ".golangci.yml"));
1615    }
1616
1617    // ── JS / TS ────────────────────────────────────────────────────────────
1618
1619    #[test]
1620    fn js_default_scaffolding_is_vitest_prettier_npm() {
1621        let cfg = TargetScaffoldConfig::default();
1622        let files = run_scaffolder("js", &cfg, &[], Some("My App")).unwrap();
1623        let pkg = file(&files, "package.json");
1624        assert!(pkg.content.contains("\"name\": \"my-app\""));
1625        // `"type":"module"` is load-bearing for the `node main.js` run plan.
1626        assert!(pkg.content.contains("\"type\": \"module\""));
1627        assert!(pkg.content.contains("\"vitest\""));
1628        assert!(pkg.content.contains("\"vitest run\"")); // test script
1629        assert!(!pkg.content.contains("jest"));
1630        // Default formatter = prettier ⇒ config emitted.
1631        assert!(has(&files, ".prettierrc.json"));
1632        // No tsconfig for JS.
1633        assert!(!has(&files, "tsconfig.json"));
1634        // No eslint unless opted in.
1635        assert!(!has(&files, "eslint.config.js"));
1636        let readme = file(&files, "README.md");
1637        assert!(readme.content.contains("npm install"));
1638        assert!(readme.content.contains("node main.js"));
1639    }
1640
1641    #[test]
1642    fn js_jest_and_none_formatter_branches() {
1643        let cfg = TargetScaffoldConfig {
1644            test_framework: Some("jest".into()),
1645            formatter: Some("none".into()),
1646            ..Default::default()
1647        };
1648        let files = run_scaffolder("js", &cfg, &[], None).unwrap();
1649        let pkg = file(&files, "package.json");
1650        assert!(pkg.content.contains("\"jest\""));
1651        assert!(pkg.content.contains("\"test\": \"jest\""));
1652        assert!(!pkg.content.contains("vitest"));
1653        // formatter=none ⇒ no Prettier config or dev-dep.
1654        assert!(!has(&files, ".prettierrc.json"));
1655        assert!(!pkg.content.contains("prettier"));
1656    }
1657
1658    #[test]
1659    fn ts_adds_tsconfig_and_typescript_dep() {
1660        let cfg = TargetScaffoldConfig::default();
1661        let files = run_scaffolder("ts", &cfg, &[], Some("app")).unwrap();
1662        let pkg = file(&files, "package.json");
1663        assert!(pkg.content.contains("\"typescript\""));
1664        // ≥ 5.7 is required for `rewriteRelativeImportExtensions`.
1665        assert!(pkg.content.contains("\"typescript\": \"^5.7.0\""));
1666        let ts = file(&files, "tsconfig.json");
1667        assert!(ts.content.contains("\"module\": \"NodeNext\""));
1668        assert!(ts.content.contains("\"strict\": true"));
1669        // The emitted tree imports via `.ts` specifiers (so node strip-types runs
1670        // it verbatim); `rewriteRelativeImportExtensions` lets `tsc` both accept
1671        // them and rewrite to `.js` on emit, keeping `tsc -p .` → `node main.js`.
1672        assert!(ts
1673            .content
1674            .contains("\"rewriteRelativeImportExtensions\": true"));
1675        // The transpiled test file imports `vitest` (only present after
1676        // `npm install`), so it is excluded from the build-time `tsc --noEmit
1677        // -p .` scope; the framework (Vitest) discovers and runs it separately.
1678        assert!(ts.content.contains("\"exclude\": [\"bock.test.ts\"]"));
1679        assert!(file(&files, "README.md")
1680            .content
1681            .contains("tsc -p . && node main.js"));
1682        // `@types/node` gives `npm install` users the full Node type surface;
1683        // see `node-globals.d.ts` below for the no-install (build/conformance)
1684        // path. (Q-ts-print-scaffold-types.)
1685        assert!(pkg.content.contains("\"@types/node\""));
1686        // Vendored ambient shim so bare `print(...)` (→ `process.stdout.write`)
1687        // type-checks under `tsc --noEmit -p .` with no `node_modules` present
1688        // (the conformance/build-validation path never runs `npm install`).
1689        assert!(has(&files, "node-globals.d.ts"));
1690        assert!(file(&files, "node-globals.d.ts")
1691            .content
1692            .contains("declare var process: any;"));
1693    }
1694
1695    #[test]
1696    fn js_eslint_and_package_manager_hint() {
1697        let cfg = TargetScaffoldConfig {
1698            linter: Some("eslint".into()),
1699            package_manager: Some("pnpm".into()),
1700            ..Default::default()
1701        };
1702        let files = run_scaffolder("js", &cfg, &[], None).unwrap();
1703        assert!(has(&files, "eslint.config.js"));
1704        let pkg = file(&files, "package.json");
1705        assert!(pkg.content.contains("\"eslint\""));
1706        // package_manager affects README only (shallow), not the manifest.
1707        assert!(file(&files, "README.md").content.contains("pnpm install"));
1708        assert!(file(&files, "README.md").content.contains("pnpm test"));
1709    }
1710
1711    // ── Python ─────────────────────────────────────────────────────────────
1712
1713    #[test]
1714    fn python_default_scaffolding_is_pytest_black_pip() {
1715        let cfg = TargetScaffoldConfig::default();
1716        let files = run_scaffolder("python", &cfg, &[], Some("My App")).unwrap();
1717        let py = file(&files, "pyproject.toml");
1718        assert!(py.content.contains("name = \"my-app\""));
1719        assert!(py.content.contains("pytest"));
1720        assert!(py.content.contains("[tool.pytest.ini_options]"));
1721        // Default formatter = black.
1722        assert!(py.content.contains("[tool.black]"));
1723        // No linter unless opted in.
1724        assert!(!py.content.contains("[tool.ruff.lint]"));
1725        assert!(!py.content.contains("[tool.pylint"));
1726        let readme = file(&files, "README.md");
1727        assert!(readme.content.contains("python3 main.py"));
1728        assert!(readme.content.contains("pip install"));
1729        assert!(readme.content.contains("pytest"));
1730    }
1731
1732    #[test]
1733    fn python_unittest_ruff_uv_branches() {
1734        let cfg = TargetScaffoldConfig {
1735            test_framework: Some("unittest".into()),
1736            formatter: Some("ruff".into()),
1737            linter: Some("ruff".into()),
1738            package_manager: Some("uv".into()),
1739            ..Default::default()
1740        };
1741        let files = run_scaffolder("python", &cfg, &[], Some("app")).unwrap();
1742        let py = file(&files, "pyproject.toml");
1743        // unittest is stdlib ⇒ no pytest config/dep.
1744        assert!(!py.content.contains("[tool.pytest"));
1745        assert!(!py.content.contains("pytest>="));
1746        // ruff format + ruff lint.
1747        assert!(py.content.contains("[tool.ruff.format]"));
1748        assert!(py.content.contains("[tool.ruff.lint]"));
1749        // README: uv + unittest.
1750        let readme = file(&files, "README.md");
1751        assert!(readme.content.contains("uv sync"));
1752        assert!(readme.content.contains("python -m unittest"));
1753    }
1754
1755    #[test]
1756    fn python_pylint_and_package_override() {
1757        let cfg = TargetScaffoldConfig {
1758            package: Some("custom_pkg".into()),
1759            linter: Some("pylint".into()),
1760            ..Default::default()
1761        };
1762        let files = run_scaffolder("python", &cfg, &[], Some("app")).unwrap();
1763        let py = file(&files, "pyproject.toml");
1764        assert!(py.content.contains("[tool.pylint.main]"));
1765    }
1766
1767    // ── Framework / shape ──────────────────────────────────────────────────
1768
1769    #[test]
1770    fn every_target_emits_a_readme() {
1771        let cfg = TargetScaffoldConfig::default();
1772        for t in SCAFFOLD_TARGETS {
1773            let files = run_scaffolder(t, &cfg, &[], Some("demo")).unwrap();
1774            assert!(has(&files, "README.md"), "target {t} README");
1775        }
1776    }
1777
1778    #[test]
1779    fn run_scaffolder_drops_collisions_with_emitted_tree() {
1780        // A scaffolder file colliding with an already-emitted path is dropped.
1781        let cfg = TargetScaffoldConfig::default();
1782        let emitted = vec![OutputFile {
1783            path: Path::new("package.json").to_path_buf(),
1784            content: "already here".into(),
1785            source_map: None,
1786        }];
1787        let files = run_scaffolder("js", &cfg, &emitted, None).unwrap();
1788        // package.json collides and is dropped; the README still comes through.
1789        assert!(!has(&files, "package.json"));
1790        assert!(has(&files, "README.md"));
1791    }
1792
1793    #[test]
1794    fn run_scaffolder_unknown_target_errors() {
1795        let cfg = TargetScaffoldConfig::default();
1796        let err = run_scaffolder("brainfuck", &cfg, &[], None).unwrap_err();
1797        assert!(matches!(err, ScaffoldError::Parse(_)));
1798    }
1799}