huskies: merge 608_story_extract_io_and_anthropic_services
This commit is contained in:
@@ -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::http::context::{AppContext, OpenApiResult, bad_request};
|
||||||
use crate::llm::chat;
|
use crate::service::anthropic::{self as svc, ModelSummary};
|
||||||
use crate::store::StoreOps;
|
|
||||||
use poem_openapi::{Object, OpenApi, Tags, payload::Json};
|
use poem_openapi::{Object, OpenApi, Tags, payload::Json};
|
||||||
use reqwest::header::{HeaderMap, HeaderValue};
|
use serde::Deserialize;
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use std::sync::Arc;
|
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<AnthropicModelInfo>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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<String, String> {
|
|
||||||
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)]
|
#[derive(Deserialize, Object)]
|
||||||
struct ApiKeyPayload {
|
struct ApiKeyPayload {
|
||||||
api_key: String,
|
api_key: String,
|
||||||
@@ -79,8 +39,8 @@ impl AnthropicApi {
|
|||||||
/// Returns `true` if a non-empty key is present, otherwise `false`.
|
/// Returns `true` if a non-empty key is present, otherwise `false`.
|
||||||
#[oai(path = "/anthropic/key/exists", method = "get")]
|
#[oai(path = "/anthropic/key/exists", method = "get")]
|
||||||
async fn get_anthropic_api_key_exists(&self) -> OpenApiResult<Json<bool>> {
|
async fn get_anthropic_api_key_exists(&self) -> OpenApiResult<Json<bool>> {
|
||||||
let exists =
|
let exists = svc::get_api_key_exists(self.ctx.store.as_ref())
|
||||||
chat::get_anthropic_api_key_exists(self.ctx.store.as_ref()).map_err(bad_request)?;
|
.map_err(|e| bad_request(e.to_string()))?;
|
||||||
Ok(Json(exists))
|
Ok(Json(exists))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,74 +52,62 @@ impl AnthropicApi {
|
|||||||
&self,
|
&self,
|
||||||
payload: Json<ApiKeyPayload>,
|
payload: Json<ApiKeyPayload>,
|
||||||
) -> OpenApiResult<Json<bool>> {
|
) -> OpenApiResult<Json<bool>> {
|
||||||
chat::set_anthropic_api_key(self.ctx.store.as_ref(), payload.0.api_key)
|
svc::set_api_key(self.ctx.store.as_ref(), payload.0.api_key)
|
||||||
.map_err(bad_request)?;
|
.map_err(|e| bad_request(e.to_string()))?;
|
||||||
Ok(Json(true))
|
Ok(Json(true))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// List available Anthropic models.
|
/// List available Anthropic models.
|
||||||
#[oai(path = "/anthropic/models", method = "get")]
|
#[oai(path = "/anthropic/models", method = "get")]
|
||||||
async fn list_anthropic_models(&self) -> OpenApiResult<Json<Vec<AnthropicModelSummary>>> {
|
async fn list_anthropic_models(&self) -> OpenApiResult<Json<Vec<ModelSummary>>> {
|
||||||
self.list_anthropic_models_from(ANTHROPIC_MODELS_URL).await
|
let models = svc::list_models(self.ctx.store.as_ref())
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AnthropicApi {
|
|
||||||
async fn list_anthropic_models_from(
|
|
||||||
&self,
|
|
||||||
url: &str,
|
|
||||||
) -> OpenApiResult<Json<Vec<AnthropicModelSummary>>> {
|
|
||||||
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()
|
|
||||||
.await
|
.await
|
||||||
.map_err(|e| bad_request(e.to_string()))?;
|
.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::<AnthropicModelsResponse>()
|
|
||||||
.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))
|
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<Json<Vec<ModelSummary>>> {
|
||||||
|
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<String, String> {
|
||||||
|
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<AnthropicModelInfo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct AnthropicModelInfo {
|
||||||
|
id: String,
|
||||||
|
context_window: u64,
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::http::context::AppContext;
|
||||||
use crate::http::test_helpers::{make_api, test_ctx};
|
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 serde_json::json;
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
|||||||
+24
-25
@@ -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::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 poem_openapi::{Object, OpenApi, Tags, payload::Json};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -46,18 +46,18 @@ impl IoApi {
|
|||||||
/// Read a file from the currently open project and return its contents.
|
/// Read a file from the currently open project and return its contents.
|
||||||
#[oai(path = "/io/fs/read", method = "post")]
|
#[oai(path = "/io/fs/read", method = "post")]
|
||||||
async fn read_file(&self, payload: Json<FilePathPayload>) -> OpenApiResult<Json<String>> {
|
async fn read_file(&self, payload: Json<FilePathPayload>) -> OpenApiResult<Json<String>> {
|
||||||
let content = io_fs::read_file(payload.0.path, &self.ctx.state)
|
let content = svc::read_file(payload.0.path, &self.ctx.state)
|
||||||
.await
|
.await
|
||||||
.map_err(bad_request)?;
|
.map_err(|e| bad_request(e.to_string()))?;
|
||||||
Ok(Json(content))
|
Ok(Json(content))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Write a file to the currently open project, creating parent directories if needed.
|
/// Write a file to the currently open project, creating parent directories if needed.
|
||||||
#[oai(path = "/io/fs/write", method = "post")]
|
#[oai(path = "/io/fs/write", method = "post")]
|
||||||
async fn write_file(&self, payload: Json<WriteFilePayload>) -> OpenApiResult<Json<bool>> {
|
async fn write_file(&self, payload: Json<WriteFilePayload>) -> OpenApiResult<Json<bool>> {
|
||||||
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
|
.await
|
||||||
.map_err(bad_request)?;
|
.map_err(|e| bad_request(e.to_string()))?;
|
||||||
Ok(Json(true))
|
Ok(Json(true))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,10 +66,10 @@ impl IoApi {
|
|||||||
async fn list_directory(
|
async fn list_directory(
|
||||||
&self,
|
&self,
|
||||||
payload: Json<FilePathPayload>,
|
payload: Json<FilePathPayload>,
|
||||||
) -> OpenApiResult<Json<Vec<io_fs::FileEntry>>> {
|
) -> OpenApiResult<Json<Vec<FileEntry>>> {
|
||||||
let entries = io_fs::list_directory(payload.0.path, &self.ctx.state)
|
let entries = svc::list_directory(payload.0.path, &self.ctx.state)
|
||||||
.await
|
.await
|
||||||
.map_err(bad_request)?;
|
.map_err(|e| bad_request(e.to_string()))?;
|
||||||
Ok(Json(entries))
|
Ok(Json(entries))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,10 +78,10 @@ impl IoApi {
|
|||||||
async fn list_directory_absolute(
|
async fn list_directory_absolute(
|
||||||
&self,
|
&self,
|
||||||
payload: Json<FilePathPayload>,
|
payload: Json<FilePathPayload>,
|
||||||
) -> OpenApiResult<Json<Vec<io_fs::FileEntry>>> {
|
) -> OpenApiResult<Json<Vec<FileEntry>>> {
|
||||||
let entries = io_fs::list_directory_absolute(payload.0.path)
|
let entries = svc::list_directory_absolute(payload.0.path)
|
||||||
.await
|
.await
|
||||||
.map_err(bad_request)?;
|
.map_err(|e| bad_request(e.to_string()))?;
|
||||||
Ok(Json(entries))
|
Ok(Json(entries))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,25 +91,25 @@ impl IoApi {
|
|||||||
&self,
|
&self,
|
||||||
payload: Json<CreateDirectoryPayload>,
|
payload: Json<CreateDirectoryPayload>,
|
||||||
) -> OpenApiResult<Json<bool>> {
|
) -> OpenApiResult<Json<bool>> {
|
||||||
io_fs::create_directory_absolute(payload.0.path)
|
svc::create_directory_absolute(payload.0.path)
|
||||||
.await
|
.await
|
||||||
.map_err(bad_request)?;
|
.map_err(|e| bad_request(e.to_string()))?;
|
||||||
Ok(Json(true))
|
Ok(Json(true))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the user's home directory.
|
/// Get the user's home directory.
|
||||||
#[oai(path = "/io/fs/home", method = "get")]
|
#[oai(path = "/io/fs/home", method = "get")]
|
||||||
async fn get_home_directory(&self) -> OpenApiResult<Json<String>> {
|
async fn get_home_directory(&self) -> OpenApiResult<Json<String>> {
|
||||||
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))
|
Ok(Json(home))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// List all files in the project recursively, respecting .gitignore.
|
/// List all files in the project recursively, respecting .gitignore.
|
||||||
#[oai(path = "/io/fs/files", method = "get")]
|
#[oai(path = "/io/fs/files", method = "get")]
|
||||||
async fn list_project_files(&self) -> OpenApiResult<Json<Vec<String>>> {
|
async fn list_project_files(&self) -> OpenApiResult<Json<Vec<String>>> {
|
||||||
let files = io_fs::list_project_files(&self.ctx.state)
|
let files = svc::list_project_files(&self.ctx.state)
|
||||||
.await
|
.await
|
||||||
.map_err(bad_request)?;
|
.map_err(|e| bad_request(e.to_string()))?;
|
||||||
Ok(Json(files))
|
Ok(Json(files))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,10 +118,10 @@ impl IoApi {
|
|||||||
async fn search_files(
|
async fn search_files(
|
||||||
&self,
|
&self,
|
||||||
payload: Json<SearchPayload>,
|
payload: Json<SearchPayload>,
|
||||||
) -> OpenApiResult<Json<Vec<crate::io::search::SearchResult>>> {
|
) -> OpenApiResult<Json<Vec<crate::service::file_io::SearchResult>>> {
|
||||||
let results = crate::io::search::search_files(payload.0.query, &self.ctx.state)
|
let results = svc::search_files(payload.0.query, &self.ctx.state)
|
||||||
.await
|
.await
|
||||||
.map_err(bad_request)?;
|
.map_err(|e| bad_request(e.to_string()))?;
|
||||||
Ok(Json(results))
|
Ok(Json(results))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,11 +130,10 @@ impl IoApi {
|
|||||||
async fn exec_shell(
|
async fn exec_shell(
|
||||||
&self,
|
&self,
|
||||||
payload: Json<ExecShellPayload>,
|
payload: Json<ExecShellPayload>,
|
||||||
) -> OpenApiResult<Json<crate::io::shell::CommandOutput>> {
|
) -> OpenApiResult<Json<crate::service::file_io::CommandOutput>> {
|
||||||
let output =
|
let output = svc::exec_shell(payload.0.command, payload.0.args, &self.ctx.state)
|
||||||
crate::io::shell::exec_shell(payload.0.command, payload.0.args, &self.ctx.state)
|
.await
|
||||||
.await
|
.map_err(|e| bad_request(e.to_string()))?;
|
||||||
.map_err(bad_request)?;
|
|
||||||
Ok(Json(output))
|
Ok(Json(output))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,35 +31,6 @@ pub struct ChatResult {
|
|||||||
pub session_id: Option<String>,
|
pub session_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
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<String, String> {
|
fn get_anthropic_api_key_impl(store: &dyn StoreOps) -> Result<String, String> {
|
||||||
match store.get(KEY_ANTHROPIC_API_KEY) {
|
match store.get(KEY_ANTHROPIC_API_KEY) {
|
||||||
Some(value) => {
|
Some(value) => {
|
||||||
@@ -172,14 +143,6 @@ pub async fn get_ollama_models(base_url: Option<String>) -> Result<Vec<String>,
|
|||||||
OllamaProvider::get_models(&url).await
|
OllamaProvider::get_models(&url).await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_anthropic_api_key_exists(store: &dyn StoreOps) -> Result<bool, String> {
|
|
||||||
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.
|
/// Build a prompt for Claude Code that includes prior conversation history.
|
||||||
///
|
///
|
||||||
/// When a Claude Code session cannot be resumed (no session_id), we embed
|
/// When a Claude Code session cannot be resumed (no session_id), we embed
|
||||||
@@ -627,22 +590,6 @@ mod tests {
|
|||||||
save_should_fail: false,
|
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 {
|
impl StoreOps for MockStore {
|
||||||
@@ -695,121 +642,6 @@ mod tests {
|
|||||||
assert!(result.is_ok());
|
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
|
// get_tool_definitions
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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<String, Error> {
|
||||||
|
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<Vec<ModelSummary>, 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::<ModelsResponse>()
|
||||||
|
.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())
|
||||||
|
}
|
||||||
@@ -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<ModelInfo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<bool, Error> {
|
||||||
|
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<String, Error> {
|
||||||
|
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<Vec<ModelSummary>, 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<Vec<ModelSummary>, 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<Vec<ModelSummary>, 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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String, Error> {
|
||||||
|
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<Vec<FileEntry>, Error> {
|
||||||
|
crate::io::fs::list_directory(path, state)
|
||||||
|
.await
|
||||||
|
.map_err(Error::Filesystem)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn list_directory_absolute(path: String) -> Result<Vec<FileEntry>, 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<String, Error> {
|
||||||
|
crate::io::fs::get_home_directory().map_err(Error::Filesystem)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn list_project_files(state: &SessionState) -> Result<Vec<String>, Error> {
|
||||||
|
crate::io::fs::list_project_files(state)
|
||||||
|
.await
|
||||||
|
.map_err(Error::Filesystem)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn search_files(
|
||||||
|
query: String,
|
||||||
|
state: &SessionState,
|
||||||
|
) -> Result<Vec<SearchResult>, Error> {
|
||||||
|
crate::io::search::search_files(query, state)
|
||||||
|
.await
|
||||||
|
.map_err(Error::Filesystem)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn exec_shell(
|
||||||
|
command: String,
|
||||||
|
args: Vec<String>,
|
||||||
|
state: &SessionState,
|
||||||
|
) -> Result<CommandOutput, Error> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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<String, Error> {
|
||||||
|
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<Vec<FileEntry>, 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<Vec<FileEntry>, 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<String, Error> {
|
||||||
|
io::get_home_directory()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List all files in the project recursively, respecting `.gitignore`.
|
||||||
|
pub async fn list_project_files(state: &SessionState) -> Result<Vec<String>, 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<Vec<SearchResult>, 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<String>,
|
||||||
|
state: &SessionState,
|
||||||
|
) -> Result<CommandOutput, Error> {
|
||||||
|
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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,8 +6,10 @@
|
|||||||
//! - `io.rs` is the only file that performs side effects
|
//! - `io.rs` is the only file that performs side effects
|
||||||
//! - Topic-named pure files contain branching logic with no I/O
|
//! - Topic-named pure files contain branching logic with no I/O
|
||||||
pub mod agents;
|
pub mod agents;
|
||||||
|
pub mod anthropic;
|
||||||
pub mod bot_command;
|
pub mod bot_command;
|
||||||
pub mod events;
|
pub mod events;
|
||||||
|
pub mod file_io;
|
||||||
pub mod health;
|
pub mod health;
|
||||||
pub mod project;
|
pub mod project;
|
||||||
pub mod ws;
|
pub mod ws;
|
||||||
|
|||||||
Reference in New Issue
Block a user