huskies: merge 618_story_extract_mcp_only_domain_services

This commit is contained in:
dave
2026-04-24 21:12:03 +00:00
parent 360bca45c8
commit c16d9e471d
29 changed files with 1924 additions and 409 deletions
+129
View File
@@ -0,0 +1,129 @@
//! Pure criterion helpers for `service::story`.
//!
//! These functions parse, validate, and manipulate story acceptance criteria
//! without performing any I/O.
use crate::workflow::{TestCaseResult, TestStatus};
use serde_json::Value;
/// Parse an optional JSON array of test-case objects into a `Vec<TestCaseResult>`.
///
/// Each object must have `"name"` (string) and `"status"` (`"pass"` or `"fail"`)
/// fields. The optional `"details"` field is preserved when present.
///
/// Returns an empty vector for `None` or `Value::Null` inputs.
///
/// # Errors
/// Returns `Err(String)` if the value is not an array, or if any item is
/// missing a required field or has an unrecognised status string.
pub fn parse_test_cases(value: Option<&Value>) -> Result<Vec<TestCaseResult>, String> {
let arr = match value {
Some(Value::Array(a)) => a,
Some(Value::Null) | None => return Ok(Vec::new()),
_ => return Err("Expected array for test cases".to_string()),
};
arr.iter()
.map(|item| {
let name = item
.get("name")
.and_then(|v| v.as_str())
.ok_or("Test case missing 'name'")?
.to_string();
let status_str = item
.get("status")
.and_then(|v| v.as_str())
.ok_or("Test case missing 'status'")?;
let status = match status_str {
"pass" => TestStatus::Pass,
"fail" => TestStatus::Fail,
other => {
return Err(format!(
"Invalid test status '{other}'. Use 'pass' or 'fail'."
));
}
};
let details = item
.get("details")
.and_then(|v| v.as_str())
.map(String::from);
Ok(TestCaseResult {
name,
status,
details,
})
})
.collect()
}
// ── Tests ─────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn parse_none_returns_empty() {
let result = parse_test_cases(None).unwrap();
assert!(result.is_empty());
}
#[test]
fn parse_null_value_returns_empty() {
let null_val = json!(null);
let result = parse_test_cases(Some(&null_val)).unwrap();
assert!(result.is_empty());
}
#[test]
fn parse_valid_cases() {
let input = json!([
{"name": "test1", "status": "pass"},
{"name": "test2", "status": "fail", "details": "assertion failed"}
]);
let result = parse_test_cases(Some(&input)).unwrap();
assert_eq!(result.len(), 2);
assert_eq!(result[0].name, "test1");
assert_eq!(result[0].status, TestStatus::Pass);
assert_eq!(result[1].status, TestStatus::Fail);
assert_eq!(result[1].details, Some("assertion failed".to_string()));
}
#[test]
fn parse_invalid_status_returns_error() {
let input = json!([{"name": "t", "status": "maybe"}]);
assert!(parse_test_cases(Some(&input)).is_err());
}
#[test]
fn parse_non_array_returns_error() {
let obj = json!({"invalid": "input"});
let result = parse_test_cases(Some(&obj));
assert!(result.is_err());
assert!(result.unwrap_err().contains("Expected array"));
}
#[test]
fn parse_missing_name_returns_error() {
let input = json!([{"status": "pass"}]);
let result = parse_test_cases(Some(&input));
assert!(result.is_err());
assert!(result.unwrap_err().contains("name"));
}
#[test]
fn parse_missing_status_returns_error() {
let input = json!([{"name": "test1"}]);
let result = parse_test_cases(Some(&input));
assert!(result.is_err());
assert!(result.unwrap_err().contains("status"));
}
#[test]
fn parse_details_is_optional() {
let input = json!([{"name": "no_details", "status": "pass"}]);
let result = parse_test_cases(Some(&input)).unwrap();
assert_eq!(result[0].details, None);
}
}
+77
View File
@@ -0,0 +1,77 @@
//! Pure front-matter helpers for `service::story`.
//!
//! These functions validate and inspect story front-matter field values
//! without performing any I/O. Parsing is delegated to `crate::io::story_metadata`.
#[allow(dead_code)]
/// Return `true` if `stage` is a recognised pipeline stage directory name.
///
/// Valid stage names match the `.huskies/work/N_name/` directory scheme.
pub fn is_valid_stage(stage: &str) -> bool {
matches!(
stage,
"1_backlog" | "2_current" | "3_qa" | "4_merge" | "5_done" | "6_archived"
)
}
#[allow(dead_code)]
/// Map a human-readable stage alias (e.g. `"backlog"`) to its directory name
/// (e.g. `"1_backlog"`). Returns `None` for unrecognised aliases.
pub fn stage_alias_to_dir(alias: &str) -> Option<&'static str> {
match alias {
"backlog" | "1_backlog" => Some("1_backlog"),
"current" | "2_current" => Some("2_current"),
"qa" | "3_qa" => Some("3_qa"),
"merge" | "4_merge" => Some("4_merge"),
"done" | "5_done" => Some("5_done"),
"archived" | "6_archived" => Some("6_archived"),
_ => None,
}
}
// ── Tests ─────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn is_valid_stage_accepts_all_known_stages() {
assert!(is_valid_stage("1_backlog"));
assert!(is_valid_stage("2_current"));
assert!(is_valid_stage("3_qa"));
assert!(is_valid_stage("4_merge"));
assert!(is_valid_stage("5_done"));
assert!(is_valid_stage("6_archived"));
}
#[test]
fn is_valid_stage_rejects_unknown() {
assert!(!is_valid_stage("current"));
assert!(!is_valid_stage("backlog"));
assert!(!is_valid_stage("7_future"));
assert!(!is_valid_stage(""));
}
#[test]
fn stage_alias_maps_short_names() {
assert_eq!(stage_alias_to_dir("backlog"), Some("1_backlog"));
assert_eq!(stage_alias_to_dir("current"), Some("2_current"));
assert_eq!(stage_alias_to_dir("qa"), Some("3_qa"));
assert_eq!(stage_alias_to_dir("merge"), Some("4_merge"));
assert_eq!(stage_alias_to_dir("done"), Some("5_done"));
assert_eq!(stage_alias_to_dir("archived"), Some("6_archived"));
}
#[test]
fn stage_alias_maps_full_dir_names() {
assert_eq!(stage_alias_to_dir("1_backlog"), Some("1_backlog"));
assert_eq!(stage_alias_to_dir("6_archived"), Some("6_archived"));
}
#[test]
fn stage_alias_returns_none_for_unknown() {
assert_eq!(stage_alias_to_dir("unknown"), None);
assert_eq!(stage_alias_to_dir(""), None);
}
}
+7
View File
@@ -0,0 +1,7 @@
//! Story I/O — the ONLY place in `service::story/` that may perform side effects.
//!
//! Currently, the bulk of story file I/O is handled by `crate::http::workflow`
//! (story file creation, criterion editing, stage moves) and
//! `crate::io::story_metadata` (front-matter parsing, merge-failure writes).
//! This file is the designated home for any future story-specific I/O helpers
//! that are extracted from those modules.
+58
View File
@@ -0,0 +1,58 @@
//! Pure story-lifecycle helpers for `service::story`.
//!
//! 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()
}
// ── Tests ─────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn story_number_extracts_prefix() {
assert_eq!(story_number("42_story_foo"), Some("42"));
assert_eq!(story_number("1_bug_bar"), Some("1"));
assert_eq!(story_number("100_refactor_baz"), Some("100"));
}
#[test]
fn story_number_returns_none_for_no_numeric_prefix() {
assert_eq!(story_number("story_without_number"), None);
assert_eq!(story_number("abc_story"), None);
assert_eq!(story_number(""), 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(""));
}
}
+120
View File
@@ -0,0 +1,120 @@
//! Story service — domain logic for creating, updating, and managing pipeline work items.
//!
//! Extracted from `http/mcp/story_tools.rs` following the conventions in
//! `docs/architecture/service-modules.md`:
//! - `mod.rs` (this file) — public API, typed [`Error`], orchestration
//! - `io.rs` — the ONLY place that performs side effects (story file I/O)
//! - `criteria.rs` — pure criterion parsing and validation
//! - `lifecycle.rs` — pure story-ID and lifecycle helpers
//! - `front_matter.rs` — pure front-matter field validation
//! - `validation.rs` — pure story content validation
//!
//! # State-Mutation Inventory (Axis 3)
//!
//! The story service orchestrates the following state writes across all
//! lifecycle operations:
//!
//! ## CRDT Writes
//! - `crdt_state::write_item` — on story/bug/spike/refactor creation (via `http::workflow`)
//! - `crdt_state::evict_item` — on story deletion or purge (tombstone op)
//! - `crdt_state::move_item` — on stage transitions (backlog → current → qa → merge → done → archived)
//!
//! ## Filesystem Shadow Writes
//! - `.huskies/work/1_backlog/<id>.md` — written on story/bug/spike/refactor creation
//! - `.huskies/work/<stage>/<id>.md` — moved between stages on lifecycle transitions
//! - `.huskies/work/<stage>/<id>.md` front matter — updated by `update_story`, `check_criterion`,
//! `edit_criterion`, `add_criterion`, `remove_criterion`, `accept_story`, `reject_qa`
//! - `.huskies/bugs/archive/<id>.md` — written on `close_bug`
//! - `.huskies/work/4_merge/<id>.md` merge_failure field — written by `report_merge_failure`
//!
//! ## Database Shadow Table
//! - `pipeline_items` row — updated on stage transitions and item creation/deletion
//! - `content_store` entry — updated on story content changes, deleted on purge/delete
pub mod criteria;
pub mod front_matter;
pub mod io;
pub mod lifecycle;
pub mod validation;
pub use criteria::parse_test_cases;
#[allow(unused_imports)]
pub use front_matter::{is_valid_stage, stage_alias_to_dir};
#[allow(unused_imports)]
pub use lifecycle::{has_valid_id_prefix, story_number};
#[allow(unused_imports)]
pub use validation::{is_valid_story_name, validate_criterion_index};
// ── Error type ────────────────────────────────────────────────────────────────
/// Typed errors returned by `service::story` functions.
///
/// HTTP handlers map these to status codes:
/// - [`Error::NotFound`] → 404 Not Found
/// - [`Error::Validation`] → 400 Bad Request
/// - [`Error::Conflict`] → 409 Conflict
/// - [`Error::Io`] → 500 Internal Server Error
/// - [`Error::UpstreamFailure`] → 500 Internal Server Error
#[allow(dead_code)]
#[derive(Debug)]
pub enum Error {
/// The requested story or work item was not found.
NotFound(String),
/// A required argument is missing or has an invalid value.
Validation(String),
/// The operation cannot proceed due to a conflicting state (e.g. unmerged branch).
Conflict(String),
/// A filesystem read or write operation failed.
Io(String),
/// An upstream dependency (agents, CRDT, git) returned an unexpected error.
UpstreamFailure(String),
}
impl std::fmt::Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NotFound(msg) => write!(f, "Not found: {msg}"),
Self::Validation(msg) => write!(f, "Validation error: {msg}"),
Self::Conflict(msg) => write!(f, "Conflict: {msg}"),
Self::Io(msg) => write!(f, "I/O error: {msg}"),
Self::UpstreamFailure(msg) => write!(f, "Upstream failure: {msg}"),
}
}
}
// ── Tests ─────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn error_display_not_found() {
let e = Error::NotFound("story 42 not found".to_string());
assert!(e.to_string().contains("Not found"));
}
#[test]
fn error_display_validation() {
let e = Error::Validation("name is required".to_string());
assert!(e.to_string().contains("Validation error"));
}
#[test]
fn error_display_conflict() {
let e = Error::Conflict("unmerged changes".to_string());
assert!(e.to_string().contains("Conflict"));
}
#[test]
fn error_display_io() {
let e = Error::Io("story file write failed".to_string());
assert!(e.to_string().contains("I/O error"));
}
#[test]
fn error_display_upstream_failure() {
let e = Error::UpstreamFailure("CRDT eviction failed".to_string());
assert!(e.to_string().contains("Upstream failure"));
}
}
+70
View File
@@ -0,0 +1,70 @@
//! Pure story validation helpers for `service::story`.
//!
//! These functions validate story content and metadata without performing
//! any I/O.
#[allow(dead_code)]
/// Return `true` if `name` is a valid story name.
///
/// A valid name must contain at least one alphanumeric character (letters or
/// digits). Pure punctuation, symbols, or blank names are rejected.
pub fn is_valid_story_name(name: &str) -> bool {
name.chars().any(|c| c.is_alphanumeric())
}
#[allow(dead_code)]
/// Validate that `criterion_index` is within bounds for `total_criteria`.
///
/// Returns `Ok(())` if valid, or `Err(message)` if out of range.
pub fn validate_criterion_index(
criterion_index: usize,
total_criteria: usize,
) -> Result<(), String> {
if criterion_index >= total_criteria {
Err(format!(
"criterion_index {criterion_index} is out of range — story has {total_criteria} criteria (0-based)"
))
} else {
Ok(())
}
}
// ── Tests ─────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn is_valid_story_name_accepts_alphanumeric() {
assert!(is_valid_story_name("My Story"));
assert!(is_valid_story_name("story123"));
assert!(is_valid_story_name("Test"));
}
#[test]
fn is_valid_story_name_rejects_symbols_only() {
assert!(!is_valid_story_name("!!!"));
assert!(!is_valid_story_name("---"));
assert!(!is_valid_story_name(""));
}
#[test]
fn validate_criterion_index_in_range() {
assert!(validate_criterion_index(0, 3).is_ok());
assert!(validate_criterion_index(2, 3).is_ok());
}
#[test]
fn validate_criterion_index_out_of_range() {
let e = validate_criterion_index(3, 3).unwrap_err();
assert!(e.contains("out of range"));
assert!(e.contains("3"));
}
#[test]
fn validate_criterion_index_zero_criteria() {
let e = validate_criterion_index(0, 0).unwrap_err();
assert!(e.contains("out of range"));
}
}