doot/crates/doot-lang/src/builtins/io.rs
Ray Andrew 0eb4d38392
feat(utils): introduce doot-utils crate with XDG directory helpers
- Add new `doot-utils` crate with `xdg` module for consistent cross-platform directory resolution
- Replace `dirs` crate usage with `doot_utils::xdg` functions in cli, core, and lang crates
- Use XDG layout on all platforms (including macOS) for config, data, cache, and state directories
- Add home_dir(), config_home(), data_home(), cache_home(), and state_home() helpers
- Update dependencies to use doot-utils workspace reference
- Remove unused dirs and related crates from Cargo.lock
- Improve error handling in template rendering and package installation
- Add InstalledCache for package managers to reduce process spawning
- Optimize brew package installation with parallel fetching and sequential installing
- Fix path canonicalization in e2e tests for consistent symlink handling
- Add clippy allowance for large parser errors in doot-lang
2026-06-09 23:24:46 -07:00

428 lines
14 KiB
Rust

use crate::evaluator::{EvalError, Value};
use std::path::PathBuf;
use std::process::Command;
use walkdir::WalkDir;
#[tracing::instrument(level = "trace", skip_all)]
pub fn read_file(args: &[Value]) -> Result<Value, EvalError> {
let path = get_path(args)?;
let content = std::fs::read_to_string(&path)?;
Ok(Value::Str(content))
}
#[tracing::instrument(level = "trace", skip_all)]
pub fn read_file_lines(args: &[Value]) -> Result<Value, EvalError> {
let path = get_path(args)?;
let content = std::fs::read_to_string(&path)?;
let lines: Vec<Value> = content.lines().map(|l| Value::Str(l.to_string())).collect();
Ok(Value::List(lines))
}
#[tracing::instrument(level = "trace", skip_all)]
pub fn write_file(args: &[Value]) -> Result<Value, EvalError> {
let path = get_path(args)?;
let content = match args.get(1) {
Some(Value::Str(s)) => s,
_ => {
return Err(EvalError::TypeError(
"write_file requires content string".to_string(),
));
}
};
std::fs::write(&path, content)?;
Ok(Value::Bool(true))
}
#[tracing::instrument(level = "trace", skip_all)]
pub fn copy_file(args: &[Value]) -> Result<Value, EvalError> {
let src = get_path(args)?;
let dst = match args.get(1) {
Some(Value::Path(p)) => p.clone(),
Some(Value::Str(s)) => expand_path(s),
_ => {
return Err(EvalError::TypeError(
"copy_file requires destination path".to_string(),
));
}
};
std::fs::copy(&src, &dst)?;
Ok(Value::Bool(true))
}
#[tracing::instrument(level = "trace", skip_all)]
pub fn delete_file(args: &[Value]) -> Result<Value, EvalError> {
let path = get_path(args)?;
std::fs::remove_file(&path)?;
Ok(Value::Bool(true))
}
#[tracing::instrument(level = "trace", skip_all)]
pub fn file_exists(args: &[Value]) -> Result<Value, EvalError> {
let path = get_path(args)?;
Ok(Value::Bool(path.is_file()))
}
#[tracing::instrument(level = "trace", skip_all)]
pub fn dir_exists(args: &[Value]) -> Result<Value, EvalError> {
let path = get_path(args)?;
Ok(Value::Bool(path.is_dir()))
}
#[tracing::instrument(level = "trace", skip_all)]
pub fn create_dir_all(args: &[Value]) -> Result<Value, EvalError> {
let path = get_path(args)?;
std::fs::create_dir_all(&path)?;
Ok(Value::Bool(true))
}
#[tracing::instrument(level = "trace", skip_all)]
pub fn list_dir(args: &[Value]) -> Result<Value, EvalError> {
let path = get_path(args)?;
let entries: Vec<Value> = std::fs::read_dir(&path)?
.filter_map(|e| e.ok())
.map(|e| Value::Path(e.path()))
.collect();
Ok(Value::List(entries))
}
#[tracing::instrument(level = "trace", skip_all)]
pub fn walk_dir(args: &[Value]) -> Result<Value, EvalError> {
let path = get_path(args)?;
let entries: Vec<Value> = WalkDir::new(&path)
.into_iter()
.filter_map(|e| e.ok())
.map(|e| Value::Path(e.path().to_path_buf()))
.collect();
Ok(Value::List(entries))
}
#[tracing::instrument(level = "trace", skip_all)]
pub fn temp_dir() -> Result<Value, EvalError> {
Ok(Value::Path(std::env::temp_dir()))
}
#[tracing::instrument(level = "trace", skip_all)]
pub fn temp_file(args: &[Value]) -> Result<Value, EvalError> {
let prefix = match args.first() {
Some(Value::Str(s)) => s.as_str(),
_ => "doot",
};
let suffix = match args.get(1) {
Some(Value::Str(s)) => s.as_str(),
_ => "",
};
let path = std::env::temp_dir().join(format!("{}_{}{}", prefix, uuid_simple(), suffix));
Ok(Value::Path(path))
}
#[tracing::instrument(level = "trace", skip_all)]
pub fn is_symlink(args: &[Value]) -> Result<Value, EvalError> {
let path = get_path(args)?;
Ok(Value::Bool(path.is_symlink()))
}
#[tracing::instrument(level = "trace", skip_all)]
pub fn read_link(args: &[Value]) -> Result<Value, EvalError> {
let path = get_path(args)?;
let target = std::fs::read_link(&path)?;
Ok(Value::Path(target))
}
#[tracing::instrument(level = "trace", skip_all)]
pub fn path_join(args: &[Value]) -> Result<Value, EvalError> {
let mut result = PathBuf::new();
for arg in args {
match arg {
Value::Path(p) => result.push(p),
Value::Str(s) => result.push(s),
_ => {
return Err(EvalError::TypeError(
"path_join expects paths or strings".to_string(),
));
}
}
}
Ok(Value::Path(result))
}
#[tracing::instrument(level = "trace", skip_all)]
pub fn path_parent(args: &[Value]) -> Result<Value, EvalError> {
let path = get_path(args)?;
Ok(Value::Path(
path.parent().map(|p| p.to_path_buf()).unwrap_or_default(),
))
}
#[tracing::instrument(level = "trace", skip_all)]
pub fn path_filename(args: &[Value]) -> Result<Value, EvalError> {
let path = get_path(args)?;
Ok(Value::Str(
path.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_default(),
))
}
#[tracing::instrument(level = "trace", skip_all)]
pub fn path_extension(args: &[Value]) -> Result<Value, EvalError> {
let path = get_path(args)?;
Ok(Value::Str(
path.extension()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_default(),
))
}
#[tracing::instrument(level = "trace", skip_all)]
pub fn home_dir() -> Result<Value, EvalError> {
Ok(Value::Path(doot_utils::xdg::home_dir()))
}
#[tracing::instrument(level = "trace", skip_all)]
pub fn config_dir() -> Result<Value, EvalError> {
Ok(Value::Path(doot_utils::xdg::config_home()))
}
#[tracing::instrument(level = "trace", skip_all)]
pub fn data_dir() -> Result<Value, EvalError> {
Ok(Value::Path(doot_utils::xdg::data_home()))
}
#[tracing::instrument(level = "trace", skip_all)]
pub fn cache_dir() -> Result<Value, EvalError> {
Ok(Value::Path(doot_utils::xdg::cache_home()))
}
#[tracing::instrument(level = "trace", skip_all)]
pub fn exec(args: &[Value]) -> Result<Value, EvalError> {
let cmd = match args.first() {
Some(Value::Str(s)) => s,
_ => {
return Err(EvalError::TypeError(
"exec expects a command string".to_string(),
));
}
};
let output = Command::new("sh").arg("-c").arg(cmd).output()?;
Ok(Value::Str(
String::from_utf8_lossy(&output.stdout).to_string(),
))
}
#[tracing::instrument(level = "trace", skip_all)]
pub fn exec_with_status(args: &[Value]) -> Result<Value, EvalError> {
let cmd = match args.first() {
Some(Value::Str(s)) => s,
_ => {
return Err(EvalError::TypeError(
"exec_with_status expects a command string".to_string(),
));
}
};
let status = Command::new("sh").arg("-c").arg(cmd).status()?;
Ok(Value::Int(status.code().unwrap_or(-1) as i64))
}
#[tracing::instrument(level = "trace", skip_all)]
pub fn shell(args: &[Value]) -> Result<Value, EvalError> {
exec(args)
}
#[tracing::instrument(level = "trace", skip_all)]
pub fn which(args: &[Value]) -> Result<Value, EvalError> {
let cmd = match args.first() {
Some(Value::Str(s)) => s,
_ => {
return Err(EvalError::TypeError(
"which expects a command name".to_string(),
));
}
};
let output = Command::new("which").arg(cmd).output()?;
if output.status.success() {
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
Ok(Value::Path(PathBuf::from(path)))
} else {
Ok(Value::None)
}
}
#[tracing::instrument(level = "trace", skip_all)]
pub fn to_json(args: &[Value]) -> Result<Value, EvalError> {
let val = args.first().unwrap_or(&Value::None);
let json = value_to_json(val);
Ok(Value::Str(json.to_string()))
}
#[tracing::instrument(level = "trace", skip_all)]
pub fn from_json(args: &[Value]) -> Result<Value, EvalError> {
let s = match args.first() {
Some(Value::Str(s)) => s,
_ => {
return Err(EvalError::TypeError(
"from_json expects a string".to_string(),
));
}
};
let json: serde_json::Value = serde_json::from_str(s)
.map_err(|e| EvalError::TypeError(format!("invalid JSON: {}", e)))?;
Ok(json_to_value(&json))
}
#[tracing::instrument(level = "trace", skip_all)]
pub fn to_toml(args: &[Value]) -> Result<Value, EvalError> {
let val = args.first().unwrap_or(&Value::None);
let toml_val = value_to_toml(val);
let s = toml::to_string(&toml_val)
.map_err(|e| EvalError::TypeError(format!("TOML serialization error: {}", e)))?;
Ok(Value::Str(s))
}
#[tracing::instrument(level = "trace", skip_all)]
pub fn from_toml(args: &[Value]) -> Result<Value, EvalError> {
let s = match args.first() {
Some(Value::Str(s)) => s,
_ => {
return Err(EvalError::TypeError(
"from_toml expects a string".to_string(),
));
}
};
let toml_val: toml::Value =
toml::from_str(s).map_err(|e| EvalError::TypeError(format!("invalid TOML: {}", e)))?;
Ok(toml_to_value(&toml_val))
}
#[tracing::instrument(level = "trace", skip_all)]
pub fn to_yaml(args: &[Value]) -> Result<Value, EvalError> {
let val = args.first().unwrap_or(&Value::None);
let json = value_to_json(val);
Ok(Value::Str(
serde_json::to_string_pretty(&json).unwrap_or_default(),
))
}
#[tracing::instrument(level = "trace", skip_all)]
pub fn from_yaml(args: &[Value]) -> Result<Value, EvalError> {
from_json(args)
}
fn get_path(args: &[Value]) -> Result<PathBuf, EvalError> {
match args.first() {
Some(Value::Path(p)) => Ok(p.clone()),
Some(Value::Str(s)) => Ok(expand_path(s)),
_ => Err(EvalError::TypeError("expected path or string".to_string())),
}
}
fn expand_path(s: &str) -> PathBuf {
if let Some(stripped) = s.strip_prefix('~') {
let home = doot_utils::xdg::home_dir();
home.join(stripped.strip_prefix('/').unwrap_or(stripped))
} else {
PathBuf::from(s)
}
}
fn uuid_simple() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
format!("{:x}", nanos)
}
fn value_to_json(val: &Value) -> serde_json::Value {
match val {
Value::Int(n) => serde_json::Value::Number(serde_json::Number::from(*n)),
Value::Float(n) => serde_json::Number::from_f64(*n)
.map(serde_json::Value::Number)
.unwrap_or(serde_json::Value::Null),
Value::Str(s) => serde_json::Value::String(s.clone()),
Value::Bool(b) => serde_json::Value::Bool(*b),
Value::Path(p) => serde_json::Value::String(p.display().to_string()),
Value::List(items) => serde_json::Value::Array(items.iter().map(value_to_json).collect()),
Value::Struct(_, fields) => {
let map: serde_json::Map<String, serde_json::Value> = fields
.iter()
.map(|(k, v)| (k.clone(), value_to_json(v)))
.collect();
serde_json::Value::Object(map)
}
Value::None => serde_json::Value::Null,
_ => serde_json::Value::Null,
}
}
fn json_to_value(json: &serde_json::Value) -> Value {
match json {
serde_json::Value::Null => Value::None,
serde_json::Value::Bool(b) => Value::Bool(*b),
serde_json::Value::Number(n) => {
if let Some(i) = n.as_i64() {
Value::Int(i)
} else if let Some(f) = n.as_f64() {
Value::Float(f)
} else {
Value::None
}
}
serde_json::Value::String(s) => Value::Str(s.clone()),
serde_json::Value::Array(arr) => Value::List(arr.iter().map(json_to_value).collect()),
serde_json::Value::Object(obj) => {
let fields: indexmap::IndexMap<String, Value> = obj
.iter()
.map(|(k, v)| (k.clone(), json_to_value(v)))
.collect();
Value::Struct("object".to_string(), fields)
}
}
}
fn value_to_toml(val: &Value) -> toml::Value {
match val {
Value::Int(n) => toml::Value::Integer(*n),
Value::Float(n) => toml::Value::Float(*n),
Value::Str(s) => toml::Value::String(s.clone()),
Value::Bool(b) => toml::Value::Boolean(*b),
Value::Path(p) => toml::Value::String(p.display().to_string()),
Value::List(items) => toml::Value::Array(items.iter().map(value_to_toml).collect()),
Value::Struct(_, fields) => {
let map: toml::map::Map<String, toml::Value> = fields
.iter()
.map(|(k, v)| (k.clone(), value_to_toml(v)))
.collect();
toml::Value::Table(map)
}
_ => toml::Value::String(String::new()),
}
}
fn toml_to_value(toml: &toml::Value) -> Value {
match toml {
toml::Value::Boolean(b) => Value::Bool(*b),
toml::Value::Integer(i) => Value::Int(*i),
toml::Value::Float(f) => Value::Float(*f),
toml::Value::String(s) => Value::Str(s.clone()),
toml::Value::Array(arr) => Value::List(arr.iter().map(toml_to_value).collect()),
toml::Value::Table(table) => {
let fields: indexmap::IndexMap<String, Value> = table
.iter()
.map(|(k, v)| (k.clone(), toml_to_value(v)))
.collect();
Value::Struct("table".to_string(), fields)
}
toml::Value::Datetime(dt) => Value::Str(dt.to_string()),
}
}