feat: add utility action suites
CI / validate (push) Successful in 36s

This commit is contained in:
2026-07-21 11:38:11 -05:00
parent 52c56f7fed
commit f1a8bb0f35
15 changed files with 1117 additions and 42 deletions
+162
View File
@@ -0,0 +1,162 @@
use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize};
use std::fs::{self, OpenOptions};
use std::io::Write;
use std::path::{Component, Path, PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct FilePayload {
pub filename: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct WriteFilePayload {
pub filename: String,
pub content: String,
}
pub fn run_write_note(payload: WriteFilePayload) -> Result<()> {
let path = write_note(payload)?;
print_json(
"write_note",
serde_json::json!({ "path": path.display().to_string() }),
)
}
pub fn run_append_to_file(payload: WriteFilePayload) -> Result<()> {
let path = append_to_file(payload)?;
print_json(
"append_to_file",
serde_json::json!({ "path": path.display().to_string() }),
)
}
pub fn run_read_file(payload: FilePayload) -> Result<()> {
let contents = read_file(payload)?;
print_json("read_file", serde_json::json!({ "content": contents }))
}
pub fn run_delete_file(payload: FilePayload) -> Result<()> {
let path = delete_file(payload)?;
print_json(
"delete_file",
serde_json::json!({ "path": path.display().to_string() }),
)
}
pub fn write_note(payload: WriteFilePayload) -> Result<PathBuf> {
let path = safe_notes_path(&payload.filename)?;
ensure_parent(&path)?;
fs::write(&path, payload.content).with_context(|| format!("writing {}", path.display()))?;
Ok(path)
}
pub fn append_to_file(payload: WriteFilePayload) -> Result<PathBuf> {
let path = safe_notes_path(&payload.filename)?;
ensure_parent(&path)?;
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.with_context(|| format!("opening {}", path.display()))?;
file.write_all(payload.content.as_bytes())?;
Ok(path)
}
pub fn read_file(payload: FilePayload) -> Result<String> {
let path = safe_notes_path(&payload.filename)?;
fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))
}
pub fn delete_file(payload: FilePayload) -> Result<PathBuf> {
let path = safe_notes_path(&payload.filename)?;
fs::remove_file(&path).with_context(|| format!("deleting {}", path.display()))?;
Ok(path)
}
fn safe_notes_path(filename: &str) -> Result<PathBuf> {
let trimmed = filename.trim();
if trimmed.is_empty() {
bail!("filename must not be empty");
}
let relative = Path::new(trimmed);
if relative.is_absolute() {
bail!("filename must be relative to notes/");
}
for component in relative.components() {
match component {
Component::Normal(_) => {}
_ => bail!("filename must not contain path traversal or special components"),
}
}
Ok(PathBuf::from("notes").join(relative))
}
fn ensure_parent(path: &Path) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).with_context(|| format!("creating {}", parent.display()))?;
}
Ok(())
}
fn print_json(action_id: &str, payload: serde_json::Value) -> Result<()> {
println!(
"{}",
serde_json::to_string(&serde_json::json!({
"action_id": action_id,
"output": payload
}))?
);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rejects_unsafe_paths() {
assert!(safe_notes_path("../secret.txt").is_err());
assert!(safe_notes_path("/tmp/secret.txt").is_err());
assert!(safe_notes_path("").is_err());
assert_eq!(
safe_notes_path("daily/note.txt").unwrap(),
PathBuf::from("notes/daily/note.txt")
);
}
#[test]
fn writes_appends_reads_and_deletes_note() {
let temp = tempfile::tempdir().unwrap();
let previous = std::env::current_dir().unwrap();
std::env::set_current_dir(temp.path()).unwrap();
write_note(WriteFilePayload {
filename: "test.txt".to_string(),
content: "hello".to_string(),
})
.unwrap();
append_to_file(WriteFilePayload {
filename: "test.txt".to_string(),
content: " world".to_string(),
})
.unwrap();
assert_eq!(
read_file(FilePayload {
filename: "test.txt".to_string()
})
.unwrap(),
"hello world"
);
delete_file(FilePayload {
filename: "test.txt".to_string(),
})
.unwrap();
assert!(!Path::new("notes/test.txt").exists());
std::env::set_current_dir(previous).unwrap();
}
}
+157
View File
@@ -0,0 +1,157 @@
use anyhow::{Result, bail};
use evalexpr::eval;
use rand::Rng;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct CalculatePayload {
pub expression: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ConvertUnitsPayload {
pub value: f64,
pub from: String,
pub to: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct GenerateUuidPayload {}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RandomNumberPayload {
pub min: i64,
pub max: i64,
}
pub fn run_calculate(payload: CalculatePayload) -> Result<()> {
let result = calculate(payload)?;
print_json("calculate", serde_json::json!({ "result": result }))
}
pub fn run_convert_units(payload: ConvertUnitsPayload) -> Result<()> {
let result = convert_units(payload)?;
print_json("convert_units", result)
}
pub fn run_generate_uuid(_payload: GenerateUuidPayload) -> Result<()> {
print_json(
"generate_uuid",
serde_json::json!({ "uuid": Uuid::new_v4().to_string() }),
)
}
pub fn run_random_number(payload: RandomNumberPayload) -> Result<()> {
let number = random_number(payload)?;
print_json("random_number", serde_json::json!({ "number": number }))
}
pub fn calculate(payload: CalculatePayload) -> Result<Value> {
let expression = payload.expression.trim();
if expression.is_empty() {
bail!("expression must not be empty");
}
let result = eval(expression)?;
Ok(match result {
evalexpr::Value::Int(value) => serde_json::json!(value),
evalexpr::Value::Float(value) => serde_json::json!(value),
evalexpr::Value::Boolean(value) => serde_json::json!(value),
evalexpr::Value::String(value) => serde_json::json!(value),
other => serde_json::json!(other.to_string()),
})
}
pub fn convert_units(payload: ConvertUnitsPayload) -> Result<Value> {
let from = normalize_unit(&payload.from);
let to = normalize_unit(&payload.to);
let meters = to_meters(payload.value, &from)?;
let converted = from_meters(meters, &to)?;
Ok(serde_json::json!({
"value": converted,
"from": payload.from,
"to": payload.to
}))
}
pub fn random_number(payload: RandomNumberPayload) -> Result<i64> {
if payload.min > payload.max {
bail!("min must be less than or equal to max");
}
Ok(rand::thread_rng().gen_range(payload.min..=payload.max))
}
fn normalize_unit(unit: &str) -> String {
unit.trim().to_ascii_lowercase()
}
fn to_meters(value: f64, unit: &str) -> Result<f64> {
match unit {
"m" | "meter" | "meters" => Ok(value),
"km" | "kilometer" | "kilometers" => Ok(value * 1000.0),
"mi" | "mile" | "miles" => Ok(value * 1609.344),
"ft" | "foot" | "feet" => Ok(value * 0.3048),
"in" | "inch" | "inches" => Ok(value * 0.0254),
"cm" | "centimeter" | "centimeters" => Ok(value * 0.01),
"mm" | "millimeter" | "millimeters" => Ok(value * 0.001),
other => bail!("unsupported source unit `{other}`"),
}
}
fn from_meters(value: f64, unit: &str) -> Result<f64> {
match unit {
"m" | "meter" | "meters" => Ok(value),
"km" | "kilometer" | "kilometers" => Ok(value / 1000.0),
"mi" | "mile" | "miles" => Ok(value / 1609.344),
"ft" | "foot" | "feet" => Ok(value / 0.3048),
"in" | "inch" | "inches" => Ok(value / 0.0254),
"cm" | "centimeter" | "centimeters" => Ok(value / 0.01),
"mm" | "millimeter" | "millimeters" => Ok(value / 0.001),
other => bail!("unsupported target unit `{other}`"),
}
}
fn print_json(action_id: &str, payload: Value) -> Result<()> {
println!(
"{}",
serde_json::to_string(&serde_json::json!({
"action_id": action_id,
"output": payload
}))?
);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn evaluates_math_expression() {
let result = calculate(CalculatePayload {
expression: "2 + 2 * 3".to_string(),
})
.unwrap();
assert_eq!(result, serde_json::json!(8));
}
#[test]
fn converts_km_to_miles() {
let result = convert_units(ConvertUnitsPayload {
value: 100.0,
from: "km".to_string(),
to: "miles".to_string(),
})
.unwrap();
let converted = result["value"].as_f64().unwrap();
assert!((converted - 62.137119).abs() < 0.0001);
}
#[test]
fn validates_random_range() {
assert!(random_number(RandomNumberPayload { min: 1, max: 3 }).unwrap() >= 1);
assert!(random_number(RandomNumberPayload { min: 5, max: 1 }).is_err());
}
}
+3
View File
@@ -1 +1,4 @@
pub mod file_ops;
pub mod logger;
pub mod math;
pub mod text;
+181
View File
@@ -0,0 +1,181 @@
use anyhow::{Result, bail};
use md5::Md5;
use serde::{Deserialize, Serialize};
use sha1::Sha1;
use sha2::{Digest, Sha256};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct CountWordsPayload {
pub text: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ToSlugPayload {
pub text: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct HashStringPayload {
pub text: String,
pub algorithm: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct TruncateTextPayload {
pub text: String,
pub max_chars: usize,
}
pub fn run_count_words(payload: CountWordsPayload) -> Result<()> {
print_json(
"count_words",
serde_json::json!({ "word_count": count_words(&payload.text) }),
)
}
pub fn run_to_slug(payload: ToSlugPayload) -> Result<()> {
print_json(
"to_slug",
serde_json::json!({ "slug": to_slug(&payload.text) }),
)
}
pub fn run_hash_string(payload: HashStringPayload) -> Result<()> {
let hash = hash_string(payload)?;
print_json("hash_string", serde_json::json!({ "hash": hash }))
}
pub fn run_truncate_text(payload: TruncateTextPayload) -> Result<()> {
let truncated = truncate_text(payload)?;
print_json(
"truncate_text",
serde_json::json!({ "text": truncated.text, "truncated": truncated.truncated }),
)
}
pub fn count_words(text: &str) -> usize {
text.split_whitespace()
.filter(|word| !word.is_empty())
.count()
}
pub fn to_slug(text: &str) -> String {
let mut slug = String::new();
let mut last_was_dash = false;
for ch in text.chars().flat_map(char::to_lowercase) {
if ch.is_ascii_alphanumeric() {
slug.push(ch);
last_was_dash = false;
} else if !last_was_dash && !slug.is_empty() {
slug.push('-');
last_was_dash = true;
}
}
slug.trim_matches('-').to_string()
}
pub fn hash_string(payload: HashStringPayload) -> Result<String> {
match payload.algorithm.trim().to_ascii_lowercase().as_str() {
"sha256" => Ok(hex_digest(
Sha256::digest(payload.text.as_bytes()).as_slice(),
)),
"sha1" => Ok(hex_digest(Sha1::digest(payload.text.as_bytes()).as_slice())),
"md5" => Ok(hex_digest(Md5::digest(payload.text.as_bytes()).as_slice())),
other => bail!("unsupported hash algorithm `{other}`"),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TruncatedText {
pub text: String,
pub truncated: bool,
}
pub fn truncate_text(payload: TruncateTextPayload) -> Result<TruncatedText> {
if payload.max_chars == 0 {
bail!("max_chars must be greater than zero");
}
let mut chars = payload.text.chars();
let text = chars.by_ref().take(payload.max_chars).collect::<String>();
let truncated = chars.next().is_some();
Ok(TruncatedText { text, truncated })
}
fn hex_digest(bytes: &[u8]) -> String {
bytes.iter().map(|byte| format!("{byte:02x}")).collect()
}
fn print_json(action_id: &str, payload: serde_json::Value) -> Result<()> {
println!(
"{}",
serde_json::to_string(&serde_json::json!({
"action_id": action_id,
"output": payload
}))?
);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn counts_words() {
assert_eq!(count_words("hello world\nfrom codex"), 4);
}
#[test]
fn creates_slug() {
assert_eq!(to_slug("Hello, World! Rust 2026"), "hello-world-rust-2026");
}
#[test]
fn hashes_supported_algorithms() {
assert_eq!(
hash_string(HashStringPayload {
text: "hello".to_string(),
algorithm: "sha256".to_string(),
})
.unwrap(),
"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
);
assert_eq!(
hash_string(HashStringPayload {
text: "hello".to_string(),
algorithm: "md5".to_string(),
})
.unwrap(),
"5d41402abc4b2a76b9719d911017c592"
);
assert_eq!(
hash_string(HashStringPayload {
text: "hello".to_string(),
algorithm: "sha1".to_string(),
})
.unwrap(),
"aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d"
);
}
#[test]
fn truncates_by_chars() {
let truncated = truncate_text(TruncateTextPayload {
text: "abcdef".to_string(),
max_chars: 3,
})
.unwrap();
assert_eq!(truncated.text, "abc");
assert!(truncated.truncated);
assert!(
truncate_text(TruncateTextPayload {
text: "abc".to_string(),
max_chars: 0,
})
.is_err()
);
}
}