use std::collections::HashMap;
use std::path::{Component, Path, PathBuf};
use std::sync::Arc;
use crate::ast::Value;
use crate::backend::{KernelBackend, LocalBackend};
use crate::dispatch::PipelinePosition;
use crate::ignore_config::IgnoreConfig;
use crate::interpreter::{ExecResult, Scope};
use crate::nonce::NonceStore;
use crate::output_limit::OutputLimitConfig;
use crate::scheduler::{JobManager, PipeReader, PipeWriter, StderrStream};
use crate::tools::ToolRegistry;
use crate::trash::TrashBackend;
use crate::vfs::VfsRouter;
use kaish_vfs::ByteBudget;
use tokio::sync::oneshot;
use tokio_util::sync::CancellationToken;
use crate::interpreter::OutputFormat;
use super::traits::ToolSchema;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum OutputContext {
#[default]
Interactive,
Piped,
Model,
Script,
}
pub struct ExecContext {
pub backend: Arc<dyn KernelBackend>,
pub scope: Scope,
pub cwd: PathBuf,
pub prev_cwd: Option<PathBuf>,
pub stdin: Option<String>,
pub stdin_data: Option<Value>,
pub stdin_data_rx: Option<oneshot::Receiver<Option<Value>>>,
pub pipe_stdin: Option<PipeReader>,
pub pipe_stdout: Option<PipeWriter>,
pub tool_schemas: Arc<[ToolSchema]>,
pub tools: Option<Arc<ToolRegistry>>,
pub job_manager: Option<Arc<JobManager>>,
pub stderr: Option<StderrStream>,
pub pipeline_position: PipelinePosition,
pub interactive: bool,
pub aliases: HashMap<String, String>,
pub ignore_config: IgnoreConfig,
pub output_limit: OutputLimitConfig,
pub allow_external_commands: bool,
pub nonce_store: NonceStore,
pub trash_backend: Option<Arc<dyn TrashBackend>>,
#[cfg(all(unix, feature = "subprocess"))]
pub terminal_state: Option<std::sync::Arc<crate::terminal::TerminalState>>,
pub dispatcher: Option<Arc<dyn crate::dispatch::CommandDispatcher>>,
pub cancel: CancellationToken,
pub output_format: Option<OutputFormat>,
pub current_invocation: Option<Box<(String, Vec<String>)>>,
pub vfs_budget: Option<Arc<ByteBudget>>,
pub watchdog: Option<Arc<crate::watchdog::Watchdog>>,
#[cfg(all(feature = "localfs", feature = "overlay"))]
pub overlay_handle: Option<Arc<crate::kernel::OverlayHandle>>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum MutationAction {
Proceed,
TrashFirst,
Latch,
}
pub(crate) type GateSnapshots = std::collections::HashMap<PathBuf, Vec<u8>>;
pub(crate) fn is_trash_excluded(real_path: Option<&Path>) -> bool {
matches!(real_path, Some(rp) if rp.starts_with("/tmp"))
}
pub(crate) fn decide_mutation_action(
trash_enabled: bool,
latch_enabled: bool,
real_path: Option<&Path>,
target_exists: bool,
is_append: bool,
file_size: u64,
trash_max_size: u64,
) -> MutationAction {
if !target_exists || is_append {
return MutationAction::Proceed;
}
if is_trash_excluded(real_path) {
return MutationAction::Proceed;
}
if trash_enabled && file_size <= trash_max_size {
return MutationAction::TrashFirst;
}
if latch_enabled {
return MutationAction::Latch;
}
MutationAction::Proceed
}
pub(crate) async fn cas_overwrite(
backend: &dyn KernelBackend,
resolved: &Path,
content: &[u8],
expected: Option<&[u8]>,
) -> Result<(), crate::backend::BackendError> {
if let Some(exp) = expected {
let current = backend.read(resolved, None).await?;
if current != exp {
return Err(crate::backend::BackendError::InvalidOperation(
"file changed since the write-model gate checked it (concurrent write); \
aborting overwrite"
.to_string(),
));
}
}
backend
.write(resolved, content, crate::backend::WriteMode::Overwrite)
.await
}
impl ExecContext {
pub fn new(vfs: Arc<VfsRouter>) -> Self {
Self {
backend: Arc::new(LocalBackend::new(vfs)),
scope: Scope::new(),
cwd: PathBuf::from("/"),
prev_cwd: None,
stdin: None,
stdin_data: None,
stdin_data_rx: None,
pipe_stdin: None,
pipe_stdout: None,
stderr: None,
tool_schemas: Vec::new().into(),
tools: None,
job_manager: None,
pipeline_position: PipelinePosition::Only,
interactive: false,
aliases: HashMap::new(),
ignore_config: IgnoreConfig::none(),
output_limit: OutputLimitConfig::none(),
allow_external_commands: true,
nonce_store: NonceStore::new(),
trash_backend: None,
#[cfg(all(unix, feature = "subprocess"))]
terminal_state: None,
dispatcher: None,
cancel: CancellationToken::new(),
output_format: None,
current_invocation: None,
vfs_budget: None,
watchdog: None,
#[cfg(all(feature = "localfs", feature = "overlay"))]
overlay_handle: None,
}
}
pub fn with_vfs_and_tools(vfs: Arc<VfsRouter>, tools: Arc<ToolRegistry>) -> Self {
Self {
backend: Arc::new(LocalBackend::with_tools(vfs, tools.clone())),
scope: Scope::new(),
cwd: PathBuf::from("/"),
prev_cwd: None,
stdin: None,
stdin_data: None,
stdin_data_rx: None,
pipe_stdin: None,
pipe_stdout: None,
stderr: None,
tool_schemas: Vec::new().into(),
tools: Some(tools),
job_manager: None,
pipeline_position: PipelinePosition::Only,
interactive: false,
aliases: HashMap::new(),
ignore_config: IgnoreConfig::none(),
output_limit: OutputLimitConfig::none(),
allow_external_commands: true,
nonce_store: NonceStore::new(),
trash_backend: None,
#[cfg(all(unix, feature = "subprocess"))]
terminal_state: None,
dispatcher: None,
cancel: CancellationToken::new(),
output_format: None,
current_invocation: None,
vfs_budget: None,
watchdog: None,
#[cfg(all(feature = "localfs", feature = "overlay"))]
overlay_handle: None,
}
}
pub fn with_backend(backend: Arc<dyn KernelBackend>) -> Self {
Self {
backend,
scope: Scope::new(),
cwd: PathBuf::from("/"),
prev_cwd: None,
stdin: None,
stdin_data: None,
stdin_data_rx: None,
pipe_stdin: None,
pipe_stdout: None,
stderr: None,
tool_schemas: Vec::new().into(),
tools: None,
job_manager: None,
pipeline_position: PipelinePosition::Only,
interactive: false,
aliases: HashMap::new(),
ignore_config: IgnoreConfig::none(),
output_limit: OutputLimitConfig::none(),
allow_external_commands: true,
nonce_store: NonceStore::new(),
trash_backend: None,
#[cfg(all(unix, feature = "subprocess"))]
terminal_state: None,
dispatcher: None,
cancel: CancellationToken::new(),
output_format: None,
current_invocation: None,
vfs_budget: None,
watchdog: None,
#[cfg(all(feature = "localfs", feature = "overlay"))]
overlay_handle: None,
}
}
pub fn with_vfs_tools_and_scope(vfs: Arc<VfsRouter>, tools: Arc<ToolRegistry>, scope: Scope) -> Self {
Self {
backend: Arc::new(LocalBackend::with_tools(vfs, tools.clone())),
scope,
cwd: PathBuf::from("/"),
prev_cwd: None,
stdin: None,
stdin_data: None,
stdin_data_rx: None,
pipe_stdin: None,
pipe_stdout: None,
stderr: None,
tool_schemas: Vec::new().into(),
tools: Some(tools),
job_manager: None,
pipeline_position: PipelinePosition::Only,
interactive: false,
aliases: HashMap::new(),
ignore_config: IgnoreConfig::none(),
output_limit: OutputLimitConfig::none(),
allow_external_commands: true,
nonce_store: NonceStore::new(),
trash_backend: None,
#[cfg(all(unix, feature = "subprocess"))]
terminal_state: None,
dispatcher: None,
cancel: CancellationToken::new(),
output_format: None,
current_invocation: None,
vfs_budget: None,
watchdog: None,
#[cfg(all(feature = "localfs", feature = "overlay"))]
overlay_handle: None,
}
}
pub fn with_scope(vfs: Arc<VfsRouter>, scope: Scope) -> Self {
Self {
backend: Arc::new(LocalBackend::new(vfs)),
scope,
cwd: PathBuf::from("/"),
prev_cwd: None,
stdin: None,
stdin_data: None,
stdin_data_rx: None,
pipe_stdin: None,
pipe_stdout: None,
stderr: None,
tool_schemas: Vec::new().into(),
tools: None,
job_manager: None,
pipeline_position: PipelinePosition::Only,
interactive: false,
aliases: HashMap::new(),
ignore_config: IgnoreConfig::none(),
output_limit: OutputLimitConfig::none(),
allow_external_commands: true,
nonce_store: NonceStore::new(),
trash_backend: None,
#[cfg(all(unix, feature = "subprocess"))]
terminal_state: None,
dispatcher: None,
cancel: CancellationToken::new(),
output_format: None,
current_invocation: None,
vfs_budget: None,
watchdog: None,
#[cfg(all(feature = "localfs", feature = "overlay"))]
overlay_handle: None,
}
}
pub fn with_backend_and_scope(backend: Arc<dyn KernelBackend>, scope: Scope) -> Self {
Self {
backend,
scope,
cwd: PathBuf::from("/"),
prev_cwd: None,
stdin: None,
stdin_data: None,
stdin_data_rx: None,
pipe_stdin: None,
pipe_stdout: None,
stderr: None,
tool_schemas: Vec::new().into(),
tools: None,
job_manager: None,
pipeline_position: PipelinePosition::Only,
interactive: false,
aliases: HashMap::new(),
ignore_config: IgnoreConfig::none(),
output_limit: OutputLimitConfig::none(),
allow_external_commands: true,
nonce_store: NonceStore::new(),
trash_backend: None,
#[cfg(all(unix, feature = "subprocess"))]
terminal_state: None,
dispatcher: None,
cancel: CancellationToken::new(),
output_format: None,
current_invocation: None,
vfs_budget: None,
watchdog: None,
#[cfg(all(feature = "localfs", feature = "overlay"))]
overlay_handle: None,
}
}
pub fn set_tool_schemas(&mut self, schemas: Vec<ToolSchema>) {
self.tool_schemas = schemas.into();
}
pub fn set_tools(&mut self, tools: Arc<ToolRegistry>) {
self.tools = Some(tools);
}
pub fn set_job_manager(&mut self, manager: Arc<JobManager>) {
self.job_manager = Some(manager);
}
pub fn set_trash_backend(&mut self, backend: Arc<dyn TrashBackend>) {
self.trash_backend = Some(backend);
}
pub fn set_stdin(&mut self, stdin: String) {
self.stdin = Some(stdin);
self.pipe_stdin = None;
}
pub fn take_stdin(&mut self) -> Option<String> {
self.stdin.take()
}
pub fn set_stdin_with_data(&mut self, text: String, data: Option<Value>) {
self.stdin = Some(text);
self.stdin_data = data;
}
pub fn take_stdin_data(&mut self) -> Option<Value> {
self.stdin_data.take()
}
pub async fn resolve_stdin(&mut self) -> Result<(Option<Value>, String), String> {
if let Some(data) = self.stdin_data.take() {
return Ok((Some(data), String::new()));
}
let text = self.read_stdin_to_text().await?.unwrap_or_default();
if let Some(rx) = self.stdin_data_rx.take()
&& let Ok(Some(data)) = rx.await
{
return Ok((Some(data), text));
}
Ok((None, text))
}
pub fn resolve_path(&self, path: &str) -> PathBuf {
let raw = if path.starts_with('/') {
PathBuf::from(path)
} else {
self.cwd.join(path)
};
normalize_path(&raw)
}
pub fn set_cwd(&mut self, path: PathBuf) {
self.prev_cwd = Some(self.cwd.clone());
self.cwd = path;
}
pub fn get_prev_cwd(&self) -> Option<&PathBuf> {
self.prev_cwd.as_ref()
}
pub async fn read_stdin_to_string(&mut self) -> Option<String> {
if let Some(mut reader) = self.pipe_stdin.take() {
use tokio::io::AsyncReadExt;
let mut buf = Vec::new();
reader.read_to_end(&mut buf).await.ok()?;
Some(String::from_utf8_lossy(&buf).into_owned())
} else {
self.stdin.take()
}
}
pub async fn read_stdin_to_text(&mut self) -> Result<Option<String>, String> {
match self.read_stdin_to_bytes().await {
None => Ok(None),
Some(bytes) => String::from_utf8(bytes).map(Some).map_err(|_| {
"input is not valid UTF-8 (binary data?) — pipe through base64/xxd \
or use a binary-aware tool (cat, dd, cmp, wc -c)"
.to_string()
}),
}
}
pub async fn read_stdin_to_bytes(&mut self) -> Option<Vec<u8>> {
if let Some(mut reader) = self.pipe_stdin.take() {
use tokio::io::AsyncReadExt;
let mut buf = Vec::new();
reader.read_to_end(&mut buf).await.ok()?;
Some(buf)
} else {
self.stdin.take().map(String::into_bytes)
}
}
pub fn child_for_pipeline(&self) -> Self {
Self {
backend: self.backend.clone(),
scope: self.scope.clone(),
cwd: self.cwd.clone(),
prev_cwd: self.prev_cwd.clone(),
stdin: None,
stdin_data: None,
stdin_data_rx: None,
pipe_stdin: None,
pipe_stdout: None,
stderr: self.stderr.clone(),
tool_schemas: self.tool_schemas.clone(),
tools: self.tools.clone(),
job_manager: self.job_manager.clone(),
pipeline_position: PipelinePosition::Only,
interactive: self.interactive,
aliases: self.aliases.clone(),
ignore_config: self.ignore_config.clone(),
output_limit: self.output_limit.clone(),
allow_external_commands: self.allow_external_commands,
nonce_store: self.nonce_store.clone(),
trash_backend: self.trash_backend.clone(),
#[cfg(all(unix, feature = "subprocess"))]
terminal_state: self.terminal_state.clone(),
dispatcher: self.dispatcher.clone(),
cancel: self.cancel.clone(),
output_format: None,
current_invocation: None,
vfs_budget: self.vfs_budget.clone(),
watchdog: self.watchdog.clone(),
#[cfg(all(feature = "localfs", feature = "overlay"))]
overlay_handle: self.overlay_handle.clone(),
}
}
pub async fn build_ignore_filter(&self, root: &std::path::Path) -> Option<crate::walker::IgnoreFilter> {
use crate::backend_walker_fs::BackendWalkerFs;
let fs = BackendWalkerFs(self.backend.as_ref());
self.ignore_config.build_filter(root, &fs).await
}
pub fn verify_nonce(&self, nonce: &str, command: &str, paths: &[&str]) -> Result<(), String> {
self.nonce_store.validate(nonce, command, paths)
}
pub fn latch_result(
&self,
command: &str,
paths: &[&str],
reason: &str,
confirm_hint: impl FnOnce(&str) -> String,
) -> ExecResult {
let nonce = self.nonce_store.issue(command, paths);
let ttl = self.nonce_store.ttl().as_secs();
let authorized = if paths.is_empty() {
String::new()
} else {
format!("\nAuthorized: {}", paths.join(", "))
};
let hint = confirm_hint(&nonce);
let mut result = ExecResult::failure(2, format!(
"{command}: confirmation required ({reason}){authorized}\nTo confirm, run: {hint}\nNonce expires in {ttl} seconds."
));
let (tool, argv) = self.current_invocation.as_deref().cloned().unwrap_or_default();
result.latch = Some(Box::new(crate::interpreter::LatchRequest {
nonce,
command: command.to_string(),
paths: paths.iter().map(|p| (*p).to_string()).collect(),
hint,
tool,
argv,
ttl,
job_id: None,
}));
result
}
pub async fn gate_overwrites(
&mut self,
command: &str,
targets: &[(String, bool)],
confirm: Option<&str>,
confirm_hint: impl FnOnce(&str, &str) -> String,
) -> Result<GateSnapshots, ExecResult> {
let trash_enabled = self.scope.trash_enabled();
let latch_enabled = self.scope.latch_enabled();
if !trash_enabled && !latch_enabled {
return Ok(GateSnapshots::new());
}
let trash_max_size = self.scope.trash_max_size();
struct Decided {
display: String,
resolved: PathBuf,
action: MutationAction,
}
let mut seen = std::collections::HashSet::new();
let mut decided = Vec::with_capacity(targets.len());
for (display, is_append) in targets {
let resolved = self.resolve_path(display);
if !seen.insert(resolved.clone()) {
continue;
}
let real = self.backend.resolve_real_path(Path::new(&resolved));
let exists = self.backend.exists(Path::new(&resolved)).await;
let size = if exists {
self.backend
.stat(Path::new(&resolved))
.await
.map(|e| e.size)
.unwrap_or(0)
} else {
0
};
let action = decide_mutation_action(
trash_enabled,
latch_enabled,
real.as_deref(),
exists,
*is_append,
size,
trash_max_size,
);
decided.push(Decided { display: display.clone(), resolved, action });
}
let latched: Vec<&str> = decided
.iter()
.filter(|d| matches!(d.action, MutationAction::Latch))
.map(|d| d.display.as_str())
.collect();
if !latched.is_empty() {
match confirm {
Some(nonce) => {
if let Err(e) = self.verify_nonce(nonce, command, &latched) {
return Err(ExecResult::failure(1, format!("{command}: {e}")));
}
}
None => {
let joined = latched.join(" ");
return Err(self.latch_result(command, &latched, "latch enabled", |nonce| {
confirm_hint(nonce, &joined)
}));
}
}
}
let mut snapshots = GateSnapshots::new();
for d in &decided {
if matches!(d.action, MutationAction::TrashFirst) {
match self.snapshot_for_overwrite(&d.display, &d.resolved).await {
Ok(bytes) => {
snapshots.insert(d.resolved.clone(), bytes);
}
Err(e) => return Err(ExecResult::failure(1, format!("{command}: {e}"))),
}
}
}
Ok(snapshots)
}
async fn snapshot_for_overwrite(
&self,
display: &str,
resolved: &Path,
) -> Result<Vec<u8>, String> {
let trash = self
.trash_backend
.as_ref()
.ok_or_else(|| "trash backend not available".to_string())?;
let bytes = self
.backend
.read(resolved, None)
.await
.map_err(|e| format!("{display}: {e}"))?;
trash
.trash_bytes(Path::new(display), &bytes)
.await
.map_err(|e| format!("{display}: trash failed: {e}"))?;
Ok(bytes)
}
pub(crate) async fn overwrite_checked(
&self,
resolved: &Path,
content: &[u8],
expected: Option<&[u8]>,
) -> Result<(), String> {
cas_overwrite(&*self.backend, resolved, content, expected)
.await
.map_err(|e| e.to_string())
}
pub async fn expand_glob(&self, pattern: &str) -> Result<Vec<PathBuf>, String> {
use crate::backend_walker_fs::BackendWalkerFs;
use crate::walker::{EntryTypes, FileWalker, GlobPath, WalkOptions};
let glob = GlobPath::new(pattern).map_err(|e| format!("invalid pattern: {}", e))?;
let root = if glob.is_anchored() {
self.resolve_path("/")
} else {
self.resolve_path(".")
};
let options = WalkOptions {
entry_types: EntryTypes::all(),
respect_gitignore: self.ignore_config.auto_gitignore(),
..WalkOptions::default()
};
let fs = BackendWalkerFs(self.backend.as_ref());
let mut walker = FileWalker::new(&fs, &root)
.with_pattern(glob)
.with_options(options);
if let Some(filter) = self.ignore_config.build_filter(&root, &fs).await {
walker = walker.with_ignore(filter);
}
walker.collect().await.map_err(|e| e.to_string())
}
pub async fn expand_paths(&self, positional: &[Value]) -> Result<Vec<String>, String> {
let mut paths = Vec::new();
for arg in positional {
let s = match arg {
Value::String(s) => s.clone(),
Value::Int(n) => n.to_string(),
Value::Float(f) => f.to_string(),
Value::Bytes(_) => {
crate::interpreter::value_to_text_sink_named(arg, "a path").map_err(|e| e.to_string())?
}
Value::Json(_) => {
return Err(crate::interpreter::structured_boundary_error("a path", arg)
.unwrap_or_else(|| "cannot use this value as a path".to_string()));
}
Value::Bool(b) => return Err(format!("cannot use a bool ({b}) as a path")),
Value::Null => return Err("cannot use null as a path".to_string()),
};
if crate::glob::contains_glob(&s) {
let expanded = self.expand_glob(&s).await?;
let root = self.resolve_path(".");
for p in expanded {
let rel = p.strip_prefix(&root).unwrap_or(&p);
paths.push(rel.to_string_lossy().to_string());
}
} else {
paths.push(s);
}
}
Ok(paths)
}
pub const STREAM_CHUNK_SIZE: u64 = 256 * 1024;
pub async fn read_file_chunked<F>(
&self,
path: &std::path::Path,
chunk_size: u64,
mut f: F,
) -> kaish_types::backend::BackendResult<()>
where
F: FnMut(&[u8]) -> std::ops::ControlFlow<()>,
{
use kaish_types::ReadRange;
let mut offset = 0u64;
loop {
let chunk = self
.backend
.read(path, Some(ReadRange::bytes(offset, chunk_size)))
.await?;
if chunk.is_empty() {
break;
}
offset += chunk.len() as u64;
if f(&chunk).is_break() {
break;
}
}
Ok(())
}
}
impl kaish_tool_api::ToolCtx for ExecContext {
fn backend(&self) -> &Arc<dyn KernelBackend> {
&self.backend
}
fn cwd(&self) -> &std::path::Path {
self.cwd.as_path()
}
fn resolve_path(&self, path: &str) -> PathBuf {
ExecContext::resolve_path(self, path)
}
fn var(&self, name: &str) -> Option<Value> {
self.scope.get(name).cloned()
}
fn set_var(&mut self, name: &str, value: Value) {
self.scope.set(name, value);
}
fn set_output_format(&mut self, format: OutputFormat) {
self.output_format = Some(format);
}
fn patient(&self, budget: std::time::Duration) -> kaish_tool_api::PatientGuard {
match &self.watchdog {
Some(watchdog) => kaish_tool_api::PatientGuard::held(Box::new(watchdog.hold(budget))),
None => kaish_tool_api::PatientGuard::inert(),
}
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
self
}
}
fn normalize_path(path: &std::path::Path) -> PathBuf {
let mut parts: Vec<Component> = Vec::new();
for component in path.components() {
match component {
Component::CurDir => {} Component::ParentDir => {
if let Some(Component::Normal(_)) = parts.last() {
parts.pop();
} else {
parts.push(component);
}
}
_ => parts.push(component),
}
}
if parts.is_empty() {
PathBuf::from("/")
} else {
parts.iter().collect()
}
}
#[cfg(test)]
mod tests {
use super::{decide_mutation_action, MutationAction};
use std::path::Path;
fn decide(
trash: bool,
latch: bool,
real: Option<&str>,
exists: bool,
append: bool,
) -> MutationAction {
decide_mutation_action(trash, latch, real.map(Path::new), exists, append, 1, 10_000_000)
}
#[test]
fn new_file_and_append_always_proceed() {
assert_eq!(decide(true, true, Some("/work/new"), false, false), MutationAction::Proceed);
assert_eq!(decide(true, true, Some("/work/log"), true, true), MutationAction::Proceed);
}
#[test]
fn trash_wins_over_latch_on_existing_file() {
assert_eq!(decide(true, true, Some("/work/f"), true, false), MutationAction::TrashFirst);
assert_eq!(decide(true, false, Some("/work/f"), true, false), MutationAction::TrashFirst);
}
#[test]
fn latch_gates_when_trash_off() {
assert_eq!(decide(false, true, Some("/work/f"), true, false), MutationAction::Latch);
}
#[test]
fn both_gates_off_proceeds() {
assert_eq!(decide(false, false, Some("/work/f"), true, false), MutationAction::Proceed);
}
#[test]
fn tmp_bypasses_gate_but_real_v_path_stays_gated() {
assert_eq!(decide(true, true, Some("/tmp/scratch"), true, false), MutationAction::Proceed);
assert_eq!(decide(true, true, Some("/v/cas/blob.bin"), true, false), MutationAction::TrashFirst);
}
#[test]
fn overlay_no_real_path_stays_gated() {
assert_eq!(decide(true, true, None, true, false), MutationAction::TrashFirst);
assert_eq!(decide(false, true, None, true, false), MutationAction::Latch);
}
#[test]
fn file_too_big_to_trash_falls_through_like_rm() {
let big = 100u64;
let cap = 10u64;
assert_eq!(
decide_mutation_action(true, true, Some(Path::new("/work/f")), true, false, big, cap),
MutationAction::Latch
);
assert_eq!(
decide_mutation_action(true, false, Some(Path::new("/work/f")), true, false, big, cap),
MutationAction::Proceed
);
assert_eq!(
decide_mutation_action(true, false, Some(Path::new("/work/f")), true, false, cap, cap),
MutationAction::TrashFirst
);
}
}