diff --git a/src/main.rs b/src/main.rs index 5de44e6..41973c2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -589,21 +589,57 @@ fn should_prompt_for_unknown_window( window_snapshot: &window::WindowSnapshot, ) -> bool { let allowed_context = &policy.allowed_context; - if allowed_context.window_title_substrings.is_empty() - && allowed_context.window_classes.is_empty() - { + 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_matches = window_title_allowed(allowed_context, &window_snapshot.title); - let class_matches = window_snapshot - .class - .as_ref() - .is_some_and(|class| window_class_allowed(allowed_context, class)); + 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 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, Duration>) -> Vec { let mut stats: Vec<_> = session_stats .iter() @@ -662,12 +698,16 @@ fn handle_events(app: &mut App) -> Result<()> { return Ok(()); } - if key.code == KeyCode::Esc { + 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 { app.state = State::ShouldQuit; } match app.state { - State::InputIntention => match key.code { + State::InputIntention => match key_code { KeyCode::Enter => app.state = State::InputSuccessCondition, KeyCode::Tab => app.state = State::InputSuccessCondition, KeyCode::Backspace => { @@ -678,7 +718,7 @@ fn handle_events(app: &mut App) -> Result<()> { } _ => {} }, - State::InputSuccessCondition => match key.code { + State::InputSuccessCondition => match key_code { KeyCode::Enter => app.state = State::InputDuration, KeyCode::Tab => app.state = State::InputDuration, KeyCode::Backspace => { @@ -689,7 +729,7 @@ fn handle_events(app: &mut App) -> Result<()> { } _ => {} }, - State::InputDuration => match key.code { + State::InputDuration => match key_code { KeyCode::Enter => app.to_in_progress()?, KeyCode::Tab => app.state = State::InputIntention, KeyCode::Backspace => { @@ -700,7 +740,7 @@ fn handle_events(app: &mut App) -> Result<()> { } _ => {} }, - State::InputTransitionReason => match key.code { + State::InputTransitionReason => match key_code { KeyCode::Enter => app.state = State::InputTransitionReturn, KeyCode::Tab => app.state = State::InputTransitionReturn, KeyCode::Backspace => { @@ -711,7 +751,7 @@ fn handle_events(app: &mut App) -> Result<()> { } _ => {} }, - State::InputTransitionReturn => match key.code { + State::InputTransitionReturn => match key_code { KeyCode::Enter => app.state = State::InputTransitionDuration, KeyCode::Tab => app.state = State::InputTransitionDuration, KeyCode::Backspace => { @@ -722,7 +762,7 @@ fn handle_events(app: &mut App) -> Result<()> { } _ => {} }, - State::InputTransitionDuration => match key.code { + State::InputTransitionDuration => match key_code { KeyCode::Enter => app.start_transition_if_valid()?, KeyCode::Tab => app.state = State::InputTransitionReason, KeyCode::Backspace => { @@ -733,7 +773,7 @@ fn handle_events(app: &mut App) -> Result<()> { } _ => {} }, - State::InProgress => match key.code { + State::InProgress => match key_code { KeyCode::Char('q') => { app.to_end()?; } @@ -749,11 +789,11 @@ fn handle_events(app: &mut App) -> Result<()> { _ => {} }, State::Paused => { - if key.code == KeyCode::Char('p') { + if key_code == KeyCode::Char('p') { app.resume()?; } } - State::ViolationPrompt => match key.code { + State::ViolationPrompt => match key_code { KeyCode::Enter => app.dismiss_violation_if_valid()?, KeyCode::Backspace => { let _ = app.violation_dismissal_reason.pop(); @@ -765,7 +805,7 @@ fn handle_events(app: &mut App) -> Result<()> { }, State::ShouldQuit => (), State::End => { - let code = match key.code { + let code = match key_code { KeyCode::Char('1') => 1, KeyCode::Char('2') => 2, KeyCode::Char('3') => 3, @@ -1197,6 +1237,73 @@ mod tests { 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"); @@ -1222,6 +1329,21 @@ mod tests { 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");