//! Filesystem issue store. //! //! Project-scoped issues live under `/.ideai/tickets/`, one directory per //! issue number. 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 std::str::FromStr; use std::time::Duration; use async_trait::async_trait; use serde::{Deserialize, Serialize}; use tokio::io::AsyncWriteExt; use domain::{ AgentIssueRef, Issue, IssueActor, IssueCarnet, IssueId, IssueIndexEntry, IssueLink, IssueListFilter, IssueNumber, IssueNumberAllocator, IssuePriority, IssueRef, IssueStatus, IssueStore, IssueStoreError, IssueVersion, MarkdownDoc, ProjectPath, SprintId, }; const IDEAI_DIR: &str = ".ideai"; const ISSUES_DIR: &str = "tickets"; const ISSUE_FILE: &str = "issue.md"; const CARNET_FILE: &str = "carnet.md"; const INDEX_FILE: &str = "index.json"; const COUNTER_FILE: &str = "counter.json"; const COUNTER_LOCK: &str = "counter.lock"; const MUTATION_LOCK: &str = "mutation.lock"; const INDEX_VERSION: u32 = 1; /// Filesystem-backed issue store. #[derive(Debug, Clone, Default)] pub struct FsIssueStore; impl FsIssueStore { /// Builds a store. #[must_use] pub const fn new() -> Self { Self } } /// Filesystem-backed issue number allocator. #[derive(Debug, Clone, Default)] pub struct FsIssueNumberAllocator; impl FsIssueNumberAllocator { /// Builds an allocator. #[must_use] pub const fn new() -> Self { Self } } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct CounterDoc { next_number: u64, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct IndexDoc { version: u32, issues: Vec, } fn issue_root(root: &ProjectPath) -> PathBuf { PathBuf::from(root.as_str()) .join(IDEAI_DIR) .join(ISSUES_DIR) } fn issue_dir(root: &ProjectPath, number: IssueNumber) -> PathBuf { issue_root(root).join(number.get().to_string()) } fn issue_path(root: &ProjectPath, number: IssueNumber) -> PathBuf { issue_dir(root, number).join(ISSUE_FILE) } fn carnet_path(root: &ProjectPath, number: IssueNumber) -> PathBuf { issue_dir(root, number).join(CARNET_FILE) } fn index_path(root: &ProjectPath) -> PathBuf { issue_root(root).join(INDEX_FILE) } fn counter_path(root: &ProjectPath) -> PathBuf { issue_root(root).join(COUNTER_FILE) } fn counter_lock_path(root: &ProjectPath) -> PathBuf { issue_root(root).join(COUNTER_LOCK) } fn mutation_lock_path(root: &ProjectPath) -> PathBuf { issue_root(root).join(MUTATION_LOCK) } async fn acquire_lock(path: &Path, name: &str) -> Result { if let Some(parent) = path.parent() { tokio::fs::create_dir_all(parent).await.map_err(io_error)?; } let mut lock = None; for _ in 0..50 { match tokio::fs::OpenOptions::new() .write(true) .create_new(true) .open(path) .await { Ok(file) => { lock = Some(file); break; } Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => { tokio::time::sleep(Duration::from_millis(10)).await; } Err(err) => return Err(io_error(err)), } } let Some(mut lock_file) = lock else { return Err(IssueStoreError::Store(format!("{name} lock timed out"))); }; lock_file.write_all(b"locked\n").await.map_err(io_error)?; Ok(lock_file) } async fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), IssueStoreError> { let parent = path .parent() .ok_or_else(|| IssueStoreError::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 { IssueStoreError::NotFound } else { io_error(err) } })?; String::from_utf8(bytes).map_err(|err| IssueStoreError::Store(err.to_string())) } fn io_error(err: std::io::Error) -> IssueStoreError { IssueStoreError::Store(err.to_string()) } async fn load_issue(root: &ProjectPath, issue_ref: IssueRef) -> Result { let number = issue_ref.number(); let issue_text = read_string(&issue_path(root, number)).await?; let mut issue = parse_issue_doc(&issue_text)?; let carnet = match read_string(&carnet_path(root, number)).await { Ok(text) => parse_carnet_doc(&text)?.carnet, Err(IssueStoreError::NotFound) => MarkdownDoc::default(), Err(err) => return Err(err), }; issue.carnet = carnet; Issue::rehydrate(issue).map_err(|err| IssueStoreError::Invalid(err.to_string())) } async fn save_issue(root: &ProjectPath, issue: &Issue) -> Result<(), IssueStoreError> { let dir = issue_dir(root, issue.number); tokio::fs::create_dir_all(&dir).await.map_err(io_error)?; write_atomic(&dir.join(ISSUE_FILE), render_issue_doc(issue).as_bytes()).await?; write_atomic(&dir.join(CARNET_FILE), render_carnet_doc(issue).as_bytes()).await } async fn rebuild_index(root: &ProjectPath) -> Result, IssueStoreError> { let dir = issue_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(number) = name.parse::() else { continue; }; let Ok(number) = IssueNumber::new(number) else { continue; }; if let Ok(issue) = load_issue(root, IssueRef::from(number)).await { rows.push(IssueIndexEntry::from(&issue)); } } rows.sort_by_key(|row| row.issue_ref.number().get()); write_index(root, &rows).await?; Ok(rows) } async fn read_index(root: &ProjectPath) -> Result, IssueStoreError> { match tokio::fs::read(index_path(root)).await { Ok(bytes) => { let doc: IndexDoc = serde_json::from_slice(&bytes) .map_err(|err| IssueStoreError::Store(err.to_string()))?; Ok(doc.issues) } 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: &[IssueIndexEntry]) -> Result<(), IssueStoreError> { let doc = IndexDoc { version: INDEX_VERSION, issues: rows.to_vec(), }; let bytes = serde_json::to_vec_pretty(&doc).map_err(|err| IssueStoreError::Store(err.to_string()))?; write_atomic(&index_path(root), &bytes).await } fn filter_matches(row: &IssueIndexEntry, filter: &IssueListFilter) -> bool { if !filter.statuses.is_empty() && !filter.statuses.contains(&row.status) { return false; } if !filter.priorities.is_empty() && !filter.priorities.contains(&row.priority) { return false; } if let Some(agent_id) = filter.assigned_agent_id { if !row.assigned_agent_ids.contains(&agent_id) { return false; } } if filter .sprint .is_some_and(|sprint| row.sprint != Some(sprint)) { return false; } if let Some(text) = filter .text .as_ref() .map(|s| s.trim()) .filter(|s| !s.is_empty()) { row.title .to_ascii_lowercase() .contains(&text.to_ascii_lowercase()) } else { true } } #[async_trait] impl IssueStore for FsIssueStore { async fn create(&self, root: &ProjectPath, issue: &Issue) -> Result<(), IssueStoreError> { let lock_path = mutation_lock_path(root); let _lock_file = acquire_lock(&lock_path, "issue mutation").await?; let result = async { issue .validate() .map_err(|err| IssueStoreError::Invalid(err.to_string()))?; if tokio::fs::try_exists(issue_path(root, issue.number)) .await .map_err(io_error)? { return Err(IssueStoreError::Invalid(format!( "issue {} already exists", issue.reference() ))); } save_issue(root, issue).await?; rebuild_index(root).await?; Ok(()) } .await; let _ = tokio::fs::remove_file(&lock_path).await; result } async fn get_by_ref( &self, root: &ProjectPath, issue_ref: IssueRef, ) -> Result { load_issue(root, issue_ref).await } async fn list( &self, root: &ProjectPath, filter: IssueListFilter, ) -> Result, IssueStoreError> { let rows = read_index(root).await?; if filter .text .as_ref() .is_some_and(|text| !text.trim().is_empty()) { // Text search may need description/carnet, so fall back to source files. let needle = filter.text.as_ref().unwrap().trim().to_ascii_lowercase(); let base_filter = IssueListFilter { text: None, ..filter }; let mut out = Vec::new(); for row in rows { if !filter_matches(&row, &base_filter) { continue; } if row.title.to_ascii_lowercase().contains(&needle) { out.push(row); continue; } if let Ok(issue) = load_issue(root, row.issue_ref).await { if issue_contains(&issue, &needle) { out.push(row); } } } return Ok(out); } let mut rows = rows; rows.retain(|row| filter_matches(row, &filter)); Ok(rows) } async fn update( &self, root: &ProjectPath, issue: &Issue, expected_version: IssueVersion, ) -> Result<(), IssueStoreError> { let lock_path = mutation_lock_path(root); let _lock_file = acquire_lock(&lock_path, "issue mutation").await?; let result = async { issue .validate() .map_err(|err| IssueStoreError::Invalid(err.to_string()))?; let current = load_issue(root, issue.reference()).await?; if current.version != expected_version { return Err(IssueStoreError::VersionConflict { expected: expected_version, actual: current.version, }); } save_issue(root, issue).await?; rebuild_index(root).await?; Ok(()) } .await; let _ = tokio::fs::remove_file(&lock_path).await; result } async fn delete(&self, root: &ProjectPath, issue_ref: IssueRef) -> Result<(), IssueStoreError> { let lock_path = mutation_lock_path(root); let _lock_file = acquire_lock(&lock_path, "issue mutation").await?; let result = async { if !tokio::fs::try_exists(issue_path(root, issue_ref.number())) .await .map_err(io_error)? { return Err(IssueStoreError::NotFound); } tokio::fs::remove_dir_all(issue_dir(root, issue_ref.number())) .await .map_err(io_error)?; rebuild_index(root).await?; Ok(()) } .await; let _ = tokio::fs::remove_file(&lock_path).await; result } async fn read_carnet( &self, root: &ProjectPath, issue_ref: IssueRef, ) -> Result { let text = read_string(&carnet_path(root, issue_ref.number())).await?; parse_carnet_doc(&text) } async fn write_carnet( &self, root: &ProjectPath, issue_ref: IssueRef, carnet: MarkdownDoc, actor: IssueActor, now_ms: u64, expected_version: IssueVersion, ) -> Result { let lock_path = mutation_lock_path(root); let _lock_file = acquire_lock(&lock_path, "issue mutation").await?; let result = async { let current = load_issue(root, issue_ref).await?; if current.version != expected_version { return Err(IssueStoreError::VersionConflict { expected: expected_version, actual: current.version, }); } let updated = current .mutate(actor, now_ms, |issue| issue.carnet = carnet) .map_err(|err| IssueStoreError::Invalid(err.to_string()))?; save_issue(root, &updated).await?; rebuild_index(root).await?; self.read_carnet(root, issue_ref).await } .await; let _ = tokio::fs::remove_file(&lock_path).await; result } } #[async_trait] impl IssueNumberAllocator for FsIssueNumberAllocator { async fn allocate_next(&self, root: &ProjectPath) -> Result { let dir = issue_root(root); tokio::fs::create_dir_all(&dir).await.map_err(io_error)?; let lock_path = counter_lock_path(root); let _lock_file = acquire_lock(&lock_path, "issue number allocator").await?; let result = async { let path = counter_path(root); let current = match tokio::fs::read(&path).await { Ok(bytes) => { serde_json::from_slice::(&bytes) .map_err(|err| IssueStoreError::Store(err.to_string()))? .next_number } Err(err) if err.kind() == std::io::ErrorKind::NotFound => 1, Err(err) => return Err(io_error(err)), }; let allocated = IssueNumber::new(current) .map_err(|err| IssueStoreError::Invalid(err.to_string()))?; let next = CounterDoc { next_number: current + 1, }; let bytes = serde_json::to_vec_pretty(&next) .map_err(|err| IssueStoreError::Store(err.to_string()))?; write_atomic(&path, &bytes).await?; Ok(allocated) } .await; let _ = tokio::fs::remove_file(&lock_path).await; result } } fn issue_contains(issue: &Issue, needle: &str) -> bool { issue .description .as_str() .to_ascii_lowercase() .contains(needle) || issue.carnet.as_str().to_ascii_lowercase().contains(needle) } fn render_issue_doc(issue: &Issue) -> String { format!( "---\n\ id: {}\n\ number: {}\n\ title: {}\n\ status: {}\n\ priority: {}\n\ sprint: {}\n\ links: {}\n\ agentRefs: {}\n\ createdBy: {}\n\ updatedBy: {}\n\ createdAt: {}\n\ updatedAt: {}\n\ version: {}\n\ ---\n{}", json_string(&issue.id.to_string()), issue.number.get(), json_string(&issue.title), json_value(&issue.status), json_value(&issue.priority), json_value(&issue.sprint), json_value(&issue.links), json_value(&issue.agent_refs), json_value(&issue.created_by), json_value(&issue.updated_by), issue.created_at, issue.updated_at, issue.version.get(), issue.description.as_str() ) } fn render_carnet_doc(issue: &Issue) -> String { format!( "---\n\ issueRef: {}\n\ version: {}\n\ updatedBy: {}\n\ updatedAt: {}\n\ ---\n{}", json_string(&issue.reference().to_string()), issue.version.get(), json_value(&issue.updated_by), issue.updated_at, issue.carnet.as_str() ) } fn parse_issue_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 = IssueId::from_uuid( uuid::Uuid::parse_str(&id_raw).map_err(|err| IssueStoreError::Invalid(err.to_string()))?, ); let number = IssueNumber::new(parse_plain_u64(&map, "number")?) .map_err(|err| IssueStoreError::Invalid(err.to_string()))?; let title: String = parse_json_field(&map, "title")?; let status: IssueStatus = parse_json_field(&map, "status")?; let priority: IssuePriority = parse_json_field(&map, "priority")?; let sprint: Option = parse_optional_json_field(&map, "sprint")?.unwrap_or(None); let links: Vec = parse_json_field(&map, "links")?; let agent_refs: Vec = parse_json_field(&map, "agentRefs")?; 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 = IssueVersion::new(parse_plain_u64(&map, "version")?) .map_err(|err| IssueStoreError::Invalid(err.to_string()))?; Issue::rehydrate(Issue { id, number, title, description: MarkdownDoc::new(body), status, priority, sprint, carnet: MarkdownDoc::default(), links, agent_refs, created_by, updated_by, created_at, updated_at, version, }) .map_err(|err| IssueStoreError::Invalid(err.to_string())) } fn parse_carnet_doc(text: &str) -> Result { let (fm, body) = split_frontmatter(text)?; let map = parse_frontmatter_map(fm)?; let raw_ref: String = parse_json_field(&map, "issueRef")?; let issue_ref = IssueRef::from_str(&raw_ref).map_err(|err| IssueStoreError::Invalid(err.to_string()))?; let version = IssueVersion::new(parse_plain_u64(&map, "version")?) .map_err(|err| IssueStoreError::Invalid(err.to_string()))?; let updated_by: IssueActor = parse_json_field(&map, "updatedBy")?; let updated_at = parse_plain_u64(&map, "updatedAt")?; Ok(IssueCarnet { issue_ref, carnet: MarkdownDoc::new(body), version, updated_by, updated_at, }) } fn split_frontmatter(text: &str) -> Result<(&str, &str), IssueStoreError> { let rest = text .strip_prefix("---\n") .or_else(|| text.strip_prefix("---\r\n")) .ok_or_else(|| IssueStoreError::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(IssueStoreError::Invalid( "missing closing frontmatter fence".to_owned(), )) } fn parse_frontmatter_map(block: &str) -> Result, IssueStoreError> { 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(|| IssueStoreError::Invalid("frontmatter line missing ':'".to_owned()))?; map.insert(key.trim().to_owned(), value.trim().to_owned()); } Ok(map) } fn parse_plain_u64(map: &BTreeMap, key: &str) -> Result { map.get(key) .ok_or_else(|| IssueStoreError::Invalid(format!("missing frontmatter key `{key}`")))? .parse::() .map_err(|err| IssueStoreError::Invalid(err.to_string())) } fn parse_json_field( map: &BTreeMap, key: &str, ) -> Result { let value = map .get(key) .ok_or_else(|| IssueStoreError::Invalid(format!("missing frontmatter key `{key}`")))?; serde_json::from_str(value).map_err(|err| IssueStoreError::Invalid(err.to_string())) } fn parse_optional_json_field( map: &BTreeMap, key: &str, ) -> Result, IssueStoreError> { let Some(value) = map.get(key) else { return Ok(None); }; serde_json::from_str(value) .map(Some) .map_err(|err| IssueStoreError::Invalid(err.to_string())) } fn json_value(value: &T) -> String { serde_json::to_string(value).expect("serializing issue frontmatter cannot fail") } fn json_string(value: &str) -> String { serde_json::to_string(value).expect("serializing issue string cannot fail") }