Adds assigned agent display to the expanded work item detail panel. Resolved conflicts by keeping master versions of bot.rs (permission handling), ChatInput.tsx, and fs.rs. Removed duplicate list_project_files endpoint and tests from io.rs. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
406 lines
13 KiB
Rust
406 lines
13 KiB
Rust
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<String>,
|
|
}
|
|
|
|
pub struct IoApi {
|
|
pub ctx: Arc<AppContext>,
|
|
}
|
|
|
|
#[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<FilePathPayload>) -> OpenApiResult<Json<String>> {
|
|
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<WriteFilePayload>) -> OpenApiResult<Json<bool>> {
|
|
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<FilePathPayload>,
|
|
) -> OpenApiResult<Json<Vec<io_fs::FileEntry>>> {
|
|
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<FilePathPayload>,
|
|
) -> OpenApiResult<Json<Vec<io_fs::FileEntry>>> {
|
|
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<CreateDirectoryPayload>,
|
|
) -> OpenApiResult<Json<bool>> {
|
|
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<Json<String>> {
|
|
let home = io_fs::get_home_directory().map_err(bad_request)?;
|
|
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<Json<Vec<String>>> {
|
|
let files = io_fs::list_project_files(&self.ctx.state)
|
|
.await
|
|
.map_err(bad_request)?;
|
|
Ok(Json(files))
|
|
}
|
|
|
|
/// 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<SearchPayload>,
|
|
) -> OpenApiResult<Json<Vec<crate::io::search::SearchResult>>> {
|
|
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<ExecShellPayload>,
|
|
) -> OpenApiResult<Json<crate::io::shell::CommandOutput>> {
|
|
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_project_files ---
|
|
|
|
#[tokio::test]
|
|
async fn list_project_files_returns_file_paths() {
|
|
let dir = TempDir::new().unwrap();
|
|
std::fs::create_dir(dir.path().join("src")).unwrap();
|
|
std::fs::write(dir.path().join("src/main.rs"), "fn main() {}").unwrap();
|
|
std::fs::write(dir.path().join("README.md"), "# readme").unwrap();
|
|
|
|
let api = make_api(&dir);
|
|
let result = api.list_project_files().await.unwrap();
|
|
let files = &result.0;
|
|
|
|
assert!(files.contains(&"README.md".to_string()));
|
|
assert!(files.contains(&"src/main.rs".to_string()));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn list_project_files_excludes_directories() {
|
|
let dir = TempDir::new().unwrap();
|
|
std::fs::create_dir(dir.path().join("subdir")).unwrap();
|
|
std::fs::write(dir.path().join("file.txt"), "").unwrap();
|
|
|
|
let api = make_api(&dir);
|
|
let result = api.list_project_files().await.unwrap();
|
|
let files = &result.0;
|
|
|
|
assert!(files.contains(&"file.txt".to_string()));
|
|
// Directories should not appear
|
|
assert!(!files.iter().any(|f| f == "subdir"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn list_project_files_returns_sorted_paths() {
|
|
let dir = TempDir::new().unwrap();
|
|
std::fs::write(dir.path().join("z_last.txt"), "").unwrap();
|
|
std::fs::write(dir.path().join("a_first.txt"), "").unwrap();
|
|
|
|
let api = make_api(&dir);
|
|
let result = api.list_project_files().await.unwrap();
|
|
let files = &result.0;
|
|
|
|
let a_idx = files.iter().position(|f| f == "a_first.txt").unwrap();
|
|
let z_idx = files.iter().position(|f| f == "z_last.txt").unwrap();
|
|
assert!(a_idx < z_idx);
|
|
}
|
|
|
|
// --- 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());
|
|
}
|
|
|
|
}
|