Files
antidrift/legacy/src/main.rs
T
2026-05-31 11:35:04 -04:00

2122 lines
74 KiB
Rust

use anyhow::Result;
use serde::{Serialize, Serializer};
use shellexpand;
use std::collections::HashMap;
use std::io::{stdout, Write};
use std::path::Path;
use std::rc::Rc;
use std::time::{Duration, Instant}; // <--- Add this
mod constants;
use antidrift::{
context::{window_class_allowed, window_title_allowed},
domain::{EvidenceHealth, PolicySnapshot, RuntimeState},
session::SessionController,
window,
};
use ratatui::{
crossterm::{
event::{self, Event, KeyCode},
execute,
terminal::{
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, SetTitle,
},
ExecutableCommand,
},
prelude::*,
style::Color,
widgets::*,
};
#[derive(Debug, PartialEq)]
enum State {
InputIntention,
InputSuccessCondition,
InputDuration,
InputTransitionReason,
InputTransitionReturn,
InputTransitionDuration,
InProgress,
Paused,
ViolationPrompt,
End,
ShouldQuit,
}
fn serialize_duration<S>(duration: &Duration, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_u64(duration.as_secs())
}
#[derive(Serialize)]
struct SessionResult {
intention: String,
#[serde(serialize_with = "serialize_duration")]
duration: Duration,
session_ratings: Vec<SessionRating>,
rating: u8,
rating_f64: f64,
}
impl SessionResult {
fn rate(&mut self) {
let mut rating = 0_f64;
let total_duration = self
.session_ratings
.iter()
.map(|r| r.duration)
.fold(Duration::ZERO, |acc, dur| acc.saturating_add(dur));
for session_rating in &self.session_ratings {
let ratio: f64 = session_rating.duration.as_secs_f64() / total_duration.as_secs_f64();
rating += (session_rating.rating as f64) * ratio;
}
self.rating_f64 = rating;
if rating > 2.5 {
self.rating = 3;
} else if rating > 2.0 {
self.rating = 2;
} else if rating > 1.0 {
self.rating = 1;
} else {
self.rating = 0;
}
}
}
#[derive(Clone, Serialize)]
struct SessionRating {
window_title: Rc<String>,
duration: Duration,
rating: u8,
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct WindowIdentity {
title: String,
class: Option<String>,
}
impl From<&window::WindowSnapshot> for WindowIdentity {
fn from(snapshot: &window::WindowSnapshot) -> Self {
Self {
title: snapshot.title.clone(),
class: snapshot.class.clone(),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct EvidenceObservationKey {
runtime_state: RuntimeState,
commitment_id: Option<String>,
health: EvidenceHealth,
}
struct App {
state: State,
user_intention: String,
user_success_condition: String,
user_duration_str: String,
user_duration: Duration,
transition_reason: String,
transition_return_target: String,
transition_duration_str: String,
violation_message: String,
violation_window_snapshot: Option<window::WindowSnapshot>,
violation_dismissal_reason: String,
current_window_title: Rc<String>,
evidence_health: EvidenceHealth,
session_controller: SessionController,
current_window_time: Instant,
session_start: Instant,
session_stats: HashMap<Rc<String>, Duration>,
session_remaining: Duration,
session_ratings: Vec<SessionRating>,
session_ratings_index: usize,
session_results: Vec<SessionResult>,
acknowledged_unknown_window: Option<WindowIdentity>,
acknowledged_unknown_window_handoff_pending: bool,
last_recorded_evidence: Option<EvidenceObservationKey>,
last_tick_50ms: Instant,
last_tick_1s: Instant,
}
fn save_session(session: &SessionResult) {
let path = shellexpand::tilde(constants::HISTORY_FILE).to_string();
// 1. Open file in append mode, create if missing
let file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(path);
if let Ok(mut f) = file {
// 2. Serialize to JSON
if let Ok(json) = serde_json::to_string(session) {
// 3. Write newline appended
let _ = writeln!(f, "{}", json);
}
}
}
impl App {
fn new() -> Result<Self> {
let event_log_path = shellexpand::tilde(constants::EVENT_LOG_FILE).to_string();
Self::new_with_event_log_path(event_log_path)
}
fn new_with_event_log_path(event_log_path: impl AsRef<Path>) -> Result<Self> {
let window_snapshot = window::get_snapshot();
let mut session_controller = SessionController::new(event_log_path)?;
match session_controller.runtime_state() {
RuntimeState::Locked => session_controller.enter_planning()?,
_ => {}
}
let active_commitment = session_controller.active_commitment().cloned();
let state = match session_controller.runtime_state() {
RuntimeState::Active if active_commitment.is_some() => State::InProgress,
RuntimeState::Transition if active_commitment.is_some() => State::Paused,
RuntimeState::Review => State::End,
_ => State::InputIntention,
};
let user_intention = active_commitment
.as_ref()
.map(|commitment| commitment.next_action.clone())
.unwrap_or_default();
let user_success_condition = active_commitment
.as_ref()
.map(|commitment| commitment.success_condition.clone())
.unwrap_or_default();
let user_duration = active_commitment
.as_ref()
.map(|commitment| Duration::from_secs(commitment.timebox_secs))
.unwrap_or(Duration::ZERO);
let user_duration_str = active_commitment
.as_ref()
.map(|commitment| (commitment.timebox_secs / 60).to_string())
.unwrap_or_else(|| constants::DEFAULT_DURATION.to_string());
let restored_elapsed = session_controller
.timing_summary()?
.map(|summary| Duration::from_secs(summary.active_elapsed_secs))
.unwrap_or(Duration::ZERO);
let restored_elapsed_capped = restored_elapsed.min(user_duration);
let now = Instant::now();
let session_start = now.checked_sub(restored_elapsed_capped).unwrap_or(now);
let session_remaining = user_duration.saturating_sub(restored_elapsed);
let mut session_ratings = Vec::new();
if session_controller.runtime_state() == RuntimeState::Review {
session_ratings.push(recoverable_review_rating());
}
Ok(App {
state,
user_intention,
user_success_condition,
user_duration_str,
user_duration,
transition_reason: String::new(),
transition_return_target: String::new(),
transition_duration_str: "2".to_string(),
violation_message: String::new(),
violation_window_snapshot: None,
violation_dismissal_reason: String::new(),
current_window_title: window_snapshot.title.into(),
evidence_health: window_snapshot.health,
session_controller,
current_window_time: now,
session_start,
session_stats: HashMap::new(),
session_remaining,
session_ratings,
session_ratings_index: 0,
session_results: Vec::new(),
acknowledged_unknown_window: None,
acknowledged_unknown_window_handoff_pending: false,
last_recorded_evidence: None,
last_tick_50ms: now,
last_tick_1s: now,
})
}
fn handle_ticks(&mut self) -> Result<()> {
if self.last_tick_50ms.elapsed() >= Duration::from_millis(50) {
self.tick_50ms()?;
}
if self.last_tick_1s.elapsed() >= Duration::from_secs(1) {
self.tick_1s();
}
Ok(())
}
fn to_in_progress(&mut self) -> Result<()> {
self.user_duration =
parse_duration_minutes(&self.user_duration_str).unwrap_or(Duration::ZERO);
if self.user_intention.trim().is_empty() {
self.state = State::InputIntention;
return Ok(());
}
if self.user_success_condition.trim().is_empty() {
self.state = State::InputSuccessCondition;
return Ok(());
}
if self.user_duration == Duration::ZERO {
self.state = State::InputDuration;
return Ok(());
}
self.session_controller.start_manual_commitment(
self.user_intention.clone(),
self.user_success_condition.clone(),
self.user_duration,
)?;
self.state = State::InProgress;
self.current_window_time = Instant::now();
self.session_start = self.current_window_time;
self.session_stats = HashMap::new();
self.session_ratings_index = 0;
Ok(())
}
fn pause(&mut self) -> Result<()> {
self.transition_reason.clear();
self.transition_return_target = self.user_intention.clone();
self.transition_duration_str = "2".to_string();
self.state = State::InputTransitionReason;
Ok(())
}
fn start_transition_if_valid(&mut self) -> Result<()> {
if self.transition_reason.trim().is_empty() {
self.state = State::InputTransitionReason;
return Ok(());
}
if self.transition_return_target.trim().is_empty() {
self.state = State::InputTransitionReturn;
return Ok(());
}
let expected_duration =
parse_duration_minutes(&self.transition_duration_str).unwrap_or(Duration::ZERO);
if expected_duration.is_zero() {
self.state = State::InputTransitionDuration;
return Ok(());
}
self.session_controller.start_transition(
self.transition_reason.clone(),
self.transition_return_target.clone(),
expected_duration,
)?;
self.state = State::Paused;
Ok(())
}
fn resume(&mut self) -> Result<()> {
if self.session_controller.runtime_state() == RuntimeState::Transition {
self.session_controller.return_from_transition()?;
}
self.state = State::InProgress;
Ok(())
}
fn enter_violation_prompt(&mut self, window_snapshot: &window::WindowSnapshot) {
self.violation_message =
format!("{}: {}", constants::VIOLATION_STATUS, window_snapshot.title);
self.violation_window_snapshot = Some(window_snapshot.clone());
self.violation_dismissal_reason.clear();
self.state = State::ViolationPrompt;
}
fn dismiss_violation_if_valid(&mut self) -> Result<()> {
if self.violation_dismissal_reason.trim().is_empty() {
return Ok(());
}
let violation_window_snapshot = self.violation_window_snapshot.clone();
let window_title = violation_window_snapshot
.as_ref()
.map(|snapshot| snapshot.title.clone())
.unwrap_or_else(|| self.current_window_title.as_ref().clone());
let evidence_health = violation_window_snapshot
.as_ref()
.map(|snapshot| snapshot.health.clone())
.unwrap_or_else(|| self.evidence_health.clone());
self.session_controller.record_violation(
self.violation_dismissal_reason.clone(),
serde_json::json!({
"message": self.violation_message,
"window_title": window_title,
"evidence_health": evidence_health,
}),
)?;
if let Some(window_snapshot) = violation_window_snapshot.as_ref() {
self.acknowledge_unknown_window(window_snapshot);
}
self.violation_window_snapshot = None;
self.state = State::InProgress;
Ok(())
}
fn to_end(&mut self) -> Result<()> {
match self.session_controller.runtime_state() {
RuntimeState::Transition => {
self.session_controller.return_from_transition()?;
self.session_controller.complete_for_review()?;
}
RuntimeState::Active => {
self.session_controller.complete_for_review()?;
}
_ => {}
}
self.state = State::End;
self.session_ratings = session_stats_as_vec(&self.session_stats);
self.ensure_recoverable_review_rating();
Ok(())
}
fn ensure_recoverable_review_rating(&mut self) {
if self.session_controller.runtime_state() == RuntimeState::Review
&& self.session_ratings.is_empty()
{
self.session_ratings.push(recoverable_review_rating());
}
}
fn cleanup(&self) {
let path = shellexpand::tilde(constants::STATUS_FILE).to_string();
let _ = std::fs::remove_file(path);
}
fn write_status(&self) {
let status = self.status_text();
let path = shellexpand::tilde(constants::STATUS_FILE).to_string();
if let Ok(mut file) = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.open(&path)
{
let _ = file.write_all(status.as_bytes());
}
}
fn status_text(&self) -> String {
match self.state {
State::InputTransitionReason
| State::InputTransitionReturn
| State::InputTransitionDuration
if self.session_controller.runtime_state() == RuntimeState::Active =>
{
self.active_status_text()
}
State::InputIntention | State::InputSuccessCondition | State::InputDuration => {
match self.session_controller.runtime_state() {
RuntimeState::Locked => constants::STATUS_LOCKED.to_string(),
RuntimeState::Planning => constants::STATUS_PLANNING.to_string(),
RuntimeState::Transition => constants::STATUS_TRANSITION.to_string(),
_ => constants::STATUS_CONFIGURE.to_string(),
}
}
State::InputTransitionReason
| State::InputTransitionReturn
| State::InputTransitionDuration => match self.session_controller.runtime_state() {
RuntimeState::Locked => constants::STATUS_LOCKED.to_string(),
RuntimeState::Planning => constants::STATUS_PLANNING.to_string(),
RuntimeState::Transition => constants::STATUS_TRANSITION.to_string(),
_ => constants::STATUS_CONFIGURE.to_string(),
},
State::InProgress => self.active_status_text(),
State::Paused => format!(
"{} - {}",
constants::STATUS_PAUSED,
duration_as_str(&self.session_remaining)
),
State::ViolationPrompt => constants::VIOLATION_STATUS.to_string(),
State::End => constants::STATUS_RATE_SESSION.to_string(),
State::ShouldQuit => constants::STATUS_QUIT.to_string(),
}
}
fn active_status_text(&self) -> String {
format!(
"🎯 {} - {}",
self.user_intention,
duration_as_str(&self.session_remaining)
)
}
fn tick_50ms(&mut self) -> Result<()> {
match self.state {
State::InputIntention | State::InputSuccessCondition | State::InputDuration => {
self.user_duration =
parse_duration_minutes(&self.user_duration_str).unwrap_or(Duration::ZERO);
window::minimize_other(&constants::APP_TITLE);
}
State::InputTransitionReason
| State::InputTransitionReturn
| State::InputTransitionDuration
| State::InProgress => self.tick_active_session()?,
State::ShouldQuit => {}
State::Paused => {
window::minimize_other(&constants::APP_TITLE);
update_session_stats(self)?;
}
State::ViolationPrompt => {}
State::End => {
window::minimize_other(&constants::APP_TITLE);
}
}
self.last_tick_50ms = Instant::now();
Ok(())
}
fn tick_active_session(&mut self) -> Result<()> {
self.tick_active_session_with_snapshot(window::get_snapshot())
}
fn tick_active_session_with_snapshot(
&mut self,
window_snapshot: window::WindowSnapshot,
) -> Result<()> {
let elapsed = self.session_start.elapsed();
self.session_remaining = self.user_duration.saturating_sub(elapsed);
let window_snapshot = update_session_stats_with_snapshot(self, window_snapshot)?;
let should_prompt = self.state == State::InProgress
&& self.session_controller.runtime_state() == RuntimeState::Active
&& !self.is_unknown_window_acknowledged(&window_snapshot)
&& self
.session_controller
.active_policy()
.is_some_and(|policy| should_prompt_for_unknown_window(policy, &window_snapshot));
if should_prompt {
self.enter_violation_prompt(&window_snapshot);
return Ok(());
}
if self.session_remaining.is_zero() {
self.to_end()?;
}
Ok(())
}
fn tick_1s(&mut self) {
self.last_tick_1s = Instant::now();
self.write_status();
if self.state == State::Paused {
self.user_duration = self.user_duration.saturating_add(Duration::from_secs(1));
}
}
fn acknowledge_unknown_window(&mut self, window_snapshot: &window::WindowSnapshot) {
self.acknowledged_unknown_window = Some(WindowIdentity::from(window_snapshot));
self.acknowledged_unknown_window_handoff_pending = true;
}
fn is_unknown_window_acknowledged(&mut self, window_snapshot: &window::WindowSnapshot) -> bool {
let current = WindowIdentity::from(window_snapshot);
if self.acknowledged_unknown_window.as_ref() == Some(&current) {
return true;
}
if self.acknowledged_unknown_window_handoff_pending
&& is_post_dismissal_app_foreground(window_snapshot)
{
return true;
}
self.acknowledged_unknown_window = None;
self.acknowledged_unknown_window_handoff_pending = false;
false
}
fn timeout(&self) -> Duration {
Duration::from_millis(50).saturating_sub(self.last_tick_50ms.elapsed())
}
fn complete_rating(&mut self) -> Result<()> {
self.ensure_recoverable_review_rating();
let mut session_result = SessionResult {
intention: self.user_intention.clone(),
duration: self.session_start.elapsed(),
session_ratings: self.session_ratings.clone(),
rating: 0,
rating_f64: 0.0,
};
session_result.rate();
if self.session_controller.runtime_state() == RuntimeState::Review {
self.session_controller
.complete_review_and_return_to_planning(
session_result.rating,
session_result.rating_f64,
session_result.session_ratings.len() as u32,
)?;
}
self.state = State::InputIntention;
self.session_ratings.clear();
save_session(&session_result);
self.session_results.push(session_result);
Ok(())
}
fn record_evidence_if_needed(&mut self, health: &EvidenceHealth) -> Result<()> {
if matches!(health, EvidenceHealth::Available) {
self.last_recorded_evidence = None;
return Ok(());
}
let key = EvidenceObservationKey {
runtime_state: self.session_controller.runtime_state(),
commitment_id: self
.session_controller
.active_commitment()
.map(|commitment| commitment.id.clone()),
health: health.clone(),
};
if self.last_recorded_evidence.as_ref() == Some(&key) {
return Ok(());
}
self.session_controller.record_evidence(health.clone())?;
self.last_recorded_evidence = Some(key);
Ok(())
}
fn get_session_results(&self) -> Vec<Line<'_>> {
self.session_results
.iter()
.map(|r| {
Line::from(Span::styled(
format!("{} {}", duration_as_str(&r.duration), r.intention,),
match r.rating {
2 => Style::new().fg(Color::LightYellow),
3 => Style::new().fg(Color::LightGreen),
_ => Style::new().fg(Color::LightRed),
},
))
})
.collect()
}
fn get_session_stats(&self) -> Vec<Line<'_>> {
let mut zero_encountered = if self.state != State::End {
true
} else {
false
};
self.session_ratings
.iter()
.map(|s| {
Line::from(Span::styled(
format!("{}: {}", duration_as_str(&s.duration), s.window_title),
match s.rating {
0 if !zero_encountered => {
zero_encountered = true;
Style::new().fg(Color::LightBlue)
}
0 if zero_encountered => Style::new(),
1 => Style::new().fg(Color::LightRed),
2 => Style::new().fg(Color::LightYellow),
3 => Style::new().fg(Color::LightGreen),
_ => Style::new().fg(Color::LightBlue),
},
))
})
.collect()
}
}
fn duration_as_str(duration: &Duration) -> String {
format!(
"{:3}:{:02}",
duration.as_secs() / 60,
duration.as_secs() % 60
)
}
fn parse_duration_minutes(input: &str) -> Option<Duration> {
let minutes = input.parse::<u64>().ok()?;
if minutes == 0 {
return None;
}
minutes.checked_mul(60).map(Duration::from_secs)
}
fn is_transition_input_state(state: &State) -> bool {
matches!(
state,
State::InputTransitionReason
| State::InputTransitionReturn
| State::InputTransitionDuration
)
}
fn should_prompt_for_unknown_window(
policy: &PolicySnapshot,
window_snapshot: &window::WindowSnapshot,
) -> bool {
let allowed_context = &policy.allowed_context;
let title_policy = !allowed_context.window_title_substrings.is_empty();
let class_policy = !allowed_context.window_classes.is_empty();
if !title_policy && !class_policy {
return false;
}
let title_usable = usable_title_evidence(window_snapshot);
let class_usable = usable_class_evidence(window_snapshot);
if (title_policy && !title_usable) || (class_policy && !class_usable) {
return false;
}
let title_matches =
title_policy && window_title_allowed(allowed_context, &window_snapshot.title);
let class_matches = class_policy
&& window_snapshot
.class
.as_ref()
.is_some_and(|class| window_class_allowed(allowed_context, class));
!(title_matches || class_matches)
}
fn is_post_dismissal_app_foreground(window_snapshot: &window::WindowSnapshot) -> bool {
window_snapshot
.title
.to_lowercase()
.contains(&constants::APP_TITLE.to_lowercase())
}
fn usable_title_evidence(window_snapshot: &window::WindowSnapshot) -> bool {
let title = window_snapshot.title.trim();
!title.is_empty()
&& title != "none"
&& !evidence_unavailable(&window_snapshot.health)
&& !degraded_evidence_mentions(&window_snapshot.health, "title unavailable")
}
fn usable_class_evidence(window_snapshot: &window::WindowSnapshot) -> bool {
window_snapshot
.class
.as_deref()
.is_some_and(|class| !class.trim().is_empty())
&& !evidence_unavailable(&window_snapshot.health)
&& !degraded_evidence_mentions(&window_snapshot.health, "class unavailable")
}
fn evidence_unavailable(health: &EvidenceHealth) -> bool {
matches!(health, EvidenceHealth::Unavailable(_))
}
fn degraded_evidence_mentions(health: &EvidenceHealth, needle: &str) -> bool {
match health {
EvidenceHealth::Degraded(reason) => reason.to_lowercase().contains(needle),
EvidenceHealth::Available | EvidenceHealth::Unavailable(_) => false,
}
}
fn session_stats_as_vec(session_stats: &HashMap<Rc<String>, Duration>) -> Vec<SessionRating> {
let mut stats: Vec<_> = session_stats
.iter()
.filter(|(_, duration)| duration.as_secs() > 30)
.map(|(title, duration)| SessionRating {
window_title: title.clone(),
duration: duration.clone(),
rating: 0,
})
.collect();
stats.sort_by(|a, b| b.duration.cmp(&a.duration));
stats
}
fn recoverable_review_rating() -> SessionRating {
SessionRating {
window_title: Rc::new("Recovered review summary".to_string()),
duration: Duration::from_secs(1),
rating: 0,
}
}
fn main() -> 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()))?;
let result = (|| -> Result<()> {
while app.state != State::ShouldQuit {
terminal.draw(|frame| ui(frame, &app))?;
handle_events(&mut app)?;
app.handle_ticks()?;
}
Ok(())
})();
app.cleanup();
result
}
fn handle_events(app: &mut App) -> Result<()> {
if !event::poll(app.timeout())? {
return Ok(());
}
let Event::Key(key) = event::read()? else {
return Ok(());
};
if key.kind != event::KeyEventKind::Press {
return Ok(());
}
handle_key_press(app, key.code)
}
fn handle_key_press(app: &mut App, key_code: KeyCode) -> Result<()> {
if key_code == KeyCode::Esc && app.state != State::ViolationPrompt {
if app.state == State::End && app.session_controller.runtime_state() == RuntimeState::Review
{
return Ok(());
}
app.state = State::ShouldQuit;
}
match app.state {
State::InputIntention => match key_code {
KeyCode::Enter => app.state = State::InputSuccessCondition,
KeyCode::Tab => app.state = State::InputSuccessCondition,
KeyCode::Backspace => {
let _ = app.user_intention.pop();
}
KeyCode::Char(c) => {
app.user_intention.push(c);
}
_ => {}
},
State::InputSuccessCondition => match key_code {
KeyCode::Enter => app.state = State::InputDuration,
KeyCode::Tab => app.state = State::InputDuration,
KeyCode::Backspace => {
let _ = app.user_success_condition.pop();
}
KeyCode::Char(c) => {
app.user_success_condition.push(c);
}
_ => {}
},
State::InputDuration => match key_code {
KeyCode::Enter => app.to_in_progress()?,
KeyCode::Tab => app.state = State::InputIntention,
KeyCode::Backspace => {
let _ = app.user_duration_str.pop();
}
KeyCode::Char(c) if c.is_ascii_digit() => {
app.user_duration_str.push(c);
}
_ => {}
},
State::InputTransitionReason => match key_code {
KeyCode::Enter => app.state = State::InputTransitionReturn,
KeyCode::Tab => app.state = State::InputTransitionReturn,
KeyCode::Backspace => {
let _ = app.transition_reason.pop();
}
KeyCode::Char(c) => {
app.transition_reason.push(c);
}
_ => {}
},
State::InputTransitionReturn => match key_code {
KeyCode::Enter => app.state = State::InputTransitionDuration,
KeyCode::Tab => app.state = State::InputTransitionDuration,
KeyCode::Backspace => {
let _ = app.transition_return_target.pop();
}
KeyCode::Char(c) => {
app.transition_return_target.push(c);
}
_ => {}
},
State::InputTransitionDuration => match key_code {
KeyCode::Enter => app.start_transition_if_valid()?,
KeyCode::Tab => app.state = State::InputTransitionReason,
KeyCode::Backspace => {
let _ = app.transition_duration_str.pop();
}
KeyCode::Char(c) if c.is_ascii_digit() => {
app.transition_duration_str.push(c);
}
_ => {}
},
State::InProgress => match key_code {
KeyCode::Char('q') => {
app.to_end()?;
}
KeyCode::Char('p') => {
app.pause()?;
}
KeyCode::Char('a') => {
app.user_duration = app.user_duration.saturating_add(Duration::from_secs(60));
}
KeyCode::Char('x') => {
app.user_duration = app.user_duration.saturating_sub(Duration::from_secs(60));
}
_ => {}
},
State::Paused => {
if key_code == KeyCode::Char('p') {
app.resume()?;
}
}
State::ViolationPrompt => match key_code {
KeyCode::Enter => app.dismiss_violation_if_valid()?,
KeyCode::Backspace => {
let _ = app.violation_dismissal_reason.pop();
}
KeyCode::Char(c) => {
app.violation_dismissal_reason.push(c);
}
_ => {}
},
State::ShouldQuit => (),
State::End => {
let code = match key_code {
KeyCode::Char('1') => 1,
KeyCode::Char('2') => 2,
KeyCode::Char('3') => 3,
_ => 0,
};
if app.session_ratings_index < app.session_ratings.len() && code != 0 {
app.session_ratings[app.session_ratings_index].rating = code;
app.session_ratings_index += 1;
}
if app.session_ratings_index >= app.session_ratings.len() {
app.complete_rating()?;
}
}
}
Ok(())
}
fn update_session_stats(app: &mut App) -> Result<window::WindowSnapshot> {
update_session_stats_with_snapshot(app, window::get_snapshot())
}
fn update_session_stats_with_snapshot(
app: &mut App,
window_snapshot: window::WindowSnapshot,
) -> Result<window::WindowSnapshot> {
app.evidence_health = window_snapshot.health.clone();
app.record_evidence_if_needed(&window_snapshot.health)?;
let window_title = if app.state == State::Paused {
constants::PAUSED.to_string()
} else {
window_snapshot.title.clone()
};
let window_title = Rc::new(window_title);
let delta = app.current_window_time.elapsed();
if app.current_window_title != window_title || (delta > Duration::from_secs(1)) {
let entry = app
.session_stats
.entry(app.current_window_title.clone())
.or_insert_with(|| Duration::default());
*entry += app.current_window_time.elapsed();
app.current_window_time = Instant::now();
app.current_window_title = window_title;
app.session_ratings = session_stats_as_vec(&app.session_stats);
}
Ok(window_snapshot)
}
fn ui(frame: &mut Frame, app: &App) {
if app.state == State::ViolationPrompt {
render_violation_prompt(frame, app);
return;
}
let layout = Layout::vertical([
Constraint::Min(3),
Constraint::Min(3),
Constraint::Min(3),
Constraint::Percentage(100),
Constraint::Min(3),
]);
let [layout_intention, layout_success_condition, layout_duration, layout_titles, layout_status] =
layout.areas(frame.size());
let transition_input = is_transition_input_state(&app.state);
let border_type_intention =
if app.state == State::InputIntention || app.state == State::InputTransitionReason {
BorderType::Thick
} else {
BorderType::Plain
};
let input_intention = if transition_input {
Line::from(Span::raw(&app.transition_reason))
} else {
Line::from(Span::raw(&app.user_intention))
};
let intention_title = if transition_input {
constants::TRANSITION_REASON_TITLE
} else {
constants::INTENTION_TITLE
};
frame.render_widget(
Paragraph::new(input_intention).block(
Block::bordered()
.border_type(border_type_intention)
.title(intention_title),
),
layout_intention,
);
let border_type_success_condition =
if app.state == State::InputSuccessCondition || app.state == State::InputTransitionReturn {
BorderType::Thick
} else {
BorderType::Plain
};
let input_success_condition = if transition_input {
Line::from(Span::raw(&app.transition_return_target))
} else {
Line::from(Span::raw(&app.user_success_condition))
};
let success_condition_title = if transition_input {
constants::TRANSITION_RETURN_TITLE
} else {
constants::SUCCESS_CONDITION_TITLE
};
frame.render_widget(
Paragraph::new(input_success_condition).block(
Block::bordered()
.border_type(border_type_success_condition)
.title(success_condition_title),
),
layout_success_condition,
);
let input_duration: Vec<Line> = match app.state {
State::InProgress | State::Paused => {
let s = duration_as_str(&app.session_remaining);
vec![Line::from(Span::raw(s))]
}
State::InputTransitionReason
| State::InputTransitionReturn
| State::InputTransitionDuration => {
vec![Line::from(Span::raw(&app.transition_duration_str))]
}
_ => {
vec![Line::from(Span::raw(&app.user_duration_str))]
}
};
let border_type_duration =
if app.state == State::InputDuration || app.state == State::InputTransitionDuration {
BorderType::Thick
} else {
BorderType::Plain
};
let duration_title = if transition_input {
constants::TRANSITION_DURATION_TITLE
} else {
constants::DURATION_TITLE
};
frame.render_widget(
Paragraph::new(input_duration).block(
Block::bordered()
.border_type(border_type_duration)
.title(duration_title),
),
layout_duration,
);
if matches!(
app.state,
State::InputIntention | State::InputSuccessCondition | State::InputDuration
) {
let results = app.get_session_results();
frame.render_widget(
Paragraph::new(results)
.block(Block::bordered().title(constants::PREVIOUS_SESSIONS_TITLE)),
layout_titles,
);
} else {
let stats = app.get_session_stats();
frame.render_widget(
Paragraph::new(stats).block(Block::bordered().title(constants::SESSION_STATS_TITLE)),
layout_titles,
);
}
let mut spans: Vec<Span> = Vec::new();
if transition_input {
if app.transition_reason.trim().is_empty() {
let span = Span::styled(
constants::PROVIDE_TRANSITION_REASON,
Style::new().fg(Color::LightRed),
);
spans.push(span);
}
if app.transition_return_target.trim().is_empty() {
let span = Span::styled(
constants::PROVIDE_TRANSITION_RETURN,
Style::new().fg(Color::LightRed),
);
spans.push(span);
}
if parse_duration_minutes(&app.transition_duration_str).is_none() {
let span = Span::styled(
constants::PROVIDE_VALID_DURATION,
Style::new().fg(Color::LightRed),
);
spans.push(span);
}
} else if app.user_intention.len() == 0 {
let span = Span::styled(
constants::PROVIDE_INTENTION,
Style::new().fg(Color::LightRed),
);
spans.push(span);
}
if app.user_success_condition.trim().is_empty() {
let span = Span::styled(
constants::ENTER_SUCCESS_CONDITION,
Style::new().fg(Color::LightRed),
);
spans.push(span);
}
if !transition_input && parse_duration_minutes(&app.user_duration_str).is_none() {
let span = Span::styled(
constants::PROVIDE_VALID_DURATION,
Style::new().fg(Color::LightRed),
);
spans.push(span);
}
match app.state {
State::InputIntention | State::InputSuccessCondition | State::InputDuration => {
if spans.len() == 0 {
let span = Span::styled(
constants::READY_TO_START,
Style::new().fg(Color::LightGreen),
);
spans.push(span);
}
}
State::InputTransitionReason
| State::InputTransitionReturn
| State::InputTransitionDuration => {}
State::InProgress => {
let span = Span::styled(
constants::SESSION_IN_PROGRESS,
Style::new().fg(Color::LightGreen),
);
spans.push(span);
}
State::Paused => {
let span = Span::styled(constants::SESSION_PAUSED, Style::new().fg(Color::LightBlue));
spans.push(span);
}
State::ViolationPrompt => {}
State::ShouldQuit => {}
State::End => {
let span = Span::styled(constants::RATE_TITLES, Style::new().fg(Color::LightBlue));
spans.push(span);
}
}
if let Some(text) = evidence_status_text(&app.evidence_health) {
if !spans.is_empty() {
spans.push(Span::raw(" | "));
}
spans.push(Span::styled(
text,
Style::new().fg(evidence_status_color(&app.evidence_health)),
));
}
let input_status: Vec<Line> = vec![Line::from(spans)];
frame.render_widget(
Paragraph::new(input_status).block(Block::bordered().title(constants::STATUS_TITLE)),
layout_status,
);
}
fn evidence_status_text(health: &EvidenceHealth) -> Option<String> {
match health {
EvidenceHealth::Available => None,
EvidenceHealth::Degraded(reason) => Some(format!("Evidence degraded: {}", reason)),
EvidenceHealth::Unavailable(reason) => Some(format!("Evidence unavailable: {}", reason)),
}
}
fn evidence_status_color(health: &EvidenceHealth) -> Color {
match health {
EvidenceHealth::Available => Color::Reset,
EvidenceHealth::Degraded(_) => Color::LightYellow,
EvidenceHealth::Unavailable(_) => Color::LightRed,
}
}
fn render_violation_prompt(frame: &mut Frame, app: &App) {
let layout = Layout::vertical([
Constraint::Min(5),
Constraint::Length(3),
Constraint::Length(3),
]);
let [layout_message, layout_reason, layout_status] = layout.areas(frame.size());
frame.render_widget(
Paragraph::new(Line::from(Span::raw(&app.violation_message))).block(
Block::bordered()
.border_type(BorderType::Thick)
.title(constants::VIOLATION_TITLE),
),
layout_message,
);
frame.render_widget(
Paragraph::new(Line::from(Span::raw(&app.violation_dismissal_reason))).block(
Block::bordered()
.border_type(BorderType::Thick)
.title(constants::VIOLATION_REASON_TITLE),
),
layout_reason,
);
frame.render_widget(
Paragraph::new(Line::from(Span::styled(
constants::VIOLATION_STATUS,
Style::new().fg(Color::LightRed),
)))
.block(Block::bordered().title(constants::STATUS_TITLE)),
layout_status,
);
}
#[cfg(test)]
mod tests {
use super::*;
use antidrift::domain::{AllowedContext, EnforcementLevel, PolicySnapshot, RuntimeState};
use antidrift::event_log::{EventLog, EventType};
use std::fs;
use std::time::{SystemTime, UNIX_EPOCH};
fn unique_event_log_path(test_name: &str) -> std::path::PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
std::env::temp_dir().join(format!(
"antidrift-{test_name}-{}-{}.jsonl",
std::process::id(),
nanos
))
}
#[test]
fn starting_session_requires_success_condition_and_logs_commitment() {
let path = unique_event_log_path("success-condition");
let mut app = App::new_with_event_log_path(&path).unwrap();
app.user_intention = "write tests".to_string();
app.user_duration_str = "1".to_string();
app.to_in_progress().unwrap();
assert_eq!(app.state, State::InputSuccessCondition);
assert_eq!(
app.session_controller.runtime_state(),
RuntimeState::Planning
);
app.user_success_condition = "tests pass".to_string();
app.to_in_progress().unwrap();
assert_eq!(app.state, State::InProgress);
assert_eq!(app.session_controller.runtime_state(), RuntimeState::Active);
assert!(app.session_controller.active_commitment().is_some());
let _ = fs::remove_file(path);
}
#[test]
fn unknown_window_prompt_is_inactive_for_empty_allowlist() {
let policy = PolicySnapshot::for_runtime(
"commitment-test".to_string(),
RuntimeState::Active,
EnforcementLevel::Warn,
AllowedContext::default(),
);
let snapshot = window::WindowSnapshot {
title: "Any Window".to_string(),
class: Some("any-class".to_string()),
health: antidrift::domain::EvidenceHealth::Available,
};
assert!(!should_prompt_for_unknown_window(&policy, &snapshot));
}
#[test]
fn unknown_window_prompt_is_inactive_for_matching_title() {
let policy = PolicySnapshot::for_runtime(
"commitment-test".to_string(),
RuntimeState::Active,
EnforcementLevel::Warn,
AllowedContext {
window_title_substrings: vec!["Editor".to_string()],
..AllowedContext::default()
},
);
let snapshot = window::WindowSnapshot {
title: "Code Editor - antidrift".to_string(),
class: None,
health: antidrift::domain::EvidenceHealth::Available,
};
assert!(!should_prompt_for_unknown_window(&policy, &snapshot));
}
#[test]
fn unknown_window_prompt_uses_shared_title_normalization() {
let policy = PolicySnapshot::for_runtime(
"commitment-test".to_string(),
RuntimeState::Active,
EnforcementLevel::Warn,
AllowedContext {
window_title_substrings: vec![" Editor ".to_string()],
..AllowedContext::default()
},
);
let snapshot = window::WindowSnapshot {
title: "Code Editor - antidrift".to_string(),
class: None,
health: antidrift::domain::EvidenceHealth::Available,
};
assert!(!should_prompt_for_unknown_window(&policy, &snapshot));
}
#[test]
fn unknown_window_prompt_uses_shared_class_normalization() {
let policy = PolicySnapshot::for_runtime(
"commitment-test".to_string(),
RuntimeState::Active,
EnforcementLevel::Warn,
AllowedContext {
window_classes: vec![" Code ".to_string()],
..AllowedContext::default()
},
);
let snapshot = window::WindowSnapshot {
title: "Unrelated title".to_string(),
class: Some(" code ".to_string()),
health: antidrift::domain::EvidenceHealth::Available,
};
assert!(!should_prompt_for_unknown_window(&policy, &snapshot));
}
#[test]
fn unknown_window_prompt_is_active_for_nonmatching_title() {
let policy = PolicySnapshot::for_runtime(
"commitment-test".to_string(),
RuntimeState::Active,
EnforcementLevel::Warn,
AllowedContext {
window_title_substrings: vec!["Editor".to_string()],
..AllowedContext::default()
},
);
let snapshot = window::WindowSnapshot {
title: "Browser - unrelated".to_string(),
class: None,
health: antidrift::domain::EvidenceHealth::Available,
};
assert!(should_prompt_for_unknown_window(&policy, &snapshot));
}
#[test]
fn unknown_window_prompt_ignores_unavailable_title_evidence() {
let policy = PolicySnapshot::for_runtime(
"commitment-test".to_string(),
RuntimeState::Active,
EnforcementLevel::Warn,
AllowedContext {
window_title_substrings: vec!["Editor".to_string()],
..AllowedContext::default()
},
);
let snapshot = window::WindowSnapshot::unavailable("no active window");
assert!(!should_prompt_for_unknown_window(&policy, &snapshot));
}
#[test]
fn unknown_window_prompt_ignores_class_policy_without_class_evidence() {
let policy = PolicySnapshot::for_runtime(
"commitment-test".to_string(),
RuntimeState::Active,
EnforcementLevel::Warn,
AllowedContext {
window_classes: vec!["Code".to_string()],
..AllowedContext::default()
},
);
let snapshot = window::WindowSnapshot {
title: "Browser - unrelated".to_string(),
class: None,
health: antidrift::domain::EvidenceHealth::Degraded(
"window class unavailable on current Windows adapter".to_string(),
),
};
assert!(!should_prompt_for_unknown_window(&policy, &snapshot));
}
#[test]
fn unknown_window_prompt_requires_usable_evidence_for_mixed_policy() {
let policy = PolicySnapshot::for_runtime(
"commitment-test".to_string(),
RuntimeState::Active,
EnforcementLevel::Warn,
AllowedContext {
window_title_substrings: vec!["Editor".to_string()],
window_classes: vec!["Code".to_string()],
..AllowedContext::default()
},
);
let missing_class = window::WindowSnapshot {
title: "Browser - unrelated".to_string(),
class: None,
health: antidrift::domain::EvidenceHealth::Degraded(
"window class unavailable on current Windows adapter".to_string(),
),
};
let proved_unknown = window::WindowSnapshot {
title: "Browser - unrelated".to_string(),
class: Some("firefox".to_string()),
health: antidrift::domain::EvidenceHealth::Available,
};
assert!(!should_prompt_for_unknown_window(&policy, &missing_class));
assert!(should_prompt_for_unknown_window(&policy, &proved_unknown));
}
#[test]
fn acknowledged_unknown_window_is_suppressed_until_window_changes() {
let path = unique_event_log_path("violation-ack-suppression");
let mut app = App::new_with_event_log_path(&path).unwrap();
let unknown = window::WindowSnapshot {
title: "AntiDrift".to_string(),
class: Some("terminal".to_string()),
health: antidrift::domain::EvidenceHealth::Available,
};
let other_unknown = window::WindowSnapshot {
title: "Browser - unrelated".to_string(),
class: Some("firefox".to_string()),
health: antidrift::domain::EvidenceHealth::Available,
};
assert!(!app.is_unknown_window_acknowledged(&unknown));
app.acknowledge_unknown_window(&unknown);
assert!(app.is_unknown_window_acknowledged(&unknown));
assert!(!app.is_unknown_window_acknowledged(&other_unknown));
assert!(!app.is_unknown_window_acknowledged(&unknown));
let _ = fs::remove_file(path);
}
#[test]
fn violation_prompt_ignores_esc() {
let path = unique_event_log_path("violation-prompt-esc");
let mut app = App::new_with_event_log_path(&path).unwrap();
app.state = State::ViolationPrompt;
app.violation_message = "Unknown context detected: Browser - unrelated".to_string();
handle_key_press(&mut app, KeyCode::Esc).unwrap();
assert_eq!(app.state, State::ViolationPrompt);
assert!(app.violation_dismissal_reason.is_empty());
let _ = fs::remove_file(path);
}
#[test]
fn violation_prompt_requires_reason_then_logs_and_returns_to_progress() {
let path = unique_event_log_path("violation-prompt-dismissal");
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.state = State::ViolationPrompt;
app.violation_message = "Unknown context detected: Browser - unrelated".to_string();
app.dismiss_violation_if_valid().unwrap();
assert_eq!(app.state, State::ViolationPrompt);
app.violation_dismissal_reason = "Needed to inspect docs".to_string();
app.dismiss_violation_if_valid().unwrap();
assert_eq!(app.state, State::InProgress);
let records = EventLog::open(&path).unwrap().records().unwrap();
assert!(records
.iter()
.any(|record| record.event_type == EventType::ViolationObserved
&& record.payload_json["reason"] == "Needed to inspect docs"));
let _ = fs::remove_file(path);
}
#[test]
fn violation_dismissal_acknowledges_prompted_window_not_current_foreground() {
let path = unique_event_log_path("violation-prompt-original-ack");
let mut app = App::new_with_event_log_path(&path).unwrap();
let prompted_unknown = window::WindowSnapshot {
title: format!(
"Prompted Unknown Window {}",
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
),
class: Some("prompted-unknown-class".to_string()),
health: antidrift::domain::EvidenceHealth::Available,
};
let dismissal_foreground = window::WindowSnapshot {
title: "AntiDrift Terminal".to_string(),
class: Some("terminal".to_string()),
health: antidrift::domain::EvidenceHealth::Degraded(
"terminal foreground during dismissal".to_string(),
),
};
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.enter_violation_prompt(&prompted_unknown);
app.current_window_title = Rc::new(dismissal_foreground.title.clone());
app.evidence_health = dismissal_foreground.health.clone();
app.violation_dismissal_reason = "Needed to inspect docs".to_string();
app.dismiss_violation_if_valid().unwrap();
assert_eq!(app.state, State::InProgress);
assert!(app.is_unknown_window_acknowledged(&prompted_unknown));
assert!(app.is_unknown_window_acknowledged(&dismissal_foreground));
assert!(app.is_unknown_window_acknowledged(&prompted_unknown));
let records = EventLog::open(&path).unwrap().records().unwrap();
let violation = records
.iter()
.find(|record| record.event_type == EventType::ViolationObserved)
.expect("dismissal should record a violation");
assert_eq!(
violation.payload_json["evidence"]["window_title"],
prompted_unknown.title
);
assert_eq!(
violation.payload_json["evidence"]["message"],
format!(
"{}: {}",
constants::VIOLATION_STATUS,
prompted_unknown.title
)
);
assert_eq!(
violation.payload_json["evidence"]["evidence_health"],
serde_json::json!("available")
);
let _ = fs::remove_file(path);
}
#[test]
fn post_dismissal_app_foreground_does_not_prompt_or_clear_triggering_acknowledgement() {
let path = unique_event_log_path("violation-prompt-app-handoff");
let mut app = App::new_with_event_log_path(&path).unwrap();
let prompted_unknown = window::WindowSnapshot {
title: "Browser - unrelated".to_string(),
class: Some("firefox".to_string()),
health: antidrift::domain::EvidenceHealth::Available,
};
let dismissal_foreground = window::WindowSnapshot {
title: "AntiDrift".to_string(),
class: Some("terminal".to_string()),
health: antidrift::domain::EvidenceHealth::Available,
};
let different_unknown = window::WindowSnapshot {
title: "Chat - unrelated".to_string(),
class: Some("chat".to_string()),
health: antidrift::domain::EvidenceHealth::Available,
};
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();
let commitment_id = app
.session_controller
.active_commitment()
.unwrap()
.id
.clone();
let mut log = EventLog::open(&path).unwrap();
log.append(
EventType::PolicyApplied,
RuntimeState::Active,
Some(commitment_id.clone()),
serde_json::json!({
"schema_version": antidrift::domain::POLICY_SCHEMA_VERSION,
"policy_id": "policy-app-handoff-test",
"commitment_id": commitment_id,
"runtime_state": "active",
"enforcement_level": "warn",
"allowed_context": {
"window_classes": [],
"window_title_substrings": ["Editor"],
"domains": [],
"repos": [],
"commands": [],
},
}),
)
.unwrap();
let mut app = App::new_with_event_log_path(&path).unwrap();
app.enter_violation_prompt(&prompted_unknown);
app.violation_dismissal_reason = "Needed to inspect docs".to_string();
app.dismiss_violation_if_valid().unwrap();
app.tick_active_session_with_snapshot(dismissal_foreground)
.unwrap();
assert_eq!(app.state, State::InProgress);
assert!(app.is_unknown_window_acknowledged(&prompted_unknown));
app.tick_active_session_with_snapshot(different_unknown)
.unwrap();
assert_eq!(app.state, State::ViolationPrompt);
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.session_ratings[0].rating = 2;
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 records = EventLog::open(&path).unwrap().records().unwrap();
let review_completed_index = records
.iter()
.position(|record| record.event_type == EventType::ReviewCompleted)
.expect("review completion must be recorded durably");
let commitment_complete_index = records
.iter()
.position(|record| {
record.event_type == EventType::CommitmentTransition
&& record.payload_json["action"] == "complete"
})
.expect("review completion must complete the commitment");
let planning_transition_index = records
.iter()
.position(|record| {
record.event_type == EventType::RuntimeTransition
&& record.runtime_state == RuntimeState::Planning
&& record.payload_json["action"] == "return_to_planning_after_review"
})
.expect("review must return to planning after completion");
assert_eq!(commitment_complete_index, review_completed_index + 1);
assert_eq!(planning_transition_index, commitment_complete_index + 1);
assert_eq!(
records[review_completed_index].commitment_id,
records[commitment_complete_index].commitment_id
);
assert_eq!(
records[review_completed_index].payload_json["outcome"],
"completed"
);
let _ = fs::remove_file(path);
}
#[test]
fn hydrated_review_session_stays_recoverable_until_rating_completion() {
let path = unique_event_log_path("hydrated-review-cycle");
{
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.session_controller.runtime_state(), RuntimeState::Review);
}
let mut app = App::new_with_event_log_path(&path).unwrap();
assert_eq!(app.state, State::End);
assert_eq!(app.session_controller.runtime_state(), RuntimeState::Review);
assert_eq!(app.session_ratings.len(), 1);
handle_key_press(&mut app, KeyCode::Esc).unwrap();
assert_eq!(app.state, State::End);
assert_eq!(app.session_controller.runtime_state(), RuntimeState::Review);
app.user_intention = "write next test".to_string();
app.user_success_condition = "next test passes".to_string();
app.user_duration_str = "1".to_string();
let err = app
.to_in_progress()
.expect_err("review must be completed before starting another commitment");
assert!(err
.chain()
.any(|cause| cause.to_string().contains("activate runtime")));
app.session_ratings[0].rating = 3;
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);
assert_eq!(
app.session_controller
.active_commitment()
.unwrap()
.next_action,
"write next test"
);
let records = EventLog::open(&path).unwrap().records().unwrap();
let review_completed_index = records
.iter()
.position(|record| record.event_type == EventType::ReviewCompleted)
.expect("review completion must be recorded");
let commitment_complete_index = records
.iter()
.position(|record| {
record.event_type == EventType::CommitmentTransition
&& record.payload_json["action"] == "complete"
})
.expect("review completion must complete the commitment");
let planning_transition_index = records
.iter()
.position(|record| {
record.event_type == EventType::RuntimeTransition
&& record.runtime_state == RuntimeState::Planning
&& record.payload_json["action"] == "return_to_planning_after_review"
})
.expect("review completion must return to planning");
assert_eq!(commitment_complete_index, review_completed_index + 1);
assert_eq!(planning_transition_index, commitment_complete_index + 1);
assert_eq!(
records[review_completed_index].commitment_id,
records[commitment_complete_index].commitment_id
);
assert_eq!(
records[review_completed_index].payload_json["outcome"],
"completed"
);
let _ = fs::remove_file(path);
}
#[test]
fn hydrated_active_session_keeps_elapsed_time() {
let path = unique_event_log_path("hydrated-active-timer");
{
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();
}
std::thread::sleep(Duration::from_millis(1100));
let app = App::new_with_event_log_path(&path).unwrap();
assert_eq!(app.state, State::InProgress);
assert_eq!(app.session_controller.runtime_state(), RuntimeState::Active);
assert!(app.session_start.elapsed() >= Duration::from_secs(1));
assert!(app.session_remaining <= Duration::from_secs(59));
let _ = fs::remove_file(path);
}
#[test]
fn hydrated_active_session_excludes_completed_pause_time() {
let path = unique_event_log_path("hydrated-active-after-pause-timer");
{
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();
std::thread::sleep(Duration::from_millis(1200));
app.pause().unwrap();
app.transition_reason = "stretch".to_string();
app.transition_return_target = "write tests".to_string();
app.transition_duration_str = "2".to_string();
app.start_transition_if_valid().unwrap();
std::thread::sleep(Duration::from_millis(2200));
app.resume().unwrap();
}
let app = App::new_with_event_log_path(&path).unwrap();
assert_eq!(app.state, State::InProgress);
assert_eq!(app.session_controller.runtime_state(), RuntimeState::Active);
assert!(
app.session_remaining >= Duration::from_secs(58),
"completed pause time should not reduce remaining time, got {:?}",
app.session_remaining
);
let _ = fs::remove_file(path);
}
#[test]
fn hydrated_transition_session_freezes_remaining_at_pause_start() {
let path = unique_event_log_path("hydrated-open-pause-timer");
{
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();
std::thread::sleep(Duration::from_millis(1200));
app.pause().unwrap();
app.transition_reason = "stretch".to_string();
app.transition_return_target = "write tests".to_string();
app.transition_duration_str = "2".to_string();
app.start_transition_if_valid().unwrap();
std::thread::sleep(Duration::from_millis(2200));
}
let app = App::new_with_event_log_path(&path).unwrap();
assert_eq!(app.state, State::Paused);
assert_eq!(
app.session_controller.runtime_state(),
RuntimeState::Transition
);
assert!(
app.session_remaining >= Duration::from_secs(58),
"open pause time should not reduce remaining time, got {:?}",
app.session_remaining
);
let _ = fs::remove_file(path);
}
#[test]
fn hydrated_transition_can_resume_end_and_return_to_planning() {
let path = unique_event_log_path("hydrated-transition-resume");
{
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.pause().unwrap();
app.transition_reason = "stretch".to_string();
app.transition_return_target = "write tests".to_string();
app.transition_duration_str = "2".to_string();
app.start_transition_if_valid().unwrap();
}
let mut app = App::new_with_event_log_path(&path).unwrap();
assert_eq!(app.state, State::Paused);
assert_eq!(
app.session_controller.runtime_state(),
RuntimeState::Transition
);
app.resume().unwrap();
assert_eq!(app.state, State::InProgress);
assert_eq!(app.session_controller.runtime_state(), RuntimeState::Active);
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
);
let _ = fs::remove_file(path);
}
#[test]
fn pause_from_in_progress_opens_transition_reason_without_starting_controller_transition() {
let path = unique_event_log_path("transition-prompt");
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.pause().unwrap();
assert_eq!(app.state, State::InputTransitionReason);
assert_eq!(app.session_controller.runtime_state(), RuntimeState::Active);
assert_eq!(app.transition_reason, "");
assert_eq!(app.transition_return_target, "write tests");
assert_eq!(app.transition_duration_str, "2");
let _ = fs::remove_file(path);
}
#[test]
fn valid_transition_inputs_enter_paused_and_start_controller_transition() {
let path = unique_event_log_path("valid-transition-inputs");
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.pause().unwrap();
app.transition_reason = "stretch".to_string();
app.transition_return_target = "write tests".to_string();
app.transition_duration_str = "3".to_string();
app.start_transition_if_valid().unwrap();
assert_eq!(app.state, State::Paused);
assert_eq!(
app.session_controller.runtime_state(),
RuntimeState::Transition
);
let _ = fs::remove_file(path);
}
#[test]
fn missing_transition_reason_or_return_target_does_not_start_transition() {
let path = unique_event_log_path("invalid-transition-inputs");
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.pause().unwrap();
app.transition_reason = "".to_string();
app.transition_return_target = "write tests".to_string();
app.transition_duration_str = "3".to_string();
app.start_transition_if_valid().unwrap();
assert_eq!(app.state, State::InputTransitionReason);
assert_eq!(app.session_controller.runtime_state(), RuntimeState::Active);
app.transition_reason = "stretch".to_string();
app.transition_return_target = "".to_string();
app.start_transition_if_valid().unwrap();
assert_eq!(app.state, State::InputTransitionReturn);
assert_eq!(app.session_controller.runtime_state(), RuntimeState::Active);
let _ = fs::remove_file(path);
}
#[test]
fn parse_duration_minutes_rejects_invalid_and_overflowing_values() {
assert_eq!(parse_duration_minutes(""), None);
assert_eq!(parse_duration_minutes("0"), None);
assert_eq!(parse_duration_minutes("abc"), None);
assert_eq!(
parse_duration_minutes("25"),
Some(Duration::from_secs(25 * 60))
);
assert_eq!(parse_duration_minutes(&u64::MAX.to_string()), None);
}
#[test]
fn transition_prompt_tick_keeps_active_time_and_can_end_session() {
let path = unique_event_log_path("transition-prompt-active-time");
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.pause().unwrap();
app.session_start = Instant::now().checked_sub(Duration::from_secs(61)).unwrap();
app.tick_50ms().unwrap();
assert_eq!(app.state, State::End);
assert_eq!(app.session_remaining, Duration::ZERO);
assert_eq!(app.session_controller.runtime_state(), RuntimeState::Review);
let _ = fs::remove_file(path);
}
#[test]
fn transition_prompt_status_shows_active_countdown() {
let path = unique_event_log_path("transition-prompt-status");
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.pause().unwrap();
assert_eq!(
app.status_text(),
format!(
"🎯 write tests - {}",
duration_as_str(&app.session_remaining)
)
);
let _ = fs::remove_file(path);
}
#[test]
fn evidence_status_text_describes_degraded_and_unavailable_health() {
assert_eq!(evidence_status_text(&EvidenceHealth::Available), None);
assert_eq!(
evidence_status_text(&EvidenceHealth::Degraded(
"window class unavailable".to_string(),
)),
Some("Evidence degraded: window class unavailable".to_string())
);
assert_eq!(
evidence_status_text(&EvidenceHealth::Unavailable(
"xdotool active window unavailable".to_string(),
)),
Some("Evidence unavailable: xdotool active window unavailable".to_string())
);
}
#[test]
fn active_ticks_record_degraded_evidence_once_until_health_changes() {
let path = unique_event_log_path("evidence-dedup");
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();
let degraded = window::WindowSnapshot {
title: "Editor".to_string(),
class: None,
health: EvidenceHealth::Degraded("window class unavailable".to_string()),
};
app.tick_active_session_with_snapshot(degraded.clone())
.unwrap();
app.tick_active_session_with_snapshot(degraded).unwrap();
let records = EventLog::open(&path).unwrap().records().unwrap();
assert_eq!(
records
.iter()
.filter(|record| record.event_type == EventType::EvidenceObserved)
.count(),
1
);
let unavailable = window::WindowSnapshot::unavailable("xdotool active window unavailable");
app.tick_active_session_with_snapshot(unavailable).unwrap();
let records = EventLog::open(&path).unwrap().records().unwrap();
assert_eq!(
records
.iter()
.filter(|record| record.event_type == EventType::EvidenceObserved)
.count(),
2
);
assert_eq!(
app.evidence_health,
EvidenceHealth::Unavailable("xdotool active window unavailable".to_string())
);
let _ = fs::remove_file(path);
}
}