use crate::error::{CruiseError, Result};
use crate::new_session_history::{
BUILTIN_CONFIG_KEY, NewSessionHistory, NewSessionHistoryEntry, resolved_config_key_for_session,
};
use crate::session::{SessionManager, SessionPhase, SessionState, current_iso8601};
pub enum CurrentStepUpdate {
Unchanged,
Clear,
Set(String),
}
pub struct SessionSettingsUpdate {
pub config_path: Option<String>,
pub skipped_steps: Vec<String>,
pub current_step_update: CurrentStepUpdate,
}
pub fn update_session_settings(
manager: &SessionManager,
session_id: &str,
update: SessionSettingsUpdate,
) -> Result<(SessionState, bool)> {
let mut session = manager.load(session_id)?;
let is_failed_or_suspended = matches!(
&session.phase,
SessionPhase::Failed(_) | SessionPhase::Suspended
);
match &session.phase {
SessionPhase::Draft
| SessionPhase::AwaitingApproval
| SessionPhase::Planned
| SessionPhase::Failed(_)
| SessionPhase::Suspended => {}
other => {
return Err(CruiseError::Other(format!(
"Cannot edit session in '{}' phase. Only 'Draft', 'Awaiting Approval', 'Planned', 'Failed' and 'Suspended' sessions are editable.",
other.label()
)));
}
}
let SessionSettingsUpdate {
config_path: requested_config_path,
skipped_steps,
current_step_update,
} = update;
let old_explicit_config = session
.config_path
.as_ref()
.map(|p| p.to_string_lossy().into_owned());
if is_failed_or_suspended && old_explicit_config != requested_config_path {
return Err(CruiseError::Other(
"Cannot change config for a Failed or Suspended session. Only skip steps and current step can be edited.".to_string(),
));
}
if !matches!(current_step_update, CurrentStepUpdate::Unchanged) && !is_failed_or_suspended {
return Err(CruiseError::Other(
"Cannot update current step for a session that is not Failed or Suspended.".to_string(),
));
}
let (yaml, source) = crate::resolver::resolve_config_in_dir(
requested_config_path.as_deref(),
&session.base_dir,
)?;
let config = crate::config::WorkflowConfig::from_yaml(&yaml)
.map_err(|e| CruiseError::Other(format!("config parse error: {e}")))?;
crate::config::validate_config(&config)?;
match current_step_update {
CurrentStepUpdate::Unchanged => {}
CurrentStepUpdate::Clear => session.current_step = None,
CurrentStepUpdate::Set(step_name) => {
validate_current_step_name(&config, &step_name, &skipped_steps)?;
session.current_step = Some(step_name);
}
}
session.config_source = source.display_string();
session.config_path = if requested_config_path.is_some() {
source.path().cloned()
} else {
None
};
session.skipped_steps = skipped_steps;
session.plan_error = None;
session.updated_at = Some(current_iso8601());
let session_dir = manager.sessions_dir().join(session_id);
if session.config_path.is_none() {
std::fs::write(session_dir.join("config.yaml"), &yaml)
.map_err(|e| CruiseError::Other(format!("failed to write session config: {e}")))?;
}
manager.save(&session)?;
if !is_failed_or_suspended {
let resolved_config_key = source.path().map_or_else(
|| BUILTIN_CONFIG_KEY.to_string(),
|p| resolved_config_key_for_session(p),
);
let mut history = NewSessionHistory::load_best_effort();
history.record_selection(NewSessionHistoryEntry {
selected_at: current_iso8601(),
input: session.input.clone(),
requested_config_path: requested_config_path.clone(),
working_dir: if session.repo.is_some() {
String::new()
} else {
session.base_dir.to_string_lossy().into_owned()
},
repo: session.repo.clone(),
resolved_config_key,
skipped_steps: session.skipped_steps.clone(),
});
history.save_best_effort();
}
let config_changed = old_explicit_config != requested_config_path;
Ok((session, config_changed))
}
fn validate_current_step_name(
config: &crate::config::WorkflowConfig,
step_name: &str,
skipped_steps: &[String],
) -> Result<()> {
let nodes = crate::workflow::list_skippable_steps(config)
.map_err(|e| CruiseError::Other(format!("step expansion error: {e}")))?;
let valid_ids: std::collections::HashSet<&str> = nodes
.iter()
.flat_map(|n| n.expanded_step_ids.iter().map(String::as_str))
.collect();
if !valid_ids.contains(step_name) {
return Err(CruiseError::Other(format!(
"Step '{step_name}' does not exist in the workflow config."
)));
}
if skipped_steps.iter().any(|s| s == step_name) {
return Err(CruiseError::Other(format!(
"Cannot set current_step to '{step_name}' because it is in skipped_steps."
)));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::session::{SessionManager, SessionPhase, SessionState};
use std::fs;
fn make_session(id: &str, base_dir: &std::path::Path) -> SessionState {
let mut s = SessionState::new(
id.to_string(),
base_dir.to_path_buf(),
"cruise.yaml".to_string(),
"test task".to_string(),
);
s.phase = SessionPhase::Planned;
s
}
fn write_minimal_config(dir: &std::path::Path) {
fs::write(
dir.join("cruise.yaml"),
"command: [local]\nsteps:\n s:\n command: echo ok",
)
.unwrap_or_else(|e| panic!("{e:?}"));
}
#[test]
fn test_update_session_settings_draft_phase_succeeds() {
let _lock = crate::test_support::lock_process();
let tmp = tempfile::TempDir::new().unwrap_or_else(|e| panic!("{e:?}"));
let _home = crate::test_support::set_fake_home(tmp.path());
let repo = tmp.path().join("repo");
fs::create_dir_all(&repo).unwrap_or_else(|e| panic!("{e:?}"));
write_minimal_config(&repo);
let manager = SessionManager::new(tmp.path().join(".cruise"));
let mut session = make_session("20260619000001", &repo);
session.phase = SessionPhase::Draft;
manager.create(&session).unwrap_or_else(|e| panic!("{e:?}"));
let result = update_session_settings(
&manager,
"20260619000001",
SessionSettingsUpdate {
config_path: None,
skipped_steps: vec![],
current_step_update: CurrentStepUpdate::Unchanged,
},
);
assert!(
result.is_ok(),
"Draft phase should be allowed: {:?}",
result.err()
);
}
#[test]
fn test_update_session_settings_awaiting_approval_phase_succeeds() {
let _lock = crate::test_support::lock_process();
let tmp = tempfile::TempDir::new().unwrap_or_else(|e| panic!("{e:?}"));
let _home = crate::test_support::set_fake_home(tmp.path());
let repo = tmp.path().join("repo");
fs::create_dir_all(&repo).unwrap_or_else(|e| panic!("{e:?}"));
write_minimal_config(&repo);
let manager = SessionManager::new(tmp.path().join(".cruise"));
let mut session = make_session("20260619000002", &repo);
session.phase = SessionPhase::AwaitingApproval;
manager.create(&session).unwrap_or_else(|e| panic!("{e:?}"));
let result = update_session_settings(
&manager,
"20260619000002",
SessionSettingsUpdate {
config_path: None,
skipped_steps: vec!["build".to_string()],
current_step_update: CurrentStepUpdate::Unchanged,
},
);
assert!(
result.is_ok(),
"AwaitingApproval phase should be allowed: {:?}",
result.err()
);
}
#[test]
fn test_update_session_settings_planned_phase_succeeds() {
let _lock = crate::test_support::lock_process();
let tmp = tempfile::TempDir::new().unwrap_or_else(|e| panic!("{e:?}"));
let _home = crate::test_support::set_fake_home(tmp.path());
let repo = tmp.path().join("repo");
fs::create_dir_all(&repo).unwrap_or_else(|e| panic!("{e:?}"));
write_minimal_config(&repo);
let manager = SessionManager::new(tmp.path().join(".cruise"));
let session = make_session("20260619000003", &repo);
manager.create(&session).unwrap_or_else(|e| panic!("{e:?}"));
let result = update_session_settings(
&manager,
"20260619000003",
SessionSettingsUpdate {
config_path: None,
skipped_steps: vec!["test".to_string()],
current_step_update: CurrentStepUpdate::Unchanged,
},
);
assert!(
result.is_ok(),
"Planned phase should be allowed: {:?}",
result.err()
);
}
#[test]
fn test_update_session_settings_running_phase_fails_with_phase_message() {
let _lock = crate::test_support::lock_process();
let tmp = tempfile::TempDir::new().unwrap_or_else(|e| panic!("{e:?}"));
let repo = tmp.path().join("repo");
fs::create_dir_all(&repo).unwrap_or_else(|e| panic!("{e:?}"));
let manager = SessionManager::new(tmp.path().join(".cruise"));
let mut session = make_session("20260619000004", &repo);
session.phase = SessionPhase::Running;
manager.create(&session).unwrap_or_else(|e| panic!("{e:?}"));
let result = update_session_settings(
&manager,
"20260619000004",
SessionSettingsUpdate {
config_path: None,
skipped_steps: vec![],
current_step_update: CurrentStepUpdate::Unchanged,
},
);
assert!(result.is_err(), "Running phase should be rejected");
let msg = result
.err()
.unwrap_or_else(|| panic!("expected Err"))
.to_string();
assert!(
msg.contains("Running") || msg.contains("running"),
"error should mention phase: {msg}"
);
}
#[test]
fn test_update_session_settings_completed_phase_fails() {
let _lock = crate::test_support::lock_process();
let tmp = tempfile::TempDir::new().unwrap_or_else(|e| panic!("{e:?}"));
let repo = tmp.path().join("repo");
fs::create_dir_all(&repo).unwrap_or_else(|e| panic!("{e:?}"));
let manager = SessionManager::new(tmp.path().join(".cruise"));
let mut session = make_session("20260619000007", &repo);
session.phase = SessionPhase::Completed;
manager.create(&session).unwrap_or_else(|e| panic!("{e:?}"));
let result = update_session_settings(
&manager,
"20260619000007",
SessionSettingsUpdate {
config_path: None,
skipped_steps: vec![],
current_step_update: CurrentStepUpdate::Unchanged,
},
);
assert!(result.is_err(), "Completed phase should be rejected");
}
#[test]
fn test_update_session_settings_skipped_steps_persisted_on_disk() {
let _lock = crate::test_support::lock_process();
let tmp = tempfile::TempDir::new().unwrap_or_else(|e| panic!("{e:?}"));
let _home = crate::test_support::set_fake_home(tmp.path());
let repo = tmp.path().join("repo");
fs::create_dir_all(&repo).unwrap_or_else(|e| panic!("{e:?}"));
write_minimal_config(&repo);
let manager = SessionManager::new(tmp.path().join(".cruise"));
let mut session = make_session("20260619000008", &repo);
session.skipped_steps = vec![];
manager.create(&session).unwrap_or_else(|e| panic!("{e:?}"));
let result = update_session_settings(
&manager,
"20260619000008",
SessionSettingsUpdate {
config_path: None,
skipped_steps: vec!["build".to_string(), "test".to_string()],
current_step_update: CurrentStepUpdate::Unchanged,
},
);
assert!(result.is_ok(), "should succeed: {:?}", result.err());
let reloaded = manager
.load("20260619000008")
.unwrap_or_else(|e| panic!("{e:?}"));
assert_eq!(
reloaded.skipped_steps,
vec!["build".to_string(), "test".to_string()],
"skipped_steps should be persisted"
);
}
#[test]
fn test_update_session_settings_config_changed_false_for_skip_only_edit() {
let _lock = crate::test_support::lock_process();
let tmp = tempfile::TempDir::new().unwrap_or_else(|e| panic!("{e:?}"));
let _home = crate::test_support::set_fake_home(tmp.path());
let repo = tmp.path().join("repo");
fs::create_dir_all(&repo).unwrap_or_else(|e| panic!("{e:?}"));
write_minimal_config(&repo);
let manager = SessionManager::new(tmp.path().join(".cruise"));
let mut session = make_session("20260619000009", &repo);
session.config_path = None;
manager.create(&session).unwrap_or_else(|e| panic!("{e:?}"));
let (_, config_changed) = update_session_settings(
&manager,
"20260619000009",
SessionSettingsUpdate {
config_path: None,
skipped_steps: vec!["lint".to_string()],
current_step_update: CurrentStepUpdate::Unchanged,
},
)
.unwrap_or_else(|e| panic!("{e:?}"));
assert!(
!config_changed,
"config_changed should be false when only skipped_steps differ"
);
}
#[test]
fn test_update_session_settings_config_changed_true_when_explicit_path_given() {
let _lock = crate::test_support::lock_process();
let tmp = tempfile::TempDir::new().unwrap_or_else(|e| panic!("{e:?}"));
let _home = crate::test_support::set_fake_home(tmp.path());
let repo = tmp.path().join("repo");
fs::create_dir_all(&repo).unwrap_or_else(|e| panic!("{e:?}"));
write_minimal_config(&repo);
let alt_config = tmp.path().join("alt.yaml");
fs::write(
&alt_config,
"command: [local]\nsteps:\n s:\n command: echo alt",
)
.unwrap_or_else(|e| panic!("{e:?}"));
let manager = SessionManager::new(tmp.path().join(".cruise"));
let mut session = make_session("20260619000010", &repo);
session.config_path = None;
manager.create(&session).unwrap_or_else(|e| panic!("{e:?}"));
let (_, config_changed) = update_session_settings(
&manager,
"20260619000010",
SessionSettingsUpdate {
config_path: Some(alt_config.to_string_lossy().into_owned()),
skipped_steps: vec![],
current_step_update: CurrentStepUpdate::Unchanged,
},
)
.unwrap_or_else(|e| panic!("{e:?}"));
assert!(
config_changed,
"config_changed should be true when config path switches"
);
}
#[test]
fn test_update_session_settings_writes_session_config_yaml_for_builtin_config() {
let _lock = crate::test_support::lock_process();
let tmp = tempfile::TempDir::new().unwrap_or_else(|e| panic!("{e:?}"));
let _home = crate::test_support::set_fake_home(tmp.path());
let repo = tmp.path().join("repo");
fs::create_dir_all(&repo).unwrap_or_else(|e| panic!("{e:?}"));
write_minimal_config(&repo);
let manager = SessionManager::new(tmp.path().join(".cruise"));
let mut session = make_session("20260619000011", &repo);
session.config_path = None;
manager.create(&session).unwrap_or_else(|e| panic!("{e:?}"));
let result = update_session_settings(
&manager,
"20260619000011",
SessionSettingsUpdate {
config_path: None,
skipped_steps: vec![],
current_step_update: CurrentStepUpdate::Unchanged,
},
);
assert!(result.is_ok(), "should succeed: {:?}", result.err());
let session_dir = manager.sessions_dir().join("20260619000011");
assert!(
session_dir.join("config.yaml").exists(),
"config.yaml should be written for builtin/auto-resolved config"
);
}
#[test]
fn test_failed_phase_succeeds_for_skip_only() {
let _lock = crate::test_support::lock_process();
let tmp = tempfile::TempDir::new().unwrap_or_else(|e| panic!("{e:?}"));
let _home = crate::test_support::set_fake_home(tmp.path());
let repo = tmp.path().join("repo");
fs::create_dir_all(&repo).unwrap_or_else(|e| panic!("{e:?}"));
write_minimal_config(&repo);
let manager = SessionManager::new(tmp.path().join(".cruise"));
let mut session = make_session("20260619000012", &repo);
session.phase = SessionPhase::Failed("build error".to_string());
manager.create(&session).unwrap_or_else(|e| panic!("{e:?}"));
let result = update_session_settings(
&manager,
"20260619000012",
SessionSettingsUpdate {
config_path: None,
skipped_steps: vec!["s".to_string()],
current_step_update: CurrentStepUpdate::Unchanged,
},
);
assert!(
result.is_ok(),
"Failed phase should allow skip-only edits: {:?}",
result.err()
);
}
#[test]
fn test_suspended_phase_succeeds_for_skip_only() {
let _lock = crate::test_support::lock_process();
let tmp = tempfile::TempDir::new().unwrap_or_else(|e| panic!("{e:?}"));
let _home = crate::test_support::set_fake_home(tmp.path());
let repo = tmp.path().join("repo");
fs::create_dir_all(&repo).unwrap_or_else(|e| panic!("{e:?}"));
write_minimal_config(&repo);
let manager = SessionManager::new(tmp.path().join(".cruise"));
let mut session = make_session("20260619000013", &repo);
session.phase = SessionPhase::Suspended;
manager.create(&session).unwrap_or_else(|e| panic!("{e:?}"));
let result = update_session_settings(
&manager,
"20260619000013",
SessionSettingsUpdate {
config_path: None,
skipped_steps: vec!["s".to_string()],
current_step_update: CurrentStepUpdate::Unchanged,
},
);
assert!(
result.is_ok(),
"Suspended phase should allow skip-only edits: {:?}",
result.err()
);
}
#[test]
fn test_failed_phase_updates_current_step() {
let _lock = crate::test_support::lock_process();
let tmp = tempfile::TempDir::new().unwrap_or_else(|e| panic!("{e:?}"));
let _home = crate::test_support::set_fake_home(tmp.path());
let repo = tmp.path().join("repo");
fs::create_dir_all(&repo).unwrap_or_else(|e| panic!("{e:?}"));
write_minimal_config(&repo);
let manager = SessionManager::new(tmp.path().join(".cruise"));
let mut session = make_session("20260619000014", &repo);
session.phase = SessionPhase::Failed("step s failed".to_string());
session.current_step = None;
manager.create(&session).unwrap_or_else(|e| panic!("{e:?}"));
let result = update_session_settings(
&manager,
"20260619000014",
SessionSettingsUpdate {
config_path: None,
skipped_steps: vec![],
current_step_update: CurrentStepUpdate::Set("s".to_string()),
},
);
assert!(
result.is_ok(),
"Failed phase should allow current_step update: {:?}",
result.err()
);
let reloaded = manager
.load("20260619000014")
.unwrap_or_else(|e| panic!("{e:?}"));
assert_eq!(
reloaded.current_step,
Some("s".to_string()),
"current_step should be set to 's'"
);
}
#[test]
fn test_suspended_phase_clears_current_step() {
let _lock = crate::test_support::lock_process();
let tmp = tempfile::TempDir::new().unwrap_or_else(|e| panic!("{e:?}"));
let _home = crate::test_support::set_fake_home(tmp.path());
let repo = tmp.path().join("repo");
fs::create_dir_all(&repo).unwrap_or_else(|e| panic!("{e:?}"));
write_minimal_config(&repo);
let manager = SessionManager::new(tmp.path().join(".cruise"));
let mut session = make_session("20260619000015", &repo);
session.phase = SessionPhase::Suspended;
session.current_step = Some("s".to_string());
manager.create(&session).unwrap_or_else(|e| panic!("{e:?}"));
let result = update_session_settings(
&manager,
"20260619000015",
SessionSettingsUpdate {
config_path: None,
skipped_steps: vec![],
current_step_update: CurrentStepUpdate::Clear,
},
);
assert!(
result.is_ok(),
"Suspended phase should allow current_step clear: {:?}",
result.err()
);
let reloaded = manager
.load("20260619000015")
.unwrap_or_else(|e| panic!("{e:?}"));
assert_eq!(
reloaded.current_step, None,
"current_step should be cleared"
);
}
#[test]
fn test_failed_phase_rejects_config_swap() {
let _lock = crate::test_support::lock_process();
let tmp = tempfile::TempDir::new().unwrap_or_else(|e| panic!("{e:?}"));
let _home = crate::test_support::set_fake_home(tmp.path());
let repo = tmp.path().join("repo");
fs::create_dir_all(&repo).unwrap_or_else(|e| panic!("{e:?}"));
write_minimal_config(&repo);
let alt_config = tmp.path().join("alt.yaml");
fs::write(
&alt_config,
"command: [local]\nsteps:\n s:\n command: echo alt",
)
.unwrap_or_else(|e| panic!("{e:?}"));
let manager = SessionManager::new(tmp.path().join(".cruise"));
let mut session = make_session("20260619000016", &repo);
session.phase = SessionPhase::Failed("err".to_string());
manager.create(&session).unwrap_or_else(|e| panic!("{e:?}"));
let result = update_session_settings(
&manager,
"20260619000016",
SessionSettingsUpdate {
config_path: Some(alt_config.to_string_lossy().into_owned()),
skipped_steps: vec![],
current_step_update: CurrentStepUpdate::Unchanged,
},
);
assert!(
result.is_err(),
"Failed phase should reject config_path change"
);
}
#[test]
fn test_failed_phase_rejects_invalid_current_step() {
let _lock = crate::test_support::lock_process();
let tmp = tempfile::TempDir::new().unwrap_or_else(|e| panic!("{e:?}"));
let _home = crate::test_support::set_fake_home(tmp.path());
let repo = tmp.path().join("repo");
fs::create_dir_all(&repo).unwrap_or_else(|e| panic!("{e:?}"));
write_minimal_config(&repo);
let manager = SessionManager::new(tmp.path().join(".cruise"));
let mut session = make_session("20260619000017", &repo);
session.phase = SessionPhase::Failed("err".to_string());
manager.create(&session).unwrap_or_else(|e| panic!("{e:?}"));
let result = update_session_settings(
&manager,
"20260619000017",
SessionSettingsUpdate {
config_path: None,
skipped_steps: vec![],
current_step_update: CurrentStepUpdate::Set("nonexistent_step".to_string()),
},
);
assert!(
result.is_err(),
"Should reject current_step that doesn't exist in workflow"
);
}
#[test]
fn test_planned_phase_rejects_current_step_update() {
let _lock = crate::test_support::lock_process();
let tmp = tempfile::TempDir::new().unwrap_or_else(|e| panic!("{e:?}"));
let _home = crate::test_support::set_fake_home(tmp.path());
let repo = tmp.path().join("repo");
fs::create_dir_all(&repo).unwrap_or_else(|e| panic!("{e:?}"));
write_minimal_config(&repo);
let manager = SessionManager::new(tmp.path().join(".cruise"));
let session = make_session("20260619000018", &repo);
manager.create(&session).unwrap_or_else(|e| panic!("{e:?}"));
let result = update_session_settings(
&manager,
"20260619000018",
SessionSettingsUpdate {
config_path: None,
skipped_steps: vec![],
current_step_update: CurrentStepUpdate::Set("s".to_string()),
},
);
assert!(
result.is_err(),
"Planned phase should reject current_step update"
);
}
}