diff --git a/src/main.rs b/src/main.rs index af2684a..aaf60b1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -186,13 +186,14 @@ impl App { }) } - fn handle_ticks(&mut self) { + fn handle_ticks(&mut self) -> Result<()> { if self.last_tick_50ms.elapsed() >= Duration::from_millis(50) { - self.tick_50ms(); + self.tick_50ms()?; } if self.last_tick_1s.elapsed() >= Duration::from_secs(1) { self.tick_1s(); } + Ok(()) } fn to_in_progress(&mut self) -> Result<()> { @@ -229,9 +230,13 @@ impl App { Ok(()) } - fn to_end(&mut self) { + fn to_end(&mut self) -> Result<()> { + if self.session_controller.runtime_state() == RuntimeState::Active { + self.session_controller.complete_for_review()?; + } self.state = State::End; self.session_ratings = session_stats_as_vec(&self.session_stats); + Ok(()) } fn cleanup(&self) { @@ -274,7 +279,7 @@ impl App { } } - fn tick_50ms(&mut self) { + fn tick_50ms(&mut self) -> Result<()> { match self.state { State::InputIntention | State::InputSuccessCondition | State::InputDuration => { if let Ok(user_duration_mins) = self.user_duration_str.parse::() { @@ -291,7 +296,7 @@ impl App { update_session_stats(self); if self.session_remaining.is_zero() { - self.to_end(); + self.to_end()?; } } State::ShouldQuit => {} @@ -305,6 +310,7 @@ impl App { } self.last_tick_50ms = Instant::now(); + Ok(()) } fn tick_1s(&mut self) { @@ -320,6 +326,24 @@ impl App { Duration::from_millis(50).saturating_sub(self.last_tick_50ms.elapsed()) } + fn complete_rating(&mut self) -> Result<()> { + if self.session_controller.runtime_state() == RuntimeState::Review { + self.session_controller.return_to_planning_after_review()?; + } + self.state = State::InputIntention; + let mut session_result = SessionResult { + intention: self.user_intention.clone(), + duration: self.session_start.elapsed(), + session_ratings: std::mem::take(&mut self.session_ratings), + rating: 0, + rating_f64: 0.0, + }; + session_result.rate(); + save_session(&session_result); + self.session_results.push(session_result); + Ok(()) + } + fn get_session_results(&self) -> Vec> { self.session_results .iter() @@ -388,22 +412,34 @@ fn session_stats_as_vec(session_stats: &HashMap, Duration>) -> Vec Result<()> { + let result = run_app(); + let raw_mode_result = disable_raw_mode(); + let screen_result = stdout().execute(LeaveAlternateScreen).map(|_| ()); + + result?; + raw_mode_result?; + screen_result?; + Ok(()) +} + +fn run_app() -> Result<()> { let mut app = App::new()?; enable_raw_mode()?; stdout().execute(EnterAlternateScreen)?; execute!(stdout(), SetTitle(constants::APP_TITLE))?; let mut terminal = Terminal::new(CrosstermBackend::new(stdout()))?; - while app.state != State::ShouldQuit { - terminal.draw(|frame| ui(frame, &app))?; - handle_events(&mut app)?; - app.handle_ticks(); - } + let result = (|| -> Result<()> { + while app.state != State::ShouldQuit { + terminal.draw(|frame| ui(frame, &app))?; + handle_events(&mut app)?; + app.handle_ticks()?; + } + Ok(()) + })(); app.cleanup(); - disable_raw_mode()?; - stdout().execute(LeaveAlternateScreen)?; - Ok(()) + result } fn handle_events(app: &mut App) -> Result<()> { @@ -459,7 +495,7 @@ fn handle_events(app: &mut App) -> Result<()> { }, State::InProgress => match key.code { KeyCode::Char('q') => { - app.to_end(); + app.to_end()?; } KeyCode::Char('p') => { app.state = State::Paused; @@ -492,17 +528,7 @@ fn handle_events(app: &mut App) -> Result<()> { } if app.session_ratings_index >= app.session_ratings.len() { - app.state = State::InputIntention; - let mut session_result = SessionResult { - intention: app.user_intention.clone(), - duration: app.session_start.elapsed(), - session_ratings: std::mem::take(&mut app.session_ratings), - rating: 0, - rating_f64: 0.0, - }; - session_result.rate(); - save_session(&session_result); - app.session_results.push(session_result); + app.complete_rating()?; } } } @@ -718,4 +744,33 @@ mod tests { let _ = fs::remove_file(path); } + + #[test] + fn rating_completion_returns_controller_to_planning_for_next_session() { + let path = unique_event_log_path("rating-completion"); + let mut app = App::new_with_event_log_path(&path).unwrap(); + + app.user_intention = "write tests".to_string(); + app.user_success_condition = "tests pass".to_string(); + app.user_duration_str = "1".to_string(); + app.to_in_progress().unwrap(); + app.to_end().unwrap(); + + assert_eq!(app.state, State::End); + assert_eq!(app.session_controller.runtime_state(), RuntimeState::Review); + + app.complete_rating().unwrap(); + + assert_eq!(app.state, State::InputIntention); + assert_eq!( + app.session_controller.runtime_state(), + RuntimeState::Planning + ); + + app.to_in_progress().unwrap(); + assert_eq!(app.state, State::InProgress); + assert_eq!(app.session_controller.runtime_state(), RuntimeState::Active); + + let _ = fs::remove_file(path); + } } diff --git a/src/session.rs b/src/session.rs index e9e57df..dc92d28 100644 --- a/src/session.rs +++ b/src/session.rs @@ -133,6 +133,64 @@ impl SessionController { Ok(()) } + pub fn complete_for_review(&mut self) -> Result<()> { + let previous_runtime_state = self.runtime_state; + let next_runtime_state = + transition_runtime(previous_runtime_state, RuntimeAction::CompleteForReview) + .context("complete active session for review runtime transition")?; + let commitment_id = self + .active_commitment + .as_ref() + .map(|commitment| commitment.id.clone()); + + self.event_log + .append( + EventType::RuntimeTransition, + next_runtime_state, + commitment_id, + serde_json::to_value(RuntimeTransitionPayload { + schema_version: POLICY_SCHEMA_VERSION, + action: "complete_for_review".to_string(), + from: previous_runtime_state, + to: next_runtime_state, + }) + .context("serialize runtime transition payload")?, + ) + .context("log runtime transition into review")?; + + self.runtime_state = next_runtime_state; + self.active_commitment = None; + self.active_policy = None; + Ok(()) + } + + pub fn return_to_planning_after_review(&mut self) -> Result<()> { + let previous_runtime_state = self.runtime_state; + let next_runtime_state = + transition_runtime(previous_runtime_state, RuntimeAction::ContinuePlanning) + .context("continue planning after review runtime transition")?; + + self.event_log + .append( + EventType::RuntimeTransition, + next_runtime_state, + None, + serde_json::to_value(RuntimeTransitionPayload { + schema_version: POLICY_SCHEMA_VERSION, + action: "return_to_planning_after_review".to_string(), + from: previous_runtime_state, + to: next_runtime_state, + }) + .context("serialize runtime transition payload")?, + ) + .context("log runtime transition from review to planning")?; + + self.runtime_state = next_runtime_state; + self.active_commitment = None; + self.active_policy = None; + Ok(()) + } + pub fn start_manual_commitment( &mut self, next_action: impl Into, @@ -302,13 +360,6 @@ fn hydrate_session( let payload: RuntimeTransitionPayload = parse_payload(&record, "runtime transition")?; ensure_schema_version(payload.schema_version, record.sequence)?; - if payload.action != "enter_planning" { - return Err(anyhow!( - "unsupported runtime transition action '{}' at sequence {}", - payload.action, - record.sequence - )); - } if payload.from != runtime_state { return Err(anyhow!( "runtime transition source mismatch at sequence {}: expected {:?}, found {:?}", @@ -317,10 +368,21 @@ fn hydrate_session( payload.from )); } - let next = transition_runtime(runtime_state, RuntimeAction::EnterPlanning) - .with_context(|| { - format!("replay runtime transition at sequence {}", record.sequence) - })?; + let action = match payload.action.as_str() { + "enter_planning" => RuntimeAction::EnterPlanning, + "complete_for_review" => RuntimeAction::CompleteForReview, + "return_to_planning_after_review" => RuntimeAction::ContinuePlanning, + _ => { + return Err(anyhow!( + "unsupported runtime transition action '{}' at sequence {}", + payload.action, + record.sequence + )); + } + }; + let next = transition_runtime(runtime_state, action).with_context(|| { + format!("replay runtime transition at sequence {}", record.sequence) + })?; if payload.to != next || record.runtime_state != next { return Err(anyhow!( "runtime transition target mismatch at sequence {}", @@ -328,6 +390,13 @@ fn hydrate_session( )); } runtime_state = next; + if matches!( + action, + RuntimeAction::CompleteForReview | RuntimeAction::ContinuePlanning + ) { + active_commitment = None; + active_policy = None; + } } EventType::CommitmentCreated => { let payload: CommitmentCreatedPayload = @@ -676,7 +745,10 @@ fn validate_transition_started_payload(payload: &TransitionStartedPayload) -> Re #[cfg(test)] mod tests { - use super::{CommitmentCreatedPayload, PolicyAppliedPayload, TransitionStartedPayload}; + use super::{ + CommitmentCreatedPayload, PolicyAppliedPayload, RuntimeTransitionPayload, + TransitionStartedPayload, + }; use crate::domain::{ AllowedContext, CommitmentSource, CommitmentState, EnforcementLevel, RuntimeState, POLICY_SCHEMA_VERSION, @@ -939,6 +1011,64 @@ mod tests { fs::remove_file(path).unwrap(); } + #[test] + fn completes_review_returns_to_planning_and_starts_next_commitment() { + let path = temp_log_path("review-cycle"); + let mut session = SessionController::new(&path).unwrap(); + + session.enter_planning().unwrap(); + session + .start_manual_commitment( + "Implement session controller", + "session tests pass", + Duration::from_secs(1500), + ) + .unwrap(); + + session.complete_for_review().unwrap(); + assert_eq!(session.runtime_state(), RuntimeState::Review); + + session.return_to_planning_after_review().unwrap(); + assert_eq!(session.runtime_state(), RuntimeState::Planning); + + session + .start_manual_commitment( + "Start another session", + "second session starts", + Duration::from_secs(600), + ) + .unwrap(); + assert_eq!(session.runtime_state(), RuntimeState::Active); + assert_eq!( + session.active_commitment().unwrap().next_action, + "Start another session" + ); + + drop(session); + let reopened = SessionController::new(&path).unwrap(); + assert_eq!(reopened.runtime_state(), RuntimeState::Active); + assert_eq!( + reopened.active_commitment().unwrap().next_action, + "Start another session" + ); + + let records = read_records(&path); + let review_transition = records + .iter() + .find(|record| { + record.event_type == EventType::RuntimeTransition + && record.runtime_state == RuntimeState::Review + }) + .unwrap(); + let payload: RuntimeTransitionPayload = + serde_json::from_value(review_transition.payload_json.clone()).unwrap(); + assert_eq!(payload.action, "complete_for_review"); + assert_eq!(payload.from, RuntimeState::Active); + assert_eq!(payload.to, RuntimeState::Review); + + fs::remove_file(path).unwrap(); + } + #[test] fn rejects_standalone_commitment_created_on_replay() { let path = temp_log_path("standalone-commitment");