Files
huskies/server/src/http/settings.rs
T

616 lines
21 KiB
Rust
Raw Normal View History

//! 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<String> {
svc::get_editor_command(&*ctx.store)
}
#[derive(Tags)]
enum SettingsTags {
Settings,
}
#[derive(Object)]
struct EditorCommandPayload {
editor_command: Option<String>,
}
#[derive(Object, Serialize)]
struct EditorCommandResponse {
editor_command: Option<String>,
}
#[derive(Debug, Object, Serialize)]
struct OpenFileResponse {
success: bool,
}
2026-04-29 10:41:32 +00:00
/// OpenAPI endpoint group for user preferences and editor configuration.
pub struct SettingsApi {
pub ctx: Arc<AppContext>,
}
#[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<Json<EditorCommandResponse>> {
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<String>,
line: Query<Option<u32>>,
) -> OpenApiResult<Json<OpenFileResponse>> {
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<Json<ProjectSettings>> {
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<ProjectSettings>,
) -> OpenApiResult<Json<ProjectSettings>> {
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<EditorCommandPayload>,
) -> OpenApiResult<Json<EditorCommandResponse>> {
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<std::sync::Arc<AppContext>> for SettingsApi {
fn from(ctx: std::sync::Arc<AppContext>) -> 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::<SettingsApi>(&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::<SettingsApi>(&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::<SettingsApi>(&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::<SettingsApi>(&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::<SettingsApi>(&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::<SettingsApi>(&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::<SettingsApi>(&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::<SettingsApi>(&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::<SettingsApi>(&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::<SettingsApi>(&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());
}
}