//! 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::{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, } #[derive(Object)] struct EditorCommandPayload { editor_command: Option, } #[derive(Object, Serialize)] struct EditorCommandResponse { editor_command: Option, } #[derive(Debug, Object, Serialize)] struct OpenFileResponse { success: bool, } pub struct SettingsApi { pub ctx: Arc, } #[OpenApi(tag = "SettingsTags::Settings")] impl SettingsApi { /// Get the configured editor command (e.g. "zed", "code", "cursor"), or null if not set. #[oai(path = "/settings/editor", method = "get")] async fn get_editor(&self) -> OpenApiResult> { let editor_command = self .ctx .store .get(EDITOR_COMMAND_KEY) .and_then(|v| v.as_str().map(|s| s.to_string())); Ok(Json(EditorCommandResponse { editor_command })) } /// Open a file in the configured editor at the given line number. /// /// Invokes the stored editor CLI (e.g. "zed", "code") with `path:line` as the argument. /// Returns an error if no editor is configured or if the process fails to spawn. #[oai(path = "/settings/open-file", method = "post")] async fn open_file( &self, path: Query, line: Query>, ) -> OpenApiResult> { let editor_command = get_editor_command_from_store(&self.ctx) .ok_or_else(|| bad_request("No editor configured".to_string()))?; let file_ref = match line.0 { Some(l) => format!("{}:{}", path.0, l), None => path.0.clone(), }; std::process::Command::new(&editor_command) .arg(&file_ref) .spawn() .map_err(|e| bad_request(format!("Failed to open editor: {e}")))?; 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")] async fn set_editor( &self, payload: Json, ) -> OpenApiResult> { let editor_command = payload.0.editor_command; let trimmed = editor_command .as_deref() .map(str::trim) .filter(|s| !s.is_empty()); match trimmed { Some(cmd) => { self.ctx.store.set(EDITOR_COMMAND_KEY, json!(cmd)); self.ctx.store.save().map_err(bad_request)?; Ok(Json(EditorCommandResponse { editor_command: Some(cmd.to_string()), })) } None => { self.ctx.store.delete(EDITOR_COMMAND_KEY); self.ctx.store.save().map_err(bad_request)?; Ok(Json(EditorCommandResponse { editor_command: None, })) } } } } pub fn get_editor_command_from_store(ctx: &AppContext) -> Option { ctx.store .get(EDITOR_COMMAND_KEY) .and_then(|v| v.as_str().map(|s| s.to_string())) } #[cfg(test)] impl From> for SettingsApi { fn from(ctx: std::sync::Arc) -> Self { Self { ctx } } } #[cfg(test)] mod tests { use super::*; use crate::http::test_helpers::{make_api, test_ctx}; use tempfile::TempDir; #[tokio::test] async fn get_editor_returns_none_when_unset() { let dir = TempDir::new().unwrap(); let api = make_api::(&dir); let result = api.get_editor().await.unwrap(); assert!(result.0.editor_command.is_none()); } #[tokio::test] async fn set_editor_stores_command() { let dir = TempDir::new().unwrap(); let api = make_api::(&dir); let payload = Json(EditorCommandPayload { editor_command: Some("zed".to_string()), }); let result = api.set_editor(payload).await.unwrap(); assert_eq!(result.0.editor_command, Some("zed".to_string())); } #[tokio::test] async fn set_editor_clears_command_on_null() { let dir = TempDir::new().unwrap(); let api = make_api::(&dir); api.set_editor(Json(EditorCommandPayload { editor_command: Some("zed".to_string()), })) .await .unwrap(); let result = api .set_editor(Json(EditorCommandPayload { editor_command: None, })) .await .unwrap(); assert!(result.0.editor_command.is_none()); } #[tokio::test] async fn set_editor_clears_command_on_empty_string() { let dir = TempDir::new().unwrap(); let api = make_api::(&dir); let result = api .set_editor(Json(EditorCommandPayload { editor_command: Some(String::new()), })) .await .unwrap(); assert!(result.0.editor_command.is_none()); } #[tokio::test] async fn set_editor_trims_whitespace_only() { let dir = TempDir::new().unwrap(); let api = make_api::(&dir); let result = api .set_editor(Json(EditorCommandPayload { editor_command: Some(" ".to_string()), })) .await .unwrap(); assert!(result.0.editor_command.is_none()); } #[tokio::test] async fn get_editor_returns_value_after_set() { let dir = TempDir::new().unwrap(); let api = make_api::(&dir); api.set_editor(Json(EditorCommandPayload { editor_command: Some("cursor".to_string()), })) .await .unwrap(); let result = api.get_editor().await.unwrap(); assert_eq!(result.0.editor_command, Some("cursor".to_string())); } #[test] fn editor_command_defaults_to_null() { let dir = TempDir::new().unwrap(); let ctx = test_ctx(dir.path()); let result = get_editor_command_from_store(&ctx); assert!(result.is_none()); } #[test] fn set_editor_command_persists_in_store() { let dir = TempDir::new().unwrap(); let ctx = test_ctx(dir.path()); ctx.store.set(EDITOR_COMMAND_KEY, json!("zed")); ctx.store.save().unwrap(); let result = get_editor_command_from_store(&ctx); assert_eq!(result, Some("zed".to_string())); } #[test] fn get_editor_command_from_store_returns_value() { let dir = TempDir::new().unwrap(); let ctx = test_ctx(dir.path()); ctx.store.set(EDITOR_COMMAND_KEY, json!("code")); let result = get_editor_command_from_store(&ctx); assert_eq!(result, Some("code".to_string())); } #[test] fn delete_editor_command_returns_none() { let dir = TempDir::new().unwrap(); let ctx = test_ctx(dir.path()); ctx.store.set(EDITOR_COMMAND_KEY, json!("cursor")); ctx.store.delete(EDITOR_COMMAND_KEY); let result = get_editor_command_from_store(&ctx); assert!(result.is_none()); } #[test] fn editor_command_survives_reload() { let dir = TempDir::new().unwrap(); let store_path = dir.path().join(".huskies_store.json"); { let ctx = AppContext::new_test(dir.path().to_path_buf()); ctx.store.set(EDITOR_COMMAND_KEY, json!("zed")); ctx.store.save().unwrap(); } // Reload from disk let store2 = crate::store::JsonFileStore::new(store_path).unwrap(); let val = store2.get(EDITOR_COMMAND_KEY); assert_eq!(val, Some(json!("zed"))); } #[tokio::test] async fn get_editor_http_handler_returns_null_when_not_set() { let dir = TempDir::new().unwrap(); let ctx = test_ctx(dir.path()); let api = SettingsApi { ctx: Arc::new(ctx) }; let result = api.get_editor().await.unwrap().0; assert!(result.editor_command.is_none()); } #[tokio::test] async fn set_editor_http_handler_stores_value() { let dir = TempDir::new().unwrap(); let ctx = test_ctx(dir.path()); let api = SettingsApi { ctx: Arc::new(ctx) }; let result = api .set_editor(Json(EditorCommandPayload { editor_command: Some("zed".to_string()), })) .await .unwrap() .0; assert_eq!(result.editor_command, Some("zed".to_string())); } #[tokio::test] async fn set_editor_http_handler_clears_value_when_null() { let dir = TempDir::new().unwrap(); let ctx = test_ctx(dir.path()); let api = SettingsApi { ctx: Arc::new(ctx) }; // First set a value api.set_editor(Json(EditorCommandPayload { editor_command: Some("code".to_string()), })) .await .unwrap(); // Now clear it let result = api .set_editor(Json(EditorCommandPayload { editor_command: None, })) .await .unwrap() .0; assert!(result.editor_command.is_none()); } #[tokio::test] async fn open_file_returns_error_when_no_editor_configured() { let dir = TempDir::new().unwrap(); let api = make_api::(&dir); let result = api .open_file(Query("src/main.rs".to_string()), Query(Some(42))) .await; assert!(result.is_err()); let err = result.unwrap_err(); assert_eq!(err.status(), poem::http::StatusCode::BAD_REQUEST); } #[tokio::test] async fn open_file_spawns_editor_with_path_and_line() { let dir = TempDir::new().unwrap(); let api = make_api::(&dir); // Configure the editor to "echo" which is a safe no-op command api.set_editor(Json(EditorCommandPayload { editor_command: Some("echo".to_string()), })) .await .unwrap(); let result = api .open_file(Query("src/main.rs".to_string()), Query(Some(42))) .await .unwrap(); assert!(result.0.success); } #[tokio::test] async fn open_file_spawns_editor_with_path_only_when_no_line() { let dir = TempDir::new().unwrap(); let api = make_api::(&dir); api.set_editor(Json(EditorCommandPayload { editor_command: Some("echo".to_string()), })) .await .unwrap(); let result = api .open_file(Query("src/lib.rs".to_string()), Query(None)) .await .unwrap(); assert!(result.0.success); } #[tokio::test] async fn open_file_returns_error_for_nonexistent_editor() { let dir = TempDir::new().unwrap(); let api = make_api::(&dir); api.set_editor(Json(EditorCommandPayload { editor_command: Some("this_editor_does_not_exist_xyz_abc".to_string()), })) .await .unwrap(); let result = api .open_file(Query("src/main.rs".to_string()), Query(Some(1))) .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()); } }