From 43ca0cbc5967b74c1b1fe4a497c0f5e92e6ad144 Mon Sep 17 00:00:00 2001 From: dave Date: Fri, 17 Apr 2026 13:33:19 +0000 Subject: [PATCH] huskies: merge 595_story_web_ui_settings_page_with_form_based_project_toml_editor --- frontend/src/api/settings.test.ts | 71 ++++ frontend/src/api/settings.ts | 28 ++ frontend/src/components/Chat.tsx | 10 +- frontend/src/components/ChatHeader.tsx | 39 ++ frontend/src/components/SettingsPage.tsx | 461 +++++++++++++++++++++++ server/src/http/settings.rs | 412 +++++++++++++++++++- 6 files changed, 1018 insertions(+), 3 deletions(-) create mode 100644 frontend/src/components/SettingsPage.tsx diff --git a/frontend/src/api/settings.test.ts b/frontend/src/api/settings.test.ts index 963799e3..6d0d4cb1 100644 --- a/frontend/src/api/settings.test.ts +++ b/frontend/src/api/settings.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { ProjectSettings } from "./settings"; import { settingsApi } from "./settings"; const mockFetch = vi.fn(); @@ -22,7 +23,77 @@ function errorResponse(status: number, text: string) { return new Response(text, { status }); } +const defaultProjectSettings: ProjectSettings = { + default_qa: "server", + default_coder_model: null, + max_coders: null, + max_retries: 2, + base_branch: null, + rate_limit_notifications: true, + timezone: null, + rendezvous: null, + watcher_sweep_interval_secs: 60, + watcher_done_retention_secs: 14400, +}; + describe("settingsApi", () => { + describe("getProjectSettings", () => { + it("sends GET to /settings and returns project settings", async () => { + mockFetch.mockResolvedValueOnce(okResponse(defaultProjectSettings)); + + const result = await settingsApi.getProjectSettings(); + + expect(mockFetch).toHaveBeenCalledWith( + "/api/settings", + expect.objectContaining({ + headers: expect.objectContaining({ + "Content-Type": "application/json", + }), + }), + ); + expect(result).toEqual(defaultProjectSettings); + }); + + it("uses custom baseUrl when provided", async () => { + mockFetch.mockResolvedValueOnce(okResponse(defaultProjectSettings)); + await settingsApi.getProjectSettings("http://localhost:4000/api"); + expect(mockFetch).toHaveBeenCalledWith( + "http://localhost:4000/api/settings", + expect.anything(), + ); + }); + }); + + describe("putProjectSettings", () => { + it("sends PUT to /settings with settings body", async () => { + const updated = { ...defaultProjectSettings, default_qa: "agent" }; + mockFetch.mockResolvedValueOnce(okResponse(updated)); + + const result = await settingsApi.putProjectSettings(updated); + + expect(mockFetch).toHaveBeenCalledWith( + "/api/settings", + expect.objectContaining({ + method: "PUT", + body: JSON.stringify(updated), + }), + ); + expect(result.default_qa).toBe("agent"); + }); + + it("throws on validation error", async () => { + mockFetch.mockResolvedValueOnce( + errorResponse(400, "Invalid default_qa value"), + ); + await expect( + settingsApi.putProjectSettings({ + ...defaultProjectSettings, + default_qa: "invalid", + }), + ).rejects.toThrow("Invalid default_qa value"); + }); + }); + describe("getEditorCommand", () => { it("sends GET to /settings/editor and returns editor settings", async () => { const expected = { editor_command: "zed" }; diff --git a/frontend/src/api/settings.ts b/frontend/src/api/settings.ts index 21c36f27..9afc3e55 100644 --- a/frontend/src/api/settings.ts +++ b/frontend/src/api/settings.ts @@ -2,6 +2,19 @@ export interface EditorSettings { editor_command: string | null; } +export interface ProjectSettings { + default_qa: string; + default_coder_model: string | null; + max_coders: number | null; + max_retries: number; + base_branch: string | null; + rate_limit_notifications: boolean; + timezone: string | null; + rendezvous: string | null; + watcher_sweep_interval_secs: number; + watcher_done_retention_secs: number; +} + export interface OpenFileResult { success: boolean; } @@ -34,6 +47,21 @@ async function requestJson( } export const settingsApi = { + getProjectSettings(baseUrl?: string): Promise { + return requestJson("/settings", {}, baseUrl); + }, + + putProjectSettings( + settings: ProjectSettings, + baseUrl?: string, + ): Promise { + return requestJson( + "/settings", + { method: "PUT", body: JSON.stringify(settings) }, + baseUrl, + ); + }, + getEditorCommand(baseUrl?: string): Promise { return requestJson("/settings/editor", {}, baseUrl); }, diff --git a/frontend/src/components/Chat.tsx b/frontend/src/components/Chat.tsx index 64714737..ee2e81be 100644 --- a/frontend/src/components/Chat.tsx +++ b/frontend/src/components/Chat.tsx @@ -9,6 +9,7 @@ import { useChatWebSocket } from "../hooks/useChatWebSocket"; import { estimateTokens, getContextWindowSize } from "../utils/chatUtils"; import { ApiKeyDialog } from "./ApiKeyDialog"; import { BotConfigPage } from "./BotConfigPage"; +import { SettingsPage } from "./SettingsPage"; import { ChatHeader } from "./ChatHeader"; import type { ChatInputHandle } from "./ChatInput"; import { ChatInput } from "./ChatInput"; @@ -62,7 +63,7 @@ export function Chat({ null, ); const [showHelp, setShowHelp] = useState(false); - const [view, setView] = useState<"chat" | "bot-config">("chat"); + const [view, setView] = useState<"chat" | "bot-config" | "settings">("chat"); const [queuedMessages, setQueuedMessages] = useState< { id: string; text: string }[] >([]); @@ -376,16 +377,21 @@ export function Chat({ wsConnected={wsConnected} oauthStatus={oauthStatus} onShowBotConfig={() => setView("bot-config")} + onShowSettings={() => setView("settings")} /> {view === "bot-config" && ( setView("chat")} /> )} + {view === "settings" && ( + setView("chat")} /> + )} +
void; + onShowSettings?: () => void; } const getContextEmoji = (percentage: number): string => { @@ -60,6 +61,7 @@ export function ChatHeader({ wsConnected, oauthStatus = null, onShowBotConfig, + onShowSettings, }: ChatHeaderProps) { const hasModelOptions = availableModels.length > 0 || claudeModels.length > 0; const [showConfirm, setShowConfirm] = useState(false); @@ -552,6 +554,43 @@ export function ChatHeader({ )} + {onShowSettings && ( + + )} + {hasModelOptions ? ( onChange(e.target.value)} + placeholder={placeholder ?? ""} + style={inputStyle} + autoComplete="off" + /> +
+ ); +} + +interface NumberFieldProps { + label: string; + description?: string; + value: number | null; + onChange: (v: number | null) => void; + min?: number; + placeholder?: string; +} + +function NumberField({ label, description, value, onChange, min, placeholder }: NumberFieldProps) { + return ( +
+ + {description && {description}} + { + const raw = e.target.value.trim(); + if (raw === "") { + onChange(null); + } else { + const n = Number(raw); + if (!Number.isNaN(n)) onChange(n); + } + }} + placeholder={placeholder ?? ""} + style={inputStyle} + /> +
+ ); +} + +interface CheckboxFieldProps { + label: string; + description?: string; + checked: boolean; + onChange: (v: boolean) => void; +} + +function CheckboxField({ label, description, checked, onChange }: CheckboxFieldProps) { + return ( +
+ {description && {description}} + +
+ ); +} + +const QA_MODES = ["server", "agent", "human"] as const; + +/** Settings page — form-based editor for project.toml scalar settings. */ +export function SettingsPage({ onBack }: SettingsPageProps) { + const [settings, setSettings] = useState(null); + const [status, setStatus] = useState<"idle" | "loading" | "saving" | "saved" | "error">("loading"); + const [errorMsg, setErrorMsg] = useState(null); + const [validationErrors, setValidationErrors] = useState>({}); + + useEffect(() => { + settingsApi + .getProjectSettings() + .then((s) => { + setSettings(s); + setStatus("idle"); + }) + .catch((e: unknown) => { + setStatus("error"); + setErrorMsg(e instanceof Error ? e.message : "Failed to load settings"); + }); + }, []); + + function patch(partial: Partial) { + setSettings((prev) => (prev ? { ...prev, ...partial } : prev)); + setValidationErrors({}); + } + + function validate(s: ProjectSettings): Record { + const errors: Record = {}; + if (!QA_MODES.includes(s.default_qa as (typeof QA_MODES)[number])) { + errors.default_qa = `Must be one of: ${QA_MODES.join(", ")}`; + } + if (s.max_retries < 0) { + errors.max_retries = "Must be 0 or greater"; + } + if (s.watcher_sweep_interval_secs < 1) { + errors.watcher_sweep_interval_secs = "Must be at least 1 second"; + } + if (s.watcher_done_retention_secs < 1) { + errors.watcher_done_retention_secs = "Must be at least 1 second"; + } + return errors; + } + + async function handleSave() { + if (!settings) return; + const errors = validate(settings); + if (Object.keys(errors).length > 0) { + setValidationErrors(errors); + return; + } + setStatus("saving"); + setErrorMsg(null); + try { + const saved = await settingsApi.putProjectSettings(settings); + setSettings(saved); + setStatus("saved"); + setTimeout(() => setStatus("idle"), 2000); + } catch (e) { + setStatus("error"); + setErrorMsg(e instanceof Error ? e.message : "Save failed"); + } + } + + const s = settings; + + return ( +
+ {/* Header */} +
+ + Project Settings +
+ + {/* Body */} +
+ {status === "loading" && ( +

Loading settings…

+ )} + + {status === "error" && !s && ( +

+ Error: {errorMsg} +

+ )} + + {s && ( + <> + {/* Pipeline */} +
+
Pipeline
+ +
+ + + How stories are QA-reviewed after the coder stage. + Default: server. + + + {validationErrors.default_qa && ( + + {validationErrors.default_qa} + + )} +
+ + patch({ max_retries: v ?? 0 })} + /> + {validationErrors.max_retries && ( + + {validationErrors.max_retries} + + )} + + patch({ max_coders: v })} + /> + + + patch({ default_coder_model: v.trim() || null }) + } + placeholder="e.g. sonnet" + /> +
+ + {/* Git */} +
+
Git
+ + + patch({ base_branch: v.trim() || null }) + } + placeholder="e.g. master" + /> +
+ + {/* Notifications */} +
+
Notifications
+ + patch({ rate_limit_notifications: v })} + /> +
+ + {/* Advanced */} +
+
Advanced
+ + patch({ timezone: v.trim() || null })} + placeholder="e.g. Europe/London" + /> + + patch({ rendezvous: v.trim() || null })} + placeholder="e.g. ws://host:3001/crdt-sync" + /> +
+ + {/* Watcher */} +
+
Archiver
+ + + patch({ watcher_sweep_interval_secs: v ?? 60 }) + } + /> + {validationErrors.watcher_sweep_interval_secs && ( + + {validationErrors.watcher_sweep_interval_secs} + + )} + + + patch({ watcher_done_retention_secs: v ?? 14400 }) + } + /> + {validationErrors.watcher_done_retention_secs && ( + + {validationErrors.watcher_done_retention_secs} + + )} +
+ + {/* Save */} +
+ + {status === "error" && errorMsg && ( + + {errorMsg} + + )} +
+ + )} +
+
+ ); +} diff --git a/server/src/http/settings.rs b/server/src/http/settings.rs index d1c000cb..30c0d2cf 100644 --- a/server/src/http/settings.rs +++ b/server/src/http/settings.rs @@ -1,13 +1,181 @@ //! HTTP settings endpoints — REST API for user preferences and editor configuration. +use crate::config::ProjectConfig; use crate::http::context::{AppContext, OpenApiResult, bad_request}; use crate::store::StoreOps; use poem_openapi::{Object, OpenApi, Tags, param::Query, payload::Json}; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use serde_json::json; +use std::path::Path; use std::sync::Arc; const EDITOR_COMMAND_KEY: &str = "editor_command"; +/// Project-level settings exposed via `GET /api/settings` and `PUT /api/settings`. +/// +/// Only contains the scalar fields of `ProjectConfig` — array sections +/// (`[[component]]`, `[[agent]]`, `[watcher]`) are preserved in the TOML file +/// and are not editable through this API. +#[derive(Debug, Object, Serialize, Deserialize)] +struct ProjectSettings { + /// Project-wide default QA mode: "server", "agent", or "human". Default: "server". + default_qa: String, + /// Default model for coder-stage agents (e.g. "sonnet"). When set, only agents whose + /// model matches this value are used for auto-assignment. + default_coder_model: Option, + /// Maximum number of concurrent coder-stage agents. When set, stories wait in + /// 2_current/ until a slot is free. + max_coders: Option, + /// Maximum retries per story per pipeline stage before marking as blocked. Default: 2. + max_retries: u32, + /// Optional base branch name (e.g. "main", "master"). Overrides auto-detection. + base_branch: Option, + /// Whether to send RateLimitWarning chat notifications. Default: true. + rate_limit_notifications: bool, + /// IANA timezone name (e.g. "Europe/London"). Timer inputs are interpreted in this tz. + timezone: Option, + /// WebSocket URL of a remote huskies node to sync CRDT state with. + rendezvous: Option, + /// How often (seconds) to check 5_done/ for items to archive. Default: 60. + watcher_sweep_interval_secs: u64, + /// How long (seconds) an item must remain in 5_done/ before archiving. Default: 14400. + watcher_done_retention_secs: u64, +} + +/// Load `ProjectSettings` from `ProjectConfig`. +fn settings_from_config(cfg: &ProjectConfig) -> ProjectSettings { + ProjectSettings { + default_qa: cfg.default_qa.clone(), + default_coder_model: cfg.default_coder_model.clone(), + max_coders: cfg.max_coders.map(|v| v as u32), + max_retries: cfg.max_retries, + base_branch: cfg.base_branch.clone(), + rate_limit_notifications: cfg.rate_limit_notifications, + timezone: cfg.timezone.clone(), + rendezvous: cfg.rendezvous.clone(), + watcher_sweep_interval_secs: cfg.watcher.sweep_interval_secs, + watcher_done_retention_secs: cfg.watcher.done_retention_secs, + } +} + +/// Validate the incoming `ProjectSettings` before writing. +fn validate_project_settings(s: &ProjectSettings) -> Result<(), String> { + match s.default_qa.as_str() { + "server" | "agent" | "human" => {} + other => { + return Err(format!( + "Invalid default_qa value '{other}'. Must be one of: server, agent, human" + )); + } + } + Ok(()) +} + +/// Write only the scalar settings from `s` into the project.toml at the given root. +/// Array sections (`[[component]]`, `[[agent]]`) are preserved unchanged. +fn write_project_settings(project_root: &Path, s: &ProjectSettings) -> Result<(), String> { + let config_path = project_root.join(".huskies/project.toml"); + + let content = if config_path.exists() { + std::fs::read_to_string(&config_path).map_err(|e| format!("Read config: {e}"))? + } else { + String::new() + }; + + let mut val: toml::Value = if content.trim().is_empty() { + toml::Value::Table(toml::map::Map::new()) + } else { + toml::from_str(&content).map_err(|e| format!("Parse config: {e}"))? + }; + + let table = val + .as_table_mut() + .ok_or_else(|| "Config is not a TOML table".to_string())?; + + // Scalar root fields + table.insert( + "default_qa".to_string(), + toml::Value::String(s.default_qa.clone()), + ); + table.insert( + "max_retries".to_string(), + toml::Value::Integer(s.max_retries as i64), + ); + table.insert( + "rate_limit_notifications".to_string(), + toml::Value::Boolean(s.rate_limit_notifications), + ); + + // Optional scalar fields + match &s.default_coder_model { + Some(v) => { + table.insert( + "default_coder_model".to_string(), + toml::Value::String(v.clone()), + ); + } + None => { + table.remove("default_coder_model"); + } + } + match s.max_coders { + Some(v) => { + table.insert("max_coders".to_string(), toml::Value::Integer(v as i64)); + } + None => { + table.remove("max_coders"); + } + } + match &s.base_branch { + Some(v) => { + table.insert("base_branch".to_string(), toml::Value::String(v.clone())); + } + None => { + table.remove("base_branch"); + } + } + match &s.timezone { + Some(v) => { + table.insert("timezone".to_string(), toml::Value::String(v.clone())); + } + None => { + table.remove("timezone"); + } + } + match &s.rendezvous { + Some(v) => { + table.insert("rendezvous".to_string(), toml::Value::String(v.clone())); + } + None => { + table.remove("rendezvous"); + } + } + + // [watcher] sub-table + let watcher_entry = table + .entry("watcher".to_string()) + .or_insert_with(|| toml::Value::Table(toml::map::Map::new())); + if let toml::Value::Table(wt) = watcher_entry { + wt.insert( + "sweep_interval_secs".to_string(), + toml::Value::Integer(s.watcher_sweep_interval_secs as i64), + ); + wt.insert( + "done_retention_secs".to_string(), + toml::Value::Integer(s.watcher_done_retention_secs as i64), + ); + } + + // Ensure .huskies/ directory exists + if let Some(parent) = config_path.parent() { + std::fs::create_dir_all(parent).map_err(|e| format!("Create .huskies dir: {e}"))?; + } + + let new_content = toml::to_string_pretty(&val).map_err(|e| format!("Serialize config: {e}"))?; + std::fs::write(&config_path, new_content).map_err(|e| format!("Write config: {e}"))?; + + Ok(()) +} + #[derive(Tags)] enum SettingsTags { Settings, @@ -71,6 +239,30 @@ impl SettingsApi { Ok(Json(OpenFileResponse { success: true })) } + /// Get current project.toml scalar settings as JSON. + #[oai(path = "/settings", method = "get")] + async fn get_settings(&self) -> OpenApiResult> { + let project_root = self.ctx.state.get_project_root().map_err(bad_request)?; + let config = ProjectConfig::load(&project_root).map_err(bad_request)?; + Ok(Json(settings_from_config(&config))) + } + + /// Update project.toml scalar settings. Array sections (component, agent) are preserved. + /// + /// Returns 400 if the input fails validation (e.g. unknown qa mode, negative max_retries). + #[oai(path = "/settings", method = "put")] + async fn put_settings( + &self, + payload: Json, + ) -> OpenApiResult> { + validate_project_settings(&payload.0).map_err(bad_request)?; + let project_root = self.ctx.state.get_project_root().map_err(bad_request)?; + write_project_settings(&project_root, &payload.0).map_err(bad_request)?; + // Re-read to confirm what was written + let config = ProjectConfig::load(&project_root).map_err(bad_request)?; + Ok(Json(settings_from_config(&config))) + } + /// Set the preferred editor command (e.g. "zed", "code", "cursor"). /// Pass null or empty string to clear the preference. #[oai(path = "/settings/editor", method = "put")] @@ -360,4 +552,222 @@ mod tests { .await; assert!(result.is_err()); } + + // ── /api/settings GET/PUT ────────────────────────────────────────────── + + fn default_project_settings() -> ProjectSettings { + let cfg = ProjectConfig::default(); + settings_from_config(&cfg) + } + + #[tokio::test] + async fn get_settings_returns_defaults_when_no_project_toml() { + let dir = TempDir::new().unwrap(); + // Create .huskies dir so project root detection works but no project.toml + std::fs::create_dir_all(dir.path().join(".huskies")).unwrap(); + let ctx = AppContext::new_test(dir.path().to_path_buf()); + let api = SettingsApi { ctx: Arc::new(ctx) }; + let result = api.get_settings().await.unwrap().0; + assert_eq!(result.default_qa, "server"); + assert_eq!(result.max_retries, 2); + assert!(result.rate_limit_notifications); + } + + #[tokio::test] + async fn put_settings_writes_and_returns_settings() { + let dir = TempDir::new().unwrap(); + std::fs::create_dir_all(dir.path().join(".huskies")).unwrap(); + let ctx = AppContext::new_test(dir.path().to_path_buf()); + let api = SettingsApi { ctx: Arc::new(ctx) }; + + let mut s = default_project_settings(); + s.default_qa = "agent".to_string(); + s.max_retries = 5; + s.rate_limit_notifications = false; + + let result = api.put_settings(Json(s)).await.unwrap().0; + assert_eq!(result.default_qa, "agent"); + assert_eq!(result.max_retries, 5); + assert!(!result.rate_limit_notifications); + } + + #[tokio::test] + async fn put_settings_preserves_agent_sections() { + let dir = TempDir::new().unwrap(); + let huskies_dir = dir.path().join(".huskies"); + std::fs::create_dir_all(&huskies_dir).unwrap(); + + // Write a project.toml with agent sections + std::fs::write( + huskies_dir.join("project.toml"), + r#" +[[agent]] +name = "coder-1" +model = "sonnet" +stage = "coder" + +[[component]] +name = "server" +path = "." +"#, + ) + .unwrap(); + + let ctx = AppContext::new_test(dir.path().to_path_buf()); + let api = SettingsApi { ctx: Arc::new(ctx) }; + + let mut s = default_project_settings(); + s.default_qa = "human".to_string(); + api.put_settings(Json(s)).await.unwrap(); + + // Re-read the file and verify agent/component sections are still there + let written = std::fs::read_to_string(huskies_dir.join("project.toml")).unwrap(); + assert!( + written.contains("coder-1"), + "agent section should be preserved" + ); + assert!( + written.contains("server"), + "component section should be preserved" + ); + assert!(written.contains("human"), "new setting should be written"); + } + + #[tokio::test] + async fn put_settings_rejects_invalid_qa_mode() { + let dir = TempDir::new().unwrap(); + std::fs::create_dir_all(dir.path().join(".huskies")).unwrap(); + let ctx = AppContext::new_test(dir.path().to_path_buf()); + let api = SettingsApi { ctx: Arc::new(ctx) }; + + let mut s = default_project_settings(); + s.default_qa = "invalid_mode".to_string(); + + let result = api.put_settings(Json(s)).await; + assert!(result.is_err()); + let err = result.unwrap_err(); + assert_eq!(err.status(), poem::http::StatusCode::BAD_REQUEST); + } + + #[test] + fn validate_project_settings_accepts_valid_qa_modes() { + for mode in &["server", "agent", "human"] { + let s = ProjectSettings { + default_qa: mode.to_string(), + default_coder_model: None, + max_coders: None, + max_retries: 2, + base_branch: None, + rate_limit_notifications: true, + timezone: None, + rendezvous: None, + watcher_sweep_interval_secs: 60, + watcher_done_retention_secs: 14400, + }; + assert!( + validate_project_settings(&s).is_ok(), + "qa mode '{mode}' should be valid" + ); + } + } + + #[test] + fn validate_project_settings_rejects_unknown_qa_mode() { + let s = ProjectSettings { + default_qa: "robot".to_string(), + default_coder_model: None, + max_coders: None, + max_retries: 2, + base_branch: None, + rate_limit_notifications: true, + timezone: None, + rendezvous: None, + watcher_sweep_interval_secs: 60, + watcher_done_retention_secs: 14400, + }; + let err = validate_project_settings(&s).unwrap_err(); + assert!(err.contains("robot")); + } + + #[test] + fn write_and_read_project_settings_roundtrip() { + let dir = TempDir::new().unwrap(); + std::fs::create_dir_all(dir.path().join(".huskies")).unwrap(); + + let s = ProjectSettings { + default_qa: "agent".to_string(), + default_coder_model: Some("opus".to_string()), + max_coders: Some(2), + max_retries: 3, + base_branch: Some("main".to_string()), + rate_limit_notifications: false, + timezone: Some("America/New_York".to_string()), + rendezvous: Some("ws://host:3001/crdt-sync".to_string()), + watcher_sweep_interval_secs: 30, + watcher_done_retention_secs: 7200, + }; + + write_project_settings(dir.path(), &s).unwrap(); + + let config = ProjectConfig::load(dir.path()).unwrap(); + let loaded = settings_from_config(&config); + + assert_eq!(loaded.default_qa, "agent"); + assert_eq!(loaded.default_coder_model, Some("opus".to_string())); + assert_eq!(loaded.max_coders, Some(2)); + assert_eq!(loaded.max_retries, 3); + assert_eq!(loaded.base_branch, Some("main".to_string())); + assert!(!loaded.rate_limit_notifications); + assert_eq!(loaded.timezone, Some("America/New_York".to_string())); + assert_eq!( + loaded.rendezvous, + Some("ws://host:3001/crdt-sync".to_string()) + ); + assert_eq!(loaded.watcher_sweep_interval_secs, 30); + assert_eq!(loaded.watcher_done_retention_secs, 7200); + } + + #[test] + fn write_project_settings_clears_optional_fields_when_none() { + let dir = TempDir::new().unwrap(); + let huskies_dir = dir.path().join(".huskies"); + std::fs::create_dir_all(&huskies_dir).unwrap(); + + // First write with optional fields set + let s_with = ProjectSettings { + default_qa: "server".to_string(), + default_coder_model: Some("sonnet".to_string()), + max_coders: Some(3), + max_retries: 2, + base_branch: Some("master".to_string()), + rate_limit_notifications: true, + timezone: Some("UTC".to_string()), + rendezvous: None, + watcher_sweep_interval_secs: 60, + watcher_done_retention_secs: 14400, + }; + write_project_settings(dir.path(), &s_with).unwrap(); + + // Then write with optional fields cleared + let s_clear = ProjectSettings { + default_qa: "server".to_string(), + default_coder_model: None, + max_coders: None, + max_retries: 2, + base_branch: None, + rate_limit_notifications: true, + timezone: None, + rendezvous: None, + watcher_sweep_interval_secs: 60, + watcher_done_retention_secs: 14400, + }; + write_project_settings(dir.path(), &s_clear).unwrap(); + + let config = ProjectConfig::load(dir.path()).unwrap(); + let loaded = settings_from_config(&config); + assert!(loaded.default_coder_model.is_none()); + assert!(loaded.max_coders.is_none()); + assert!(loaded.base_branch.is_none()); + assert!(loaded.timezone.is_none()); + } }