diff --git a/server/src/service/common/item_id.rs b/server/src/service/common/item_id.rs new file mode 100644 index 00000000..fe5f3ae4 --- /dev/null +++ b/server/src/service/common/item_id.rs @@ -0,0 +1,70 @@ +//! Pure helpers for pipeline item ID parsing. +//! +//! Pipeline item IDs share the format `{number}_{type}_{slug}`, e.g. +//! `"42_story_foo"`, `"7_bug_bar"`, `"100_refactor_baz"`. The functions here +//! extract or validate the leading numeric segment without performing any I/O. + +/// Extract the numeric prefix from a pipeline item ID. +/// +/// Returns the leading digit sequence from IDs like `"42_story_foo"` → `"42"`. +/// Returns `None` if the ID has no leading digit sequence. +pub fn extract_item_number(item_id: &str) -> Option<&str> { + item_id + .split('_') + .next() + .filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit())) +} + +#[allow(dead_code)] +/// Return `true` if `item_id` has a valid `{digits}_` prefix format. +/// +/// Valid: `"42_story_foo"`, `"1_bug_bar"`. +/// Invalid: `"story_without_number"`, `""`, `"abc_story"`. +pub fn has_valid_id_prefix(item_id: &str) -> bool { + extract_item_number(item_id).is_some() +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn extract_item_number_extracts_prefix() { + assert_eq!(extract_item_number("42_story_foo"), Some("42")); + assert_eq!(extract_item_number("1_bug_bar"), Some("1")); + assert_eq!(extract_item_number("100_refactor_baz"), Some("100")); + assert_eq!( + extract_item_number("261_story_bot_notifications"), + Some("261") + ); + assert_eq!(extract_item_number("1_spike_research"), Some("1")); + } + + #[test] + fn extract_item_number_returns_none_for_no_numeric_prefix() { + assert_eq!(extract_item_number("story_without_number"), None); + assert_eq!(extract_item_number("abc_story"), None); + assert_eq!(extract_item_number("abc_story_thing"), None); + assert_eq!(extract_item_number(""), None); + } + + #[test] + fn extract_item_number_returns_none_for_empty_first_segment() { + // Leading underscore: first segment is "". + assert_eq!(extract_item_number("_story_thing"), None); + } + + #[test] + fn has_valid_id_prefix_returns_true_for_valid_ids() { + assert!(has_valid_id_prefix("42_story_foo")); + assert!(has_valid_id_prefix("1_bug_bar")); + } + + #[test] + fn has_valid_id_prefix_returns_false_for_invalid_ids() { + assert!(!has_valid_id_prefix("story_no_number")); + assert!(!has_valid_id_prefix("")); + } +} diff --git a/server/src/service/common/mod.rs b/server/src/service/common/mod.rs new file mode 100644 index 00000000..8595a336 --- /dev/null +++ b/server/src/service/common/mod.rs @@ -0,0 +1,6 @@ +//! Shared pure helpers used by multiple service modules. +//! +//! All sub-modules here are pure (no I/O, no side effects). Any helper that +//! duplicates logic across two or more service modules belongs here; anything +//! used by only one service stays in that service. +pub mod item_id; diff --git a/server/src/service/mod.rs b/server/src/service/mod.rs index 5acbb5d9..3397e5d8 100644 --- a/server/src/service/mod.rs +++ b/server/src/service/mod.rs @@ -8,6 +8,7 @@ pub mod agents; pub mod anthropic; pub mod bot_command; +pub mod common; pub mod diagnostics; pub mod events; pub mod file_io; diff --git a/server/src/service/notifications/format.rs b/server/src/service/notifications/format.rs index 43ef4a8f..d87887dc 100644 --- a/server/src/service/notifications/format.rs +++ b/server/src/service/notifications/format.rs @@ -4,6 +4,8 @@ //! or borrowed string data. They return `(plain_text, html)` pairs suitable //! for `ChatTransport::send_message`. +use crate::service::common::item_id::extract_item_number; + /// Human-readable display name for a pipeline stage directory. pub fn stage_display_name(stage: &str) -> &'static str { match stage { @@ -17,14 +19,6 @@ pub fn stage_display_name(stage: &str) -> &'static str { } } -/// Extract the numeric story number from an item ID like `"261_story_slug"`. -pub fn extract_story_number(item_id: &str) -> Option<&str> { - item_id - .split('_') - .next() - .filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit())) -} - /// Format a stage transition notification message. /// /// Returns `(plain_text, html)` suitable for `ChatTransport::send_message`. @@ -34,7 +28,7 @@ pub fn format_stage_notification( from_stage: &str, to_stage: &str, ) -> (String, String) { - let number = extract_story_number(item_id).unwrap_or(item_id); + let number = extract_item_number(item_id).unwrap_or(item_id); let name = story_name.unwrap_or(item_id); let prefix = if to_stage == "Done" { "\u{1f389} " } else { "" }; @@ -53,7 +47,7 @@ pub fn format_error_notification( story_name: Option<&str>, reason: &str, ) -> (String, String) { - let number = extract_story_number(item_id).unwrap_or(item_id); + let number = extract_item_number(item_id).unwrap_or(item_id); let name = story_name.unwrap_or(item_id); let plain = format!("\u{274c} #{number} {name} \u{2014} {reason}"); @@ -69,7 +63,7 @@ pub fn format_blocked_notification( story_name: Option<&str>, reason: &str, ) -> (String, String) { - let number = extract_story_number(item_id).unwrap_or(item_id); + let number = extract_item_number(item_id).unwrap_or(item_id); let name = story_name.unwrap_or(item_id); let plain = format!("\u{1f6ab} #{number} {name} \u{2014} BLOCKED: {reason}"); @@ -86,7 +80,7 @@ pub fn format_rate_limit_notification( story_name: Option<&str>, agent_name: &str, ) -> (String, String) { - let number = extract_story_number(item_id).unwrap_or(item_id); + let number = extract_item_number(item_id).unwrap_or(item_id); let name = story_name.unwrap_or(item_id); let plain = @@ -121,30 +115,6 @@ mod tests { assert_eq!(stage_display_name(""), "Unknown"); } - // ── extract_story_number ────────────────────────────────────────────────── - - #[test] - fn extract_story_number_parses_numeric_prefix() { - assert_eq!( - extract_story_number("261_story_bot_notifications"), - Some("261") - ); - assert_eq!(extract_story_number("42_bug_fix_thing"), Some("42")); - assert_eq!(extract_story_number("1_spike_research"), Some("1")); - } - - #[test] - fn extract_story_number_returns_none_for_non_numeric() { - assert_eq!(extract_story_number("abc_story_thing"), None); - assert_eq!(extract_story_number(""), None); - } - - #[test] - fn extract_story_number_returns_none_for_empty_first_segment() { - // Leading underscore: first segment is "" - assert_eq!(extract_story_number("_story_thing"), None); - } - // ── format_stage_notification ───────────────────────────────────────────── #[test] diff --git a/server/src/service/story/lifecycle.rs b/server/src/service/story/lifecycle.rs index 89a272ac..5d286988 100644 --- a/server/src/service/story/lifecycle.rs +++ b/server/src/service/story/lifecycle.rs @@ -3,26 +3,9 @@ //! These functions reason about story IDs and dependencies without performing //! any I/O. They inform routing decisions in `mod.rs` and the MCP adapter. -#[allow(dead_code)] -/// Extract the numeric prefix from a story ID (e.g. `"42"` from `"42_story_foo"`). -/// -/// Returns `None` if the ID has no leading digit sequence. -pub fn story_number(story_id: &str) -> Option<&str> { - let num = story_id.split('_').next()?; - if num.is_empty() || !num.chars().all(|c| c.is_ascii_digit()) { - return None; - } - Some(num) -} - -#[allow(dead_code)] -/// Return `true` if `story_id` has a valid `{digits}_` prefix format. -/// -/// Valid: `"42_story_foo"`, `"1_bug_bar"`. -/// Invalid: `"story_without_number"`, `""`, `"abc_story"`. -pub fn has_valid_id_prefix(story_id: &str) -> bool { - story_number(story_id).is_some() -} +pub use crate::service::common::item_id::{ + extract_item_number as story_number, has_valid_id_prefix, +}; // ── Tests ─────────────────────────────────────────────────────────────────────