//! Shared story-lookup helper for chat commands. //! //! All chat commands that need to find a work item by its numeric prefix //! use [`find_story_by_number`]. The lookup reads from: //! //! 1. **CRDT** — authoritative in-memory state. //! 2. **Content store / pipeline_items** — in-memory mirror of the //! `pipeline_items` table; catches items that are in the DB but whose //! CRDT entry hasn't been synced yet. use std::path::{Path, PathBuf}; /// Locate a work item by its numeric ID prefix. /// /// Returns `(story_id, stage_dir, path, content)` where: /// - `story_id` — full stem, e.g. `"503_story_some_feature"` /// - `stage_dir` — pipeline directory name, e.g. `"4_merge"` /// - `path` — canonical path under `.huskies/work/`; may not exist on disk /// for CRDT-only stories /// - `content` — markdown body from the content store when available; /// for the filesystem fallback this is the file contents read from disk; /// `None` only when the story was found in CRDT but has no content entry /// (callers may fall back to `fs::read_to_string(&path)` in that case) /// /// Returns `None` when no matching work item is found by any mechanism. pub(crate) fn find_story_by_number( project_root: &Path, number: &str, ) -> Option<(String, String, PathBuf, Option)> { // ── 1. CRDT (authoritative) ────────────────────────────────────────── // `read_all_items` returns None only when the CRDT layer is not yet // initialised (e.g. in unit tests or very early startup). if let Some(items) = crate::crdt_state::read_all_items() { for item in items { if item.story_id.split('_').next().unwrap_or("") == number { let path = project_root .join(".huskies") .join("work") .join(&item.stage) .join(format!("{}.md", item.story_id)); let content = crate::db::read_content(&item.story_id); return Some((item.story_id, item.stage, path, content)); } } } // ── 2. Content store + CRDT stage lookup ──────────────────────────── // Handles the edge case where an item is in the content store but was // somehow missing from the CRDT iteration above (e.g. concurrent write // or CRDT not yet initialised, such as in unit tests). for id in crate::db::all_content_ids() { if id.split('_').next().unwrap_or("") != number { continue; } let stage_dir = crate::crdt_state::read_item(&id) .map(|v| v.stage) .unwrap_or_else(|| "1_backlog".to_string()); let path = project_root .join(".huskies") .join("work") .join(&stage_dir) .join(format!("{id}.md")); let content = crate::db::read_content(&id); return Some((id, stage_dir, path, content)); } None } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; use crate::chat::test_helpers::write_story_file; #[test] fn not_found_returns_none() { let tmp = tempfile::TempDir::new().unwrap(); let result = find_story_by_number(tmp.path(), "999"); assert!( result.is_none(), "should return None when story is not found" ); } #[test] fn finds_story_in_content_store() { let tmp = tempfile::TempDir::new().unwrap(); write_story_file( tmp.path(), "1_backlog", "9970_story_some_feature.md", "---\nname: Some Feature\n---\n\n# Story 9970\n", ); let (story_id, _stage_dir, path, content) = find_story_by_number(tmp.path(), "9970").expect("should find story 9970"); assert_eq!(story_id, "9970_story_some_feature"); assert!( path.ends_with("9970_story_some_feature.md"), "unexpected path: {path:?}" ); assert!( content.as_deref().unwrap_or("").contains("Some Feature"), "content should include story text" ); } #[test] fn finds_bug_by_number() { let tmp = tempfile::TempDir::new().unwrap(); write_story_file( tmp.path(), "2_current", "7_bug_crash_on_login.md", "---\nname: Crash on login\n---\n", ); let (story_id, _stage_dir, _, _) = find_story_by_number(tmp.path(), "7").expect("should find bug 7"); assert_eq!(story_id, "7_bug_crash_on_login"); } #[test] fn numeric_prefix_must_match_exactly() { let tmp = tempfile::TempDir::new().unwrap(); // Story 9971 exists; searching for "99710" must not match "9971_story_foo". write_story_file( tmp.path(), "1_backlog", "9971_story_foo.md", "---\nname: Foo\n---\n", ); let result = find_story_by_number(tmp.path(), "99710"); assert!(result.is_none(), "number 99710 should not match story 9971"); } #[test] fn returned_path_points_into_project_root() { let tmp = tempfile::TempDir::new().unwrap(); write_story_file( tmp.path(), "4_merge", "503_story_migration.md", "---\nname: Migration\n---\n", ); let (_, _, path, _) = find_story_by_number(tmp.path(), "503").expect("should find story 503"); assert!( path.starts_with(tmp.path()), "path should be under the project root" ); } }