diff --git a/server/src/config/mod.rs b/server/src/config/mod.rs index d3ea29ec..78627ef3 100644 --- a/server/src/config/mod.rs +++ b/server/src/config/mod.rs @@ -246,7 +246,13 @@ fn default_max_mesh_peers() -> usize { } /// Configuration for a project component (name, path, setup/teardown commands). +/// +/// `deny_unknown_fields` turns a top-level setting that lands inside a +/// `[[component]]` table (e.g. because it was placed below the last +/// `[[component]]` header) into a hard parse error instead of a silently +/// dropped key. #[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] #[allow(dead_code)] pub struct ComponentConfig { pub name: String, diff --git a/server/src/config/tests.rs b/server/src/config/tests.rs index a614fee6..ce1252c0 100644 --- a/server/src/config/tests.rs +++ b/server/src/config/tests.rs @@ -521,6 +521,26 @@ runtime = "openai" assert!(err.contains("unknown runtime 'openai'")); } +#[test] +fn unrecognized_key_in_component_table_errors_instead_of_silently_dropping() { + // Regression for story 1177: a top-level setting that ends up attached to + // a `[[component]]` table (e.g. because it was placed below the last + // `[[component]]` header) must be a hard error, not a silently swallowed + // key. + let toml_str = r#" +[[component]] +name = "server" +path = "." +base_branch = "main" +"#; + + let err = ProjectConfig::parse(toml_str).unwrap_err(); + assert!( + err.contains("base_branch"), + "expected error to mention the unrecognized key, got: {err}" + ); +} + // ── base_branch config ────────────────────────────────────────────────── #[test] diff --git a/server/src/io/fs/scaffold/helpers.rs b/server/src/io/fs/scaffold/helpers.rs index df2b0ab2..32a01a0b 100644 --- a/server/src/io/fs/scaffold/helpers.rs +++ b/server/src/io/fs/scaffold/helpers.rs @@ -8,8 +8,13 @@ use super::detect::detect_components_toml; use super::templates::{DEFAULT_AGENTS_TOML, DEFAULT_PROJECT_SETTINGS_TOML}; pub(super) fn generate_project_toml(root: &Path) -> String { + // TOML requires bare `key = value` settings to appear before any table + // header. Emitting components (which open with `[[component]]`) first + // would make every commented top-level setting below them silently + // attach to the last `[[component]]` table once uncommented, instead of + // the top-level document — so settings must come first. let components = detect_components_toml(root); - format!("{components}\n{DEFAULT_PROJECT_SETTINGS_TOML}") + format!("{DEFAULT_PROJECT_SETTINGS_TOML}\n{components}") } pub(super) fn write_file_if_missing(path: &Path, content: &str) -> Result<(), String> { diff --git a/server/src/io/fs/scaffold/tests.rs b/server/src/io/fs/scaffold/tests.rs index 42e7b76b..b0aa63a6 100644 --- a/server/src/io/fs/scaffold/tests.rs +++ b/server/src/io/fs/scaffold/tests.rs @@ -140,6 +140,38 @@ fn scaffold_project_toml_round_trips_through_project_config_load() { ); } +#[test] +fn scaffold_project_toml_uncommenting_base_branch_takes_effect() { + use crate::config::ProjectConfig; + + // Regression for story 1177: the scaffold template used to emit + // `[[component]]` headers before the commented top-level settings, so + // uncommenting a setting like `base_branch` attached it to the last + // `[[component]]` table instead of the top level, silently no-opping. + let dir = tempdir().unwrap(); + fs::write( + dir.path().join("Cargo.toml"), + "[package]\nname = \"myapp\"\n", + ) + .unwrap(); + scaffold_story_kit(dir.path(), 3001).unwrap(); + + let content = fs::read_to_string(dir.path().join(".huskies/project.toml")).unwrap(); + assert!( + content.contains("# base_branch = \"main\""), + "scaffold should emit a commented-out base_branch line to uncomment" + ); + let uncommented = content.replace("# base_branch = \"main\"", "base_branch = \"main\""); + + let config = + ProjectConfig::parse(&uncommented).expect("uncommented project.toml should still parse"); + assert_eq!( + config.base_branch, + Some("main".to_string()), + "uncommenting base_branch in the scaffolded project.toml must take effect" + ); +} + #[test] fn scaffold_context_is_blank_template_not_story_kit_content() { let dir = tempdir().unwrap();