use crate::error::{OrchestratorError, Result};
use crate::tui::log_deduplicator;
use regex::Regex;
use std::fmt::Write as _;
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();
for line in content.lines() {
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(path: &Path, content: String) -> Result<()> {
std::fs::write(path, content).map_err(|e| {
OrchestratorError::ConfigLoad(format!("Failed to write tasks file {:?}: {}", path, e))
})
}
#[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 find_archive_directory(change_id: &str, base_path: Option<&Path>) -> Option<std::path::PathBuf> {
let archive_dir = match base_path {
Some(base) => base.join("openspec/changes/archive"),
None => Path::new("openspec/changes/archive").to_path_buf(),
};
if !archive_dir.exists() {
return None;
}
let exact_match = archive_dir.join(change_id);
if exact_match.exists() && exact_match.is_dir() {
return Some(exact_match);
}
if let Ok(entries) = std::fs::read_dir(&archive_dir) {
for entry in entries.filter_map(|e| e.ok()) {
let name = entry.file_name();
let name_str = name.to_string_lossy();
if name_str.ends_with(&format!("-{}", change_id)) && entry.path().is_dir() {
return Some(entry.path());
}
}
}
None
}
#[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> {
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> {
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(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
)))
}
fn acceptance_follow_up_heading(attempt: u32) -> String {
format!("## Acceptance #{} Failure Follow-up", attempt)
}
fn normalize_acceptance_findings(findings: &[String]) -> Vec<String> {
let mut normalized_findings = findings
.iter()
.map(|finding| finding.trim())
.filter(|finding| !finding.is_empty())
.map(str::to_string)
.collect::<Vec<_>>();
normalized_findings.sort();
normalized_findings.dedup();
if normalized_findings.is_empty() {
normalized_findings
.push("Investigate acceptance failure and apply the required fix".to_string());
}
normalized_findings
}
fn render_acceptance_follow_up_section(findings: &[String]) -> String {
let mut section = String::new();
for finding in findings {
let _ = writeln!(&mut section, "- [ ] {}", finding);
}
section
}
fn upsert_acceptance_follow_up_section(content: &mut String, heading: &str, findings: &[String]) {
let new_section = render_acceptance_follow_up_section(findings);
if let Some(section_start) = content.find(heading) {
let section_body_start = content[section_start..]
.find('\n')
.map(|offset| section_start + offset + 1)
.unwrap_or(content.len());
let section_end = content[section_body_start..]
.find("\n## ")
.map(|offset| section_body_start + offset + 1)
.unwrap_or(content.len());
let existing_section = &content[section_body_start..section_end];
if existing_section.trim() != new_section.trim() {
content.replace_range(section_body_start..section_end, &new_section);
}
} else {
if !content.ends_with('\n') {
content.push('\n');
}
if !content.ends_with("\n\n") {
content.push('\n');
}
let _ = writeln!(content, "{}", heading);
content.push_str(&new_section);
}
}
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(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 record_acceptance_follow_up(
tasks_path: &Path,
attempt: u32,
findings: &[String],
) -> Result<()> {
let mut content = read_tasks_file(tasks_path)?;
let heading = acceptance_follow_up_heading(attempt);
let normalized_findings = normalize_acceptance_findings(findings);
upsert_acceptance_follow_up_section(&mut content, &heading, &normalized_findings);
write_tasks_file(tasks_path, content)
}
#[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 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();
record_acceptance_follow_up(
&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("## Acceptance #2 Failure 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## Acceptance #1 Failure Follow-up\n- [x] stale\n\n## Final Validation\n- [ ] run tests\n",
)
.unwrap();
record_acceptance_follow_up(&tasks_path, 1, &["fresh 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("- [ ] fresh finding"));
assert!(content.contains("## Final Validation\n- [ ] run tests"));
assert!(!content.contains("- [x] stale"));
}
#[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();
record_acceptance_follow_up(&tasks_path, 3, &[" ".to_string(), "\t".to_string()]).unwrap();
let content = std::fs::read_to_string(&tasks_path).unwrap();
assert!(content.contains("## Acceptance #3 Failure 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();
record_acceptance_follow_up(&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## Acceptance #4 Failure Follow-up\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 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_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();
}
}