use crate::execution::apply::check_task_progress;
use crate::execution::state::{detect_workspace_state, WorkspaceState};
use crate::parallel::workspace::get_or_create_workspace;
use crate::parallel::ParallelEvent;
use crate::vcs::{
VcsBackend, VcsResult, VcsWarning, Workspace, WorkspaceInfo, WorkspaceManager, WorkspaceStatus,
};
use async_trait::async_trait;
use std::collections::HashSet;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command as StdCommand;
use std::time::SystemTime;
use tempfile::TempDir;
use tokio::sync::mpsc;
fn init_git_repo(repo_root: &Path) {
StdCommand::new("git")
.args(["init", "-b", "main"])
.current_dir(repo_root)
.output()
.unwrap();
StdCommand::new("git")
.args(["config", "user.email", "test@example.com"])
.current_dir(repo_root)
.output()
.unwrap();
StdCommand::new("git")
.args(["config", "user.name", "Test User"])
.current_dir(repo_root)
.output()
.unwrap();
}
fn git_commit(repo_root: &Path, message: &str) {
fs::write(repo_root.join("test.txt"), message).unwrap();
StdCommand::new("git")
.args(["add", "-A"])
.current_dir(repo_root)
.output()
.unwrap();
StdCommand::new("git")
.args(["commit", "-m", message])
.current_dir(repo_root)
.output()
.unwrap();
}
struct ResumeTestManager {
existing: Option<WorkspaceInfo>,
workspace_path: PathBuf,
}
#[async_trait]
impl WorkspaceManager for ResumeTestManager {
fn backend_type(&self) -> VcsBackend {
VcsBackend::Git
}
async fn check_available(&self) -> VcsResult<bool> {
Ok(true)
}
async fn prepare_for_parallel(&self) -> VcsResult<Option<VcsWarning>> {
Ok(None)
}
async fn get_current_revision(&self) -> VcsResult<String> {
Ok("abc123".to_string())
}
async fn create_workspace(
&mut self,
change_id: &str,
_base_revision: Option<&str>,
) -> VcsResult<Workspace> {
Ok(Workspace {
name: format!("ws-{}", change_id),
path: self.workspace_path.clone(),
change_id: change_id.to_string(),
base_revision: "base".to_string(),
status: WorkspaceStatus::Created,
})
}
fn update_workspace_status(&mut self, _workspace_name: &str, _status: WorkspaceStatus) {}
async fn find_existing_workspace(
&mut self,
_change_id: &str,
) -> VcsResult<Option<WorkspaceInfo>> {
Ok(self.existing.clone())
}
async fn reuse_workspace(&mut self, info: &WorkspaceInfo) -> VcsResult<Workspace> {
Ok(Workspace {
name: info.workspace_name.clone(),
path: self.workspace_path.clone(),
change_id: info.change_id.clone(),
base_revision: "base".to_string(),
status: WorkspaceStatus::Applying,
})
}
async fn merge_workspaces(&self, _revisions: &[String]) -> VcsResult<String> {
Ok("merge-rev".to_string())
}
async fn cleanup_workspace(&mut self, _workspace_name: &str) -> VcsResult<()> {
Ok(())
}
async fn cleanup_all(&mut self) -> VcsResult<()> {
Ok(())
}
fn max_concurrent(&self) -> usize {
1
}
fn workspaces(&self) -> Vec<Workspace> {
vec![]
}
async fn list_worktree_change_ids(&self) -> VcsResult<HashSet<String>> {
Ok(HashSet::new())
}
fn conflict_resolution_prompt(&self) -> &'static str {
""
}
async fn snapshot_working_copy(&self, _workspace_path: &Path) -> VcsResult<()> {
Ok(())
}
async fn set_commit_message(&self, _workspace_path: &Path, _message: &str) -> VcsResult<()> {
Ok(())
}
async fn create_iteration_snapshot(
&self,
_workspace_path: &Path,
_change_id: &str,
_iteration: u32,
_completed: u32,
_total: u32,
) -> VcsResult<()> {
Ok(())
}
async fn squash_wip_commits(
&self,
_workspace_path: &Path,
_change_id: &str,
_final_iteration: u32,
) -> VcsResult<()> {
Ok(())
}
async fn get_revision_in_workspace(&self, _workspace_path: &Path) -> VcsResult<String> {
Ok("workspace-rev".to_string())
}
async fn get_status(&self) -> VcsResult<String> {
Ok(String::new())
}
async fn get_log_for_revisions(&self, _revisions: &[String]) -> VcsResult<String> {
Ok(String::new())
}
async fn detect_conflicts(&self) -> VcsResult<Vec<String>> {
Ok(vec![])
}
fn forget_workspace_sync(&self, _workspace_name: &str) {}
fn repo_root(&self) -> &Path {
&self.workspace_path
}
async fn ensure_original_branch_initialized(&self) -> VcsResult<String> {
Ok("main".to_string())
}
fn original_branch(&self) -> Option<String> {
Some("main".to_string())
}
}
#[tokio::test]
async fn test_get_or_create_workspace_new_returns_not_resumed() {
let tmp = TempDir::new().unwrap();
let mut manager = ResumeTestManager {
existing: None,
workspace_path: tmp.path().to_path_buf(),
};
let (tx, _rx) = mpsc::channel::<ParallelEvent>(16);
let event_tx = Some(tx);
let (_ws, was_resumed) = get_or_create_workspace(
&mut manager,
"my-change",
"base-rev",
false,
&HashSet::new(),
&event_tx,
)
.await
.expect("get_or_create_workspace should succeed");
assert!(!was_resumed, "new workspace must report was_resumed=false");
}
#[tokio::test]
async fn test_get_or_create_workspace_reuse_returns_resumed() {
let tmp = TempDir::new().unwrap();
let workspace_info = WorkspaceInfo {
workspace_name: "ws-my-change".to_string(),
path: tmp.path().to_path_buf(),
change_id: "my-change".to_string(),
last_modified: SystemTime::UNIX_EPOCH,
};
let mut manager = ResumeTestManager {
existing: Some(workspace_info),
workspace_path: tmp.path().to_path_buf(),
};
let (tx, _rx) = mpsc::channel::<ParallelEvent>(16);
let event_tx = Some(tx);
let (_ws, was_resumed) = get_or_create_workspace(
&mut manager,
"my-change",
"base-rev",
false,
&HashSet::new(),
&event_tx,
)
.await
.expect("get_or_create_workspace should succeed");
assert!(
was_resumed,
"existing workspace must report was_resumed=true"
);
}
#[tokio::test]
async fn test_get_or_create_workspace_no_resume_creates_fresh() {
let tmp = TempDir::new().unwrap();
let workspace_info = WorkspaceInfo {
workspace_name: "ws-my-change".to_string(),
path: tmp.path().to_path_buf(),
change_id: "my-change".to_string(),
last_modified: SystemTime::UNIX_EPOCH,
};
let mut manager = ResumeTestManager {
existing: Some(workspace_info),
workspace_path: tmp.path().to_path_buf(),
};
let (tx, _rx) = mpsc::channel::<ParallelEvent>(16);
let event_tx = Some(tx);
let (_ws, was_resumed) = get_or_create_workspace(
&mut manager,
"my-change",
"base-rev",
true, &HashSet::new(),
&event_tx,
)
.await
.expect("get_or_create_workspace should succeed");
assert!(
!was_resumed,
"no_resume=true must bypass existing workspace and report was_resumed=false"
);
}
#[tokio::test]
async fn test_detect_workspace_state_archived_not_treated_as_created() {
let tmp = TempDir::new().unwrap();
let repo_root = tmp.path();
init_git_repo(repo_root);
git_commit(repo_root, "Initial commit");
StdCommand::new("git")
.args(["checkout", "-b", "workspace-my-change"])
.current_dir(repo_root)
.output()
.unwrap();
let archive_dir = repo_root.join("openspec/changes/archive/my-change");
fs::create_dir_all(&archive_dir).unwrap();
fs::write(
archive_dir.join("tasks.md"),
"## Tasks\n- [x] Do something\n",
)
.unwrap();
git_commit(repo_root, "Archive: my-change");
let state = detect_workspace_state("my-change", repo_root, "main")
.await
.expect("detect_workspace_state must not fail");
assert_eq!(
state,
WorkspaceState::Archived,
"workspace with committed archive entry must be detected as Archived, not Created"
);
}
#[tokio::test]
async fn test_detect_workspace_state_archived_date_prefixed_not_treated_as_created() {
let tmp = TempDir::new().unwrap();
let repo_root = tmp.path();
init_git_repo(repo_root);
git_commit(repo_root, "Initial commit");
StdCommand::new("git")
.args(["checkout", "-b", "workspace-dated-change"])
.current_dir(repo_root)
.output()
.unwrap();
let archive_dir = repo_root.join("openspec/changes/archive/2024-03-15-dated-change");
fs::create_dir_all(&archive_dir).unwrap();
fs::write(archive_dir.join("tasks.md"), "## Tasks\n- [x] Done\n").unwrap();
git_commit(repo_root, "Archive: dated-change");
let state = detect_workspace_state("dated-change", repo_root, "main")
.await
.expect("detect_workspace_state must not fail");
assert_eq!(
state,
WorkspaceState::Archived,
"workspace with date-prefixed archive entry must be detected as Archived"
);
}
#[test]
fn test_check_task_progress_archive_fallback_returns_progress() {
let tmp = TempDir::new().unwrap();
let workspace_path = tmp.path();
let archive_dir = workspace_path.join("openspec/changes/archive/my-change");
fs::create_dir_all(&archive_dir).unwrap();
fs::write(
archive_dir.join("tasks.md"),
"## Implementation Tasks\n- [x] Task one\n- [x] Task two\n",
)
.unwrap();
let progress = check_task_progress(workspace_path, "my-change")
.expect("check_task_progress must succeed when tasks.md is in archive dir");
assert_eq!(progress.completed, 2);
assert_eq!(progress.total, 2);
}
#[test]
fn test_check_task_progress_missing_returns_error() {
let tmp = TempDir::new().unwrap();
let workspace_path = tmp.path();
let result = check_task_progress(workspace_path, "no-such-change");
assert!(
result.is_err(),
"missing tasks.md must produce an error, not a false complete result"
);
}
#[tokio::test]
async fn test_mixed_restart_archiving_and_archived_states_are_distinct() {
let tmp_archiving = TempDir::new().unwrap();
let path_archiving = tmp_archiving.path();
init_git_repo(path_archiving);
git_commit(path_archiving, "Initial commit");
StdCommand::new("git")
.args(["checkout", "-b", "workspace-change-archiving"])
.current_dir(path_archiving)
.output()
.unwrap();
let archive_dir_a = path_archiving.join("openspec/changes/archive/change-archiving");
fs::create_dir_all(&archive_dir_a).unwrap();
fs::write(archive_dir_a.join("proposal.md"), "# Archiving change").unwrap();
let state_archiving = detect_workspace_state("change-archiving", path_archiving, "main")
.await
.expect("detect_workspace_state must not fail for Archiving workspace");
let tmp_archived = TempDir::new().unwrap();
let path_archived = tmp_archived.path();
init_git_repo(path_archived);
git_commit(path_archived, "Initial commit");
StdCommand::new("git")
.args(["checkout", "-b", "workspace-change-archived"])
.current_dir(path_archived)
.output()
.unwrap();
let archive_dir_b = path_archived.join("openspec/changes/archive/change-archived");
fs::create_dir_all(&archive_dir_b).unwrap();
fs::write(archive_dir_b.join("tasks.md"), "## Tasks\n- [x] Done\n").unwrap();
git_commit(path_archived, "Archive: change-archived");
let state_archived = detect_workspace_state("change-archived", path_archived, "main")
.await
.expect("detect_workspace_state must not fail for Archived workspace");
assert_eq!(
state_archiving,
WorkspaceState::Archiving,
"workspace with uncommitted archive files must be Archiving"
);
assert_eq!(
state_archived,
WorkspaceState::Archived,
"workspace with committed archive entry must be Archived"
);
assert_ne!(
state_archiving, state_archived,
"Archiving and Archived states must be distinguishable on restart"
);
}
#[tokio::test]
async fn test_archived_workspace_head_revision_is_readable() {
let tmp = TempDir::new().unwrap();
let repo_root = tmp.path();
init_git_repo(repo_root);
git_commit(repo_root, "Initial commit");
StdCommand::new("git")
.args(["checkout", "-b", "workspace-archive-rev"])
.current_dir(repo_root)
.output()
.unwrap();
let archive_dir = repo_root.join("openspec/changes/archive/archive-rev-change");
fs::create_dir_all(&archive_dir).unwrap();
fs::write(archive_dir.join("tasks.md"), "## Tasks\n- [x] Done\n").unwrap();
git_commit(repo_root, "Archive: archive-rev-change");
let state = detect_workspace_state("archive-rev-change", repo_root, "main")
.await
.expect("detect_workspace_state must succeed");
assert_eq!(state, WorkspaceState::Archived);
let rev = crate::vcs::git::commands::get_current_commit(repo_root)
.await
.expect("get_current_commit must succeed on a clean archived workspace");
assert!(
!rev.is_empty(),
"archived workspace HEAD revision must be non-empty"
);
assert!(
rev.len() >= 7,
"archived workspace HEAD revision must look like a git SHA, got: {}",
rev
);
}