use std::ffi::{OsStr, OsString};
use std::fmt;
use std::fs::{self, File};
use std::io::{self, ErrorKind, Read as _};
use std::path::{Component, Path, PathBuf};
use std::sync::Arc;
use std::time::SystemTime;
use ignore::WalkBuilder;
use ignore::overrides::OverrideBuilder;
use rig_core::tool::{ContentHash, ToolExecutionError, content_hash};
use crate::fs::{Previous, io_error, write_atomic};
use crate::process::{Launcher, Process, Spawn};
pub use crate::watch::Watch;
use crate::{MAX_BYTES, MAX_FILE_BYTES};
pub trait Workspace: Send + Sync + fmt::Debug + 'static {
fn host(&self) -> Option<&str> {
None
}
fn stat(&self, path: &Path) -> io::Result<Stat>;
fn read(&self, path: &Path, limit: u64) -> io::Result<Contents>;
fn read_files(&self, paths: &[PathBuf], limit: u64) -> Vec<Option<Vec<u8>>> {
paths
.iter()
.map(|path| read_file(self, path, limit))
.collect()
}
fn excerpt(&self, path: &Path, first: usize, count: usize) -> io::Result<Excerpt> {
excerpt_of(self, path, first, count)
}
fn write(&self, path: &Path, bytes: &[u8]) -> io::Result<()>;
fn replace(&self, path: &Path, bytes: &[u8], limit: u64) -> io::Result<io::Result<Contents>> {
let before = self.read(path, limit);
self.write(path, bytes)?;
Ok(before)
}
fn write_if(
&self,
path: &Path,
bytes: &[u8],
held: &ContentHash,
) -> io::Result<Option<Contents>> {
let now = self.read(path, MAX_FILE_BYTES)?;
if now.size > MAX_FILE_BYTES || content_hash(&now.bytes) != *held {
return Ok(Some(now));
}
self.write(path, bytes)?;
Ok(None)
}
fn remove(&self, path: &Path) -> io::Result<()>;
fn rename(&self, from: &Path, to: &Path) -> io::Result<()>;
fn canonical(&self, path: &Path) -> PathBuf;
fn join(&self, base: &Path, path: &Path) -> PathBuf {
base.join(path)
}
fn files(&self, root: &Path, glob: Option<&str>, listing: Listing) -> io::Result<Vec<PathBuf>>;
fn spawn(
&self,
launcher: &Launcher,
dir: Option<&Path>,
program: &OsStr,
args: &[OsString],
how: Spawn,
) -> io::Result<Process>;
fn watch(self: Arc<Self>, root: &Path, recursive: bool, listing: Listing) -> io::Result<Watch> {
crate::watch::poll(self, root, recursive, listing)
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Listing {
#[default]
Visible,
Hidden,
All,
}
impl Listing {
pub fn for_glob(self, glob: Option<&str>) -> Self {
let names_hidden = glob.is_some_and(|glob| {
glob.split(['/', '\\'])
.any(|part| part.starts_with('.') && part != "." && part != "..")
});
match self {
Self::Visible if names_hidden => Self::Hidden,
other => other,
}
}
pub fn keeps(self, below: &Path) -> bool {
let mut parts = below.iter();
match self {
Self::Visible => !parts.any(|part| part == "target" || is_hidden(Path::new(part))),
Self::Hidden => !parts.any(|part| part == ".git"),
Self::All => true,
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Contents {
pub bytes: Vec<u8>,
pub size: u64,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Excerpt {
pub text: String,
pub total: usize,
pub hash: ContentHash,
pub whole: Option<String>,
}
impl Excerpt {
pub fn of(text: &str, first: usize, count: usize) -> Self {
let start = text
.split_inclusive('\n')
.take(first.saturating_sub(1))
.map(str::len)
.sum();
let rest = text.get(start..).unwrap_or_default();
let len = rest.split_inclusive('\n').take(count).map(str::len).sum();
Self {
text: rest.get(..len).unwrap_or_default().to_owned(),
total: text.lines().count(),
hash: content_hash(text.as_bytes()),
whole: (text.len() <= MAX_BYTES).then(|| text.to_owned()),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Stat {
File {
size: u64,
read_only: bool,
modified: Option<SystemTime>,
},
Dir,
Other,
Missing,
}
impl dyn Workspace {
pub fn resolve(&self, base: Option<&Path>, path: &str) -> PathBuf {
self.canonical(&base.map_or_else(
|| PathBuf::from(path),
|base| self.join(base, path.as_ref()),
))
}
pub fn place(
&self,
base: Option<&Path>,
confine: bool,
path: &str,
) -> Result<PathBuf, ToolExecutionError> {
let resolved = self.resolve(base, path);
if confine {
let root = self.resolve(base, ".");
if !resolved.starts_with(&root) {
return Err(ToolExecutionError::permission_denied(format!(
"{path} is outside {}, the only directory this tool works in",
root.display()
)));
}
}
Ok(resolved)
}
pub fn read_text(&self, path: &Path) -> Result<String, ToolExecutionError> {
let shown = path.to_string_lossy();
self.read(path, MAX_FILE_BYTES)
.and_then(text_of)
.map_err(|error| text_error(&shown, error))
}
pub(crate) fn previous(&self, path: &Path) -> Option<Previous> {
Previous::read(self.read(path, MAX_FILE_BYTES))
}
}
pub(crate) fn text_of(contents: Contents) -> io::Result<String> {
if contents.size > MAX_FILE_BYTES {
return Err(too_large(contents.size));
}
String::from_utf8(contents.bytes).map_err(|_| not_text())
}
pub(crate) fn too_large(size: u64) -> io::Error {
io::Error::new(
ErrorKind::FileTooLarge,
format!(
"{} MB; the tools open files up to {} MB",
size / (1024 * 1024),
MAX_FILE_BYTES / (1024 * 1024)
),
)
}
pub(crate) fn not_text() -> io::Error {
io::Error::new(ErrorKind::InvalidData, "stream did not contain valid UTF-8")
}
pub(crate) fn text_error(shown: &str, error: io::Error) -> ToolExecutionError {
match error.kind() {
ErrorKind::NotFound => ToolExecutionError::not_found(format!("{shown}: no such file")),
ErrorKind::InvalidInput | ErrorKind::IsADirectory => {
ToolExecutionError::invalid_args(format!("{shown} is not a regular file"))
}
ErrorKind::FileTooLarge => ToolExecutionError::invalid_args(format!("{shown} is {error}")),
_ => io_error(shown, error),
}
}
pub(crate) fn excerpt_of<W: Workspace + ?Sized>(
workspace: &W,
path: &Path,
first: usize,
count: usize,
) -> io::Result<Excerpt> {
let text = text_of(workspace.read(path, MAX_FILE_BYTES)?)?;
Ok(Excerpt::of(&text, first, count))
}
pub(crate) fn read_file<W: Workspace + ?Sized>(
workspace: &W,
path: &Path,
limit: u64,
) -> Option<Vec<u8>> {
let small = matches!(workspace.stat(path), Ok(Stat::File { size, .. }) if size <= limit);
small
.then(|| workspace.read(path, limit).ok())
.flatten()
.map(|contents| contents.bytes)
}
#[derive(Clone, Copy, Debug, Default)]
pub struct Local;
impl Workspace for Local {
fn stat(&self, path: &Path) -> io::Result<Stat> {
match fs::metadata(path) {
Ok(meta) if meta.is_file() => Ok(Stat::File {
size: meta.len(),
read_only: meta.permissions().readonly(),
modified: meta.modified().ok(),
}),
Ok(meta) if meta.is_dir() => Ok(Stat::Dir),
Ok(_) => Ok(Stat::Other),
Err(error) if error.kind() == ErrorKind::NotFound => Ok(Stat::Missing),
Err(error) => Err(error),
}
}
fn read(&self, path: &Path, limit: u64) -> io::Result<Contents> {
if !fs::metadata(path)?.is_file() {
return Err(io::Error::new(
ErrorKind::InvalidInput,
"not a regular file",
));
}
let file = File::open(path)?;
let size = file.metadata()?.len();
let mut bytes = Vec::new();
file.take(limit).read_to_end(&mut bytes)?;
Ok(Contents { bytes, size })
}
fn write(&self, path: &Path, bytes: &[u8]) -> io::Result<()> {
write_atomic(path, bytes)
}
fn remove(&self, path: &Path) -> io::Result<()> {
fs::remove_file(path)
}
fn rename(&self, from: &Path, to: &Path) -> io::Result<()> {
if let Some(parent) = to.parent().filter(|parent| !parent.as_os_str().is_empty()) {
fs::create_dir_all(parent)?;
}
match fs::rename(from, to) {
Err(error) if error.kind() == ErrorKind::CrossesDevices => {
fs::copy(from, to)?;
fs::remove_file(from)
}
moved => moved,
}
}
fn canonical(&self, path: &Path) -> PathBuf {
let joined = std::path::absolute(path).unwrap_or_else(|_| path.to_path_buf());
let real = joined.ancestors().find_map(|ancestor| {
let real = fs::canonicalize(ancestor).ok()?;
Some((real, joined.strip_prefix(ancestor).ok()?))
});
let (mut out, rest) = real.unwrap_or((PathBuf::new(), &joined));
for component in rest.components() {
match component {
Component::ParentDir => {
out.pop();
}
Component::CurDir => {}
other => out.push(other),
}
}
out
}
fn files(&self, root: &Path, glob: Option<&str>, listing: Listing) -> io::Result<Vec<PathBuf>> {
let mut walk = WalkBuilder::new(root);
match listing {
Listing::All => walk.standard_filters(false),
Listing::Visible | Listing::Hidden => walk.hidden(false).filter_entry(move |entry| {
entry.depth() == 0 || listing.keeps(Path::new(entry.file_name()))
}),
};
if let Some(glob) = glob {
let overrides = OverrideBuilder::new(root)
.add(glob)
.and_then(|builder| builder.build())
.map_err(|error| {
io::Error::new(ErrorKind::InvalidInput, format!("bad glob: {error}"))
})?;
walk.overrides(overrides);
}
Ok(walk
.build()
.flatten()
.filter(|entry| entry.file_type().is_some_and(|kind| kind.is_file()))
.map(ignore::DirEntry::into_path)
.collect())
}
fn spawn(
&self,
launcher: &Launcher,
dir: Option<&Path>,
program: &OsStr,
args: &[OsString],
how: Spawn,
) -> io::Result<Process> {
match dir {
Some(dir) => launcher.clone().in_dir(dir).spawn(program, args, how),
None => launcher.spawn(program, args, how),
}
}
fn watch(self: Arc<Self>, root: &Path, recursive: bool, listing: Listing) -> io::Result<Watch> {
crate::watch::notified(root, recursive, listing)
}
}
pub(crate) fn is_hidden(path: &Path) -> bool {
path.file_name()
.is_some_and(|name| name.as_encoded_bytes().starts_with(b"."))
}
#[cfg(test)]
pub(crate) mod tests;