//! HTTP settings endpoints — REST API for user preferences and editor configuration. use crate::http::context::{AppContext, OpenApiResult, bad_request}; use crate::service::settings as svc; use crate::store::StoreOps; use poem_openapi::{Object, OpenApi, Tags, param::Query, payload::Json}; use serde::Serialize; use serde_json::json; #[cfg(test)] use std::path::Path; use std::sync::Arc; // Re-export service types so the test module (which does `use super::*`) can // access them without modification. pub use svc::EDITOR_COMMAND_KEY; pub use svc::ProjectSettings; #[cfg(test)] pub use svc::settings_from_config; /// Thin wrapper — delegates to [`svc::validate_project_settings`] and maps /// the typed error to `String` so existing tests calling `.unwrap_err()` can /// call `.contains()` directly. fn validate_project_settings(s: &ProjectSettings) -> Result<(), String> { svc::validate_project_settings(s).map_err(|e| e.to_string()) } /// Thin wrapper — delegates to [`svc::write_project_settings`] and maps the /// typed error to `String` so existing tests can call `.unwrap()` unchanged. #[cfg(test)] fn write_project_settings(project_root: &Path, s: &ProjectSettings) -> Result<(), String> { svc::write_project_settings(project_root, s).map_err(|e| e.to_string()) } /// Return the configured editor command from the store, or `None` if not set. pub fn get_editor_command_from_store(ctx: &AppContext) -> Option { svc::get_editor_command(&*ctx.store) } #[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 = get_editor_command_from_store(&self.ctx); 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> { svc::open_file_in_editor(&*self.ctx.store, &path.0, line.0) .map_err(|e| bad_request(e.to_string()))?; 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 s = svc::load_project_settings(&project_root).map_err(|e| bad_request(e.to_string()))?; Ok(Json(s)) } /// 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)?; svc::write_project_settings(&project_root, &payload.0) .map_err(|e| bad_request(e.to_string()))?; // Re-read to confirm what was written let s = svc::load_project_settings(&project_root).map_err(|e| bad_request(e.to_string()))?; Ok(Json(s)) } /// 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, })) } } } } #[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 = crate::config::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 = crate::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 = crate::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()); } }