huskies: merge 618_story_extract_mcp_only_domain_services
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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(""));
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user