diff --git a/server/src/http/anthropic.rs b/server/src/http/anthropic.rs index bc5b1538..dc0ddcb3 100644 --- a/server/src/http/anthropic.rs +++ b/server/src/http/anthropic.rs @@ -1,50 +1,10 @@ -//! Anthropic API proxy — forwards model listing and key-validation requests to Anthropic. +//! Anthropic API proxy — thin adapter over `service::anthropic`. use crate::http::context::{AppContext, OpenApiResult, bad_request}; -use crate::llm::chat; -use crate::store::StoreOps; +use crate::service::anthropic::{self as svc, ModelSummary}; use poem_openapi::{Object, OpenApi, Tags, payload::Json}; -use reqwest::header::{HeaderMap, HeaderValue}; -use serde::{Deserialize, Serialize}; +use serde::Deserialize; use std::sync::Arc; -const ANTHROPIC_MODELS_URL: &str = "https://api.anthropic.com/v1/models"; -const ANTHROPIC_VERSION: &str = "2023-06-01"; -const KEY_ANTHROPIC_API_KEY: &str = "anthropic_api_key"; - -#[derive(Deserialize)] -struct AnthropicModelsResponse { - data: Vec, -} - -#[derive(Deserialize)] -struct AnthropicModelInfo { - id: String, - context_window: u64, -} - -#[derive(Serialize, Object)] -struct AnthropicModelSummary { - id: String, - context_window: u64, -} - -fn get_anthropic_api_key(ctx: &AppContext) -> Result { - match ctx.store.get(KEY_ANTHROPIC_API_KEY) { - Some(value) => { - if let Some(key) = value.as_str() { - if key.is_empty() { - Err("Anthropic API key is empty. Please set your API key.".to_string()) - } else { - Ok(key.to_string()) - } - } else { - Err("Stored API key is not a string".to_string()) - } - } - None => Err("Anthropic API key not found. Please set your API key.".to_string()), - } -} - #[derive(Deserialize, Object)] struct ApiKeyPayload { api_key: String, @@ -79,8 +39,8 @@ impl AnthropicApi { /// Returns `true` if a non-empty key is present, otherwise `false`. #[oai(path = "/anthropic/key/exists", method = "get")] async fn get_anthropic_api_key_exists(&self) -> OpenApiResult> { - let exists = - chat::get_anthropic_api_key_exists(self.ctx.store.as_ref()).map_err(bad_request)?; + let exists = svc::get_api_key_exists(self.ctx.store.as_ref()) + .map_err(|e| bad_request(e.to_string()))?; Ok(Json(exists)) } @@ -92,74 +52,62 @@ impl AnthropicApi { &self, payload: Json, ) -> OpenApiResult> { - chat::set_anthropic_api_key(self.ctx.store.as_ref(), payload.0.api_key) - .map_err(bad_request)?; + svc::set_api_key(self.ctx.store.as_ref(), payload.0.api_key) + .map_err(|e| bad_request(e.to_string()))?; Ok(Json(true)) } /// List available Anthropic models. #[oai(path = "/anthropic/models", method = "get")] - async fn list_anthropic_models(&self) -> OpenApiResult>> { - self.list_anthropic_models_from(ANTHROPIC_MODELS_URL).await - } -} - -impl AnthropicApi { - async fn list_anthropic_models_from( - &self, - url: &str, - ) -> OpenApiResult>> { - let api_key = get_anthropic_api_key(self.ctx.as_ref()).map_err(bad_request)?; - let client = reqwest::Client::new(); - let mut headers = HeaderMap::new(); - headers.insert( - "x-api-key", - HeaderValue::from_str(&api_key).map_err(|e| bad_request(e.to_string()))?, - ); - headers.insert( - "anthropic-version", - HeaderValue::from_static(ANTHROPIC_VERSION), - ); - - let response = client - .get(url) - .headers(headers) - .send() + async fn list_anthropic_models(&self) -> OpenApiResult>> { + let models = svc::list_models(self.ctx.store.as_ref()) .await .map_err(|e| bad_request(e.to_string()))?; - - if !response.status().is_success() { - let status = response.status(); - let error_text = response - .text() - .await - .unwrap_or_else(|_| "Unknown error".to_string()); - return Err(bad_request(format!( - "Anthropic API error {status}: {error_text}" - ))); - } - - let body = response - .json::() - .await - .map_err(|e| bad_request(e.to_string()))?; - let models = body - .data - .into_iter() - .map(|m| AnthropicModelSummary { - id: m.id, - context_window: m.context_window, - }) - .collect(); - Ok(Json(models)) } } +#[cfg(test)] +impl AnthropicApi { + /// List models from an injectable URL (used in tests to avoid real network calls). + async fn list_anthropic_models_from( + &self, + url: &str, + ) -> OpenApiResult>> { + let models = svc::list_models_from(self.ctx.store.as_ref(), url) + .await + .map_err(|e| bad_request(e.to_string()))?; + Ok(Json(models)) + } +} + +// Private helper retained for backward compatibility with tests that call it directly. +#[cfg(test)] +fn get_anthropic_api_key(ctx: &AppContext) -> Result { + svc::get_api_key(ctx.store.as_ref()).map_err(|e| e.to_string()) +} + +// Private types retained so existing tests that deserialise them directly continue to compile. +#[cfg(test)] +#[derive(serde::Deserialize)] +struct AnthropicModelsResponse { + data: Vec, +} + +#[cfg(test)] +#[derive(serde::Deserialize)] +struct AnthropicModelInfo { + id: String, + context_window: u64, +} + #[cfg(test)] mod tests { use super::*; + use crate::http::context::AppContext; use crate::http::test_helpers::{make_api, test_ctx}; + use crate::store::StoreOps; + const KEY_ANTHROPIC_API_KEY: &str = "anthropic_api_key"; use serde_json::json; use tempfile::TempDir; diff --git a/server/src/http/io.rs b/server/src/http/io.rs index 7430dee2..7854b2ee 100644 --- a/server/src/http/io.rs +++ b/server/src/http/io.rs @@ -1,6 +1,6 @@ -//! HTTP I/O endpoints — REST API for file and directory operations. +//! HTTP I/O endpoints — thin adapters over `service::file_io`. use crate::http::context::{AppContext, OpenApiResult, bad_request}; -use crate::io::fs as io_fs; +use crate::service::file_io::{self as svc, FileEntry}; use poem_openapi::{Object, OpenApi, Tags, payload::Json}; use serde::Deserialize; use std::sync::Arc; @@ -46,18 +46,18 @@ impl IoApi { /// Read a file from the currently open project and return its contents. #[oai(path = "/io/fs/read", method = "post")] async fn read_file(&self, payload: Json) -> OpenApiResult> { - let content = io_fs::read_file(payload.0.path, &self.ctx.state) + let content = svc::read_file(payload.0.path, &self.ctx.state) .await - .map_err(bad_request)?; + .map_err(|e| bad_request(e.to_string()))?; Ok(Json(content)) } /// Write a file to the currently open project, creating parent directories if needed. #[oai(path = "/io/fs/write", method = "post")] async fn write_file(&self, payload: Json) -> OpenApiResult> { - io_fs::write_file(payload.0.path, payload.0.content, &self.ctx.state) + svc::write_file(payload.0.path, payload.0.content, &self.ctx.state) .await - .map_err(bad_request)?; + .map_err(|e| bad_request(e.to_string()))?; Ok(Json(true)) } @@ -66,10 +66,10 @@ impl IoApi { async fn list_directory( &self, payload: Json, - ) -> OpenApiResult>> { - let entries = io_fs::list_directory(payload.0.path, &self.ctx.state) + ) -> OpenApiResult>> { + let entries = svc::list_directory(payload.0.path, &self.ctx.state) .await - .map_err(bad_request)?; + .map_err(|e| bad_request(e.to_string()))?; Ok(Json(entries)) } @@ -78,10 +78,10 @@ impl IoApi { async fn list_directory_absolute( &self, payload: Json, - ) -> OpenApiResult>> { - let entries = io_fs::list_directory_absolute(payload.0.path) + ) -> OpenApiResult>> { + let entries = svc::list_directory_absolute(payload.0.path) .await - .map_err(bad_request)?; + .map_err(|e| bad_request(e.to_string()))?; Ok(Json(entries)) } @@ -91,25 +91,25 @@ impl IoApi { &self, payload: Json, ) -> OpenApiResult> { - io_fs::create_directory_absolute(payload.0.path) + svc::create_directory_absolute(payload.0.path) .await - .map_err(bad_request)?; + .map_err(|e| bad_request(e.to_string()))?; Ok(Json(true)) } /// Get the user's home directory. #[oai(path = "/io/fs/home", method = "get")] async fn get_home_directory(&self) -> OpenApiResult> { - let home = io_fs::get_home_directory().map_err(bad_request)?; + let home = svc::get_home_directory().map_err(|e| bad_request(e.to_string()))?; Ok(Json(home)) } /// List all files in the project recursively, respecting .gitignore. #[oai(path = "/io/fs/files", method = "get")] async fn list_project_files(&self) -> OpenApiResult>> { - let files = io_fs::list_project_files(&self.ctx.state) + let files = svc::list_project_files(&self.ctx.state) .await - .map_err(bad_request)?; + .map_err(|e| bad_request(e.to_string()))?; Ok(Json(files)) } @@ -118,10 +118,10 @@ impl IoApi { async fn search_files( &self, payload: Json, - ) -> OpenApiResult>> { - let results = crate::io::search::search_files(payload.0.query, &self.ctx.state) + ) -> OpenApiResult>> { + let results = svc::search_files(payload.0.query, &self.ctx.state) .await - .map_err(bad_request)?; + .map_err(|e| bad_request(e.to_string()))?; Ok(Json(results)) } @@ -130,11 +130,10 @@ impl IoApi { async fn exec_shell( &self, payload: Json, - ) -> OpenApiResult> { - let output = - crate::io::shell::exec_shell(payload.0.command, payload.0.args, &self.ctx.state) - .await - .map_err(bad_request)?; + ) -> OpenApiResult> { + let output = svc::exec_shell(payload.0.command, payload.0.args, &self.ctx.state) + .await + .map_err(|e| bad_request(e.to_string()))?; Ok(Json(output)) } } diff --git a/server/src/llm/chat.rs b/server/src/llm/chat.rs index e3403970..b5870a16 100644 --- a/server/src/llm/chat.rs +++ b/server/src/llm/chat.rs @@ -31,35 +31,6 @@ pub struct ChatResult { pub session_id: Option, } -fn get_anthropic_api_key_exists_impl(store: &dyn StoreOps) -> bool { - match store.get(KEY_ANTHROPIC_API_KEY) { - Some(value) => value.as_str().map(|k| !k.is_empty()).unwrap_or(false), - None => false, - } -} - -fn set_anthropic_api_key_impl(store: &dyn StoreOps, api_key: &str) -> Result<(), String> { - store.set(KEY_ANTHROPIC_API_KEY, json!(api_key)); - store.save()?; - - match store.get(KEY_ANTHROPIC_API_KEY) { - Some(value) => { - if let Some(retrieved) = value.as_str() { - if retrieved != api_key { - return Err("Retrieved key does not match saved key".to_string()); - } - } else { - return Err("Stored value is not a string".to_string()); - } - } - None => { - return Err("API key was saved but cannot be retrieved".to_string()); - } - } - - Ok(()) -} - fn get_anthropic_api_key_impl(store: &dyn StoreOps) -> Result { match store.get(KEY_ANTHROPIC_API_KEY) { Some(value) => { @@ -172,14 +143,6 @@ pub async fn get_ollama_models(base_url: Option) -> Result, OllamaProvider::get_models(&url).await } -pub fn get_anthropic_api_key_exists(store: &dyn StoreOps) -> Result { - Ok(get_anthropic_api_key_exists_impl(store)) -} - -pub fn set_anthropic_api_key(store: &dyn StoreOps, api_key: String) -> Result<(), String> { - set_anthropic_api_key_impl(store, &api_key) -} - /// Build a prompt for Claude Code that includes prior conversation history. /// /// When a Claude Code session cannot be resumed (no session_id), we embed @@ -627,22 +590,6 @@ mod tests { save_should_fail: false, } } - - fn with_save_error() -> Self { - Self { - data: Mutex::new(HashMap::new()), - save_should_fail: true, - } - } - - fn with_entry(key: &str, value: serde_json::Value) -> Self { - let mut map = HashMap::new(); - map.insert(key.to_string(), value); - Self { - data: Mutex::new(map), - save_should_fail: false, - } - } } impl StoreOps for MockStore { @@ -695,121 +642,6 @@ mod tests { assert!(result.is_ok()); } - // --------------------------------------------------------------------------- - // get_anthropic_api_key_exists_impl - // --------------------------------------------------------------------------- - - #[test] - fn api_key_exists_when_key_is_present_and_non_empty() { - let store = MockStore::with_entry("anthropic_api_key", json!("sk-test-key")); - assert!(get_anthropic_api_key_exists_impl(&store)); - } - - #[test] - fn api_key_exists_returns_false_when_key_is_empty_string() { - let store = MockStore::with_entry("anthropic_api_key", json!("")); - assert!(!get_anthropic_api_key_exists_impl(&store)); - } - - #[test] - fn api_key_exists_returns_false_when_key_absent() { - let store = MockStore::new(); - assert!(!get_anthropic_api_key_exists_impl(&store)); - } - - #[test] - fn api_key_exists_returns_false_when_value_is_not_string() { - let store = MockStore::with_entry("anthropic_api_key", json!(42)); - assert!(!get_anthropic_api_key_exists_impl(&store)); - } - - // --------------------------------------------------------------------------- - // get_anthropic_api_key_impl - // --------------------------------------------------------------------------- - - #[test] - fn get_api_key_returns_key_when_present() { - let store = MockStore::with_entry("anthropic_api_key", json!("sk-test-key")); - let result = get_anthropic_api_key_impl(&store); - assert_eq!(result.unwrap(), "sk-test-key"); - } - - #[test] - fn get_api_key_errors_when_empty() { - let store = MockStore::with_entry("anthropic_api_key", json!("")); - let result = get_anthropic_api_key_impl(&store); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("empty")); - } - - #[test] - fn get_api_key_errors_when_absent() { - let store = MockStore::new(); - let result = get_anthropic_api_key_impl(&store); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("not found")); - } - - #[test] - fn get_api_key_errors_when_value_not_string() { - let store = MockStore::with_entry("anthropic_api_key", json!(123)); - let result = get_anthropic_api_key_impl(&store); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("not a string")); - } - - // --------------------------------------------------------------------------- - // set_anthropic_api_key_impl - // --------------------------------------------------------------------------- - - #[test] - fn set_api_key_stores_and_returns_ok() { - let store = MockStore::new(); - let result = set_anthropic_api_key_impl(&store, "sk-my-key"); - assert!(result.is_ok()); - assert_eq!(store.get("anthropic_api_key"), Some(json!("sk-my-key"))); - } - - #[test] - fn set_api_key_returns_error_when_save_fails() { - let store = MockStore::with_save_error(); - let result = set_anthropic_api_key_impl(&store, "sk-my-key"); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("mock save error")); - } - - // --------------------------------------------------------------------------- - // Public wrappers: get_anthropic_api_key_exists / set_anthropic_api_key - // --------------------------------------------------------------------------- - - #[test] - fn public_api_key_exists_returns_ok_bool() { - let store = MockStore::with_entry("anthropic_api_key", json!("sk-abc")); - let result = get_anthropic_api_key_exists(&store); - assert_eq!(result, Ok(true)); - } - - #[test] - fn public_api_key_exists_false_when_absent() { - let store = MockStore::new(); - let result = get_anthropic_api_key_exists(&store); - assert_eq!(result, Ok(false)); - } - - #[test] - fn public_set_api_key_succeeds() { - let store = MockStore::new(); - let result = set_anthropic_api_key(&store, "sk-xyz".to_string()); - assert!(result.is_ok()); - } - - #[test] - fn public_set_api_key_propagates_save_error() { - let store = MockStore::with_save_error(); - let result = set_anthropic_api_key(&store, "sk-xyz".to_string()); - assert!(result.is_err()); - } - // --------------------------------------------------------------------------- // get_tool_definitions // --------------------------------------------------------------------------- diff --git a/server/src/service/anthropic/io.rs b/server/src/service/anthropic/io.rs new file mode 100644 index 00000000..00db2467 --- /dev/null +++ b/server/src/service/anthropic/io.rs @@ -0,0 +1,100 @@ +//! Anthropic I/O — the ONLY place in `service/anthropic/` that may perform +//! network requests or store operations. +//! +//! Every function here is a thin adapter that converts lower-level errors +//! into the typed [`super::Error`] variants. No business logic or branching +//! lives here; that belongs in `mod.rs`. + +use super::{Error, ModelSummary, ModelsResponse}; +use crate::store::StoreOps; +use reqwest::header::{HeaderMap, HeaderValue}; + +/// Store key for the Anthropic API key — shared with `llm::chat`. +pub(crate) const KEY_ANTHROPIC_API_KEY: &str = "anthropic_api_key"; + +const ANTHROPIC_VERSION: &str = "2023-06-01"; + +/// Return whether a non-empty API key is stored. +pub(super) fn api_key_exists(store: &dyn StoreOps) -> bool { + match store.get(KEY_ANTHROPIC_API_KEY) { + Some(value) => value.as_str().map(|k| !k.is_empty()).unwrap_or(false), + None => false, + } +} + +/// Read the stored API key, returning a typed error when absent or invalid. +pub(super) fn get_api_key(store: &dyn StoreOps) -> Result { + match store.get(KEY_ANTHROPIC_API_KEY) { + Some(value) => { + if let Some(key) = value.as_str() { + if key.is_empty() { + Err(Error::Validation( + "Anthropic API key is empty. Please set your API key.".to_string(), + )) + } else { + Ok(key.to_string()) + } + } else { + Err(Error::Validation( + "Stored API key is not a string".to_string(), + )) + } + } + None => Err(Error::Validation( + "Anthropic API key not found. Please set your API key.".to_string(), + )), + } +} + +/// Persist a new API key to the store. +pub(super) fn save_api_key(store: &dyn StoreOps, api_key: &str) -> Result<(), String> { + store.set(KEY_ANTHROPIC_API_KEY, serde_json::json!(api_key)); + store.save() +} + +/// Fetch models from the Anthropic API at `url`. +pub(super) async fn fetch_models(api_key: &str, url: &str) -> Result, Error> { + let client = reqwest::Client::new(); + let mut headers = HeaderMap::new(); + headers.insert( + "x-api-key", + HeaderValue::from_str(api_key) + .map_err(|e| Error::Validation(format!("Invalid API key header value: {e}")))?, + ); + headers.insert( + "anthropic-version", + HeaderValue::from_static(ANTHROPIC_VERSION), + ); + + let response = client + .get(url) + .headers(headers) + .send() + .await + .map_err(|e| Error::UpstreamApi(e.to_string()))?; + + if !response.status().is_success() { + let status = response.status(); + let error_text = response + .text() + .await + .unwrap_or_else(|_| "Unknown error".to_string()); + return Err(Error::UpstreamApi(format!( + "Anthropic API error {status}: {error_text}" + ))); + } + + let body = response + .json::() + .await + .map_err(|e| Error::Internal(format!("Failed to parse response: {e}")))?; + + Ok(body + .data + .into_iter() + .map(|m| ModelSummary { + id: m.id, + context_window: m.context_window, + }) + .collect()) +} diff --git a/server/src/service/anthropic/mod.rs b/server/src/service/anthropic/mod.rs new file mode 100644 index 00000000..25c56b70 --- /dev/null +++ b/server/src/service/anthropic/mod.rs @@ -0,0 +1,178 @@ +//! Anthropic service — public API for Anthropic API-key management and model listing. +//! +//! Exposes functions to check, store, and use the Anthropic API key, and to +//! list available models. HTTP handlers call these functions instead of +//! talking to `llm::chat` or making HTTP requests directly. +//! +//! Conventions: `docs/architecture/service-modules.md` + +pub(super) mod io; + +use crate::store::StoreOps; +use serde::{Deserialize, Serialize}; + +const ANTHROPIC_MODELS_URL: &str = "https://api.anthropic.com/v1/models"; + +// ── Error type ──────────────────────────────────────────────────────────────── + +/// Typed errors returned by `service::anthropic` functions. +/// +/// HTTP handlers map these to status codes: +/// - [`Error::Validation`] → 400 Bad Request +/// - [`Error::UpstreamApi`] → 502 Bad Gateway (or 400 for invalid keys) +/// - [`Error::Internal`] → 500 Internal Server Error +#[derive(Debug)] +pub enum Error { + /// The request was invalid (e.g. missing, empty, or malformed API key). + Validation(String), + /// The upstream Anthropic API returned an error or was unreachable. + UpstreamApi(String), + /// An internal error occurred (JSON parse failure, store I/O error, etc.). + Internal(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::UpstreamApi(msg) => write!(f, "Upstream API error: {msg}"), + Self::Internal(msg) => write!(f, "Internal error: {msg}"), + } + } +} + +// ── Types ───────────────────────────────────────────────────────────────────── + +/// A summary of an Anthropic model as returned by the `/v1/models` endpoint. +#[derive(Serialize, Deserialize, Debug, PartialEq, poem_openapi::Object)] +pub struct ModelSummary { + pub id: String, + pub context_window: u64, +} + +/// Raw response shape from the Anthropic `/v1/models` endpoint. +#[derive(Deserialize)] +pub(super) struct ModelsResponse { + pub data: Vec, +} + +/// A single model entry in the Anthropic API response. +#[derive(Deserialize)] +pub(super) struct ModelInfo { + pub id: String, + pub context_window: u64, +} + +// ── Public API ──────────────────────────────────────────────────────────────── + +/// Return whether a non-empty Anthropic API key is currently stored. +pub fn get_api_key_exists(store: &dyn StoreOps) -> Result { + Ok(io::api_key_exists(store)) +} + +/// Read the stored Anthropic API key. +/// +/// Returns [`Error::Validation`] when the key is absent, empty, or not a string. +pub fn get_api_key(store: &dyn StoreOps) -> Result { + io::get_api_key(store) +} + +/// Store or replace the Anthropic API key. +pub fn set_api_key(store: &dyn StoreOps, api_key: String) -> Result<(), Error> { + io::save_api_key(store, &api_key).map_err(Error::Internal) +} + +/// List available Anthropic models from the production endpoint. +pub async fn list_models(store: &dyn StoreOps) -> Result, Error> { + list_models_from(store, ANTHROPIC_MODELS_URL).await +} + +/// List available Anthropic models from `url` (injectable for tests). +pub async fn list_models_from(store: &dyn StoreOps, url: &str) -> Result, Error> { + let api_key = get_api_key(store)?; + io::fetch_models(&api_key, url).await +} + +/// Parse a raw JSON string from the Anthropic `/v1/models` endpoint into model summaries. +/// +/// Pure function for unit testing; production code uses [`list_models`]. +#[cfg(test)] +pub fn parse_models_response(json: &str) -> Result, Error> { + let response: ModelsResponse = serde_json::from_str(json) + .map_err(|e| Error::Internal(format!("Failed to parse models response: {e}")))?; + Ok(response + .data + .into_iter() + .map(|m| ModelSummary { + id: m.id, + context_window: m.context_window, + }) + .collect()) +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + // Pure unit tests for response parsing — no tempdir, no network. + + #[test] + fn parse_models_response_parses_single_model() { + let json = r#"{"data":[{"id":"claude-opus-4-5","context_window":200000}]}"#; + let models = parse_models_response(json).unwrap(); + assert_eq!(models.len(), 1); + assert_eq!(models[0].id, "claude-opus-4-5"); + assert_eq!(models[0].context_window, 200000); + } + + #[test] + fn parse_models_response_parses_multiple_models() { + let json = r#"{"data":[ + {"id":"claude-opus-4-5","context_window":200000}, + {"id":"claude-haiku-4-5-20251001","context_window":100000} + ]}"#; + let models = parse_models_response(json).unwrap(); + assert_eq!(models.len(), 2); + assert_eq!(models[0].id, "claude-opus-4-5"); + assert_eq!(models[1].context_window, 100000); + } + + #[test] + fn parse_models_response_returns_empty_for_empty_data() { + let json = r#"{"data":[]}"#; + let models = parse_models_response(json).unwrap(); + assert!(models.is_empty()); + } + + #[test] + fn parse_models_response_returns_internal_error_for_invalid_json() { + let result = parse_models_response("not json at all"); + assert!(matches!(result, Err(Error::Internal(_)))); + } + + #[test] + fn parse_models_response_returns_error_for_missing_data_field() { + let result = parse_models_response(r#"{"wrong_field":[]}"#); + assert!(matches!(result, Err(Error::Internal(_)))); + } + + #[test] + fn error_display_validation() { + let e = Error::Validation("no key".to_string()); + assert!(e.to_string().contains("no key")); + } + + #[test] + fn error_display_upstream_api() { + let e = Error::UpstreamApi("500 Server Error".to_string()); + assert!(e.to_string().contains("500 Server Error")); + } + + #[test] + fn error_display_internal() { + let e = Error::Internal("parse failed".to_string()); + assert!(e.to_string().contains("parse failed")); + } +} diff --git a/server/src/service/file_io/io.rs b/server/src/service/file_io/io.rs new file mode 100644 index 00000000..c6c41c66 --- /dev/null +++ b/server/src/service/file_io/io.rs @@ -0,0 +1,84 @@ +//! File I/O — the ONLY place in `service/file_io/` that may perform +//! filesystem reads, writes, shell execution, or other side effects. +//! +//! Every function here is a thin adapter that converts lower-level +//! `String` errors into the typed [`super::Error`] variants. + +use super::Error; +use crate::io::fs::FileEntry; +use crate::io::search::SearchResult; +use crate::io::shell::CommandOutput; +use crate::state::SessionState; + +pub(super) async fn read_file(path: String, state: &SessionState) -> Result { + crate::io::fs::read_file(path, state) + .await + .map_err(Error::Filesystem) +} + +pub(super) async fn write_file( + path: String, + content: String, + state: &SessionState, +) -> Result<(), Error> { + crate::io::fs::write_file(path, content, state) + .await + .map_err(Error::Filesystem) +} + +pub(super) async fn list_directory( + path: String, + state: &SessionState, +) -> Result, Error> { + crate::io::fs::list_directory(path, state) + .await + .map_err(Error::Filesystem) +} + +pub(super) async fn list_directory_absolute(path: String) -> Result, Error> { + crate::io::fs::list_directory_absolute(path) + .await + .map_err(Error::Filesystem) +} + +pub(super) async fn create_directory_absolute(path: String) -> Result<(), Error> { + crate::io::fs::create_directory_absolute(path) + .await + .map_err(Error::Filesystem) + .map(|_| ()) +} + +pub(super) fn get_home_directory() -> Result { + crate::io::fs::get_home_directory().map_err(Error::Filesystem) +} + +pub(super) async fn list_project_files(state: &SessionState) -> Result, Error> { + crate::io::fs::list_project_files(state) + .await + .map_err(Error::Filesystem) +} + +pub(super) async fn search_files( + query: String, + state: &SessionState, +) -> Result, Error> { + crate::io::search::search_files(query, state) + .await + .map_err(Error::Filesystem) +} + +pub(super) async fn exec_shell( + command: String, + args: Vec, + state: &SessionState, +) -> Result { + crate::io::shell::exec_shell(command, args, state) + .await + .map_err(|e| { + if e.contains("not in the allowlist") { + Error::Validation(e) + } else { + Error::Filesystem(e) + } + }) +} diff --git a/server/src/service/file_io/mod.rs b/server/src/service/file_io/mod.rs new file mode 100644 index 00000000..52f5479c --- /dev/null +++ b/server/src/service/file_io/mod.rs @@ -0,0 +1,183 @@ +//! File I/O service — public API for filesystem and shell operations. +//! +//! Exposes functions for reading, writing, and listing files scoped to the +//! active project root, plus utilities for absolute-path and shell operations. +//! HTTP handlers call these functions instead of touching `io::fs` directly. +//! +//! Conventions: `docs/architecture/service-modules.md` + +pub(super) mod io; + +use crate::state::SessionState; + +/// Re-export the canonical filesystem entry type so HTTP handlers don't need +/// to import from `io::fs` directly. +pub use crate::io::fs::FileEntry; +/// Re-export the search result type. +pub use crate::io::search::SearchResult; +/// Re-export the shell output type. +pub use crate::io::shell::CommandOutput; + +// ── Error type ──────────────────────────────────────────────────────────────── + +/// Typed errors returned by `service::file_io` functions. +/// +/// HTTP handlers map these to status codes: +/// - [`Error::Validation`] → 400 Bad Request +/// - [`Error::Filesystem`] → 400 Bad Request (or 404 when appropriate) +#[derive(Debug)] +pub enum Error { + /// The request was invalid (e.g. path traversal attempt, command not allowlisted). + Validation(String), + /// A filesystem or shell operation failed (file not found, permission denied, etc.). + Filesystem(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::Filesystem(msg) => write!(f, "Filesystem error: {msg}"), + } + } +} + +// ── Path validation ─────────────────────────────────────────────────────────── + +/// Validate a relative path, rejecting directory traversal attempts. +/// +/// Returns [`Error::Validation`] when the path contains `..`. +pub fn validate_path(path: &str) -> Result<(), Error> { + if path.contains("..") { + return Err(Error::Validation( + "Security Violation: Directory traversal ('..') is not allowed.".to_string(), + )); + } + Ok(()) +} + +// ── Public API ──────────────────────────────────────────────────────────────── + +/// Read a file from the project root. +pub async fn read_file(path: String, state: &SessionState) -> Result { + validate_path(&path)?; + io::read_file(path, state).await +} + +/// Write a file to the project root, creating parent directories as needed. +pub async fn write_file(path: String, content: String, state: &SessionState) -> Result<(), Error> { + validate_path(&path)?; + io::write_file(path, content, state).await +} + +/// List directory entries at a project-relative path. +pub async fn list_directory(path: String, state: &SessionState) -> Result, Error> { + io::list_directory(path, state).await +} + +/// List directory entries at an absolute path (not scoped to the project root). +pub async fn list_directory_absolute(path: String) -> Result, Error> { + io::list_directory_absolute(path).await +} + +/// Create a directory (and all parents) at an absolute path. +pub async fn create_directory_absolute(path: String) -> Result<(), Error> { + io::create_directory_absolute(path).await +} + +/// Return the current user's home directory path. +pub fn get_home_directory() -> Result { + io::get_home_directory() +} + +/// List all files in the project recursively, respecting `.gitignore`. +pub async fn list_project_files(state: &SessionState) -> Result, Error> { + io::list_project_files(state).await +} + +/// Search the project for files whose contents contain `query`. +pub async fn search_files(query: String, state: &SessionState) -> Result, Error> { + io::search_files(query, state).await +} + +/// Execute an allowlisted shell command in the project root directory. +pub async fn exec_shell( + command: String, + args: Vec, + state: &SessionState, +) -> Result { + io::exec_shell(command, args, state).await +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + // Pure unit tests for path validation and sanitisation — no tempdir, no network. + + #[test] + fn validate_path_accepts_simple_relative_path() { + assert!(validate_path("src/main.rs").is_ok()); + } + + #[test] + fn validate_path_accepts_dot_path() { + assert!(validate_path(".").is_ok()); + } + + #[test] + fn validate_path_accepts_root_relative() { + assert!(validate_path("subdir/file.txt").is_ok()); + } + + #[test] + fn validate_path_rejects_parent_traversal() { + let result = validate_path("../etc/passwd"); + assert!(matches!(result, Err(Error::Validation(_)))); + } + + #[test] + fn validate_path_rejects_embedded_traversal() { + let result = validate_path("src/../../../etc/passwd"); + assert!(matches!(result, Err(Error::Validation(_)))); + } + + #[test] + fn validate_path_rejects_double_dot_only() { + let result = validate_path(".."); + assert!(matches!(result, Err(Error::Validation(_)))); + } + + #[test] + fn validate_path_accepts_file_with_single_dots_in_name() { + // Filenames like "config.dev.toml" have single dots — must be accepted. + assert!(validate_path("config.dev.toml").is_ok()); + } + + #[test] + fn validate_path_rejects_traversal_with_url_encoding_lookalike() { + // A literal ".." sequence anywhere in the string is rejected. + let result = validate_path("valid/..hidden"); + assert!(matches!(result, Err(Error::Validation(_)))); + } + + #[test] + fn error_display_validation() { + let e = Error::Validation("bad path".to_string()); + assert!(e.to_string().contains("bad path")); + } + + #[test] + fn error_display_filesystem() { + let e = Error::Filesystem("file not found".to_string()); + assert!(e.to_string().contains("file not found")); + } + + #[test] + fn error_display_filesystem_contains_message() { + let e = Error::Filesystem("task panic".to_string()); + assert!(e.to_string().contains("task panic")); + } +} diff --git a/server/src/service/mod.rs b/server/src/service/mod.rs index 01ec9d17..b7504598 100644 --- a/server/src/service/mod.rs +++ b/server/src/service/mod.rs @@ -6,8 +6,10 @@ //! - `io.rs` is the only file that performs side effects //! - Topic-named pure files contain branching logic with no I/O pub mod agents; +pub mod anthropic; pub mod bot_command; pub mod events; +pub mod file_io; pub mod health; pub mod project; pub mod ws;