@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -1 +1,4 @@
|
||||
pub mod file_ops;
|
||||
pub mod logger;
|
||||
pub mod math;
|
||||
pub mod text;
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user