use crate::http::context::{AppContext, OpenApiResult, bad_request}; use crate::store::StoreOps; use poem_openapi::{Object, OpenApi, Tags, payload::Json}; use serde::Serialize; use serde_json::json; use std::sync::Arc; const EDITOR_COMMAND_KEY: &str = "editor_command"; #[derive(Tags)] enum SettingsTags { Settings, } #[derive(Object)] struct EditorCommandPayload { editor_command: Option, } #[derive(Object, Serialize)] struct EditorCommandResponse { editor_command: Option, } 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 })) } /// 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)] mod tests { use super::*; use crate::http::context::AppContext; use std::sync::Arc; use tempfile::TempDir; fn test_ctx(dir: &TempDir) -> AppContext { AppContext::new_test(dir.path().to_path_buf()) } fn make_api(dir: &TempDir) -> SettingsApi { SettingsApi { ctx: Arc::new(AppContext::new_test(dir.path().to_path_buf())), } } #[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); 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); 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); 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); 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(".story_kit_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); 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); 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); 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()); } }