use serde::{Deserialize, Serialize}; use std::{ io, path::{Path, PathBuf}, }; use tokio::{io::AsyncWriteExt, sync::Mutex}; #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(deny_unknown_fields)] pub struct Item { pub id: String, pub name: String, pub quantity: u32, pub note: String, pub version: u32, } #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] struct InventoryFile { schema_version: u8, items: Vec, } pub struct Store { path: PathBuf, inventory: Mutex, } #[derive(Debug)] pub enum SaveError { NotFound, Conflict, Exhausted, Storage(io::Error), } pub fn validate_quantity(value: &str) -> Result { if value.is_empty() || value.len() > 4 || !value.bytes().all(|byte| byte.is_ascii_digit()) { return Err( "Quantity must be a whole number from 0 to 9,999. Your changes were not saved.", ); } value .parse() .map_err(|_| "Quantity must be a whole number from 0 to 9,999.") } pub fn validate_note(value: &str) -> Result<(), &'static str> { if value.chars().count() > 80 || value.chars().any(char::is_control) { return Err( "Shelf notes must be at most 80 characters on one line. Your changes were not saved.", ); } Ok(()) } fn seeded() -> InventoryFile { InventoryFile { schema_version: 1, items: [ ("notebook", "Field notebook", 8), ("pencil", "Graphite pencil", 24), ("map-case", "Map case", 3), ] .into_iter() .map(|(id, name, quantity)| Item { id: id.into(), name: name.into(), quantity, note: String::new(), version: 1, }) .collect(), } } fn invalid(message: &str) -> io::Error { io::Error::new(io::ErrorKind::InvalidData, message) } fn validate_file(file: &InventoryFile) -> io::Result<()> { let expected = seeded(); if file.schema_version != 1 || file.items.len() != expected.items.len() { return Err(invalid("unsupported inventory schema or item set")); } for seed in &expected.items { let matches: Vec<_> = file .items .iter() .filter(|item| item.id == seed.id) .collect(); if matches.len() != 1 { return Err(invalid("missing or duplicate inventory item")); } let item = matches[0]; if item.name != seed.name || item.quantity > 9999 || item.version == 0 || validate_note(&item.note).is_err() { return Err(invalid("invalid inventory record")); } } Ok(()) } async fn persist(path: &Path, inventory: &InventoryFile) -> io::Result<()> { let parent = path .parent() .filter(|path| !path.as_os_str().is_empty()) .unwrap_or(Path::new(".")); tokio::fs::create_dir_all(parent).await?; let filename = path .file_name() .and_then(|name| name.to_str()) .ok_or_else(|| invalid("invalid data filename"))?; let pending = parent.join(format!(".{filename}.{}.pending", std::process::id())); let bytes = serde_json::to_vec_pretty(inventory).map_err(io::Error::other)?; // Same-directory rename replaces a whole file. create_new refuses an // existing temporary path; it never follows a pre-existing temp symlink. let mut file = tokio::fs::OpenOptions::new() .write(true) .create_new(true) .open(&pending) .await?; let written = async { file.write_all(&bytes).await?; file.sync_all().await?; drop(file); tokio::fs::rename(&pending, path).await } .await; if written.is_err() { let _ = tokio::fs::remove_file(&pending).await; } written } impl Store { pub async fn open(path: PathBuf) -> io::Result { let inventory = match tokio::fs::metadata(&path).await { Ok(metadata) => { if !metadata.is_file() || metadata.len() > 65_536 { return Err(invalid( "inventory file must be a regular file below 64 KiB", )); } let bytes = tokio::fs::read(&path).await?; if bytes.len() > 65_536 { return Err(invalid("inventory file is too large")); } let inventory: InventoryFile = serde_json::from_slice(&bytes).map_err(io::Error::other)?; validate_file(&inventory)?; inventory } Err(error) if error.kind() == io::ErrorKind::NotFound => { let inventory = seeded(); persist(&path, &inventory).await?; inventory } Err(error) => return Err(error), }; Ok(Self { path, inventory: Mutex::new(inventory), }) } pub async fn snapshot(&self) -> Vec { self.inventory.lock().await.items.clone() } pub async fn update( &self, id: &str, quantity: u32, note: String, version: u32, ) -> Result { // Hold one in-process lock across the read/check/persist/commit sequence. let mut stored = self.inventory.lock().await; let index = stored .items .iter() .position(|item| item.id == id) .ok_or(SaveError::NotFound)?; if stored.items[index].version != version { return Err(SaveError::Conflict); } let next_version = version.checked_add(1).ok_or(SaveError::Exhausted)?; let mut next = stored.clone(); next.items[index].quantity = quantity; next.items[index].note = note; next.items[index].version = next_version; persist(&self.path, &next) .await .map_err(SaveError::Storage)?; let confirmed = next.items[index].clone(); *stored = next; Ok(confirmed) } } #[cfg(test)] mod tests { use super::*; use std::sync::atomic::{AtomicU64, Ordering}; static SEQUENCE: AtomicU64 = AtomicU64::new(0); struct Scratch(PathBuf); impl Scratch { fn new() -> Self { Self(std::env::temp_dir().join(format!( "forma-course-{}-{}", std::process::id(), SEQUENCE.fetch_add(1, Ordering::Relaxed) ))) } fn file(&self) -> PathBuf { self.0.join("inventory.json") } } impl Drop for Scratch { fn drop(&mut self) { let _ = std::fs::remove_dir_all(&self.0); } } #[test] fn validates_edits_at_the_server_boundary() { assert_eq!(validate_quantity("0"), Ok(0)); assert_eq!(validate_quantity("9999"), Ok(9999)); for value in ["", "-1", "10000", "1.5", " 5", "NaN"] { assert!(validate_quantity(value).is_err()); } assert!(validate_note("Aisle B / shelf 2").is_ok()); assert!(validate_note(&"x".repeat(81)).is_err()); assert!(validate_note("two\nlines").is_err()); } #[tokio::test] async fn accepted_edits_survive_reload_and_stale_edits_do_not_write() { let scratch = Scratch::new(); let store = Store::open(scratch.file()).await.unwrap(); let item = store .update("notebook", 17, "Shelf B".into(), 1) .await .unwrap(); assert_eq!(item.version, 2); let before = tokio::fs::read(scratch.file()).await.unwrap(); assert!(matches!( store.update("notebook", 99, "stale".into(), 1).await, Err(SaveError::Conflict) )); assert_eq!(tokio::fs::read(scratch.file()).await.unwrap(), before); drop(store); let reopened = Store::open(scratch.file()).await.unwrap(); assert_eq!(reopened.snapshot().await[0], item); } #[tokio::test] async fn concurrent_same_version_edits_have_one_winner() { let scratch = Scratch::new(); let store = Store::open(scratch.file()).await.unwrap(); let (a, b) = tokio::join!( store.update("notebook", 10, "a".into(), 1), store.update("notebook", 11, "b".into(), 1) ); assert_eq!(usize::from(a.is_ok()) + usize::from(b.is_ok()), 1); let reread = Store::open(scratch.file()).await.unwrap(); assert_eq!(reread.snapshot().await, store.snapshot().await); } #[tokio::test] async fn storage_failure_does_not_advance_confirmed_state() { let scratch = Scratch::new(); let store = Store::open(scratch.file()).await.unwrap(); let before = store.snapshot().await; tokio::fs::rename(scratch.file(), scratch.0.join("original.json")) .await .unwrap(); tokio::fs::create_dir(scratch.file()).await.unwrap(); assert!(matches!( store.update("notebook", 17, "failed".into(), 1).await, Err(SaveError::Storage(_)) )); assert_eq!(store.snapshot().await, before); } #[tokio::test] async fn corrupt_storage_refuses_startup_without_resetting_data() { let scratch = Scratch::new(); tokio::fs::create_dir_all(&scratch.0).await.unwrap(); tokio::fs::write(scratch.file(), "not valid JSON") .await .unwrap(); assert!(Store::open(scratch.file()).await.is_err()); assert_eq!( tokio::fs::read_to_string(scratch.file()).await.unwrap(), "not valid JSON" ); } }