2026-04-12 13:11:23 +00:00
|
|
|
//! Story metadata — parses and modifies YAML front matter in story markdown files.
|
2026-03-22 19:07:07 +00:00
|
|
|
use serde::Deserialize;
|
|
|
|
|
use std::fs;
|
|
|
|
|
use std::path::Path;
|
|
|
|
|
|
|
|
|
|
/// QA mode for a story: determines how the pipeline handles post-coder review.
|
|
|
|
|
///
|
|
|
|
|
/// - `Server` — skip the QA agent; rely on server gate checks (clippy + tests).
|
|
|
|
|
/// If gates pass, advance straight to merge.
|
|
|
|
|
/// - `Agent` — spin up a QA agent (Claude session) to review code and run gates.
|
|
|
|
|
/// - `Human` — hold in QA for human approval after server gates pass.
|
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
|
|
|
pub enum QaMode {
|
|
|
|
|
Server,
|
|
|
|
|
Agent,
|
|
|
|
|
Human,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl QaMode {
|
|
|
|
|
/// Parse a string into a `QaMode`. Returns `None` for unrecognised values.
|
|
|
|
|
pub fn from_str(s: &str) -> Option<Self> {
|
|
|
|
|
match s.trim().to_lowercase().as_str() {
|
|
|
|
|
"server" => Some(Self::Server),
|
|
|
|
|
"agent" => Some(Self::Agent),
|
|
|
|
|
"human" => Some(Self::Human),
|
|
|
|
|
_ => None,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn as_str(&self) -> &'static str {
|
|
|
|
|
match self {
|
|
|
|
|
Self::Server => "server",
|
|
|
|
|
Self::Agent => "agent",
|
|
|
|
|
Self::Human => "human",
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl std::fmt::Display for QaMode {
|
|
|
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
|
|
|
f.write_str(self.as_str())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
|
|
|
|
pub struct StoryMetadata {
|
|
|
|
|
pub name: Option<String>,
|
|
|
|
|
pub coverage_baseline: Option<String>,
|
|
|
|
|
pub merge_failure: Option<String>,
|
|
|
|
|
pub agent: Option<String>,
|
|
|
|
|
pub review_hold: Option<bool>,
|
|
|
|
|
pub qa: Option<QaMode>,
|
|
|
|
|
/// Number of times this story has been retried at its current pipeline stage.
|
|
|
|
|
pub retry_count: Option<u32>,
|
|
|
|
|
/// When `true`, auto-assign will skip this story (retry limit exceeded).
|
|
|
|
|
pub blocked: Option<bool>,
|
2026-04-04 21:43:29 +00:00
|
|
|
/// Story numbers this story depends on. Auto-assign will skip this story
|
|
|
|
|
/// until all dependencies have reached `5_done` or `6_archived`.
|
|
|
|
|
pub depends_on: Option<Vec<u32>>,
|
2026-03-22 19:07:07 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
|
|
|
pub enum StoryMetaError {
|
|
|
|
|
MissingFrontMatter,
|
|
|
|
|
InvalidFrontMatter(String),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl std::fmt::Display for StoryMetaError {
|
|
|
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
|
|
|
match self {
|
|
|
|
|
StoryMetaError::MissingFrontMatter => write!(f, "Missing front matter"),
|
|
|
|
|
StoryMetaError::InvalidFrontMatter(msg) => write!(f, "Invalid front matter: {msg}"),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
|
|
|
struct FrontMatter {
|
|
|
|
|
name: Option<String>,
|
|
|
|
|
coverage_baseline: Option<String>,
|
|
|
|
|
merge_failure: Option<String>,
|
|
|
|
|
agent: Option<String>,
|
|
|
|
|
review_hold: Option<bool>,
|
|
|
|
|
/// Configurable QA mode field: "human", "server", or "agent".
|
|
|
|
|
qa: Option<String>,
|
|
|
|
|
/// Number of times this story has been retried at its current pipeline stage.
|
|
|
|
|
retry_count: Option<u32>,
|
|
|
|
|
/// When `true`, auto-assign will skip this story (retry limit exceeded).
|
|
|
|
|
blocked: Option<bool>,
|
2026-04-04 21:43:29 +00:00
|
|
|
/// Story numbers this story depends on.
|
|
|
|
|
depends_on: Option<Vec<u32>>,
|
2026-03-22 19:07:07 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn parse_front_matter(contents: &str) -> Result<StoryMetadata, StoryMetaError> {
|
|
|
|
|
let mut lines = contents.lines();
|
|
|
|
|
|
|
|
|
|
let first = lines.next().unwrap_or_default().trim();
|
|
|
|
|
if first != "---" {
|
|
|
|
|
return Err(StoryMetaError::MissingFrontMatter);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let mut front_lines = Vec::new();
|
|
|
|
|
for line in &mut lines {
|
|
|
|
|
let trimmed = line.trim();
|
|
|
|
|
if trimmed == "---" {
|
|
|
|
|
let raw = front_lines.join("\n");
|
|
|
|
|
let front: FrontMatter = serde_yaml::from_str(&raw)
|
|
|
|
|
.map_err(|e| StoryMetaError::InvalidFrontMatter(e.to_string()))?;
|
|
|
|
|
return Ok(build_metadata(front));
|
|
|
|
|
}
|
|
|
|
|
front_lines.push(line);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Err(StoryMetaError::InvalidFrontMatter(
|
|
|
|
|
"Missing closing front matter delimiter".to_string(),
|
|
|
|
|
))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn build_metadata(front: FrontMatter) -> StoryMetadata {
|
|
|
|
|
let qa = front.qa.as_deref().and_then(QaMode::from_str);
|
|
|
|
|
|
|
|
|
|
StoryMetadata {
|
|
|
|
|
name: front.name,
|
|
|
|
|
coverage_baseline: front.coverage_baseline,
|
|
|
|
|
merge_failure: front.merge_failure,
|
|
|
|
|
agent: front.agent,
|
|
|
|
|
review_hold: front.review_hold,
|
|
|
|
|
qa,
|
|
|
|
|
retry_count: front.retry_count,
|
|
|
|
|
blocked: front.blocked,
|
2026-04-04 21:43:29 +00:00
|
|
|
depends_on: front.depends_on,
|
2026-03-22 19:07:07 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Write or update a `merge_failure:` field in the YAML front matter of a story file.
|
|
|
|
|
///
|
2026-04-11 17:43:50 +00:00
|
|
|
/// The reason is stored as a quoted YAML string so that colons, hashes, and newlines
|
|
|
|
|
/// in the failure message do not break front-matter parsing.
|
|
|
|
|
/// If no front matter is present, this is a no-op (returns Ok).
|
|
|
|
|
pub fn write_merge_failure(path: &Path, reason: &str) -> Result<(), String> {
|
|
|
|
|
let contents =
|
|
|
|
|
fs::read_to_string(path).map_err(|e| format!("Failed to read story file: {e}"))?;
|
|
|
|
|
|
|
|
|
|
// Produce a YAML-safe inline quoted string: collapse newlines, escape inner quotes.
|
2026-04-13 14:07:08 +00:00
|
|
|
let escaped = reason
|
|
|
|
|
.replace('"', "\\\"")
|
|
|
|
|
.replace('\n', " ")
|
|
|
|
|
.replace('\r', "");
|
2026-04-11 17:43:50 +00:00
|
|
|
let yaml_value = format!("\"{escaped}\"");
|
|
|
|
|
|
|
|
|
|
let updated = set_front_matter_field(&contents, "merge_failure", &yaml_value);
|
|
|
|
|
fs::write(path, &updated).map_err(|e| format!("Failed to write story file: {e}"))?;
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-22 19:07:07 +00:00
|
|
|
/// Write `review_hold: true` to the YAML front matter of a story file.
|
|
|
|
|
///
|
|
|
|
|
/// Used to mark spikes that have passed QA and are waiting for human review.
|
|
|
|
|
pub fn write_review_hold(path: &Path) -> Result<(), String> {
|
|
|
|
|
let contents =
|
|
|
|
|
fs::read_to_string(path).map_err(|e| format!("Failed to read story file: {e}"))?;
|
|
|
|
|
let updated = set_front_matter_field(&contents, "review_hold", "true");
|
|
|
|
|
fs::write(path, &updated).map_err(|e| format!("Failed to write story file: {e}"))?;
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Remove a key from the YAML front matter of a story file on disk.
|
|
|
|
|
///
|
|
|
|
|
/// If front matter is present and contains the key, the line is removed.
|
|
|
|
|
/// If no front matter or key is not found, the file is left unchanged.
|
|
|
|
|
pub fn clear_front_matter_field(path: &Path, key: &str) -> Result<(), String> {
|
|
|
|
|
let contents =
|
|
|
|
|
fs::read_to_string(path).map_err(|e| format!("Failed to read story file: {e}"))?;
|
|
|
|
|
let updated = remove_front_matter_field(&contents, key);
|
|
|
|
|
if updated != contents {
|
|
|
|
|
fs::write(path, &updated).map_err(|e| format!("Failed to write story file: {e}"))?;
|
|
|
|
|
}
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Remove a key: value line from the YAML front matter of a markdown string.
|
|
|
|
|
///
|
|
|
|
|
/// If no front matter (opening `---`) is found or the key is absent, returns content unchanged.
|
|
|
|
|
fn remove_front_matter_field(contents: &str, key: &str) -> String {
|
|
|
|
|
let mut lines: Vec<String> = contents.lines().map(String::from).collect();
|
|
|
|
|
if lines.is_empty() || lines[0].trim() != "---" {
|
|
|
|
|
return contents.to_string();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let close_idx = match lines[1..].iter().position(|l| l.trim() == "---") {
|
|
|
|
|
Some(i) => i + 1,
|
|
|
|
|
None => return contents.to_string(),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let key_prefix = format!("{key}:");
|
|
|
|
|
if let Some(idx) = lines[1..close_idx]
|
|
|
|
|
.iter()
|
|
|
|
|
.position(|l| l.trim_start().starts_with(&key_prefix))
|
|
|
|
|
.map(|i| i + 1)
|
|
|
|
|
{
|
|
|
|
|
lines.remove(idx);
|
|
|
|
|
} else {
|
|
|
|
|
return contents.to_string();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let mut result = lines.join("\n");
|
|
|
|
|
if contents.ends_with('\n') {
|
|
|
|
|
result.push('\n');
|
|
|
|
|
}
|
|
|
|
|
result
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Insert or update a key: value pair in the YAML front matter of a markdown string.
|
|
|
|
|
///
|
|
|
|
|
/// If no front matter (opening `---`) is found, returns the content unchanged.
|
|
|
|
|
pub fn set_front_matter_field(contents: &str, key: &str, value: &str) -> String {
|
|
|
|
|
let mut lines: Vec<String> = contents.lines().map(String::from).collect();
|
|
|
|
|
if lines.is_empty() || lines[0].trim() != "---" {
|
|
|
|
|
return contents.to_string();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Find closing --- (search from index 1)
|
|
|
|
|
let close_idx = match lines[1..].iter().position(|l| l.trim() == "---") {
|
|
|
|
|
Some(i) => i + 1,
|
|
|
|
|
None => return contents.to_string(),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let key_prefix = format!("{key}:");
|
|
|
|
|
let existing_idx = lines[1..close_idx]
|
|
|
|
|
.iter()
|
|
|
|
|
.position(|l| l.trim_start().starts_with(&key_prefix))
|
|
|
|
|
.map(|i| i + 1);
|
|
|
|
|
|
|
|
|
|
let new_line = format!("{key}: {value}");
|
|
|
|
|
if let Some(idx) = existing_idx {
|
|
|
|
|
lines[idx] = new_line;
|
|
|
|
|
} else {
|
|
|
|
|
lines.insert(close_idx, new_line);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let mut result = lines.join("\n");
|
|
|
|
|
if contents.ends_with('\n') {
|
|
|
|
|
result.push('\n');
|
|
|
|
|
}
|
|
|
|
|
result
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 17:43:50 +00:00
|
|
|
/// Write `blocked: true` to the YAML front matter of a story file.
|
|
|
|
|
///
|
|
|
|
|
/// Used to mark stories that have exceeded the retry limit and should not
|
|
|
|
|
/// be auto-assigned again.
|
|
|
|
|
pub fn write_blocked(path: &Path) -> Result<(), String> {
|
|
|
|
|
let contents =
|
|
|
|
|
fs::read_to_string(path).map_err(|e| format!("Failed to read story file: {e}"))?;
|
|
|
|
|
let updated = set_front_matter_field(&contents, "blocked", "true");
|
|
|
|
|
fs::write(path, &updated).map_err(|e| format!("Failed to write story file: {e}"))?;
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-04 21:43:29 +00:00
|
|
|
/// Write or update a `depends_on:` field in the YAML front matter of a story file.
|
|
|
|
|
///
|
|
|
|
|
/// Serialises `deps` as an inline YAML sequence, e.g. `[477, 478]`.
|
|
|
|
|
/// If `deps` is empty the field is removed.
|
|
|
|
|
/// If no front matter is present, this is a no-op (returns Ok).
|
|
|
|
|
pub fn write_depends_on(path: &Path, deps: &[u32]) -> Result<(), String> {
|
|
|
|
|
let contents =
|
|
|
|
|
fs::read_to_string(path).map_err(|e| format!("Failed to read story file: {e}"))?;
|
|
|
|
|
let updated = if deps.is_empty() {
|
|
|
|
|
remove_front_matter_field(&contents, "depends_on")
|
|
|
|
|
} else {
|
|
|
|
|
let nums: Vec<String> = deps.iter().map(|n| n.to_string()).collect();
|
|
|
|
|
let yaml_value = format!("[{}]", nums.join(", "));
|
|
|
|
|
set_front_matter_field(&contents, "depends_on", &yaml_value)
|
|
|
|
|
};
|
|
|
|
|
fs::write(path, &updated).map_err(|e| format!("Failed to write story file: {e}"))?;
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Return the list of dependency story numbers from `story_id`'s front matter
|
|
|
|
|
/// that have **not** yet reached `5_done` or `6_archived`.
|
|
|
|
|
///
|
|
|
|
|
/// Returns an empty `Vec` when there are no unmet dependencies (including when
|
|
|
|
|
/// the story has no `depends_on` field at all).
|
|
|
|
|
pub fn check_unmet_deps(project_root: &Path, stage_dir: &str, story_id: &str) -> Vec<u32> {
|
|
|
|
|
let path = project_root
|
|
|
|
|
.join(".huskies")
|
|
|
|
|
.join("work")
|
|
|
|
|
.join(stage_dir)
|
|
|
|
|
.join(format!("{story_id}.md"));
|
|
|
|
|
let contents = match fs::read_to_string(&path) {
|
|
|
|
|
Ok(c) => c,
|
|
|
|
|
Err(_) => return Vec::new(),
|
|
|
|
|
};
|
2026-04-13 14:07:08 +00:00
|
|
|
let deps = match parse_front_matter(&contents)
|
|
|
|
|
.ok()
|
|
|
|
|
.and_then(|m| m.depends_on)
|
|
|
|
|
{
|
2026-04-04 21:43:29 +00:00
|
|
|
Some(d) => d,
|
|
|
|
|
None => return Vec::new(),
|
|
|
|
|
};
|
|
|
|
|
deps.into_iter()
|
|
|
|
|
.filter(|&dep| !dep_is_done(project_root, dep))
|
|
|
|
|
.collect()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Return `true` if a story with the given numeric ID exists in `5_done` or `6_archived`.
|
2026-04-09 18:27:25 +00:00
|
|
|
///
|
|
|
|
|
/// **Dependency semantics:** Both `5_done` and `6_archived` satisfy a `depends_on` entry.
|
|
|
|
|
/// Stories auto-sweep from `5_done` to `6_archived` after 4 hours, so by the time a dep
|
|
|
|
|
/// reaches `6_archived`, the dependent story has already been promoted. When a dep is
|
|
|
|
|
/// already in `6_archived` at the moment of promotion (e.g., it was manually archived or
|
|
|
|
|
/// abandoned before the dependent story was created), the dependency is still considered
|
|
|
|
|
/// satisfied — but a warning is logged so the user can see that the dep was archived, not
|
|
|
|
|
/// cleanly completed. Use `check_archived_deps` to detect this case.
|
2026-04-04 21:43:29 +00:00
|
|
|
fn dep_is_done(project_root: &Path, dep_number: u32) -> bool {
|
|
|
|
|
let prefix = format!("{dep_number}_");
|
|
|
|
|
let exact = dep_number.to_string();
|
|
|
|
|
for stage in &["5_done", "6_archived"] {
|
|
|
|
|
let dir = project_root.join(".huskies").join("work").join(stage);
|
|
|
|
|
if let Ok(entries) = fs::read_dir(&dir) {
|
|
|
|
|
for entry in entries.flatten() {
|
|
|
|
|
let path = entry.path();
|
|
|
|
|
if path.extension().and_then(|e| e.to_str()) != Some("md") {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if let Some(stem) = path.file_stem().and_then(|s| s.to_str())
|
|
|
|
|
&& (stem == exact || stem.starts_with(&prefix))
|
|
|
|
|
{
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
false
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-09 18:27:25 +00:00
|
|
|
/// Return `true` if a story with the given numeric ID exists specifically in `6_archived`
|
|
|
|
|
/// (i.e., it satisfies a `depends_on` but via the archive rather than via a clean done).
|
|
|
|
|
fn dep_is_archived(project_root: &Path, dep_number: u32) -> bool {
|
|
|
|
|
let prefix = format!("{dep_number}_");
|
|
|
|
|
let exact = dep_number.to_string();
|
2026-04-13 14:07:08 +00:00
|
|
|
let dir = project_root
|
|
|
|
|
.join(".huskies")
|
|
|
|
|
.join("work")
|
|
|
|
|
.join("6_archived");
|
2026-04-09 18:27:25 +00:00
|
|
|
if let Ok(entries) = fs::read_dir(&dir) {
|
|
|
|
|
for entry in entries.flatten() {
|
|
|
|
|
let path = entry.path();
|
|
|
|
|
if path.extension().and_then(|e| e.to_str()) != Some("md") {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if let Some(stem) = path.file_stem().and_then(|s| s.to_str())
|
|
|
|
|
&& (stem == exact || stem.starts_with(&prefix))
|
|
|
|
|
{
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Return the list of dependency story numbers from `story_id`'s front matter
|
|
|
|
|
/// that are in `6_archived` (satisfied via archive rather than via normal done).
|
|
|
|
|
///
|
|
|
|
|
/// Used to emit a warning when backlog promotion fires because a dep was archived
|
|
|
|
|
/// rather than cleanly completed. Returns an empty `Vec` when no deps are archived.
|
|
|
|
|
pub fn check_archived_deps(project_root: &Path, stage_dir: &str, story_id: &str) -> Vec<u32> {
|
|
|
|
|
let path = project_root
|
|
|
|
|
.join(".huskies")
|
|
|
|
|
.join("work")
|
|
|
|
|
.join(stage_dir)
|
|
|
|
|
.join(format!("{story_id}.md"));
|
|
|
|
|
let contents = match fs::read_to_string(&path) {
|
|
|
|
|
Ok(c) => c,
|
|
|
|
|
Err(_) => return Vec::new(),
|
|
|
|
|
};
|
2026-04-13 14:07:08 +00:00
|
|
|
let deps = match parse_front_matter(&contents)
|
|
|
|
|
.ok()
|
|
|
|
|
.and_then(|m| m.depends_on)
|
|
|
|
|
{
|
2026-04-09 18:27:25 +00:00
|
|
|
Some(d) => d,
|
|
|
|
|
None => return Vec::new(),
|
|
|
|
|
};
|
|
|
|
|
deps.into_iter()
|
|
|
|
|
.filter(|&dep| dep_is_archived(project_root, dep))
|
|
|
|
|
.collect()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Given an explicit list of dep numbers, return those already in `6_archived`.
|
|
|
|
|
///
|
|
|
|
|
/// Used at story-creation time when the dep list is known in memory (before the
|
|
|
|
|
/// story file has been written), so the caller does not need to parse the story.
|
|
|
|
|
pub fn check_archived_deps_from_list(project_root: &Path, deps: &[u32]) -> Vec<u32> {
|
|
|
|
|
deps.iter()
|
|
|
|
|
.copied()
|
|
|
|
|
.filter(|&dep| dep_is_archived(project_root, dep))
|
|
|
|
|
.collect()
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-08 03:03:59 +00:00
|
|
|
// ── In-memory content variants (no filesystem access) ───────────────
|
|
|
|
|
|
|
|
|
|
/// Remove a key from the YAML front matter of a markdown string (pure function).
|
2026-03-22 19:07:07 +00:00
|
|
|
///
|
2026-04-08 03:03:59 +00:00
|
|
|
/// Returns the updated content. If no front matter or key is not found,
|
|
|
|
|
/// returns the original content unchanged.
|
|
|
|
|
pub fn clear_front_matter_field_in_content(contents: &str, key: &str) -> String {
|
|
|
|
|
remove_front_matter_field(contents, key)
|
|
|
|
|
}
|
2026-03-22 19:07:07 +00:00
|
|
|
|
2026-04-08 03:03:59 +00:00
|
|
|
/// Append rejection notes to a markdown string (pure function).
|
|
|
|
|
///
|
|
|
|
|
/// Returns the updated content with a `## QA Rejection Notes` section appended.
|
|
|
|
|
pub fn write_rejection_notes_to_content(contents: &str, notes: &str) -> String {
|
2026-03-22 19:07:07 +00:00
|
|
|
let section = format!("\n\n## QA Rejection Notes\n\n{notes}\n");
|
2026-04-08 03:03:59 +00:00
|
|
|
format!("{contents}{section}")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Resolve the effective QA mode from story content (no filesystem access).
|
|
|
|
|
///
|
|
|
|
|
/// Parses front matter from `contents` and returns the `qa` field if present,
|
|
|
|
|
/// otherwise returns `default`.
|
|
|
|
|
pub fn resolve_qa_mode_from_content(contents: &str, default: QaMode) -> QaMode {
|
|
|
|
|
match parse_front_matter(contents) {
|
|
|
|
|
Ok(meta) => meta.qa.unwrap_or(default),
|
|
|
|
|
Err(_) => default,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Increment the `retry_count` field in story content (pure function).
|
|
|
|
|
///
|
|
|
|
|
/// Returns `(updated_content, new_count)`.
|
|
|
|
|
pub fn increment_retry_count_in_content(contents: &str) -> (String, u32) {
|
|
|
|
|
let current = parse_front_matter(contents)
|
|
|
|
|
.ok()
|
|
|
|
|
.and_then(|m| m.retry_count)
|
|
|
|
|
.unwrap_or(0);
|
|
|
|
|
let new_count = current + 1;
|
|
|
|
|
let updated = set_front_matter_field(contents, "retry_count", &new_count.to_string());
|
|
|
|
|
(updated, new_count)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Write `blocked: true` to story content (pure function).
|
|
|
|
|
pub fn write_blocked_in_content(contents: &str) -> String {
|
|
|
|
|
set_front_matter_field(contents, "blocked", "true")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Write or update `merge_failure` in story content (pure function).
|
|
|
|
|
pub fn write_merge_failure_in_content(contents: &str, reason: &str) -> String {
|
2026-04-13 14:07:08 +00:00
|
|
|
let escaped = reason
|
|
|
|
|
.replace('"', "\\\"")
|
|
|
|
|
.replace('\n', " ")
|
|
|
|
|
.replace('\r', "");
|
2026-04-08 03:03:59 +00:00
|
|
|
let yaml_value = format!("\"{escaped}\"");
|
|
|
|
|
set_front_matter_field(contents, "merge_failure", &yaml_value)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Write `review_hold: true` to story content (pure function).
|
|
|
|
|
pub fn write_review_hold_in_content(contents: &str) -> String {
|
|
|
|
|
set_front_matter_field(contents, "review_hold", "true")
|
2026-03-22 19:07:07 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Resolve the effective QA mode for a story file.
|
|
|
|
|
///
|
|
|
|
|
/// Reads the `qa` front matter field. If absent, falls back to `default`.
|
|
|
|
|
/// Spikes are **not** handled here — the caller is responsible for overriding
|
|
|
|
|
/// to `Human` for spikes.
|
|
|
|
|
pub fn resolve_qa_mode(path: &Path, default: QaMode) -> QaMode {
|
|
|
|
|
let contents = match fs::read_to_string(path) {
|
|
|
|
|
Ok(c) => c,
|
|
|
|
|
Err(_) => return default,
|
|
|
|
|
};
|
|
|
|
|
match parse_front_matter(&contents) {
|
|
|
|
|
Ok(meta) => meta.qa.unwrap_or(default),
|
|
|
|
|
Err(_) => default,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn parse_unchecked_todos(contents: &str) -> Vec<String> {
|
|
|
|
|
contents
|
|
|
|
|
.lines()
|
|
|
|
|
.filter_map(|line| {
|
|
|
|
|
let trimmed = line.trim();
|
2026-04-13 14:07:08 +00:00
|
|
|
trimmed.strip_prefix("- [ ] ").map(|text| text.to_string())
|
2026-03-22 19:07:07 +00:00
|
|
|
})
|
|
|
|
|
.collect()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn parses_front_matter_metadata() {
|
|
|
|
|
let input = r#"---
|
|
|
|
|
name: Establish the TDD Workflow and Gates
|
|
|
|
|
workflow: tdd
|
|
|
|
|
---
|
|
|
|
|
# Story 26
|
|
|
|
|
"#;
|
|
|
|
|
|
|
|
|
|
let meta = parse_front_matter(input).expect("front matter");
|
2026-04-13 14:07:08 +00:00
|
|
|
assert_eq!(
|
|
|
|
|
meta.name.as_deref(),
|
|
|
|
|
Some("Establish the TDD Workflow and Gates")
|
|
|
|
|
);
|
2026-03-22 19:07:07 +00:00
|
|
|
assert_eq!(meta.coverage_baseline, None);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn parses_coverage_baseline_from_front_matter() {
|
|
|
|
|
let input = "---\nname: Test Story\ncoverage_baseline: 78.5%\n---\n# Story\n";
|
|
|
|
|
let meta = parse_front_matter(input).expect("front matter");
|
|
|
|
|
assert_eq!(meta.coverage_baseline.as_deref(), Some("78.5%"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn set_front_matter_field_inserts_new_key() {
|
|
|
|
|
let input = "---\nname: My Story\n---\n# Body\n";
|
|
|
|
|
let output = set_front_matter_field(input, "coverage_baseline", "55.0%");
|
|
|
|
|
assert!(output.contains("coverage_baseline: 55.0%"));
|
|
|
|
|
assert!(output.contains("name: My Story"));
|
|
|
|
|
assert!(output.ends_with('\n'));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn set_front_matter_field_updates_existing_key() {
|
|
|
|
|
let input = "---\nname: My Story\ncoverage_baseline: 40.0%\n---\n# Body\n";
|
|
|
|
|
let output = set_front_matter_field(input, "coverage_baseline", "55.0%");
|
|
|
|
|
assert!(output.contains("coverage_baseline: 55.0%"));
|
|
|
|
|
assert!(!output.contains("40.0%"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn set_front_matter_field_no_op_without_front_matter() {
|
|
|
|
|
let input = "# No front matter\n";
|
|
|
|
|
let output = set_front_matter_field(input, "coverage_baseline", "55.0%");
|
|
|
|
|
assert_eq!(output, input);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn rejects_missing_front_matter() {
|
|
|
|
|
let input = "# Story 26\n";
|
|
|
|
|
assert_eq!(
|
|
|
|
|
parse_front_matter(input),
|
|
|
|
|
Err(StoryMetaError::MissingFrontMatter)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn rejects_unclosed_front_matter() {
|
|
|
|
|
let input = "---\nname: Test\n";
|
|
|
|
|
assert!(matches!(
|
|
|
|
|
parse_front_matter(input),
|
|
|
|
|
Err(StoryMetaError::InvalidFrontMatter(_))
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn remove_front_matter_field_removes_key() {
|
|
|
|
|
let input = "---\nname: My Story\nmerge_failure: \"something broke\"\n---\n# Body\n";
|
|
|
|
|
let output = remove_front_matter_field(input, "merge_failure");
|
|
|
|
|
assert!(!output.contains("merge_failure"));
|
|
|
|
|
assert!(output.contains("name: My Story"));
|
|
|
|
|
assert!(output.ends_with('\n'));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn remove_front_matter_field_no_op_when_absent() {
|
|
|
|
|
let input = "---\nname: My Story\n---\n# Body\n";
|
|
|
|
|
let output = remove_front_matter_field(input, "merge_failure");
|
|
|
|
|
assert_eq!(output, input);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn remove_front_matter_field_no_op_without_front_matter() {
|
|
|
|
|
let input = "# No front matter\n";
|
|
|
|
|
let output = remove_front_matter_field(input, "merge_failure");
|
|
|
|
|
assert_eq!(output, input);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn clear_front_matter_field_updates_file() {
|
|
|
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
|
|
|
let path = tmp.path().join("story.md");
|
2026-04-13 14:07:08 +00:00
|
|
|
std::fs::write(
|
|
|
|
|
&path,
|
|
|
|
|
"---\nname: Test\nmerge_failure: \"bad\"\n---\n# Story\n",
|
|
|
|
|
)
|
|
|
|
|
.unwrap();
|
2026-03-22 19:07:07 +00:00
|
|
|
clear_front_matter_field(&path, "merge_failure").unwrap();
|
|
|
|
|
let contents = std::fs::read_to_string(&path).unwrap();
|
|
|
|
|
assert!(!contents.contains("merge_failure"));
|
|
|
|
|
assert!(contents.contains("name: Test"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn parse_unchecked_todos_mixed() {
|
|
|
|
|
let input = "## AC\n- [ ] First thing\n- [x] Done thing\n- [ ] Second thing\n";
|
|
|
|
|
assert_eq!(
|
|
|
|
|
parse_unchecked_todos(input),
|
|
|
|
|
vec!["First thing", "Second thing"]
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn parse_unchecked_todos_all_checked() {
|
|
|
|
|
let input = "- [x] Done\n- [x] Also done\n";
|
|
|
|
|
assert!(parse_unchecked_todos(input).is_empty());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn parse_unchecked_todos_no_checkboxes() {
|
|
|
|
|
let input = "# Story\nSome text\n- A bullet\n";
|
|
|
|
|
assert!(parse_unchecked_todos(input).is_empty());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn parse_unchecked_todos_leading_whitespace() {
|
|
|
|
|
let input = " - [ ] Indented item\n";
|
|
|
|
|
assert_eq!(parse_unchecked_todos(input), vec!["Indented item"]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn parses_review_hold_from_front_matter() {
|
|
|
|
|
let input = "---\nname: Spike\nreview_hold: true\n---\n# Spike\n";
|
|
|
|
|
let meta = parse_front_matter(input).expect("front matter");
|
|
|
|
|
assert_eq!(meta.review_hold, Some(true));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn review_hold_defaults_to_none() {
|
|
|
|
|
let input = "---\nname: Story\n---\n# Story\n";
|
|
|
|
|
let meta = parse_front_matter(input).expect("front matter");
|
|
|
|
|
assert_eq!(meta.review_hold, None);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn write_review_hold_sets_field() {
|
|
|
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
|
|
|
let path = tmp.path().join("spike.md");
|
|
|
|
|
std::fs::write(&path, "---\nname: My Spike\n---\n# Spike\n").unwrap();
|
|
|
|
|
write_review_hold(&path).unwrap();
|
|
|
|
|
let contents = std::fs::read_to_string(&path).unwrap();
|
|
|
|
|
assert!(contents.contains("review_hold: true"));
|
|
|
|
|
assert!(contents.contains("name: My Spike"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn parses_qa_mode_from_front_matter() {
|
|
|
|
|
let input = "---\nname: Story\nqa: server\n---\n# Story\n";
|
|
|
|
|
let meta = parse_front_matter(input).expect("front matter");
|
|
|
|
|
assert_eq!(meta.qa, Some(QaMode::Server));
|
|
|
|
|
|
|
|
|
|
let input = "---\nname: Story\nqa: agent\n---\n# Story\n";
|
|
|
|
|
let meta = parse_front_matter(input).expect("front matter");
|
|
|
|
|
assert_eq!(meta.qa, Some(QaMode::Agent));
|
|
|
|
|
|
|
|
|
|
let input = "---\nname: Story\nqa: human\n---\n# Story\n";
|
|
|
|
|
let meta = parse_front_matter(input).expect("front matter");
|
|
|
|
|
assert_eq!(meta.qa, Some(QaMode::Human));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn qa_mode_defaults_to_none() {
|
|
|
|
|
let input = "---\nname: Story\n---\n# Story\n";
|
|
|
|
|
let meta = parse_front_matter(input).expect("front matter");
|
|
|
|
|
assert_eq!(meta.qa, None);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn resolve_qa_mode_uses_file_value() {
|
|
|
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
|
|
|
let path = tmp.path().join("story.md");
|
|
|
|
|
std::fs::write(&path, "---\nname: Test\nqa: human\n---\n# Story\n").unwrap();
|
|
|
|
|
assert_eq!(resolve_qa_mode(&path, QaMode::Server), QaMode::Human);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn resolve_qa_mode_falls_back_to_default() {
|
|
|
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
|
|
|
let path = tmp.path().join("story.md");
|
|
|
|
|
std::fs::write(&path, "---\nname: Test\n---\n# Story\n").unwrap();
|
|
|
|
|
assert_eq!(resolve_qa_mode(&path, QaMode::Server), QaMode::Server);
|
|
|
|
|
assert_eq!(resolve_qa_mode(&path, QaMode::Agent), QaMode::Agent);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn resolve_qa_mode_missing_file_uses_default() {
|
|
|
|
|
let path = std::path::Path::new("/nonexistent/story.md");
|
|
|
|
|
assert_eq!(resolve_qa_mode(path, QaMode::Server), QaMode::Server);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-04 21:43:29 +00:00
|
|
|
#[test]
|
|
|
|
|
fn parses_depends_on_from_front_matter() {
|
|
|
|
|
let input = "---\nname: Story\ndepends_on: [477, 478]\n---\n# Story\n";
|
|
|
|
|
let meta = parse_front_matter(input).expect("front matter");
|
|
|
|
|
assert_eq!(meta.depends_on, Some(vec![477, 478]));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn depends_on_defaults_to_none() {
|
|
|
|
|
let input = "---\nname: Story\n---\n# Story\n";
|
|
|
|
|
let meta = parse_front_matter(input).expect("front matter");
|
|
|
|
|
assert_eq!(meta.depends_on, None);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn write_depends_on_sets_field() {
|
|
|
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
|
|
|
let path = tmp.path().join("story.md");
|
|
|
|
|
std::fs::write(&path, "---\nname: Test\n---\n# Story\n").unwrap();
|
|
|
|
|
write_depends_on(&path, &[477, 478]).unwrap();
|
|
|
|
|
let contents = std::fs::read_to_string(&path).unwrap();
|
|
|
|
|
assert!(contents.contains("depends_on: [477, 478]"), "{contents}");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn write_depends_on_removes_field_when_empty() {
|
|
|
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
|
|
|
let path = tmp.path().join("story.md");
|
|
|
|
|
std::fs::write(&path, "---\nname: Test\ndepends_on: [477]\n---\n# Story\n").unwrap();
|
|
|
|
|
write_depends_on(&path, &[]).unwrap();
|
|
|
|
|
let contents = std::fs::read_to_string(&path).unwrap();
|
|
|
|
|
assert!(!contents.contains("depends_on"), "{contents}");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn check_unmet_deps_returns_empty_when_no_deps() {
|
|
|
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
|
|
|
let stage = tmp.path().join(".huskies/work/2_current");
|
|
|
|
|
std::fs::create_dir_all(&stage).unwrap();
|
|
|
|
|
std::fs::write(stage.join("10_story_foo.md"), "---\nname: Foo\n---\n").unwrap();
|
|
|
|
|
let unmet = check_unmet_deps(tmp.path(), "2_current", "10_story_foo");
|
|
|
|
|
assert!(unmet.is_empty());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn check_unmet_deps_returns_unmet_numbers() {
|
|
|
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
|
|
|
let current = tmp.path().join(".huskies/work/2_current");
|
|
|
|
|
let done = tmp.path().join(".huskies/work/5_done");
|
|
|
|
|
std::fs::create_dir_all(¤t).unwrap();
|
|
|
|
|
std::fs::create_dir_all(&done).unwrap();
|
|
|
|
|
// Dep 477 is done, dep 478 is not.
|
|
|
|
|
std::fs::write(done.join("477_story_dep.md"), "---\nname: Dep\n---\n").unwrap();
|
|
|
|
|
std::fs::write(
|
|
|
|
|
current.join("10_story_foo.md"),
|
|
|
|
|
"---\nname: Foo\ndepends_on: [477, 478]\n---\n",
|
|
|
|
|
)
|
|
|
|
|
.unwrap();
|
|
|
|
|
let unmet = check_unmet_deps(tmp.path(), "2_current", "10_story_foo");
|
|
|
|
|
assert_eq!(unmet, vec![478]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn check_unmet_deps_returns_empty_when_all_deps_done() {
|
|
|
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
|
|
|
let current = tmp.path().join(".huskies/work/2_current");
|
|
|
|
|
let done = tmp.path().join(".huskies/work/5_done");
|
|
|
|
|
std::fs::create_dir_all(¤t).unwrap();
|
|
|
|
|
std::fs::create_dir_all(&done).unwrap();
|
|
|
|
|
std::fs::write(done.join("477_story_a.md"), "---\nname: A\n---\n").unwrap();
|
|
|
|
|
std::fs::write(done.join("478_story_b.md"), "---\nname: B\n---\n").unwrap();
|
|
|
|
|
std::fs::write(
|
|
|
|
|
current.join("10_story_foo.md"),
|
|
|
|
|
"---\nname: Foo\ndepends_on: [477, 478]\n---\n",
|
|
|
|
|
)
|
|
|
|
|
.unwrap();
|
|
|
|
|
let unmet = check_unmet_deps(tmp.path(), "2_current", "10_story_foo");
|
|
|
|
|
assert!(unmet.is_empty());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn dep_is_done_finds_story_in_archived() {
|
|
|
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
|
|
|
let archived = tmp.path().join(".huskies/work/6_archived");
|
|
|
|
|
std::fs::create_dir_all(&archived).unwrap();
|
|
|
|
|
std::fs::write(archived.join("100_story_old.md"), "---\nname: Old\n---\n").unwrap();
|
|
|
|
|
assert!(dep_is_done(tmp.path(), 100));
|
|
|
|
|
assert!(!dep_is_done(tmp.path(), 101));
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-09 18:27:25 +00:00
|
|
|
// ── Bug 503: archived-dep visibility ─────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
/// check_archived_deps returns the dep IDs that are in 6_archived.
|
|
|
|
|
#[test]
|
|
|
|
|
fn check_archived_deps_returns_archived_dep_numbers() {
|
|
|
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
|
|
|
let current = tmp.path().join(".huskies/work/2_current");
|
|
|
|
|
let archived = tmp.path().join(".huskies/work/6_archived");
|
|
|
|
|
std::fs::create_dir_all(¤t).unwrap();
|
|
|
|
|
std::fs::create_dir_all(&archived).unwrap();
|
|
|
|
|
// Dep 100 is in 6_archived; dep 101 is not anywhere.
|
|
|
|
|
std::fs::write(archived.join("100_spike_old.md"), "---\nname: Old\n---\n").unwrap();
|
|
|
|
|
std::fs::write(
|
|
|
|
|
current.join("5_story_dependent.md"),
|
|
|
|
|
"---\nname: Dep\ndepends_on: [100, 101]\n---\n",
|
|
|
|
|
)
|
|
|
|
|
.unwrap();
|
|
|
|
|
let archived_deps = check_archived_deps(tmp.path(), "2_current", "5_story_dependent");
|
|
|
|
|
assert_eq!(archived_deps, vec![100]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// check_archived_deps returns empty when no deps are in 6_archived.
|
|
|
|
|
#[test]
|
|
|
|
|
fn check_archived_deps_returns_empty_when_dep_in_done() {
|
|
|
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
|
|
|
let backlog = tmp.path().join(".huskies/work/1_backlog");
|
|
|
|
|
let done = tmp.path().join(".huskies/work/5_done");
|
|
|
|
|
std::fs::create_dir_all(&backlog).unwrap();
|
|
|
|
|
std::fs::create_dir_all(&done).unwrap();
|
|
|
|
|
// Dep 200 is in 5_done (not archived).
|
|
|
|
|
std::fs::write(done.join("200_story_done.md"), "---\nname: Done\n---\n").unwrap();
|
|
|
|
|
std::fs::write(
|
|
|
|
|
backlog.join("5_story_waiting.md"),
|
|
|
|
|
"---\nname: Waiting\ndepends_on: [200]\n---\n",
|
|
|
|
|
)
|
|
|
|
|
.unwrap();
|
|
|
|
|
let archived_deps = check_archived_deps(tmp.path(), "1_backlog", "5_story_waiting");
|
|
|
|
|
assert!(archived_deps.is_empty());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// check_archived_deps returns empty when story has no depends_on.
|
|
|
|
|
#[test]
|
|
|
|
|
fn check_archived_deps_returns_empty_when_no_deps() {
|
|
|
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
|
|
|
let current = tmp.path().join(".huskies/work/2_current");
|
|
|
|
|
std::fs::create_dir_all(¤t).unwrap();
|
|
|
|
|
std::fs::write(current.join("3_story_free.md"), "---\nname: Free\n---\n").unwrap();
|
|
|
|
|
let archived_deps = check_archived_deps(tmp.path(), "2_current", "3_story_free");
|
|
|
|
|
assert!(archived_deps.is_empty());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// check_archived_deps_from_list returns archived dep IDs from an in-memory list.
|
|
|
|
|
#[test]
|
|
|
|
|
fn check_archived_deps_from_list_returns_archived_ids() {
|
|
|
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
|
|
|
let done = tmp.path().join(".huskies/work/5_done");
|
|
|
|
|
let archived = tmp.path().join(".huskies/work/6_archived");
|
|
|
|
|
std::fs::create_dir_all(&done).unwrap();
|
|
|
|
|
std::fs::create_dir_all(&archived).unwrap();
|
|
|
|
|
std::fs::write(done.join("10_story_done.md"), "---\nname: Done\n---\n").unwrap();
|
|
|
|
|
std::fs::write(archived.join("20_story_old.md"), "---\nname: Old\n---\n").unwrap();
|
|
|
|
|
// Only 20 is archived; 10 is in done, 30 is nowhere.
|
|
|
|
|
let result = check_archived_deps_from_list(tmp.path(), &[10, 20, 30]);
|
|
|
|
|
assert_eq!(result, vec![20]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// check_archived_deps_from_list returns empty when no deps are archived.
|
|
|
|
|
#[test]
|
|
|
|
|
fn check_archived_deps_from_list_empty_when_no_archived_deps() {
|
|
|
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
|
|
|
let done = tmp.path().join(".huskies/work/5_done");
|
|
|
|
|
std::fs::create_dir_all(&done).unwrap();
|
|
|
|
|
std::fs::write(done.join("10_story_done.md"), "---\nname: Done\n---\n").unwrap();
|
|
|
|
|
let result = check_archived_deps_from_list(tmp.path(), &[10]);
|
|
|
|
|
assert!(result.is_empty());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// dep_is_archived returns true only for stories in 6_archived, not 5_done.
|
|
|
|
|
#[test]
|
|
|
|
|
fn dep_is_archived_distinguishes_done_from_archived() {
|
|
|
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
|
|
|
let done = tmp.path().join(".huskies/work/5_done");
|
|
|
|
|
let archived = tmp.path().join(".huskies/work/6_archived");
|
|
|
|
|
std::fs::create_dir_all(&done).unwrap();
|
|
|
|
|
std::fs::create_dir_all(&archived).unwrap();
|
|
|
|
|
std::fs::write(done.join("10_story_done.md"), "---\nname: Done\n---\n").unwrap();
|
|
|
|
|
std::fs::write(archived.join("20_story_old.md"), "---\nname: Old\n---\n").unwrap();
|
|
|
|
|
// 10 is in 5_done only — not archived.
|
|
|
|
|
assert!(!dep_is_archived(tmp.path(), 10));
|
|
|
|
|
// 20 is in 6_archived — archived.
|
|
|
|
|
assert!(dep_is_archived(tmp.path(), 20));
|
|
|
|
|
// 99 doesn't exist anywhere.
|
|
|
|
|
assert!(!dep_is_archived(tmp.path(), 99));
|
|
|
|
|
}
|
2026-03-22 19:07:07 +00:00
|
|
|
}
|