use std::fs::{self, File};
use std::io::{self, ErrorKind, Write as _};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
use rig_core::tool::{FileChange, FileState, Hunk, HunkLine, Lines, ToolExecutionError};
use serde::Serialize;
use serde::de::DeserializeOwned;
use crate::workspace::{Contents, Listing, Local, Stat, Workspace};
pub fn read_text(path: &str) -> Result<String, ToolExecutionError> {
local().read_text(Path::new(path))
}
pub struct Prefix {
pub text: String,
pub size: u64,
}
impl Prefix {
pub fn cut(&self) -> bool {
self.size > self.text.len() as u64
}
pub fn of(contents: Contents, cap: usize) -> Self {
let Contents { bytes, size } = contents;
let mut text = match String::from_utf8(bytes) {
Ok(text) => text,
Err(error) => {
let valid = error.utf8_error().valid_up_to();
let mut bytes = error.into_bytes();
if size > cap as u64 && bytes.len().saturating_sub(valid) < 4 {
bytes.truncate(valid);
}
String::from_utf8_lossy(&bytes).into_owned()
}
};
if let Some(rest) = text.strip_prefix('\u{feff}') {
text = rest.to_owned();
}
Self { text, size }
}
}
pub fn read_prefix(path: &Path, cap: usize) -> io::Result<Prefix> {
Ok(Prefix::of(Local.read(path, cap as u64)?, cap))
}
pub fn write_atomic(path: &Path, contents: &[u8]) -> io::Result<()> {
let (target, permissions) = match fs::metadata(path) {
Ok(meta) if !meta.is_file() => {
return Err(io::Error::new(
ErrorKind::InvalidInput,
"not a regular file",
));
}
Ok(meta) if meta.permissions().readonly() => {
return Err(io::Error::new(
ErrorKind::PermissionDenied,
"the file is read-only; leave it unchanged or ask the user",
));
}
Ok(meta) => (fs::canonicalize(path)?, Some(meta.permissions())),
Err(error) if error.kind() == ErrorKind::NotFound => (link_target(path), None),
Err(error) => return Err(error),
};
if let Some(parent) = target
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
{
fs::create_dir_all(parent)?;
}
let name = target
.file_name()
.ok_or_else(|| io::Error::new(ErrorKind::InvalidInput, "names no file"))?;
let temporary = temporary_beside(&target, name);
let written = File::create_new(&temporary).and_then(|mut file| {
if let Some(permissions) = permissions {
file.set_permissions(permissions)?;
}
file.write_all(contents)?;
file.sync_all()?;
fs::rename(&temporary, &target)
});
written.inspect_err(|_| {
fs::remove_file(&temporary).ok();
})
}
pub(crate) fn write_scratch(path: &Path, contents: &[u8]) -> io::Result<()> {
let name = path
.file_name()
.ok_or_else(|| io::Error::new(ErrorKind::InvalidInput, "names no file"))?;
let temporary = temporary_beside(path, name);
let written = File::create_new(&temporary)
.and_then(|mut file| file.write_all(contents))
.and_then(|()| fs::rename(&temporary, path));
written.inspect_err(|_| {
fs::remove_file(&temporary).ok();
})
}
fn temporary_beside(target: &Path, name: &std::ffi::OsStr) -> PathBuf {
static NEXT: AtomicU64 = AtomicU64::new(0);
target.with_file_name(format!(
".{}.{}-{}.tmp",
name.to_string_lossy(),
std::process::id(),
NEXT.fetch_add(1, Ordering::Relaxed)
))
}
pub fn read_json<T: DeserializeOwned>(path: &Path) -> Option<T> {
serde_json::from_slice(&fs::read(path).ok()?).ok()
}
pub fn write_json(path: &Path, value: &impl Serialize) -> io::Result<()> {
write_atomic(path, &serde_json::to_vec_pretty(value)?)
}
fn link_target(path: &Path) -> PathBuf {
const MAX_HOPS: usize = 40;
let mut target = path.to_path_buf();
for _ in 0..MAX_HOPS {
let Ok(link) = fs::read_link(&target) else {
break;
};
target = target.parent().unwrap_or(Path::new("")).join(link);
}
target
}
pub fn io_error(path: &str, error: io::Error) -> ToolExecutionError {
let message = format!("{path}: {error}");
match error.kind() {
ErrorKind::NotFound => ToolExecutionError::not_found(message),
ErrorKind::PermissionDenied => ToolExecutionError::permission_denied(message),
_ => ToolExecutionError::other(message),
}
}
pub(crate) fn listed(
workspace: &dyn Workspace,
root: &Path,
glob: Option<&str>,
listing: Listing,
) -> Result<Vec<PathBuf>, ToolExecutionError> {
let shown = root.to_string_lossy();
let missing = || {
let missing = io::Error::new(ErrorKind::NotFound, "no such file or directory");
io_error(&shown, missing)
};
let files = workspace
.files(root, glob, listing)
.map_err(|error| match error.kind() {
ErrorKind::InvalidInput => ToolExecutionError::invalid_args(error.to_string()),
ErrorKind::NotFound => missing(),
_ => io_error(&shown, error),
})?;
if files.is_empty()
&& workspace
.stat(root)
.map_err(|error| io_error(&shown, error))?
== Stat::Missing
{
return Err(missing());
}
Ok(files)
}
pub fn resolve(base: Option<&Path>, path: &str) -> PathBuf {
local().resolve(base, path)
}
pub fn place(
base: Option<&Path>,
confine: bool,
path: &str,
) -> Result<PathBuf, ToolExecutionError> {
local().place(base, confine, path)
}
fn local() -> &'static dyn Workspace {
&Local
}
pub fn file_change(path: &str, before: Option<&str>, after: Option<&str>) -> FileChange {
let mut change = diff(path, before, after);
change.before_text = before.map(str::to_owned);
change.before = before.map(|text| FileState::of(text.as_bytes()));
change.after = after.map(|text| FileState::of(text.as_bytes()));
change
}
pub(crate) fn diff(path: &str, before: Option<&str>, after: Option<&str>) -> FileChange {
let diff = similar::TextDiff::configure()
.timeout(Duration::from_millis(100))
.diff_lines(before.unwrap_or_default(), after.unwrap_or_default());
let hunks: Vec<Hunk> = diff
.grouped_ops(3)
.iter()
.filter_map(|ops| hunk(&diff, ops))
.collect();
let lines = hunks.iter().flat_map(|hunk| &hunk.lines);
let counted = |sign| lines.clone().filter(|line| line.sign() == sign).count();
let (added, removed) = (counted('+'), counted('-'));
let mut change = match (before, after) {
(_, None) => FileChange::deleted(path, removed),
(None, Some(_)) => FileChange::created(path, added),
(Some(_), Some(_)) => FileChange::modified(path, added, removed),
};
change.hunks = hunks;
change
}
fn hunk(diff: &similar::TextDiff<'_, '_, '_, str>, ops: &[similar::DiffOp]) -> Option<Hunk> {
let (first, last) = (ops.first()?, ops.last()?);
let span = |start: usize, end: usize| Lines {
start: start + 1,
count: end.saturating_sub(start),
};
let lines = ops
.iter()
.flat_map(|op| diff.iter_changes(op))
.map(|change| {
let text = change.value().to_owned();
match change.tag() {
similar::ChangeTag::Equal => HunkLine::Same(text),
similar::ChangeTag::Delete => HunkLine::Removed(text),
similar::ChangeTag::Insert => HunkLine::Added(text),
}
});
Some(Hunk {
old: span(first.old_range().start, last.old_range().end),
new: span(first.new_range().start, last.new_range().end),
lines: lines.collect(),
})
}
#[derive(Default)]
pub(crate) struct Previous {
pub(crate) text: String,
pub(crate) exact: bool,
pub(crate) state: Option<FileState>,
}
impl Previous {
pub(crate) fn of(bytes: Vec<u8>) -> Self {
let state = Some(FileState::of(&bytes));
let (text, exact) = match String::from_utf8(bytes) {
Ok(text) => (text, true),
Err(error) => (String::from_utf8_lossy(error.as_bytes()).into(), false),
};
Self { text, exact, state }
}
pub(crate) fn read(read: io::Result<Contents>) -> Option<Self> {
match read {
Err(error) if error.kind() == ErrorKind::NotFound => None,
Ok(contents) if contents.size > crate::MAX_FILE_BYTES => {
Some(Self::unread(contents.size))
}
Ok(contents) => Some(Self::of(contents.bytes)),
Err(_) => Some(Self::default()),
}
}
pub(crate) fn unread(size: u64) -> Self {
Self {
text: String::new(),
exact: false,
state: Some(FileState::unread(size)),
}
}
pub(crate) fn change(this: Option<Self>, path: &str, after: Option<&[u8]>) -> FileChange {
let text = after.map(String::from_utf8_lossy);
let before = this.as_ref().map(|it| it.text.as_str());
let mut change = diff(path, before, text.as_deref());
change.after = after.map(FileState::of);
if let Some(this) = this {
change.before = this.state;
change.before_text = this.exact.then_some(this.text);
}
change
}
}
#[cfg(test)]
mod tests;