use std::path::{Path, PathBuf};
use std::time::SystemTime;
use serde::{Deserialize, Serialize};
use crate::error::{CruiseError, Result};
pub const PLAN_VAR: &str = "plan";
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
pub enum SessionPhase {
AwaitingApproval,
Planned,
Running,
Completed,
Failed(String),
Suspended,
}
impl SessionPhase {
#[must_use]
pub fn label(&self) -> &str {
match self {
Self::AwaitingApproval => "Awaiting Approval",
Self::Planned => "Planned",
Self::Running => "Running",
Self::Completed => "Completed",
Self::Failed(_) => "Failed",
Self::Suspended => "Suspended",
}
}
#[must_use]
pub fn is_runnable(&self) -> bool {
matches!(
self,
Self::Planned | Self::Running | Self::Failed(_) | Self::Suspended
)
}
}
#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Default)]
pub enum WorkspaceMode {
#[default]
Worktree,
CurrentBranch,
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
pub struct SessionState {
pub id: String,
pub base_dir: PathBuf,
pub phase: SessionPhase,
pub config_source: String,
pub input: String,
#[serde(default)]
pub title: Option<String>,
pub current_step: Option<String>,
pub created_at: String,
pub completed_at: Option<String>,
pub worktree_path: Option<PathBuf>,
pub worktree_branch: Option<String>,
#[serde(default)]
pub workspace_mode: WorkspaceMode,
#[serde(default)]
pub target_branch: Option<String>,
#[serde(default)]
pub pr_url: Option<String>,
#[serde(default)]
pub config_path: Option<PathBuf>,
#[serde(default)]
pub updated_at: Option<String>,
#[serde(default)]
pub awaiting_input: bool,
#[serde(default)]
pub plan_error: Option<String>,
#[serde(default)]
pub skipped_steps: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SessionStateFingerprint([u8; 32]);
impl SessionStateFingerprint {
fn from_bytes(bytes: &[u8]) -> Self {
Self(crate::file_tracker::sha256_digest(bytes))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SessionFileContents {
Missing,
Parsed {
state: Box<SessionState>,
fingerprint: SessionStateFingerprint,
},
Invalid {
fingerprint: SessionStateFingerprint,
error: String,
},
}
impl SessionFileContents {
#[must_use]
pub fn fingerprint(&self) -> Option<SessionStateFingerprint> {
match self {
Self::Missing => None,
Self::Parsed { fingerprint, .. } | Self::Invalid { fingerprint, .. } => {
Some(*fingerprint)
}
}
}
}
impl SessionState {
#[must_use]
pub fn new(id: String, base_dir: PathBuf, config_source: String, input: String) -> Self {
Self {
id,
base_dir,
phase: SessionPhase::AwaitingApproval,
config_source,
input,
title: None,
current_step: None,
created_at: current_iso8601(),
completed_at: None,
worktree_path: None,
worktree_branch: None,
workspace_mode: WorkspaceMode::Worktree,
target_branch: None,
pr_url: None,
config_path: None,
updated_at: None,
awaiting_input: false,
plan_error: None,
skipped_steps: vec![],
}
}
#[must_use]
pub fn plan_path(&self, sessions_dir: &Path) -> PathBuf {
sessions_dir.join(&self.id).join("plan.md")
}
#[must_use]
pub fn title_or_input(&self) -> &str {
self.title
.as_deref()
.map(str::trim)
.filter(|title| !title.is_empty())
.unwrap_or(&self.input)
}
pub fn approve(&mut self) {
assert!(
matches!(self.phase, SessionPhase::AwaitingApproval),
"approve() called on session in '{}' phase",
self.phase.label()
);
self.plan_error = None;
self.phase = SessionPhase::Planned;
}
pub fn reset_to_planned(&mut self) {
self.phase = SessionPhase::Planned;
self.current_step = None;
self.completed_at = None;
self.pr_url = None;
self.plan_error = None;
}
#[must_use]
pub fn worktree_context(&self) -> Option<crate::worktree::WorktreeContext> {
let path = self.worktree_path.as_ref()?;
let branch = self.worktree_branch.as_ref()?;
if !path.exists() {
return None;
}
Some(crate::worktree::WorktreeContext {
path: path.clone(),
branch: branch.clone(),
original_dir: self.base_dir.clone(),
})
}
}
pub struct SessionManager {
base: PathBuf,
}
impl SessionManager {
#[must_use]
pub fn new(base: PathBuf) -> Self {
Self { base }
}
#[must_use]
pub fn sessions_dir(&self) -> PathBuf {
self.base.join("sessions")
}
#[must_use]
pub fn worktrees_dir(&self) -> PathBuf {
self.base.join("worktrees")
}
#[must_use]
pub fn run_log_path(&self, session_id: &str) -> PathBuf {
self.sessions_dir().join(session_id).join("run.log")
}
#[must_use]
pub fn new_session_id() -> String {
current_timestamp_id()
}
pub fn create(&self, state: &SessionState) -> Result<()> {
let session_dir = self.sessions_dir().join(&state.id);
std::fs::create_dir_all(&session_dir)?;
self.save(state)?;
Ok(())
}
pub fn load(&self, id: &str) -> Result<SessionState> {
let (state, _) = self.load_with_fingerprint(id)?;
Ok(state)
}
pub fn save(&self, state: &SessionState) -> Result<()> {
let mut state = state.clone();
state.updated_at = Some(current_iso8601());
self.save_with_fingerprint(&state)?;
Ok(())
}
pub(crate) fn state_path(&self, id: &str) -> PathBuf {
self.sessions_dir().join(id).join("state.json")
}
pub(crate) fn load_with_fingerprint(
&self,
id: &str,
) -> Result<(SessionState, SessionStateFingerprint)> {
let path = self.state_path(id);
let bytes = std::fs::read(&path)
.map_err(|e| CruiseError::SessionError(format!("failed to load session {id}: {e}")))?;
let fingerprint = SessionStateFingerprint::from_bytes(&bytes);
let state = serde_json::from_slice(&bytes)
.map_err(|e| CruiseError::SessionError(format!("failed to parse session {id}: {e}")))?;
Ok((state, fingerprint))
}
pub fn inspect_state_file(&self, id: &str) -> Result<SessionFileContents> {
let path = self.state_path(id);
let bytes = match std::fs::read(&path) {
Ok(bytes) => bytes,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Ok(SessionFileContents::Missing);
}
Err(e) => {
return Err(CruiseError::SessionError(format!(
"failed to inspect session {id}: {e}"
)));
}
};
let fingerprint = SessionStateFingerprint::from_bytes(&bytes);
match serde_json::from_slice(&bytes) {
Ok(state) => Ok(SessionFileContents::Parsed {
state: Box::new(state),
fingerprint,
}),
Err(e) => Ok(SessionFileContents::Invalid {
fingerprint,
error: e.to_string(),
}),
}
}
pub(crate) fn save_with_fingerprint(
&self,
state: &SessionState,
) -> Result<SessionStateFingerprint> {
let path = self.state_path(&state.id);
let json = serde_json::to_vec_pretty(state)
.map_err(|e| CruiseError::SessionError(format!("serialize error: {e}")))?;
let fingerprint = SessionStateFingerprint::from_bytes(&json);
std::fs::write(&path, json)?;
Ok(fingerprint)
}
pub fn list(&self) -> Result<Vec<SessionState>> {
let sessions_dir = self.sessions_dir();
if !sessions_dir.exists() {
return Ok(vec![]);
}
let mut sessions = Vec::new();
for entry in std::fs::read_dir(&sessions_dir)? {
let entry = entry?;
if !entry.file_type()?.is_dir() {
continue;
}
let id = entry.file_name().to_string_lossy().to_string();
match self.load(&id) {
Ok(state) => sessions.push(state),
Err(e) => eprintln!("warning: {e}"),
}
}
sessions.sort_by(|a, b| a.id.cmp(&b.id));
Ok(sessions)
}
pub fn pending(&self) -> Result<Vec<SessionState>> {
Ok(self
.list()?
.into_iter()
.filter(|s| s.phase.is_runnable())
.collect())
}
#[cfg(test)]
pub fn planned(&self) -> Result<Vec<SessionState>> {
Ok(self
.list()?
.into_iter()
.filter(|s| s.phase == SessionPhase::Planned)
.collect())
}
pub fn run_all_candidates(&self) -> Result<Vec<SessionState>> {
Ok(self
.list()?
.into_iter()
.filter(|s| matches!(s.phase, SessionPhase::Planned | SessionPhase::Suspended))
.collect())
}
pub fn run_all_remaining(
&self,
seen: &std::collections::HashSet<String>,
) -> Result<Vec<SessionState>> {
Ok(self
.run_all_candidates()?
.into_iter()
.filter(|s| !seen.contains(&s.id))
.collect())
}
pub fn load_config(&self, state: &SessionState) -> Result<crate::config::WorkflowConfig> {
let config_path = state.config_path.clone().unwrap_or_else(|| {
self.sessions_dir().join(&state.id).join("config.yaml")
});
let yaml = std::fs::read_to_string(&config_path).map_err(|e| {
CruiseError::Other(format!(
"failed to read session config {}: {}",
config_path.display(),
e
))
})?;
crate::config::WorkflowConfig::from_yaml(&yaml)
.map_err(|e| CruiseError::ConfigParseError(e.to_string()))
}
pub fn delete(&self, id: &str) -> Result<()> {
let session_dir = self.sessions_dir().join(id);
if session_dir.exists() {
std::fs::remove_dir_all(&session_dir)?;
}
Ok(())
}
pub fn cleanup_by_pr_status(&self) -> Result<CleanupReport> {
let sessions = self.list()?;
let mut report = CleanupReport::default();
for session in sessions {
if !matches!(session.phase, SessionPhase::Completed) {
continue;
}
let Some(ref pr_url) = session.pr_url else {
continue;
};
let output = std::process::Command::new("gh")
.args(["pr", "view", pr_url, "--json", "state", "--jq", ".state"])
.output();
let state = match output {
Ok(out) if out.status.success() => {
let raw = String::from_utf8_lossy(&out.stdout);
raw.trim().to_uppercase()
}
Ok(out) => {
eprintln!(
"warning: gh pr view failed for {}: {}",
session.id,
String::from_utf8_lossy(&out.stderr).trim()
);
report.skipped += 1;
continue;
}
Err(e) => {
eprintln!("warning: failed to run gh for {}: {}", session.id, e);
report.skipped += 1;
continue;
}
};
if state != "CLOSED" && state != "MERGED" {
report.skipped += 1;
continue;
}
if let Some(ctx) = session.worktree_context()
&& let Err(e) = crate::worktree::cleanup_worktree(&ctx)
{
eprintln!(
"warning: failed to remove worktree for {}: {}",
session.id, e
);
}
self.delete(&session.id)?;
report.deleted += 1;
}
Ok(report)
}
}
#[derive(Default)]
pub struct CleanupReport {
pub deleted: usize,
pub skipped: usize,
}
#[must_use]
pub fn cruise_home() -> Option<PathBuf> {
home::home_dir().map(|h| h.join(".cruise"))
}
pub fn get_cruise_home() -> crate::error::Result<PathBuf> {
cruise_home()
.ok_or_else(|| crate::error::CruiseError::Other("home directory not found".to_string()))
}
#[must_use]
pub fn current_timestamp_id() -> String {
let secs = SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let (year, month, day, h, m, s) = seconds_to_datetime(secs);
format!("{year:04}{month:02}{day:02}{h:02}{m:02}{s:02}")
}
#[must_use]
pub fn current_iso8601() -> String {
let secs = SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let (year, month, day, h, m, s) = seconds_to_datetime(secs);
format!("{year:04}-{month:02}-{day:02}T{h:02}:{m:02}:{s:02}Z")
}
pub struct SessionLogger {
path: std::path::PathBuf,
}
impl SessionLogger {
#[must_use]
pub fn new(path: std::path::PathBuf) -> Self {
Self { path }
}
pub fn write(&self, line: &str) {
use std::io::Write as _;
if let Ok(mut file) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&self.path)
{
let ts = current_iso8601();
let _ = writeln!(file, "[{ts}] {line}");
}
}
}
#[cfg(test)]
fn parse_iso8601_secs(s: &str) -> Option<u64> {
let s = s.trim_end_matches('Z');
let (date_str, time_str) = s.split_once('T')?;
let mut dp = date_str.split('-');
let year: u16 = dp.next()?.parse().ok()?;
let month: u8 = dp.next()?.parse().ok()?;
let day: u8 = dp.next()?.parse().ok()?;
let mut tp = time_str.split(':');
let h: u64 = tp.next()?.parse().ok()?;
let m: u64 = tp.next()?.parse().ok()?;
let s_val: u64 = tp.next()?.parse().ok()?;
let days = u64::from(date_to_days(year, month, day));
Some(days * 86400 + h * 3600 + m * 60 + s_val)
}
#[cfg(test)]
fn date_to_days(year: u16, month: u8, day: u8) -> u32 {
let mut days = 0u32;
for y in 1970..year {
days += if is_leap_year(y) { 366 } else { 365 };
}
let months = months_in_year(year);
for month_days in months.iter().take(month as usize - 1) {
days += u32::from(*month_days);
}
days + u32::from(day) - 1
}
fn seconds_to_datetime(secs: u64) -> (u16, u8, u8, u8, u8, u8) {
let sec = (secs % 60) as u8;
let min = ((secs / 60) % 60) as u8;
let hour = ((secs / 3600) % 24) as u8;
let mut days = secs / 86400;
let mut year = 1970u16;
loop {
let days_in_year = if is_leap_year(year) { 366u64 } else { 365u64 };
if days < days_in_year {
break;
}
days -= days_in_year;
year += 1;
}
let months = months_in_year(year);
let mut month = 1u8;
for &dim in &months {
if days < u64::from(dim) {
break;
}
days -= u64::from(dim);
month += 1;
}
let day = u8::try_from(days + 1).unwrap_or(31);
(year, month, day, hour, min, sec)
}
fn is_leap_year(year: u16) -> bool {
(year.is_multiple_of(4) && !year.is_multiple_of(100)) || year.is_multiple_of(400)
}
fn months_in_year(year: u16) -> [u8; 12] {
[
31,
if is_leap_year(year) { 29 } else { 28 },
31,
30,
31,
30,
31,
31,
30,
31,
30,
31,
]
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error::CruiseError;
use tempfile::TempDir;
#[test]
fn test_timestamp_id_format() {
let id = current_timestamp_id();
assert_eq!(id.len(), 14);
assert!(id.chars().all(|c| c.is_ascii_digit()));
}
#[test]
fn test_iso8601_format() {
let ts = current_iso8601();
assert!(ts.ends_with('Z'));
assert!(ts.contains('T'));
assert_eq!(ts.len(), 20);
}
#[test]
fn test_parse_iso8601_roundtrip() {
let secs = 1_741_270_200_u64;
let (year, month, day, h, m, s) = seconds_to_datetime(secs);
let iso = format!("{year:04}-{month:02}-{day:02}T{h:02}:{m:02}:{s:02}Z");
let parsed = parse_iso8601_secs(&iso).unwrap_or_else(|| panic!("unexpected None"));
assert_eq!(parsed, secs);
}
#[test]
fn test_parse_iso8601_known_date() {
let secs =
parse_iso8601_secs("2026-03-06T00:00:00Z").unwrap_or_else(|| panic!("unexpected None"));
let (year, month, day, h, m, s) = seconds_to_datetime(secs);
assert_eq!(year, 2026);
assert_eq!(month, 3);
assert_eq!(day, 6);
assert_eq!(h, 0);
assert_eq!(m, 0);
assert_eq!(s, 0);
}
#[test]
fn test_session_create_and_load() {
let tmp = TempDir::new().unwrap_or_else(|e| panic!("{e:?}"));
let manager = SessionManager::new(tmp.path().to_path_buf());
let id = "20260306143000".to_string();
let state = SessionState::new(
id.clone(),
PathBuf::from("/repo"),
"cruise.yaml".to_string(),
"add hello world".to_string(),
);
manager.create(&state).unwrap_or_else(|e| panic!("{e:?}"));
let loaded = manager.load(&id).unwrap_or_else(|e| panic!("{e:?}"));
assert_eq!(loaded.id, id);
assert_eq!(loaded.input, "add hello world");
assert!(matches!(loaded.phase, SessionPhase::AwaitingApproval));
assert!(loaded.current_step.is_none());
assert_eq!(loaded.title, None);
assert_eq!(loaded.workspace_mode, WorkspaceMode::Worktree);
assert_eq!(loaded.target_branch, None);
assert!(loaded.pr_url.is_none());
}
#[test]
fn test_session_save_updates_state() {
let tmp = TempDir::new().unwrap_or_else(|e| panic!("{e:?}"));
let manager = SessionManager::new(tmp.path().to_path_buf());
let id = "20260306150000".to_string();
let mut state = SessionState::new(
id.clone(),
PathBuf::from("/repo"),
"cruise.yaml".to_string(),
"task".to_string(),
);
manager.create(&state).unwrap_or_else(|e| panic!("{e:?}"));
state.phase = SessionPhase::Running;
state.current_step = Some("implement".to_string());
manager.save(&state).unwrap_or_else(|e| panic!("{e:?}"));
let loaded = manager.load(&id).unwrap_or_else(|e| panic!("{e:?}"));
assert!(matches!(loaded.phase, SessionPhase::Running));
assert_eq!(loaded.current_step, Some("implement".to_string()));
}
#[test]
fn test_new_session_defaults_plan_error_to_none() {
let state = SessionState::new(
"20260310130002".to_string(),
PathBuf::from("/repo"),
"cruise.yaml".to_string(),
"task".to_string(),
);
assert_eq!(state.plan_error, None);
}
#[test]
fn test_session_save_and_load_preserves_plan_error() {
let tmp = TempDir::new().unwrap_or_else(|e| panic!("{e:?}"));
let manager = SessionManager::new(tmp.path().to_path_buf());
let id = "20260310130003".to_string();
let mut state = SessionState::new(
id.clone(),
PathBuf::from("/repo"),
"cruise.yaml".to_string(),
"task".to_string(),
);
state.plan_error = Some("planner exited 1".to_string());
manager.create(&state).unwrap_or_else(|e| panic!("{e:?}"));
let loaded = manager.load(&id).unwrap_or_else(|e| panic!("{e:?}"));
assert_eq!(loaded.plan_error.as_deref(), Some("planner exited 1"));
}
#[test]
fn test_load_with_fingerprint_matches_inspected_file() {
let tmp = TempDir::new().unwrap_or_else(|e| panic!("{e:?}"));
let manager = SessionManager::new(tmp.path().to_path_buf());
let id = "20260310130000".to_string();
let state = SessionState::new(
id.clone(),
PathBuf::from("/repo"),
"cruise.yaml".to_string(),
"task".to_string(),
);
manager.create(&state).unwrap_or_else(|e| panic!("{e:?}"));
let (loaded, load_fingerprint) = manager
.load_with_fingerprint(&id)
.unwrap_or_else(|e| panic!("{e:?}"));
let inspected = manager
.inspect_state_file(&id)
.unwrap_or_else(|e| panic!("{e:?}"));
assert_eq!(loaded.id, state.id);
assert_eq!(loaded.phase, state.phase);
assert!(loaded.updated_at.is_some());
match inspected {
SessionFileContents::Parsed {
state: inspected_state,
fingerprint,
} => {
assert_eq!(*inspected_state, loaded);
assert_eq!(fingerprint, load_fingerprint);
}
other => panic!("expected parsed contents, got {other:?}"),
}
}
#[test]
fn test_save_with_fingerprint_round_trips_through_load_with_fingerprint() {
let tmp = TempDir::new().unwrap_or_else(|e| panic!("{e:?}"));
let manager = SessionManager::new(tmp.path().to_path_buf());
let mut state = SessionState::new(
"20260310130001".to_string(),
PathBuf::from("/repo"),
"cruise.yaml".to_string(),
"task".to_string(),
);
state.phase = SessionPhase::Running;
state.current_step = Some("write-test-first".to_string());
std::fs::create_dir_all(manager.sessions_dir().join(&state.id))
.unwrap_or_else(|e| panic!("{e:?}"));
let saved_fingerprint = manager
.save_with_fingerprint(&state)
.unwrap_or_else(|e| panic!("{e:?}"));
let (loaded, loaded_fingerprint) = manager
.load_with_fingerprint(&state.id)
.unwrap_or_else(|e| panic!("{e:?}"));
assert_eq!(loaded, state);
assert_eq!(loaded_fingerprint, saved_fingerprint);
}
#[test]
fn test_inspect_state_file_returns_invalid_for_malformed_json() {
let tmp = TempDir::new().unwrap_or_else(|e| panic!("{e:?}"));
let manager = SessionManager::new(tmp.path().to_path_buf());
let id = "20260310130002";
let session_dir = manager.sessions_dir().join(id);
std::fs::create_dir_all(&session_dir).unwrap_or_else(|e| panic!("{e:?}"));
std::fs::write(session_dir.join("state.json"), "{not valid json")
.unwrap_or_else(|e| panic!("{e:?}"));
let inspected = manager
.inspect_state_file(id)
.unwrap_or_else(|e| panic!("{e:?}"));
match inspected {
SessionFileContents::Invalid { fingerprint, error } => {
assert!(
!error.is_empty(),
"invalid JSON inspection should include a parse error"
);
assert_eq!(
Some(fingerprint),
manager
.inspect_state_file(id)
.unwrap_or_else(|e| panic!("{e:?}"))
.fingerprint()
);
}
other => panic!("expected invalid contents, got {other:?}"),
}
}
#[test]
fn test_inspect_state_file_returns_missing_for_absent_file() {
let tmp = TempDir::new().unwrap_or_else(|e| panic!("{e:?}"));
let manager = SessionManager::new(tmp.path().to_path_buf());
let inspected = manager
.inspect_state_file("20260310130003")
.unwrap_or_else(|e| panic!("{e:?}"));
assert_eq!(inspected, SessionFileContents::Missing);
assert_eq!(inspected.fingerprint(), None);
}
#[test]
fn test_session_list_sorted() {
let tmp = TempDir::new().unwrap_or_else(|e| panic!("{e:?}"));
let manager = SessionManager::new(tmp.path().to_path_buf());
for id in ["20260306100000", "20260306120000", "20260306090000"] {
let state = SessionState::new(
id.to_string(),
PathBuf::from("/repo"),
"cruise.yaml".to_string(),
"task".to_string(),
);
manager.create(&state).unwrap_or_else(|e| panic!("{e:?}"));
}
let sessions = manager.list().unwrap_or_else(|e| panic!("{e:?}"));
assert_eq!(sessions.len(), 3);
assert_eq!(sessions[0].id, "20260306090000");
assert_eq!(sessions[1].id, "20260306100000");
assert_eq!(sessions[2].id, "20260306120000");
}
#[test]
fn test_session_list_empty() {
let tmp = TempDir::new().unwrap_or_else(|e| panic!("{e:?}"));
let manager = SessionManager::new(tmp.path().to_path_buf());
let sessions = manager.list().unwrap_or_else(|e| panic!("{e:?}"));
assert!(sessions.is_empty());
}
#[test]
fn test_session_pending_filters() {
let tmp = TempDir::new().unwrap_or_else(|e| panic!("{e:?}"));
let manager = SessionManager::new(tmp.path().to_path_buf());
let mut planned = SessionState::new(
"20260306100000".to_string(),
PathBuf::from("/repo"),
"cruise.yaml".to_string(),
"task1".to_string(),
);
planned.phase = SessionPhase::Planned;
manager.create(&planned).unwrap_or_else(|e| panic!("{e:?}"));
let mut completed = SessionState::new(
"20260306110000".to_string(),
PathBuf::from("/repo"),
"cruise.yaml".to_string(),
"task2".to_string(),
);
completed.phase = SessionPhase::Completed;
manager
.create(&completed)
.unwrap_or_else(|e| panic!("{e:?}"));
let mut failed = SessionState::new(
"20260306120000".to_string(),
PathBuf::from("/repo"),
"cruise.yaml".to_string(),
"task3".to_string(),
);
failed.phase = SessionPhase::Failed("some error".to_string());
manager.create(&failed).unwrap_or_else(|e| panic!("{e:?}"));
let mut running = SessionState::new(
"20260306130000".to_string(),
PathBuf::from("/repo"),
"cruise.yaml".to_string(),
"task4".to_string(),
);
running.phase = SessionPhase::Running;
manager.create(&running).unwrap_or_else(|e| panic!("{e:?}"));
let pending = manager.pending().unwrap_or_else(|e| panic!("{e:?}"));
assert_eq!(pending.len(), 3);
let ids: Vec<&str> = pending.iter().map(|s| s.id.as_str()).collect();
assert!(ids.contains(&"20260306100000"), "Planned should be pending");
assert!(ids.contains(&"20260306120000"), "Failed should be pending");
assert!(ids.contains(&"20260306130000"), "Running should be pending");
assert!(
!ids.contains(&"20260306110000"),
"Completed should not be pending"
);
}
#[test]
fn test_session_delete() {
let tmp = TempDir::new().unwrap_or_else(|e| panic!("{e:?}"));
let manager = SessionManager::new(tmp.path().to_path_buf());
let id = "20260306100000".to_string();
let state = SessionState::new(
id.clone(),
PathBuf::from("/repo"),
"cruise.yaml".to_string(),
"task".to_string(),
);
manager.create(&state).unwrap_or_else(|e| panic!("{e:?}"));
assert!(manager.sessions_dir().join(&id).exists());
manager.delete(&id).unwrap_or_else(|e| panic!("{e:?}"));
assert!(!manager.sessions_dir().join(&id).exists());
let sessions = manager.list().unwrap_or_else(|e| panic!("{e:?}"));
assert!(sessions.is_empty());
}
#[test]
fn test_session_state_pr_url_roundtrip() {
let tmp = TempDir::new().unwrap_or_else(|e| panic!("{e:?}"));
let manager = SessionManager::new(tmp.path().to_path_buf());
let id = "20260306160000".to_string();
let mut state = SessionState::new(
id.clone(),
PathBuf::from("/repo"),
"cruise.yaml".to_string(),
"task".to_string(),
);
state.phase = SessionPhase::Completed;
state.pr_url = Some("https://github.com/owner/repo/pull/42".to_string());
manager.create(&state).unwrap_or_else(|e| panic!("{e:?}"));
let loaded = manager.load(&id).unwrap_or_else(|e| panic!("{e:?}"));
assert_eq!(
loaded.pr_url,
Some("https://github.com/owner/repo/pull/42".to_string())
);
}
#[test]
fn test_session_state_title_roundtrip() {
let tmp = TempDir::new().unwrap_or_else(|e| panic!("{e:?}"));
let manager = SessionManager::new(tmp.path().to_path_buf());
let id = "20260306165000".to_string();
let mut state = SessionState::new(
id.clone(),
PathBuf::from("/repo"),
"cruise.yaml".to_string(),
"task".to_string(),
);
state.title = Some("Readable generated title".to_string());
manager.create(&state).unwrap_or_else(|e| panic!("{e:?}"));
let loaded = manager.load(&id).unwrap_or_else(|e| panic!("{e:?}"));
assert_eq!(loaded.title.as_deref(), Some("Readable generated title"));
}
#[test]
fn test_session_state_backward_compat() {
let tmp = TempDir::new().unwrap_or_else(|e| panic!("{e:?}"));
let manager = SessionManager::new(tmp.path().to_path_buf());
let id = "20260306170000".to_string();
let session_dir = manager.sessions_dir().join(&id);
std::fs::create_dir_all(&session_dir).unwrap_or_else(|e| panic!("{e:?}"));
let json = serde_json::json!({
"id": id,
"base_dir": "/repo",
"phase": "Planned",
"config_source": "cruise.yaml",
"input": "old task",
"current_step": null,
"created_at": "2026-03-06T17:00:00Z",
"completed_at": null,
"worktree_path": null,
"worktree_branch": null
});
std::fs::write(session_dir.join("state.json"), json.to_string())
.unwrap_or_else(|e| panic!("{e:?}"));
let loaded = manager.load(&id).unwrap_or_else(|e| panic!("{e:?}"));
assert_eq!(loaded.workspace_mode, WorkspaceMode::Worktree);
assert_eq!(loaded.target_branch, None);
assert_eq!(loaded.pr_url, None);
assert_eq!(loaded.title, None);
assert_eq!(loaded.input, "old task");
}
#[test]
fn test_session_state_target_branch_roundtrip() {
let tmp = TempDir::new().unwrap_or_else(|e| panic!("{e:?}"));
let manager = SessionManager::new(tmp.path().to_path_buf());
let id = "20260306180000".to_string();
let mut state = SessionState::new(
id.clone(),
PathBuf::from("/repo"),
"cruise.yaml".to_string(),
"task".to_string(),
);
state.workspace_mode = WorkspaceMode::CurrentBranch;
state.target_branch = Some("feature/direct-mode".to_string());
manager.create(&state).unwrap_or_else(|e| panic!("{e:?}"));
let loaded = manager.load(&id).unwrap_or_else(|e| panic!("{e:?}"));
assert_eq!(loaded.workspace_mode, WorkspaceMode::CurrentBranch);
assert_eq!(loaded.target_branch.as_deref(), Some("feature/direct-mode"));
}
#[test]
fn test_session_planned_returns_only_planned() {
let tmp = TempDir::new().unwrap_or_else(|e| panic!("{e:?}"));
let manager = SessionManager::new(tmp.path().to_path_buf());
let mut planned = SessionState::new(
"20260308100000".to_string(),
PathBuf::from("/repo"),
"cruise.yaml".to_string(),
"planned-task".to_string(),
);
planned.phase = SessionPhase::Planned;
manager.create(&planned).unwrap_or_else(|e| panic!("{e:?}"));
let mut completed = SessionState::new(
"20260308110000".to_string(),
PathBuf::from("/repo"),
"cruise.yaml".to_string(),
"completed-task".to_string(),
);
completed.phase = SessionPhase::Completed;
manager
.create(&completed)
.unwrap_or_else(|e| panic!("{e:?}"));
let mut failed = SessionState::new(
"20260308120000".to_string(),
PathBuf::from("/repo"),
"cruise.yaml".to_string(),
"failed-task".to_string(),
);
failed.phase = SessionPhase::Failed("error".to_string());
manager.create(&failed).unwrap_or_else(|e| panic!("{e:?}"));
let mut running = SessionState::new(
"20260308130000".to_string(),
PathBuf::from("/repo"),
"cruise.yaml".to_string(),
"running-task".to_string(),
);
running.phase = SessionPhase::Running;
manager.create(&running).unwrap_or_else(|e| panic!("{e:?}"));
let result = manager.planned().unwrap_or_else(|e| panic!("{e:?}"));
assert_eq!(result.len(), 1);
assert_eq!(result[0].id, "20260308100000");
assert!(matches!(result[0].phase, SessionPhase::Planned));
}
#[test]
fn test_session_planned_empty_when_none_planned() {
let tmp = TempDir::new().unwrap_or_else(|e| panic!("{e:?}"));
let manager = SessionManager::new(tmp.path().to_path_buf());
let mut completed = SessionState::new(
"20260308200000".to_string(),
PathBuf::from("/repo"),
"cruise.yaml".to_string(),
"done".to_string(),
);
completed.phase = SessionPhase::Completed;
manager
.create(&completed)
.unwrap_or_else(|e| panic!("{e:?}"));
let result = manager.planned().unwrap_or_else(|e| panic!("{e:?}"));
assert!(result.is_empty());
}
#[test]
fn test_session_planned_multiple_planned() {
let tmp = TempDir::new().unwrap_or_else(|e| panic!("{e:?}"));
let manager = SessionManager::new(tmp.path().to_path_buf());
for (id, input) in [
("20260308300000", "task-a"),
("20260308310000", "task-b"),
("20260308320000", "task-c"),
] {
let mut s = SessionState::new(
id.to_string(),
PathBuf::from("/repo"),
"cruise.yaml".to_string(),
input.to_string(),
);
s.phase = SessionPhase::Planned;
manager.create(&s).unwrap_or_else(|e| panic!("{e:?}"));
}
let result = manager.planned().unwrap_or_else(|e| panic!("{e:?}"));
assert_eq!(result.len(), 3);
let ids: Vec<&str> = result.iter().map(|s| s.id.as_str()).collect();
assert!(ids.contains(&"20260308300000"));
assert!(ids.contains(&"20260308310000"));
assert!(ids.contains(&"20260308320000"));
}
#[test]
fn test_session_load_config_reads_valid_yaml() {
let tmp = TempDir::new().unwrap_or_else(|e| panic!("{e:?}"));
let manager = SessionManager::new(tmp.path().to_path_buf());
let id = "20260309120000".to_string();
let state = SessionState::new(
id.clone(),
PathBuf::from("/repo"),
"cruise.yaml".to_string(),
"task".to_string(),
);
manager.create(&state).unwrap_or_else(|e| panic!("{e:?}"));
let yaml = "command:\n - echo\nsteps:\n test:\n command: \"true\"\n";
std::fs::write(manager.sessions_dir().join(&id).join("config.yaml"), yaml)
.unwrap_or_else(|e| panic!("{e:?}"));
let config = manager
.load_config(&state)
.unwrap_or_else(|e| panic!("{e:?}"));
assert_eq!(config.command, vec!["echo".to_string()]);
assert!(config.steps.contains_key("test"));
}
#[test]
fn test_session_load_config_invalid_yaml_returns_parse_error() {
let tmp = TempDir::new().unwrap_or_else(|e| panic!("{e:?}"));
let manager = SessionManager::new(tmp.path().to_path_buf());
let id = "20260309120001".to_string();
let state = SessionState::new(
id.clone(),
PathBuf::from("/repo"),
"cruise.yaml".to_string(),
"task".to_string(),
);
manager.create(&state).unwrap_or_else(|e| panic!("{e:?}"));
std::fs::write(
manager.sessions_dir().join(&id).join("config.yaml"),
"command:\n - echo\nsteps: [",
)
.unwrap_or_else(|e| panic!("{e:?}"));
let err = manager
.load_config(&state)
.map_or_else(|e| e, |v| panic!("expected Err, got Ok({v:?})"));
assert!(matches!(err, CruiseError::ConfigParseError(_)));
}
fn make_completed_session() -> SessionState {
let mut s = SessionState::new(
"20260309100000".to_string(),
PathBuf::from("/repo"),
"cruise.yaml".to_string(),
"some task".to_string(),
);
s.phase = SessionPhase::Completed;
s.current_step = Some("final-step".to_string());
s.completed_at = Some("2026-03-09T10:00:00Z".to_string());
s.pr_url = Some("https://github.com/owner/repo/pull/42".to_string());
s.worktree_path = Some(PathBuf::from("/tmp/worktree"));
s.worktree_branch = Some("cruise/20260309100000-some-task".to_string());
s
}
#[test]
fn test_reset_to_planned_from_completed() {
let mut s = make_completed_session();
let orig_id = s.id.clone();
let orig_input = s.input.clone();
let orig_created_at = s.created_at.clone();
let orig_base_dir = s.base_dir.clone();
let orig_config_source = s.config_source.clone();
s.reset_to_planned();
assert!(matches!(s.phase, SessionPhase::Planned));
assert!(s.current_step.is_none());
assert!(s.completed_at.is_none());
assert!(s.pr_url.is_none());
assert_eq!(s.worktree_path, Some(PathBuf::from("/tmp/worktree")));
assert_eq!(
s.worktree_branch,
Some("cruise/20260309100000-some-task".to_string())
);
assert_eq!(s.id, orig_id);
assert_eq!(s.input, orig_input);
assert_eq!(s.created_at, orig_created_at);
assert_eq!(s.base_dir, orig_base_dir);
assert_eq!(s.config_source, orig_config_source);
}
#[test]
fn test_reset_to_planned_from_running() {
let mut s = SessionState::new(
"20260309110000".to_string(),
PathBuf::from("/repo"),
"cruise.yaml".to_string(),
"running task".to_string(),
);
s.phase = SessionPhase::Running;
s.current_step = Some("implement".to_string());
s.worktree_path = Some(PathBuf::from("/tmp/wt2"));
s.worktree_branch = Some("cruise/20260309110000-running-task".to_string());
s.reset_to_planned();
assert!(matches!(s.phase, SessionPhase::Planned));
assert!(s.current_step.is_none());
assert!(s.completed_at.is_none());
assert_eq!(s.worktree_path, Some(PathBuf::from("/tmp/wt2")));
assert_eq!(
s.worktree_branch,
Some("cruise/20260309110000-running-task".to_string())
);
}
#[test]
fn test_suspended_phase_label() {
let phase = SessionPhase::Suspended;
let label = phase.label();
assert_eq!(label, "Suspended");
}
#[test]
fn test_suspended_is_runnable() {
let phase = SessionPhase::Suspended;
assert!(
phase.is_runnable(),
"Suspended should be runnable (resumable)"
);
}
#[test]
fn test_suspended_serialize_deserialize_roundtrip() {
let tmp = TempDir::new().unwrap_or_else(|e| panic!("{e:?}"));
let manager = SessionManager::new(tmp.path().to_path_buf());
let id = "20260310100000".to_string();
let mut state = SessionState::new(
id.clone(),
PathBuf::from("/repo"),
"cruise.yaml".to_string(),
"task".to_string(),
);
state.phase = SessionPhase::Suspended;
state.current_step = Some("implement".to_string());
manager.create(&state).unwrap_or_else(|e| panic!("{e:?}"));
let loaded = manager.load(&id).unwrap_or_else(|e| panic!("{e:?}"));
assert!(
matches!(loaded.phase, SessionPhase::Suspended),
"phase should be Suspended after roundtrip"
);
assert_eq!(loaded.current_step, Some("implement".to_string()));
}
#[test]
fn test_pending_includes_suspended() {
let tmp = TempDir::new().unwrap_or_else(|e| panic!("{e:?}"));
let manager = SessionManager::new(tmp.path().to_path_buf());
let mut suspended = SessionState::new(
"20260310110000".to_string(),
PathBuf::from("/repo"),
"cruise.yaml".to_string(),
"suspended-task".to_string(),
);
suspended.phase = SessionPhase::Suspended;
manager
.create(&suspended)
.unwrap_or_else(|e| panic!("{e:?}"));
let mut completed = SessionState::new(
"20260310120000".to_string(),
PathBuf::from("/repo"),
"cruise.yaml".to_string(),
"completed-task".to_string(),
);
completed.phase = SessionPhase::Completed;
manager
.create(&completed)
.unwrap_or_else(|e| panic!("{e:?}"));
let pending = manager.pending().unwrap_or_else(|e| panic!("{e:?}"));
let ids: Vec<&str> = pending.iter().map(|s| s.id.as_str()).collect();
assert!(
ids.contains(&"20260310110000"),
"Suspended should be in pending"
);
assert!(
!ids.contains(&"20260310120000"),
"Completed should not be in pending"
);
}
#[test]
fn test_run_all_candidates_returns_planned_and_suspended_only() {
let tmp = TempDir::new().unwrap_or_else(|e| panic!("{e:?}"));
let manager = SessionManager::new(tmp.path().to_path_buf());
for (id, phase) in [
("20260310200000", SessionPhase::Planned),
("20260310200001", SessionPhase::Suspended),
("20260310200002", SessionPhase::Running),
("20260310200003", SessionPhase::Completed),
("20260310200004", SessionPhase::Failed("err".to_string())),
] {
let mut s = SessionState::new(
id.to_string(),
PathBuf::from("/repo"),
"cruise.yaml".to_string(),
"task".to_string(),
);
s.phase = phase;
manager.create(&s).unwrap_or_else(|e| panic!("{e:?}"));
}
let candidates = manager
.run_all_candidates()
.unwrap_or_else(|e| panic!("{e:?}"));
assert_eq!(
candidates.len(),
2,
"only Planned and Suspended should be candidates"
);
let ids: Vec<&str> = candidates.iter().map(|s| s.id.as_str()).collect();
assert!(
ids.contains(&"20260310200000"),
"Planned should be included"
);
assert!(
ids.contains(&"20260310200001"),
"Suspended should be included"
);
assert!(
!ids.contains(&"20260310200002"),
"Running should NOT be included"
);
assert!(
!ids.contains(&"20260310200003"),
"Completed should NOT be included"
);
assert!(
!ids.contains(&"20260310200004"),
"Failed should NOT be included"
);
}
#[test]
fn test_run_all_candidates_empty_when_none_qualify() {
let tmp = TempDir::new().unwrap_or_else(|e| panic!("{e:?}"));
let manager = SessionManager::new(tmp.path().to_path_buf());
let mut s = SessionState::new(
"20260310210000".to_string(),
PathBuf::from("/repo"),
"cruise.yaml".to_string(),
"done".to_string(),
);
s.phase = SessionPhase::Completed;
manager.create(&s).unwrap_or_else(|e| panic!("{e:?}"));
let candidates = manager
.run_all_candidates()
.unwrap_or_else(|e| panic!("{e:?}"));
assert!(
candidates.is_empty(),
"no candidates when only Completed exists"
);
}
#[test]
fn test_run_all_remaining_returns_all_when_seen_is_empty() {
let tmp = TempDir::new().unwrap_or_else(|e| panic!("{e:?}"));
let manager = SessionManager::new(tmp.path().to_path_buf());
for id in ["20260403300000", "20260403300001"] {
let mut s = SessionState::new(
id.to_string(),
PathBuf::from("/repo"),
"cruise.yaml".to_string(),
"task".to_string(),
);
s.phase = SessionPhase::Planned;
manager.create(&s).unwrap_or_else(|e| panic!("{e:?}"));
}
let seen = std::collections::HashSet::new();
let remaining = manager
.run_all_remaining(&seen)
.unwrap_or_else(|e| panic!("{e:?}"));
assert_eq!(
remaining.len(),
2,
"empty seen should return all candidates"
);
}
#[test]
fn test_run_all_remaining_excludes_seen_ids() {
let tmp = TempDir::new().unwrap_or_else(|e| panic!("{e:?}"));
let manager = SessionManager::new(tmp.path().to_path_buf());
for (id, phase) in [
("20260403310000", SessionPhase::Planned),
("20260403310001", SessionPhase::Planned),
("20260403310002", SessionPhase::Suspended),
] {
let mut s = SessionState::new(
id.to_string(),
PathBuf::from("/repo"),
"cruise.yaml".to_string(),
"task".to_string(),
);
s.phase = phase;
manager.create(&s).unwrap_or_else(|e| panic!("{e:?}"));
}
let seen: std::collections::HashSet<String> = ["20260403310000".to_string()].into();
let remaining = manager
.run_all_remaining(&seen)
.unwrap_or_else(|e| panic!("{e:?}"));
assert_eq!(remaining.len(), 2, "seen ID should be excluded");
let ids: Vec<&str> = remaining.iter().map(|s| s.id.as_str()).collect();
assert!(!ids.contains(&"20260403310000"), "seen ID must not appear");
assert!(
ids.contains(&"20260403310001"),
"unseen Planned must be included"
);
assert!(
ids.contains(&"20260403310002"),
"unseen Suspended must be included"
);
}
#[test]
fn test_run_all_remaining_returns_empty_when_all_seen() {
let tmp = TempDir::new().unwrap_or_else(|e| panic!("{e:?}"));
let manager = SessionManager::new(tmp.path().to_path_buf());
let mut s = SessionState::new(
"20260403320000".to_string(),
PathBuf::from("/repo"),
"cruise.yaml".to_string(),
"task".to_string(),
);
s.phase = SessionPhase::Planned;
manager.create(&s).unwrap_or_else(|e| panic!("{e:?}"));
let seen: std::collections::HashSet<String> = ["20260403320000".to_string()].into();
let remaining = manager
.run_all_remaining(&seen)
.unwrap_or_else(|e| panic!("{e:?}"));
assert!(
remaining.is_empty(),
"all candidates are seen, result should be empty"
);
}
#[test]
fn test_run_all_remaining_preserves_id_ascending_order() {
let tmp = TempDir::new().unwrap_or_else(|e| panic!("{e:?}"));
let manager = SessionManager::new(tmp.path().to_path_buf());
for id in ["20260403330002", "20260403330000", "20260403330001"] {
let mut s = SessionState::new(
id.to_string(),
PathBuf::from("/repo"),
"cruise.yaml".to_string(),
"task".to_string(),
);
s.phase = SessionPhase::Planned;
manager.create(&s).unwrap_or_else(|e| panic!("{e:?}"));
}
let seen = std::collections::HashSet::new();
let remaining = manager
.run_all_remaining(&seen)
.unwrap_or_else(|e| panic!("{e:?}"));
let ids: Vec<&str> = remaining.iter().map(|s| s.id.as_str()).collect();
assert_eq!(
ids,
vec!["20260403330000", "20260403330001", "20260403330002"],
"run_all_remaining must preserve ascending ID order"
);
}
#[test]
fn test_run_all_remaining_ignores_non_candidate_phases() {
let tmp = TempDir::new().unwrap_or_else(|e| panic!("{e:?}"));
let manager = SessionManager::new(tmp.path().to_path_buf());
for (id, phase) in [
("20260403340000", SessionPhase::Running),
("20260403340001", SessionPhase::Completed),
("20260403340002", SessionPhase::Failed("err".to_string())),
("20260403340003", SessionPhase::Planned),
] {
let mut s = SessionState::new(
id.to_string(),
PathBuf::from("/repo"),
"cruise.yaml".to_string(),
"task".to_string(),
);
s.phase = phase;
manager.create(&s).unwrap_or_else(|e| panic!("{e:?}"));
}
let seen: std::collections::HashSet<String> = std::collections::HashSet::new();
let remaining = manager
.run_all_remaining(&seen)
.unwrap_or_else(|e| panic!("{e:?}"));
assert_eq!(remaining.len(), 1, "only Planned/Suspended qualify");
assert_eq!(remaining[0].id, "20260403340003");
}
#[test]
fn test_reset_to_planned_preserves_workspace_mode_and_target_branch() {
let mut s = SessionState::new(
"20260310120000".to_string(),
PathBuf::from("/repo"),
"cruise.yaml".to_string(),
"direct mode task".to_string(),
);
s.phase = SessionPhase::Running;
s.current_step = Some("implement".to_string());
s.workspace_mode = WorkspaceMode::CurrentBranch;
s.target_branch = Some("feature/my-branch".to_string());
s.reset_to_planned();
assert!(matches!(s.phase, SessionPhase::Planned));
assert!(s.current_step.is_none());
assert_eq!(s.workspace_mode, WorkspaceMode::CurrentBranch);
assert_eq!(s.target_branch.as_deref(), Some("feature/my-branch"));
}
#[test]
fn test_session_new_starts_in_awaiting_approval() {
let s = SessionState::new(
"20260311100000".to_string(),
PathBuf::from("/repo"),
"cruise.yaml".to_string(),
"some task".to_string(),
);
assert!(
matches!(s.phase, SessionPhase::AwaitingApproval),
"new session should start in AwaitingApproval, got {:?}",
s.phase
);
}
#[test]
fn test_awaiting_approval_is_not_runnable() {
let phase = SessionPhase::AwaitingApproval;
assert!(
!phase.is_runnable(),
"AwaitingApproval should not be runnable"
);
}
#[test]
fn test_awaiting_approval_label_is_distinct() {
let phase = SessionPhase::AwaitingApproval;
let label = phase.label();
assert_eq!(label, "Awaiting Approval");
assert_ne!(label, SessionPhase::Planned.label());
assert_ne!(label, SessionPhase::Running.label());
assert_ne!(label, SessionPhase::Completed.label());
assert_ne!(label, SessionPhase::Failed("x".to_string()).label());
}
#[test]
fn test_pending_excludes_awaiting_approval() {
let tmp = TempDir::new().unwrap_or_else(|e| panic!("{e:?}"));
let manager = SessionManager::new(tmp.path().to_path_buf());
let awaiting = SessionState::new(
"20260311200000".to_string(),
PathBuf::from("/repo"),
"cruise.yaml".to_string(),
"unapproved".to_string(),
);
manager
.create(&awaiting)
.unwrap_or_else(|e| panic!("{e:?}"));
let mut planned = SessionState::new(
"20260311200001".to_string(),
PathBuf::from("/repo"),
"cruise.yaml".to_string(),
"approved".to_string(),
);
planned.phase = SessionPhase::Planned;
manager.create(&planned).unwrap_or_else(|e| panic!("{e:?}"));
let mut running = SessionState::new(
"20260311200002".to_string(),
PathBuf::from("/repo"),
"cruise.yaml".to_string(),
"running".to_string(),
);
running.phase = SessionPhase::Running;
manager.create(&running).unwrap_or_else(|e| panic!("{e:?}"));
let pending = manager.pending().unwrap_or_else(|e| panic!("{e:?}"));
let ids: Vec<&str> = pending.iter().map(|s| s.id.as_str()).collect();
assert!(
!ids.contains(&"20260311200000"),
"AwaitingApproval should NOT be in pending: {ids:?}"
);
assert!(
ids.contains(&"20260311200001"),
"Planned should be in pending: {ids:?}"
);
assert!(
ids.contains(&"20260311200002"),
"Running should be in pending: {ids:?}"
);
}
#[test]
fn test_planned_excludes_awaiting_approval() {
let tmp = TempDir::new().unwrap_or_else(|e| panic!("{e:?}"));
let manager = SessionManager::new(tmp.path().to_path_buf());
let awaiting = SessionState::new(
"20260311300000".to_string(),
PathBuf::from("/repo"),
"cruise.yaml".to_string(),
"not yet approved".to_string(),
);
manager
.create(&awaiting)
.unwrap_or_else(|e| panic!("{e:?}"));
let mut approved = SessionState::new(
"20260311300001".to_string(),
PathBuf::from("/repo"),
"cruise.yaml".to_string(),
"approved task".to_string(),
);
approved.phase = SessionPhase::Planned;
manager
.create(&approved)
.unwrap_or_else(|e| panic!("{e:?}"));
let result = manager.planned().unwrap_or_else(|e| panic!("{e:?}"));
assert_eq!(result.len(), 1);
assert_eq!(result[0].id, "20260311300001");
assert!(
!result.iter().any(|s| s.id == "20260311300000"),
"AwaitingApproval should NOT appear in planned()"
);
}
#[test]
fn test_awaiting_approval_session_roundtrip() {
let tmp = TempDir::new().unwrap_or_else(|e| panic!("{e:?}"));
let manager = SessionManager::new(tmp.path().to_path_buf());
let id = "20260311400000".to_string();
let state = SessionState::new(
id.clone(),
PathBuf::from("/repo"),
"cruise.yaml".to_string(),
"pending approval".to_string(),
);
manager.create(&state).unwrap_or_else(|e| panic!("{e:?}"));
let loaded = manager.load(&id).unwrap_or_else(|e| panic!("{e:?}"));
assert!(
matches!(loaded.phase, SessionPhase::AwaitingApproval),
"loaded phase should be AwaitingApproval, got {:?}",
loaded.phase
);
}
#[test]
fn test_approve_from_awaiting_approval() {
let mut s = SessionState::new(
"20260311500000".to_string(),
PathBuf::from("/repo"),
"cruise.yaml".to_string(),
"task".to_string(),
);
assert!(matches!(s.phase, SessionPhase::AwaitingApproval));
s.approve();
assert!(
matches!(s.phase, SessionPhase::Planned),
"approve should set phase to Planned, got {:?}",
s.phase
);
}
#[test]
fn test_session_state_config_path_defaults_to_none_on_new() {
let state = SessionState::new(
"20260314120000".to_string(),
PathBuf::from("/repo"),
"cruise.yaml".to_string(),
"task".to_string(),
);
assert!(state.config_path.is_none());
}
#[test]
fn test_session_state_backward_compat_config_path_none() {
let tmp = TempDir::new().unwrap_or_else(|e| panic!("{e:?}"));
let manager = SessionManager::new(tmp.path().to_path_buf());
let id = "20260314120002".to_string();
let session_dir = manager.sessions_dir().join(&id);
std::fs::create_dir_all(&session_dir).unwrap_or_else(|e| panic!("{e:?}"));
let json = serde_json::json!({
"id": id,
"base_dir": "/repo",
"phase": "Planned",
"config_source": "cruise.yaml",
"input": "old task",
"current_step": null,
"created_at": "2026-03-14T12:00:00Z",
"completed_at": null,
"worktree_path": null,
"worktree_branch": null
});
std::fs::write(session_dir.join("state.json"), json.to_string())
.unwrap_or_else(|e| panic!("{e:?}"));
let loaded = manager.load(&id).unwrap_or_else(|e| panic!("{e:?}"));
assert!(
loaded.config_path.is_none(),
"config_path should default to None for old sessions"
);
}
#[test]
fn test_session_load_config_reads_from_config_path_when_set() {
let tmp = TempDir::new().unwrap_or_else(|e| panic!("{e:?}"));
let manager = SessionManager::new(tmp.path().to_path_buf());
let id = "20260314120003".to_string();
let config_file = tmp.path().join("external_cruise.yaml");
let yaml = "command:\n - cat\nsteps:\n check:\n command: \"true\"\n";
std::fs::write(&config_file, yaml).unwrap_or_else(|e| panic!("{e:?}"));
let mut state = SessionState::new(
id.clone(),
PathBuf::from("/repo"),
config_file.display().to_string(),
"task".to_string(),
);
state.config_path = Some(config_file);
manager.create(&state).unwrap_or_else(|e| panic!("{e:?}"));
let config = manager
.load_config(&state)
.unwrap_or_else(|e| panic!("{e:?}"));
assert_eq!(config.command, vec!["cat".to_string()]);
assert!(config.steps.contains_key("check"));
}
#[test]
fn test_session_load_config_falls_back_to_session_dir_when_config_path_none() {
let tmp = TempDir::new().unwrap_or_else(|e| panic!("{e:?}"));
let manager = SessionManager::new(tmp.path().to_path_buf());
let id = "20260314120004".to_string();
let state = SessionState::new(
id.clone(),
PathBuf::from("/repo"),
"cruise.yaml".to_string(),
"task".to_string(),
);
assert!(state.config_path.is_none());
manager.create(&state).unwrap_or_else(|e| panic!("{e:?}"));
let yaml = "command:\n - bash\nsteps:\n fallback_step:\n command: \"true\"\n";
std::fs::write(manager.sessions_dir().join(&id).join("config.yaml"), yaml)
.unwrap_or_else(|e| panic!("{e:?}"));
let config = manager
.load_config(&state)
.unwrap_or_else(|e| panic!("{e:?}"));
assert_eq!(config.command, vec!["bash".to_string()]);
assert!(config.steps.contains_key("fallback_step"));
}
#[test]
fn test_session_load_config_config_path_not_found_returns_error() {
let tmp = TempDir::new().unwrap_or_else(|e| panic!("{e:?}"));
let manager = SessionManager::new(tmp.path().to_path_buf());
let id = "20260314120005".to_string();
let mut state = SessionState::new(
id.clone(),
PathBuf::from("/repo"),
"cruise.yaml".to_string(),
"task".to_string(),
);
state.config_path = Some(PathBuf::from("/nonexistent/cruise.yaml"));
manager.create(&state).unwrap_or_else(|e| panic!("{e:?}"));
let result = manager.load_config(&state);
assert!(result.is_err());
}
#[test]
fn test_session_logger_creates_file_and_writes_line() {
let tmp = TempDir::new().unwrap_or_else(|e| panic!("{e:?}"));
let log_path = tmp.path().join("run.log");
let logger = SessionLogger::new(log_path.clone());
logger.write("test message");
let content = std::fs::read_to_string(&log_path).unwrap_or_else(|e| panic!("{e:?}"));
assert!(
content.contains("test message"),
"log should contain 'test message'"
);
}
#[test]
fn test_session_logger_line_format_has_timestamp_prefix() {
let tmp = TempDir::new().unwrap_or_else(|e| panic!("{e:?}"));
let log_path = tmp.path().join("run.log");
let logger = SessionLogger::new(log_path.clone());
logger.write("hello");
let content = std::fs::read_to_string(&log_path).unwrap_or_else(|e| panic!("{e:?}"));
let line = content
.lines()
.next()
.unwrap_or_else(|| panic!("should have at least one line"));
assert!(line.starts_with('['), "line should start with '['");
assert!(line.contains("] hello"), "line should contain '] hello'");
}
#[test]
fn test_session_logger_appends_multiple_writes() {
let tmp = TempDir::new().unwrap_or_else(|e| panic!("{e:?}"));
let log_path = tmp.path().join("run.log");
let logger = SessionLogger::new(log_path.clone());
logger.write("line one");
logger.write("line two");
logger.write("line three");
let content = std::fs::read_to_string(&log_path).unwrap_or_else(|e| panic!("{e:?}"));
let lines: Vec<&str> = content.lines().collect();
assert_eq!(lines.len(), 3, "should have 3 lines");
assert!(
lines[0].contains("line one"),
"first line should contain 'line one'"
);
assert!(
lines[1].contains("line two"),
"second line should contain 'line two'"
);
assert!(
lines[2].contains("line three"),
"third line should contain 'line three'"
);
}
#[test]
fn test_state_json_round_trip_preserves_control_characters_in_input() {
let cases = &[
"first line\nsecond line\nthird line",
"line one\r\nline two\rline three",
"col1\tcol2\tcol3",
"line1\nline2\tindented\r\nwin-line",
];
for &input in cases {
let tmp = TempDir::new().unwrap_or_else(|e| panic!("{e:?}"));
let manager = SessionManager::new(tmp.path().to_path_buf());
let id = "20260401120000".to_string();
let state = SessionState::new(
id.clone(),
PathBuf::from("/repo"),
"cruise.yaml".to_string(),
input.to_string(),
);
manager.create(&state).unwrap_or_else(|e| panic!("{e:?}"));
let loaded = manager.load(&id).unwrap_or_else(|e| panic!("{e:?}"));
assert_eq!(loaded.input, input, "input should round-trip: {input:?}");
}
}
#[test]
fn test_session_logger_write_silently_ignores_nonexistent_directory() {
let tmp = TempDir::new().unwrap_or_else(|e| panic!("{e:?}"));
let log_path = tmp.path().join("nonexistent_dir").join("run.log");
let logger = SessionLogger::new(log_path);
logger.write("this should not panic");
}
#[test]
fn test_skipped_steps_defaults_to_empty_on_new() {
let state = SessionState::new(
"20260407000000".to_string(),
PathBuf::from("/repo"),
"cruise.yaml".to_string(),
"add feature".to_string(),
);
assert!(
state.skipped_steps.is_empty(),
"skipped_steps should be empty for a new session"
);
}
#[test]
fn test_skipped_steps_deserializes_from_existing_json_without_field() {
let json = r#"{
"id": "20260407000001",
"base_dir": "/repo",
"phase": "AwaitingApproval",
"config_source": "cruise.yaml",
"input": "add feature",
"current_step": null,
"created_at": "2026-04-07T03:02:37Z",
"completed_at": null,
"worktree_path": null,
"worktree_branch": null
}"#;
let state: SessionState = serde_json::from_str(json)
.unwrap_or_else(|e| panic!("failed to deserialize legacy JSON: {e:?}"));
assert!(
state.skipped_steps.is_empty(),
"skipped_steps should default to empty when absent from JSON"
);
}
#[test]
fn test_skipped_steps_round_trips_through_save_and_load() {
let tmp = TempDir::new().unwrap_or_else(|e| panic!("{e:?}"));
let manager = SessionManager::new(tmp.path().to_path_buf());
let id = "20260407000002".to_string();
let mut state = SessionState::new(
id.clone(),
PathBuf::from("/repo"),
"cruise.yaml".to_string(),
"add feature".to_string(),
);
state.skipped_steps = vec!["plan".to_string(), "write-test".to_string()];
manager.create(&state).unwrap_or_else(|e| panic!("{e:?}"));
manager.save(&state).unwrap_or_else(|e| panic!("{e:?}"));
let loaded = manager.load(&id).unwrap_or_else(|e| panic!("{e:?}"));
assert_eq!(
loaded.skipped_steps,
vec!["plan", "write-test"],
"skipped_steps should round-trip through save and load"
);
}
#[test]
fn test_skipped_steps_empty_list_round_trips() {
let tmp = TempDir::new().unwrap_or_else(|e| panic!("{e:?}"));
let manager = SessionManager::new(tmp.path().to_path_buf());
let id = "20260407000003".to_string();
let state = SessionState::new(
id.clone(),
PathBuf::from("/repo"),
"cruise.yaml".to_string(),
"task".to_string(),
);
manager.create(&state).unwrap_or_else(|e| panic!("{e:?}"));
let loaded = manager.load(&id).unwrap_or_else(|e| panic!("{e:?}"));
assert!(
loaded.skipped_steps.is_empty(),
"empty skipped_steps should round-trip as empty"
);
}
}