huskies: merge 611_story_extract_settings_service
This commit is contained in:
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user