use std::path::Path;
use serde::Deserialize;
use crate::generator::OutputFile;
pub const SCAFFOLD_TARGETS: &[&str] = &["js", "ts", "python", "rust", "go"];
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ScaffoldError {
Parse(String),
UnknownValue {
target: String,
field: String,
value: String,
options: Vec<&'static str>,
},
NotConfigurable {
target: String,
field: String,
reason: &'static str,
},
}
impl std::fmt::Display for ScaffoldError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Parse(msg) => write!(f, "bock.project parse error: {msg}"),
Self::UnknownValue {
target,
field,
value,
options,
} => write!(
f,
"unknown `{field}` value `{value}` for target `{target}`; \
supported options: {}",
options.join(", ")
),
Self::NotConfigurable {
target,
field,
reason,
} => write!(
f,
"`{field}` is not configurable for target `{target}`: {reason}"
),
}
}
}
impl std::error::Error for ScaffoldError {}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct TargetScaffoldConfig {
pub test_framework: Option<String>,
pub formatter: Option<String>,
pub package: Option<String>,
pub module: Option<String>,
pub linter: Option<String>,
pub package_manager: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ScaffoldConfig {
js: TargetScaffoldConfig,
ts: TargetScaffoldConfig,
python: TargetScaffoldConfig,
rust: TargetScaffoldConfig,
go: TargetScaffoldConfig,
}
impl ScaffoldConfig {
#[must_use]
pub fn target(&self, target: &str) -> Option<&TargetScaffoldConfig> {
match target {
"js" | "javascript" => Some(&self.js),
"ts" | "typescript" => Some(&self.ts),
"python" | "py" => Some(&self.python),
"rust" | "rs" => Some(&self.rust),
"go" | "golang" => Some(&self.go),
_ => None,
}
}
pub fn from_project_toml(source: &str) -> Result<Self, ScaffoldError> {
Ok(Self::from_project_toml_with_name(source)?.0)
}
pub fn from_project_toml_with_name(
source: &str,
) -> Result<(Self, Option<String>), ScaffoldError> {
let doc: ProjectDoc =
toml::from_str(source).map_err(|e| ScaffoldError::Parse(e.to_string()))?;
let raw = doc.targets.unwrap_or_default();
let name = doc.project.and_then(|p| p.name);
let config = Self {
js: validate_target("js", raw.js)?,
ts: validate_target("ts", raw.ts)?,
python: validate_target("python", raw.python)?,
rust: validate_target("rust", raw.rust)?,
go: validate_target("go", raw.go)?,
};
Ok((config, name))
}
}
fn test_framework_options(target: &str) -> &'static [&'static str] {
match target {
"js" | "ts" => &["vitest", "jest"],
"python" => &["pytest", "unittest"],
_ => &[], }
}
fn formatter_options(target: &str) -> &'static [&'static str] {
match target {
"js" | "ts" => &["prettier", "none"],
"python" => &["black", "ruff", "none"],
_ => &[], }
}
fn linter_options(target: &str) -> &'static [&'static str] {
match target {
"js" | "ts" => &["eslint"],
"python" => &["ruff", "pylint"],
"rust" => &["clippy"],
"go" => &["golangci-lint"],
_ => &[],
}
}
fn package_manager_options(target: &str) -> &'static [&'static str] {
match target {
"js" | "ts" => &["npm", "pnpm", "yarn"],
"python" => &["pip", "poetry", "uv"],
_ => &[], }
}
fn validate_field(
target: &str,
field: &str,
value: Option<String>,
options: &[&'static str],
universal_reason: &'static str,
) -> Result<Option<String>, ScaffoldError> {
let Some(value) = value else {
return Ok(None);
};
if options.is_empty() {
return Err(ScaffoldError::NotConfigurable {
target: target.to_string(),
field: field.to_string(),
reason: universal_reason,
});
}
let normalized = value.to_ascii_lowercase();
if options.contains(&normalized.as_str()) {
Ok(Some(normalized))
} else {
Err(ScaffoldError::UnknownValue {
target: target.to_string(),
field: field.to_string(),
value,
options: options.to_vec(),
})
}
}
fn validate_target(
target: &str,
raw: Option<RawTarget>,
) -> Result<TargetScaffoldConfig, ScaffoldError> {
let Some(raw) = raw else {
return Ok(TargetScaffoldConfig::default());
};
let scaffolding = raw.scaffolding.unwrap_or_default();
let test_framework = validate_field(
target,
"test_framework",
raw.test_framework,
test_framework_options(target),
"test framework is universal (cargo test / go test) and always-on",
)?;
let formatter = validate_field(
target,
"formatter",
raw.formatter,
formatter_options(target),
"formatter is universal (rustfmt / gofmt) and always-on",
)?;
let linter = validate_field(
target,
"linter",
scaffolding.linter,
linter_options(target),
"no linter is configurable for this target",
)?;
let package_manager = validate_field(
target,
"package_manager",
scaffolding.package_manager,
package_manager_options(target),
"package manager is fixed for this target (cargo / go mod)",
)?;
Ok(TargetScaffoldConfig {
test_framework,
formatter,
package: raw.package,
module: raw.module,
linter,
package_manager,
})
}
#[derive(Debug, Deserialize, Default)]
struct ProjectDoc {
project: Option<RawProject>,
targets: Option<RawTargets>,
}
#[derive(Debug, Deserialize, Default)]
struct RawProject {
name: Option<String>,
}
#[derive(Debug, Deserialize, Default)]
struct RawTargets {
js: Option<RawTarget>,
ts: Option<RawTarget>,
python: Option<RawTarget>,
rust: Option<RawTarget>,
go: Option<RawTarget>,
}
#[derive(Debug, Deserialize, Default)]
struct RawTarget {
test_framework: Option<String>,
formatter: Option<String>,
package: Option<String>,
module: Option<String>,
scaffolding: Option<RawScaffolding>,
}
#[derive(Debug, Deserialize, Default)]
struct RawScaffolding {
linter: Option<String>,
package_manager: Option<String>,
}
pub struct ScaffoldContext<'a> {
pub target: &'a str,
pub config: &'a TargetScaffoldConfig,
pub emitted: &'a [OutputFile],
pub project_name: Option<&'a str>,
}
pub trait Scaffolder {
fn target_id(&self) -> &'static str;
fn scaffold(&self, ctx: &ScaffoldContext<'_>) -> Result<Vec<OutputFile>, ScaffoldError>;
}
#[must_use]
pub fn scaffolder_for(target: &str) -> Option<Box<dyn Scaffolder>> {
match target {
"js" | "javascript" => Some(Box::new(JsScaffolder { target_id: "js" })),
"ts" | "typescript" => Some(Box::new(JsScaffolder { target_id: "ts" })),
"python" | "py" => Some(Box::new(PythonScaffolder)),
"rust" | "rs" => Some(Box::new(RustScaffolder)),
"go" | "golang" => Some(Box::new(GoScaffolder)),
_ => None,
}
}
pub fn run_scaffolder(
target: &str,
config: &TargetScaffoldConfig,
emitted: &[OutputFile],
project_name: Option<&str>,
) -> Result<Vec<OutputFile>, ScaffoldError> {
let scaffolder = scaffolder_for(target).ok_or_else(|| {
ScaffoldError::Parse(format!("no scaffolder registered for target `{target}`"))
})?;
let ctx = ScaffoldContext {
target,
config,
emitted,
project_name,
};
let mut files = scaffolder.scaffold(&ctx)?;
files.retain(|f| !emitted.iter().any(|e| e.path == f.path));
Ok(files)
}
const RUST_TOKIO_MARKER: &str = "tokio::";
fn rust_emits_tokio(emitted: &[OutputFile]) -> bool {
emitted.iter().any(|f| {
f.path.extension().and_then(|e| e.to_str()) == Some("rs")
&& f.content.contains(RUST_TOKIO_MARKER)
})
}
const DEFAULT_PROJECT_NAME: &str = "bock-app";
fn npm_name(project_name: Option<&str>) -> String {
let raw = project_name.unwrap_or(DEFAULT_PROJECT_NAME);
let slug: String = raw
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() {
c.to_ascii_lowercase()
} else {
'-'
}
})
.collect();
let trimmed = slug.trim_matches('-');
if trimmed.is_empty() {
DEFAULT_PROJECT_NAME.to_string()
} else {
let mut out = String::with_capacity(trimmed.len());
let mut last_dash = false;
for c in trimmed.chars() {
if c == '-' {
if !last_dash {
out.push(c);
}
last_dash = true;
} else {
out.push(c);
last_dash = false;
}
}
out
}
}
fn python_names(project_name: Option<&str>, package_override: Option<&str>) -> (String, String) {
let dist = npm_name(project_name);
let module = package_override
.map(str::to_string)
.unwrap_or_else(|| dist.replace('-', "_"));
(dist, module)
}
#[must_use]
pub fn effective_test_framework(target: &str, cfg: &TargetScaffoldConfig) -> String {
match target {
"js" | "javascript" | "ts" | "typescript" => js_test_framework(cfg).to_string(),
"python" | "py" => py_test_framework(cfg).to_string(),
"rust" | "rs" => "cargo".to_string(),
"go" | "golang" => "go".to_string(),
_ => String::new(),
}
}
fn js_test_framework(cfg: &TargetScaffoldConfig) -> &str {
cfg.test_framework.as_deref().unwrap_or("vitest")
}
fn js_package_manager(cfg: &TargetScaffoldConfig) -> &str {
cfg.package_manager.as_deref().unwrap_or("npm")
}
fn js_prettier_enabled(cfg: &TargetScaffoldConfig) -> bool {
!matches!(cfg.formatter.as_deref(), Some("none"))
}
fn py_test_framework(cfg: &TargetScaffoldConfig) -> &str {
cfg.test_framework.as_deref().unwrap_or("pytest")
}
fn py_formatter(cfg: &TargetScaffoldConfig) -> &str {
cfg.formatter.as_deref().unwrap_or("black")
}
fn py_package_manager(cfg: &TargetScaffoldConfig) -> &str {
cfg.package_manager.as_deref().unwrap_or("pip")
}
struct JsScaffolder {
target_id: &'static str,
}
impl JsScaffolder {
fn is_ts(&self) -> bool {
self.target_id == "ts"
}
fn package_json(&self, ctx: &ScaffoldContext<'_>) -> String {
let name = npm_name(ctx.project_name);
let framework = js_test_framework(ctx.config);
let test_script = match framework {
"jest" => "jest",
_ => "vitest run",
};
let mut dev_deps: Vec<(&str, &str)> = match framework {
"jest" => vec![("jest", "^29.0.0")],
_ => vec![("vitest", "^2.0.0")],
};
if self.is_ts() {
dev_deps.push(("typescript", "^5.7.0"));
dev_deps.push(("@types/node", "^22.0.0"));
}
if js_prettier_enabled(ctx.config) {
dev_deps.push(("prettier", "^3.0.0"));
}
if ctx.config.linter.as_deref() == Some("eslint") {
dev_deps.push(("eslint", "^9.0.0"));
}
dev_deps.sort_by(|a, b| a.0.cmp(b.0));
let mut s = String::new();
s.push_str("{\n");
s.push_str(&format!(" \"name\": \"{name}\",\n"));
s.push_str(" \"version\": \"0.1.0\",\n");
s.push_str(" \"private\": true,\n");
s.push_str(" \"type\": \"module\",\n");
s.push_str(" \"scripts\": {\n");
s.push_str(&format!(" \"test\": \"{test_script}\"\n"));
s.push_str(" },\n");
s.push_str(" \"devDependencies\": {\n");
for (i, (dep, ver)) in dev_deps.iter().enumerate() {
let comma = if i + 1 < dev_deps.len() { "," } else { "" };
s.push_str(&format!(" \"{dep}\": \"{ver}\"{comma}\n"));
}
s.push_str(" }\n");
s.push_str("}\n");
s
}
fn tsconfig_json() -> String {
"{\n \"compilerOptions\": {\n \"target\": \"ES2022\",\n \
\"module\": \"NodeNext\",\n \"moduleResolution\": \"NodeNext\",\n \
\"rewriteRelativeImportExtensions\": true,\n \
\"strict\": true,\n \"esModuleInterop\": true,\n \
\"skipLibCheck\": true,\n \"forceConsistentCasingInFileNames\": true\n },\n \
\"include\": [\"**/*.ts\"],\n \"exclude\": [\"bock.test.ts\"]\n}\n"
.to_string()
}
fn prettierrc() -> String {
"{\n \"semi\": true,\n \"singleQuote\": false\n}\n".to_string()
}
fn eslint_config() -> String {
"export default [\n {\n languageOptions: { ecmaVersion: \"latest\", \
sourceType: \"module\" },\n rules: {}\n }\n];\n"
.to_string()
}
fn readme(&self, ctx: &ScaffoldContext<'_>) -> String {
let name = npm_name(ctx.project_name);
let pm = js_package_manager(ctx.config);
let framework = js_test_framework(ctx.config);
let lang = if self.is_ts() {
"TypeScript"
} else {
"JavaScript"
};
let install = match pm {
"pnpm" => "pnpm install",
"yarn" => "yarn",
_ => "npm install",
};
let test_cmd = match pm {
"pnpm" => "pnpm test",
"yarn" => "yarn test",
_ => "npm test",
};
let run_cmd = if self.is_ts() {
"tsc -p . && node main.js"
} else {
"node main.js"
};
format!(
"# {name}\n\n\
{lang} output generated by [Bock](https://bock-lang.org) (project mode).\n\n\
## Run\n\n\
```sh\n{install}\n{run_cmd}\n```\n\n\
## Test\n\n\
Tests are generated as {framework} tests:\n\n\
```sh\n{test_cmd}\n```\n\n\
> Generated by `bock build`. Re-running the build regenerates this output.\n"
)
}
}
impl Scaffolder for JsScaffolder {
fn target_id(&self) -> &'static str {
self.target_id
}
fn scaffold(&self, ctx: &ScaffoldContext<'_>) -> Result<Vec<OutputFile>, ScaffoldError> {
let mut files = vec![
out_file("package.json", self.package_json(ctx)),
out_file("README.md", self.readme(ctx)),
];
if self.is_ts() {
files.push(out_file("tsconfig.json", Self::tsconfig_json()));
files.push(out_file(
"node-globals.d.ts",
"declare var process: any;\n".to_string(),
));
}
if js_prettier_enabled(ctx.config) {
files.push(out_file(".prettierrc.json", Self::prettierrc()));
}
if ctx.config.linter.as_deref() == Some("eslint") {
files.push(out_file("eslint.config.js", Self::eslint_config()));
}
Ok(files)
}
}
struct PythonScaffolder;
impl PythonScaffolder {
fn pyproject(ctx: &ScaffoldContext<'_>) -> String {
let (dist, _module) = python_names(ctx.project_name, ctx.config.package.as_deref());
let framework = py_test_framework(ctx.config);
let formatter = py_formatter(ctx.config);
let mut s = String::new();
s.push_str("[project]\n");
s.push_str(&format!("name = \"{dist}\"\n"));
s.push_str("version = \"0.1.0\"\n");
s.push_str("requires-python = \">=3.9\"\n\n");
s.push_str("[build-system]\nrequires = [\"setuptools\"]\nbuild-backend = \"setuptools.build_meta\"\n\n");
s.push_str(&format!(
"[dependency-groups]\n# Test framework: {framework}\ndev = [\n"
));
match framework {
"unittest" => s.push_str("]\n"),
_ => s.push_str(" \"pytest>=8.0\",\n]\n"),
}
if framework == "pytest" {
s.push_str("\n[tool.pytest.ini_options]\ntestpaths = [\".\"]\n");
}
match formatter {
"black" => {
s.push_str("\n[tool.black]\nline-length = 88\ntarget-version = [\"py39\"]\n");
}
"ruff" => {
s.push_str("\n[tool.ruff]\nline-length = 88\n\n[tool.ruff.format]\nquote-style = \"double\"\n");
}
_ => {}
}
match ctx.config.linter.as_deref() {
Some("ruff") => {
if formatter != "ruff" {
s.push_str("\n[tool.ruff]\nline-length = 88\n");
}
s.push_str("\n[tool.ruff.lint]\nselect = [\"E\", \"F\"]\n");
}
Some("pylint") => {
s.push_str(
"\n[tool.pylint.main]\n# Baseline Pylint config; add rules as needed.\n",
);
}
_ => {}
}
s
}
fn readme(ctx: &ScaffoldContext<'_>) -> String {
let (dist, _module) = python_names(ctx.project_name, ctx.config.package.as_deref());
let pm = py_package_manager(ctx.config);
let framework = py_test_framework(ctx.config);
let install = match pm {
"poetry" => "poetry install",
"uv" => "uv sync",
_ => "pip install -e .",
};
let test_cmd = match (pm, framework) {
(_, "unittest") => "python -m unittest".to_string(),
("poetry", _) => "poetry run pytest".to_string(),
("uv", _) => "uv run pytest".to_string(),
_ => "pytest".to_string(),
};
format!(
"# {dist}\n\n\
Python output generated by [Bock](https://bock-lang.org) (project mode).\n\n\
## Run\n\n\
```sh\npython3 main.py\n```\n\n\
## Test\n\n\
Tests are generated as {framework} tests:\n\n\
```sh\n{install}\n{test_cmd}\n```\n\n\
> Generated by `bock build`. Re-running the build regenerates this output.\n"
)
}
}
impl Scaffolder for PythonScaffolder {
fn target_id(&self) -> &'static str {
"python"
}
fn scaffold(&self, ctx: &ScaffoldContext<'_>) -> Result<Vec<OutputFile>, ScaffoldError> {
Ok(vec![
out_file("pyproject.toml", Self::pyproject(ctx)),
out_file("README.md", Self::readme(ctx)),
])
}
}
struct RustScaffolder;
impl RustScaffolder {
fn cargo_toml(needs_tokio: bool) -> String {
let mut s = String::from(
"[package]\n\
name = \"bock_app\"\n\
version = \"0.1.0\"\n\
edition = \"2021\"\n\n\
[[bin]]\n\
name = \"bock_app\"\n\
path = \"src/main.rs\"\n\n\
[workspace]\n",
);
if needs_tokio {
s.push_str(
"\n[dependencies]\n\
tokio = { version = \"1\", features = [\"rt-multi-thread\", \"macros\", \"sync\", \"time\"] }\n",
);
}
s
}
fn readme(ctx: &ScaffoldContext<'_>) -> String {
let name = npm_name(ctx.project_name);
format!(
"# {name}\n\n\
Rust output generated by [Bock](https://bock-lang.org) (project mode).\n\n\
## Run\n\n\
```sh\ncargo run\n```\n\n\
## Test\n\n\
Tests are generated as `cargo test` tests:\n\n\
```sh\ncargo test\n```\n\n\
Formatting is `rustfmt` (`cargo fmt`); the output is rustfmt-clean.\n\n\
> Generated by `bock build`. Re-running the build regenerates this output.\n"
)
}
}
impl Scaffolder for RustScaffolder {
fn target_id(&self) -> &'static str {
"rust"
}
fn scaffold(&self, ctx: &ScaffoldContext<'_>) -> Result<Vec<OutputFile>, ScaffoldError> {
let needs_tokio = rust_emits_tokio(ctx.emitted);
let mut files = vec![
out_file("Cargo.toml", Self::cargo_toml(needs_tokio)),
out_file("README.md", Self::readme(ctx)),
];
if ctx.config.linter.as_deref() == Some("clippy") {
files.push(out_file(
"clippy.toml",
"# Baseline Clippy config; add lint thresholds as needed.\n".to_string(),
));
}
Ok(files)
}
}
struct GoScaffolder;
impl GoScaffolder {
fn go_mod(ctx: &ScaffoldContext<'_>) -> String {
let module = ctx
.config
.module
.clone()
.unwrap_or_else(|| npm_name(ctx.project_name).replace('-', "_"));
format!("module {module}\n\ngo 1.21\n")
}
fn readme(ctx: &ScaffoldContext<'_>) -> String {
let name = npm_name(ctx.project_name);
format!(
"# {name}\n\n\
Go output generated by [Bock](https://bock-lang.org) (project mode).\n\n\
## Run\n\n\
```sh\ngo run .\n```\n\n\
## Test\n\n\
Tests are generated as `go test` tests (stdlib `testing`):\n\n\
```sh\ngo test ./...\n```\n\n\
Formatting is `gofmt` (`go fmt ./...`); the output is gofmt-clean.\n\n\
> Generated by `bock build`. Re-running the build regenerates this output.\n"
)
}
}
impl Scaffolder for GoScaffolder {
fn target_id(&self) -> &'static str {
"go"
}
fn scaffold(&self, ctx: &ScaffoldContext<'_>) -> Result<Vec<OutputFile>, ScaffoldError> {
let mut files = vec![
out_file("go.mod", Self::go_mod(ctx)),
out_file("README.md", Self::readme(ctx)),
];
if ctx.config.linter.as_deref() == Some("golangci-lint") {
files.push(out_file(
".golangci.yml",
"# Baseline golangci-lint config; enable linters as needed.\nlinters:\n enable:\n - gofmt\n".to_string(),
));
}
Ok(files)
}
}
fn out_file(path: &str, content: String) -> OutputFile {
OutputFile {
path: Path::new(path).to_path_buf(),
content,
source_map: None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn no_targets_table_yields_all_none() {
let cfg = ScaffoldConfig::from_project_toml("[project]\nname = \"x\"\n").unwrap();
for t in SCAFFOLD_TARGETS {
let tc = cfg.target(t).unwrap();
assert_eq!(*tc, TargetScaffoldConfig::default(), "target {t}");
}
}
#[test]
fn empty_document_yields_default() {
let cfg = ScaffoldConfig::from_project_toml("").unwrap();
assert_eq!(cfg, ScaffoldConfig::default());
}
#[test]
fn empty_per_target_table_is_all_none() {
let cfg = ScaffoldConfig::from_project_toml("[targets.js]\n").unwrap();
assert_eq!(*cfg.target("js").unwrap(), TargetScaffoldConfig::default());
}
#[test]
fn unset_fields_stay_none_when_some_are_set() {
let cfg =
ScaffoldConfig::from_project_toml("[targets.js]\ntest_framework = \"jest\"\n").unwrap();
let js = cfg.target("js").unwrap();
assert_eq!(js.test_framework.as_deref(), Some("jest"));
assert!(js.formatter.is_none());
assert!(js.linter.is_none());
assert!(js.package_manager.is_none());
}
#[test]
fn target_lookup_accepts_aliases() {
let cfg =
ScaffoldConfig::from_project_toml("[targets.python]\nformatter = \"black\"\n").unwrap();
assert_eq!(
cfg.target("py").unwrap().formatter.as_deref(),
Some("black")
);
assert!(cfg.target("javascript").is_some());
assert!(cfg.target("typescript").is_some());
assert!(cfg.target("rs").is_some());
assert!(cfg.target("golang").is_some());
assert!(cfg.target("nope").is_none());
}
#[test]
fn js_ts_valid_deep_and_shallow() {
let src = r#"
[targets.js]
test_framework = "jest"
formatter = "none"
[targets.js.scaffolding]
linter = "eslint"
package_manager = "pnpm"
[targets.ts]
test_framework = "vitest"
formatter = "prettier"
[targets.ts.scaffolding]
linter = "eslint"
package_manager = "yarn"
"#;
let cfg = ScaffoldConfig::from_project_toml(src).unwrap();
let js = cfg.target("js").unwrap();
assert_eq!(js.test_framework.as_deref(), Some("jest"));
assert_eq!(js.formatter.as_deref(), Some("none"));
assert_eq!(js.linter.as_deref(), Some("eslint"));
assert_eq!(js.package_manager.as_deref(), Some("pnpm"));
let ts = cfg.target("ts").unwrap();
assert_eq!(ts.test_framework.as_deref(), Some("vitest"));
assert_eq!(ts.formatter.as_deref(), Some("prettier"));
assert_eq!(ts.package_manager.as_deref(), Some("yarn"));
}
#[test]
fn python_valid_deep_and_shallow() {
let src = r#"
[targets.python]
package = "my_app"
test_framework = "unittest"
formatter = "ruff"
[targets.python.scaffolding]
linter = "pylint"
package_manager = "uv"
"#;
let cfg = ScaffoldConfig::from_project_toml(src).unwrap();
let py = cfg.target("python").unwrap();
assert_eq!(py.package.as_deref(), Some("my_app"));
assert_eq!(py.test_framework.as_deref(), Some("unittest"));
assert_eq!(py.formatter.as_deref(), Some("ruff"));
assert_eq!(py.linter.as_deref(), Some("pylint"));
assert_eq!(py.package_manager.as_deref(), Some("uv"));
}
#[test]
fn rust_go_free_form_and_universal_linters() {
let src = r#"
[targets.go]
module = "github.com/user/my-app"
[targets.go.scaffolding]
linter = "golangci-lint"
[targets.rust.scaffolding]
linter = "clippy"
"#;
let cfg = ScaffoldConfig::from_project_toml(src).unwrap();
assert_eq!(
cfg.target("go").unwrap().module.as_deref(),
Some("github.com/user/my-app")
);
assert_eq!(
cfg.target("go").unwrap().linter.as_deref(),
Some("golangci-lint")
);
assert_eq!(
cfg.target("rust").unwrap().linter.as_deref(),
Some("clippy")
);
}
#[test]
fn value_normalized_to_lowercase() {
let cfg = ScaffoldConfig::from_project_toml(
"[targets.python]\nformatter = \"Black\"\ntest_framework = \"PyTest\"\n",
)
.unwrap();
let py = cfg.target("python").unwrap();
assert_eq!(py.formatter.as_deref(), Some("black"));
assert_eq!(py.test_framework.as_deref(), Some("pytest"));
}
#[test]
fn unknown_js_test_framework_errors_with_options() {
let err = ScaffoldConfig::from_project_toml("[targets.js]\ntest_framework = \"mocha\"\n")
.unwrap_err();
match &err {
ScaffoldError::UnknownValue {
target,
field,
value,
options,
} => {
assert_eq!(target, "js");
assert_eq!(field, "test_framework");
assert_eq!(value, "mocha");
assert_eq!(options, &vec!["vitest", "jest"]);
}
other => panic!("expected UnknownValue, got {other:?}"),
}
let msg = err.to_string();
assert!(msg.contains("mocha"));
assert!(msg.contains("vitest"));
assert!(msg.contains("jest"));
}
#[test]
fn unknown_python_formatter_errors() {
let err = ScaffoldConfig::from_project_toml("[targets.python]\nformatter = \"yapf\"\n")
.unwrap_err();
assert!(matches!(
err,
ScaffoldError::UnknownValue { ref field, .. } if field == "formatter"
));
let msg = err.to_string();
assert!(msg.contains("black"));
assert!(msg.contains("ruff"));
assert!(msg.contains("none"));
}
#[test]
fn unknown_js_formatter_errors() {
let err = ScaffoldConfig::from_project_toml("[targets.js]\nformatter = \"standard\"\n")
.unwrap_err();
assert!(matches!(err, ScaffoldError::UnknownValue { .. }));
}
#[test]
fn unknown_linter_errors() {
let err =
ScaffoldConfig::from_project_toml("[targets.js.scaffolding]\nlinter = \"jshint\"\n")
.unwrap_err();
match err {
ScaffoldError::UnknownValue { field, options, .. } => {
assert_eq!(field, "linter");
assert_eq!(options, vec!["eslint"]);
}
other => panic!("expected UnknownValue, got {other:?}"),
}
}
#[test]
fn unknown_python_linter_errors() {
let err = ScaffoldConfig::from_project_toml(
"[targets.python.scaffolding]\nlinter = \"flake8\"\n",
)
.unwrap_err();
assert!(matches!(
err,
ScaffoldError::UnknownValue { ref options, .. }
if options == &vec!["ruff", "pylint"]
));
}
#[test]
fn unknown_package_manager_errors() {
let err = ScaffoldConfig::from_project_toml(
"[targets.python.scaffolding]\npackage_manager = \"conda\"\n",
)
.unwrap_err();
match err {
ScaffoldError::UnknownValue { field, options, .. } => {
assert_eq!(field, "package_manager");
assert_eq!(options, vec!["pip", "poetry", "uv"]);
}
other => panic!("expected UnknownValue, got {other:?}"),
}
}
#[test]
fn rust_formatter_not_configurable() {
let err = ScaffoldConfig::from_project_toml("[targets.rust]\nformatter = \"rustfmt\"\n")
.unwrap_err();
match err {
ScaffoldError::NotConfigurable { target, field, .. } => {
assert_eq!(target, "rust");
assert_eq!(field, "formatter");
}
other => panic!("expected NotConfigurable, got {other:?}"),
}
}
#[test]
fn go_test_framework_not_configurable() {
let err = ScaffoldConfig::from_project_toml("[targets.go]\ntest_framework = \"testify\"\n")
.unwrap_err();
assert!(matches!(
err,
ScaffoldError::NotConfigurable { ref field, .. } if field == "test_framework"
));
}
#[test]
fn rust_package_manager_not_configurable() {
let err = ScaffoldConfig::from_project_toml(
"[targets.rust.scaffolding]\npackage_manager = \"cargo\"\n",
)
.unwrap_err();
assert!(matches!(
err,
ScaffoldError::NotConfigurable { ref field, .. } if field == "package_manager"
));
}
#[test]
fn go_package_manager_not_configurable() {
let err = ScaffoldConfig::from_project_toml(
"[targets.go.scaffolding]\npackage_manager = \"go\"\n",
)
.unwrap_err();
assert!(matches!(err, ScaffoldError::NotConfigurable { .. }));
}
#[test]
fn invalid_toml_is_parse_error() {
let err = ScaffoldConfig::from_project_toml("not = valid = toml").unwrap_err();
assert!(matches!(err, ScaffoldError::Parse(_)));
}
#[test]
fn wrong_field_type_is_parse_error() {
let err =
ScaffoldConfig::from_project_toml("[targets.js]\ntest_framework = 42\n").unwrap_err();
assert!(matches!(err, ScaffoldError::Parse(_)));
}
#[test]
fn unrelated_sections_are_ignored() {
let src = r#"
[project]
name = "demo"
[strictness]
default = "development"
[ai]
provider = "anthropic"
[targets.js]
formatter = "prettier"
"#;
let cfg = ScaffoldConfig::from_project_toml(src).unwrap();
assert_eq!(
cfg.target("js").unwrap().formatter.as_deref(),
Some("prettier")
);
}
#[test]
fn scaffolder_registered_for_all_targets() {
for t in SCAFFOLD_TARGETS {
assert!(scaffolder_for(t).is_some(), "scaffolder for {t}");
}
assert!(scaffolder_for("nope").is_none());
}
fn file<'a>(files: &'a [OutputFile], rel: &str) -> &'a OutputFile {
files
.iter()
.find(|f| f.path == Path::new(rel))
.unwrap_or_else(|| {
let paths: Vec<String> =
files.iter().map(|f| f.path.display().to_string()).collect();
panic!("no scaffolded `{rel}`; emitted: {paths:?}")
})
}
fn has(files: &[OutputFile], rel: &str) -> bool {
files.iter().any(|f| f.path == Path::new(rel))
}
#[test]
fn npm_name_slugs_and_defaults() {
assert_eq!(npm_name(Some("My App")), "my-app");
assert_eq!(npm_name(Some("Foo__Bar!!")), "foo-bar");
assert_eq!(npm_name(Some("--weird--")), "weird");
assert_eq!(npm_name(Some("")), DEFAULT_PROJECT_NAME);
assert_eq!(npm_name(None), DEFAULT_PROJECT_NAME);
}
#[test]
fn python_names_dist_and_module() {
let (dist, module) = python_names(Some("My App"), None);
assert_eq!(dist, "my-app");
assert_eq!(module, "my_app");
let (_dist, module) = python_names(Some("my-app"), Some("custom_pkg"));
assert_eq!(module, "custom_pkg");
}
#[test]
fn rust_scaffolder_emits_cargo_toml_and_readme() {
let cfg = TargetScaffoldConfig::default();
let files = run_scaffolder("rust", &cfg, &[], Some("my-app")).unwrap();
let cargo = file(&files, "Cargo.toml");
assert!(cargo.content.contains("[package]"));
assert!(cargo.content.contains("name = \"bock_app\""));
assert!(cargo.content.contains("path = \"src/main.rs\""));
assert!(cargo.content.contains("[workspace]"));
assert!(!cargo.content.contains("tokio"));
let readme = file(&files, "README.md");
assert!(readme.content.contains("# my-app"));
assert!(readme.content.contains("cargo run"));
assert!(readme.content.contains("cargo test"));
assert!(!has(&files, "rustfmt.toml"));
assert!(!has(&files, "clippy.toml"));
}
#[test]
fn rust_scaffolder_adds_tokio_when_runtime_present() {
let cfg = TargetScaffoldConfig::default();
let emitted = vec![OutputFile {
path: Path::new("src/bock_runtime.rs").to_path_buf(),
content: "let (tx, rx) = tokio::sync::mpsc::unbounded_channel();".into(),
source_map: None,
}];
let files = run_scaffolder("rust", &cfg, &emitted, None).unwrap();
let cargo = file(&files, "Cargo.toml");
assert!(cargo.content.contains("[dependencies]"));
assert!(cargo.content.contains("tokio"));
}
#[test]
fn rust_scaffolder_adds_tokio_for_bare_host_sleep() {
let cfg = TargetScaffoldConfig::default();
let emitted = vec![OutputFile {
path: Path::new("src/main.rs").to_path_buf(),
content: "#[tokio::main]\nasync fn main() -> () {\n \
tokio::time::sleep(std::time::Duration::from_nanos((0i64) as u64)).await;\n}\n"
.into(),
source_map: None,
}];
let files = run_scaffolder("rust", &cfg, &emitted, None).unwrap();
let cargo = file(&files, "Cargo.toml");
assert!(cargo.content.contains("[dependencies]"));
assert!(cargo.content.contains("tokio"));
assert!(cargo.content.contains("\"time\""));
}
#[test]
fn rust_scaffolder_no_tokio_when_unused() {
let cfg = TargetScaffoldConfig::default();
let emitted = vec![OutputFile {
path: Path::new("src/main.rs").to_path_buf(),
content: "fn main() {\n println!(\"{}\", \"hi\");\n}\n".into(),
source_map: None,
}];
let files = run_scaffolder("rust", &cfg, &emitted, None).unwrap();
let cargo = file(&files, "Cargo.toml");
assert!(!cargo.content.contains("tokio"));
assert!(!cargo.content.contains("[dependencies]"));
}
#[test]
fn rust_clippy_config_only_when_opted_in() {
let cfg = TargetScaffoldConfig {
linter: Some("clippy".into()),
..Default::default()
};
let files = run_scaffolder("rust", &cfg, &[], None).unwrap();
assert!(has(&files, "clippy.toml"), "clippy linter opted in");
}
#[test]
fn go_scaffolder_emits_go_mod_and_readme() {
let cfg = TargetScaffoldConfig::default();
let files = run_scaffolder("go", &cfg, &[], Some("my-app")).unwrap();
let go_mod = file(&files, "go.mod");
assert!(go_mod.content.contains("module my_app"));
assert!(go_mod.content.contains("go 1.21"));
let readme = file(&files, "README.md");
assert!(readme.content.contains("go run ."));
assert!(readme.content.contains("go test"));
assert!(!has(&files, ".golangci.yml"));
}
#[test]
fn go_module_path_honors_config() {
let cfg = TargetScaffoldConfig {
module: Some("github.com/user/my-app".into()),
..Default::default()
};
let files = run_scaffolder("go", &cfg, &[], Some("ignored")).unwrap();
assert!(file(&files, "go.mod")
.content
.contains("module github.com/user/my-app"));
}
#[test]
fn go_golangci_config_only_when_opted_in() {
let cfg = TargetScaffoldConfig {
linter: Some("golangci-lint".into()),
..Default::default()
};
let files = run_scaffolder("go", &cfg, &[], None).unwrap();
assert!(has(&files, ".golangci.yml"));
}
#[test]
fn js_default_scaffolding_is_vitest_prettier_npm() {
let cfg = TargetScaffoldConfig::default();
let files = run_scaffolder("js", &cfg, &[], Some("My App")).unwrap();
let pkg = file(&files, "package.json");
assert!(pkg.content.contains("\"name\": \"my-app\""));
assert!(pkg.content.contains("\"type\": \"module\""));
assert!(pkg.content.contains("\"vitest\""));
assert!(pkg.content.contains("\"vitest run\"")); assert!(!pkg.content.contains("jest"));
assert!(has(&files, ".prettierrc.json"));
assert!(!has(&files, "tsconfig.json"));
assert!(!has(&files, "eslint.config.js"));
let readme = file(&files, "README.md");
assert!(readme.content.contains("npm install"));
assert!(readme.content.contains("node main.js"));
}
#[test]
fn js_jest_and_none_formatter_branches() {
let cfg = TargetScaffoldConfig {
test_framework: Some("jest".into()),
formatter: Some("none".into()),
..Default::default()
};
let files = run_scaffolder("js", &cfg, &[], None).unwrap();
let pkg = file(&files, "package.json");
assert!(pkg.content.contains("\"jest\""));
assert!(pkg.content.contains("\"test\": \"jest\""));
assert!(!pkg.content.contains("vitest"));
assert!(!has(&files, ".prettierrc.json"));
assert!(!pkg.content.contains("prettier"));
}
#[test]
fn ts_adds_tsconfig_and_typescript_dep() {
let cfg = TargetScaffoldConfig::default();
let files = run_scaffolder("ts", &cfg, &[], Some("app")).unwrap();
let pkg = file(&files, "package.json");
assert!(pkg.content.contains("\"typescript\""));
assert!(pkg.content.contains("\"typescript\": \"^5.7.0\""));
let ts = file(&files, "tsconfig.json");
assert!(ts.content.contains("\"module\": \"NodeNext\""));
assert!(ts.content.contains("\"strict\": true"));
assert!(ts
.content
.contains("\"rewriteRelativeImportExtensions\": true"));
assert!(ts.content.contains("\"exclude\": [\"bock.test.ts\"]"));
assert!(file(&files, "README.md")
.content
.contains("tsc -p . && node main.js"));
assert!(pkg.content.contains("\"@types/node\""));
assert!(has(&files, "node-globals.d.ts"));
assert!(file(&files, "node-globals.d.ts")
.content
.contains("declare var process: any;"));
}
#[test]
fn js_eslint_and_package_manager_hint() {
let cfg = TargetScaffoldConfig {
linter: Some("eslint".into()),
package_manager: Some("pnpm".into()),
..Default::default()
};
let files = run_scaffolder("js", &cfg, &[], None).unwrap();
assert!(has(&files, "eslint.config.js"));
let pkg = file(&files, "package.json");
assert!(pkg.content.contains("\"eslint\""));
assert!(file(&files, "README.md").content.contains("pnpm install"));
assert!(file(&files, "README.md").content.contains("pnpm test"));
}
#[test]
fn python_default_scaffolding_is_pytest_black_pip() {
let cfg = TargetScaffoldConfig::default();
let files = run_scaffolder("python", &cfg, &[], Some("My App")).unwrap();
let py = file(&files, "pyproject.toml");
assert!(py.content.contains("name = \"my-app\""));
assert!(py.content.contains("pytest"));
assert!(py.content.contains("[tool.pytest.ini_options]"));
assert!(py.content.contains("[tool.black]"));
assert!(!py.content.contains("[tool.ruff.lint]"));
assert!(!py.content.contains("[tool.pylint"));
let readme = file(&files, "README.md");
assert!(readme.content.contains("python3 main.py"));
assert!(readme.content.contains("pip install"));
assert!(readme.content.contains("pytest"));
}
#[test]
fn python_unittest_ruff_uv_branches() {
let cfg = TargetScaffoldConfig {
test_framework: Some("unittest".into()),
formatter: Some("ruff".into()),
linter: Some("ruff".into()),
package_manager: Some("uv".into()),
..Default::default()
};
let files = run_scaffolder("python", &cfg, &[], Some("app")).unwrap();
let py = file(&files, "pyproject.toml");
assert!(!py.content.contains("[tool.pytest"));
assert!(!py.content.contains("pytest>="));
assert!(py.content.contains("[tool.ruff.format]"));
assert!(py.content.contains("[tool.ruff.lint]"));
let readme = file(&files, "README.md");
assert!(readme.content.contains("uv sync"));
assert!(readme.content.contains("python -m unittest"));
}
#[test]
fn python_pylint_and_package_override() {
let cfg = TargetScaffoldConfig {
package: Some("custom_pkg".into()),
linter: Some("pylint".into()),
..Default::default()
};
let files = run_scaffolder("python", &cfg, &[], Some("app")).unwrap();
let py = file(&files, "pyproject.toml");
assert!(py.content.contains("[tool.pylint.main]"));
}
#[test]
fn every_target_emits_a_readme() {
let cfg = TargetScaffoldConfig::default();
for t in SCAFFOLD_TARGETS {
let files = run_scaffolder(t, &cfg, &[], Some("demo")).unwrap();
assert!(has(&files, "README.md"), "target {t} README");
}
}
#[test]
fn run_scaffolder_drops_collisions_with_emitted_tree() {
let cfg = TargetScaffoldConfig::default();
let emitted = vec![OutputFile {
path: Path::new("package.json").to_path_buf(),
content: "already here".into(),
source_map: None,
}];
let files = run_scaffolder("js", &cfg, &emitted, None).unwrap();
assert!(!has(&files, "package.json"));
assert!(has(&files, "README.md"));
}
#[test]
fn run_scaffolder_unknown_target_errors() {
let cfg = TargetScaffoldConfig::default();
let err = run_scaffolder("brainfuck", &cfg, &[], None).unwrap_err();
assert!(matches!(err, ScaffoldError::Parse(_)));
}
}