use crate::registry::{ToolEntry, ToolPermission};
use crate::substrate::Substrate;
use regex::Regex;
use serde_json::{json, Value};
use std::collections::HashMap;
use std::hash::{Hash, Hasher};
use std::path::{Component, Path, PathBuf};
use std::sync::{Arc, Mutex};
const MAX_FILE_BYTES: usize = 512 * 1024;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReadState {
Unread,
Stale,
FreshPartial,
FreshFull,
}
#[derive(Debug, Clone)]
struct ReadRecord {
hash: u64,
full_read: bool,
lines_seen: Vec<(usize, usize)>,
}
#[derive(Debug, Default)]
pub struct ReadLedger {
seen: Mutex<HashMap<String, ReadRecord>>,
mutation_locks: Arc<Mutex<HashMap<String, Arc<tokio::sync::Mutex<()>>>>>,
}
impl ReadLedger {
pub fn new() -> Self {
Self::default()
}
fn with_mutation_locks(
mutation_locks: Arc<Mutex<HashMap<String, Arc<tokio::sync::Mutex<()>>>>>,
) -> Self {
Self {
seen: Mutex::new(HashMap::new()),
mutation_locks,
}
}
fn hash(content: &str) -> u64 {
let mut hasher = std::collections::hash_map::DefaultHasher::new();
content.hash(&mut hasher);
hasher.finish()
}
fn normalize_key(path: &str) -> String {
let mut normalized = PathBuf::new();
for component in Path::new(path).components() {
if !matches!(component, Component::CurDir) {
normalized.push(component.as_os_str());
}
}
if normalized.as_os_str().is_empty() {
".".to_string()
} else {
normalized.to_string_lossy().into_owned()
}
}
pub fn record(&self, path: &str, content: &str, full_read: bool) {
let record = ReadRecord {
hash: Self::hash(content),
full_read,
lines_seen: Vec::new(),
};
self.seen
.lock()
.expect("read ledger mutex poisoned")
.insert(Self::normalize_key(path), record);
}
pub fn record_lines(
&self,
path: &str,
content: &str,
start: usize,
end: usize,
total_lines: usize,
) -> bool {
let hash = Self::hash(content);
let mut guard = self.seen.lock().expect("read ledger mutex poisoned");
let mut lines_seen = match guard.get(&Self::normalize_key(path)) {
Some(prior) if prior.hash == hash && prior.full_read => return true,
Some(prior) if prior.hash == hash => prior.lines_seen.clone(),
_ => Vec::new(),
};
if start < end {
lines_seen.push((start, end));
}
lines_seen.sort_unstable();
let mut merged: Vec<(usize, usize)> = Vec::new();
for (a, b) in lines_seen {
match merged.last_mut() {
Some(last) if a <= last.1 => last.1 = last.1.max(b),
_ => merged.push((a, b)),
}
}
let full_read =
merged.first() == Some(&(0, total_lines)) || (total_lines == 0 && start == 0);
guard.insert(
Self::normalize_key(path),
ReadRecord {
hash,
full_read,
lines_seen: merged,
},
);
full_read
}
pub fn lines_seen(&self, path: &str, content: &str) -> Vec<(usize, usize)> {
let guard = self.seen.lock().expect("read ledger mutex poisoned");
match guard.get(&Self::normalize_key(path)) {
Some(record) if record.hash == Self::hash(content) && record.full_read => {
vec![(0, usize::MAX)]
}
Some(record) if record.hash == Self::hash(content) => record.lines_seen.clone(),
_ => Vec::new(),
}
}
pub fn record_seen(&self, path: &str, content: &str, ranges: Vec<(usize, usize)>) {
self.record(path, content, false);
let total = if content.is_empty() {
0
} else {
content.split('\n').count() - usize::from(content.ends_with('\n'))
};
for (a, b) in ranges {
self.record_lines(path, content, a, b.min(total), total);
}
}
pub fn check(&self, path: &str, content: &str) -> ReadState {
let guard = self.seen.lock().expect("read ledger mutex poisoned");
match guard.get(&Self::normalize_key(path)) {
None => ReadState::Unread,
Some(recorded) if recorded.hash == Self::hash(content) && recorded.full_read => {
ReadState::FreshFull
}
Some(recorded) if recorded.hash == Self::hash(content) => ReadState::FreshPartial,
Some(_) => ReadState::Stale,
}
}
pub fn mutation_lock(&self, path: &str) -> Arc<tokio::sync::Mutex<()>> {
let key = Self::normalize_key(path);
let mut locks = self
.mutation_locks
.lock()
.expect("read ledger mutation-lock mutex poisoned");
locks
.entry(key)
.or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
.clone()
}
pub fn clear(&self) {
self.seen
.lock()
.expect("read ledger mutex poisoned")
.clear();
}
}
#[derive(Debug)]
pub struct SessionReadLedgers {
default: Arc<ReadLedger>,
sessions: Mutex<HashMap<String, Arc<ReadLedger>>>,
mutation_locks: Arc<Mutex<HashMap<String, Arc<tokio::sync::Mutex<()>>>>>,
}
impl Default for SessionReadLedgers {
fn default() -> Self {
Self::new()
}
}
impl SessionReadLedgers {
pub fn new() -> Self {
let mutation_locks = Arc::new(Mutex::new(HashMap::new()));
Self {
default: Arc::new(ReadLedger::with_mutation_locks(mutation_locks.clone())),
sessions: Mutex::new(HashMap::new()),
mutation_locks,
}
}
pub fn ledger_for(&self, session_id: Option<&str>) -> Arc<ReadLedger> {
let Some(session_id) = session_id else {
return self.default.clone();
};
let mut sessions = self
.sessions
.lock()
.expect("session read-ledger mutex poisoned");
sessions
.entry(session_id.to_string())
.or_insert_with(|| {
Arc::new(ReadLedger::with_mutation_locks(self.mutation_locks.clone()))
})
.clone()
}
pub fn remove(&self, session_id: &str) {
self.sessions
.lock()
.expect("session read-ledger mutex poisoned")
.remove(session_id);
}
pub fn clear(&self) {
self.default.clear();
let ledgers: Vec<Arc<ReadLedger>> = self
.sessions
.lock()
.expect("session read-ledger mutex poisoned")
.values()
.cloned()
.collect();
for ledger in ledgers {
ledger.clear();
}
}
}
fn read_first_error(display_path: &str, verb: &str) -> String {
format!("you must read '{display_path}' before {verb} it — call read_file first")
}
fn stale_error(display_path: &str) -> String {
format!("'{display_path}' changed since you last read it — re-read it and retry")
}
fn full_read_error(display_path: &str, verb: &str) -> String {
format!(
"you must read the full current content of '{display_path}' before {verb} it — call \
read_file without offset or limit first; if the file is too large for one read, keep \
reading from the offset each page names until you have seen every line, or make a \
unique targeted edit instead"
)
}
fn looks_like_pasted_line_number(old_text: &str) -> bool {
let after_spaces = old_text.trim_start_matches(' ');
let digits = after_spaces
.bytes()
.take_while(|b| b.is_ascii_digit())
.count();
digits > 0 && after_spaces.as_bytes().get(digits) == Some(&b'\t')
}
fn unnumbered_read_text(content: &str, displayed: &str) -> Option<(String, usize, usize)> {
let mut source: Vec<&str> = content.split('\n').collect();
if source.last() == Some(&"") {
source.pop();
}
let mut first = None;
let mut plain = String::new();
for (index, line) in displayed.split_inclusive('\n').enumerate() {
let (prefix, body) = line.split_once('\t')?;
let number = prefix.trim_start_matches(' ').parse::<usize>().ok()?;
if prefix != format!("{number:>6}") {
return None;
}
let start = *first.get_or_insert(number.checked_sub(1)?);
let position = start.checked_add(index)?;
if number != position.checked_add(1)?
|| source.get(position)? != &body.strip_suffix('\n').unwrap_or(body)
{
return None;
}
plain.push_str(body);
}
if plain.is_empty() {
return None;
}
let first = first?;
let offset: usize = source.iter().take(first).map(|line| line.len() + 1).sum();
let lines = plain.split_inclusive('\n').count();
content
.get(offset..)?
.starts_with(&plain)
.then_some((plain, first, first + lines))
}
const HINT_BEGIN: &str = "----- exact existing block -----\n";
const HINT_END: &str = "\n----- end of block -----";
fn shift_seen_lines(
seen: &[(usize, usize)],
start: usize,
old_end: usize,
new_end: usize,
) -> Vec<(usize, usize)> {
let moved = |line: usize| line + new_end - old_end;
let mut out = Vec::new();
for &(a, b) in seen {
if b <= start {
out.push((a, b));
} else if a >= old_end {
out.push((moved(a), moved(b)));
} else {
if a < start {
out.push((a, start));
}
if b > old_end {
out.push((new_end, moved(b)));
}
}
}
if seen_covers(seen, start, old_end) {
out.push((start, new_end));
}
out
}
fn seen_covers(seen: &[(usize, usize)], start: usize, end: usize) -> bool {
seen.iter().any(|&(a, b)| a <= start && end <= b)
}
fn indentation_hint<'a>(content: &'a str, old_text: &str) -> Option<(&'a str, usize, usize)> {
if old_text.len() > 2048 {
return None;
}
let wanted: Vec<_> = old_text.lines().collect();
if wanted.is_empty() || wanted.len() > 12 || wanted.iter().all(|line| line.trim().is_empty()) {
return None;
}
let mut offset = 0;
let source: Vec<_> = content
.split_inclusive('\n')
.map(|line| {
let start = offset;
offset += line.len();
(start, line)
})
.collect();
let mut found = None;
for (first_line, block) in source.windows(wanted.len()).enumerate() {
if !block.iter().zip(&wanted).all(|((_, line), wanted)| {
line.lines()
.next()
.unwrap_or("")
.trim_start_matches([' ', '\t'])
== wanted.trim_start_matches([' ', '\t'])
}) {
continue;
}
let (last_offset, last_line) = block.last()?;
let last_len = if old_text.ends_with('\n') {
last_line.len()
} else {
last_line.lines().next().unwrap_or("").len()
};
let candidate = content.get(block[0].0..last_offset + last_len)?;
if found.is_some() || candidate.len() > 4096 {
return None;
}
found = Some((candidate, first_line, first_line + wanted.len()));
}
found
}
pub fn entries() -> Vec<ToolEntry> {
vec![
ToolEntry::builtin(car_ir::builtins::read_file()).with_category("filesystem"),
ToolEntry::builtin(car_ir::builtins::list_dir()).with_category("filesystem"),
ToolEntry::builtin(car_ir::builtins::find_files()).with_category("filesystem"),
ToolEntry::builtin(car_ir::builtins::grep_files()).with_category("filesystem"),
ToolEntry::builtin(car_ir::builtins::calculate()).with_category("utility"),
ToolEntry::builtin(car_ir::builtins::write_file())
.with_permission(ToolPermission::AskUser)
.with_side_effects(true)
.with_category("filesystem"),
ToolEntry::builtin(car_ir::builtins::edit_file())
.with_permission(ToolPermission::AskUser)
.with_side_effects(true)
.with_category("filesystem"),
]
}
pub async fn execute(
substrate: &Arc<dyn Substrate>,
tool: &str,
params: &Value,
) -> Option<Result<Value, String>> {
execute_inner(substrate, None, tool, params).await
}
pub async fn execute_with_ledger(
substrate: &Arc<dyn Substrate>,
ledger: &ReadLedger,
tool: &str,
params: &Value,
) -> Option<Result<Value, String>> {
execute_inner(substrate, Some(ledger), tool, params).await
}
async fn execute_inner(
substrate: &Arc<dyn Substrate>,
ledger: Option<&ReadLedger>,
tool: &str,
params: &Value,
) -> Option<Result<Value, String>> {
let result = match tool {
"read_file" => exec_read_file(substrate, ledger, params).await,
"write_file" => exec_write_file(substrate, ledger, params).await,
"edit_file" => exec_edit_file(substrate, ledger, params).await,
"list_dir" => exec_list_dir(substrate, params).await,
"find_files" => exec_find_files(substrate, params).await,
"grep_files" => exec_grep_files(substrate, params).await,
"calculate" => exec_calculate(params),
_ => return None,
};
Some(result)
}
async fn exec_read_file(
substrate: &Arc<dyn Substrate>,
ledger: Option<&ReadLedger>,
params: &Value,
) -> Result<Value, String> {
let path = params
.get("path")
.and_then(|v| v.as_str())
.ok_or("missing 'path' parameter")?;
let offset = params.get("offset").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
let limit = params
.get("limit")
.and_then(|v| v.as_u64())
.map(|v| v as usize);
let max_output_bytes = params
.get("max_output_bytes")
.and_then(|v| v.as_u64())
.map(|v| v as usize);
let content = substrate.read_text(path).await?;
let size_bytes = content.len();
let total_lines = content.lines().count();
let mut lines: Vec<&str> = content.split('\n').collect();
if lines.last() == Some(&"") {
lines.pop();
}
let start = offset.min(lines.len());
let end = limit
.map(|line_count| (start + line_count).min(lines.len()))
.unwrap_or(lines.len());
let mut truncated = None;
let returned = if let Some(ledger) = ledger {
let mut shown = Vec::new();
let mut used = 0usize;
let mut shown_end = start;
for (i, line) in lines[start..end].iter().enumerate() {
let rendered = format!("{:>6}\t{}", start + i + 1, line);
let cost = rendered.len() + usize::from(!shown.is_empty());
match max_output_bytes {
Some(max) if used + cost > max => {
if shown.is_empty() {
let mut cut = max.min(rendered.len());
while !rendered.is_char_boundary(cut) {
cut -= 1;
}
shown.push(rendered[..cut].to_string());
truncated = Some(format!(
"line {} is longer than one read can show and was cut after {cut} \
bytes; inspect it with shell (for example `cut -c` or python), \
or call read_file with offset={} to continue after it",
start + i + 1,
start + i + 1
));
} else {
truncated = Some(format!(
"showing lines {}-{shown_end} of {}; call read_file with \
offset={shown_end} to read further",
start + 1,
lines.len()
));
}
break;
}
_ => {}
}
used += cost;
shown.push(rendered);
shown_end = start + i + 1;
}
ledger.record_lines(path, &content, start, shown_end, lines.len());
shown.join("\n")
} else if offset > 0 || limit.is_some() {
lines[start..end].join("\n")
} else {
content.clone()
};
if let Some(note) = truncated {
return Ok(json!({
"path": substrate.display_path(path),
"truncated": note,
"content": returned,
"size_bytes": size_bytes,
"total_lines": total_lines,
}));
}
Ok(json!({
"path": substrate.display_path(path),
"content": returned,
"size_bytes": size_bytes,
"total_lines": total_lines,
}))
}
async fn exec_write_file(
substrate: &Arc<dyn Substrate>,
ledger: Option<&ReadLedger>,
params: &Value,
) -> Result<Value, String> {
let path = params
.get("path")
.and_then(|v| v.as_str())
.ok_or("missing 'path' parameter")?;
let content = params
.get("content")
.and_then(|v| v.as_str())
.ok_or("missing 'content' parameter")?;
let append = params
.get("append")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let _mutation_guard = match ledger {
Some(ledger) => Some(ledger.mutation_lock(path).lock_owned().await),
None => None,
};
let existing = if ledger.is_some() {
match substrate.read_text(path).await {
Ok(content) => Some(content),
Err(read_error) => match substrate.path_state(path).await {
crate::substrate::PathState::Missing => None,
crate::substrate::PathState::Exists => {
return Err(format!(
"cannot modify existing file '{}' because it cannot be read as UTF-8: {read_error}",
substrate.display_path(path)
));
}
crate::substrate::PathState::Unknown(reason) => {
return Err(format!(
"cannot determine whether '{}' is safe to create: {reason}",
substrate.display_path(path)
));
}
},
}
} else {
None
};
let combined = if append {
let existed = existing.is_some();
let existing = match existing {
Some(existing) => existing,
None if ledger.is_some() => String::new(),
None => substrate.read_text(path).await.unwrap_or_default(),
};
if let Some(ledger) = ledger.filter(|_| existed) {
match ledger.check(path, &existing) {
ReadState::Unread => {
return Err(read_first_error(
&substrate.display_path(path),
"overwriting",
));
}
ReadState::Stale => {
return Err(stale_error(&substrate.display_path(path)));
}
ReadState::FreshPartial => {
return Err(full_read_error(
&substrate.display_path(path),
"appending to",
));
}
ReadState::FreshFull => {}
}
}
let mut combined = existing;
combined.push_str(content);
substrate.write_text(path, &combined).await?;
combined
} else {
if let Some(ledger) = ledger {
if let Some(existing) = existing {
match ledger.check(path, &existing) {
ReadState::Unread => {
return Err(read_first_error(
&substrate.display_path(path),
"overwriting",
));
}
ReadState::Stale => {
return Err(stale_error(&substrate.display_path(path)));
}
ReadState::FreshPartial => {
return Err(full_read_error(
&substrate.display_path(path),
"overwriting",
));
}
ReadState::FreshFull => {}
}
}
}
substrate.write_text(path, content).await?;
content.to_string()
};
if let Some(ledger) = ledger {
ledger.record(path, &combined, true);
}
Ok(json!({
"path": substrate.display_path(path),
"bytes_written": content.len(),
"append": append,
}))
}
async fn exec_edit_file(
substrate: &Arc<dyn Substrate>,
ledger: Option<&ReadLedger>,
params: &Value,
) -> Result<Value, String> {
let path = params
.get("path")
.and_then(|v| v.as_str())
.ok_or("missing 'path' parameter")?;
let old_text = params
.get("old_text")
.and_then(|v| v.as_str())
.ok_or("missing 'old_text' parameter")?;
let new_text = params
.get("new_text")
.and_then(|v| v.as_str())
.ok_or("missing 'new_text' parameter")?;
let replace_all = params
.get("replace_all")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let _mutation_guard = match ledger {
Some(ledger) => Some(ledger.mutation_lock(path).lock_owned().await),
None => None,
};
let content = substrate.read_text(path).await?;
let full_read = if let Some(ledger) = ledger {
match ledger.check(path, &content) {
ReadState::Unread => {
return Err(read_first_error(&substrate.display_path(path), "editing"));
}
ReadState::Stale => {
return Err(stale_error(&substrate.display_path(path)));
}
ReadState::FreshPartial if replace_all => {
return Err(full_read_error(
&substrate.display_path(path),
"replacing every occurrence in",
));
}
ReadState::FreshPartial => false,
ReadState::FreshFull => true,
}
} else {
false
};
if old_text.is_empty() {
return Err(
"old_text must be non-empty — pass the exact existing text to replace \
(use write_file to replace a whole file)"
.to_string(),
);
}
let seen = ledger
.map(|ledger| ledger.lines_seen(path, &content))
.unwrap_or_default();
let recovered = if !replace_all && !content.contains(old_text) {
unnumbered_read_text(&content, old_text)
.filter(|(_, start, end)| seen_covers(&seen, *start, *end))
.map(|(plain, _, _)| plain)
} else {
None
};
let old_text = recovered.as_deref().unwrap_or(old_text);
let crlf = (!content.contains(old_text)
&& old_text.contains('\n')
&& !old_text.contains('\r')
&& content.contains("\r\n"))
.then(|| {
(
old_text.replace('\n', "\r\n"),
new_text.replace("\r\n", "\n").replace('\n', "\r\n"),
)
})
.filter(|(old, _)| content.contains(old.as_str()));
let (old_text, new_text) = match &crlf {
Some((old, new)) => (old.as_str(), new.as_str()),
None => (old_text, new_text),
};
let count = content.matches(old_text).count();
if count == 0 {
let mut msg = format!("old_text not found in '{}'", substrate.display_path(path));
if looks_like_pasted_line_number(old_text) {
msg.push_str(
" — old_text looks like it includes read_file's line-number prefixes; \
strip them and retry",
);
}
if !replace_all {
if let Some((candidate, _, _)) = indentation_hint(&content, old_text)
.filter(|(_, start, end)| seen_covers(&seen, *start, *end))
{
msg.push_str("\nOne block matches after ignoring leading indentation or line endings. No edit was applied. Inspect this block and retry with exact old_text, preserving the intended indentation in new_text.\n");
msg.push_str(HINT_BEGIN);
msg.push_str(candidate);
msg.push_str(HINT_END);
}
}
return Err(msg);
}
let new_content = if replace_all {
content.replace(old_text, new_text)
} else {
if count > 1 {
return Err(format!(
"old_text found {count} times in '{}' and must match uniquely — \
pass `replace_all: true` to replace every occurrence, or add \
surrounding context to old_text so it matches one place",
substrate.display_path(path)
));
}
content.replacen(old_text, new_text, 1)
};
substrate.write_text(path, &new_content).await?;
if let Some(ledger) = ledger {
if full_read || replace_all {
ledger.record(path, &new_content, full_read);
} else {
let offset = content.find(old_text).unwrap_or(0);
let start = content[..offset].matches('\n').count();
let old_end = start + old_text.matches('\n').count() + 1;
let new_end = start + new_text.matches('\n').count() + 1;
let seen = shift_seen_lines(&seen, start, old_end, new_end);
ledger.record_seen(path, &new_content, seen);
}
}
Ok(json!({
"edited": substrate.display_path(path),
"diff_summary": format!(
"replaced {} lines with {} lines",
old_text.lines().count(),
new_text.lines().count()
),
"replacements": count,
"removed_display_line_numbers": recovered.is_some(),
}))
}
async fn exec_list_dir(substrate: &Arc<dyn Substrate>, params: &Value) -> Result<Value, String> {
let path = params.get("path").and_then(|v| v.as_str()).unwrap_or(".");
if !substrate.is_local() {
return list_dir_via_command(substrate, path).await;
}
let full_path = local_resolve(path)?;
let mut entries = Vec::new();
let read_dir = std::fs::read_dir(&full_path)
.map_err(|e| format!("failed to read dir '{}': {e}", full_path.display()))?;
for entry in read_dir {
let entry = entry.map_err(|e| format!("failed to read dir entry: {e}"))?;
let file_name = entry.file_name().to_string_lossy().to_string();
if should_skip_name(&file_name) {
continue;
}
let metadata = entry
.metadata()
.map_err(|e| format!("failed to read metadata for '{}': {e}", file_name))?;
entries.push(json!({
"name": file_name,
"path": entry.path().display().to_string(),
"is_dir": metadata.is_dir(),
"size_bytes": if metadata.is_file() { Some(metadata.len()) } else { None::<u64> },
}));
}
Ok(json!({
"path": full_path.display().to_string(),
"entries": entries,
}))
}
async fn exec_find_files(substrate: &Arc<dyn Substrate>, params: &Value) -> Result<Value, String> {
let pattern = params
.get("pattern")
.and_then(|v| v.as_str())
.ok_or("missing 'pattern' parameter")?;
let root = params.get("path").and_then(|v| v.as_str()).unwrap_or(".");
let max_results = params
.get("max_results")
.and_then(|v| v.as_u64())
.unwrap_or(1000) as usize;
if !substrate.is_local() {
return find_files_via_command(substrate, pattern, root, max_results).await;
}
let root_path = local_resolve(root)?;
let matcher = glob_to_regex(pattern)?;
let pattern_has_sep = pattern.contains('/');
let root_str = root_path.to_string_lossy().to_string();
let mut files = Vec::new();
walk_files(&root_path, &mut |path| {
if files.len() >= max_results {
return;
}
if let Some(full) = path.to_str() {
if matcher.is_match(&glob_haystack(pattern_has_sep, full, &root_str)) {
files.push(path.display().to_string());
}
}
})?;
Ok(json!({
"files": files,
"count": files.len(),
"truncated": files.len() >= max_results,
}))
}
async fn exec_grep_files(substrate: &Arc<dyn Substrate>, params: &Value) -> Result<Value, String> {
let pattern = params
.get("pattern")
.and_then(|v| v.as_str())
.ok_or("missing 'pattern' parameter")?;
let root = params.get("path").and_then(|v| v.as_str()).unwrap_or(".");
let max_results = params
.get("max_results")
.and_then(|v| v.as_u64())
.unwrap_or(50) as usize;
if !substrate.is_local() {
return grep_files_via_command(substrate, pattern, root, max_results).await;
}
let root_path = local_resolve(root)?;
let regex = Regex::new(pattern).map_err(|e| format!("invalid regex pattern: {e}"))?;
let mut matches = Vec::new();
walk_files(&root_path, &mut |path| {
if matches.len() >= max_results || !is_text_file(path) {
return;
}
let Ok(content) = std::fs::read_to_string(path) else {
return;
};
if content.len() > MAX_FILE_BYTES {
return;
}
for (idx, line) in content.lines().enumerate() {
if regex.is_match(line) {
matches.push(json!({
"path": path.display().to_string(),
"line": idx + 1,
"text": line,
}));
if matches.len() >= max_results {
break;
}
}
}
})?;
Ok(json!({
"matches": matches,
"count": matches.len(),
"truncated": matches.len() >= max_results,
}))
}
fn exec_calculate(params: &Value) -> Result<Value, String> {
let expression = params
.get("expression")
.and_then(|v| v.as_str())
.ok_or("missing 'expression' parameter")?;
let mut ns = |name: &str, args: Vec<f64>| -> Option<f64> {
match (name, args.as_slice()) {
("sqrt", [x]) => Some(x.sqrt()),
("ln", [x]) => Some(x.ln()),
("pi", []) => Some(std::f64::consts::PI),
("e", []) => Some(std::f64::consts::E),
_ => None,
}
};
let result = fasteval::ez_eval(expression, &mut ns)
.map_err(|e| format!("failed to evaluate expression: {e}"))?;
Ok(json!({ "result": result }))
}
fn local_resolve(path: &str) -> Result<PathBuf, String> {
crate::substrate::LocalSubstrate::resolve_path(path)
}
fn should_skip_name(name: &str) -> bool {
name.starts_with('.') || matches!(name, "node_modules" | "__pycache__" | "target")
}
fn is_text_file(path: &Path) -> bool {
matches!(
path.extension().and_then(|v| v.to_str()),
Some(
"c" | "cc"
| "cpp"
| "cs"
| "css"
| "go"
| "h"
| "html"
| "ini"
| "java"
| "js"
| "json"
| "jsx"
| "kt"
| "md"
| "py"
| "rb"
| "rs"
| "sh"
| "sql"
| "toml"
| "ts"
| "tsx"
| "txt"
| "xml"
| "yaml"
| "yml"
)
)
}
fn walk_files(root: &Path, visit: &mut dyn FnMut(&Path)) -> Result<(), String> {
if root.is_file() {
visit(root);
return Ok(());
}
let read_dir = std::fs::read_dir(root)
.map_err(|e| format!("failed to read dir '{}': {e}", root.display()))?;
for entry in read_dir {
let entry = entry.map_err(|e| format!("failed to read dir entry: {e}"))?;
let path = entry.path();
let file_name = entry.file_name().to_string_lossy().to_string();
if should_skip_name(&file_name) {
continue;
}
let metadata = entry
.metadata()
.map_err(|e| format!("failed to read metadata for '{}': {e}", path.display()))?;
if metadata.is_dir() {
walk_files(&path, visit)?;
} else if metadata.is_file() {
visit(&path);
}
}
Ok(())
}
fn glob_to_regex(pattern: &str) -> Result<Regex, String> {
let chars: Vec<char> = pattern.chars().collect();
let mut re = String::from("^");
let mut i = 0;
while i < chars.len() {
match chars[i] {
'*' => {
if i + 1 < chars.len() && chars[i + 1] == '*' {
i += 1; if i + 1 < chars.len() && chars[i + 1] == '/' {
i += 1; re.push_str("(?:.*/)?");
} else {
re.push_str(".*");
}
} else {
re.push_str("[^/]*");
}
}
'?' => re.push_str("[^/]"),
c => re.push_str(®ex::escape(&c.to_string())),
}
i += 1;
}
re.push('$');
Regex::new(&re).map_err(|e| format!("invalid glob pattern: {e}"))
}
fn glob_haystack(pattern_has_sep: bool, full: &str, root: &str) -> String {
if pattern_has_sep {
let full = full.replace('\\', "/");
let root = root.replace('\\', "/");
full.strip_prefix(&root)
.unwrap_or(&full)
.trim_start_matches('/')
.to_string()
} else {
full.rsplit(['/', '\\']).next().unwrap_or(full).to_string()
}
}
fn shell_quote(s: &str) -> String {
format!("'{}'", s.replace('\'', "'\\''"))
}
async fn list_dir_via_command(substrate: &Arc<dyn Substrate>, path: &str) -> Result<Value, String> {
let cmd = format!("ls -1Ap {}", shell_quote(path));
let out = substrate.run_command(&cmd, Some(30.0)).await?;
let entries: Vec<Value> = out
.stdout
.lines()
.filter(|l| !l.is_empty())
.filter(|name| {
let bare = name.trim_end_matches('/');
!should_skip_name(bare)
})
.map(|name| {
let is_dir = name.ends_with('/');
let bare = name.trim_end_matches('/');
json!({
"name": bare,
"path": format!("{}/{}", path.trim_end_matches('/'), bare),
"is_dir": is_dir,
"size_bytes": Value::Null,
})
})
.collect();
Ok(json!({ "path": path, "entries": entries }))
}
async fn find_files_via_command(
substrate: &Arc<dyn Substrate>,
pattern: &str,
root: &str,
max_results: usize,
) -> Result<Value, String> {
let cmd = format!("find {} -type f", shell_quote(root));
let out = substrate.run_command(&cmd, Some(30.0)).await?;
let matcher = glob_to_regex(pattern)?;
let pattern_has_sep = pattern.contains('/');
let mut files: Vec<String> = Vec::new();
let mut truncated = false;
for line in out.stdout.lines() {
if line.is_empty() {
continue;
}
let name = line.rsplit(['/', '\\']).next().unwrap_or(line);
if should_skip_name(name) {
continue;
}
if !matcher.is_match(&glob_haystack(pattern_has_sep, line, root)) {
continue;
}
if files.len() >= max_results {
truncated = true;
break;
}
files.push(line.to_string());
}
Ok(json!({
"files": files,
"count": files.len(),
"truncated": truncated,
}))
}
async fn grep_files_via_command(
substrate: &Arc<dyn Substrate>,
pattern: &str,
root: &str,
max_results: usize,
) -> Result<Value, String> {
let cmd = format!("grep -rnE {} {}", shell_quote(pattern), shell_quote(root));
let out = substrate.run_command(&cmd, Some(30.0)).await?;
let mut matches = Vec::new();
for line in out.stdout.lines() {
if matches.len() >= max_results {
break;
}
let mut parts = line.splitn(3, ':');
let (Some(p), Some(ln), Some(text)) = (parts.next(), parts.next(), parts.next()) else {
continue;
};
let Ok(line_no) = ln.parse::<usize>() else {
continue;
};
matches.push(json!({ "path": p, "line": line_no, "text": text }));
}
let truncated = matches.len() >= max_results;
Ok(json!({
"matches": matches,
"count": matches.len(),
"truncated": truncated,
}))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn glob_patterns_match_file_names() {
let regex = glob_to_regex("*.rs").unwrap();
assert!(regex.is_match("lib.rs"));
assert!(!regex.is_match("lib.ts"));
}
#[test]
fn glob_haystack_supports_path_and_basename_matching() {
assert_eq!(
glob_haystack(true, "/root/src/inner/deep.rs", "/root"),
"src/inner/deep.rs"
);
assert_eq!(
glob_haystack(false, "/root/src/inner/deep.rs", "/root"),
"deep.rs"
);
let m = glob_to_regex("src/**/*.rs").unwrap();
assert!(m.is_match(&glob_haystack(true, "/root/src/top.rs", "/root")));
assert!(m.is_match(&glob_haystack(true, "/root/src/inner/deep.rs", "/root")));
assert!(!m.is_match(&glob_haystack(true, "/root/src/inner/note.txt", "/root")));
}
#[tokio::test]
async fn find_files_supports_recursive_path_globs() {
let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
let dir = std::env::temp_dir().join(format!(
"car-find-glob-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(dir.join("src").join("inner")).unwrap();
std::fs::write(dir.join("src").join("top.rs"), "x").unwrap();
std::fs::write(dir.join("src").join("inner").join("deep.rs"), "x").unwrap();
std::fs::write(dir.join("src").join("inner").join("note.txt"), "x").unwrap();
let root = dir.to_string_lossy().to_string();
let r = exec_find_files(
&substrate,
&json!({ "path": root, "pattern": "src/**/*.rs" }),
)
.await
.unwrap();
let joined = r["files"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap())
.collect::<Vec<_>>()
.join("\n");
assert!(
joined.contains("deep.rs"),
"recursive glob missed nested file:\n{joined}"
);
assert!(
joined.contains("top.rs"),
"recursive glob missed top-level file:\n{joined}"
);
assert!(
!joined.contains("note.txt"),
"glob matched the wrong extension:\n{joined}"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn find_files_non_local_substrate_applies_path_glob() {
use crate::substrate::CommandOutput;
struct RemoteStub {
stdout: String,
}
#[async_trait::async_trait]
impl Substrate for RemoteStub {
fn name(&self) -> &str {
"test-remote"
}
async fn run_command(
&self,
_cmd: &str,
_timeout_s: Option<f64>,
) -> Result<CommandOutput, String> {
Ok(CommandOutput {
stdout: self.stdout.clone(),
stderr: String::new(),
exit_code: 0,
})
}
async fn read_text(&self, _path: &str) -> Result<String, String> {
Err("unused".into())
}
async fn write_text(&self, _path: &str, _content: &str) -> Result<(), String> {
Err("unused".into())
}
async fn read_bytes(
&self,
_path: &str,
_offset: Option<u64>,
_len: Option<u64>,
) -> Result<Vec<u8>, String> {
Err("unused".into())
}
async fn write_bytes(&self, _path: &str, _bytes: &[u8]) -> Result<(), String> {
Err("unused".into())
}
}
let substrate: Arc<dyn Substrate> = Arc::new(RemoteStub {
stdout: [
"/root/src/top.rs",
"/root/src/inner/deep.rs",
"/root/src/inner/note.txt",
"/root/README.md",
]
.join("\n"),
});
let r = exec_find_files(
&substrate,
&json!({ "path": "/root", "pattern": "src/**/*.rs" }),
)
.await
.unwrap();
let files: Vec<&str> = r["files"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap())
.collect();
assert_eq!(files, vec!["/root/src/top.rs", "/root/src/inner/deep.rs"]);
assert_eq!(r["count"], 2);
}
fn fresh_dir(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"car-agent-basics-{tag}-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[tokio::test]
async fn read_write_roundtrip_against_local_substrate() {
let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
let dir = fresh_dir("roundtrip");
let path = dir.join("note.txt").to_string_lossy().to_string();
let w = exec_write_file(&substrate, None, &json!({ "path": path, "content": "abc" }))
.await
.unwrap();
assert_eq!(w["bytes_written"], 3);
let r = exec_read_file(&substrate, None, &json!({ "path": path }))
.await
.unwrap();
assert_eq!(r["content"], "abc");
assert_eq!(r["size_bytes"], 3);
assert_eq!(r["total_lines"], 1);
exec_write_file(
&substrate,
None,
&json!({ "path": path, "content": "def", "append": true }),
)
.await
.unwrap();
let r2 = exec_read_file(&substrate, None, &json!({ "path": path }))
.await
.unwrap();
assert_eq!(r2["content"], "abcdef");
let e = exec_edit_file(
&substrate,
None,
&json!({ "path": path, "old_text": "abc", "new_text": "XYZ" }),
)
.await
.unwrap();
assert!(e["edited"].is_string());
assert_eq!(e["replacements"], 1);
let r3 = exec_read_file(&substrate, None, &json!({ "path": path }))
.await
.unwrap();
assert_eq!(r3["content"], "XYZdef");
std::fs::remove_dir_all(&dir).ok();
}
#[tokio::test]
async fn read_file_output_is_line_numbered() {
let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
let ledger = ReadLedger::new();
let dir = fresh_dir("linenum");
let path = dir.join("f.txt").to_string_lossy().to_string();
exec_write_file(
&substrate,
None,
&json!({ "path": path, "content": "alpha\nbeta\ngamma" }),
)
.await
.unwrap();
let r = execute_with_ledger(&substrate, &ledger, "read_file", &json!({ "path": path }))
.await
.unwrap()
.unwrap();
assert_eq!(r["content"], " 1\talpha\n 2\tbeta\n 3\tgamma");
assert_eq!(r["total_lines"], 3);
let r2 = execute_with_ledger(
&substrate,
&ledger,
"read_file",
&json!({ "path": path, "offset": 1, "limit": 1 }),
)
.await
.unwrap()
.unwrap();
assert_eq!(r2["content"], " 2\tbeta");
assert_eq!(r2["total_lines"], 3);
std::fs::remove_dir_all(&dir).ok();
}
#[tokio::test]
async fn plain_execute_preserves_legacy_raw_read_output() {
let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
let dir = fresh_dir("rawread");
let path = dir.join("f.txt").to_string_lossy().to_string();
std::fs::write(&path, "alpha\nbeta\n").unwrap();
let result = execute(&substrate, "read_file", &json!({ "path": path }))
.await
.unwrap()
.unwrap();
assert_eq!(result["content"], "alpha\nbeta\n");
std::fs::remove_dir_all(&dir).ok();
}
#[tokio::test]
async fn edit_requires_prior_read() {
let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
let ledger = ReadLedger::new();
let dir = fresh_dir("editread");
let path = dir.join("f.txt").to_string_lossy().to_string();
std::fs::write(dir.join("f.txt"), "hello world").unwrap();
let err = execute_with_ledger(
&substrate,
&ledger,
"edit_file",
&json!({ "path": path, "old_text": "hello", "new_text": "hi" }),
)
.await
.unwrap()
.unwrap_err();
assert!(err.contains("before editing it"), "{err}");
assert!(err.contains("read_file"), "{err}");
execute_with_ledger(&substrate, &ledger, "read_file", &json!({ "path": path }))
.await
.unwrap()
.unwrap();
let ok = execute_with_ledger(
&substrate,
&ledger,
"edit_file",
&json!({ "path": path, "old_text": "hello", "new_text": "hi" }),
)
.await
.unwrap()
.unwrap();
assert_eq!(ok["replacements"], 1);
std::fs::remove_dir_all(&dir).ok();
}
#[tokio::test]
async fn edit_detects_stale_file() {
let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
let ledger = ReadLedger::new();
let dir = fresh_dir("stale");
let path = dir.join("f.txt").to_string_lossy().to_string();
std::fs::write(dir.join("f.txt"), "version one").unwrap();
execute_with_ledger(&substrate, &ledger, "read_file", &json!({ "path": path }))
.await
.unwrap()
.unwrap();
std::fs::write(dir.join("f.txt"), "version two changed").unwrap();
let err = execute_with_ledger(
&substrate,
&ledger,
"edit_file",
&json!({ "path": path, "old_text": "version", "new_text": "v" }),
)
.await
.unwrap()
.unwrap_err();
assert!(err.contains("changed since you last read it"), "{err}");
execute_with_ledger(&substrate, &ledger, "read_file", &json!({ "path": path }))
.await
.unwrap()
.unwrap();
let ok = execute_with_ledger(
&substrate,
&ledger,
"edit_file",
&json!({ "path": path, "old_text": "version", "new_text": "v" }),
)
.await
.unwrap()
.unwrap();
assert!(ok["edited"].is_string());
std::fs::remove_dir_all(&dir).ok();
}
#[tokio::test]
async fn read_file_preserves_crlf_line_endings() {
let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
let ledger = ReadLedger::new();
let dir = fresh_dir("crlf");
let path = dir.join("f.txt").to_string_lossy().to_string();
std::fs::write(dir.join("f.txt"), "line one\r\nline two\r\n").unwrap();
let out = execute_with_ledger(&substrate, &ledger, "read_file", &json!({ "path": path }))
.await
.unwrap()
.unwrap();
let shown = out["content"].as_str().unwrap();
assert_eq!(
shown, " 1\tline one\r\n 2\tline two\r",
"\\r must survive into the numbered display"
);
let ok = execute_with_ledger(
&substrate,
&ledger,
"edit_file",
&json!({ "path": path, "old_text": "line one\r\nline two", "new_text": "merged" }),
)
.await
.unwrap()
.unwrap();
assert_eq!(ok["replacements"], 1);
std::fs::remove_dir_all(&dir).ok();
}
#[tokio::test]
async fn edit_rejects_empty_old_text() {
let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
let dir = fresh_dir("emptyold");
let path = dir.join("f.txt").to_string_lossy().to_string();
exec_write_file(&substrate, None, &json!({ "path": path, "content": "abc" }))
.await
.unwrap();
for replace_all in [false, true] {
let err = exec_edit_file(
&substrate,
None,
&json!({
"path": path,
"old_text": "",
"new_text": "X",
"replace_all": replace_all
}),
)
.await
.unwrap_err();
assert!(err.contains("old_text must be non-empty"), "{err}");
}
assert_eq!(std::fs::read_to_string(dir.join("f.txt")).unwrap(), "abc");
std::fs::remove_dir_all(&dir).ok();
}
#[tokio::test]
async fn write_existing_rejects_stale_after_external_change() {
let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
let ledger = ReadLedger::new();
let dir = fresh_dir("stalewrite");
let path = dir.join("f.txt").to_string_lossy().to_string();
std::fs::write(dir.join("f.txt"), "original").unwrap();
execute_with_ledger(&substrate, &ledger, "read_file", &json!({ "path": path }))
.await
.unwrap()
.unwrap();
std::fs::write(dir.join("f.txt"), "changed underneath").unwrap();
let err = execute_with_ledger(
&substrate,
&ledger,
"write_file",
&json!({ "path": path, "content": "clobber" }),
)
.await
.unwrap()
.unwrap_err();
assert!(err.contains("changed since you last read it"), "{err}");
let err = execute_with_ledger(
&substrate,
&ledger,
"write_file",
&json!({ "path": path, "content": " + more", "append": true }),
)
.await
.unwrap()
.unwrap_err();
assert!(err.contains("changed since you last read it"), "{err}");
execute_with_ledger(&substrate, &ledger, "read_file", &json!({ "path": path }))
.await
.unwrap()
.unwrap();
execute_with_ledger(
&substrate,
&ledger,
"write_file",
&json!({ "path": path, "content": "rewritten" }),
)
.await
.unwrap()
.unwrap();
assert_eq!(
std::fs::read_to_string(dir.join("f.txt")).unwrap(),
"rewritten"
);
std::fs::remove_dir_all(&dir).ok();
}
#[tokio::test]
async fn replace_all_replaces_every_occurrence() {
let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
let dir = fresh_dir("replaceall");
let path = dir.join("f.txt").to_string_lossy().to_string();
exec_write_file(
&substrate,
None,
&json!({ "path": path, "content": "a x a x a" }),
)
.await
.unwrap();
let err = exec_edit_file(
&substrate,
None,
&json!({ "path": path, "old_text": "a", "new_text": "b" }),
)
.await
.unwrap_err();
assert!(err.contains("replace_all"), "{err}");
let ok = exec_edit_file(
&substrate,
None,
&json!({ "path": path, "old_text": "a", "new_text": "b", "replace_all": true }),
)
.await
.unwrap();
assert_eq!(ok["replacements"], 3);
let r = exec_read_file(&substrate, None, &json!({ "path": path }))
.await
.unwrap();
assert_eq!(r["content"], "b x b x b");
std::fs::remove_dir_all(&dir).ok();
}
#[tokio::test]
async fn write_new_file_allowed() {
let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
let ledger = ReadLedger::new();
let dir = fresh_dir("writenew");
let path = dir.join("new.txt").to_string_lossy().to_string();
let ok = execute_with_ledger(
&substrate,
&ledger,
"write_file",
&json!({ "path": path, "content": "fresh" }),
)
.await
.unwrap()
.unwrap();
assert_eq!(ok["bytes_written"], 5);
std::fs::remove_dir_all(&dir).ok();
}
#[tokio::test]
async fn write_existing_requires_prior_read() {
let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
let ledger = ReadLedger::new();
let dir = fresh_dir("writeexisting");
let path = dir.join("f.txt").to_string_lossy().to_string();
std::fs::write(dir.join("f.txt"), "existing content").unwrap();
let err = execute_with_ledger(
&substrate,
&ledger,
"write_file",
&json!({ "path": path, "content": "clobber" }),
)
.await
.unwrap()
.unwrap_err();
assert!(err.contains("before overwriting it"), "{err}");
assert!(err.contains("read_file"), "{err}");
execute_with_ledger(&substrate, &ledger, "read_file", &json!({ "path": path }))
.await
.unwrap()
.unwrap();
let ok = execute_with_ledger(
&substrate,
&ledger,
"write_file",
&json!({ "path": path, "content": "clobber" }),
)
.await
.unwrap()
.unwrap();
assert_eq!(ok["bytes_written"], 7);
std::fs::remove_dir_all(&dir).ok();
}
#[tokio::test]
async fn write_self_records_enabling_edit() {
let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
let ledger = ReadLedger::new();
let dir = fresh_dir("writeedits");
let path = dir.join("f.txt").to_string_lossy().to_string();
execute_with_ledger(
&substrate,
&ledger,
"write_file",
&json!({ "path": path, "content": "one two three" }),
)
.await
.unwrap()
.unwrap();
let ok = execute_with_ledger(
&substrate,
&ledger,
"edit_file",
&json!({ "path": path, "old_text": "two", "new_text": "TWO" }),
)
.await
.unwrap()
.unwrap();
assert_eq!(ok["replacements"], 1);
std::fs::remove_dir_all(&dir).ok();
}
#[tokio::test]
async fn edit_self_records_enabling_second_edit_without_reread() {
let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
let ledger = ReadLedger::new();
let dir = fresh_dir("editselfrec");
let path = dir.join("f.txt").to_string_lossy().to_string();
std::fs::write(dir.join("f.txt"), "one two three").unwrap();
execute_with_ledger(&substrate, &ledger, "read_file", &json!({ "path": path }))
.await
.unwrap()
.unwrap();
execute_with_ledger(
&substrate,
&ledger,
"edit_file",
&json!({ "path": path, "old_text": "one", "new_text": "1" }),
)
.await
.unwrap()
.unwrap();
let ok = execute_with_ledger(
&substrate,
&ledger,
"edit_file",
&json!({ "path": path, "old_text": "two", "new_text": "2" }),
)
.await
.unwrap()
.unwrap();
assert_eq!(ok["replacements"], 1);
assert_eq!(
std::fs::read_to_string(dir.join("f.txt")).unwrap(),
"1 2 three"
);
std::fs::remove_dir_all(&dir).ok();
}
#[tokio::test]
async fn sliced_read_hashes_full_file_licensing_edit() {
let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
let ledger = ReadLedger::new();
let dir = fresh_dir("slicedread");
let path = dir.join("f.txt").to_string_lossy().to_string();
std::fs::write(dir.join("f.txt"), "l1\nl2\nl3\nl4").unwrap();
let r = execute_with_ledger(
&substrate,
&ledger,
"read_file",
&json!({ "path": path, "offset": 1, "limit": 1 }),
)
.await
.unwrap()
.unwrap();
assert_eq!(r["content"], " 2\tl2");
let ok = execute_with_ledger(
&substrate,
&ledger,
"edit_file",
&json!({ "path": path, "old_text": "l4", "new_text": "L4" }),
)
.await
.unwrap()
.unwrap();
assert_eq!(ok["replacements"], 1);
std::fs::remove_dir_all(&dir).ok();
}
#[tokio::test]
async fn append_to_existing_unread_requires_prior_read() {
let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
let ledger = ReadLedger::new();
let dir = fresh_dir("appendunread");
let path = dir.join("f.txt").to_string_lossy().to_string();
std::fs::write(dir.join("f.txt"), "existing content").unwrap();
let err = execute_with_ledger(
&substrate,
&ledger,
"write_file",
&json!({ "path": path, "content": " more", "append": true }),
)
.await
.unwrap()
.unwrap_err();
assert!(err.contains("before overwriting it"), "{err}");
execute_with_ledger(&substrate, &ledger, "read_file", &json!({ "path": path }))
.await
.unwrap()
.unwrap();
let ok = execute_with_ledger(
&substrate,
&ledger,
"write_file",
&json!({ "path": path, "content": " more", "append": true }),
)
.await
.unwrap()
.unwrap();
assert_eq!(ok["append"], true);
assert_eq!(
std::fs::read_to_string(dir.join("f.txt")).unwrap(),
"existing content more"
);
std::fs::remove_dir_all(&dir).ok();
}
#[tokio::test]
async fn append_to_existing_empty_file_requires_prior_read() {
let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
let ledger = ReadLedger::new();
let dir = fresh_dir("appendempty");
let path = dir.join("f.txt").to_string_lossy().to_string();
std::fs::write(dir.join("f.txt"), "").unwrap();
let err = execute_with_ledger(
&substrate,
&ledger,
"write_file",
&json!({ "path": path, "content": "new", "append": true }),
)
.await
.unwrap()
.unwrap_err();
assert!(err.contains("before overwriting it"), "{err}");
assert!(std::fs::read_to_string(dir.join("f.txt"))
.unwrap()
.is_empty());
std::fs::remove_dir_all(&dir).ok();
}
#[tokio::test]
async fn partial_read_cannot_authorize_whole_file_mutation() {
let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
let ledger = ReadLedger::new();
let dir = fresh_dir("partialread");
let path = dir.join("f.txt").to_string_lossy().to_string();
std::fs::write(dir.join("f.txt"), "first\nsecond\nthird").unwrap();
execute_with_ledger(
&substrate,
&ledger,
"read_file",
&json!({ "path": path, "offset": 0, "limit": 1 }),
)
.await
.unwrap()
.unwrap();
let overwrite = execute_with_ledger(
&substrate,
&ledger,
"write_file",
&json!({ "path": path, "content": "replacement" }),
)
.await
.unwrap()
.unwrap_err();
assert!(overwrite.contains("full current content"), "{overwrite}");
let replace_all = execute_with_ledger(
&substrate,
&ledger,
"edit_file",
&json!({ "path": path, "old_text": "i", "new_text": "I", "replace_all": true }),
)
.await
.unwrap()
.unwrap_err();
assert!(
replace_all.contains("full current content"),
"{replace_all}"
);
let edit = execute_with_ledger(
&substrate,
&ledger,
"edit_file",
&json!({ "path": path, "old_text": "first", "new_text": "FIRST" }),
)
.await
.unwrap()
.unwrap();
assert_eq!(edit["replacements"], 1);
std::fs::remove_dir_all(&dir).ok();
}
#[tokio::test]
async fn guarded_write_refuses_existing_non_utf8_file() {
let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
let ledger = ReadLedger::new();
let dir = fresh_dir("nonutf8write");
let path = dir.join("f.bin").to_string_lossy().to_string();
std::fs::write(dir.join("f.bin"), [0xff, 0x00, 0xfe]).unwrap();
let err = execute_with_ledger(
&substrate,
&ledger,
"write_file",
&json!({ "path": path, "content": "text" }),
)
.await
.unwrap()
.unwrap_err();
assert!(err.contains("cannot modify existing file"), "{err}");
assert_eq!(
std::fs::read(dir.join("f.bin")).unwrap(),
[0xff, 0x00, 0xfe]
);
std::fs::remove_dir_all(&dir).ok();
}
#[tokio::test]
async fn append_detects_stale_after_external_change() {
let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
let ledger = ReadLedger::new();
let dir = fresh_dir("appendstale");
let path = dir.join("f.txt").to_string_lossy().to_string();
std::fs::write(dir.join("f.txt"), "v1").unwrap();
execute_with_ledger(&substrate, &ledger, "read_file", &json!({ "path": path }))
.await
.unwrap()
.unwrap();
std::fs::write(dir.join("f.txt"), "v2 changed").unwrap();
let err = execute_with_ledger(
&substrate,
&ledger,
"write_file",
&json!({ "path": path, "content": " appended", "append": true }),
)
.await
.unwrap()
.unwrap_err();
assert!(err.contains("changed since you last read it"), "{err}");
std::fs::remove_dir_all(&dir).ok();
}
#[tokio::test]
async fn replace_all_edit_is_gated_by_the_ledger() {
let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
let ledger = ReadLedger::new();
let dir = fresh_dir("replaceallgate");
let path = dir.join("f.txt").to_string_lossy().to_string();
std::fs::write(dir.join("f.txt"), "a a a").unwrap();
execute_with_ledger(&substrate, &ledger, "read_file", &json!({ "path": path }))
.await
.unwrap()
.unwrap();
let ok = execute_with_ledger(
&substrate,
&ledger,
"edit_file",
&json!({ "path": path, "old_text": "a", "new_text": "b", "replace_all": true }),
)
.await
.unwrap()
.unwrap();
assert_eq!(ok["replacements"], 3);
let ok2 = execute_with_ledger(
&substrate,
&ledger,
"edit_file",
&json!({ "path": path, "old_text": "b", "new_text": "c", "replace_all": true }),
)
.await
.unwrap()
.unwrap();
assert_eq!(ok2["replacements"], 3);
std::fs::write(dir.join("f.txt"), "c c c c").unwrap();
let err = execute_with_ledger(
&substrate,
&ledger,
"edit_file",
&json!({ "path": path, "old_text": "c", "new_text": "d", "replace_all": true }),
)
.await
.unwrap()
.unwrap_err();
assert!(err.contains("changed since you last read it"), "{err}");
std::fs::remove_dir_all(&dir).ok();
}
#[tokio::test]
async fn builtin_names_never_return_unknown_tool_prefix() {
let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
let ledger = ReadLedger::new();
for name in [
"read_file",
"write_file",
"edit_file",
"list_dir",
"find_files",
"grep_files",
"calculate",
] {
let result = execute_with_ledger(&substrate, &ledger, name, &json!({})).await;
let inner = result.unwrap_or_else(|| panic!("{name} must be handled, not None"));
if let Err(e) = &inner {
assert!(
!e.starts_with("unknown tool"),
"{name} error must not start with 'unknown tool': {e}"
);
}
}
assert!(
execute_with_ledger(&substrate, &ledger, "no_such_tool", &json!({}))
.await
.is_none()
);
let dir = fresh_dir("unknownprefix");
let path = dir.join("f.txt").to_string_lossy().to_string();
std::fs::write(dir.join("f.txt"), "content").unwrap();
let edit_err = execute_with_ledger(
&substrate,
&ledger,
"edit_file",
&json!({ "path": path, "old_text": "content", "new_text": "x" }),
)
.await
.unwrap()
.unwrap_err();
assert!(!edit_err.starts_with("unknown tool"), "{edit_err}");
assert!(edit_err.contains("before editing it"), "{edit_err}");
let write_err = execute_with_ledger(
&substrate,
&ledger,
"write_file",
&json!({ "path": path, "content": "y" }),
)
.await
.unwrap()
.unwrap_err();
assert!(!write_err.starts_with("unknown tool"), "{write_err}");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn ledger_normalizes_dot_components() {
let ledger = ReadLedger::new();
ledger.record("./src/x.rs", "content", true);
assert_eq!(ledger.check("src/x.rs", "content"), ReadState::FreshFull);
assert_eq!(ledger.check("./src/x.rs", "content"), ReadState::FreshFull);
let ledger2 = ReadLedger::new();
ledger2.record("src/x.rs", "content", true);
assert_eq!(ledger2.check("./src/x.rs", "content"), ReadState::FreshFull);
assert_eq!(ledger.check("other.rs", "content"), ReadState::Unread);
let ledger3 = ReadLedger::new();
ledger3.record("/root/a/./b", "c", true);
assert_eq!(ledger3.check("/root/a/b", "c"), ReadState::FreshFull);
}
#[test]
fn session_ledgers_share_mutation_locks_without_sharing_observations() {
let ledgers = SessionReadLedgers::new();
let first = ledgers.ledger_for(Some("first"));
let second = ledgers.ledger_for(Some("second"));
first.record("f.txt", "first view", true);
assert_eq!(
second.check("f.txt", "first view"),
ReadState::Unread,
"one session's read must not authorize another session"
);
assert!(Arc::ptr_eq(
&first.mutation_lock("f.txt"),
&second.mutation_lock("f.txt")
));
}
#[tokio::test]
async fn indentation_error_provides_exact_bytes_for_a_literal_retry_without_mutation() {
let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
let dir = fresh_dir("indentation-retry");
let path = dir.join("test.py").to_string_lossy().to_string();
for (content, old_text, exact) in [
(
"# tests\nif __name__ == '__main__':\n unittest.main()\n",
" if __name__ == '__main__':\n unittest.main()",
"if __name__ == '__main__':\n unittest.main()",
),
(
"# tests\r\nif __name__ == '__main__':\r\n\tunittest.main()\r\n",
" if __name__ == '__main__':\n unittest.main()",
"if __name__ == '__main__':\r\n\tunittest.main()",
),
("α\nβ\n", " β\n", "β\n"),
] {
std::fs::write(&path, content).unwrap();
let ledger = ReadLedger::new();
exec_read_file(&substrate, Some(&ledger), &json!({"path":path}))
.await
.unwrap();
let error = exec_edit_file(
&substrate,
Some(&ledger),
&json!({
"path":path, "old_text":old_text, "new_text":"WRONG"
}),
)
.await
.unwrap_err();
assert_eq!(std::fs::read_to_string(&path).unwrap(), content);
let suggested = error
.split_once(HINT_BEGIN)
.and_then(|(_, rest)| rest.split_once(HINT_END))
.expect(&error)
.0
.to_string();
assert_eq!(suggested, exact);
exec_edit_file(
&substrate,
Some(&ledger),
&json!({
"path":path, "old_text":suggested, "new_text":"replacement"
}),
)
.await
.unwrap();
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
content.replacen(exact, "replacement", 1)
);
}
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn indentation_hints_do_not_guess_among_duplicates_or_unrelated_text() {
assert!(indentation_hint(" call()\n\tcall()\n", " call()").is_none());
assert_eq!(
indentation_hint("a\n call()\n", "call()"),
Some((" call()", 1, 2))
);
assert!(indentation_hint(" call()\n", " different()").is_none());
assert!(indentation_hint(" \n", " \n").is_none());
assert!(indentation_hint(&"line\n".repeat(13), &" line\n".repeat(13)).is_none());
assert!(indentation_hint(&"a".repeat(2048), &format!(" {}", "a".repeat(2048))).is_none());
}
#[tokio::test]
async fn indentation_hints_require_full_fresh_observation_and_a_single_edit() {
let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
let dir = fresh_dir("indentation-guards");
let path = dir.join("f.txt").to_string_lossy().to_string();
let content = "first\ncall()\n";
for (partial, stale, replace_all) in [
(true, false, false),
(false, true, false),
(false, false, true),
] {
std::fs::write(&path, content).unwrap();
let ledger = ReadLedger::new();
let mut read = json!({"path":path});
if partial {
read["limit"] = json!(1);
}
exec_read_file(&substrate, Some(&ledger), &read)
.await
.unwrap();
if stale {
std::fs::write(&path, "changed\ncall()\n").unwrap();
}
let before = std::fs::read_to_string(&path).unwrap();
let error = exec_edit_file(&substrate, Some(&ledger), &json!({
"path":path, "old_text":" call()", "new_text":"WRONG", "replace_all":replace_all
})).await.unwrap_err();
assert!(!error.contains(HINT_BEGIN), "{error}");
assert_eq!(std::fs::read_to_string(&path).unwrap(), before);
}
std::fs::remove_dir_all(dir).unwrap();
}
#[tokio::test]
async fn a_paged_read_keeps_recovery_and_hints_for_the_lines_it_showed() {
let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
let dir = fresh_dir("paged-recovery");
let path = dir.join("big.py").to_string_lossy().to_string();
let content = "a = 1\n b = 2\nc = 3\n d = 4\n";
std::fs::write(&path, content).unwrap();
let ledger = ReadLedger::new();
exec_read_file(
&substrate,
Some(&ledger),
&json!({"path": path, "limit": 2}),
)
.await
.unwrap();
exec_edit_file(
&substrate,
Some(&ledger),
&json!({"path": path, "old_text": " 1\ta = 1", "new_text": "a = 10"}),
)
.await
.unwrap();
exec_read_file(
&substrate,
Some(&ledger),
&json!({"path": path, "limit": 2}),
)
.await
.unwrap();
let error = exec_edit_file(
&substrate,
Some(&ledger),
&json!({"path": path, "old_text": "a = 10\n b = 2", "new_text": "x"}),
)
.await
.unwrap_err();
assert!(
error.contains(&format!("{HINT_BEGIN}a = 10\n b = 2{HINT_END}")),
"{error}"
);
let error = exec_edit_file(
&substrate,
Some(&ledger),
&json!({"path": path, "old_text": "c = 3\n d = 4", "new_text": "x"}),
)
.await
.unwrap_err();
assert!(!error.contains(HINT_BEGIN), "{error}");
let error = exec_edit_file(
&substrate,
Some(&ledger),
&json!({"path": path, "old_text": " 3\tc = 3", "new_text": "x"}),
)
.await
.unwrap_err();
assert!(error.contains("not found"), "{error}");
std::fs::remove_dir_all(dir).unwrap();
}
#[tokio::test]
async fn a_page_keeps_its_recoveries_across_an_edit() {
let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
let dir = fresh_dir("paged-after-edit");
let path = dir.join("f.py").to_string_lossy().to_string();
std::fs::write(&path, "a = 1\nb = 2\nc = 3\nd = 4\ne = 5\n").unwrap();
let ledger = ReadLedger::new();
exec_read_file(
&substrate,
Some(&ledger),
&json!({"path": path, "limit": 4}),
)
.await
.unwrap();
exec_edit_file(
&substrate,
Some(&ledger),
&json!({"path": path, "old_text": "b = 2", "new_text": "b = 2\nb2 = 2"}),
)
.await
.unwrap();
exec_edit_file(
&substrate,
Some(&ledger),
&json!({"path": path, "old_text": " 5\td = 4", "new_text": "d = 40"}),
)
.await
.unwrap();
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
"a = 1\nb = 2\nb2 = 2\nc = 3\nd = 40\ne = 5\n"
);
let err = exec_edit_file(
&substrate,
Some(&ledger),
&json!({"path": path, "old_text": " 6\te = 5", "new_text": "x"}),
)
.await
.unwrap_err();
assert!(err.contains("not found"), "{err}");
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn seen_ranges_move_with_an_edit() {
assert_eq!(
shift_seen_lines(&[(0, 4)], 1, 2, 4),
[(0, 1), (4, 6), (1, 4)]
);
assert_eq!(shift_seen_lines(&[(0, 2)], 5, 6, 8), [(0, 2)]);
assert_eq!(shift_seen_lines(&[(6, 9)], 1, 3, 2), [(5, 8)]);
}
#[tokio::test]
async fn edit_accepts_exact_numbered_display_after_full_fresh_read() {
let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
let dir = fresh_dir("numbered-edit");
let path = dir.join("f.txt").to_string_lossy().to_string();
for content in ["first\n\nlast\n", "first\r\n\r\nlast\r\n", "first\n\nlast"] {
std::fs::write(&path, content).unwrap();
let ledger = ReadLedger::new();
let read = exec_read_file(&substrate, Some(&ledger), &json!({"path":path}))
.await
.unwrap();
let display = read["content"].as_str().unwrap();
let result = exec_edit_file(
&substrate,
Some(&ledger),
&json!({
"path":path, "old_text":display, "new_text":"changed"
}),
)
.await
.unwrap();
assert_eq!(result["removed_display_line_numbers"], true);
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
if content.ends_with('\n') {
"changed\n"
} else {
"changed"
}
);
}
std::fs::write(&path, "α\nβ\tdata\nγ\n").unwrap();
let ledger = ReadLedger::new();
exec_read_file(&substrate, Some(&ledger), &json!({"path":path}))
.await
.unwrap();
exec_edit_file(
&substrate,
Some(&ledger),
&json!({
"path":path, "old_text":" 2\tβ\tdata", "new_text":"δ\tdata"
}),
)
.await
.unwrap();
assert_eq!(std::fs::read_to_string(&path).unwrap(), "α\nδ\tdata\nγ\n");
std::fs::remove_dir_all(dir).unwrap();
}
#[tokio::test]
async fn numbered_edit_keeps_location_freshness_and_uniqueness_guards() {
let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
let dir = fresh_dir("numbered-edit-guards");
let path = dir.join("f.txt").to_string_lossy().to_string();
for (content, old, replace_all) in [
("first\nlast\n", " 1\tlast", false),
("first\nlast\n", " 1\tfirst\n 3\tlast", false),
("first \nlast\n", " 1\tfirst", false),
("same\nsame\n", " 2\tsame", false),
("first\nlast\n", " 1\tfirst", true),
] {
std::fs::write(&path, content).unwrap();
let ledger = ReadLedger::new();
exec_read_file(&substrate, Some(&ledger), &json!({"path":path}))
.await
.unwrap();
assert!(exec_edit_file(
&substrate,
Some(&ledger),
&json!({
"path":path, "old_text":old, "new_text":"changed", "replace_all":replace_all
})
)
.await
.is_err());
assert_eq!(std::fs::read_to_string(&path).unwrap(), content);
}
std::fs::write(&path, "first\nlast\n").unwrap();
let ledger = ReadLedger::new();
exec_read_file(&substrate, Some(&ledger), &json!({"path":path, "limit":1}))
.await
.unwrap();
assert!(exec_edit_file(
&substrate,
Some(&ledger),
&json!({
"path":path, "old_text":" 2\tlast", "new_text":"changed"
})
)
.await
.is_err());
exec_read_file(&substrate, Some(&ledger), &json!({"path":path}))
.await
.unwrap();
std::fs::write(&path, "external change").unwrap();
assert!(exec_edit_file(
&substrate,
Some(&ledger),
&json!({
"path":path, "old_text":" 1\tfirst", "new_text":"changed"
})
)
.await
.unwrap_err()
.contains("changed since"));
std::fs::write(&path, " 1\tliteral").unwrap();
exec_read_file(&substrate, Some(&ledger), &json!({"path":path}))
.await
.unwrap();
let result = exec_edit_file(
&substrate,
Some(&ledger),
&json!({
"path":path, "old_text":" 1\tliteral", "new_text":"changed"
}),
)
.await
.unwrap();
assert_eq!(result["removed_display_line_numbers"], false);
assert_eq!(std::fs::read_to_string(&path).unwrap(), "changed");
std::fs::remove_dir_all(dir).unwrap();
}
#[tokio::test]
async fn a_multi_line_edit_without_carriage_returns_lands_in_a_crlf_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("w.py");
std::fs::write(&path, "a = 1\r\nb = 2\r\nc = 3\r\n").unwrap();
let path = path.to_string_lossy().into_owned();
let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
let ledger = ReadLedger::new();
execute_with_ledger(&substrate, &ledger, "read_file", &json!({ "path": path }))
.await
.unwrap()
.unwrap();
execute_with_ledger(
&substrate,
&ledger,
"edit_file",
&json!({ "path": path, "old_text": "a = 1\nb = 2", "new_text": "a = 1\nb = 20" }),
)
.await
.unwrap()
.unwrap();
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
"a = 1\r\nb = 20\r\nc = 3\r\n"
);
}
#[tokio::test]
async fn edit_no_match_hints_pasted_line_number() {
let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
let dir = fresh_dir("linenumhint");
let path = dir.join("f.txt").to_string_lossy().to_string();
exec_write_file(
&substrate,
None,
&json!({ "path": path, "content": "hello world" }),
)
.await
.unwrap();
let err = exec_edit_file(
&substrate,
None,
&json!({ "path": path, "old_text": " 1\thello world", "new_text": "hi" }),
)
.await
.unwrap_err();
assert!(err.contains("line-number prefixes"), "{err}");
let plain = exec_edit_file(
&substrate,
None,
&json!({ "path": path, "old_text": "absent", "new_text": "x" }),
)
.await
.unwrap_err();
assert!(!plain.contains("line-number prefixes"), "{plain}");
assert!(plain.contains("old_text not found"), "{plain}");
std::fs::remove_dir_all(&dir).ok();
}
#[tokio::test]
async fn calculate_is_pure() {
let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
let out = execute(
&substrate,
"calculate",
&json!({ "expression": "2 + 3 * 4" }),
)
.await
.unwrap()
.unwrap();
assert_eq!(out["result"], 14.0);
}
#[tokio::test]
async fn unknown_tool_returns_none() {
let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
assert!(execute(&substrate, "nope", &json!({})).await.is_none());
}
fn calc(expr: &str) -> f64 {
exec_calculate(&json!({ "expression": expr }))
.unwrap_or_else(|e| panic!("calculate({expr:?}) failed: {e}"))
.get("result")
.and_then(|v| v.as_f64())
.unwrap_or_else(|| panic!("calculate({expr:?}) returned no numeric result"))
}
#[test]
fn calculate_contract_semantics() {
assert_eq!(calc("2 + 3 * 4"), 14.0, "operator precedence");
assert_eq!(calc("(1 + 2) * 3"), 9.0, "parentheses override precedence");
assert_eq!(calc("2^3"), 8.0, "^ is exponentiation, not XOR");
assert_eq!(calc("2^10"), 1024.0, "^ is exponentiation");
assert_eq!(calc("10 % 3"), 1.0, "modulo");
assert_eq!(calc("-5 + 2"), -3.0, "unary minus");
assert_eq!(calc("sin(0)"), 0.0, "native function: sin");
assert_eq!(calc("abs(-3)"), 3.0, "native function: abs");
assert_eq!(calc("sqrt(16)"), 4.0, "shim function: sqrt");
assert!((calc("ln(e)") - 1.0).abs() < 1e-12, "shim: ln + e constant");
assert!(
(calc("pi") - std::f64::consts::PI).abs() < 1e-12,
"shim: pi constant"
);
}
#[test]
fn calculate_rejects_invalid_expression() {
assert!(exec_calculate(&json!({ "expression": "2 +" })).is_err());
assert!(exec_calculate(&json!({})).is_err(), "missing parameter");
}
}