2026-04-12 13:11:23 +00:00
|
|
|
//! HTTP settings endpoints — REST API for user preferences and editor configuration.
|
2026-04-17 13:33:19 +00:00
|
|
|
use crate::config::ProjectConfig;
|
2026-03-22 19:07:07 +00:00
|
|
|
use crate::http::context::{AppContext, OpenApiResult, bad_request};
|
|
|
|
|
use crate::store::StoreOps;
|
|
|
|
|
use poem_openapi::{Object, OpenApi, Tags, param::Query, payload::Json};
|
2026-04-17 13:33:19 +00:00
|
|
|
use serde::{Deserialize, Serialize};
|
2026-03-22 19:07:07 +00:00
|
|
|
use serde_json::json;
|
2026-04-17 13:33:19 +00:00
|
|
|
use std::path::Path;
|
2026-03-22 19:07:07 +00:00
|
|
|
use std::sync::Arc;
|
|
|
|
|
|
|
|
|
|
const EDITOR_COMMAND_KEY: &str = "editor_command";
|
|
|
|
|
|
2026-04-17 13:33:19 +00:00
|
|
|
/// 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<String>,
|
|
|
|
|
/// Maximum number of concurrent coder-stage agents. When set, stories wait in
|
|
|
|
|
/// 2_current/ until a slot is free.
|
|
|
|
|
max_coders: Option<u32>,
|
|
|
|
|
/// 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<String>,
|
|
|
|
|
/// 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<String>,
|
|
|
|
|
/// WebSocket URL of a remote huskies node to sync CRDT state with.
|
|
|
|
|
rendezvous: Option<String>,
|
|
|
|
|
/// 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(())
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-22 19:07:07 +00:00
|
|
|
#[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,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 = 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<String>,
|
|
|
|
|
line: Query<Option<u32>>,
|
|
|
|
|
) -> OpenApiResult<Json<OpenFileResponse>> {
|
|
|
|
|
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 }))
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-17 13:33:19 +00:00
|
|
|
/// 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 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<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)?;
|
|
|
|
|
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)))
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-22 19:07:07 +00:00
|
|
|
/// 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;
|
2026-04-13 14:07:08 +00:00
|
|
|
let trimmed = editor_command
|
|
|
|
|
.as_deref()
|
|
|
|
|
.map(str::trim)
|
|
|
|
|
.filter(|s| !s.is_empty());
|
2026-03-22 19:07:07 +00:00
|
|
|
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<String> {
|
|
|
|
|
ctx.store
|
|
|
|
|
.get(EDITOR_COMMAND_KEY)
|
|
|
|
|
.and_then(|v| v.as_str().map(|s| s.to_string()))
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-28 19:47:59 +00:00
|
|
|
#[cfg(test)]
|
|
|
|
|
impl From<std::sync::Arc<AppContext>> for SettingsApi {
|
|
|
|
|
fn from(ctx: std::sync::Arc<AppContext>) -> Self {
|
|
|
|
|
Self { ctx }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-22 19:07:07 +00:00
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
2026-03-28 19:47:59 +00:00
|
|
|
use crate::http::test_helpers::{make_api, test_ctx};
|
2026-03-22 19:07:07 +00:00
|
|
|
use tempfile::TempDir;
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn get_editor_returns_none_when_unset() {
|
|
|
|
|
let dir = TempDir::new().unwrap();
|
2026-03-28 19:47:59 +00:00
|
|
|
let api = make_api::<SettingsApi>(&dir);
|
2026-03-22 19:07:07 +00:00
|
|
|
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();
|
2026-03-28 19:47:59 +00:00
|
|
|
let api = make_api::<SettingsApi>(&dir);
|
2026-03-22 19:07:07 +00:00
|
|
|
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();
|
2026-03-28 19:47:59 +00:00
|
|
|
let api = make_api::<SettingsApi>(&dir);
|
2026-03-22 19:07:07 +00:00
|
|
|
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();
|
2026-03-28 19:47:59 +00:00
|
|
|
let api = make_api::<SettingsApi>(&dir);
|
2026-03-22 19:07:07 +00:00
|
|
|
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();
|
2026-03-28 19:47:59 +00:00
|
|
|
let api = make_api::<SettingsApi>(&dir);
|
2026-03-22 19:07:07 +00:00
|
|
|
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();
|
2026-03-28 19:47:59 +00:00
|
|
|
let api = make_api::<SettingsApi>(&dir);
|
2026-03-22 19:07:07 +00:00
|
|
|
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();
|
2026-03-28 19:47:59 +00:00
|
|
|
let ctx = test_ctx(dir.path());
|
2026-03-22 19:07:07 +00:00
|
|
|
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();
|
2026-03-28 19:47:59 +00:00
|
|
|
let ctx = test_ctx(dir.path());
|
2026-03-22 19:07:07 +00:00
|
|
|
|
|
|
|
|
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();
|
2026-03-28 19:47:59 +00:00
|
|
|
let ctx = test_ctx(dir.path());
|
2026-03-22 19:07:07 +00:00
|
|
|
|
|
|
|
|
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();
|
2026-03-28 19:47:59 +00:00
|
|
|
let ctx = test_ctx(dir.path());
|
2026-03-22 19:07:07 +00:00
|
|
|
|
|
|
|
|
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();
|
2026-04-03 16:12:52 +01:00
|
|
|
let store_path = dir.path().join(".huskies_store.json");
|
2026-03-22 19:07:07 +00:00
|
|
|
|
|
|
|
|
{
|
|
|
|
|
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();
|
2026-03-28 19:47:59 +00:00
|
|
|
let ctx = test_ctx(dir.path());
|
2026-04-13 14:07:08 +00:00
|
|
|
let api = SettingsApi { ctx: Arc::new(ctx) };
|
2026-03-22 19:07:07 +00:00
|
|
|
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();
|
2026-03-28 19:47:59 +00:00
|
|
|
let ctx = test_ctx(dir.path());
|
2026-04-13 14:07:08 +00:00
|
|
|
let api = SettingsApi { ctx: Arc::new(ctx) };
|
2026-03-22 19:07:07 +00:00
|
|
|
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();
|
2026-03-28 19:47:59 +00:00
|
|
|
let ctx = test_ctx(dir.path());
|
2026-04-13 14:07:08 +00:00
|
|
|
let api = SettingsApi { ctx: Arc::new(ctx) };
|
2026-03-22 19:07:07 +00:00
|
|
|
// 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();
|
2026-03-28 19:47:59 +00:00
|
|
|
let api = make_api::<SettingsApi>(&dir);
|
2026-03-22 19:07:07 +00:00
|
|
|
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();
|
2026-03-28 19:47:59 +00:00
|
|
|
let api = make_api::<SettingsApi>(&dir);
|
2026-03-22 19:07:07 +00:00
|
|
|
// 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();
|
2026-03-28 19:47:59 +00:00
|
|
|
let api = make_api::<SettingsApi>(&dir);
|
2026-03-22 19:07:07 +00:00
|
|
|
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();
|
2026-03-28 19:47:59 +00:00
|
|
|
let api = make_api::<SettingsApi>(&dir);
|
2026-03-22 19:07:07 +00:00
|
|
|
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());
|
|
|
|
|
}
|
2026-04-17 13:33:19 +00:00
|
|
|
|
|
|
|
|
// ── /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());
|
|
|
|
|
}
|
2026-03-22 19:07:07 +00:00
|
|
|
}
|