use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use tokio::fs;
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
pub struct ReadFileInput {
pub path: String,
#[serde(default)]
pub max_bytes: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ReadFileOutput {
pub content: String,
pub bytes_read: usize,
pub truncated: bool,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
pub struct WriteFileInput {
pub path: String,
pub content: String,
#[serde(default = "default_true")]
pub create_parents: bool,
}
fn default_true() -> bool {
true
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct WriteFileOutput {
pub bytes_written: usize,
pub path: String,
}
pub struct FileSystemService {
project_dir: PathBuf,
}
impl FileSystemService {
pub fn new(project_dir: PathBuf) -> Self {
Self { project_dir }
}
pub fn from_cwd() -> Result<Self> {
let project_dir = std::env::current_dir().context("Failed to get current directory")?;
Ok(Self { project_dir })
}
fn resolve_path(&self, path: &str) -> PathBuf {
let p = PathBuf::from(path);
if p.is_absolute() {
p
} else {
self.project_dir.join(p)
}
}
#[tracing::instrument(skip(self))]
pub async fn read_file(&self, input: &ReadFileInput) -> Result<ReadFileOutput> {
let path = self.resolve_path(&input.path);
let content = fs::read_to_string(&path)
.await
.with_context(|| format!("Failed to read file: {}", path.display()))?;
let bytes_read = content.len();
let (content, truncated) = if input.max_bytes > 0 && bytes_read > input.max_bytes {
let truncated_content: String = content.chars().take(input.max_bytes).collect();
(truncated_content, true)
} else {
(content, false)
};
Ok(ReadFileOutput {
content,
bytes_read,
truncated,
})
}
#[tracing::instrument(skip(self))]
pub async fn write_file(&self, input: &WriteFileInput) -> Result<WriteFileOutput> {
let path = self.resolve_path(&input.path);
if input.create_parents {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).await.with_context(|| {
format!(
"Failed to create parent directories for: {}",
path.display()
)
})?;
}
}
let bytes_written = input.content.len();
fs::write(&path, &input.content)
.await
.with_context(|| format!("Failed to write file: {}", path.display()))?;
Ok(WriteFileOutput {
bytes_written,
path: path.to_string_lossy().to_string(),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[tokio::test]
async fn test_read_write_file() {
let dir = tempdir().unwrap();
let service = FileSystemService::new(dir.path().to_path_buf());
let write_input = WriteFileInput {
path: "test.txt".to_string(),
content: "Hello, World!".to_string(),
create_parents: true,
};
let write_result = service.write_file(&write_input).await.unwrap();
assert_eq!(write_result.bytes_written, 13);
let read_input = ReadFileInput {
path: "test.txt".to_string(),
max_bytes: 0,
};
let read_result = service.read_file(&read_input).await.unwrap();
assert_eq!(read_result.content, "Hello, World!");
assert!(!read_result.truncated);
}
#[tokio::test]
async fn test_read_with_truncation() {
let dir = tempdir().unwrap();
let service = FileSystemService::new(dir.path().to_path_buf());
let write_input = WriteFileInput {
path: "long.txt".to_string(),
content: "Hello, World! This is a longer message.".to_string(),
create_parents: true,
};
service.write_file(&write_input).await.unwrap();
let read_input = ReadFileInput {
path: "long.txt".to_string(),
max_bytes: 5,
};
let read_result = service.read_file(&read_input).await.unwrap();
assert_eq!(read_result.content, "Hello");
assert!(read_result.truncated);
}
#[tokio::test]
async fn test_create_parent_directories() {
let dir = tempdir().unwrap();
let service = FileSystemService::new(dir.path().to_path_buf());
let write_input = WriteFileInput {
path: "a/b/c/test.txt".to_string(),
content: "nested".to_string(),
create_parents: true,
};
let result = service.write_file(&write_input).await.unwrap();
assert!(result.path.ends_with("a/b/c/test.txt"));
let read_input = ReadFileInput {
path: "a/b/c/test.txt".to_string(),
max_bytes: 0,
};
let read_result = service.read_file(&read_input).await.unwrap();
assert_eq!(read_result.content, "nested");
}
}