//! Filesystem sprint store. //! //! Project-scoped sprints live under `/.ideai/sprints/`, one directory per //! stable sprint id. The Markdown files are the source of truth; `index.json` is //! a reconstructible projection kept in sync after writes. use std::collections::BTreeMap; use std::path::{Path, PathBuf}; use async_trait::async_trait; use serde::{Deserialize, Serialize}; use domain::{ IssueActor, ProjectPath, Sprint, SprintId, SprintIndexEntry, SprintOrder, SprintStatus, SprintStore, SprintStoreError, SprintVersion, }; const IDEAI_DIR: &str = ".ideai"; const SPRINTS_DIR: &str = "sprints"; const SPRINT_FILE: &str = "sprint.md"; const INDEX_FILE: &str = "index.json"; const INDEX_VERSION: u32 = 1; /// Filesystem-backed sprint store. #[derive(Debug, Clone, Default)] pub struct FsSprintStore; impl FsSprintStore { /// Builds a store. #[must_use] pub const fn new() -> Self { Self } } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct IndexDoc { version: u32, sprints: Vec, } fn sprint_root(root: &ProjectPath) -> PathBuf { PathBuf::from(root.as_str()) .join(IDEAI_DIR) .join(SPRINTS_DIR) } fn sprint_dir(root: &ProjectPath, sprint_id: SprintId) -> PathBuf { sprint_root(root).join(sprint_id.to_string()) } fn sprint_path(root: &ProjectPath, sprint_id: SprintId) -> PathBuf { sprint_dir(root, sprint_id).join(SPRINT_FILE) } fn index_path(root: &ProjectPath) -> PathBuf { sprint_root(root).join(INDEX_FILE) } async fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), SprintStoreError> { let parent = path .parent() .ok_or_else(|| SprintStoreError::Store("path has no parent".to_owned()))?; tokio::fs::create_dir_all(parent).await.map_err(io_error)?; let tmp = path.with_extension("tmp"); tokio::fs::write(&tmp, bytes).await.map_err(io_error)?; tokio::fs::rename(&tmp, path).await.map_err(io_error) } async fn read_string(path: &Path) -> Result { let bytes = tokio::fs::read(path).await.map_err(|err| { if err.kind() == std::io::ErrorKind::NotFound { SprintStoreError::NotFound } else { io_error(err) } })?; String::from_utf8(bytes).map_err(|err| SprintStoreError::Store(err.to_string())) } fn io_error(err: std::io::Error) -> SprintStoreError { SprintStoreError::Store(err.to_string()) } async fn load_sprint(root: &ProjectPath, sprint_id: SprintId) -> Result { let text = read_string(&sprint_path(root, sprint_id)).await?; parse_sprint_doc(&text) } async fn save_sprint(root: &ProjectPath, sprint: &Sprint) -> Result<(), SprintStoreError> { let dir = sprint_dir(root, sprint.id); tokio::fs::create_dir_all(&dir).await.map_err(io_error)?; write_atomic(&dir.join(SPRINT_FILE), render_sprint_doc(sprint).as_bytes()).await } async fn rebuild_index(root: &ProjectPath) -> Result, SprintStoreError> { let dir = sprint_root(root); let mut rows = Vec::new(); let mut entries = match tokio::fs::read_dir(&dir).await { Ok(entries) => entries, Err(err) if err.kind() == std::io::ErrorKind::NotFound => { write_index(root, &rows).await?; return Ok(rows); } Err(err) => return Err(io_error(err)), }; while let Some(entry) = entries.next_entry().await.map_err(io_error)? { let Ok(file_type) = entry.file_type().await else { continue; }; if !file_type.is_dir() { continue; } let name = entry.file_name().to_string_lossy().to_string(); let Ok(id) = uuid::Uuid::parse_str(&name) else { continue; }; if let Ok(sprint) = load_sprint(root, SprintId::from_uuid(id)).await { rows.push(SprintIndexEntry::from(&sprint)); } } rows.sort_by_key(|row| row.order.get()); write_index(root, &rows).await?; Ok(rows) } async fn read_index(root: &ProjectPath) -> Result, SprintStoreError> { match tokio::fs::read(index_path(root)).await { Ok(bytes) => { let doc: IndexDoc = serde_json::from_slice(&bytes) .map_err(|err| SprintStoreError::Store(err.to_string()))?; Ok(doc.sprints) } Err(err) if err.kind() == std::io::ErrorKind::NotFound => rebuild_index(root).await, Err(err) => Err(io_error(err)), } } async fn write_index( root: &ProjectPath, rows: &[SprintIndexEntry], ) -> Result<(), SprintStoreError> { let doc = IndexDoc { version: INDEX_VERSION, sprints: rows.to_vec(), }; let bytes = serde_json::to_vec_pretty(&doc).map_err(|err| SprintStoreError::Store(err.to_string()))?; write_atomic(&index_path(root), &bytes).await } #[async_trait] impl SprintStore for FsSprintStore { async fn create(&self, root: &ProjectPath, sprint: &Sprint) -> Result<(), SprintStoreError> { sprint .validate() .map_err(|err| SprintStoreError::Invalid(err.to_string()))?; if tokio::fs::try_exists(sprint_path(root, sprint.id)) .await .map_err(io_error)? { return Err(SprintStoreError::Invalid(format!( "sprint {} already exists", sprint.id ))); } save_sprint(root, sprint).await?; rebuild_index(root).await?; Ok(()) } async fn get( &self, root: &ProjectPath, sprint_id: SprintId, ) -> Result { load_sprint(root, sprint_id).await } async fn list(&self, root: &ProjectPath) -> Result, SprintStoreError> { let mut rows = read_index(root).await?; rows.sort_by_key(|row| row.order.get()); Ok(rows) } async fn update( &self, root: &ProjectPath, sprint: &Sprint, expected_version: SprintVersion, ) -> Result<(), SprintStoreError> { sprint .validate() .map_err(|err| SprintStoreError::Invalid(err.to_string()))?; let current = load_sprint(root, sprint.id).await?; if current.version != expected_version { return Err(SprintStoreError::VersionConflict { expected: expected_version, actual: current.version, }); } save_sprint(root, sprint).await?; rebuild_index(root).await?; Ok(()) } async fn delete( &self, root: &ProjectPath, sprint_id: SprintId, ) -> Result<(), SprintStoreError> { if !tokio::fs::try_exists(sprint_path(root, sprint_id)) .await .map_err(io_error)? { return Err(SprintStoreError::NotFound); } tokio::fs::remove_dir_all(sprint_dir(root, sprint_id)) .await .map_err(io_error)?; rebuild_index(root).await?; Ok(()) } } fn render_sprint_doc(sprint: &Sprint) -> String { format!( "---\n\ id: {}\n\ order: {}\n\ name: {}\n\ status: {}\n\ createdBy: {}\n\ updatedBy: {}\n\ createdAt: {}\n\ updatedAt: {}\n\ version: {}\n\ ---\n", json_string(&sprint.id.to_string()), sprint.order.get(), json_string(&sprint.name), json_value(&sprint.status), json_value(&sprint.created_by), json_value(&sprint.updated_by), sprint.created_at, sprint.updated_at, sprint.version.get(), ) } fn parse_sprint_doc(text: &str) -> Result { let (fm, _body) = split_frontmatter(text)?; let map = parse_frontmatter_map(fm)?; let id_raw: String = parse_json_field(&map, "id")?; let id = SprintId::from_uuid( uuid::Uuid::parse_str(&id_raw).map_err(|err| SprintStoreError::Invalid(err.to_string()))?, ); let order = SprintOrder::new(parse_plain_u32(&map, "order")?) .map_err(|err| SprintStoreError::Invalid(err.to_string()))?; let name: String = parse_json_field(&map, "name")?; let status: SprintStatus = parse_json_field(&map, "status")?; let created_by: IssueActor = parse_json_field(&map, "createdBy")?; let updated_by: IssueActor = parse_json_field(&map, "updatedBy")?; let created_at = parse_plain_u64(&map, "createdAt")?; let updated_at = parse_plain_u64(&map, "updatedAt")?; let version = SprintVersion::new(parse_plain_u64(&map, "version")?) .map_err(|err| SprintStoreError::Invalid(err.to_string()))?; Sprint::rehydrate(Sprint { id, order, name, status, created_by, updated_by, created_at, updated_at, version, }) .map_err(|err| SprintStoreError::Invalid(err.to_string())) } fn split_frontmatter(text: &str) -> Result<(&str, &str), SprintStoreError> { let rest = text .strip_prefix("---\n") .or_else(|| text.strip_prefix("---\r\n")) .ok_or_else(|| SprintStoreError::Invalid("missing opening frontmatter fence".to_owned()))?; let mut offset = 0; for line in rest.split_inclusive('\n') { if line.trim_end_matches(['\r', '\n']) == "---" { let body_start = offset + line.len(); return Ok((&rest[..offset], &rest[body_start..])); } offset += line.len(); } Err(SprintStoreError::Invalid( "missing closing frontmatter fence".to_owned(), )) } fn parse_frontmatter_map(block: &str) -> Result, SprintStoreError> { let mut map = BTreeMap::new(); for line in block.lines().filter(|line| !line.trim().is_empty()) { let (key, value) = line .split_once(':') .ok_or_else(|| SprintStoreError::Invalid("frontmatter line missing ':'".to_owned()))?; map.insert(key.trim().to_owned(), value.trim().to_owned()); } Ok(map) } fn parse_plain_u32(map: &BTreeMap, key: &str) -> Result { map.get(key) .ok_or_else(|| SprintStoreError::Invalid(format!("missing frontmatter key `{key}`")))? .parse::() .map_err(|err| SprintStoreError::Invalid(err.to_string())) } fn parse_plain_u64(map: &BTreeMap, key: &str) -> Result { map.get(key) .ok_or_else(|| SprintStoreError::Invalid(format!("missing frontmatter key `{key}`")))? .parse::() .map_err(|err| SprintStoreError::Invalid(err.to_string())) } fn parse_json_field( map: &BTreeMap, key: &str, ) -> Result { let value = map .get(key) .ok_or_else(|| SprintStoreError::Invalid(format!("missing frontmatter key `{key}`")))?; serde_json::from_str(value).map_err(|err| SprintStoreError::Invalid(err.to_string())) } fn json_value(value: &T) -> String { serde_json::to_string(value).expect("serializing sprint frontmatter cannot fail") } fn json_string(value: &str) -> String { serde_json::to_string(value).expect("serializing sprint string cannot fail") }