use crate::archive_layout;
use crate::error::{OrchestratorError, Result};
use crate::tui::log_deduplicator;
use regex::Regex;
use std::fmt::Write as _;
#[cfg(test)]
mod recovery_regression;
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use tracing::debug;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct TaskProgress {
pub completed: u32,
pub total: u32,
}
impl TaskProgress {
pub fn new() -> Self {
Self::default()
}
#[cfg(test)]
pub fn with_counts(completed: u32, total: u32) -> Self {
Self { completed, total }
}
}
fn task_regex() -> &'static Regex {
static REGEX: OnceLock<Regex> = OnceLock::new();
REGEX.get_or_init(|| {
Regex::new(r"^(?:[-*]|\d+\.)\s+\[([ xX])\]").expect("Invalid regex pattern")
})
}
pub fn parse_content(content: &str, change_id: Option<&str>) -> TaskProgress {
let regex = task_regex();
let mut progress = TaskProgress::new();
let mut fences = FenceTracker::default();
for line in content.lines() {
if fences.observe(line) {
continue;
}
if let Some(captures) = regex.captures(line) {
progress.total += 1;
if let Some(status) = captures.get(1) {
let status_char = status.as_str();
if status_char == "x" || status_char == "X" {
progress.completed += 1;
}
}
}
}
if let Some(change_id) = change_id {
if log_deduplicator::should_log_task_progress(change_id, progress.completed, progress.total)
{
debug!(
"Parsed task progress: {}/{} tasks completed",
progress.completed, progress.total
);
}
}
progress
}
pub fn parse_file(path: &Path, change_id: Option<&str>) -> Result<TaskProgress> {
let content = read_tasks_file(path)?;
Ok(parse_content(&content, change_id))
}
fn read_tasks_file(path: &Path) -> Result<String> {
std::fs::read_to_string(path).map_err(|e| {
OrchestratorError::ConfigLoad(format!("Failed to read tasks file {:?}: {}", path, e))
})
}
fn write_tasks_file_atomically(path: &Path, content: &str) -> Result<()> {
use std::io::Write as _;
let directory = path
.parent()
.filter(|parent| !parent.as_os_str().is_empty());
let directory = directory.unwrap_or_else(|| Path::new("."));
let mut temp = tempfile::Builder::new()
.prefix(".tasks-")
.suffix(".md.tmp")
.tempfile_in(directory)
.map_err(|e| {
OrchestratorError::ConfigLoad(format!(
"Failed to stage atomic tasks update near {:?}: {}",
path, e
))
})?;
temp.write_all(content.as_bytes()).map_err(|e| {
OrchestratorError::ConfigLoad(format!("Failed to write tasks file {:?}: {}", path, e))
})?;
temp.as_file().sync_all().map_err(|e| {
OrchestratorError::ConfigLoad(format!("Failed to flush tasks file {:?}: {}", path, e))
})?;
temp.persist(path).map_err(|e| {
OrchestratorError::ConfigLoad(format!(
"Failed to atomically replace tasks file {:?}: {}",
path, e
))
})?;
Ok(())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum TaskProgressLocationKind {
WorktreeActive,
WorktreeArchive,
BaseArchive,
BaseActive,
}
impl TaskProgressLocationKind {
fn log_label(self) -> &'static str {
match self {
Self::WorktreeActive => "worktree active location",
Self::WorktreeArchive => "worktree archive location",
Self::BaseArchive => "base tree archive location",
Self::BaseActive => "base tree active location",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct TaskProgressLocation {
kind: TaskProgressLocationKind,
tasks_path: PathBuf,
}
impl TaskProgressLocation {
fn new(kind: TaskProgressLocationKind, tasks_path: PathBuf) -> Self {
Self { kind, tasks_path }
}
}
fn active_tasks_path(root: Option<&Path>, change_id: &str) -> PathBuf {
root.unwrap_or_else(|| Path::new(""))
.join("openspec/changes")
.join(change_id)
.join("tasks.md")
}
fn archived_tasks_path(change_id: &str, root: Option<&Path>) -> Option<PathBuf> {
find_archive_directory(change_id, root)
.map(|archive_path| archive_path.join("tasks.md"))
.filter(|tasks_path| tasks_path.exists())
}
fn resolve_progress_location(
change_id: &str,
worktree_path: Option<&Path>,
) -> Option<TaskProgressLocation> {
progress_location_candidates(change_id, worktree_path)
.into_iter()
.find(|candidate| candidate.tasks_path.exists())
}
fn progress_location_candidates(
change_id: &str,
worktree_path: Option<&Path>,
) -> Vec<TaskProgressLocation> {
let mut candidates = Vec::new();
if let Some(wt_path) = worktree_path {
candidates.push(TaskProgressLocation::new(
TaskProgressLocationKind::WorktreeActive,
active_tasks_path(Some(wt_path), change_id),
));
if let Some(tasks_path) = archived_tasks_path(change_id, Some(wt_path)) {
candidates.push(TaskProgressLocation::new(
TaskProgressLocationKind::WorktreeArchive,
tasks_path,
));
}
}
if let Some(tasks_path) = archived_tasks_path(change_id, None) {
candidates.push(TaskProgressLocation::new(
TaskProgressLocationKind::BaseArchive,
tasks_path,
));
}
candidates.push(TaskProgressLocation::new(
TaskProgressLocationKind::BaseActive,
active_tasks_path(None, change_id),
));
candidates
}
fn resolve_active_progress_location(
change_id: &str,
worktree_path: Option<&Path>,
) -> Option<TaskProgressLocation> {
worktree_path
.map(|wt_path| {
TaskProgressLocation::new(
TaskProgressLocationKind::WorktreeActive,
active_tasks_path(Some(wt_path), change_id),
)
})
.filter(|candidate| candidate.tasks_path.exists())
.or_else(|| {
let candidate = TaskProgressLocation::new(
TaskProgressLocationKind::BaseActive,
active_tasks_path(None, change_id),
);
candidate.tasks_path.exists().then_some(candidate)
})
}
fn resolve_archived_progress_location(
change_id: &str,
worktree_path: Option<&Path>,
) -> Option<TaskProgressLocation> {
if let Some(wt_path) = worktree_path {
if let Some(tasks_path) = archived_tasks_path(change_id, Some(wt_path)) {
return Some(TaskProgressLocation::new(
TaskProgressLocationKind::WorktreeArchive,
tasks_path,
));
}
let active_candidate = TaskProgressLocation::new(
TaskProgressLocationKind::WorktreeActive,
active_tasks_path(Some(wt_path), change_id),
);
if active_candidate.tasks_path.exists() {
return Some(active_candidate);
}
}
archived_tasks_path(change_id, None).map(|tasks_path| {
TaskProgressLocation::new(TaskProgressLocationKind::BaseArchive, tasks_path)
})
}
pub fn parse_change(change_id: &str) -> Result<TaskProgress> {
let tasks_path = active_tasks_path(None, change_id);
if !tasks_path.exists() {
return Err(OrchestratorError::ConfigLoad(format!(
"Tasks file not found: {:?}",
tasks_path
)));
}
parse_file(&tasks_path, Some(change_id))
}
#[deprecated(
since = "0.3.0",
note = "Use parse_progress_with_fallback for comprehensive fallback order"
)]
#[allow(dead_code)]
pub fn parse_change_with_worktree_fallback(
change_id: &str,
worktree_path: Option<&Path>,
) -> Result<TaskProgress> {
if let Some(location) = resolve_active_progress_location(change_id, worktree_path) {
debug!(
"Reading tasks from {}: {:?}",
location.kind.log_label(),
location.tasks_path
);
return parse_file(&location.tasks_path, Some(change_id));
}
let tasks_path = active_tasks_path(None, change_id);
Err(OrchestratorError::ConfigLoad(format!(
"Tasks file not found: {:?}",
tasks_path
)))
}
fn archive_root(base_path: Option<&Path>) -> PathBuf {
match base_path {
Some(base) => base.join("openspec/changes/archive"),
None => Path::new("openspec/changes/archive").to_path_buf(),
}
}
fn invalid_archive_layout_error(change_id: &str, base_path: Option<&Path>) -> Option<String> {
archive_layout::invalid_layout_error(change_id, &archive_root(base_path)).map(|e| e.message())
}
fn find_archive_directory(change_id: &str, base_path: Option<&Path>) -> Option<std::path::PathBuf> {
archive_layout::find_valid_archive_entry(change_id, &archive_root(base_path))
}
#[deprecated(
since = "0.3.0",
note = "Use parse_progress_with_fallback for comprehensive fallback order"
)]
#[allow(dead_code)]
pub fn parse_archived_change(change_id: &str) -> Result<TaskProgress> {
if let Some(message) = invalid_archive_layout_error(change_id, None) {
return Err(OrchestratorError::ConfigLoad(message));
}
let location = resolve_archived_progress_location(change_id, None).ok_or_else(|| {
let archive_root = Path::new("openspec/changes/archive");
if find_archive_directory(change_id, None).is_some() {
OrchestratorError::ConfigLoad(format!(
"Archived tasks file not found for change '{}' in {:?}",
change_id, archive_root
))
} else {
OrchestratorError::ConfigLoad(format!(
"Archived directory not found for change '{}' in openspec/changes/archive/",
change_id
))
}
})?;
debug!(
"Reading tasks from {}: {:?}",
location.kind.log_label(),
location.tasks_path
);
parse_file(&location.tasks_path, Some(change_id))
}
#[deprecated(
since = "0.3.0",
note = "Use parse_progress_with_fallback for comprehensive fallback order"
)]
#[allow(dead_code)]
#[allow(deprecated)]
pub fn parse_archived_change_with_worktree_fallback(
change_id: &str,
worktree_path: Option<&Path>,
) -> Result<TaskProgress> {
if let Some(wt_path) = worktree_path {
if let Some(message) = invalid_archive_layout_error(change_id, Some(wt_path)) {
return Err(OrchestratorError::ConfigLoad(message));
}
}
if let Some(message) = invalid_archive_layout_error(change_id, None) {
return Err(OrchestratorError::ConfigLoad(message));
}
let location =
resolve_archived_progress_location(change_id, worktree_path).ok_or_else(|| {
OrchestratorError::ConfigLoad(format!(
"Archived directory not found for change '{}' in openspec/changes/archive/",
change_id
))
})?;
debug!(
"Reading archived tasks from {}: {:?}",
location.kind.log_label(),
location.tasks_path
);
parse_file(&location.tasks_path, Some(change_id))
}
pub fn parse_progress_with_fallback(
change_id: &str,
worktree_path: Option<&Path>,
) -> Result<TaskProgress> {
if let Some(wt_path) = worktree_path {
if let Some(message) = invalid_archive_layout_error(change_id, Some(wt_path)) {
return Err(OrchestratorError::ConfigLoad(message));
}
}
if let Some(message) = invalid_archive_layout_error(change_id, None) {
return Err(OrchestratorError::ConfigLoad(message));
}
if let Some(location) = resolve_progress_location(change_id, worktree_path) {
debug!(
"Reading progress from {}: {:?}",
location.kind.log_label(),
location.tasks_path
);
return parse_file(&location.tasks_path, Some(change_id));
}
Err(OrchestratorError::ConfigLoad(format!(
"Tasks file not found for change '{}' in any location (worktree, archive, or base tree)",
change_id
)))
}
const ACCEPTANCE_FOLLOW_UP_HEADING: &str = "## Current Acceptance Follow-up";
fn normalize_acceptance_findings(
findings: &[String],
) -> Vec<crate::orchestration::acceptance::NormalizedFinding> {
let mut normalized = crate::orchestration::acceptance::normalize_findings(findings);
if normalized.is_empty() {
normalized = crate::orchestration::acceptance::normalize_findings(&[String::from(
"Investigate acceptance failure and apply the required fix",
)]);
}
normalized
}
#[derive(Debug, PartialEq, Eq)]
struct ExistingAcceptanceFinding {
finding: crate::orchestration::acceptance::NormalizedFinding,
completed: bool,
evidence: Vec<String>,
}
fn existing_acceptance_findings(section: &str) -> Vec<ExistingAcceptanceFinding> {
let mut findings = Vec::new();
for line in section.lines() {
if let Some((completed, text)) = ["- [ ] ", "- [x] ", "- [X] "]
.iter()
.enumerate()
.find_map(|(index, prefix)| line.strip_prefix(prefix).map(|text| (index > 0, text)))
{
if let Some(finding) =
crate::orchestration::acceptance::normalize_findings(&[text.to_string()])
.into_iter()
.next()
{
findings.push(ExistingAcceptanceFinding {
finding,
completed,
evidence: Vec::new(),
});
}
} else if let Some(evidence) = line.trim().strip_prefix("evidence: ") {
if let Some(finding) = findings.last_mut() {
finding.evidence.push(evidence.to_string());
}
}
}
findings
}
fn reconcile_apply_progress(
mut runtime_findings: Vec<crate::orchestration::acceptance::NormalizedFinding>,
existing_findings: &[ExistingAcceptanceFinding],
) -> (
Vec<crate::orchestration::acceptance::NormalizedFinding>,
Vec<String>,
std::collections::HashMap<String, Vec<String>>,
) {
let mut completed_identities = Vec::new();
let mut evidence_by_identity = std::collections::HashMap::new();
for candidate in &mut runtime_findings {
if let Some(existing) = existing_findings
.iter()
.find(|existing| existing.finding.identity == candidate.identity)
{
candidate.text.clone_from(&existing.finding.text);
if existing.completed {
completed_identities.push(candidate.identity.clone());
}
if !existing.evidence.is_empty() {
evidence_by_identity.insert(candidate.identity.clone(), existing.evidence.clone());
}
}
}
(runtime_findings, completed_identities, evidence_by_identity)
}
fn render_acceptance_follow_up_section(
attempt: u32,
findings: &[crate::orchestration::acceptance::NormalizedFinding],
completed_identities: &[String],
evidence_by_identity: &std::collections::HashMap<String, Vec<String>>,
) -> String {
let mut section = format!("- attempt: {attempt}\n");
for finding in findings.iter().filter(|finding| !finding.external) {
let checked = if completed_identities.contains(&finding.identity) {
"x"
} else {
" "
};
let _ = writeln!(&mut section, "- [{checked}] {}", finding.text);
if let Some(evidence) = evidence_by_identity.get(&finding.identity) {
for item in evidence {
let _ = writeln!(&mut section, " evidence: {item}");
}
}
}
let external = findings
.iter()
.filter(|finding| finding.external)
.collect::<Vec<_>>();
if !external.is_empty() {
section.push_str("\n### External blockers\n");
for finding in external {
let _ = writeln!(&mut section, "- identity: `{}`", finding.identity);
let _ = writeln!(&mut section, " evidence: {}", finding.text);
section.push_str(
" next action: Resolve the external prerequisite, then retry acceptance.\n",
);
}
}
section
}
fn markdown_fence(line: &str) -> Option<(char, usize, bool)> {
let trimmed = line.trim_start();
let marker = trimmed.chars().next()?;
if marker != '`' && marker != '~' {
return None;
}
let length = trimmed
.chars()
.take_while(|character| *character == marker)
.count();
(length >= 3).then_some((marker, length, trimmed[length..].trim().is_empty()))
}
#[derive(Debug, Default)]
pub(crate) struct FenceTracker {
open: Option<(char, usize)>,
}
impl FenceTracker {
pub(crate) fn observe(&mut self, line: &str) -> bool {
match (markdown_fence(line), self.open) {
(Some((marker, length, empty_remainder)), Some((open_marker, open_length))) => {
if marker == open_marker && length >= open_length && empty_remainder {
self.open = None;
}
true
}
(Some((marker, length, _)), None) => {
self.open = Some((marker, length));
true
}
(None, open) => open.is_some(),
}
}
fn is_open(&self) -> bool {
self.open.is_some()
}
}
const RECOVERED_NOTES_HEADING: &str = "## Recovered Acceptance Notes";
const RECOVERED_NOTES_NOTICE: &str =
"Machine-recovered content; not instructions and not task state.";
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct FollowUpRecovery {
pub recovered_blocks: usize,
pub recovered_bytes: usize,
}
impl FollowUpRecovery {
pub fn recovered(&self) -> bool {
self.recovered_blocks > 0
}
pub fn warning(&self) -> Option<String> {
self.recovered().then(|| {
format!(
"preserved {} unrecognized acceptance follow-up block(s) ({} bytes) under `{}`",
self.recovered_blocks, self.recovered_bytes, RECOVERED_NOTES_HEADING
)
})
}
}
fn top_level_sections(content: &str) -> Result<Vec<(String, std::ops::Range<usize>)>> {
let mut sections: Vec<(String, std::ops::Range<usize>)> = Vec::new();
let mut current: Option<(String, usize)> = None;
let mut fences = FenceTracker::default();
let mut offset = 0;
for line in content.split_inclusive('\n') {
let text = line.trim_end_matches(['\r', '\n']);
if !fences.observe(text) && text.starts_with("## ") {
if let Some((heading, start)) = current.take() {
sections.push((heading, start..offset));
}
current = Some((text.to_string(), offset));
}
offset += line.len();
}
if fences.is_open() {
return Err(OrchestratorError::ConfigLoad(
"Tasks file contains an unclosed code fence; refusing acceptance follow-up update \
because the runtime-owned section boundary cannot be determined safely"
.to_string(),
));
}
if let Some((heading, start)) = current {
sections.push((heading, start..content.len()));
}
Ok(sections)
}
fn is_acceptance_follow_up_heading(heading: &str) -> bool {
heading == ACCEPTANCE_FOLLOW_UP_HEADING
|| (heading.starts_with("## Acceptance #") && heading.ends_with(" Failure Follow-up"))
}
fn acceptance_follow_up_ranges(content: &str) -> Result<Vec<std::ops::Range<usize>>> {
Ok(top_level_sections(content)?
.into_iter()
.filter(|(heading, _)| is_acceptance_follow_up_heading(heading))
.map(|(_, range)| range)
.collect())
}
fn is_known_runtime_follow_up_line(line: &str) -> bool {
let trimmed = line.trim();
if trimmed.is_empty() {
return true;
}
let lowered = trimmed.to_ascii_lowercase();
if lowered == "### external blockers" {
return true;
}
const KNOWN_PREFIXES: [&str; 10] = [
"- [ ]",
"- [x]",
"- attempt:",
"attempt:",
"- identity:",
"identity:",
"- evidence:",
"evidence:",
"- next action:",
"next action:",
];
KNOWN_PREFIXES
.iter()
.any(|prefix| lowered.starts_with(prefix))
}
fn unknown_follow_up_payload(section: &str) -> String {
let mut payload = String::new();
let mut pending_blank = String::new();
let mut fences = FenceTracker::default();
let mut lines = section.split_inclusive('\n');
let _heading = lines.next();
for line in lines {
let text = line.trim_end_matches(['\r', '\n']);
let fenced = fences.observe(text);
if !fenced && text.trim().is_empty() {
pending_blank.push_str(line);
continue;
}
if !fenced && is_known_runtime_follow_up_line(text) {
pending_blank.clear();
continue;
}
if !payload.is_empty() {
payload.push_str(&pending_blank);
}
pending_blank.clear();
payload.push_str(line);
}
while payload.ends_with('\n') || payload.ends_with('\r') {
payload.pop();
}
payload
}
fn recovered_fence_length(payload: &str) -> usize {
let mut longest = 0usize;
let mut run = 0usize;
for character in payload.chars() {
if character == '`' {
run += 1;
longest = longest.max(run);
} else {
run = 0;
}
}
longest.saturating_add(1).max(3)
}
fn recovered_payloads(section: &str) -> Vec<String> {
let mut payloads = Vec::new();
let mut open: Option<(char, usize)> = None;
let mut current = String::new();
for line in section.split_inclusive('\n') {
let text = line.trim_end_matches(['\r', '\n']);
match (markdown_fence(text), open) {
(Some((marker, length, empty_remainder)), Some((open_marker, open_length))) => {
if marker == open_marker && length >= open_length && empty_remainder {
let mut payload = std::mem::take(&mut current);
while payload.ends_with('\n') || payload.ends_with('\r') {
payload.pop();
}
payloads.push(payload);
open = None;
} else {
current.push_str(line);
}
}
(Some((marker, length, _)), None) => open = Some((marker, length)),
(None, Some(_)) => current.push_str(line),
(None, None) => {}
}
}
payloads
}
fn render_recovered_notes_section(payloads: &[String]) -> String {
let mut section = format!("{RECOVERED_NOTES_HEADING}\n\n{RECOVERED_NOTES_NOTICE}\n");
for payload in payloads {
let fence = "`".repeat(recovered_fence_length(payload));
let _ = write!(&mut section, "\n{fence}text\n{payload}\n{fence}\n");
}
section
}
fn ensure_blank_line_separator(content: &mut String) {
if content.is_empty() {
return;
}
if !content.ends_with('\n') {
content.push('\n');
}
if !content.ends_with("\n\n") {
content.push('\n');
}
}
fn trim_trailing_blank_lines(content: &mut String) {
while content.ends_with("\n\n\n") {
content.pop();
}
}
fn merge_recovered_notes(content: &mut String, payloads: Vec<String>) -> Result<FollowUpRecovery> {
let mut recovery = FollowUpRecovery::default();
if payloads.is_empty() {
return Ok(recovery);
}
let existing_range = top_level_sections(content)?
.into_iter()
.find(|(heading, _)| heading == RECOVERED_NOTES_HEADING)
.map(|(_, range)| range);
let mut known = existing_range
.as_ref()
.map(|range| recovered_payloads(&content[range.clone()]))
.unwrap_or_default();
for payload in payloads {
if known.contains(&payload) {
continue;
}
recovery.recovered_blocks += 1;
recovery.recovered_bytes += payload.len();
known.push(payload);
}
if !recovery.recovered() {
return Ok(recovery);
}
let rendered = render_recovered_notes_section(&known);
match existing_range {
Some(range) => {
let trailing = if range.end == content.len() { "" } else { "\n" };
content.replace_range(range, &format!("{rendered}{trailing}"));
}
None => {
ensure_blank_line_separator(content);
content.push_str(&rendered);
}
}
Ok(recovery)
}
fn strip_and_recover_follow_up_sections(content: &mut String) -> Result<FollowUpRecovery> {
let ranges = acceptance_follow_up_ranges(content)?;
let payloads = ranges
.iter()
.map(|range| unknown_follow_up_payload(&content[range.clone()]))
.filter(|payload| !payload.is_empty())
.collect::<Vec<_>>();
for range in ranges.into_iter().rev() {
content.replace_range(range, "");
}
trim_trailing_blank_lines(content);
merge_recovered_notes(content, payloads)
}
fn upsert_acceptance_follow_up_section(
content: &mut String,
attempt: u32,
findings: &[crate::orchestration::acceptance::NormalizedFinding],
completed_identities: &[String],
evidence_by_identity: &std::collections::HashMap<String, Vec<String>>,
) -> Result<FollowUpRecovery> {
let recovery = strip_and_recover_follow_up_sections(content)?;
ensure_blank_line_separator(content);
let _ = writeln!(content, "{ACCEPTANCE_FOLLOW_UP_HEADING}");
content.push_str(&render_acceptance_follow_up_section(
attempt,
findings,
completed_identities,
evidence_by_identity,
));
Ok(recovery)
}
pub fn resolve_acceptance_follow_up_tasks_path(
change_id: &str,
worktree_path: &Path,
) -> Result<std::path::PathBuf> {
let active_path = worktree_path
.join("openspec")
.join("changes")
.join(change_id)
.join("tasks.md");
if active_path.exists() {
return Ok(active_path);
}
if let Some(message) = invalid_archive_layout_error(change_id, Some(worktree_path)) {
return Err(OrchestratorError::ConfigLoad(message));
}
if let Some(archive_path) = find_archive_directory(change_id, Some(worktree_path)) {
let archive_tasks = archive_path.join("tasks.md");
if archive_tasks.exists() {
return Ok(archive_tasks);
}
}
Err(OrchestratorError::ConfigLoad(format!(
"Acceptance follow-up tasks path not found for change '{}' under worktree '{}'",
change_id,
worktree_path.display()
)))
}
pub fn resolve_acceptance_follow_up_tasks_path_for_cleanup(
change_id: &str,
worktree_path: &Path,
) -> Result<Option<std::path::PathBuf>> {
let active_path = worktree_path
.join("openspec")
.join("changes")
.join(change_id)
.join("tasks.md");
if active_path.exists() {
return Ok(Some(active_path));
}
if let Some(message) = invalid_archive_layout_error(change_id, Some(worktree_path)) {
return Err(OrchestratorError::ConfigLoad(message));
}
Ok(find_archive_directory(change_id, Some(worktree_path))
.map(|archive_path| archive_path.join("tasks.md"))
.filter(|tasks_path| tasks_path.exists()))
}
fn plan_replace_acceptance_follow_up(
content: &str,
attempt: u32,
findings: &[String],
) -> Result<(String, FollowUpRecovery)> {
let mut content = content.to_string();
let normalized_findings = normalize_acceptance_findings(findings);
let recovery = upsert_acceptance_follow_up_section(
&mut content,
attempt,
&normalized_findings,
&[],
&std::collections::HashMap::new(),
)?;
Ok((content, recovery))
}
fn plan_merge_acceptance_follow_up(
content: &str,
attempt: u32,
findings: &[String],
) -> Result<(String, FollowUpRecovery)> {
let mut content = content.to_string();
let normalized_findings = normalize_acceptance_findings(findings);
let existing_section = acceptance_follow_up_ranges(&content)?
.first()
.map(|range| content[range.clone()].to_string())
.unwrap_or_default();
let existing_findings = existing_acceptance_findings(&existing_section);
let (merged_findings, completed_identities, evidence_by_identity) =
reconcile_apply_progress(normalized_findings, &existing_findings);
let recovery = upsert_acceptance_follow_up_section(
&mut content,
attempt,
&merged_findings,
&completed_identities,
&evidence_by_identity,
)?;
Ok((content, recovery))
}
fn plan_clear_acceptance_follow_up(content: &str) -> Result<(String, FollowUpRecovery)> {
let mut content = content.to_string();
let recovery = strip_and_recover_follow_up_sections(&mut content)?;
Ok((content, recovery))
}
pub fn replace_acceptance_follow_up_from_latest_fail(
tasks_path: &Path,
attempt: u32,
findings: &[String],
) -> Result<FollowUpRecovery> {
let original = read_tasks_file(tasks_path)?;
let (content, recovery) = plan_replace_acceptance_follow_up(&original, attempt, findings)?;
write_tasks_file_atomically(tasks_path, &content)?;
Ok(recovery)
}
pub fn merge_acceptance_follow_up_apply_progress(
tasks_path: &Path,
attempt: u32,
findings: &[String],
) -> Result<FollowUpRecovery> {
let original = read_tasks_file(tasks_path)?;
let (content, recovery) = plan_merge_acceptance_follow_up(&original, attempt, findings)?;
write_tasks_file_atomically(tasks_path, &content)?;
Ok(recovery)
}
pub fn read_acceptance_follow_up(tasks_path: &Path) -> Result<Option<(u32, Vec<String>)>> {
let content = read_tasks_file(tasks_path)?;
let ranges = acceptance_follow_up_ranges(&content)?;
let Some(range) = ranges.last() else {
return Ok(None);
};
let section = &content[range.clone()];
let mut lines = section.lines();
let heading = lines.next().unwrap_or_default();
let attempt = if heading == ACCEPTANCE_FOLLOW_UP_HEADING {
lines
.next()
.and_then(|line| line.strip_prefix("- attempt: "))
.and_then(|value| value.parse::<u32>().ok())
} else {
heading
.strip_prefix("## Acceptance #")
.and_then(|value| value.strip_suffix(" Failure Follow-up"))
.and_then(|value| value.parse::<u32>().ok())
}
.ok_or_else(|| {
OrchestratorError::ConfigLoad(format!(
"Invalid acceptance follow-up heading in {}",
tasks_path.display()
))
})?;
let mut findings = Vec::new();
let mut in_external_blockers = false;
for line in lines {
let trimmed = line.trim();
if trimmed == "### External blockers" {
in_external_blockers = true;
continue;
}
if let Some(finding) = ["- [ ] ", "- [x] ", "- [X] "]
.iter()
.find_map(|prefix| line.strip_prefix(prefix))
{
findings.push(finding.to_string());
} else if in_external_blockers {
if let Some(evidence) = trimmed.strip_prefix("evidence: ") {
findings.push(evidence.to_string());
}
}
}
Ok((!findings.is_empty()).then_some((attempt, findings)))
}
pub fn clear_acceptance_follow_up(tasks_path: &Path) -> Result<FollowUpRecovery> {
let original = read_tasks_file(tasks_path)?;
let (content, recovery) = plan_clear_acceptance_follow_up(&original)?;
if content != original {
write_tasks_file_atomically(tasks_path, &content)?;
}
Ok(recovery)
}
#[cfg(test)]
mod tests {
use super::*;
fn write_tasks(path: &Path, content: &str) {
std::fs::create_dir_all(path.parent().expect("tasks path has a parent")).unwrap();
std::fs::write(path, content).unwrap();
}
fn checked_tasks(count: u32) -> String {
(1..=count)
.map(|index| format!("- [x] Task {}\n", index))
.collect()
}
#[test]
fn stable_finding_identity_is_code_first_and_structural_without_code() {
let findings = normalize_acceptance_findings(&[
"[RETRY_MISSING] old evidence at src/run.rs:10".into(),
"[RETRY_MISSING] changed evidence at tests/run.rs:90".into(),
"Missing retry test at src/run.rs:11".into(),
"Different prose: regression coverage absent in src/run.rs:99".into(),
"Incorrect implementation at src/run.rs:12".into(),
"Missing retry test at src/other.rs:10".into(),
]);
let identities = findings
.iter()
.map(|finding| finding.identity.as_str())
.collect::<Vec<_>>();
assert_eq!(findings.len(), 4);
assert!(identities.contains(&"repository|code|[retry_missing]"));
assert!(identities.contains(&"repository|src/run.rs|verification"));
assert!(identities.contains(&"repository|src/run.rs|implementation"));
assert!(identities.contains(&"repository|src/other.rs|verification"));
}
#[test]
fn apply_reconciliation_is_monotonic_and_preserves_text_and_evidence() {
let existing = existing_acceptance_findings(
"- [x] Missing retry test at src/run.rs:10\n evidence: cargo test retry passes\n- [ ] Broken implementation at src/other.rs:4\n",
);
let incoming = normalize_acceptance_findings(&[
"Regression coverage absent in src/run.rs:99".into(),
"Incorrect implementation at src/other.rs:40".into(),
]);
let (merged, completed, evidence) = reconcile_apply_progress(incoming, &existing);
assert!(merged
.iter()
.any(|finding| finding.text == "Missing retry test at src/run.rs:10"));
assert!(merged
.iter()
.any(|finding| finding.text == "Broken implementation at src/other.rs:4"));
assert_eq!(completed, ["repository|src/run.rs|verification"]);
assert_eq!(
evidence["repository|src/run.rs|verification"],
["cargo test retry passes"]
);
}
#[test]
fn test_bullet_unchecked() {
let content = "- [ ] Task 1\n- [ ] Task 2";
let progress = parse_content(content, None);
assert_eq!(progress.total, 2);
assert_eq!(progress.completed, 0);
}
#[test]
fn test_bullet_checked_lowercase() {
let content = "- [x] Task 1\n- [x] Task 2";
let progress = parse_content(content, None);
assert_eq!(progress.total, 2);
assert_eq!(progress.completed, 2);
}
#[test]
fn test_bullet_checked_uppercase() {
let content = "- [X] Task 1\n- [X] Task 2";
let progress = parse_content(content, None);
assert_eq!(progress.total, 2);
assert_eq!(progress.completed, 2);
}
#[test]
fn test_asterisk_bullets() {
let content = "* [ ] Task 1\n* [x] Task 2";
let progress = parse_content(content, None);
assert_eq!(progress.total, 2);
assert_eq!(progress.completed, 1);
}
#[test]
fn test_bullet_mixed_status() {
let content = "- [x] Completed\n- [ ] Pending\n- [X] Also done";
let progress = parse_content(content, None);
assert_eq!(progress.total, 3);
assert_eq!(progress.completed, 2);
}
#[test]
fn test_numbered_unchecked() {
let content = "1. [ ] Task 1\n2. [ ] Task 2";
let progress = parse_content(content, None);
assert_eq!(progress.total, 2);
assert_eq!(progress.completed, 0);
}
#[test]
fn test_record_acceptance_follow_up_appends_unchecked_tasks() {
let dir = tempfile::tempdir().unwrap();
let tasks_path = dir.path().join("tasks.md");
std::fs::write(&tasks_path, "## Implementation Tasks\n- [x] done\n").unwrap();
replace_acceptance_follow_up_from_latest_fail(
&tasks_path,
2,
&[
"missing repository coverage".to_string(),
"add notification links".to_string(),
],
)
.unwrap();
let content = std::fs::read_to_string(&tasks_path).unwrap();
assert!(content.contains("## Current Acceptance Follow-up"));
assert!(content.contains("- [ ] missing repository coverage"));
assert!(content.contains("- [ ] add notification links"));
let progress = parse_file(&tasks_path, None).unwrap();
assert_eq!(progress.completed, 1);
assert_eq!(progress.total, 3);
}
#[test]
fn test_record_acceptance_follow_up_replaces_existing_section() {
let dir = tempfile::tempdir().unwrap();
let tasks_path = dir.path().join("tasks.md");
std::fs::write(
&tasks_path,
"## Implementation Tasks\n- [x] done\n\n## Current Acceptance Follow-up\n- [x] stale\n\n## Final Validation\n- [ ] run tests\n",
)
.unwrap();
replace_acceptance_follow_up_from_latest_fail(
&tasks_path,
1,
&["fresh finding".to_string()],
)
.unwrap();
let content = std::fs::read_to_string(&tasks_path).unwrap();
assert!(content.contains("## Current Acceptance Follow-up"));
assert!(content.contains("- [ ] fresh finding"));
assert!(content.contains("## Final Validation\n- [ ] run tests"));
assert!(!content.contains("- [x] stale"));
}
#[test]
fn record_acceptance_follow_up_replaces_previous_attempt() {
let dir = tempfile::tempdir().unwrap();
let tasks_path = dir.path().join("tasks.md");
std::fs::write(
&tasks_path,
"## Implementation Tasks\n- [x] done\n\n## Acceptance #1 Failure Follow-up\n- [x] stale\n",
)
.unwrap();
replace_acceptance_follow_up_from_latest_fail(
&tasks_path,
2,
&["latest finding".to_string()],
)
.unwrap();
let content = std::fs::read_to_string(&tasks_path).unwrap();
assert!(!content.contains("## Acceptance #1 Failure Follow-up"));
assert_eq!(
content.matches("## Current Acceptance Follow-up").count(),
1
);
assert!(content.contains("- [ ] latest finding"));
}
#[test]
fn ensure_acceptance_follow_up_restores_deleted_runtime_section() {
let dir = tempfile::tempdir().unwrap();
let tasks_path = dir.path().join("tasks.md");
std::fs::write(&tasks_path, "## Implementation Tasks\n- [x] done\n").unwrap();
merge_acceptance_follow_up_apply_progress(
&tasks_path,
2,
&[
"latest finding".to_string(),
"add regression test".to_string(),
],
)
.unwrap();
let content = std::fs::read_to_string(&tasks_path).unwrap();
assert!(content.contains("## Current Acceptance Follow-up"));
assert!(content.contains("- [ ] latest finding"));
assert!(content.contains("- [ ] add regression test"));
}
#[test]
fn ensure_acceptance_follow_up_restores_deleted_finding_and_preserves_completed_finding() {
let dir = tempfile::tempdir().unwrap();
let tasks_path = dir.path().join("tasks.md");
std::fs::write(
&tasks_path,
"## Implementation Tasks\n- [x] done\n\n## Current Acceptance Follow-up\n- [x] add regression test\n",
)
.unwrap();
merge_acceptance_follow_up_apply_progress(
&tasks_path,
2,
&[
"latest finding".to_string(),
"add regression test".to_string(),
],
)
.unwrap();
let content = std::fs::read_to_string(&tasks_path).unwrap();
assert!(content.contains("- [x] add regression test"));
assert!(content.contains("- [ ] latest finding"));
let progress = parse_file(&tasks_path, None).unwrap();
assert_eq!(progress, TaskProgress::with_counts(2, 3));
}
#[test]
fn apply_progress_preserves_completed_fallback_identity_text_and_evidence() {
let dir = tempfile::tempdir().unwrap();
let tasks_path = dir.path().join("tasks.md");
std::fs::write(
&tasks_path,
"## Implementation Tasks\n- [x] done\n\n## Current Acceptance Follow-up\n- attempt: 1\n- [x] Missing retry coverage at src/example.rs:10\n evidence: cargo test retry passes\n- [ ] Incorrect implementation at src/other.rs:4\n",
)
.unwrap();
merge_acceptance_follow_up_apply_progress(
&tasks_path,
2,
&[
"Regression test absent in src/example.rs:99 with changed detail".to_string(),
"Incorrect implementation at src/other.rs:40 with new evidence".to_string(),
],
)
.unwrap();
let content = std::fs::read_to_string(&tasks_path).unwrap();
assert!(content.contains("- [x] Missing retry coverage at src/example.rs:10"));
assert!(content.contains("evidence: cargo test retry passes"));
assert!(content.contains("- [ ] Incorrect implementation at src/other.rs:4"));
assert!(!content.contains("changed detail"));
assert_eq!(
parse_file(&tasks_path, None).unwrap(),
TaskProgress::with_counts(2, 3)
);
}
#[test]
fn ensure_acceptance_follow_up_preserves_completed_finding_by_code() {
let dir = tempfile::tempdir().unwrap();
let tasks_path = dir.path().join("tasks.md");
std::fs::write(
&tasks_path,
"## Implementation Tasks\n- [x] done\n\n## Current Acceptance Follow-up\n- [x] [SERIAL_STALLED_MARKER_MISSING] fixed and verified\n",
)
.unwrap();
merge_acceptance_follow_up_apply_progress(
&tasks_path,
2,
&["[SERIAL_STALLED_MARKER_MISSING] detailed original finding".to_string()],
)
.unwrap();
let content = std::fs::read_to_string(&tasks_path).unwrap();
assert!(content.contains("- [x] [SERIAL_STALLED_MARKER_MISSING] fixed and verified"));
assert_eq!(
parse_file(&tasks_path, None).unwrap(),
TaskProgress::with_counts(2, 2)
);
}
#[test]
fn record_acceptance_follow_up_reopens_repeated_stable_identity() {
let dir = tempfile::tempdir().unwrap();
let tasks_path = dir.path().join("tasks.md");
std::fs::write(
&tasks_path,
"## Implementation Tasks\n- [x] done\n\n## Current Acceptance Follow-up\n- attempt: 1\n- [x] [SERIAL_STALLED_MARKER_MISSING] fixed and verified\n",
)
.unwrap();
replace_acceptance_follow_up_from_latest_fail(
&tasks_path,
2,
&["[SERIAL_STALLED_MARKER_MISSING] still missing at src/run.rs:42".to_string()],
)
.unwrap();
let content = std::fs::read_to_string(&tasks_path).unwrap();
assert!(content
.contains("- [ ] [SERIAL_STALLED_MARKER_MISSING] still missing at src/run.rs:42"));
assert!(!content.contains("fixed and verified"));
}
#[test]
fn acceptance_follow_up_renders_external_blockers_without_checkboxes() {
let dir = tempfile::tempdir().unwrap();
let tasks_path = dir.path().join("tasks.md");
std::fs::write(&tasks_path, "## Implementation Tasks\n- [x] done\n").unwrap();
replace_acceptance_follow_up_from_latest_fail(
&tasks_path,
3,
&[
"fix repository regression at src/run.rs:4".to_string(),
"external non-mockable prerequisite: vendor approval".to_string(),
],
)
.unwrap();
let content = std::fs::read_to_string(&tasks_path).unwrap();
assert!(content.contains("- [ ] fix repository regression at src/run.rs:4"));
assert!(content.contains("### External blockers"));
assert!(content.contains("evidence: external non-mockable prerequisite: vendor approval"));
assert!(content.contains("next action: Resolve the external prerequisite"));
assert!(!content.contains("- [ ] external non-mockable prerequisite"));
assert_eq!(
parse_file(&tasks_path, None).unwrap(),
TaskProgress::with_counts(1, 2)
);
}
#[test]
fn read_acceptance_follow_up_restores_mixed_repository_and_external_findings() {
let dir = tempfile::tempdir().unwrap();
let tasks_path = dir.path().join("tasks.md");
std::fs::write(
&tasks_path,
"## Current Acceptance Follow-up\n- attempt: 3\n- [x] fix repository regression at src/run.rs:4\n\n### External blockers\n- identity: `external||vendor approval|plain`\n evidence: external non-mockable prerequisite: vendor approval\n next action: Resolve the external prerequisite, then retry acceptance.\n",
)
.unwrap();
let follow_up = read_acceptance_follow_up(&tasks_path).unwrap();
assert_eq!(
follow_up,
Some((
3,
vec![
"fix repository regression at src/run.rs:4".to_string(),
"external non-mockable prerequisite: vendor approval".to_string(),
],
))
);
}
#[test]
fn acceptance_follow_up_normalizes_multiline_findings() {
let dir = tempfile::tempdir().unwrap();
let tasks_path = dir.path().join("tasks.md");
std::fs::write(&tasks_path, "## Implementation Tasks\n- [x] done\n").unwrap();
replace_acceptance_follow_up_from_latest_fail(
&tasks_path,
2,
&["finding\n## injected heading\n- [ ] injected task".to_string()],
)
.unwrap();
let content = std::fs::read_to_string(&tasks_path).unwrap();
assert!(content.contains("- [ ] finding ## injected heading - [ ] injected task"));
assert_eq!(
content.matches("## Current Acceptance Follow-up").count(),
1
);
assert_eq!(parse_file(&tasks_path, None).unwrap().total, 2);
}
#[test]
fn clear_acceptance_follow_up_ignores_examples_in_code_fences() {
let dir = tempfile::tempdir().unwrap();
let tasks_path = dir.path().join("tasks.md");
std::fs::write(
&tasks_path,
"## Notes\n```md\n## Acceptance #9 Failure Follow-up\n- [ ] example\n```\n\n## Current Acceptance Follow-up\n- [x] fixed\n",
)
.unwrap();
clear_acceptance_follow_up(&tasks_path).unwrap();
let content = std::fs::read_to_string(&tasks_path).unwrap();
assert!(content.contains("## Acceptance #9 Failure Follow-up\n- [ ] example"));
assert!(!content.contains("## Current Acceptance Follow-up"));
}
#[test]
fn clear_acceptance_follow_up_ignores_tilde_fence_examples() {
let dir = tempfile::tempdir().unwrap();
let tasks_path = dir.path().join("tasks.md");
std::fs::write(
&tasks_path,
"## Notes\n~~~md\n## Acceptance #9 Failure Follow-up\n- [ ] example\n~~~\n",
)
.unwrap();
clear_acceptance_follow_up(&tasks_path).unwrap();
let content = std::fs::read_to_string(&tasks_path).unwrap();
assert!(content.contains("## Acceptance #9 Failure Follow-up\n- [ ] example"));
}
#[test]
fn clear_acceptance_follow_up_does_not_close_fence_with_info_string() {
let dir = tempfile::tempdir().unwrap();
let tasks_path = dir.path().join("tasks.md");
let original =
"## Notes\n```text\n```md\n## Acceptance #9 Failure Follow-up\n- [ ] example\n```\n";
std::fs::write(&tasks_path, original).unwrap();
clear_acceptance_follow_up(&tasks_path).unwrap();
assert_eq!(std::fs::read_to_string(&tasks_path).unwrap(), original);
}
#[test]
fn record_acceptance_follow_up_replaces_legacy_section_with_runtime_metadata() {
let dir = tempfile::tempdir().unwrap();
let tasks_path = dir.path().join("tasks.md");
std::fs::write(
&tasks_path,
"## Implementation Tasks\n- [x] done\n\n## Acceptance #1 Failure Follow-up\n- attempt: 1\n- [x] stale finding\n evidence: cargo test passed\n",
)
.unwrap();
replace_acceptance_follow_up_from_latest_fail(
&tasks_path,
2,
&["latest finding".to_string()],
)
.unwrap();
let content = std::fs::read_to_string(&tasks_path).unwrap();
assert!(!content.contains("## Acceptance #1 Failure Follow-up"));
assert!(content.contains("## Current Acceptance Follow-up\n- attempt: 2"));
assert!(content.contains("- [ ] latest finding"));
}
#[test]
fn clear_acceptance_follow_up_recovers_non_runtime_section_content() {
let dir = tempfile::tempdir().unwrap();
let tasks_path = dir.path().join("tasks.md");
let original = "## Current Acceptance Follow-up\n- [x] fixed\n```md\n## injected\n```\n";
std::fs::write(&tasks_path, original).unwrap();
let recovery = clear_acceptance_follow_up(&tasks_path).unwrap();
assert_eq!(recovery.recovered_blocks, 1);
let content = std::fs::read_to_string(&tasks_path).unwrap();
assert!(!content.contains("## Current Acceptance Follow-up"));
assert!(content.contains(RECOVERED_NOTES_HEADING));
assert!(content.contains("````text\n```md\n## injected\n```\n````"));
}
#[test]
fn clear_acceptance_follow_up_removes_runtime_sections_only() {
let dir = tempfile::tempdir().unwrap();
let tasks_path = dir.path().join("tasks.md");
std::fs::write(
&tasks_path,
"## Implementation Tasks\n- [x] done\n\n## Current Acceptance Follow-up\n- [x] fixed\n\n## Final Validation\nvalidation passed\n",
)
.unwrap();
clear_acceptance_follow_up(&tasks_path).unwrap();
let content = std::fs::read_to_string(&tasks_path).unwrap();
assert!(!content.contains("Failure Follow-up"));
assert!(content.contains("## Implementation Tasks\n- [x] done"));
assert!(content.contains("## Final Validation\nvalidation passed"));
}
#[test]
fn test_record_acceptance_follow_up_uses_default_finding_for_empty_input() {
let dir = tempfile::tempdir().unwrap();
let tasks_path = dir.path().join("tasks.md");
std::fs::write(&tasks_path, "## Implementation Tasks\n- [x] done\n").unwrap();
replace_acceptance_follow_up_from_latest_fail(
&tasks_path,
3,
&[" ".to_string(), "\t".to_string()],
)
.unwrap();
let content = std::fs::read_to_string(&tasks_path).unwrap();
assert!(content.contains("## Current Acceptance Follow-up"));
assert!(content.contains("- [ ] Investigate acceptance failure and apply the required fix"));
}
#[test]
fn test_record_acceptance_follow_up_adds_missing_trailing_newline_before_section() {
let dir = tempfile::tempdir().unwrap();
let tasks_path = dir.path().join("tasks.md");
std::fs::write(&tasks_path, "## Implementation Tasks\n- [x] done").unwrap();
replace_acceptance_follow_up_from_latest_fail(
&tasks_path,
4,
&["fresh finding".to_string()],
)
.unwrap();
let content = std::fs::read_to_string(&tasks_path).unwrap();
assert_eq!(
content,
"## Implementation Tasks\n- [x] done\n\n## Current Acceptance Follow-up\n- attempt: 4\n- [ ] fresh finding\n"
);
}
#[test]
fn test_resolve_acceptance_follow_up_tasks_path_prefers_active_path() {
let dir = tempfile::tempdir().unwrap();
let change_id = "change-a";
let active_dir = dir.path().join("openspec/changes").join(change_id);
std::fs::create_dir_all(&active_dir).unwrap();
let active_tasks = active_dir.join("tasks.md");
std::fs::write(&active_tasks, "- [ ] active task").unwrap();
let resolved = resolve_acceptance_follow_up_tasks_path(change_id, dir.path()).unwrap();
assert_eq!(resolved, active_tasks);
}
#[test]
fn test_resolve_acceptance_follow_up_tasks_path_falls_back_to_archive_path() {
let dir = tempfile::tempdir().unwrap();
let change_id = "change-b";
let archive_dir = dir.path().join("openspec/changes/archive").join(change_id);
std::fs::create_dir_all(&archive_dir).unwrap();
let archive_tasks = archive_dir.join("tasks.md");
std::fs::write(&archive_tasks, "- [ ] archived task").unwrap();
let resolved = resolve_acceptance_follow_up_tasks_path(change_id, dir.path()).unwrap();
assert_eq!(resolved, archive_tasks);
}
#[test]
fn cleanup_resolver_returns_none_when_follow_up_tasks_are_absent() {
let dir = tempfile::tempdir().unwrap();
assert_eq!(
resolve_acceptance_follow_up_tasks_path_for_cleanup("change-c", dir.path()).unwrap(),
None
);
}
#[test]
fn cleanup_resolver_rejects_invalid_archive_layout() {
let dir = tempfile::tempdir().unwrap();
let nested_tasks = dir
.path()
.join("openspec/changes/archive/2026-07-09/change-c/tasks.md");
write_tasks(&nested_tasks, "- [x] archived\n");
let error = resolve_acceptance_follow_up_tasks_path_for_cleanup("change-c", dir.path())
.unwrap_err();
assert!(error.to_string().contains("Invalid archive layout"));
}
#[test]
fn test_resolve_acceptance_follow_up_tasks_path_errors_when_missing_everywhere() {
let dir = tempfile::tempdir().unwrap();
let change_id = "change-c";
let result = resolve_acceptance_follow_up_tasks_path(change_id, dir.path());
assert!(result.is_err());
}
#[test]
fn test_numbered_checked() {
let content = "1. [x] Task 1\n2. [x] Task 2";
let progress = parse_content(content, None);
assert_eq!(progress.total, 2);
assert_eq!(progress.completed, 2);
}
#[test]
fn test_numbered_multi_digit() {
let content = "1. [x] Task 1\n10. [ ] Task 10\n100. [X] Task 100";
let progress = parse_content(content, None);
assert_eq!(progress.total, 3);
assert_eq!(progress.completed, 2);
}
#[test]
fn test_numbered_mixed_status() {
let content = "1. [x] Done\n2. [ ] Not done\n3. [X] Also done";
let progress = parse_content(content, None);
assert_eq!(progress.total, 3);
assert_eq!(progress.completed, 2);
}
#[test]
fn test_mixed_bullets_and_numbers() {
let content =
"- [x] Bullet done\n1. [ ] Number pending\n* [X] Asterisk done\n2. [x] Number done";
let progress = parse_content(content, None);
assert_eq!(progress.total, 4);
assert_eq!(progress.completed, 3);
}
#[test]
fn test_mixed_with_sections() {
let content = r#"# Tasks
## Implementation
- [x] Task 1
- [ ] Task 2
## Testing
1. [x] Test 1
2. [ ] Test 2
"#;
let progress = parse_content(content, None);
assert_eq!(progress.total, 4);
assert_eq!(progress.completed, 2);
}
#[test]
fn test_empty_content() {
let progress = parse_content("", None);
assert_eq!(progress.total, 0);
assert_eq!(progress.completed, 0);
}
#[test]
fn test_no_tasks() {
let content = "# Just a header\nSome text without tasks.\n\n- Regular list item";
let progress = parse_content(content, None);
assert_eq!(progress.total, 0);
assert_eq!(progress.completed, 0);
}
#[test]
fn test_indented_not_counted() {
let content =
"- [x] Parent task\n - [ ] Sub-task (should not count)\n - [x] Another sub-task";
let progress = parse_content(content, None);
assert_eq!(progress.total, 1);
assert_eq!(progress.completed, 1);
}
#[test]
fn test_inline_checkbox_not_counted() {
let content = "Some text with [ ] inline checkbox\nAnother line [x] here";
let progress = parse_content(content, None);
assert_eq!(progress.total, 0);
assert_eq!(progress.completed, 0);
}
#[test]
fn test_header_checkbox_not_counted() {
let content = "## [x] Header with checkbox\n### [ ] Another header";
let progress = parse_content(content, None);
assert_eq!(progress.total, 0);
assert_eq!(progress.completed, 0);
}
#[test]
fn test_real_world_example() {
let content = r#"# Tasks
## Implementation Tasks
- [x] Create `src/task_parser.rs` module with regex-based task parsing
- [x] Implement `TaskProgress` struct with `completed` and `total` fields
- [ ] Implement `parse_content()` function to parse task markdown content
- [ ] Implement `parse_file()` function to read and parse tasks.md files
## Testing Tasks
1. [ ] Add unit tests for bullet list format
2. [ ] Add unit tests for numbered list format
3. [x] Add unit tests for mixed format
## Validation
- [ ] Run `cargo test` to verify all tests pass
- [ ] Run `cargo clippy` to check for warnings
"#;
let progress = parse_content(content, None);
assert_eq!(progress.total, 9);
assert_eq!(progress.completed, 3);
}
#[test]
fn test_task_progress_new() {
let progress = TaskProgress::new();
assert_eq!(progress.completed, 0);
assert_eq!(progress.total, 0);
}
#[test]
fn test_task_progress_with_counts() {
let progress = TaskProgress::with_counts(5, 10);
assert_eq!(progress.completed, 5);
assert_eq!(progress.total, 10);
}
#[test]
fn test_task_progress_default() {
let progress = TaskProgress::default();
assert_eq!(progress.completed, 0);
assert_eq!(progress.total, 0);
}
#[test]
fn test_parse_file_not_found() {
let result = parse_file(Path::new("/nonexistent/path/tasks.md"), None);
assert!(result.is_err());
}
#[test]
fn test_parse_change_not_found() {
let result = parse_change("nonexistent-change-id");
assert!(result.is_err());
}
#[test]
fn test_parse_change_with_worktree_fallback_from_worktree() {
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let worktree_path = temp_dir.path();
let change_dir = worktree_path.join("openspec/changes/test-change");
std::fs::create_dir_all(&change_dir).unwrap();
let tasks_content = "- [x] Task 1\n- [x] Task 2\n- [ ] Task 3";
std::fs::write(change_dir.join("tasks.md"), tasks_content).unwrap();
let result = parse_progress_with_fallback("test-change", Some(worktree_path));
assert!(result.is_ok());
let progress = result.unwrap();
assert_eq!(progress.completed, 2);
assert_eq!(progress.total, 3);
}
#[test]
fn test_parse_change_with_worktree_fallback_to_base() {
use std::env;
use tempfile::TempDir;
let _lock = crate::test_support::cwd_lock().lock().unwrap();
let temp_dir = TempDir::new().unwrap();
let base_path = temp_dir.path();
let original_dir = env::current_dir().unwrap();
env::set_current_dir(base_path).unwrap();
let change_dir = base_path.join("openspec/changes/test-change");
std::fs::create_dir_all(&change_dir).unwrap();
let tasks_content = "- [x] Task 1\n- [ ] Task 2";
std::fs::write(change_dir.join("tasks.md"), tasks_content).unwrap();
let result = parse_progress_with_fallback("test-change", None);
assert!(result.is_ok());
let progress = result.unwrap();
assert_eq!(progress.completed, 1);
assert_eq!(progress.total, 2);
env::set_current_dir(original_dir).unwrap();
}
#[test]
fn test_parse_archived_change_with_worktree_fallback_from_worktree_archive() {
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let worktree_path = temp_dir.path();
let archive_dir = worktree_path.join("openspec/changes/archive/test-archived");
std::fs::create_dir_all(&archive_dir).unwrap();
let tasks_content = "- [x] Task 1\n- [x] Task 2\n- [x] Task 3\n- [ ] Task 4";
std::fs::write(archive_dir.join("tasks.md"), tasks_content).unwrap();
let result = parse_progress_with_fallback("test-archived", Some(worktree_path));
assert!(result.is_ok());
let progress = result.unwrap();
assert_eq!(progress.completed, 3);
assert_eq!(progress.total, 4);
}
#[test]
fn test_parse_archived_change_with_worktree_fallback_from_worktree_active() {
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let worktree_path = temp_dir.path();
let change_dir = worktree_path.join("openspec/changes/test-prearchive");
std::fs::create_dir_all(&change_dir).unwrap();
let tasks_content = "- [x] Task 1\n- [x] Task 2\n- [ ] Task 3";
std::fs::write(change_dir.join("tasks.md"), tasks_content).unwrap();
let result = parse_progress_with_fallback("test-prearchive", Some(worktree_path));
assert!(result.is_ok());
let progress = result.unwrap();
assert_eq!(progress.completed, 2);
assert_eq!(progress.total, 3);
}
#[test]
fn test_parse_archived_change_with_worktree_fallback_to_base() {
use std::env;
use tempfile::TempDir;
let _lock = crate::test_support::cwd_lock().lock().unwrap();
let temp_dir = TempDir::new().unwrap();
let base_path = temp_dir.path();
let original_dir = env::current_dir().unwrap();
env::set_current_dir(base_path).unwrap();
let archive_dir = base_path.join("openspec/changes/archive/test-base-archive");
std::fs::create_dir_all(&archive_dir).unwrap();
let tasks_content = "- [x] Task 1\n- [ ] Task 2";
std::fs::write(archive_dir.join("tasks.md"), tasks_content).unwrap();
let result = parse_progress_with_fallback("test-base-archive", None);
assert!(result.is_ok());
let progress = result.unwrap();
assert_eq!(progress.completed, 1);
assert_eq!(progress.total, 2);
env::set_current_dir(original_dir).unwrap();
}
#[test]
fn test_parse_archived_change_with_worktree_fallback_priority() {
use std::env;
use tempfile::TempDir;
let _lock = crate::test_support::cwd_lock().lock().unwrap();
let temp_dir = TempDir::new().unwrap();
let base_path = temp_dir.path();
let original_dir = env::current_dir().unwrap();
env::set_current_dir(base_path).unwrap();
let base_archive = base_path.join("openspec/changes/archive/test-priority");
std::fs::create_dir_all(&base_archive).unwrap();
std::fs::write(base_archive.join("tasks.md"), "- [ ] Old task").unwrap();
let worktree_path = base_path.join("worktree");
let wt_archive = worktree_path.join("openspec/changes/archive/test-priority");
std::fs::create_dir_all(&wt_archive).unwrap();
std::fs::write(
wt_archive.join("tasks.md"),
"- [x] New task 1\n- [x] New task 2",
)
.unwrap();
let result = parse_progress_with_fallback("test-priority", Some(&worktree_path));
assert!(result.is_ok());
let progress = result.unwrap();
assert_eq!(progress.completed, 2);
assert_eq!(progress.total, 2);
env::set_current_dir(original_dir).unwrap();
}
#[test]
fn test_parse_archived_change_date_prefixed() {
use std::env;
use tempfile::TempDir;
let _lock = crate::test_support::cwd_lock().lock().unwrap();
let temp_dir = TempDir::new().unwrap();
let base_path = temp_dir.path();
let original_dir = env::current_dir().unwrap();
env::set_current_dir(base_path).unwrap();
let archive_dir = base_path.join("openspec/changes/archive/2024-01-15-test-change");
std::fs::create_dir_all(&archive_dir).unwrap();
let tasks_content = "- [x] Task 1\n- [x] Task 2\n- [ ] Task 3";
std::fs::write(archive_dir.join("tasks.md"), tasks_content).unwrap();
let result = parse_progress_with_fallback("test-change", None);
assert!(result.is_ok());
let progress = result.unwrap();
assert_eq!(progress.completed, 2);
assert_eq!(progress.total, 3);
env::set_current_dir(original_dir).unwrap();
}
#[test]
fn test_parse_archived_change_exact_match_preferred() {
use std::env;
use tempfile::TempDir;
let _lock = crate::test_support::cwd_lock().lock().unwrap();
let temp_dir = TempDir::new().unwrap();
let base_path = temp_dir.path();
let original_dir = env::current_dir().unwrap();
env::set_current_dir(base_path).unwrap();
let exact_archive = base_path.join("openspec/changes/archive/test-exact");
std::fs::create_dir_all(&exact_archive).unwrap();
std::fs::write(exact_archive.join("tasks.md"), "- [x] Exact task").unwrap();
let date_archive = base_path.join("openspec/changes/archive/2024-01-15-test-exact");
std::fs::create_dir_all(&date_archive).unwrap();
std::fs::write(date_archive.join("tasks.md"), "- [ ] Date task").unwrap();
let result = parse_progress_with_fallback("test-exact", None);
assert!(result.is_ok());
let progress = result.unwrap();
assert_eq!(progress.completed, 1);
assert_eq!(progress.total, 1);
env::set_current_dir(original_dir).unwrap();
}
#[test]
fn test_parse_archived_change_with_worktree_fallback_date_prefixed() {
use std::env;
use tempfile::TempDir;
let _lock = crate::test_support::cwd_lock().lock().unwrap();
let temp_dir = TempDir::new().unwrap();
let base_path = temp_dir.path();
let original_dir = env::current_dir().unwrap();
env::set_current_dir(base_path).unwrap();
let base_archive = base_path.join("openspec/changes/archive/2026-01-17-test-date");
std::fs::create_dir_all(&base_archive).unwrap();
std::fs::write(
base_archive.join("tasks.md"),
"- [x] Task 1\n- [x] Task 2\n- [x] Task 3",
)
.unwrap();
let result = parse_progress_with_fallback("test-date", None);
assert!(result.is_ok());
let progress = result.unwrap();
assert_eq!(progress.completed, 3);
assert_eq!(progress.total, 3);
env::set_current_dir(original_dir).unwrap();
}
#[test]
fn test_find_archive_directory_not_found() {
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let base_path = temp_dir.path();
let archive_dir = base_path.join("openspec/changes/archive");
std::fs::create_dir_all(&archive_dir).unwrap();
let result = find_archive_directory("nonexistent", Some(base_path));
assert!(result.is_none());
}
#[test]
fn test_find_archive_directory_exact_match() {
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let base_path = temp_dir.path();
let exact_archive = base_path.join("openspec/changes/archive/exact-match");
std::fs::create_dir_all(&exact_archive).unwrap();
let result = find_archive_directory("exact-match", Some(base_path));
assert!(result.is_some());
assert_eq!(result.unwrap(), exact_archive);
}
#[test]
fn test_find_archive_directory_date_prefixed() {
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let base_path = temp_dir.path();
let date_archive = base_path.join("openspec/changes/archive/2024-01-15-my-feature");
std::fs::create_dir_all(&date_archive).unwrap();
let result = find_archive_directory("my-feature", Some(base_path));
assert!(result.is_some());
assert_eq!(result.unwrap(), date_archive);
}
#[test]
fn test_parse_progress_rejects_nested_archive_layout() {
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let nested_tasks = temp_dir
.path()
.join("openspec/changes/archive/2026-07-09/my-feature/tasks.md");
write_tasks(&nested_tasks, "- [x] archived\n");
let err = parse_progress_with_fallback("my-feature", Some(temp_dir.path())).unwrap_err();
let message = err.to_string();
assert!(message.contains("Invalid archive layout"));
assert!(message.contains("2026-07-09/my-feature"));
}
#[test]
fn test_parse_progress_with_fallback_worktree_active() {
use std::env;
use tempfile::TempDir;
let _lock = crate::test_support::cwd_lock().lock().unwrap();
let temp_dir = TempDir::new().unwrap();
let base_path = temp_dir.path();
let original_dir = env::current_dir().unwrap();
env::set_current_dir(base_path).unwrap();
let worktree_path = base_path.join("worktree");
let wt_active = worktree_path.join("openspec/changes/test-fallback");
std::fs::create_dir_all(&wt_active).unwrap();
std::fs::write(
wt_active.join("tasks.md"),
"- [x] Task 1\n- [x] Task 2\n- [ ] Task 3",
)
.unwrap();
let result = parse_progress_with_fallback("test-fallback", Some(&worktree_path));
assert!(result.is_ok());
let progress = result.unwrap();
assert_eq!(progress.completed, 2);
assert_eq!(progress.total, 3);
env::set_current_dir(original_dir).unwrap();
}
#[test]
fn test_parse_progress_with_fallback_worktree_archive() {
use std::env;
use tempfile::TempDir;
let _lock = crate::test_support::cwd_lock().lock().unwrap();
let temp_dir = TempDir::new().unwrap();
let base_path = temp_dir.path();
let original_dir = env::current_dir().unwrap();
env::set_current_dir(base_path).unwrap();
let worktree_path = base_path.join("worktree");
let wt_archive = worktree_path.join("openspec/changes/archive/test-wt-archive");
std::fs::create_dir_all(&wt_archive).unwrap();
std::fs::write(
wt_archive.join("tasks.md"),
"- [x] Task 1\n- [x] Task 2\n- [x] Task 3\n- [ ] Task 4",
)
.unwrap();
let result = parse_progress_with_fallback("test-wt-archive", Some(&worktree_path));
assert!(result.is_ok());
let progress = result.unwrap();
assert_eq!(progress.completed, 3);
assert_eq!(progress.total, 4);
env::set_current_dir(original_dir).unwrap();
}
#[test]
fn test_parse_progress_with_fallback_base_archive() {
use std::env;
use tempfile::TempDir;
let _lock = crate::test_support::cwd_lock().lock().unwrap();
let temp_dir = TempDir::new().unwrap();
let base_path = temp_dir.path();
let original_dir = env::current_dir().unwrap();
env::set_current_dir(base_path).unwrap();
let base_archive = base_path.join("openspec/changes/archive/test-base-archive");
std::fs::create_dir_all(&base_archive).unwrap();
std::fs::write(base_archive.join("tasks.md"), "- [x] Task 1\n- [x] Task 2").unwrap();
let result = parse_progress_with_fallback("test-base-archive", None);
assert!(result.is_ok());
let progress = result.unwrap();
assert_eq!(progress.completed, 2);
assert_eq!(progress.total, 2);
env::set_current_dir(original_dir).unwrap();
}
#[test]
fn test_parse_progress_with_fallback_base_active() {
use std::env;
use tempfile::TempDir;
let _lock = crate::test_support::cwd_lock().lock().unwrap();
let temp_dir = TempDir::new().unwrap();
let base_path = temp_dir.path();
let original_dir = env::current_dir().unwrap();
env::set_current_dir(base_path).unwrap();
let base_active = base_path.join("openspec/changes/test-base-active");
std::fs::create_dir_all(&base_active).unwrap();
std::fs::write(base_active.join("tasks.md"), "- [ ] Task 1").unwrap();
let result = parse_progress_with_fallback("test-base-active", None);
assert!(result.is_ok());
let progress = result.unwrap();
assert_eq!(progress.completed, 0);
assert_eq!(progress.total, 1);
env::set_current_dir(original_dir).unwrap();
}
#[test]
fn test_parse_progress_with_fallback_priority_order() {
use std::env;
use tempfile::TempDir;
let _lock = crate::test_support::cwd_lock().lock().unwrap();
let temp_dir = TempDir::new().unwrap();
let base_path = temp_dir.path();
let original_dir = env::current_dir().unwrap();
env::set_current_dir(base_path).unwrap();
let worktree_path = base_path.join("worktree");
write_tasks(
&worktree_path.join("openspec/changes/test-priority/tasks.md"),
&checked_tasks(4),
);
write_tasks(
&worktree_path.join("openspec/changes/archive/test-priority/tasks.md"),
&checked_tasks(3),
);
write_tasks(
&base_path.join("openspec/changes/archive/test-priority/tasks.md"),
&checked_tasks(2),
);
write_tasks(
&base_path.join("openspec/changes/test-priority/tasks.md"),
&checked_tasks(1),
);
let scenarios = [
(true, true, true, true, 4),
(false, true, true, true, 3),
(false, false, true, true, 2),
(false, false, false, true, 1),
];
for (worktree_active, worktree_archive, base_archive, base_active, expected_completed) in
scenarios
{
let case_dir = TempDir::new().unwrap();
let case_base = case_dir.path();
env::set_current_dir(case_base).unwrap();
let case_worktree = case_base.join("worktree");
if worktree_active {
write_tasks(
&case_worktree.join("openspec/changes/test-priority/tasks.md"),
&checked_tasks(4),
);
}
if worktree_archive {
write_tasks(
&case_worktree.join("openspec/changes/archive/test-priority/tasks.md"),
&checked_tasks(3),
);
}
if base_archive {
write_tasks(
&case_base.join("openspec/changes/archive/test-priority/tasks.md"),
&checked_tasks(2),
);
}
if base_active {
write_tasks(
&case_base.join("openspec/changes/test-priority/tasks.md"),
&checked_tasks(1),
);
}
let progress = parse_progress_with_fallback("test-priority", Some(&case_worktree))
.expect("progress should resolve from the first available fallback location");
assert_eq!(progress.completed, expected_completed);
assert_eq!(progress.total, expected_completed);
}
env::set_current_dir(original_dir).unwrap();
}
#[test]
#[allow(deprecated)]
fn test_deprecated_parse_change_with_worktree_fallback_preserves_success_and_not_found() {
use std::env;
use tempfile::TempDir;
let _lock = crate::test_support::cwd_lock().lock().unwrap();
let temp_dir = TempDir::new().unwrap();
let base_path = temp_dir.path();
let original_dir = env::current_dir().unwrap();
env::set_current_dir(base_path).unwrap();
let worktree_path = base_path.join("worktree");
write_tasks(
&worktree_path.join("openspec/changes/compat-change/tasks.md"),
"- [x] Worktree\n- [ ] Worktree pending\n",
);
write_tasks(
&base_path.join("openspec/changes/compat-change/tasks.md"),
"- [ ] Base\n",
);
let progress = parse_change_with_worktree_fallback("compat-change", Some(&worktree_path))
.expect("worktree active tasks should be preferred");
assert_eq!(progress.completed, 1);
assert_eq!(progress.total, 2);
let base_progress = parse_change_with_worktree_fallback("compat-change", None)
.expect("base active tasks should be used without a worktree");
assert_eq!(base_progress.completed, 0);
assert_eq!(base_progress.total, 1);
let missing = parse_change_with_worktree_fallback("missing-change", Some(&worktree_path));
assert!(missing.is_err());
assert!(missing
.unwrap_err()
.to_string()
.contains("Tasks file not found"));
env::set_current_dir(original_dir).unwrap();
}
#[test]
#[allow(deprecated)]
fn test_deprecated_parse_archived_change_preserves_exact_match_and_not_found() {
use std::env;
use tempfile::TempDir;
let _lock = crate::test_support::cwd_lock().lock().unwrap();
let temp_dir = TempDir::new().unwrap();
let base_path = temp_dir.path();
let original_dir = env::current_dir().unwrap();
env::set_current_dir(base_path).unwrap();
write_tasks(
&base_path.join("openspec/changes/archive/archived-compat/tasks.md"),
"- [x] Exact archive\n",
);
write_tasks(
&base_path.join("openspec/changes/archive/2026-05-13-archived-compat/tasks.md"),
"- [ ] Date archive\n",
);
let progress = parse_archived_change("archived-compat")
.expect("exact archived tasks should be preferred");
assert_eq!(progress.completed, 1);
assert_eq!(progress.total, 1);
let missing = parse_archived_change("missing-archive");
assert!(missing.is_err());
assert!(missing
.unwrap_err()
.to_string()
.contains("Archived directory not found"));
env::set_current_dir(original_dir).unwrap();
}
#[test]
#[allow(deprecated)]
fn test_deprecated_parse_archived_change_with_worktree_fallback_preserves_order() {
use std::env;
use tempfile::TempDir;
let _lock = crate::test_support::cwd_lock().lock().unwrap();
let temp_dir = TempDir::new().unwrap();
let base_path = temp_dir.path();
let original_dir = env::current_dir().unwrap();
env::set_current_dir(base_path).unwrap();
let worktree_path = base_path.join("worktree");
write_tasks(
&worktree_path.join("openspec/changes/archive/compat-archived/tasks.md"),
&checked_tasks(3),
);
write_tasks(
&worktree_path.join("openspec/changes/compat-archived/tasks.md"),
&checked_tasks(2),
);
write_tasks(
&base_path.join("openspec/changes/archive/compat-archived/tasks.md"),
&checked_tasks(1),
);
let progress =
parse_archived_change_with_worktree_fallback("compat-archived", Some(&worktree_path))
.expect("worktree archive should be preferred");
assert_eq!(progress.completed, 3);
assert_eq!(progress.total, 3);
let prearchive_dir = TempDir::new().unwrap();
let prearchive_base = prearchive_dir.path();
env::set_current_dir(prearchive_base).unwrap();
let prearchive_worktree = prearchive_base.join("worktree");
write_tasks(
&prearchive_worktree.join("openspec/changes/compat-archived/tasks.md"),
&checked_tasks(2),
);
write_tasks(
&prearchive_base.join("openspec/changes/archive/compat-archived/tasks.md"),
&checked_tasks(1),
);
let prearchive_progress = parse_archived_change_with_worktree_fallback(
"compat-archived",
Some(&prearchive_worktree),
)
.expect("worktree active pre-archive tasks should be used before base archive");
assert_eq!(prearchive_progress.completed, 2);
assert_eq!(prearchive_progress.total, 2);
let base_only_dir = TempDir::new().unwrap();
let base_only = base_only_dir.path();
env::set_current_dir(base_only).unwrap();
write_tasks(
&base_only.join("openspec/changes/archive/compat-archived/tasks.md"),
&checked_tasks(1),
);
let base_progress = parse_archived_change_with_worktree_fallback("compat-archived", None)
.expect("base archive should be used without a worktree");
assert_eq!(base_progress.completed, 1);
assert_eq!(base_progress.total, 1);
let missing = parse_archived_change_with_worktree_fallback("missing-archive", None);
assert!(missing.is_err());
assert!(missing
.unwrap_err()
.to_string()
.contains("Archived directory not found"));
env::set_current_dir(original_dir).unwrap();
}
#[test]
fn test_parse_progress_with_fallback_not_found() {
use std::env;
use tempfile::TempDir;
let _lock = crate::test_support::cwd_lock().lock().unwrap();
let temp_dir = TempDir::new().unwrap();
let base_path = temp_dir.path();
let original_dir = env::current_dir().unwrap();
env::set_current_dir(base_path).unwrap();
let result = parse_progress_with_fallback("nonexistent", None);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("not found for change 'nonexistent'"));
env::set_current_dir(original_dir).unwrap();
}
}
#[cfg(test)]
mod recovery_tests {
use super::*;
const DRIFTED_FOLLOW_UP: &str = concat!(
"## Implementation Tasks\n",
"- [x] done\n",
"\n",
"## Current Acceptance Follow-up\n",
"- attempt: 1\n",
"- [x] [SAME_FINDING] fixed wording\n",
" Evidence: ran `cargo test`\n",
"### Reviewer notes\n",
"First unknown paragraph.\n",
"\n",
"Second paragraph with ``inline`` runs and - [ ] checkbox text.\n",
);
const UNKNOWN_PAYLOAD: &str = concat!(
"### Reviewer notes\n",
"First unknown paragraph.\n",
"\n",
"Second paragraph with ``inline`` runs and - [ ] checkbox text."
);
fn replace(content: &str) -> (String, FollowUpRecovery) {
plan_replace_acceptance_follow_up(content, 2, &["[SAME_FINDING] still broken".into()])
.expect("replacement plan succeeds")
}
#[test]
fn unknown_follow_up_content_is_recovered_instead_of_terminating_the_workflow() {
let (content, recovery) = replace(DRIFTED_FOLLOW_UP);
assert_eq!(recovery.recovered_blocks, 1);
assert_eq!(recovery.recovered_bytes, UNKNOWN_PAYLOAD.len());
assert!(recovery.warning().is_some());
assert!(content.contains(&format!(
"{RECOVERED_NOTES_HEADING}\n\n{RECOVERED_NOTES_NOTICE}\n\n```text\n{UNKNOWN_PAYLOAD}\n```\n"
)));
assert!(content.contains("## Current Acceptance Follow-up\n- attempt: 2\n"));
assert!(content.contains("- [ ] [SAME_FINDING] still broken"));
assert!(!content.contains("- attempt: 1"));
assert!(content.starts_with("## Implementation Tasks\n- [x] done\n"));
assert!(content.find(RECOVERED_NOTES_HEADING) < content.find(ACCEPTANCE_FOLLOW_UP_HEADING));
}
#[test]
fn runtime_metadata_capitalization_drift_stays_runtime_owned() {
let drifted = concat!(
"## Current Acceptance Follow-up\n",
"- Attempt: 1\n",
"- [X] fixed\n",
" EVIDENCE: cargo test passed\n",
"### External Blockers\n",
"- Identity: `external|api`\n",
" Next action: Resolve the external prerequisite.\n",
);
let (content, recovery) = replace(drifted);
assert_eq!(recovery, FollowUpRecovery::default());
assert!(!content.contains(RECOVERED_NOTES_HEADING));
}
#[test]
fn repeated_normalization_and_restart_do_not_duplicate_recovered_notes() {
let (first, first_recovery) = replace(DRIFTED_FOLLOW_UP);
assert_eq!(first_recovery.recovered_blocks, 1);
let (second, second_recovery) = replace(&first);
assert_eq!(second_recovery, FollowUpRecovery::default());
assert_eq!(second.matches(RECOVERED_NOTES_HEADING).count(), 1);
assert_eq!(second.matches(UNKNOWN_PAYLOAD).count(), 1);
let (merged, merge_recovery) =
plan_merge_acceptance_follow_up(&second, 2, &["[SAME_FINDING] still broken".into()])
.unwrap();
assert_eq!(merge_recovery, FollowUpRecovery::default());
assert_eq!(merged.matches(UNKNOWN_PAYLOAD).count(), 1);
let (restarted, restart_recovery) = replace(&merged);
assert_eq!(restart_recovery, FollowUpRecovery::default());
assert_eq!(restarted, second);
}
#[test]
fn distinct_unknown_payloads_accumulate_as_separate_blocks() {
let (first, _) = replace(DRIFTED_FOLLOW_UP);
let with_new_drift = format!("{first}\nA brand new unknown note.\n");
let (second, recovery) = replace(&with_new_drift);
assert_eq!(recovery.recovered_blocks, 1);
assert_eq!(second.matches(RECOVERED_NOTES_HEADING).count(), 1);
assert!(second.contains(UNKNOWN_PAYLOAD));
assert!(second.contains("A brand new unknown note."));
}
#[test]
fn pass_cleanup_removes_runtime_section_and_retains_recovered_notes() {
let (with_notes, _) = replace(DRIFTED_FOLLOW_UP);
let (cleaned, recovery) = plan_clear_acceptance_follow_up(&with_notes).unwrap();
assert_eq!(recovery, FollowUpRecovery::default());
assert!(!cleaned.contains(ACCEPTANCE_FOLLOW_UP_HEADING));
assert!(cleaned.contains(RECOVERED_NOTES_HEADING));
assert!(cleaned.contains(UNKNOWN_PAYLOAD));
assert!(cleaned.starts_with("## Implementation Tasks\n- [x] done\n"));
}
#[test]
fn recovered_fence_is_longer_than_the_longest_backtick_run() {
let payload_source = concat!(
"## Current Acceptance Follow-up\n",
"- attempt: 1\n",
"`````\n",
"````\n",
"not a runtime record\n",
"````\n",
"`````\n",
);
let (content, recovery) = replace(payload_source);
assert_eq!(recovery.recovered_blocks, 1);
assert!(content.contains("``````text\n"));
assert!(content.contains("`````\n````\nnot a runtime record\n````\n`````\n``````\n"));
let (again, again_recovery) = replace(&content);
assert_eq!(again_recovery, FollowUpRecovery::default());
assert_eq!(again, content);
}
#[test]
fn recovered_checkbox_text_is_inert_for_task_progress() {
let (content, _) = replace(DRIFTED_FOLLOW_UP);
assert_eq!(
parse_content(&content, None),
TaskProgress::with_counts(1, 2)
);
}
#[test]
fn task_progress_ignores_dynamic_and_tilde_fences() {
let content = concat!(
"- [x] real task\n",
"~~~~\n",
"- [x] fenced\n",
"~~~\n",
"- [ ] still fenced\n",
"~~~~\n",
"````md\n",
"```\n",
"- [x] fenced\n",
"```\n",
"````\n",
"- [ ] second real task\n",
);
assert_eq!(
parse_content(content, None),
TaskProgress::with_counts(1, 2)
);
}
#[test]
fn unclosed_fence_is_a_hard_error_that_changes_nothing() {
let ambiguous = concat!(
"## Implementation Tasks\n",
"- [x] done\n",
"```md\n",
"## Current Acceptance Follow-up\n",
"- [ ] finding\n",
);
for error in [
plan_replace_acceptance_follow_up(ambiguous, 2, &["finding".into()]).unwrap_err(),
plan_merge_acceptance_follow_up(ambiguous, 2, &["finding".into()]).unwrap_err(),
plan_clear_acceptance_follow_up(ambiguous).unwrap_err(),
] {
let message = error.to_string();
assert!(message.contains("unclosed code fence"), "{message}");
assert!(
message.contains("boundary cannot be determined safely"),
"{message}"
);
}
}
#[test]
fn unclosed_fence_inside_the_follow_up_section_is_a_hard_error() {
let ambiguous = concat!(
"## Current Acceptance Follow-up\n",
"- attempt: 1\n",
"- [ ] finding\n",
"```text\n",
"unterminated reviewer dump\n",
);
let error = plan_clear_acceptance_follow_up(ambiguous).unwrap_err();
assert!(error.to_string().contains("unclosed code fence"));
}
#[test]
fn follow_up_headings_inside_fences_are_never_treated_as_runtime_sections() {
let documented = concat!(
"## Notes\n",
"```md\n",
"## Current Acceptance Follow-up\n",
"- [ ] example\n",
"```\n",
);
let (cleaned, recovery) = plan_clear_acceptance_follow_up(documented).unwrap();
assert_eq!(recovery, FollowUpRecovery::default());
assert_eq!(cleaned, documented);
}
#[test]
fn atomic_update_leaves_the_original_file_unchanged_when_staging_fails() {
let dir = tempfile::tempdir().unwrap();
let tasks_path = dir.path().join("tasks.md");
std::fs::write(&tasks_path, DRIFTED_FOLLOW_UP).unwrap();
let mut permissions = std::fs::metadata(dir.path()).unwrap().permissions();
let original_mode = {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
let mode = permissions.mode();
permissions.set_mode(0o500);
mode
}
#[cfg(not(unix))]
{
permissions.set_readonly(true);
0
}
};
std::fs::set_permissions(dir.path(), permissions).unwrap();
let error = replace_acceptance_follow_up_from_latest_fail(
&tasks_path,
2,
&["still broken".to_string()],
)
.unwrap_err();
let mut restore = std::fs::metadata(dir.path()).unwrap().permissions();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
restore.set_mode(original_mode);
}
#[cfg(not(unix))]
{
restore.set_readonly(false);
}
std::fs::set_permissions(dir.path(), restore).unwrap();
assert!(error
.to_string()
.contains("Failed to stage atomic tasks update"));
assert_eq!(
std::fs::read_to_string(&tasks_path).unwrap(),
DRIFTED_FOLLOW_UP
);
}
}