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 { 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 { 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 { 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 { 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 { 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(); } }