//! Filesystem file operations — read, write, list, and create files and directories. use crate::state::SessionState; use serde::Serialize; use std::fs; use std::path::PathBuf; use super::paths::{resolve_path, resolve_path_impl}; async fn read_file_impl(full_path: PathBuf) -> Result { tokio::task::spawn_blocking(move || { fs::read_to_string(&full_path).map_err(|e| format!("Failed to read file: {}", e)) }) .await .map_err(|e| format!("Task failed: {}", e))? } pub async fn read_file(path: String, state: &SessionState) -> Result { let full_path = resolve_path(state, &path)?; read_file_impl(full_path).await } async fn write_file_impl(full_path: PathBuf, content: String) -> Result<(), String> { tokio::task::spawn_blocking(move || { if let Some(parent) = full_path.parent() { fs::create_dir_all(parent) .map_err(|e| format!("Failed to create directories: {}", e))?; } fs::write(&full_path, content).map_err(|e| format!("Failed to write file: {}", e)) }) .await .map_err(|e| format!("Task failed: {}", e))? } pub async fn write_file(path: String, content: String, state: &SessionState) -> Result<(), String> { let root = state.get_project_root()?; let full_path = resolve_path_impl(root, &path)?; write_file_impl(full_path, content).await } #[derive(Serialize, Debug, poem_openapi::Object)] /// A directory listing entry with its name and kind (file or directory). pub struct FileEntry { pub name: String, pub kind: String, } async fn list_directory_impl(full_path: PathBuf) -> Result, String> { tokio::task::spawn_blocking(move || { let entries = fs::read_dir(&full_path).map_err(|e| format!("Failed to read dir: {}", e))?; let mut result = Vec::new(); for entry in entries { let entry = entry.map_err(|e| e.to_string())?; let ft = entry.file_type().map_err(|e| e.to_string())?; let name = entry.file_name().to_string_lossy().to_string(); result.push(FileEntry { name, kind: if ft.is_dir() { "dir".to_string() } else { "file".to_string() }, }); } result.sort_by(|a, b| match (a.kind.as_str(), b.kind.as_str()) { ("dir", "file") => std::cmp::Ordering::Less, ("file", "dir") => std::cmp::Ordering::Greater, _ => a.name.cmp(&b.name), }); Ok(result) }) .await .map_err(|e| format!("Task failed: {}", e))? } pub async fn list_directory(path: String, state: &SessionState) -> Result, String> { let full_path = resolve_path(state, &path)?; list_directory_impl(full_path).await } pub async fn list_directory_absolute(path: String) -> Result, String> { let full_path = PathBuf::from(path); list_directory_impl(full_path).await } pub async fn create_directory_absolute(path: String) -> Result { let full_path = PathBuf::from(path); tokio::task::spawn_blocking(move || { fs::create_dir_all(&full_path).map_err(|e| format!("Failed to create directory: {}", e))?; Ok(true) }) .await .map_err(|e| format!("Task failed: {}", e))? } /// List all files in the project recursively, respecting .gitignore. /// Returns relative paths from the project root (files only, not directories). pub async fn list_project_files(state: &SessionState) -> Result, String> { let root = state.get_project_root()?; list_project_files_impl(root).await } pub async fn list_project_files_impl(root: PathBuf) -> Result, String> { use ignore::WalkBuilder; let root_clone = root.clone(); let files = tokio::task::spawn_blocking(move || { let mut result = Vec::new(); let walker = WalkBuilder::new(&root_clone).git_ignore(true).build(); for entry in walker.flatten() { if entry.file_type().map(|ft| ft.is_file()).unwrap_or(false) { let relative = entry .path() .strip_prefix(&root_clone) .unwrap_or(entry.path()) .to_string_lossy() .to_string(); result.push(relative); } } result.sort(); result }) .await .map_err(|e| format!("Task failed: {e}"))?; Ok(files) } #[cfg(test)] mod tests { use super::*; use crate::state::SessionState; use std::path::PathBuf; use tempfile::tempdir; fn make_state_with_root(path: PathBuf) -> SessionState { let state = SessionState::default(); { let mut root = state.project_root.lock().unwrap(); *root = Some(path); } state } // --- file operations --- #[tokio::test] async fn read_file_impl_reads_content() { let dir = tempdir().unwrap(); let file = dir.path().join("test.txt"); fs::write(&file, "hello world").unwrap(); let content = read_file_impl(file).await.unwrap(); assert_eq!(content, "hello world"); } #[tokio::test] async fn read_file_impl_errors_on_missing() { let dir = tempdir().unwrap(); let result = read_file_impl(dir.path().join("missing.txt")).await; assert!(result.is_err()); } #[tokio::test] async fn write_file_impl_creates_and_writes() { let dir = tempdir().unwrap(); let file = dir.path().join("sub").join("output.txt"); write_file_impl(file.clone(), "content".to_string()) .await .unwrap(); assert_eq!(fs::read_to_string(&file).unwrap(), "content"); } // --- list directory --- #[tokio::test] async fn list_directory_impl_returns_sorted_entries() { let dir = tempdir().unwrap(); fs::create_dir(dir.path().join("zdir")).unwrap(); fs::create_dir(dir.path().join("adir")).unwrap(); fs::write(dir.path().join("file.txt"), "").unwrap(); let entries = list_directory_impl(dir.path().to_path_buf()).await.unwrap(); assert_eq!(entries[0].name, "adir"); assert_eq!(entries[0].kind, "dir"); assert_eq!(entries[1].name, "zdir"); assert_eq!(entries[1].kind, "dir"); assert_eq!(entries[2].name, "file.txt"); assert_eq!(entries[2].kind, "file"); } // --- list_project_files_impl --- #[tokio::test] async fn list_project_files_returns_all_files() { let dir = tempdir().unwrap(); fs::create_dir(dir.path().join("src")).unwrap(); fs::write(dir.path().join("src/main.rs"), "fn main() {}").unwrap(); fs::write(dir.path().join("README.md"), "# readme").unwrap(); let files = list_project_files_impl(dir.path().to_path_buf()) .await .unwrap(); assert!(files.contains(&"README.md".to_string())); assert!(files.contains(&"src/main.rs".to_string())); } #[tokio::test] async fn list_project_files_excludes_dirs_from_output() { let dir = tempdir().unwrap(); fs::create_dir(dir.path().join("subdir")).unwrap(); fs::write(dir.path().join("file.txt"), "").unwrap(); let files = list_project_files_impl(dir.path().to_path_buf()) .await .unwrap(); assert!(files.contains(&"file.txt".to_string())); assert!(!files.iter().any(|f| f == "subdir")); } #[tokio::test] async fn list_project_files_returns_sorted() { let dir = tempdir().unwrap(); fs::write(dir.path().join("z.txt"), "").unwrap(); fs::write(dir.path().join("a.txt"), "").unwrap(); let files = list_project_files_impl(dir.path().to_path_buf()) .await .unwrap(); let a_idx = files.iter().position(|f| f == "a.txt").unwrap(); let z_idx = files.iter().position(|f| f == "z.txt").unwrap(); assert!(a_idx < z_idx); } #[tokio::test] async fn list_project_files_with_state() { let dir = tempdir().unwrap(); fs::write(dir.path().join("hello.rs"), "").unwrap(); let state = make_state_with_root(dir.path().to_path_buf()); let files = list_project_files(&state).await.unwrap(); assert!(files.contains(&"hello.rs".to_string())); } #[tokio::test] async fn list_project_files_errors_without_project() { let state = SessionState::default(); let result = list_project_files(&state).await; assert!(result.is_err()); } }