use crate::RunMetadata;
use async_trait::async_trait;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
use std::path::{Component, Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::{SystemTime, UNIX_EPOCH};
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct WorkspaceRoot {
absolute: PathBuf,
}
impl WorkspaceRoot {
pub fn new(path: impl AsRef<Path>) -> Result<Self, WorkspaceError> {
let path = path.as_ref();
let canonical = std::fs::canonicalize(path).map_err(|error| WorkspaceError::Io {
message: format!("failed to canonicalize workspace root: {error}"),
})?;
let metadata = std::fs::metadata(&canonical).map_err(|error| WorkspaceError::Io {
message: format!("failed to inspect workspace root: {error}"),
})?;
if !metadata.is_dir() {
return Err(WorkspaceError::Unsupported {
message: format!("workspace root is not a directory: {}", canonical.display()),
});
}
Ok(Self {
absolute: canonical,
})
}
pub fn as_path(&self) -> &Path {
&self.absolute
}
pub fn join(&self, path: &WorkspacePath) -> PathBuf {
self.absolute.join(path.as_path())
}
fn strip_absolute(&self, path: &Path) -> Result<WorkspacePath, WorkspaceError> {
let relative =
path.strip_prefix(&self.absolute)
.map_err(|_| WorkspaceError::OutsideRoot {
path: path.display().to_string(),
root: self.absolute.display().to_string(),
})?;
WorkspacePath::from_relative_pathbuf(relative.to_path_buf())
}
}
impl Serialize for WorkspaceRoot {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let Some(path) = self.absolute.to_str() else {
return Err(serde::ser::Error::custom(
"workspace root path is not valid UTF-8",
));
};
serializer.serialize_str(path)
}
}
impl<'de> Deserialize<'de> for WorkspaceRoot {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let path = String::deserialize(deserializer)?;
WorkspaceRoot::new(path).map_err(serde::de::Error::custom)
}
}
#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct WorkspacePath {
relative: PathBuf,
}
impl fmt::Debug for WorkspacePath {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_tuple("WorkspacePath")
.field(&self.display())
.finish()
}
}
impl WorkspacePath {
pub fn root() -> Self {
Self {
relative: PathBuf::new(),
}
}
pub fn parse(path: impl AsRef<str>) -> Result<Self, WorkspaceError> {
let path = path.as_ref();
if path.is_empty() {
return Ok(Self::root());
}
if path.contains('\0') {
return Err(WorkspaceError::InvalidPath {
message: "workspace path contains NUL byte".to_string(),
});
}
if path.contains('\\') {
return Err(WorkspaceError::InvalidPath {
message: "workspace path must use forward slashes".to_string(),
});
}
if path.split('/').any(str::is_empty) {
return Err(WorkspaceError::InvalidPath {
message: "workspace path contains an empty component".to_string(),
});
}
if path.split('/').any(|component| component == ".") {
return Err(WorkspaceError::InvalidPath {
message: "workspace path must not contain `.`".to_string(),
});
}
if path.split('/').any(|component| component == "..") {
return Err(WorkspaceError::InvalidPath {
message: "workspace path must not contain `..`".to_string(),
});
}
let candidate = Path::new(path);
if candidate.is_absolute() {
return Err(WorkspaceError::InvalidPath {
message: "workspace path must be relative".to_string(),
});
}
let mut relative = PathBuf::new();
for component in candidate.components() {
match component {
Component::Normal(part) => relative.push(part),
Component::CurDir => {
return Err(WorkspaceError::InvalidPath {
message: "workspace path must not contain `.`".to_string(),
});
}
Component::ParentDir => {
return Err(WorkspaceError::InvalidPath {
message: "workspace path must not contain `..`".to_string(),
});
}
Component::RootDir | Component::Prefix(_) => {
return Err(WorkspaceError::InvalidPath {
message: "workspace path must not contain a root or prefix".to_string(),
});
}
}
}
Self::from_relative_pathbuf(relative)
}
pub fn as_path(&self) -> &Path {
&self.relative
}
pub fn display(&self) -> String {
if self.relative.as_os_str().is_empty() {
return String::new();
}
self.relative
.to_string_lossy()
.replace(std::path::MAIN_SEPARATOR, "/")
}
pub fn join(&self, path: impl AsRef<str>) -> Result<Self, WorkspaceError> {
let suffix = WorkspacePath::parse(path)?;
if self.relative.as_os_str().is_empty() {
return Ok(suffix);
}
if suffix.relative.as_os_str().is_empty() {
return Ok(self.clone());
}
Self::from_relative_pathbuf(self.relative.join(suffix.relative))
}
fn parent(&self) -> Self {
self.relative
.parent()
.map(|path| Self {
relative: path.to_path_buf(),
})
.unwrap_or_else(Self::root)
}
fn from_relative_pathbuf(relative: PathBuf) -> Result<Self, WorkspaceError> {
if relative.is_absolute() {
return Err(WorkspaceError::InvalidPath {
message: "workspace path must be relative".to_string(),
});
}
for component in relative.components() {
match component {
Component::Normal(_) => {}
Component::CurDir => {
return Err(WorkspaceError::InvalidPath {
message: "workspace path must not contain `.`".to_string(),
});
}
Component::ParentDir => {
return Err(WorkspaceError::InvalidPath {
message: "workspace path must not contain `..`".to_string(),
});
}
Component::RootDir | Component::Prefix(_) => {
return Err(WorkspaceError::InvalidPath {
message: "workspace path must not contain a root or prefix".to_string(),
});
}
}
}
if relative.as_os_str().is_empty() {
return Ok(Self::root());
}
if relative.to_str().is_none() {
return Err(WorkspaceError::NonUtf8Path {
path: relative.display().to_string(),
});
}
Ok(Self { relative })
}
}
impl Serialize for WorkspacePath {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(&self.display())
}
}
impl<'de> Deserialize<'de> for WorkspacePath {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let path = String::deserialize(deserializer)?;
WorkspacePath::parse(path).map_err(serde::de::Error::custom)
}
}
impl fmt::Display for WorkspacePath {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.display())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ResolvedPath {
pub root: WorkspaceRoot,
pub workspace_path: WorkspacePath,
pub absolute: PathBuf,
pub kind: ResolvedPathKind,
pub symlink_target: Option<PathBuf>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum ResolvedPathKind {
File,
Directory,
Symlink,
Missing,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum SymlinkPolicy {
#[default]
FollowReadInsideRoot,
NoFollow,
RejectAll,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum WorkspaceAccess {
Read,
List,
Create,
Modify,
Delete,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum TextEncoding {
Utf8,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FileContent {
pub path: WorkspacePath,
pub version: FileVersion,
pub body: FileBody,
pub truncated: bool,
pub metadata: RunMetadata,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum FileBody {
Text {
text: String,
encoding: TextEncoding,
},
Binary {
bytes: Vec<u8>,
media_type: Option<String>,
},
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct ContentDigest {
pub algorithm: String,
pub value: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FileVersion {
pub path: WorkspacePath,
pub digest: ContentDigest,
pub len: u64,
pub modified: Option<SystemTime>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct FileReadOptions {
pub max_bytes: Option<usize>,
pub include_binary: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum FileWriteContent {
Text(String),
Bytes(Vec<u8>),
}
impl FileWriteContent {
fn into_bytes(self) -> Vec<u8> {
match self {
Self::Text(text) => text.into_bytes(),
Self::Bytes(bytes) => bytes,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WriteFileRequest {
pub path: WorkspacePath,
pub content: FileWriteContent,
pub expected_version: Option<FileVersion>,
pub create: bool,
pub overwrite: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FileWriteResult {
pub path: WorkspacePath,
pub previous_version: Option<FileVersion>,
pub new_version: FileVersion,
pub created: bool,
pub bytes_written: u64,
pub metadata: RunMetadata,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ListFilesQuery {
pub path: WorkspacePath,
pub recursive: bool,
pub max_entries: Option<usize>,
pub include_hidden: bool,
pub respect_gitignore: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkspaceEntry {
pub path: WorkspacePath,
pub kind: ResolvedPathKind,
pub len: Option<u64>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SnapshotRequest {
pub paths: Vec<WorkspacePath>,
pub recursive: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkspaceSnapshot {
pub id: String,
pub root: WorkspaceRoot,
pub files: Vec<FileVersion>,
pub git_head: Option<String>,
pub metadata: RunMetadata,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DiffRequest {
pub before: WorkspaceSnapshot,
pub after: WorkspaceSnapshot,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkspaceDiff {
pub changed_files: Vec<WorkspacePath>,
pub text: String,
pub truncated: bool,
pub metadata: RunMetadata,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Patch {
pub files: Vec<FilePatch>,
pub original_text: Option<String>,
pub metadata: RunMetadata,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FilePatch {
pub path: WorkspacePath,
pub operation: PatchOperation,
pub expected_version: Option<FileVersion>,
pub hunks: Vec<PatchHunk>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum PatchOperation {
Create,
Modify,
Delete,
Rename {
from: WorkspacePath,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PatchHunk {
pub old_text: String,
pub new_text: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PatchRequest {
pub patch: Patch,
pub dry_run: bool,
pub allow_partial: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PatchConflict {
pub path: WorkspacePath,
pub message: String,
pub expected_version: Option<FileVersion>,
pub actual_version: Option<FileVersion>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PatchResult {
pub applied: bool,
pub changed_files: Vec<WorkspacePath>,
pub conflicts: Vec<PatchConflict>,
pub diff: WorkspaceDiff,
pub snapshot_before: WorkspaceSnapshot,
pub snapshot_after: Option<WorkspaceSnapshot>,
pub metadata: RunMetadata,
}
#[derive(Debug, Clone, Default)]
pub struct AgentChangeTracker {
changes: Arc<Mutex<BTreeMap<WorkspacePath, FileChange>>>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct FileChange {
before: Option<FileVersion>,
after: Option<FileVersion>,
}
impl AgentChangeTracker {
pub fn new() -> Self {
Self::default()
}
pub fn record(
&self,
path: WorkspacePath,
before: Option<FileVersion>,
after: Option<FileVersion>,
) {
self.changes
.lock()
.expect("AgentChangeTracker lock poisoned")
.insert(path, FileChange { before, after });
}
pub fn changed_files(&self) -> Vec<WorkspacePath> {
self.changes
.lock()
.expect("AgentChangeTracker lock poisoned")
.keys()
.cloned()
.collect()
}
pub fn clear(&self) {
self.changes
.lock()
.expect("AgentChangeTracker lock poisoned")
.clear();
}
}
#[async_trait]
pub trait Workspace: Send + Sync {
async fn root(&self) -> WorkspaceRoot;
async fn resolve(
&self,
path: &WorkspacePath,
access: WorkspaceAccess,
) -> Result<ResolvedPath, WorkspaceError>;
async fn read_file(
&self,
path: &WorkspacePath,
options: FileReadOptions,
) -> Result<FileContent, WorkspaceError>;
async fn write_file(
&self,
request: WriteFileRequest,
) -> Result<FileWriteResult, WorkspaceError>;
async fn list_files(
&self,
query: ListFilesQuery,
) -> Result<Vec<WorkspaceEntry>, WorkspaceError>;
async fn snapshot(&self, request: SnapshotRequest)
-> Result<WorkspaceSnapshot, WorkspaceError>;
async fn diff(&self, request: DiffRequest) -> Result<WorkspaceDiff, WorkspaceError>;
async fn apply_patch(&self, request: PatchRequest) -> Result<PatchResult, WorkspaceError>;
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
#[non_exhaustive]
pub struct LocalWorkspaceConfig {
pub(crate) symlink_policy: SymlinkPolicy,
pub(crate) max_read_bytes: usize,
pub(crate) max_list_entries: usize,
pub(crate) include_hidden_by_default: bool,
pub(crate) respect_gitignore_by_default: bool,
}
impl Default for LocalWorkspaceConfig {
fn default() -> Self {
Self {
symlink_policy: SymlinkPolicy::FollowReadInsideRoot,
max_read_bytes: 64 * 1024,
max_list_entries: 10_000,
include_hidden_by_default: false,
respect_gitignore_by_default: true,
}
}
}
impl LocalWorkspaceConfig {
pub fn new() -> Self {
Self::default()
}
pub fn symlink_policy(&self) -> SymlinkPolicy {
self.symlink_policy
}
pub fn with_symlink_policy(mut self, symlink_policy: SymlinkPolicy) -> Self {
self.symlink_policy = symlink_policy;
self
}
pub fn max_read_bytes(&self) -> usize {
self.max_read_bytes
}
pub fn with_max_read_bytes(mut self, max_read_bytes: usize) -> Self {
self.max_read_bytes = max_read_bytes;
self
}
pub fn max_list_entries(&self) -> usize {
self.max_list_entries
}
pub fn with_max_list_entries(mut self, max_list_entries: usize) -> Self {
self.max_list_entries = max_list_entries;
self
}
pub fn include_hidden_by_default(&self) -> bool {
self.include_hidden_by_default
}
pub fn with_include_hidden_by_default(mut self, include_hidden_by_default: bool) -> Self {
self.include_hidden_by_default = include_hidden_by_default;
self
}
pub fn respect_gitignore_by_default(&self) -> bool {
self.respect_gitignore_by_default
}
pub fn with_respect_gitignore_by_default(mut self, respect_gitignore_by_default: bool) -> Self {
self.respect_gitignore_by_default = respect_gitignore_by_default;
self
}
}
#[derive(Debug, Clone)]
pub struct LocalWorkspace {
root: WorkspaceRoot,
config: LocalWorkspaceConfig,
changes: AgentChangeTracker,
}
impl LocalWorkspace {
pub fn new(root: impl AsRef<Path>) -> Result<Self, WorkspaceError> {
Self::with_config(root, LocalWorkspaceConfig::default())
}
pub fn with_config(
root: impl AsRef<Path>,
config: LocalWorkspaceConfig,
) -> Result<Self, WorkspaceError> {
Ok(Self {
root: WorkspaceRoot::new(root)?,
config,
changes: AgentChangeTracker::new(),
})
}
pub fn change_tracker(&self) -> AgentChangeTracker {
self.changes.clone()
}
fn check_inside_root(&self, absolute: &Path) -> Result<(), WorkspaceError> {
if absolute.starts_with(self.root.as_path()) {
Ok(())
} else {
Err(WorkspaceError::OutsideRoot {
path: absolute.display().to_string(),
root: self.root.as_path().display().to_string(),
})
}
}
fn resolve_existing_kind(metadata: &std::fs::Metadata) -> ResolvedPathKind {
if metadata.is_dir() {
ResolvedPathKind::Directory
} else {
ResolvedPathKind::File
}
}
async fn version_for_absolute(
&self,
path: &WorkspacePath,
absolute: &Path,
) -> Result<FileVersion, WorkspaceError> {
let bytes = tokio::fs::read(absolute)
.await
.map_err(|error| WorkspaceError::Io {
message: format!("failed to read file for version: {error}"),
})?;
let metadata = tokio::fs::metadata(absolute)
.await
.map_err(|error| WorkspaceError::Io {
message: format!("failed to inspect file for version: {error}"),
})?;
Ok(FileVersion {
path: path.clone(),
digest: digest_bytes(&bytes),
len: metadata.len(),
modified: metadata.modified().ok(),
})
}
async fn read_text_file(
&self,
path: &WorkspacePath,
max_bytes: usize,
) -> Result<(String, FileVersion, bool), WorkspaceError> {
let content = self
.read_file(
path,
FileReadOptions {
max_bytes: Some(max_bytes),
include_binary: false,
},
)
.await?;
let text = match content.body {
FileBody::Text { text, .. } => text,
FileBody::Binary { .. } => {
return Err(WorkspaceError::Unsupported {
message: format!("file is binary: {}", path.display()),
});
}
};
Ok((text, content.version, content.truncated))
}
async fn snapshot_paths(
&self,
paths: Vec<WorkspacePath>,
recursive: bool,
) -> Result<WorkspaceSnapshot, WorkspaceError> {
let paths = if paths.is_empty() {
vec![WorkspacePath::root()]
} else {
paths
};
let mut versions = BTreeMap::new();
for path in paths {
let resolved = self.resolve(&path, WorkspaceAccess::Read).await?;
match resolved.kind {
ResolvedPathKind::Missing => {}
ResolvedPathKind::Directory => {
let entries = self
.list_files(ListFilesQuery {
path: path.clone(),
recursive,
max_entries: Some(self.config.max_list_entries),
include_hidden: self.config.include_hidden_by_default,
respect_gitignore: self.config.respect_gitignore_by_default,
})
.await?;
for entry in entries {
if entry.kind != ResolvedPathKind::File {
continue;
}
let absolute = self.root.join(&entry.path);
let version = self.version_for_absolute(&entry.path, &absolute).await?;
versions.insert(entry.path, version);
}
}
ResolvedPathKind::File => {
let version = self.version_for_absolute(&path, &resolved.absolute).await?;
versions.insert(path, version);
}
ResolvedPathKind::Symlink => {}
}
}
Ok(WorkspaceSnapshot {
id: generated_snapshot_id(),
root: self.root.clone(),
files: versions.into_values().collect(),
git_head: None,
metadata: RunMetadata::new(),
})
}
}
#[async_trait]
impl Workspace for LocalWorkspace {
async fn root(&self) -> WorkspaceRoot {
self.root.clone()
}
async fn resolve(
&self,
path: &WorkspacePath,
access: WorkspaceAccess,
) -> Result<ResolvedPath, WorkspaceError> {
let joined = self.root.join(path);
if !joined.starts_with(self.root.as_path()) {
return Err(WorkspaceError::OutsideRoot {
path: joined.display().to_string(),
root: self.root.as_path().display().to_string(),
});
}
match std::fs::symlink_metadata(&joined) {
Ok(symlink_metadata) => {
if symlink_metadata.file_type().is_symlink() {
let target =
std::fs::read_link(&joined).map_err(|error| WorkspaceError::Io {
message: format!("failed to read symlink: {error}"),
})?;
let target_absolute = if target.is_absolute() {
target
} else {
joined
.parent()
.unwrap_or_else(|| self.root.as_path())
.join(target)
};
let canonical_target =
std::fs::canonicalize(&target_absolute).map_err(|error| {
WorkspaceError::Io {
message: format!("failed to canonicalize symlink target: {error}"),
}
})?;
if !canonical_target.starts_with(self.root.as_path()) {
return Err(WorkspaceError::SymlinkEscapesRoot {
path: joined.display().to_string(),
target: canonical_target.display().to_string(),
});
}
return match self.config.symlink_policy {
SymlinkPolicy::RejectAll => Err(WorkspaceError::Unsupported {
message: format!("symlink rejected: {}", path.display()),
}),
SymlinkPolicy::NoFollow => Ok(ResolvedPath {
root: self.root.clone(),
workspace_path: path.clone(),
absolute: joined,
kind: ResolvedPathKind::Symlink,
symlink_target: Some(canonical_target),
}),
SymlinkPolicy::FollowReadInsideRoot
if matches!(access, WorkspaceAccess::Read | WorkspaceAccess::List) =>
{
let metadata =
std::fs::metadata(&canonical_target).map_err(|error| {
WorkspaceError::Io {
message: format!(
"failed to inspect symlink target: {error}"
),
}
})?;
Ok(ResolvedPath {
root: self.root.clone(),
workspace_path: path.clone(),
absolute: canonical_target.clone(),
kind: Self::resolve_existing_kind(&metadata),
symlink_target: Some(canonical_target),
})
}
SymlinkPolicy::FollowReadInsideRoot => Err(WorkspaceError::Unsupported {
message: format!(
"write/delete through symlink rejected: {}",
path.display()
),
}),
};
}
let canonical =
std::fs::canonicalize(&joined).map_err(|error| WorkspaceError::Io {
message: format!("failed to canonicalize workspace path: {error}"),
})?;
self.check_inside_root(&canonical)?;
Ok(ResolvedPath {
root: self.root.clone(),
workspace_path: path.clone(),
absolute: canonical,
kind: Self::resolve_existing_kind(&symlink_metadata),
symlink_target: None,
})
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
let parent = path.parent();
let parent_absolute = self.root.join(&parent);
let canonical_parent =
std::fs::canonicalize(&parent_absolute).map_err(|parent_error| {
WorkspaceError::NotFound {
path: parent.clone(),
message: format!("parent does not exist: {parent_error}"),
}
})?;
self.check_inside_root(&canonical_parent)?;
Ok(ResolvedPath {
root: self.root.clone(),
workspace_path: path.clone(),
absolute: joined,
kind: ResolvedPathKind::Missing,
symlink_target: None,
})
}
Err(error) => Err(WorkspaceError::Io {
message: format!("failed to inspect workspace path: {error}"),
}),
}
}
async fn read_file(
&self,
path: &WorkspacePath,
options: FileReadOptions,
) -> Result<FileContent, WorkspaceError> {
let resolved = self.resolve(path, WorkspaceAccess::Read).await?;
match resolved.kind {
ResolvedPathKind::Missing => {
return Err(WorkspaceError::NotFound {
path: path.clone(),
message: "file does not exist".to_string(),
});
}
ResolvedPathKind::Directory => {
return Err(WorkspaceError::Unsupported {
message: format!("path is a directory: {}", path.display()),
});
}
ResolvedPathKind::Symlink => {
return Err(WorkspaceError::Unsupported {
message: format!("symlink was not followed: {}", path.display()),
});
}
ResolvedPathKind::File => {}
}
let max_bytes = options.max_bytes.unwrap_or(self.config.max_read_bytes);
let mut bytes =
tokio::fs::read(&resolved.absolute)
.await
.map_err(|error| WorkspaceError::Io {
message: format!("failed to read file: {error}"),
})?;
let total_len = bytes.len();
let truncated = bytes.len() > max_bytes;
if truncated {
bytes.truncate(max_bytes);
}
let metadata = tokio::fs::metadata(&resolved.absolute)
.await
.map_err(|error| WorkspaceError::Io {
message: format!("failed to inspect file: {error}"),
})?;
let version = FileVersion {
path: path.clone(),
digest: digest_bytes(&tokio::fs::read(&resolved.absolute).await.map_err(|error| {
WorkspaceError::Io {
message: format!("failed to read file for digest: {error}"),
}
})?),
len: metadata.len(),
modified: metadata.modified().ok(),
};
let body = match String::from_utf8(bytes) {
Ok(text) => FileBody::Text {
text,
encoding: TextEncoding::Utf8,
},
Err(error) if options.include_binary => FileBody::Binary {
bytes: error.into_bytes(),
media_type: None,
},
Err(_) => FileBody::Binary {
bytes: Vec::new(),
media_type: None,
},
};
let mut metadata = RunMetadata::new();
metadata.insert("bytes_read".to_string(), serde_json::json!(total_len));
Ok(FileContent {
path: path.clone(),
version,
body,
truncated,
metadata,
})
}
async fn write_file(
&self,
request: WriteFileRequest,
) -> Result<FileWriteResult, WorkspaceError> {
let joined = self.root.join(&request.path);
let exists = std::fs::symlink_metadata(&joined).is_ok();
let access = if exists {
WorkspaceAccess::Modify
} else {
WorkspaceAccess::Create
};
let resolved = self.resolve(&request.path, access).await?;
let previous_version = if exists {
if resolved.kind != ResolvedPathKind::File {
return Err(WorkspaceError::Unsupported {
message: format!("path is not a regular file: {}", request.path.display()),
});
}
Some(
self.version_for_absolute(&request.path, &resolved.absolute)
.await?,
)
} else {
None
};
if previous_version.is_some() && !request.overwrite {
return Err(WorkspaceError::Conflict {
conflict: Box::new(PatchConflict {
path: request.path.clone(),
message: "file exists and overwrite is false".to_string(),
expected_version: request.expected_version.clone(),
actual_version: previous_version,
}),
});
}
if previous_version.is_none() && !request.create {
return Err(WorkspaceError::NotFound {
path: request.path,
message: "file does not exist and create is false".to_string(),
});
}
if let Some(expected) = &request.expected_version
&& previous_version.as_ref() != Some(expected)
{
return Err(WorkspaceError::Conflict {
conflict: Box::new(PatchConflict {
path: request.path.clone(),
message: "stale file version".to_string(),
expected_version: Some(expected.clone()),
actual_version: previous_version,
}),
});
}
let bytes = request.content.into_bytes();
let parent = resolved
.absolute
.parent()
.ok_or_else(|| WorkspaceError::InvalidPath {
message: "file path has no parent".to_string(),
})?;
let tmp = parent.join(format!(
".molo-write-{}-{}",
std::process::id(),
monotonic_nanos()
));
tokio::fs::write(&tmp, &bytes)
.await
.map_err(|error| WorkspaceError::Io {
message: format!("failed to write temporary file: {error}"),
})?;
tokio::fs::rename(&tmp, &resolved.absolute)
.await
.map_err(|error| WorkspaceError::Io {
message: format!("failed to commit file write: {error}"),
})?;
let new_version = self
.version_for_absolute(&request.path, &resolved.absolute)
.await?;
self.changes.record(
request.path.clone(),
previous_version.clone(),
Some(new_version.clone()),
);
Ok(FileWriteResult {
path: request.path,
previous_version,
new_version,
created: !exists,
bytes_written: bytes.len() as u64,
metadata: RunMetadata::new(),
})
}
async fn list_files(
&self,
query: ListFilesQuery,
) -> Result<Vec<WorkspaceEntry>, WorkspaceError> {
let resolved = self.resolve(&query.path, WorkspaceAccess::List).await?;
if resolved.kind == ResolvedPathKind::Missing {
return Err(WorkspaceError::NotFound {
path: query.path,
message: "path does not exist".to_string(),
});
}
let max_entries = query.max_entries.unwrap_or(self.config.max_list_entries);
let ignore = if query.respect_gitignore {
SimpleIgnore::load(self.root.as_path())
} else {
SimpleIgnore::default()
};
let mut entries = Vec::new();
collect_entries(
&self.root,
&resolved.absolute,
query.recursive,
query.include_hidden,
&ignore,
max_entries,
&mut entries,
)?;
entries.sort_by(|left, right| left.path.cmp(&right.path));
if entries.len() > max_entries {
entries.truncate(max_entries);
}
Ok(entries)
}
async fn snapshot(
&self,
request: SnapshotRequest,
) -> Result<WorkspaceSnapshot, WorkspaceError> {
self.snapshot_paths(request.paths, request.recursive).await
}
async fn diff(&self, request: DiffRequest) -> Result<WorkspaceDiff, WorkspaceError> {
Ok(diff_snapshots(&request.before, &request.after))
}
async fn apply_patch(&self, request: PatchRequest) -> Result<PatchResult, WorkspaceError> {
let before = self
.snapshot(SnapshotRequest {
paths: Vec::new(),
recursive: true,
})
.await?;
let mut conflicts = Vec::new();
let mut writes: BTreeMap<WorkspacePath, PlannedWrite> = BTreeMap::new();
let mut deletes: BTreeMap<WorkspacePath, FileVersion> = BTreeMap::new();
for file_patch in &request.patch.files {
match validate_file_patch(self, file_patch).await {
Ok(plan) => match plan {
PatchPlan::Write { path, bytes } => {
writes.insert(
path,
PlannedWrite {
bytes,
expected_version: None,
create: true,
overwrite: false,
},
);
}
PatchPlan::Modify {
path,
bytes,
expected_version,
} => {
writes.insert(
path,
PlannedWrite {
bytes,
expected_version: Some(expected_version),
create: false,
overwrite: true,
},
);
}
PatchPlan::Delete {
path,
expected_version,
} => {
deletes.insert(path, expected_version);
}
PatchPlan::Rename {
from,
to,
bytes,
expected_version,
} => {
deletes.insert(from, expected_version);
writes.insert(
to,
PlannedWrite {
bytes,
expected_version: None,
create: true,
overwrite: false,
},
);
}
},
Err(conflict) => conflicts.push(conflict),
}
}
let changed_files: Vec<_> = writes
.keys()
.chain(deletes.keys())
.cloned()
.collect::<BTreeSet<_>>()
.into_iter()
.collect();
if !conflicts.is_empty() || request.dry_run {
let diff = WorkspaceDiff {
changed_files: changed_files.clone(),
text: if conflicts.is_empty() {
format!("patch dry-run would change {} file(s)", changed_files.len())
} else {
format!("patch has {} conflict(s)", conflicts.len())
},
truncated: false,
metadata: RunMetadata::new(),
};
return Ok(PatchResult {
applied: false,
changed_files,
conflicts,
diff,
snapshot_before: before,
snapshot_after: None,
metadata: RunMetadata::new(),
});
}
for (path, expected_version) in deletes {
let resolved = self.resolve(&path, WorkspaceAccess::Delete).await?;
let actual_version = self.version_for_absolute(&path, &resolved.absolute).await?;
if actual_version != expected_version {
return Err(WorkspaceError::Conflict {
conflict: Box::new(PatchConflict {
path,
message: "stale file version before delete".to_string(),
expected_version: Some(expected_version),
actual_version: Some(actual_version),
}),
});
}
tokio::fs::remove_file(&resolved.absolute)
.await
.map_err(|error| WorkspaceError::Io {
message: format!("failed to delete file: {error}"),
})?;
self.changes.record(path, Some(expected_version), None);
}
for (path, planned) in writes {
let text = match String::from_utf8(planned.bytes.clone()) {
Ok(text) => FileWriteContent::Text(text),
Err(_) => FileWriteContent::Bytes(planned.bytes),
};
self.write_file(WriteFileRequest {
path,
content: text,
expected_version: planned.expected_version,
create: planned.create,
overwrite: planned.overwrite,
})
.await?;
}
let after = self
.snapshot(SnapshotRequest {
paths: Vec::new(),
recursive: true,
})
.await?;
let diff = diff_snapshots(&before, &after);
Ok(PatchResult {
applied: true,
changed_files: diff.changed_files.clone(),
conflicts: Vec::new(),
diff,
snapshot_before: before,
snapshot_after: Some(after),
metadata: RunMetadata::new(),
})
}
}
#[derive(Debug)]
enum PatchPlan {
Write {
path: WorkspacePath,
bytes: Vec<u8>,
},
Modify {
path: WorkspacePath,
bytes: Vec<u8>,
expected_version: FileVersion,
},
Delete {
path: WorkspacePath,
expected_version: FileVersion,
},
Rename {
from: WorkspacePath,
to: WorkspacePath,
bytes: Vec<u8>,
expected_version: FileVersion,
},
}
#[derive(Debug)]
struct PlannedWrite {
bytes: Vec<u8>,
expected_version: Option<FileVersion>,
create: bool,
overwrite: bool,
}
async fn validate_file_patch(
workspace: &LocalWorkspace,
file_patch: &FilePatch,
) -> Result<PatchPlan, PatchConflict> {
match &file_patch.operation {
PatchOperation::Create => {
if std::fs::symlink_metadata(workspace.root.join(&file_patch.path)).is_ok() {
return Err(PatchConflict {
path: file_patch.path.clone(),
message: "create target already exists".to_string(),
expected_version: file_patch.expected_version.clone(),
actual_version: None,
});
}
let bytes = file_patch
.hunks
.iter()
.map(|hunk| hunk.new_text.as_str())
.collect::<String>()
.into_bytes();
Ok(PatchPlan::Write {
path: file_patch.path.clone(),
bytes,
})
}
PatchOperation::Modify => {
let (mut text, version, truncated) = workspace
.read_text_file(&file_patch.path, workspace.config.max_read_bytes)
.await
.map_err(|error| PatchConflict {
path: file_patch.path.clone(),
message: error.to_string(),
expected_version: file_patch.expected_version.clone(),
actual_version: None,
})?;
if truncated {
return Err(PatchConflict {
path: file_patch.path.clone(),
message: "file is too large for local patch applier".to_string(),
expected_version: file_patch.expected_version.clone(),
actual_version: Some(version),
});
}
if let Some(expected) = &file_patch.expected_version
&& expected != &version
{
return Err(PatchConflict {
path: file_patch.path.clone(),
message: "stale file version".to_string(),
expected_version: Some(expected.clone()),
actual_version: Some(version),
});
}
for hunk in &file_patch.hunks {
let Some(index) = text.find(&hunk.old_text) else {
return Err(PatchConflict {
path: file_patch.path.clone(),
message: "patch hunk did not match".to_string(),
expected_version: file_patch.expected_version.clone(),
actual_version: Some(version),
});
};
text.replace_range(index..index + hunk.old_text.len(), &hunk.new_text);
}
Ok(PatchPlan::Modify {
path: file_patch.path.clone(),
bytes: text.into_bytes(),
expected_version: version,
})
}
PatchOperation::Delete => {
let resolved = workspace
.resolve(&file_patch.path, WorkspaceAccess::Delete)
.await
.map_err(|error| PatchConflict {
path: file_patch.path.clone(),
message: error.to_string(),
expected_version: file_patch.expected_version.clone(),
actual_version: None,
})?;
if resolved.kind != ResolvedPathKind::File {
return Err(PatchConflict {
path: file_patch.path.clone(),
message: "delete target is not a regular file".to_string(),
expected_version: file_patch.expected_version.clone(),
actual_version: None,
});
}
let version = workspace
.version_for_absolute(&file_patch.path, &resolved.absolute)
.await
.map_err(|error| PatchConflict {
path: file_patch.path.clone(),
message: error.to_string(),
expected_version: file_patch.expected_version.clone(),
actual_version: None,
})?;
if let Some(expected) = &file_patch.expected_version
&& expected != &version
{
return Err(PatchConflict {
path: file_patch.path.clone(),
message: "stale file version".to_string(),
expected_version: Some(expected.clone()),
actual_version: Some(version),
});
}
Ok(PatchPlan::Delete {
path: file_patch.path.clone(),
expected_version: version,
})
}
PatchOperation::Rename { from } => {
let (text, version, truncated) = workspace
.read_text_file(from, workspace.config.max_read_bytes)
.await
.map_err(|error| PatchConflict {
path: from.clone(),
message: error.to_string(),
expected_version: file_patch.expected_version.clone(),
actual_version: None,
})?;
if truncated {
return Err(PatchConflict {
path: from.clone(),
message: "file is too large for local patch applier".to_string(),
expected_version: file_patch.expected_version.clone(),
actual_version: Some(version),
});
}
if std::fs::symlink_metadata(workspace.root.join(&file_patch.path)).is_ok() {
return Err(PatchConflict {
path: file_patch.path.clone(),
message: "rename target already exists".to_string(),
expected_version: None,
actual_version: None,
});
}
let mut new_text = text;
for hunk in &file_patch.hunks {
if hunk.old_text.is_empty() {
continue;
}
let Some(index) = new_text.find(&hunk.old_text) else {
return Err(PatchConflict {
path: from.clone(),
message: "rename hunk did not match".to_string(),
expected_version: file_patch.expected_version.clone(),
actual_version: Some(version),
});
};
new_text.replace_range(index..index + hunk.old_text.len(), &hunk.new_text);
}
Ok(PatchPlan::Rename {
from: from.clone(),
to: file_patch.path.clone(),
bytes: new_text.into_bytes(),
expected_version: version,
})
}
}
}
fn collect_entries(
root: &WorkspaceRoot,
absolute: &Path,
recursive: bool,
include_hidden: bool,
ignore: &SimpleIgnore,
max_entries: usize,
entries: &mut Vec<WorkspaceEntry>,
) -> Result<(), WorkspaceError> {
if entries.len() >= max_entries {
return Ok(());
}
let metadata = std::fs::metadata(absolute).map_err(|error| WorkspaceError::Io {
message: format!("failed to inspect list entry: {error}"),
})?;
if metadata.is_file() {
entries.push(WorkspaceEntry {
path: root.strip_absolute(absolute)?,
kind: ResolvedPathKind::File,
len: Some(metadata.len()),
});
return Ok(());
}
let mut children = Vec::new();
for entry in std::fs::read_dir(absolute).map_err(|error| WorkspaceError::Io {
message: format!("failed to list directory: {error}"),
})? {
let entry = entry.map_err(|error| WorkspaceError::Io {
message: format!("failed to read directory entry: {error}"),
})?;
children.push(entry.path());
}
children.sort();
for child in children {
if entries.len() >= max_entries {
break;
}
let relative = root.strip_absolute(&child)?;
if !include_hidden && is_hidden(&relative) {
continue;
}
if ignore.is_ignored(&relative) {
continue;
}
let metadata = std::fs::symlink_metadata(&child).map_err(|error| WorkspaceError::Io {
message: format!("failed to inspect list entry: {error}"),
})?;
let kind = if metadata.file_type().is_symlink() {
ResolvedPathKind::Symlink
} else if metadata.is_dir() {
ResolvedPathKind::Directory
} else {
ResolvedPathKind::File
};
entries.push(WorkspaceEntry {
path: relative,
kind,
len: metadata.is_file().then_some(metadata.len()),
});
if recursive && kind == ResolvedPathKind::Directory {
collect_entries(
root,
&child,
true,
include_hidden,
ignore,
max_entries,
entries,
)?;
}
}
Ok(())
}
#[derive(Debug, Default)]
struct SimpleIgnore {
patterns: Vec<String>,
}
impl SimpleIgnore {
fn load(root: &Path) -> Self {
let mut ignore = Self {
patterns: vec![".git".to_string()],
};
let path = root.join(".gitignore");
let Ok(text) = std::fs::read_to_string(path) else {
return ignore;
};
for line in text.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') || line.starts_with('!') {
continue;
}
ignore.patterns.push(line.trim_end_matches('/').to_string());
}
ignore
}
fn is_ignored(&self, path: &WorkspacePath) -> bool {
let display = path.display();
self.patterns.iter().any(|pattern| {
display == *pattern
|| display.starts_with(&format!("{pattern}/"))
|| display
.split('/')
.any(|component| component == pattern.as_str())
})
}
}
fn is_hidden(path: &WorkspacePath) -> bool {
path.display()
.split('/')
.any(|component| component.starts_with('.') && component != "." && !component.is_empty())
}
fn digest_bytes(bytes: &[u8]) -> ContentDigest {
let mut hash = 0xcbf29ce484222325u64;
for byte in bytes {
hash ^= u64::from(*byte);
hash = hash.wrapping_mul(0x100000001b3);
}
ContentDigest {
algorithm: "fnv1a64".to_string(),
value: format!("{hash:016x}"),
}
}
fn diff_snapshots(before: &WorkspaceSnapshot, after: &WorkspaceSnapshot) -> WorkspaceDiff {
let before_map: BTreeMap<_, _> = before
.files
.iter()
.map(|version| (version.path.clone(), version))
.collect();
let after_map: BTreeMap<_, _> = after
.files
.iter()
.map(|version| (version.path.clone(), version))
.collect();
let paths: BTreeSet<_> = before_map.keys().chain(after_map.keys()).cloned().collect();
let mut changed = Vec::new();
let mut lines = Vec::new();
for path in paths {
match (before_map.get(&path), after_map.get(&path)) {
(None, Some(_)) => {
changed.push(path.clone());
lines.push(format!("created {}", path.display()));
}
(Some(_), None) => {
changed.push(path.clone());
lines.push(format!("deleted {}", path.display()));
}
(Some(left), Some(right)) if left.digest != right.digest || left.len != right.len => {
changed.push(path.clone());
lines.push(format!("modified {}", path.display()));
}
_ => {}
}
}
WorkspaceDiff {
changed_files: changed,
text: lines.join("\n"),
truncated: false,
metadata: RunMetadata::new(),
}
}
fn monotonic_nanos() -> u128 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
}
fn generated_snapshot_id() -> String {
format!("snapshot-{}-{:#x}", std::process::id(), monotonic_nanos())
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error, Serialize, Deserialize)]
#[non_exhaustive]
pub enum WorkspaceError {
#[error("invalid workspace path: {message}")]
InvalidPath {
message: String,
},
#[error("path escapes workspace root: {path} is outside {root}")]
OutsideRoot {
path: String,
root: String,
},
#[error("symlink escapes workspace root: {path} -> {target}")]
SymlinkEscapesRoot {
path: String,
target: String,
},
#[error("workspace path not found: {path}: {message}")]
NotFound {
path: WorkspacePath,
message: String,
},
#[error("workspace conflict: {conflict:?}")]
Conflict {
conflict: Box<PatchConflict>,
},
#[error("workspace path is not UTF-8: {path}")]
NonUtf8Path {
path: String,
},
#[error("workspace I/O error: {message}")]
Io {
message: String,
},
#[error("unsupported workspace operation: {message}")]
Unsupported {
message: String,
},
}
#[cfg(test)]
mod tests {
use super::*;
fn temp_dir(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"molo-coding-workspace-{}-{tag}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn workspace_path_rejects_escape() {
assert!(WorkspacePath::parse("../secret").is_err());
assert!(WorkspacePath::parse("/etc/passwd").is_err());
assert!(WorkspacePath::parse("a//b").is_err());
assert!(WorkspacePath::parse("./a").is_err());
assert!(WorkspacePath::parse("a\\b").is_err());
assert!(WorkspacePath::parse("a\0b").is_err());
assert_eq!(
WorkspacePath::parse("src/lib.rs").unwrap().display(),
"src/lib.rs"
);
}
#[test]
fn workspace_path_rejects_escape_corpus() {
let invalid = [
"..",
"../a",
"a/../b",
"a/./b",
"./a",
"/absolute",
"a//b",
"a\\b",
"a/\0/b",
"a/",
"/",
];
for candidate in invalid {
assert!(
WorkspacePath::parse(candidate).is_err(),
"candidate must be rejected: {candidate:?}"
);
}
let valid = ["", "src/lib.rs", "nested/path/file.txt", "unicode/ä½ å¥½.txt"];
for candidate in valid {
assert_eq!(
WorkspacePath::parse(candidate).unwrap().display(),
candidate
);
}
}
#[tokio::test]
async fn local_workspace_rejects_outside_symlink() {
let root = temp_dir("symlink");
let outside = root.with_extension("outside");
let _ = std::fs::remove_dir_all(&outside);
std::fs::create_dir_all(&outside).unwrap();
std::fs::write(outside.join("secret"), "secret").unwrap();
#[cfg(unix)]
std::os::unix::fs::symlink(outside.join("secret"), root.join("link")).unwrap();
#[cfg(windows)]
std::os::windows::fs::symlink_file(outside.join("secret"), root.join("link")).unwrap();
let workspace = LocalWorkspace::new(&root).unwrap();
let err = workspace
.read_file(
&WorkspacePath::parse("link").unwrap(),
FileReadOptions::default(),
)
.await
.unwrap_err();
assert!(matches!(err, WorkspaceError::SymlinkEscapesRoot { .. }));
let _ = std::fs::remove_dir_all(root);
let _ = std::fs::remove_dir_all(outside);
}
#[tokio::test]
async fn local_workspace_rejects_symlink_chain_escape() {
let root = temp_dir("symlink-chain");
let outside = root.with_extension("outside-chain");
let _ = std::fs::remove_dir_all(&outside);
std::fs::create_dir_all(&outside).unwrap();
std::fs::write(outside.join("secret"), "secret").unwrap();
#[cfg(unix)]
{
std::os::unix::fs::symlink(outside.join("secret"), root.join("outside-link")).unwrap();
std::os::unix::fs::symlink(root.join("outside-link"), root.join("chain-link")).unwrap();
}
#[cfg(windows)]
{
std::os::windows::fs::symlink_file(outside.join("secret"), root.join("outside-link"))
.unwrap();
std::os::windows::fs::symlink_file(root.join("outside-link"), root.join("chain-link"))
.unwrap();
}
let workspace = LocalWorkspace::new(&root).unwrap();
let err = workspace
.read_file(
&WorkspacePath::parse("chain-link").unwrap(),
FileReadOptions::default(),
)
.await
.unwrap_err();
assert!(matches!(err, WorkspaceError::SymlinkEscapesRoot { .. }));
let _ = std::fs::remove_dir_all(root);
let _ = std::fs::remove_dir_all(outside);
}
#[tokio::test]
async fn write_stale_version_conflicts() {
let root = temp_dir("stale");
std::fs::write(root.join("a.txt"), "one").unwrap();
let workspace = LocalWorkspace::new(&root).unwrap();
let path = WorkspacePath::parse("a.txt").unwrap();
let content = workspace
.read_file(&path, FileReadOptions::default())
.await
.unwrap();
std::fs::write(root.join("a.txt"), "two").unwrap();
let err = workspace
.write_file(WriteFileRequest {
path,
content: FileWriteContent::Text("three".to_string()),
expected_version: Some(content.version),
create: false,
overwrite: true,
})
.await
.unwrap_err();
assert!(matches!(err, WorkspaceError::Conflict { .. }));
let _ = std::fs::remove_dir_all(root);
}
#[tokio::test]
async fn patch_is_all_or_nothing_on_conflict() {
let root = temp_dir("patch");
std::fs::write(root.join("a.txt"), "alpha").unwrap();
std::fs::write(root.join("b.txt"), "bravo").unwrap();
let workspace = LocalWorkspace::new(&root).unwrap();
let result = workspace
.apply_patch(PatchRequest {
patch: Patch {
files: vec![
FilePatch {
path: WorkspacePath::parse("a.txt").unwrap(),
operation: PatchOperation::Modify,
expected_version: None,
hunks: vec![PatchHunk {
old_text: "alpha".to_string(),
new_text: "ALPHA".to_string(),
}],
},
FilePatch {
path: WorkspacePath::parse("b.txt").unwrap(),
operation: PatchOperation::Modify,
expected_version: None,
hunks: vec![PatchHunk {
old_text: "missing".to_string(),
new_text: "BRAVO".to_string(),
}],
},
],
original_text: None,
metadata: RunMetadata::new(),
},
dry_run: false,
allow_partial: false,
})
.await
.unwrap();
assert!(!result.applied);
assert_eq!(
std::fs::read_to_string(root.join("a.txt")).unwrap(),
"alpha"
);
let _ = std::fs::remove_dir_all(root);
}
#[tokio::test]
async fn patch_stale_precondition_reports_conflict_without_write() {
let root = temp_dir("patch-stale");
std::fs::write(root.join("a.txt"), "alpha").unwrap();
let workspace = LocalWorkspace::new(&root).unwrap();
let path = WorkspacePath::parse("a.txt").unwrap();
let content = workspace
.read_file(&path, FileReadOptions::default())
.await
.unwrap();
std::fs::write(root.join("a.txt"), "changed by user").unwrap();
let result = workspace
.apply_patch(PatchRequest {
patch: Patch {
files: vec![FilePatch {
path: path.clone(),
operation: PatchOperation::Modify,
expected_version: Some(content.version),
hunks: vec![PatchHunk {
old_text: "alpha".to_string(),
new_text: "ALPHA".to_string(),
}],
}],
original_text: None,
metadata: RunMetadata::new(),
},
dry_run: false,
allow_partial: false,
})
.await
.unwrap();
assert!(!result.applied);
assert_eq!(result.conflicts.len(), 1);
assert_eq!(
std::fs::read_to_string(root.join("a.txt")).unwrap(),
"changed by user"
);
let _ = std::fs::remove_dir_all(root);
}
}