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_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, } #[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 })) } /// 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(".storkit_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()); } #[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()); } }