use crate::http::context::{AppContext, OpenApiResult, bad_request}; use crate::io::fs as io_fs; use poem_openapi::{Object, OpenApi, Tags, payload::Json}; use serde::Deserialize; use std::sync::Arc; #[derive(Tags)] enum IoTags { Io, } #[derive(Deserialize, Object)] struct FilePathPayload { pub path: String, } #[derive(Deserialize, Object)] struct WriteFilePayload { pub path: String, pub content: String, } #[derive(Deserialize, Object)] struct SearchPayload { query: String, } #[derive(Deserialize, Object)] struct CreateDirectoryPayload { pub path: String, } #[derive(Deserialize, Object)] struct ExecShellPayload { pub command: String, pub args: Vec, } pub struct IoApi { pub ctx: Arc, } #[OpenApi(tag = "IoTags::Io")] 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) .await .map_err(bad_request)?; 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) .await .map_err(bad_request)?; Ok(Json(true)) } /// List files and folders in a directory within the currently open project. #[oai(path = "/io/fs/list", method = "post")] async fn list_directory( &self, payload: Json, ) -> OpenApiResult>> { let entries = io_fs::list_directory(payload.0.path, &self.ctx.state) .await .map_err(bad_request)?; Ok(Json(entries)) } /// List files and folders at an absolute path (not scoped to the project root). #[oai(path = "/io/fs/list/absolute", method = "post")] async fn list_directory_absolute( &self, payload: Json, ) -> OpenApiResult>> { let entries = io_fs::list_directory_absolute(payload.0.path) .await .map_err(bad_request)?; Ok(Json(entries)) } /// Create a directory at an absolute path. #[oai(path = "/io/fs/create/absolute", method = "post")] async fn create_directory_absolute( &self, payload: Json, ) -> OpenApiResult> { io_fs::create_directory_absolute(payload.0.path) .await .map_err(bad_request)?; 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)?; Ok(Json(home)) } /// Search the currently open project for files containing the provided query string. #[oai(path = "/io/search", method = "post")] async fn search_files( &self, payload: Json, ) -> OpenApiResult>> { let results = crate::io::search::search_files(payload.0.query, &self.ctx.state) .await .map_err(bad_request)?; Ok(Json(results)) } /// Execute an allowlisted shell command in the currently open project. #[oai(path = "/io/shell/exec", method = "post")] 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)?; Ok(Json(output)) } } #[cfg(test)] mod tests { use super::*; use crate::http::context::AppContext; use tempfile::TempDir; fn make_api(dir: &TempDir) -> IoApi { IoApi { ctx: Arc::new(AppContext::new_test(dir.path().to_path_buf())), } } // --- list_directory_absolute --- #[tokio::test] async fn list_directory_absolute_returns_entries_for_valid_path() { let dir = TempDir::new().unwrap(); std::fs::create_dir(dir.path().join("subdir")).unwrap(); std::fs::write(dir.path().join("file.txt"), "content").unwrap(); let api = make_api(&dir); let payload = Json(FilePathPayload { path: dir.path().to_string_lossy().to_string(), }); let result = api.list_directory_absolute(payload).await.unwrap(); let entries = &result.0; assert!(entries.len() >= 2); assert!(entries.iter().any(|e| e.name == "subdir" && e.kind == "dir")); assert!(entries.iter().any(|e| e.name == "file.txt" && e.kind == "file")); } #[tokio::test] async fn list_directory_absolute_returns_empty_for_empty_dir() { let dir = TempDir::new().unwrap(); let empty = dir.path().join("empty"); std::fs::create_dir(&empty).unwrap(); let api = make_api(&dir); let payload = Json(FilePathPayload { path: empty.to_string_lossy().to_string(), }); let result = api.list_directory_absolute(payload).await.unwrap(); assert!(result.0.is_empty()); } #[tokio::test] async fn list_directory_absolute_errors_on_nonexistent_path() { let dir = TempDir::new().unwrap(); let api = make_api(&dir); let payload = Json(FilePathPayload { path: dir.path().join("nonexistent").to_string_lossy().to_string(), }); let result = api.list_directory_absolute(payload).await; assert!(result.is_err()); } #[tokio::test] async fn list_directory_absolute_errors_on_file_path() { let dir = TempDir::new().unwrap(); let file = dir.path().join("not_a_dir.txt"); std::fs::write(&file, "content").unwrap(); let api = make_api(&dir); let payload = Json(FilePathPayload { path: file.to_string_lossy().to_string(), }); let result = api.list_directory_absolute(payload).await; assert!(result.is_err()); } // --- create_directory_absolute --- #[tokio::test] async fn create_directory_absolute_creates_new_dir() { let dir = TempDir::new().unwrap(); let new_dir = dir.path().join("new_dir"); let api = make_api(&dir); let payload = Json(CreateDirectoryPayload { path: new_dir.to_string_lossy().to_string(), }); let result = api.create_directory_absolute(payload).await.unwrap(); assert!(result.0); assert!(new_dir.is_dir()); } #[tokio::test] async fn create_directory_absolute_succeeds_for_existing_dir() { let dir = TempDir::new().unwrap(); let existing = dir.path().join("existing"); std::fs::create_dir(&existing).unwrap(); let api = make_api(&dir); let payload = Json(CreateDirectoryPayload { path: existing.to_string_lossy().to_string(), }); let result = api.create_directory_absolute(payload).await.unwrap(); assert!(result.0); } #[tokio::test] async fn create_directory_absolute_creates_nested_dirs() { let dir = TempDir::new().unwrap(); let nested = dir.path().join("a").join("b").join("c"); let api = make_api(&dir); let payload = Json(CreateDirectoryPayload { path: nested.to_string_lossy().to_string(), }); let result = api.create_directory_absolute(payload).await.unwrap(); assert!(result.0); assert!(nested.is_dir()); } // --- get_home_directory --- #[tokio::test] async fn get_home_directory_returns_a_path() { let dir = TempDir::new().unwrap(); let api = make_api(&dir); let result = api.get_home_directory().await.unwrap(); let home = &result.0; assert!(!home.is_empty()); assert!(std::path::Path::new(home).is_absolute()); } // --- read_file (project-scoped) --- #[tokio::test] async fn read_file_returns_content() { let dir = TempDir::new().unwrap(); std::fs::write(dir.path().join("hello.txt"), "hello world").unwrap(); let api = make_api(&dir); let payload = Json(FilePathPayload { path: "hello.txt".to_string(), }); let result = api.read_file(payload).await.unwrap(); assert_eq!(result.0, "hello world"); } #[tokio::test] async fn read_file_errors_on_missing_file() { let dir = TempDir::new().unwrap(); let api = make_api(&dir); let payload = Json(FilePathPayload { path: "nonexistent.txt".to_string(), }); let result = api.read_file(payload).await; assert!(result.is_err()); } // --- write_file (project-scoped) --- #[tokio::test] async fn write_file_creates_file() { let dir = TempDir::new().unwrap(); let api = make_api(&dir); let payload = Json(WriteFilePayload { path: "output.txt".to_string(), content: "written content".to_string(), }); let result = api.write_file(payload).await.unwrap(); assert!(result.0); assert_eq!( std::fs::read_to_string(dir.path().join("output.txt")).unwrap(), "written content" ); } #[tokio::test] async fn write_file_creates_parent_dirs() { let dir = TempDir::new().unwrap(); let api = make_api(&dir); let payload = Json(WriteFilePayload { path: "sub/dir/file.txt".to_string(), content: "nested".to_string(), }); let result = api.write_file(payload).await.unwrap(); assert!(result.0); assert_eq!( std::fs::read_to_string(dir.path().join("sub/dir/file.txt")).unwrap(), "nested" ); } // --- list_directory (project-scoped) --- #[tokio::test] async fn list_directory_returns_entries() { let dir = TempDir::new().unwrap(); std::fs::create_dir(dir.path().join("adir")).unwrap(); std::fs::write(dir.path().join("bfile.txt"), "").unwrap(); let api = make_api(&dir); let payload = Json(FilePathPayload { path: ".".to_string(), }); let result = api.list_directory(payload).await.unwrap(); let entries = &result.0; assert!(entries.iter().any(|e| e.name == "adir" && e.kind == "dir")); assert!(entries.iter().any(|e| e.name == "bfile.txt" && e.kind == "file")); } #[tokio::test] async fn list_directory_errors_on_nonexistent() { let dir = TempDir::new().unwrap(); let api = make_api(&dir); let payload = Json(FilePathPayload { path: "nonexistent_dir".to_string(), }); let result = api.list_directory(payload).await; assert!(result.is_err()); } }