use crate::error::{OrchestratorError, Result};
use crate::tui::log_deduplicator;
use regex::Regex;
use std::fmt::Write as _;
use std::path::Path;
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 = std::fs::read_to_string(path).map_err(|e| {
OrchestratorError::ConfigLoad(format!("Failed to read tasks file {:?}: {}", path, e))
})?;
Ok(parse_content(&content, change_id))
}
pub fn parse_change(change_id: &str) -> Result<TaskProgress> {
let tasks_path = Path::new("openspec/changes")
.join(change_id)
.join("tasks.md");
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(wt_path) = worktree_path {
let wt_tasks = wt_path
.join("openspec/changes")
.join(change_id)
.join("tasks.md");
if wt_tasks.exists() {
debug!("Reading tasks from worktree: {:?}", wt_tasks);
return parse_file(&wt_tasks, Some(change_id));
}
}
parse_change(change_id)
}
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 archive_path = find_archive_directory(change_id, None).ok_or_else(|| {
OrchestratorError::ConfigLoad(format!(
"Archived directory not found for change '{}' in openspec/changes/archive/",
change_id
))
})?;
let tasks_path = archive_path.join("tasks.md");
if !tasks_path.exists() {
return Err(OrchestratorError::ConfigLoad(format!(
"Archived 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)]
#[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(archive_path) = find_archive_directory(change_id, Some(wt_path)) {
let wt_archive_tasks = archive_path.join("tasks.md");
if wt_archive_tasks.exists() {
debug!(
"Reading archived tasks from worktree archive: {:?}",
wt_archive_tasks
);
return parse_file(&wt_archive_tasks, Some(change_id));
}
}
let wt_tasks = wt_path
.join("openspec/changes")
.join(change_id)
.join("tasks.md");
if wt_tasks.exists() {
debug!(
"Reading archived tasks from worktree (pre-archive): {:?}",
wt_tasks
);
return parse_file(&wt_tasks, Some(change_id));
}
}
parse_archived_change(change_id)
}
pub fn parse_progress_with_fallback(
change_id: &str,
worktree_path: Option<&Path>,
) -> Result<TaskProgress> {
if let Some(wt_path) = worktree_path {
let wt_active = wt_path
.join("openspec/changes")
.join(change_id)
.join("tasks.md");
if wt_active.exists() {
debug!(
"Reading progress from worktree active location: {:?}",
wt_active
);
return parse_file(&wt_active, Some(change_id));
}
if let Some(archive_path) = find_archive_directory(change_id, Some(wt_path)) {
let wt_archive_tasks = archive_path.join("tasks.md");
if wt_archive_tasks.exists() {
debug!(
"Reading progress from worktree archive location: {:?}",
wt_archive_tasks
);
return parse_file(&wt_archive_tasks, Some(change_id));
}
}
}
if let Some(archive_path) = find_archive_directory(change_id, None) {
let base_archive_tasks = archive_path.join("tasks.md");
if base_archive_tasks.exists() {
debug!(
"Reading progress from base tree archive location: {:?}",
base_archive_tasks
);
return parse_file(&base_archive_tasks, Some(change_id));
}
}
let base_active = Path::new("openspec/changes")
.join(change_id)
.join("tasks.md");
if base_active.exists() {
debug!(
"Reading progress from base tree active location: {:?}",
base_active
);
return parse_file(&base_active, 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)
}
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 = std::fs::read_to_string(tasks_path).map_err(|e| {
OrchestratorError::ConfigLoad(format!("Failed to read tasks file {:?}: {}", tasks_path, e))
})?;
let heading = acceptance_follow_up_heading(attempt);
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());
}
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];
let mut new_section = String::new();
for finding in normalized_findings {
let _ = writeln!(&mut new_section, "- [ ] {}", finding);
}
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!(&mut content, "{}", heading);
for finding in normalized_findings {
let _ = writeln!(&mut content, "- [ ] {}", finding);
}
}
std::fs::write(tasks_path, content).map_err(|e| {
OrchestratorError::ConfigLoad(format!(
"Failed to write tasks file {:?}: {}",
tasks_path, e
))
})?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[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",
)
.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("- [x] stale"));
}
#[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");
let wt_active = worktree_path.join("openspec/changes/test-priority");
std::fs::create_dir_all(&wt_active).unwrap();
std::fs::write(
wt_active.join("tasks.md"),
"- [x] WT Active 1\n- [x] WT Active 2\n- [x] WT Active 3",
)
.unwrap();
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] WT Archive 1\n- [x] WT Archive 2",
)
.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"), "- [x] Base Archive 1").unwrap();
let base_active = base_path.join("openspec/changes/test-priority");
std::fs::create_dir_all(&base_active).unwrap();
std::fs::write(base_active.join("tasks.md"), "- [ ] Base Active 1").unwrap();
let result = parse_progress_with_fallback("test-priority", Some(&worktree_path));
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_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();
}
}