use serde::Deserialize;
use std::collections::BTreeMap;
use std::fmt::Write as _;
use std::fs;
use std::path::PathBuf;
#[derive(Debug, Deserialize)]
struct SettingDef {
description: String,
#[serde(rename = "type")]
type_: String,
default: String,
#[serde(default)]
docs: String,
#[serde(default)]
sources: Sources,
#[serde(default, rename = "typedAccessorUnused")]
typed_accessor_unused: bool,
#[serde(default, rename = "npmShared")]
npm_shared: bool,
#[serde(default)]
precedence: Vec<String>,
#[serde(default)]
examples: Vec<String>,
}
#[derive(Debug, Default, Deserialize)]
struct Sources {
#[serde(default)]
cli: Vec<String>,
#[serde(default)]
env: Vec<String>,
#[serde(default)]
npmrc: Vec<String>,
#[serde(default, rename = "workspaceYaml")]
workspace_yaml: Vec<String>,
}
fn main() {
let settings_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("settings.toml");
println!("cargo:rerun-if-changed={}", settings_path.display());
let raw = fs::read_to_string(&settings_path)
.unwrap_or_else(|e| panic!("failed to read {}: {e}", settings_path.display()));
let settings: BTreeMap<String, SettingDef> = toml::from_str(&raw)
.unwrap_or_else(|e| panic!("failed to parse {}: {e}", settings_path.display()));
let mut out = String::from(
"// GENERATED by build.rs from settings.toml. Do not edit by hand.\n\
//\n\
// Regenerated automatically when settings.toml changes (via\n\
// cargo:rerun-if-changed).\n\n",
);
writeln!(out, "pub const SETTINGS: &[SettingMeta] = &[").unwrap();
for (name, def) in &settings {
writeln!(out, " SettingMeta {{").unwrap();
writeln!(out, " name: {},", lit(name)).unwrap();
writeln!(out, " description: {},", lit(&def.description)).unwrap();
writeln!(out, " type_: {},", lit(&def.type_)).unwrap();
writeln!(out, " default: {},", lit(&def.default)).unwrap();
writeln!(out, " docs: {},", lit(&def.docs)).unwrap();
writeln!(out, " cli_flags: {},", slice_lit(&def.sources.cli)).unwrap();
writeln!(out, " env_vars: {},", slice_lit(&def.sources.env)).unwrap();
let npmrc_keys = merged_npmrc_keys(&def.sources.npmrc);
writeln!(out, " npmrc_keys: {},", slice_lit(&npmrc_keys)).unwrap();
writeln!(
out,
" workspace_yaml_keys: {},",
slice_lit(&def.sources.workspace_yaml)
)
.unwrap();
writeln!(out, " examples: {},", slice_lit(&def.examples)).unwrap();
writeln!(
out,
" typed_accessor_unused: {},",
def.typed_accessor_unused
)
.unwrap();
writeln!(out, " npm_shared: {},", def.npm_shared).unwrap();
writeln!(out, " }},").unwrap();
}
writeln!(out, "];").unwrap();
let out_dir = PathBuf::from(std::env::var("OUT_DIR").unwrap());
let out_path = out_dir.join("settings_meta_data.rs");
fs::write(&out_path, out).unwrap_or_else(|e| {
panic!("failed to write {}: {e}", out_path.display());
});
let resolved = generate_resolved_accessors(&settings);
let resolved_path = out_dir.join("settings_resolved.rs");
fs::write(&resolved_path, resolved).unwrap_or_else(|e| {
panic!("failed to write {}: {e}", resolved_path.display());
});
}
fn generate_resolved_accessors(settings: &BTreeMap<String, SettingDef>) -> String {
let mut out = String::from(
"// GENERATED by build.rs from settings.toml. Do not edit by hand.\n\
//\n\
// One typed accessor per supported scalar setting (`bool`,\n\
// `string`, `path`, `url`, `int`, `list<string>`, and\n\
// enum-style string unions). The accessor walks sources in\n\
// precedence order (cli/env first, then aube config.toml /\n\
// npmrc / workspace.yaml).\n\
// Settings with parseable concrete defaults return `T`; settings\n\
// whose default is undefined or contextual return `Option<T>`.\n\n",
);
let mut seen_enum_names = std::collections::BTreeSet::new();
for (name, def) in settings {
if !def.type_.starts_with('"') {
continue;
}
let Some(variants) = parse_enum_variants(&def.type_) else {
continue;
};
let enum_name = pascal_case(name);
if !seen_enum_names.insert(enum_name.clone()) {
panic!(
"settings.toml: `{name}` maps to enum name `{enum_name}` which is already taken by another setting; rename one."
);
}
emit_enum_def(&mut out, &enum_name, &variants);
}
for (name, def) in settings {
let (value_ty, kind): (String, Kind) = match def.type_.as_str() {
"bool" => ("bool".into(), Kind::Bool),
"string" | "path" | "url" => ("String".into(), Kind::String),
"int" => ("u64".into(), Kind::U64),
"list<string>" => ("Vec<String>".into(), Kind::VecString),
t if t.starts_with('"') => match parse_enum_variants(t) {
Some(_) => (pascal_case(name), Kind::Enum),
None => ("String".into(), Kind::String),
},
_ => continue,
};
let fn_name = snake_case(name);
let default_expr = default_expr(name, def, kind, &value_ty);
let return_ty = match &default_expr {
Some(_) => value_ty.clone(),
None => format!("Option<{value_ty}>"),
};
let (npmrc_call, ws_call, env_call, cli_call) = match kind {
Kind::Bool => (
"bool_from_npmrc",
"bool_from_workspace_yaml",
"bool_from_env",
"bool_from_cli",
),
Kind::String | Kind::Enum => (
"string_from_npmrc",
"string_from_workspace_yaml",
"string_from_env",
"string_from_cli",
),
Kind::U64 => (
"u64_from_npmrc",
"u64_from_workspace_yaml",
"u64_from_env",
"u64_from_cli",
),
Kind::VecString => (
"string_list_from_npmrc",
"string_list_from_workspace_yaml",
"string_list_from_env",
"string_list_from_cli",
),
};
let order = resolve_precedence(&def.precedence);
writeln!(
out,
"/// Resolved `{name}` — delegates to the generic helpers in\n\
/// `super::*` so precedence stays central.\n\
pub fn {fn_name}(ctx: &ResolveCtx<'_>) -> {return_ty} {{"
)
.unwrap();
for (i, src) in order.iter().enumerate() {
let (call, arg) = match src.as_str() {
"cli" => (cli_call, "ctx.cli"),
"env" => (env_call, "ctx.env"),
"projectAubeConfig" => (npmrc_call, "ctx.project_aube_config"),
"projectNpmrc" => (npmrc_call, "ctx.project_npmrc"),
"userAubeConfig" => (npmrc_call, "ctx.user_aube_config"),
"userNpmrc" => (npmrc_call, "ctx.user_npmrc"),
"workspaceYaml" => (ws_call, "ctx.workspace_yaml"),
"embedderDefaults" => (npmrc_call, "ctx.embedder_defaults"),
other => panic!("{name}: unknown source `{other}` in precedence"),
};
let is_last = i + 1 == order.len();
let expr = format!("super::{call}({name:?}, {arg})");
let suffix = if kind == Kind::Enum && is_last {
format!(".and_then(|s| {value_ty}::from_str_normalized(&s))")
} else {
String::new()
};
if is_last {
let value_expr = format!("{expr}{suffix}");
match &default_expr {
Some(default) => {
if default == "vec![]" {
writeln!(out, " {value_expr}.unwrap_or_default()").unwrap()
} else {
writeln!(out, " {value_expr}.unwrap_or({default})").unwrap()
}
}
None => writeln!(out, " {value_expr}").unwrap(),
}
} else {
writeln!(out, " if let Some(v) = {expr} {{").unwrap();
if kind == Kind::Enum {
match &default_expr {
Some(default) => {
writeln!(
out,
" return {value_ty}::from_str_normalized(&v).unwrap_or({default});"
)
.unwrap();
}
None => {
writeln!(out, " return {value_ty}::from_str_normalized(&v);")
.unwrap();
}
}
} else {
match &default_expr {
Some(_) => writeln!(out, " return v;").unwrap(),
None => writeln!(out, " return Some(v);").unwrap(),
}
}
writeln!(out, " }}").unwrap();
}
}
writeln!(out, "}}\n").unwrap();
}
out
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Kind {
Bool,
String,
U64,
VecString,
Enum,
}
fn default_expr(name: &str, def: &SettingDef, kind: Kind, rust_ty: &str) -> Option<String> {
if matches!(name, "preferFrozenLockfile" | "storeDir" | "nodeVersion") {
return None;
}
let raw = def.default.trim();
if matches!(raw, "undefined" | "null") || raw.starts_with("null ") {
return None;
}
match kind {
Kind::Bool => match raw {
"true" => Some("true".to_string()),
"false" => Some("false".to_string()),
_ => None,
},
Kind::U64 => raw.parse::<u64>().ok().map(|n| format!("{n}")),
Kind::String => {
if raw == "undefined" || raw.starts_with("platform-") || raw == "auto-detected" {
None
} else if raw.starts_with('"') && raw.ends_with('"') {
parse_toml_string_literal(raw).map(|s| format!("{}.to_string()", lit(&s)))
} else if raw.contains(char::is_whitespace) || raw.contains('`') {
None
} else {
Some(format!("{}.to_string()", lit(raw)))
}
}
Kind::VecString => parse_default_string_list(raw).map(|items| {
let items = items
.iter()
.map(|s| format!("{}.to_string()", lit(s)))
.collect::<Vec<_>>()
.join(", ");
format!("vec![{items}]")
}),
Kind::Enum => parse_toml_string_literal(raw).and_then(|s| enum_variant_expr(rust_ty, &s)),
}
}
fn parse_toml_string_literal(raw: &str) -> Option<String> {
let parsed: toml::Value = toml::from_str(&format!("value = {raw}")).ok()?;
parsed.get("value")?.as_str().map(|s| s.to_string())
}
fn parse_default_string_list(raw: &str) -> Option<Vec<String>> {
let parsed: toml::Value = toml::from_str(&format!("value = {raw}")).ok()?;
let arr = parsed.get("value")?.as_array()?;
arr.iter()
.map(|v| v.as_str().map(|s| s.to_string()))
.collect()
}
fn enum_variant_expr(enum_name: &str, raw: &str) -> Option<String> {
if raw.is_empty()
|| !raw
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
{
return None;
}
Some(format!("{enum_name}::{}", pascal_case(raw)))
}
fn parse_enum_variants(type_spec: &str) -> Option<Vec<String>> {
let mut out = Vec::new();
for piece in type_spec.split('|') {
let piece = piece.trim();
let stripped = piece.strip_prefix('"').and_then(|s| s.strip_suffix('"'))?;
if stripped.is_empty() {
return None;
}
if !stripped
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
{
return None;
}
if !stripped
.chars()
.next()
.is_some_and(|c| c.is_ascii_lowercase())
{
return None;
}
out.push(stripped.to_string());
}
if out.is_empty() { None } else { Some(out) }
}
fn emit_enum_def(out: &mut String, enum_name: &str, variants: &[String]) {
writeln!(out, "#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]").unwrap();
writeln!(out, "pub enum {enum_name} {{").unwrap();
for v in variants {
writeln!(out, " {},", pascal_case(v)).unwrap();
}
writeln!(out, "}}\n").unwrap();
writeln!(out, "impl {enum_name} {{").unwrap();
writeln!(
out,
" /// Parse a raw setting value into this typed enum.\n\
///\n\
/// Input is trimmed and lowercased before matching, so\n\
/// `.npmrc` entries like `Time-Based` or ` hoisted\\n`\n\
/// resolve the same way pnpm's own parser does. Unknown\n\
/// values return `None`; accessors with generated defaults\n\
/// turn that into the declared default.\n\
pub fn from_str_normalized(s: &str) -> Option<Self> {{\n\
\x20 match s.trim().to_ascii_lowercase().as_str() {{"
)
.unwrap();
for v in variants {
writeln!(out, " {v:?} => Some(Self::{}),", pascal_case(v)).unwrap();
}
writeln!(out, " _ => None,").unwrap();
writeln!(out, " }}").unwrap();
writeln!(out, " }}\n").unwrap();
writeln!(
out,
" /// Kebab-case spelling of the variant, matching what a\n\
/// user would write in `.npmrc` / `pnpm-workspace.yaml`.\n\
pub fn as_str(&self) -> &'static str {{\n\
\x20 match self {{"
)
.unwrap();
for v in variants {
writeln!(out, " Self::{} => {v:?},", pascal_case(v)).unwrap();
}
writeln!(out, " }}").unwrap();
writeln!(out, " }}").unwrap();
writeln!(out, "}}\n").unwrap();
}
fn pascal_case(name: &str) -> String {
let mut out = String::with_capacity(name.len());
let mut upper_next = true;
let mut prev_lower = false;
for c in name.chars() {
if c == '-' || c == '_' || c == '.' {
upper_next = true;
prev_lower = false;
continue;
}
if c.is_ascii_uppercase() && prev_lower {
out.push(c);
prev_lower = false;
continue;
}
if upper_next {
out.extend(c.to_uppercase());
upper_next = false;
} else {
out.push(c);
}
prev_lower = c.is_ascii_lowercase() || c.is_ascii_digit();
}
out
}
fn resolve_precedence(declared: &[String]) -> Vec<String> {
let file_default = [
"projectAubeConfig",
"projectNpmrc",
"workspaceYaml",
"userAubeConfig",
"userNpmrc",
"embedderDefaults",
];
let mut files: Vec<String> = Vec::with_capacity(file_default.len());
for src in declared {
let expansion: &[&str] = match src.as_str() {
"cli" | "env" => continue,
"npmrc" => &["projectNpmrc", "userNpmrc"],
"aubeConfig" => &["projectAubeConfig", "userAubeConfig"],
other => &[other],
};
for name in expansion {
let name = (*name).to_string();
if !files.contains(&name) {
files.push(name);
}
}
}
for src in file_default {
if !files.iter().any(|s| s == src) {
files.push(src.to_string());
}
}
let mut out = vec!["cli".to_string(), "env".to_string()];
out.extend(files);
out
}
fn merged_npmrc_keys(declared: &[String]) -> Vec<String> {
let mut out: Vec<String> = Vec::with_capacity(declared.len() * 2);
for src in declared {
if !out.contains(src) {
out.push(src.clone());
}
}
for src in declared {
if !is_case_convertible_key(src) {
continue;
}
for alias in [to_kebab_case(src), to_camel_case(src)] {
if alias != *src && !out.contains(&alias) {
out.push(alias);
}
}
}
out
}
fn is_case_convertible_key(key: &str) -> bool {
!(key.starts_with('/') || key.starts_with('@') || key.contains(':'))
}
fn to_kebab_case(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 4);
let mut prev_lower = false;
for c in s.chars() {
if c == '.' {
out.push(c);
prev_lower = false;
} else if c.is_ascii_uppercase() {
if prev_lower {
out.push('-');
}
out.push(c.to_ascii_lowercase());
prev_lower = false;
} else {
out.push(c);
prev_lower = c.is_ascii_lowercase() || c.is_ascii_digit();
}
}
out
}
fn to_camel_case(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut upper_next = false;
for c in s.chars() {
if c == '-' {
upper_next = true;
} else if upper_next {
out.push(c.to_ascii_uppercase());
upper_next = false;
} else {
out.push(c);
}
}
out
}
fn snake_case(name: &str) -> String {
let mut out = String::new();
let mut prev_lower = false;
for c in name.chars() {
if c == '-' || c == '_' || c == '.' {
if !out.ends_with('_') {
out.push('_');
}
prev_lower = false;
} else if c.is_ascii_uppercase() {
if prev_lower {
out.push('_');
}
out.push(c.to_ascii_lowercase());
prev_lower = false;
} else {
out.push(c);
prev_lower = c.is_ascii_lowercase() || c.is_ascii_digit();
}
}
out
}
fn lit(s: &str) -> String {
format!("{s:?}")
}
fn slice_lit(items: &[String]) -> String {
if items.is_empty() {
return "&[]".to_string();
}
let parts: Vec<String> = items.iter().map(|s| lit(s)).collect();
format!("&[{}]", parts.join(", "))
}