use async_trait::async_trait;
use clap::{CommandFactory, Parser};
use std::path::Path;
use crate::backend::WriteMode;
use crate::interpreter::{ExecResult, OutputData};
use crate::tools::{schema_from_clap, ExecContext, ToolCtx, GlobalFlags, Tool, ToolArgs, ToolSchema};
pub struct Mktemp;
#[derive(Parser, Debug)]
#[command(name = "mktemp", about = "Create temporary file or directory with unique name")]
struct MktempArgs {
#[arg(short = 'd', long = "d")]
directory: bool,
#[arg(short = 'p', long = "p")]
p: Option<String>,
#[arg(short = 't', long = "t")]
t: Option<String>,
#[arg(long)]
template: Option<String>,
#[command(flatten)]
global: GlobalFlags,
#[arg(hide = true)]
rest: Vec<String>,
}
#[async_trait]
impl Tool for Mktemp {
fn name(&self) -> &str {
"mktemp"
}
fn schema(&self) -> ToolSchema {
schema_from_clap(
&MktempArgs::command(),
"mktemp",
"Create temporary file or directory with unique name",
[
("Create temp file", "mktemp"),
("Create temp directory", "mktemp -d"),
("Custom template", "mktemp -t myapp.XXXXXX"),
],
)
}
async fn execute(&self, args: ToolArgs, ctx: &mut dyn ToolCtx) -> ExecResult {
let Some(ctx) = ctx.as_any_mut().downcast_mut::<ExecContext>() else {
return ExecResult::failure(1, "internal error: kernel builtin requires ExecContext");
};
let parsed = match MktempArgs::try_parse_from(
std::iter::once("mktemp".to_string()).chain(args.to_argv()),
) {
Ok(p) => p,
Err(e) => return ExecResult::failure(2, format!("mktemp: {e}")),
};
parsed.global.apply(ctx);
let is_dir = parsed.directory;
let parent_dir = parsed
.p
.clone()
.or_else(|| {
ctx.var("TMPDIR")
.map(|v| crate::interpreter::value_to_string(&v))
})
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "/tmp".to_string());
let template = args
.get_string("t", 0)
.or_else(|| args.get_string("template", 0))
.unwrap_or_else(|| "tmp.XXXXXXXXXX".to_string());
let name = match expand_template(&template) {
Ok(name) => name,
Err(e) => {
return ExecResult::failure(1, format!("mktemp: could not obtain system entropy: {e}"));
}
};
let full_path = format!("{}/{}", parent_dir, name);
let resolved = ctx.resolve_path(&full_path);
if is_dir {
match ctx.backend.mkdir(Path::new(&resolved)).await {
Ok(()) => ExecResult::with_output(OutputData::text(resolved.to_string_lossy().to_string())),
Err(e) => ExecResult::failure(1, format!("mktemp: failed to create directory: {}", e)),
}
} else {
match ctx.backend.write(Path::new(&resolved), &[], WriteMode::CreateNew).await {
Ok(()) => ExecResult::with_output(OutputData::text(resolved.to_string_lossy().to_string())),
Err(e) => ExecResult::failure(1, format!("mktemp: failed to create file: {}", e)),
}
}
}
}
fn expand_template(template: &str) -> Result<String, getrandom::Error> {
let mut result = String::with_capacity(template.len());
let mut x_count = 0;
for ch in template.chars() {
if ch == 'X' {
x_count += 1;
} else {
if x_count > 0 {
result.push_str(&random_suffix(x_count)?);
x_count = 0;
}
result.push(ch);
}
}
if x_count > 0 {
result.push_str(&random_suffix(x_count)?);
}
Ok(result)
}
fn random_suffix(len: usize) -> Result<String, getrandom::Error> {
const CHARS: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789";
let mut entropy = vec![0u8; len];
getrandom::fill(&mut entropy)?;
Ok(entropy
.iter()
.map(|b| CHARS[(*b as usize) % CHARS.len()] as char)
.collect())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ast::Value;
use crate::vfs::{MemoryFs, VfsRouter};
use std::sync::Arc;
fn make_ctx() -> ExecContext {
let mut vfs = VfsRouter::new();
vfs.mount("/", MemoryFs::new());
vfs.mount("/tmp", MemoryFs::new());
ExecContext::new(Arc::new(vfs))
}
#[test]
fn test_expand_template_suffix() {
let result = expand_template("tmp.XXXXXX").expect("entropy");
assert!(result.starts_with("tmp."));
assert_eq!(result.len(), 10);
assert!(result[4..].chars().all(|c| c.is_ascii_alphanumeric()));
}
#[test]
fn test_expand_template_middle() {
let result = expand_template("app.XXX.tmp").expect("entropy");
assert!(result.starts_with("app."));
assert!(result.ends_with(".tmp"));
assert!(result[4..7].chars().all(|c| c.is_ascii_alphanumeric()));
}
#[test]
fn test_expand_template_no_x() {
let result = expand_template("fixed.txt").expect("entropy");
assert_eq!(result, "fixed.txt");
}
#[test]
fn test_expand_template_multiple_groups() {
let result = expand_template("X_XXX_X").expect("entropy");
assert_eq!(result.len(), "X_XXX_X".len());
let chars: Vec<char> = result.chars().collect();
assert_eq!(chars[1], '_');
assert_eq!(chars[5], '_');
assert!(chars[0].is_ascii_alphanumeric());
assert!(chars[2..5].iter().all(|c| c.is_ascii_alphanumeric()));
assert!(chars[6].is_ascii_alphanumeric());
}
#[test]
fn test_random_suffix_is_unique() {
let mut seen = std::collections::HashSet::new();
for _ in 0..1000 {
let suffix = random_suffix(12).expect("entropy");
assert_eq!(suffix.len(), 12);
assert!(suffix.chars().all(|c| c.is_ascii_alphanumeric()));
assert!(seen.insert(suffix), "random_suffix produced a duplicate");
}
}
#[tokio::test]
async fn test_mktemp_creates_file() {
let mut ctx = make_ctx();
let args = ToolArgs::new();
let result = Mktemp.execute(args, &mut ctx).await;
assert!(result.ok());
assert!(result.text_out().starts_with("/tmp/tmp."));
let stat = ctx.backend.stat(Path::new(&*result.text_out())).await;
assert!(stat.is_ok());
assert_eq!(stat.unwrap().is_file(), true);
}
#[tokio::test]
async fn test_mktemp_creates_directory() {
let mut ctx = make_ctx();
let mut args = ToolArgs::new();
args.flags.insert("d".to_string());
let result = Mktemp.execute(args, &mut ctx).await;
assert!(result.ok());
assert!(result.text_out().starts_with("/tmp/tmp."));
let stat = ctx.backend.stat(Path::new(&*result.text_out())).await;
assert!(stat.is_ok());
assert_eq!(stat.unwrap().is_dir(), true);
}
#[tokio::test]
async fn test_mktemp_custom_parent() {
let mut ctx = make_ctx();
ctx.backend.mkdir(Path::new("/workspace")).await.unwrap();
let mut args = ToolArgs::new();
args.named
.insert("p".to_string(), Value::String("/workspace".into()));
let result = Mktemp.execute(args, &mut ctx).await;
assert!(result.ok());
assert!(result.text_out().starts_with("/workspace/tmp."));
}
#[tokio::test]
async fn test_mktemp_custom_template() {
let mut ctx = make_ctx();
let mut args = ToolArgs::new();
args.named
.insert("t".to_string(), Value::String("myapp.XXXXX".into()));
let result = Mktemp.execute(args, &mut ctx).await;
assert!(result.ok());
assert!(result.text_out().starts_with("/tmp/myapp."));
assert_eq!(result.text_out().len(), "/tmp/myapp.".len() + 5);
}
#[tokio::test]
async fn test_mktemp_honors_tmpdir() {
let mut ctx = make_ctx();
ctx.backend.mkdir(Path::new("/customtmp")).await.unwrap();
ctx.scope.set("TMPDIR", Value::String("/customtmp".into()));
let result = Mktemp.execute(ToolArgs::new(), &mut ctx).await;
assert!(result.ok());
assert!(
result.text_out().starts_with("/customtmp/tmp."),
"expected $TMPDIR to steer the temp file, got {}",
result.text_out()
);
}
#[tokio::test]
async fn test_mktemp_p_overrides_tmpdir() {
let mut ctx = make_ctx();
ctx.backend.mkdir(Path::new("/customtmp")).await.unwrap();
ctx.backend.mkdir(Path::new("/explicit")).await.unwrap();
ctx.scope.set("TMPDIR", Value::String("/customtmp".into()));
let mut args = ToolArgs::new();
args.named
.insert("p".to_string(), Value::String("/explicit".into()));
let result = Mktemp.execute(args, &mut ctx).await;
assert!(result.ok());
assert!(
result.text_out().starts_with("/explicit/tmp."),
"expected -p to win over $TMPDIR, got {}",
result.text_out()
);
}
#[tokio::test]
async fn test_mktemp_unique_names() {
let mut ctx = make_ctx();
let result1 = Mktemp.execute(ToolArgs::new(), &mut ctx).await;
let result2 = Mktemp.execute(ToolArgs::new(), &mut ctx).await;
assert!(result1.ok());
assert!(result2.ok());
assert_ne!(&*result1.text_out(), &*result2.text_out());
}
}