huskies: merge 611_story_extract_settings_service

This commit is contained in:
dave
2026-04-24 17:07:44 +00:00
parent da6ae89667
commit 62bfaf20f4
7 changed files with 897 additions and 196 deletions
+262
View File
@@ -0,0 +1,262 @@
//! Settings service — domain logic for project settings and editor configuration.
//!
//! Extracts business logic from `http/settings.rs` following the conventions in
//! `docs/architecture/service-modules.md`:
//! - `mod.rs` (this file) — public API, typed [`Error`], orchestration
//! - `io.rs` — the ONLY place that performs side effects (filesystem I/O, process spawn)
//! - `project.rs` — pure types: [`ProjectSettings`], [`settings_from_config`],
//! [`merge_settings_into_toml`]
//! - `validate.rs` — pure validation: [`validate_project_settings`]
pub(super) mod io;
pub mod project;
pub mod validate;
pub use project::{ProjectSettings, merge_settings_into_toml, settings_from_config};
pub use validate::validate_project_settings;
use crate::config::ProjectConfig;
use crate::store::StoreOps;
use std::path::Path;
/// The store key for the configured editor command.
pub const EDITOR_COMMAND_KEY: &str = "editor_command";
// ── Error type ────────────────────────────────────────────────────────────────
/// Typed errors returned by `service::settings` functions.
///
/// HTTP handlers map these to status codes:
/// - [`Error::Validation`] → 400 Bad Request
/// - [`Error::NotConfigured`] → 400 Bad Request
/// - [`Error::Io`] → 500 Internal Server Error
/// - [`Error::Spawn`] → 500 Internal Server Error
#[derive(Debug)]
pub enum Error {
/// A field value failed validation (e.g. unknown QA mode).
Validation(String),
/// No editor is configured in the store.
NotConfigured,
/// A filesystem read or write operation failed.
Io(String),
/// The editor process failed to spawn.
Spawn(String),
}
impl std::fmt::Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Validation(msg) => write!(f, "Validation error: {msg}"),
Self::NotConfigured => write!(f, "No editor configured"),
Self::Io(msg) => write!(f, "I/O error: {msg}"),
Self::Spawn(msg) => write!(f, "Spawn error: {msg}"),
}
}
}
// ── Public API ────────────────────────────────────────────────────────────────
/// Load the current project settings from disk.
///
/// # Errors
/// - [`Error::IoError`] if the config file cannot be read or parsed.
pub fn load_project_settings(project_root: &Path) -> Result<ProjectSettings, Error> {
let config =
ProjectConfig::load(project_root).map_err(|e| Error::Io(format!("Load config: {e}")))?;
Ok(settings_from_config(&config))
}
/// Write the given settings to disk, preserving array sections.
///
/// Reads the existing project.toml, merges only the scalar fields from `s`,
/// and rewrites the file. Array sections (`[[component]]`, `[[agent]]`) are
/// untouched.
///
/// # Errors
/// - [`Error::IoError`] if the config file cannot be read, parsed, or written.
pub fn write_project_settings(project_root: &Path, s: &ProjectSettings) -> Result<(), Error> {
let config_path = project_root.join(".huskies/project.toml");
let content = io::read_config_toml(&config_path)?;
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| Error::Io(format!("Parse config: {e}")))?
};
merge_settings_into_toml(&mut val, s)?;
let new_content =
toml::to_string_pretty(&val).map_err(|e| Error::Io(format!("Serialize config: {e}")))?;
io::write_config_toml(&config_path, &new_content)?;
Ok(())
}
/// Return the configured editor command from the store, or `None` if not set.
///
/// Pure: reads from in-memory store only — no filesystem or network I/O.
pub fn get_editor_command(store: &dyn StoreOps) -> Option<String> {
store
.get(EDITOR_COMMAND_KEY)
.and_then(|v| v.as_str().map(|s| s.to_string()))
}
/// Open a file in the configured editor at the optional line number.
///
/// # Errors
/// - [`Error::NotConfigured`] if no editor has been set in the store.
/// - [`Error::SpawnError`] if the editor process fails to start.
pub fn open_file_in_editor(
store: &dyn StoreOps,
path: &str,
line: Option<u32>,
) -> Result<(), Error> {
let editor_command = get_editor_command(store).ok_or(Error::NotConfigured)?;
let file_ref = match line {
Some(l) => format!("{path}:{l}"),
None => path.to_string(),
};
io::spawn_editor(&editor_command, &file_ref)
}
// ── Tests ─────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
// ── Error Display ─────────────────────────────────────────────────────────
#[test]
fn error_display_validation() {
let e = Error::Validation("bad value".to_string());
assert!(e.to_string().contains("Validation error"));
assert!(e.to_string().contains("bad value"));
}
#[test]
fn error_display_not_configured() {
let e = Error::NotConfigured;
assert!(e.to_string().contains("No editor configured"));
}
#[test]
fn error_display_io() {
let e = Error::Io("disk full".to_string());
assert!(e.to_string().contains("I/O error"));
assert!(e.to_string().contains("disk full"));
}
#[test]
fn error_display_spawn() {
let e = Error::Spawn("not found".to_string());
assert!(e.to_string().contains("Spawn error"));
assert!(e.to_string().contains("not found"));
}
// ── load_project_settings ─────────────────────────────────────────────────
#[test]
fn load_project_settings_returns_defaults_when_no_toml() {
let dir = TempDir::new().unwrap();
std::fs::create_dir_all(dir.path().join(".huskies")).unwrap();
let s = load_project_settings(dir.path()).unwrap();
assert_eq!(s.default_qa, "server");
assert_eq!(s.max_retries, 2);
assert!(s.rate_limit_notifications);
}
// ── write_project_settings ────────────────────────────────────────────────
#[test]
fn write_project_settings_creates_file() {
let dir = TempDir::new().unwrap();
std::fs::create_dir_all(dir.path().join(".huskies")).unwrap();
let s = load_project_settings(dir.path()).unwrap();
write_project_settings(dir.path(), &s).unwrap();
assert!(dir.path().join(".huskies/project.toml").exists());
}
#[test]
fn write_project_settings_roundtrips() {
let dir = TempDir::new().unwrap();
std::fs::create_dir_all(dir.path().join(".huskies")).unwrap();
let mut s = load_project_settings(dir.path()).unwrap();
s.default_qa = "agent".to_string();
s.max_retries = 7;
write_project_settings(dir.path(), &s).unwrap();
let loaded = load_project_settings(dir.path()).unwrap();
assert_eq!(loaded.default_qa, "agent");
assert_eq!(loaded.max_retries, 7);
}
// ── get_editor_command ────────────────────────────────────────────────────
#[test]
fn get_editor_command_returns_none_when_unset() {
use crate::store::JsonFileStore;
let dir = TempDir::new().unwrap();
let store = JsonFileStore::new(dir.path().join("store.json")).unwrap();
assert!(get_editor_command(&store).is_none());
}
#[test]
fn get_editor_command_returns_value_when_set() {
use crate::store::JsonFileStore;
use serde_json::json;
let dir = TempDir::new().unwrap();
let store = JsonFileStore::new(dir.path().join("store.json")).unwrap();
store.set(EDITOR_COMMAND_KEY, json!("zed"));
assert_eq!(get_editor_command(&store), Some("zed".to_string()));
}
// ── open_file_in_editor ───────────────────────────────────────────────────
#[test]
fn open_file_in_editor_returns_not_configured_when_no_editor() {
use crate::store::JsonFileStore;
let dir = TempDir::new().unwrap();
let store = JsonFileStore::new(dir.path().join("store.json")).unwrap();
let result = open_file_in_editor(&store, "src/main.rs", Some(42));
assert!(matches!(result, Err(Error::NotConfigured)));
}
#[test]
fn open_file_in_editor_returns_spawn_error_for_nonexistent_editor() {
use crate::store::JsonFileStore;
use serde_json::json;
let dir = TempDir::new().unwrap();
let store = JsonFileStore::new(dir.path().join("store.json")).unwrap();
store.set(EDITOR_COMMAND_KEY, json!("this_editor_xyz_does_not_exist"));
let result = open_file_in_editor(&store, "src/main.rs", Some(1));
assert!(matches!(result, Err(Error::Spawn(_))));
}
#[test]
fn open_file_in_editor_succeeds_with_echo() {
use crate::store::JsonFileStore;
use serde_json::json;
let dir = TempDir::new().unwrap();
let store = JsonFileStore::new(dir.path().join("store.json")).unwrap();
store.set(EDITOR_COMMAND_KEY, json!("echo"));
let result = open_file_in_editor(&store, "src/main.rs", Some(10));
assert!(result.is_ok());
}
#[test]
fn open_file_in_editor_formats_path_without_line() {
use crate::store::JsonFileStore;
use serde_json::json;
let dir = TempDir::new().unwrap();
let store = JsonFileStore::new(dir.path().join("store.json")).unwrap();
store.set(EDITOR_COMMAND_KEY, json!("echo"));
let result = open_file_in_editor(&store, "src/lib.rs", None);
assert!(result.is_ok());
}
}