//! Build script for `aube-settings`.
//!
//! Reads `settings.toml` at the workspace root and generates two
//! sibling files in `$OUT_DIR`:
//!
//! - `settings_meta_data.rs` — `pub const SETTINGS: &[SettingMeta]`,
//!   consumed by `crate::meta`.
//! - `settings_resolved.rs` — one typed accessor per bool/string
//!   setting with at least one `sources.npmrc` or
//!   `sources.workspaceYaml` entry, consumed by `crate::values::resolved`.
//!
//! Output is built as a plain `String` via `writeln!` — no proc-macro
//! dependencies, since the generated files are straightforward.

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,
    implemented: bool,
    since: String,
    #[serde(default)]
    docs: String,
    #[serde(default)]
    sources: Sources,
    /// Opt-out escape hatch for the workspace-level accessor audit
    /// (`crates/aube-settings/tests/accessor_audit.rs`). Set to `true`
    /// when `implemented = true` but nothing calls the generated typed
    /// accessor — e.g. the setting is read via `std::env::var` behind a
    /// hand-rolled `LazyLock`, through `NpmConfig`'s string-keyed
    /// lookup, or it's an accepted-for-parity no-op that changes no
    /// behavior. Add a comment next to the flag explaining *why* it's
    /// unused so reviewers don't have to guess. Leave unset (the
    /// default `false`) for every setting whose behavior is driven by
    /// a `resolved::<name>` call — the audit then enforces the wiring.
    #[serde(default, rename = "typedAccessorUnused")]
    typed_accessor_unused: bool,
    /// Source precedence for the generated accessor, high-to-low.
    /// Valid entries: `"npmrc"`, `"workspaceYaml"`. Unspecified
    /// sources are appended in the default order
    /// (`["npmrc", "workspaceYaml"]`) so a partial override still
    /// falls back on every source. Settings that pnpm v11 reads
    /// primarily from `pnpm-workspace.yaml` (e.g.
    /// `minimumReleaseAge`) override this to
    /// `["workspaceYaml", "npmrc"]`.
    #[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() {
    // `settings.toml` lives inside this crate so it ships in the
    // published tarball — `cargo publish --verify` runs the build
    // script from `target/package/aube-settings-<ver>/`, which can
    // only see files under the crate root.
    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()));

    // Parse as an ordered map keyed by setting name. `BTreeMap` sorts
    // alphabetically which happens to match the order we want for the
    // generated slice anyway.
    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, "        implemented: {},", def.implemented).unwrap();
        writeln!(out, "        since: {},", lit(&def.since)).unwrap();
        writeln!(out, "        docs: {},", lit(&def.docs)).unwrap();
        writeln!(out, "        cli_flags: {},", slice_lit(&def.sources.cli)).unwrap();
        // Env vars: always include the pnpm/npm-convention alias
        // `npm_config_<snake>` (matching lowercase rc-file style) and
        // `NPM_CONFIG_<SCREAMING_SNAKE>` on top of whatever the
        // setting explicitly declared. Auto-synthesis keeps env
        // support working for every setting without a per-setting
        // `sources.env` edit. Explicit declarations are preserved so
        // a setting can opt in to an additional alias (e.g.
        // `AUBE_STORE_DIR`) alongside the implicit ones.
        let env_vars = merged_env_vars(name, &def.sources.env);
        writeln!(out, "        env_vars: {},", slice_lit(&env_vars)).unwrap();
        writeln!(
            out,
            "        npmrc_keys: {},",
            slice_lit(&def.sources.npmrc)
        )
        .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, "    }},").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());
    });

    // Second file: per-setting typed accessors. One function per
    // scalar setting. Accessors for settings with parseable concrete
    // defaults return `T`; accessors whose default is undefined or
    // contextual return `Option<T>`. The function signature *is* the
    // type check — you can't accidentally call `auto_install_peers`
    // expecting a String.
    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 setting that has at least one\n\
         // `sources.npmrc` or `sources.workspaceYaml` entry and a\n\
         // supported scalar type (bool or string). The accessor walks\n\
         // sources in precedence order (cli/env first, then npmrc /\n\
         // workspace.yaml). Settings with parseable concrete defaults\n\
         // return `T`; settings whose default is undefined or contextual\n\
         // return `Option<T>`.\n\n",
    );

    // Map settings.toml `type` strings to Rust return types. Anything
    // not listed here is skipped — the generator stays silent for
    // list/number settings until we have a reason to plumb one
    // through typed.
    //
    // Enum-style union literals like `"highest" | "time-based"` get
    // two passes: first we try to carve a typed enum out of the
    // variants (see [`parse_enum_variants`]); if every variant is a
    // plain lowercase-kebab identifier we emit `enum Foo { ... }`
    // plus an `Option<Foo>` accessor. Settings whose variants include
    // punctuation, empty strings, or non-string literals (`false`)
    // fall back to `Option<String>` as before — still better than
    // nothing and callers can keep hand-parsing.
    // First pass: emit a typed enum for every enum-style setting whose
    // variants all parse as plain kebab-case identifiers. The enum
    // lives inside the `resolved` module so callers write
    // `aube_settings::resolved::NodeLinker::Hoisted`. Emitted up
    // front so the accessors generated below can reference them by
    // name without caring about source order.
    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()) {
            // Two settings PascalCase-collapse to the same enum
            // identifier. Emitting only one definition would silently
            // give the second setting's accessor a type whose variants
            // don't match its declared source — a nasty surprise. Fail
            // the build so whoever renamed the setting sees it now.
            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 {
        // Types supported by the generator:
        // - `bool` → `bool` or `Option<bool>`
        // - `string` / `path` → `String` or `Option<String>` (paths are bytes on
        //   disk that we never parse into a typed path here, so String
        //   is the honest representation).
        // - `int` → `u64` or `Option<u64>`
        // - `list<string>` → `Vec<String>` or `Option<Vec<String>>`
        // - Clean enum-style unions like `"highest" | "time-based"` →
        //   `PascalName` or `Option<PascalName>`, returning one of the
        //   generated enum's variants. Settings whose variants include
        //   punctuation, empty strings, or non-string literals
        //   (`false`) fall back to `Option<String>`; callers keep
        //   hand-parsing those until the variant surface is worth
        //   plumbing through.
        let (value_ty, kind): (String, Kind) = match def.type_.as_str() {
            "bool" => ("bool".into(), Kind::Bool),
            "string" | "path" => ("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,
        };

        // No source guard: the auto-synthesized env aliases
        // (`npm_config_<snake>` / `NPM_CONFIG_<UPPER>`) give every
        // scalar-typed setting at least one source, so generating an
        // accessor is always meaningful. Skipping any setting here
        // would hide env-only and cli-only lookups behind a silent
        // `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",
            ),
        };

        // Emit source lookups in the declared precedence order. The
        // default order is `[npmrc, workspaceYaml]`; a setting whose
        // `precedence` field names only one source gets the rest
        // appended after it. Unknown source names panic loudly at
        // build time — cheaper to catch a typo here than in a user
        // bug report.
        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();
        // Enum settings walk the source chain for a *raw string* first
        // and parse once at the end. If we pushed the parse into each
        // source-specific `.and_then`, an unrecognized value in a
        // higher-precedence source (e.g. a typo in `.npmrc`) would be
        // treated as absent and let a lower-precedence source sneak
        // through — a strict precedence violation. Resolving the raw
        // string first preserves "first source with a value wins" and
        // surfaces the parse failure as `None` so the caller's default
        // applies instead of a silently-overridden value.
        for (i, src) in order.iter().enumerate() {
            let (call, arg) = match src.as_str() {
                "cli" => (cli_call, "ctx.cli"),
                "env" => (env_call, "ctx.env"),
                "npmrc" => (npmrc_call, "ctx.npmrc"),
                "workspaceYaml" => (ws_call, "ctx.workspace_yaml"),
                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> {
    // These settings have defaults that are either context-sensitive
    // in aube or routed through a crate-specific helper so callers
    // still need to distinguish "not configured" from "configured to
    // this literal value".
    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)))
}

/// Parse a `settings.toml` union type string like
/// `"highest" | "time-based" | "lowest-direct"` into the owned variant
/// list `["highest", "time-based", "lowest-direct"]`.
///
/// Returns `None` if any variant is something other than a plain
/// lowercase-kebab identifier — e.g. the `false` literal in
/// `verifyDepsBeforeRun`, the empty string in `savePrefix`, or the
/// `"^"` / `"~"` punctuation in the same setting. Those cases keep
/// using the untyped `Option<String>` accessor.
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) }
}

/// Emit a Rust `enum` plus `from_str_normalized` / `as_str` impls for
/// a generated enum setting. Variants are named by PascalCasing the
/// kebab-case spelling (`time-based` → `TimeBased`); the `as_str`
/// round-trips back to the original kebab form so anything that logs
/// or serializes the value gets the pnpm-compatible spelling.
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();
}

/// PascalCase a setting name or a kebab-case enum variant. Splits on
/// `-`, `_`, `.`, and camelCase boundaries, then uppercases the first
/// letter of each chunk.
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
}

/// Build the effective source precedence list from a setting's
/// optional `precedence` override, appending any sources the author
/// didn't mention in the default order so every source is still
/// consulted.
fn resolve_precedence(declared: &[String]) -> Vec<String> {
    // CLI and env are always highest-precedence, in that order. The
    // per-setting `precedence` override only reorders the file-based
    // sources (`npmrc`, `workspaceYaml`). Anyone who declares `cli`
    // or `env` in their precedence list gets it silently dropped
    // below because it's already pinned on top.
    let file_default = ["npmrc", "workspaceYaml"];
    let mut files: Vec<String> = Vec::with_capacity(file_default.len());
    for src in declared {
        match src.as_str() {
            "cli" | "env" => continue,
            _ if !files.contains(src) => files.push(src.clone()),
            _ => {}
        }
    }
    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
}

/// Build the effective `env_vars` list for a setting by merging the
/// explicit `sources.env` entries with auto-synthesized pnpm-compat
/// aliases. Every setting implicitly accepts both
/// `npm_config_<snake>` (lowercase, matching `.npmrc`-alike style)
/// and `NPM_CONFIG_<SCREAMING>` so users get the same experience as
/// pnpm/npm without each setting having to repeat the convention.
fn merged_env_vars(name: &str, declared: &[String]) -> Vec<String> {
    let snake = snake_case(name);
    let lower = format!("npm_config_{snake}");
    let upper = format!("NPM_CONFIG_{}", snake.to_ascii_uppercase());
    let mut out: Vec<String> = Vec::with_capacity(declared.len() + 2);
    for src in declared {
        if !out.contains(src) {
            out.push(src.clone());
        }
    }
    for implicit in [lower, upper] {
        if !out.contains(&implicit) {
            out.push(implicit);
        }
    }
    out
}

/// Convert a setting name to `snake_case` suitable for a Rust function
/// identifier. Handles camelCase (`autoInstallPeers`), SCREAMING_SNAKE
/// (`AUBE_NO_LOCK`), and mixed. Does not escape keywords — build will
/// break loudly if a setting name collides with one.
fn snake_case(name: &str) -> String {
    let mut out = String::new();
    let mut prev_lower = false;
    for c in name.chars() {
        // pnpm allows dotted setting names like
        // `peerDependencyRules.allowAny`. Flatten the dot to a
        // single underscore since Rust identifiers can't contain
        // dots — same treatment as `-` and `_`.
        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
}

/// Escape a string as a valid Rust string literal using Debug formatting.
/// `{:?}` on a `&str` produces a quoted, escaped literal with `\"`, `\\`,
/// `\n`, etc. handled correctly — good enough for every field in
/// `settings.toml` today.
fn lit(s: &str) -> String {
    format!("{s:?}")
}

/// Format a `Vec<String>` as a Rust `&[&str]` literal.
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(", "))
}
