Purge leftover Rust implementation

Remove the legacy/ Rust tree (Cargo manifests, .rs sources, desktop
entry), drop Rust-specific .gitignore blocks and README note, and strip
now-dangling "ported from Rust" comments from the Go sources.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-04 09:06:14 -04:00
parent 91a3fb29c2
commit 9012d5ddc6
19 changed files with 8 additions and 8591 deletions
-10
View File
@@ -1,13 +1,3 @@
# Generated by Cargo will have compiled files and executables
debug/
target/
# These are backup files generated by rustfmt
**/*.rs.bk
# MSVC Windows builds of rustc generate these, which store debugging information
*.pdb
# Local brainstorming companion artifacts # Local brainstorming companion artifacts
.superpowers/ .superpowers/
+1 -2
View File
@@ -3,8 +3,7 @@
A personal focus operating system: treat each work session as an explicit A personal focus operating system: treat each work session as an explicit
commitment (next action, success condition, timebox), and make drift visible. commitment (next action, success condition, timebox), and make drift visible.
This is the Go reimagining. The original Rust implementation is preserved under See `docs/superpowers/specs/` for the design.
`legacy/` for reference. See `docs/superpowers/specs/` for the design.
## Run ## Run
+2 -2
View File
@@ -1,5 +1,5 @@
// Package domain holds the core commitment types and validation, ported from // Package domain holds the core commitment types and validation. These are
// the original Rust implementation. These are pure data types with no I/O. // pure data types with no I/O.
package domain package domain
import ( import (
+2 -2
View File
@@ -30,8 +30,8 @@ type Source interface {
Watch(ctx context.Context, onChange func(WindowSnapshot)) Watch(ctx context.Context, onChange func(WindowSnapshot))
} }
// titleNoise matches the legacy clock/ratio/percent runs that pollute bucket // titleNoise matches the clock/ratio/percent runs that pollute bucket
// keys (e.g. "1:23", "50.5%", "-3.0"). Ported verbatim from the Rust impl. // keys (e.g. "1:23", "50.5%", "-3.0").
var titleNoise = regexp.MustCompile(`-?\d+([:.]\d+)+%?`) var titleNoise = regexp.MustCompile(`-?\d+([:.]\d+)+%?`)
// extraSpace collapses the whitespace runs left behind by the scrubs below. // extraSpace collapses the whitespace runs left behind by the scrubs below.
+3 -3
View File
@@ -1,5 +1,5 @@
// Package statemachine holds the pure runtime and commitment transition // Package statemachine holds the pure runtime and commitment transition
// functions ported from the Rust implementation. No I/O, no shared state. // functions. No I/O, no shared state.
package statemachine package statemachine
import ( import (
@@ -9,8 +9,8 @@ import (
) )
// RuntimeAction enumerates runtime transitions. Activate's policy acceptance // RuntimeAction enumerates runtime transitions. Activate's policy acceptance
// and admin-override exit targets from the Rust enum are flattened into // and admin-override exit targets are flattened into distinct actions so the
// distinct actions so the action type stays a simple value. // action type stays a simple value.
type RuntimeAction string type RuntimeAction string
const ( const (
-1102
View File
File diff suppressed because it is too large Load Diff
-19
View File
@@ -1,19 +0,0 @@
[package]
name = "antidrift"
version = "0.1.0"
edition = "2021"
[dependencies]
anyhow = "1.0.86"
fs2 = "0.4"
hex = "0.4.3"
ratatui = "0.27.0"
regex = "1.10.5"
shellexpand = "3.1.0"
serde = { version = "1.0", features = ["derive", "rc"] }
serde_json = "1.0"
sha2 = "0.10.8"
uuid = { version = "1", features = ["v7"] }
[target.'cfg(windows)'.dependencies]
winapi = { version = "0.3", features = ["winuser", "processthreadsapi", "windef"] }
-6
View File
@@ -1,6 +0,0 @@
[Desktop Entry]
Name=AntiDrift
Exec=antidrift
Terminal=false
Type=Application
StartupNotify=false
-36
View File
@@ -1,36 +0,0 @@
pub const APP_TITLE: &str = "AntiDrift";
pub const DEFAULT_DURATION: &str = "25";
pub const DURATION_TITLE: &str = "Duration";
#[allow(dead_code)]
pub const DEGRADED_EVIDENCE_TITLE: &str = "Evidence";
pub const ENTER_SUCCESS_CONDITION: &str = "Provide success condition! ";
pub const EVENT_LOG_FILE: &str = "~/.antidrift_events.jsonl";
pub const INTENTION_TITLE: &str = "Intention";
pub const PAUSED: &str = "paused";
pub const PREVIOUS_SESSIONS_TITLE: &str = "Previous Sessions";
pub const PROVIDE_INTENTION: &str = "Provide intention! ";
pub const PROVIDE_VALID_DURATION: &str = "Provide valid duration in minutes! ";
pub const RATE_TITLES: &str = "Press 1, 2, 3 to rate titles!";
pub const READY_TO_START: &str = "Ready to start next session.";
pub const SESSION_IN_PROGRESS: &str = "Session In-Progress";
pub const SESSION_PAUSED: &str = "Session is paused. Unpause with 'p'.";
pub const SESSION_STATS_TITLE: &str = "Session Stats";
pub const STATUS_FILE: &str = "~/.antidrift_status";
pub const HISTORY_FILE: &str = "~/.antidrift_history.jsonl";
pub const STATUS_TITLE: &str = "Status";
pub const STATUS_CONFIGURE: &str = "🔄 antidrift configure session";
pub const STATUS_LOCKED: &str = "🔒 antidrift locked";
pub const STATUS_PLANNING: &str = "🧭 antidrift planning";
pub const STATUS_PAUSED: &str = "⏸ antidrift paused";
pub const STATUS_RATE_SESSION: &str = "☑ rate antidrift session!";
pub const STATUS_QUIT: &str = "antidrift shutting down";
pub const STATUS_TRANSITION: &str = "↔ antidrift transition";
pub const SUCCESS_CONDITION_TITLE: &str = "Success Condition";
pub const TRANSITION_DURATION_TITLE: &str = "Transition Minutes";
pub const TRANSITION_REASON_TITLE: &str = "Transition Reason";
pub const TRANSITION_RETURN_TITLE: &str = "Return Target";
pub const VIOLATION_TITLE: &str = "Violation";
pub const VIOLATION_REASON_TITLE: &str = "Dismissal Reason";
pub const VIOLATION_STATUS: &str = "Unknown context detected. Enter a reason to continue.";
pub const PROVIDE_TRANSITION_REASON: &str = "Provide transition reason! ";
pub const PROVIDE_TRANSITION_RETURN: &str = "Provide return target! ";
-156
View File
@@ -1,156 +0,0 @@
use crate::domain::AllowedContext;
pub fn domain_allowed(context: &AllowedContext, candidate: &str) -> bool {
let candidate = normalize_domain(candidate);
if candidate.is_empty() {
return false;
}
context.domains.iter().any(|allowed| {
let allowed = normalize_domain(allowed);
!allowed.is_empty()
&& (candidate == allowed
|| candidate
.strip_suffix(&allowed)
.is_some_and(|prefix| prefix.ends_with('.')))
})
}
pub fn window_class_allowed(context: &AllowedContext, candidate: &str) -> bool {
let candidate = normalize_casefolded(candidate);
if candidate.is_empty() {
return false;
}
context
.window_classes
.iter()
.any(|allowed| normalize_casefolded(allowed) == candidate)
}
pub fn window_title_allowed(context: &AllowedContext, candidate: &str) -> bool {
let candidate = normalize_casefolded(candidate);
if candidate.is_empty() {
return false;
}
context.window_title_substrings.iter().any(|allowed| {
let allowed = normalize_casefolded(allowed);
!allowed.is_empty() && candidate.contains(&allowed)
})
}
pub fn command_allowed(context: &AllowedContext, candidate: &str) -> bool {
let candidate = executable_basename(candidate);
if candidate.is_empty() {
return false;
}
context
.commands
.iter()
.any(|allowed| executable_basename(allowed) == candidate)
}
fn normalize_domain(value: &str) -> String {
value.trim().trim_end_matches('.').to_lowercase()
}
fn normalize_casefolded(value: &str) -> String {
value.trim().to_lowercase()
}
fn executable_basename(command: &str) -> String {
let executable = command.split_whitespace().next().unwrap_or_default();
executable
.trim()
.rsplit(['/', '\\'])
.next()
.unwrap_or_default()
.to_lowercase()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::AllowedContext;
#[test]
fn domains_match_exact_or_subdomain() {
let context = AllowedContext {
domains: vec!["github.com".to_string()],
..AllowedContext::default()
};
assert!(domain_allowed(&context, "github.com"));
assert!(domain_allowed(&context, "docs.github.com"));
assert!(!domain_allowed(&context, "evilgithub.com"));
}
#[test]
fn window_classes_match_case_insensitively_after_normalization() {
let context = AllowedContext {
window_classes: vec!["code".to_string()],
..AllowedContext::default()
};
assert!(window_class_allowed(&context, "Code"));
assert!(!window_class_allowed(&context, "firefox"));
}
#[test]
fn titles_match_by_substring() {
let context = AllowedContext {
window_title_substrings: vec!["antidrift".to_string()],
..AllowedContext::default()
};
assert!(window_title_allowed(&context, "Commitment OS - AntiDrift"));
assert!(!window_title_allowed(&context, "random video"));
}
#[test]
fn domains_ignore_whitespace_case_and_trailing_dots() {
let context = AllowedContext {
domains: vec![" GitHub.COM. ".to_string()],
..AllowedContext::default()
};
assert!(domain_allowed(&context, " DOCS.GITHUB.COM. "));
}
#[test]
fn window_class_matching_trims_allowed_and_candidate_values() {
let context = AllowedContext {
window_classes: vec![" code ".to_string()],
..AllowedContext::default()
};
assert!(window_class_allowed(&context, " Code "));
}
#[test]
fn title_matching_trims_allowed_substrings() {
let context = AllowedContext {
window_title_substrings: vec![" antidrift ".to_string()],
..AllowedContext::default()
};
assert!(window_title_allowed(&context, "Commitment OS - AntiDrift"));
}
#[test]
fn commands_match_executable_basename() {
let context = AllowedContext {
commands: vec!["cargo".to_string()],
..AllowedContext::default()
};
assert!(command_allowed(
&context,
"/home/felixm/.cargo/bin/cargo test"
));
assert!(command_allowed(&context, "cargo"));
assert!(!command_allowed(&context, "/usr/bin/git status"));
}
}
-515
View File
@@ -1,515 +0,0 @@
use serde::{Deserialize, Serialize};
use std::fmt;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use uuid::Uuid;
pub const POLICY_SCHEMA_VERSION: u16 = 1;
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CommitmentSource {
Manual,
Planner,
Recurring,
Recovery,
Template,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CommitmentState {
Draft,
Active,
Paused,
Completed,
Abandoned,
Violated,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RuntimeState {
Locked,
Planning,
Active,
Transition,
Review,
AdminOverride,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EnforcementLevel {
Observe,
Warn,
Block,
Locked,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EvidenceHealth {
Available,
Degraded(String),
Unavailable(String),
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct AllowedContext {
pub window_classes: Vec<String>,
pub window_title_substrings: Vec<String>,
pub domains: Vec<String>,
pub repos: Vec<String>,
pub commands: Vec<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Commitment {
pub id: String,
pub created_at_unix_secs: u64,
pub source: CommitmentSource,
pub project_id: Option<String>,
pub template_id: Option<String>,
pub next_action: String,
pub success_condition: String,
pub timebox_secs: u64,
pub transition_policy_id: Option<String>,
pub state: CommitmentState,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PolicySnapshot {
pub id: String,
pub commitment_id: String,
pub schema_version: u16,
pub created_at_unix_secs: u64,
pub runtime_state: RuntimeState,
pub enforcement_level: EnforcementLevel,
pub allowed_context: AllowedContext,
pub required_monitors: Vec<String>,
pub violation_actions: Vec<String>,
pub expires_at_unix_secs: Option<u64>,
pub generated_by_agent_version: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum CommitmentValidationError {
MissingId,
MissingNextAction,
MissingSuccessCondition,
MissingTimebox,
}
impl fmt::Display for CommitmentValidationError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
CommitmentValidationError::MissingId => write!(f, "commitment id is required"),
CommitmentValidationError::MissingNextAction => write!(f, "next action is required"),
CommitmentValidationError::MissingSuccessCondition => {
write!(f, "success condition is required")
}
CommitmentValidationError::MissingTimebox => write!(f, "timebox must be nonzero"),
}
}
}
impl std::error::Error for CommitmentValidationError {}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum PolicySnapshotValidationError {
WrongSchemaVersion,
MissingId,
MissingCommitmentId,
MissingGeneratedByAgentVersion,
}
impl fmt::Display for PolicySnapshotValidationError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
PolicySnapshotValidationError::WrongSchemaVersion => {
write!(f, "policy schema version is unsupported")
}
PolicySnapshotValidationError::MissingId => write!(f, "policy id is required"),
PolicySnapshotValidationError::MissingCommitmentId => {
write!(f, "commitment id is required")
}
PolicySnapshotValidationError::MissingGeneratedByAgentVersion => {
write!(f, "generated agent version is required")
}
}
}
}
impl std::error::Error for PolicySnapshotValidationError {}
impl Commitment {
pub fn new_manual(
next_action: impl Into<String>,
success_condition: impl Into<String>,
timebox: Duration,
) -> Result<Self, CommitmentValidationError> {
let next_action = next_action.into().trim().to_string();
let success_condition = success_condition.into().trim().to_string();
if timebox.as_secs() == 0 {
return Err(CommitmentValidationError::MissingTimebox);
}
let created_at_unix_secs = unix_secs_now();
let commitment = Self {
id: unique_id("commitment"),
created_at_unix_secs,
source: CommitmentSource::Manual,
project_id: None,
template_id: None,
next_action,
success_condition,
timebox_secs: timebox.as_secs(),
transition_policy_id: None,
state: CommitmentState::Draft,
};
commitment.validate()?;
Ok(commitment)
}
pub fn validate(&self) -> Result<(), CommitmentValidationError> {
if self.id.trim().is_empty() {
return Err(CommitmentValidationError::MissingId);
}
if self.next_action.trim().is_empty() {
return Err(CommitmentValidationError::MissingNextAction);
}
if self.success_condition.trim().is_empty() {
return Err(CommitmentValidationError::MissingSuccessCondition);
}
if self.timebox_secs == 0 {
return Err(CommitmentValidationError::MissingTimebox);
}
Ok(())
}
}
impl PolicySnapshot {
pub fn for_runtime(
commitment_id: String,
runtime_state: RuntimeState,
enforcement_level: EnforcementLevel,
allowed_context: AllowedContext,
) -> Self {
Self::try_for_runtime(
commitment_id,
runtime_state,
enforcement_level,
allowed_context,
)
.expect("runtime policy snapshot requires a non-empty commitment id")
}
pub fn try_for_runtime(
commitment_id: String,
runtime_state: RuntimeState,
enforcement_level: EnforcementLevel,
allowed_context: AllowedContext,
) -> Result<Self, PolicySnapshotValidationError> {
let commitment_id = commitment_id.trim().to_string();
if commitment_id.is_empty() {
return Err(PolicySnapshotValidationError::MissingCommitmentId);
}
let created_at_unix_secs = unix_secs_now();
let policy = Self {
id: unique_id("policy"),
commitment_id,
schema_version: POLICY_SCHEMA_VERSION,
created_at_unix_secs,
runtime_state,
enforcement_level,
allowed_context,
required_monitors: Vec::new(),
violation_actions: Vec::new(),
expires_at_unix_secs: None,
generated_by_agent_version: env!("CARGO_PKG_VERSION").to_string(),
};
policy.validate()?;
Ok(policy)
}
pub fn validate(&self) -> Result<(), PolicySnapshotValidationError> {
if self.schema_version != POLICY_SCHEMA_VERSION {
return Err(PolicySnapshotValidationError::WrongSchemaVersion);
}
if self.id.trim().is_empty() {
return Err(PolicySnapshotValidationError::MissingId);
}
if self.commitment_id.trim().is_empty() {
return Err(PolicySnapshotValidationError::MissingCommitmentId);
}
if self.generated_by_agent_version.trim().is_empty() {
return Err(PolicySnapshotValidationError::MissingGeneratedByAgentVersion);
}
Ok(())
}
}
pub fn unix_secs_now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
fn unique_id(prefix: &str) -> String {
format!("{prefix}-{}", Uuid::now_v7())
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
#[test]
fn commitment_requires_concrete_action_and_success_condition() {
let err = Commitment::new_manual("", "tests pass", Duration::from_secs(1500))
.expect_err("empty next action must be rejected");
assert_eq!(err, CommitmentValidationError::MissingNextAction);
let err = Commitment::new_manual("Refactor state", "", Duration::from_secs(1500))
.expect_err("empty success condition must be rejected");
assert_eq!(err, CommitmentValidationError::MissingSuccessCondition);
}
#[test]
fn commitment_requires_nonzero_timebox() {
let err = Commitment::new_manual("Refactor state", "tests pass", Duration::ZERO)
.expect_err("zero timebox must be rejected");
assert_eq!(err, CommitmentValidationError::MissingTimebox);
}
#[test]
fn policy_snapshot_carries_runtime_contract() {
let commitment = Commitment::new_manual(
"Implement domain types",
"domain tests pass",
Duration::from_secs(1500),
)
.unwrap();
let policy = PolicySnapshot::for_runtime(
commitment.id.clone(),
RuntimeState::Active,
EnforcementLevel::Warn,
AllowedContext::default(),
);
assert_eq!(policy.commitment_id, commitment.id);
assert_eq!(policy.runtime_state, RuntimeState::Active);
assert_eq!(policy.enforcement_level, EnforcementLevel::Warn);
assert_eq!(policy.schema_version, 1);
}
#[test]
fn commitment_ids_are_unique_for_rapid_consecutive_construction() {
let first = Commitment::new_manual(
"Implement first action",
"first tests pass",
Duration::from_secs(1500),
)
.unwrap();
let second = Commitment::new_manual(
"Implement second action",
"second tests pass",
Duration::from_secs(1500),
)
.unwrap();
assert_ne!(first.id, second.id);
}
#[test]
fn policy_snapshot_ids_are_unique_for_rapid_consecutive_construction() {
let first = PolicySnapshot::for_runtime(
"commitment-1".to_string(),
RuntimeState::Active,
EnforcementLevel::Warn,
AllowedContext::default(),
);
let second = PolicySnapshot::for_runtime(
"commitment-1".to_string(),
RuntimeState::Active,
EnforcementLevel::Warn,
AllowedContext::default(),
);
assert_ne!(first.id, second.id);
}
#[test]
fn commitment_serializes_enum_contract_as_snake_case() {
let commitment = Commitment::new_manual(
"Implement domain contracts",
"domain tests pass",
Duration::from_secs(1500),
)
.unwrap();
let json = serde_json::to_string(&commitment).unwrap();
assert!(json.contains(r#""source":"manual""#));
assert!(json.contains(r#""state":"draft""#));
}
#[test]
fn policy_snapshot_serializes_enum_contract_as_snake_case() {
let policy = PolicySnapshot::for_runtime(
"commitment-1".to_string(),
RuntimeState::Active,
EnforcementLevel::Warn,
AllowedContext::default(),
);
let json = serde_json::to_string(&policy).unwrap();
assert!(json.contains(r#""runtime_state":"active""#));
assert!(json.contains(r#""enforcement_level":"warn""#));
}
#[test]
fn commitment_validate_rejects_invalid_public_field_values() {
let mut commitment =
Commitment::new_manual("Refactor state", "tests pass", Duration::from_secs(1500))
.unwrap();
commitment.id = " ".to_string();
assert_eq!(
commitment.validate(),
Err(CommitmentValidationError::MissingId)
);
commitment.id = "commitment-1".to_string();
commitment.next_action = " ".to_string();
assert_eq!(
commitment.validate(),
Err(CommitmentValidationError::MissingNextAction)
);
commitment.next_action = "Refactor state".to_string();
commitment.success_condition = " ".to_string();
assert_eq!(
commitment.validate(),
Err(CommitmentValidationError::MissingSuccessCondition)
);
commitment.success_condition = "tests pass".to_string();
commitment.timebox_secs = 0;
assert_eq!(
commitment.validate(),
Err(CommitmentValidationError::MissingTimebox)
);
}
#[test]
fn commitment_validate_rejects_invalid_deserialized_values() {
let json = r#"{
"id": "",
"created_at_unix_secs": 1,
"source": "manual",
"project_id": null,
"template_id": null,
"next_action": "Refactor state",
"success_condition": "tests pass",
"timebox_secs": 1500,
"transition_policy_id": null,
"state": "draft"
}"#;
let commitment: Commitment = serde_json::from_str(json).unwrap();
assert_eq!(
commitment.validate(),
Err(CommitmentValidationError::MissingId)
);
}
#[test]
fn validation_errors_implement_std_error() {
fn assert_std_error<E: std::error::Error>() {}
assert_std_error::<CommitmentValidationError>();
assert_std_error::<PolicySnapshotValidationError>();
}
#[test]
fn policy_snapshot_try_for_runtime_rejects_empty_commitment_id() {
let err = PolicySnapshot::try_for_runtime(
" ".to_string(),
RuntimeState::Active,
EnforcementLevel::Warn,
AllowedContext::default(),
)
.expect_err("empty commitment id must be rejected");
assert_eq!(err, PolicySnapshotValidationError::MissingCommitmentId);
}
#[test]
fn policy_snapshot_validate_rejects_invalid_public_field_values() {
let mut policy = PolicySnapshot::for_runtime(
"commitment-1".to_string(),
RuntimeState::Active,
EnforcementLevel::Warn,
AllowedContext::default(),
);
policy.schema_version = POLICY_SCHEMA_VERSION + 1;
assert_eq!(
policy.validate(),
Err(PolicySnapshotValidationError::WrongSchemaVersion)
);
policy.schema_version = POLICY_SCHEMA_VERSION;
policy.id = " ".to_string();
assert_eq!(
policy.validate(),
Err(PolicySnapshotValidationError::MissingId)
);
policy.id = "policy-1".to_string();
policy.commitment_id = " ".to_string();
assert_eq!(
policy.validate(),
Err(PolicySnapshotValidationError::MissingCommitmentId)
);
policy.commitment_id = "commitment-1".to_string();
policy.generated_by_agent_version = " ".to_string();
assert_eq!(
policy.validate(),
Err(PolicySnapshotValidationError::MissingGeneratedByAgentVersion)
);
}
#[test]
fn policy_snapshot_validate_rejects_invalid_deserialized_values() {
let json = r#"{
"id": "",
"commitment_id": "commitment-1",
"schema_version": 1,
"created_at_unix_secs": 1,
"runtime_state": "active",
"enforcement_level": "warn",
"allowed_context": {
"window_classes": [],
"window_title_substrings": [],
"domains": [],
"repos": [],
"commands": []
},
"required_monitors": [],
"violation_actions": [],
"expires_at_unix_secs": null,
"generated_by_agent_version": "0.1.0"
}"#;
let policy: PolicySnapshot = serde_json::from_str(json).unwrap();
assert_eq!(
policy.validate(),
Err(PolicySnapshotValidationError::MissingId)
);
}
}
-583
View File
@@ -1,583 +0,0 @@
use crate::domain::{unix_secs_now, RuntimeState, POLICY_SCHEMA_VERSION};
use anyhow::{anyhow, Context, Result};
use fs2::FileExt;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use sha2::{Digest, Sha256};
use std::fs::{self, File, OpenOptions};
use std::io::{BufRead, BufReader, Seek, SeekFrom, Write};
use std::path::{Path, PathBuf};
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EventType {
CommitmentCreated,
RuntimeTransition,
CommitmentTransition,
PolicyApplied,
EvidenceObserved,
ViolationObserved,
TransitionStarted,
ReviewCompleted,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct EventRecord {
pub schema_version: u16,
pub sequence: u64,
pub timestamp_unix_secs: u64,
pub event_type: EventType,
pub commitment_id: Option<String>,
pub runtime_state: RuntimeState,
pub payload_json: Value,
pub previous_hash: Option<String>,
pub hash: String,
}
#[derive(Clone, Debug, PartialEq)]
pub struct PendingEventRecord {
pub event_type: EventType,
pub runtime_state: RuntimeState,
pub commitment_id: Option<String>,
pub payload_json: Value,
}
#[derive(Debug)]
pub struct EventLog {
path: PathBuf,
next_sequence: u64,
previous_hash: Option<String>,
needs_parent_sync_after_append: bool,
}
impl EventLog {
pub fn open(path: impl AsRef<Path>) -> Result<Self> {
let path = path.as_ref().to_path_buf();
create_parent_dirs(&path)?;
let (mut file, created) = open_log_file(&path)?;
file.lock_shared()
.with_context(|| format!("lock event log for read {}", path.display()))?;
let (next_sequence, previous_hash) = load_tail_locked(&mut file, &path)?;
Ok(Self {
path,
next_sequence,
previous_hash,
needs_parent_sync_after_append: created,
})
}
pub fn append(
&mut self,
event_type: EventType,
runtime_state: RuntimeState,
commitment_id: Option<String>,
payload_json: Value,
) -> Result<EventRecord> {
let mut records = self.append_batch([PendingEventRecord {
event_type,
runtime_state,
commitment_id,
payload_json,
}])?;
records
.pop()
.ok_or_else(|| anyhow!("single event append produced no record"))
}
pub fn append_batch(
&mut self,
events: impl IntoIterator<Item = PendingEventRecord>,
) -> Result<Vec<EventRecord>> {
let events: Vec<PendingEventRecord> = events.into_iter().collect();
if events.is_empty() {
return Ok(Vec::new());
}
create_parent_dirs(&self.path)?;
let existed = self
.path
.try_exists()
.with_context(|| format!("check whether event log exists {}", self.path.display()))?;
let mut file = OpenOptions::new()
.create(true)
.read(true)
.append(true)
.open(&self.path)
.with_context(|| format!("open event log for append {}", self.path.display()))?;
file.lock_exclusive()
.with_context(|| format!("lock event log {}", self.path.display()))?;
let (next_sequence, previous_hash) = load_tail_locked(&mut file, &self.path)?;
let mut records = Vec::with_capacity(events.len());
let mut sequence = next_sequence;
let mut record_previous_hash = previous_hash;
for event in events {
let mut record = EventRecord {
schema_version: POLICY_SCHEMA_VERSION,
sequence,
timestamp_unix_secs: unix_secs_now(),
event_type: event.event_type,
commitment_id: event.commitment_id,
runtime_state: event.runtime_state,
payload_json: event.payload_json,
previous_hash: record_previous_hash,
hash: String::new(),
};
record.hash = record_hash(&record)?;
record_previous_hash = Some(record.hash.clone());
sequence += 1;
records.push(record);
}
let mut bytes = Vec::new();
for record in &records {
serde_json::to_writer(&mut bytes, record).with_context(|| {
format!("serialize event log record to {}", self.path.display())
})?;
bytes.push(b'\n');
}
file.write_all(&bytes).with_context(|| {
format!(
"write {} event log records to {}",
records.len(),
self.path.display()
)
})?;
file.flush()
.with_context(|| format!("flush event log {}", self.path.display()))?;
file.sync_data()
.with_context(|| format!("sync event log {}", self.path.display()))?;
if !existed || self.needs_parent_sync_after_append {
sync_parent_dir(&self.path)?;
self.needs_parent_sync_after_append = false;
}
self.next_sequence = sequence;
self.previous_hash = record_previous_hash;
Ok(records)
}
pub fn records(&self) -> Result<Vec<EventRecord>> {
let mut file = OpenOptions::new()
.read(true)
.open(&self.path)
.with_context(|| format!("open event log for read {}", self.path.display()))?;
file.lock_shared()
.with_context(|| format!("lock event log for read {}", self.path.display()))?;
load_records_locked(&mut file, &self.path)
}
}
fn create_parent_dirs(path: &Path) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).with_context(|| {
format!("create parent directories for event log {}", path.display())
})?;
}
Ok(())
}
fn open_log_file(path: &Path) -> Result<(File, bool)> {
let existed = path
.try_exists()
.with_context(|| format!("check whether event log exists {}", path.display()))?;
let file = OpenOptions::new()
.create(true)
.read(true)
.append(true)
.open(path)
.with_context(|| format!("open event log {}", path.display()))?;
Ok((file, !existed))
}
fn load_tail_locked(file: &mut File, path: &Path) -> Result<(u64, Option<String>)> {
let records = load_records_locked(file, path)?;
let next_sequence = records.last().map_or(1, |record| record.sequence + 1);
let previous_hash = records.last().map(|record| record.hash.clone());
Ok((next_sequence, previous_hash))
}
fn load_records_locked(file: &mut File, path: &Path) -> Result<Vec<EventRecord>> {
let mut next_sequence = 1;
let mut previous_hash = None;
let mut records = Vec::new();
file.seek(SeekFrom::Start(0))
.with_context(|| format!("seek event log {}", path.display()))?;
for (line_index, line) in BufReader::new(file).lines().enumerate() {
let line_number = line_index + 1;
let line = line.with_context(|| {
format!("read event log line {line_number} from {}", path.display())
})?;
if line.trim().is_empty() {
continue;
}
let record: EventRecord = serde_json::from_str(&line).with_context(|| {
format!("parse event log line {line_number} from {}", path.display())
})?;
validate_record(&record, next_sequence, previous_hash.as_deref())
.map_err(|err| anyhow!("validate event log line {line_number}: {err}"))?;
next_sequence += 1;
previous_hash = Some(record.hash.clone());
records.push(record);
}
Ok(records)
}
#[cfg(unix)]
fn sync_parent_dir(path: &Path) -> Result<()> {
if let Some(parent) = path.parent() {
let dir = File::open(parent)
.with_context(|| format!("open parent directory for event log {}", path.display()))?;
dir.sync_all().with_context(|| {
format!(
"sync parent directory {} for event log {}",
parent.display(),
path.display()
)
})?;
}
Ok(())
}
#[cfg(not(unix))]
fn sync_parent_dir(_path: &Path) -> Result<()> {
// Directory fsync is not exposed portably by std on all supported platforms.
// Linux and other Unix targets take the durable path above.
Ok(())
}
fn validate_record(
record: &EventRecord,
expected_sequence: u64,
expected_previous_hash: Option<&str>,
) -> Result<()> {
if record.schema_version != POLICY_SCHEMA_VERSION {
return Err(anyhow!(
"unsupported schema version at sequence {}: expected {}, found {}",
record.sequence,
POLICY_SCHEMA_VERSION,
record.schema_version
));
}
if record.sequence != expected_sequence {
return Err(anyhow!(
"sequence mismatch: expected {}, found {}",
expected_sequence,
record.sequence
));
}
if record.previous_hash.as_deref() != expected_previous_hash {
return Err(anyhow!(
"previous hash mismatch at sequence {}",
record.sequence
));
}
let expected_hash = record_hash(record)?;
if record.hash != expected_hash {
return Err(anyhow!("hash mismatch at sequence {}", record.sequence));
}
Ok(())
}
fn record_hash(record: &EventRecord) -> Result<String> {
let mut hashable = record.clone();
hashable.hash.clear();
let bytes = serde_json::to_vec(&hashable).context("serialize event log record for hash")?;
Ok(hex::encode(Sha256::digest(bytes)))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::RuntimeState;
use serde_json::json;
use std::fs;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
fn temp_log_path(name: &str) -> PathBuf {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
std::env::temp_dir().join(format!(
"antidrift-event-log-{name}-{}-{nonce}.jsonl",
std::process::id()
))
}
#[test]
fn appends_hash_chained_jsonl_records() {
let path = temp_log_path("append");
let mut log = EventLog::open(&path).unwrap();
let first = log
.append(
EventType::RuntimeTransition,
RuntimeState::Transition,
Some("commitment-1".to_string()),
json!({"from": "active", "to": "transition"}),
)
.unwrap();
let second = log
.append(
EventType::PolicyApplied,
RuntimeState::Active,
Some("commitment-1".to_string()),
json!({"policy_id": "policy-1"}),
)
.unwrap();
assert_eq!(first.sequence, 1);
assert_eq!(second.sequence, 2);
assert_eq!(second.previous_hash.as_deref(), Some(first.hash.as_str()));
assert_ne!(first.hash, second.hash);
let contents = fs::read_to_string(&path).unwrap();
assert_eq!(contents.lines().count(), 2);
fs::remove_file(path).unwrap();
}
#[test]
fn batch_appends_hash_chained_adjacent_records() {
let path = temp_log_path("batch");
let mut log = EventLog::open(&path).unwrap();
let records = log
.append_batch([
PendingEventRecord {
event_type: EventType::CommitmentCreated,
runtime_state: RuntimeState::Active,
commitment_id: Some("commitment-1".to_string()),
payload_json: json!({"commitment_id": "commitment-1"}),
},
PendingEventRecord {
event_type: EventType::PolicyApplied,
runtime_state: RuntimeState::Active,
commitment_id: Some("commitment-1".to_string()),
payload_json: json!({"policy_id": "policy-1"}),
},
])
.unwrap();
assert_eq!(records.len(), 2);
assert_eq!(records[0].sequence, 1);
assert_eq!(records[1].sequence, 2);
assert_eq!(
records[1].previous_hash.as_deref(),
Some(records[0].hash.as_str())
);
let contents = fs::read_to_string(&path).unwrap();
assert_eq!(contents.lines().count(), 2);
EventLog::open(&path).unwrap();
fs::remove_file(path).unwrap();
}
#[test]
fn opening_existing_log_validates_chain_and_continues_sequence() {
let path = temp_log_path("reopen");
{
let mut log = EventLog::open(&path).unwrap();
log.append(
EventType::RuntimeTransition,
RuntimeState::Transition,
None,
json!({"from": "active"}),
)
.unwrap();
}
let mut reopened = EventLog::open(&path).unwrap();
let next = reopened
.append(
EventType::PolicyApplied,
RuntimeState::Active,
None,
json!({"policy_id": "policy-1"}),
)
.unwrap();
assert_eq!(next.sequence, 2);
fs::remove_file(path).unwrap();
}
#[test]
fn opening_missing_log_creates_reopenable_file() {
let path = temp_log_path("open-missing");
EventLog::open(&path).unwrap();
assert!(path.exists());
EventLog::open(&path).unwrap();
fs::remove_file(path).unwrap();
}
#[test]
fn opening_malformed_json_log_fails() {
let path = temp_log_path("malformed");
fs::write(&path, "{not json}\n").unwrap();
let err = EventLog::open(&path).expect_err("malformed JSONL must fail");
assert!(err.to_string().contains("parse event log"));
fs::remove_file(path).unwrap();
}
#[test]
fn opening_tampered_log_chain_fails() {
let path = temp_log_path("tampered");
{
let mut log = EventLog::open(&path).unwrap();
log.append(
EventType::RuntimeTransition,
RuntimeState::Transition,
None,
json!({"from": "active"}),
)
.unwrap();
log.append(
EventType::PolicyApplied,
RuntimeState::Active,
None,
json!({"policy_id": "policy-1"}),
)
.unwrap();
}
let contents = fs::read_to_string(&path).unwrap();
fs::write(&path, contents.replace("\"sequence\":2", "\"sequence\":3")).unwrap();
let err = EventLog::open(&path).expect_err("tampered chain must fail");
assert!(err.to_string().contains("sequence mismatch"));
fs::remove_file(path).unwrap();
}
#[test]
fn opening_log_with_unknown_record_field_fails() {
let path = temp_log_path("unknown-field");
{
let mut log = EventLog::open(&path).unwrap();
log.append(
EventType::RuntimeTransition,
RuntimeState::Transition,
None,
json!({"from": "active"}),
)
.unwrap();
}
let contents = fs::read_to_string(&path).unwrap();
let mut record_json: Value =
serde_json::from_str(contents.lines().next().unwrap()).unwrap();
record_json["unexpected_field"] = json!("ignored-before-hashing");
fs::write(
&path,
format!("{}\n", serde_json::to_string(&record_json).unwrap()),
)
.unwrap();
let err = EventLog::open(&path).expect_err("unknown record fields must fail");
assert!(err.to_string().contains("parse event log"));
fs::remove_file(path).unwrap();
}
#[test]
fn opening_log_with_unsupported_schema_version_fails() {
let path = temp_log_path("unsupported-schema");
let mut record = EventRecord {
schema_version: 999,
sequence: 1,
timestamp_unix_secs: unix_secs_now(),
event_type: EventType::RuntimeTransition,
commitment_id: None,
runtime_state: RuntimeState::Transition,
payload_json: json!({"from": "active"}),
previous_hash: None,
hash: String::new(),
};
record.hash = record_hash(&record).unwrap();
fs::write(
&path,
format!("{}\n", serde_json::to_string(&record).unwrap()),
)
.unwrap();
let err = EventLog::open(&path).expect_err("unsupported schema version must fail");
assert!(err.to_string().contains("unsupported schema version"));
fs::remove_file(path).unwrap();
}
#[test]
fn stale_handle_reloads_tail_before_append() {
let path = temp_log_path("stale-handle");
let mut log1 = EventLog::open(&path).unwrap();
let mut log2 = EventLog::open(&path).unwrap();
let first = log1
.append(
EventType::RuntimeTransition,
RuntimeState::Transition,
None,
json!({"from": "active"}),
)
.unwrap();
let second = log2
.append(
EventType::PolicyApplied,
RuntimeState::Active,
None,
json!({"policy_id": "policy-1"}),
)
.unwrap();
assert_eq!(second.sequence, 2);
assert_eq!(second.previous_hash.as_deref(), Some(first.hash.as_str()));
EventLog::open(&path).unwrap();
fs::remove_file(path).unwrap();
}
#[test]
fn append_creates_parent_dirs_and_writes_reopenable_file() {
let path = temp_log_path("append-parents")
.with_file_name("nested")
.join("events.jsonl");
let mut log = EventLog {
path: path.clone(),
next_sequence: 1,
previous_hash: None,
needs_parent_sync_after_append: false,
};
let record = log
.append(
EventType::RuntimeTransition,
RuntimeState::Transition,
None,
json!({"from": "active"}),
)
.unwrap();
assert_eq!(record.sequence, 1);
EventLog::open(&path).unwrap();
fs::remove_file(&path).unwrap();
fs::remove_dir(path.parent().unwrap()).unwrap();
}
}
-6
View File
@@ -1,6 +0,0 @@
pub mod context;
pub mod domain;
pub mod event_log;
pub mod session;
pub mod state_machine;
pub mod window;
-2121
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-327
View File
@@ -1,327 +0,0 @@
use crate::domain::{CommitmentState, RuntimeState};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum RuntimeAction {
EnterPlanning,
CancelOrTimeout,
Activate { policy_accepted: bool },
StartTransition,
CompleteForReview,
SevereViolation,
ReturnToActive,
SwitchTask,
EndTransitionForReview,
ContinuePlanning,
EndWorkPeriod,
EnterAdminOverride,
ExitAdminOverride(RuntimeState),
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CommitmentAction {
Activate,
PauseForTransition,
ReturnFromPause,
Complete,
Abandon,
Violate,
RecoverExplicitly,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum TransitionError {
PolicyRejected,
IllegalRuntimeTransition {
current: RuntimeState,
action: RuntimeAction,
},
IllegalCommitmentTransition {
current: CommitmentState,
action: CommitmentAction,
},
InvalidAdminOverrideExitTarget {
target: RuntimeState,
},
}
impl std::fmt::Display for TransitionError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
TransitionError::PolicyRejected => write!(f, "policy was rejected"),
TransitionError::IllegalRuntimeTransition { current, action } => {
write!(
f,
"illegal runtime transition from {:?} via {:?}",
current, action
)
}
TransitionError::IllegalCommitmentTransition { current, action } => {
write!(
f,
"illegal commitment transition from {:?} via {:?}",
current, action
)
}
TransitionError::InvalidAdminOverrideExitTarget { target } => {
write!(f, "invalid admin override exit target {:?}", target)
}
}
}
}
impl std::error::Error for TransitionError {}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CompositeTransition {
pub runtime_state: RuntimeState,
pub commitment_state: CommitmentState,
}
pub fn transition_runtime(
current: RuntimeState,
action: RuntimeAction,
) -> Result<RuntimeState, TransitionError> {
use RuntimeAction::*;
use RuntimeState::*;
match (current, action) {
(Locked, EnterPlanning) => Ok(Planning),
(
Planning,
Activate {
policy_accepted: true,
},
) => Ok(Active),
(
Planning,
Activate {
policy_accepted: false,
},
) => Err(TransitionError::PolicyRejected),
(Planning, CancelOrTimeout) => Ok(Locked),
(Active, StartTransition) => Ok(Transition),
(Active, CompleteForReview) => Ok(Review),
(Active, SevereViolation) => Ok(Locked),
(Transition, ReturnToActive) => Ok(Active),
(Transition, SwitchTask) => Ok(Planning),
(Transition, EndTransitionForReview) => Ok(Review),
(Transition, CancelOrTimeout) => Ok(Locked),
(Review, ContinuePlanning) => Ok(Planning),
(Review, EndWorkPeriod) => Ok(Locked),
(_, EnterAdminOverride) => Ok(AdminOverride),
(AdminOverride, ExitAdminOverride(Locked)) => Ok(Locked),
(AdminOverride, ExitAdminOverride(Planning)) => Ok(Planning),
(AdminOverride, ExitAdminOverride(target)) => {
Err(TransitionError::InvalidAdminOverrideExitTarget { target })
}
_ => Err(TransitionError::IllegalRuntimeTransition { current, action }),
}
}
pub fn transition_commitment(
current: CommitmentState,
action: CommitmentAction,
) -> Result<CommitmentState, TransitionError> {
use CommitmentAction::*;
use CommitmentState::*;
match (current.clone(), action) {
(Draft, Activate) => Ok(Active),
(Active, PauseForTransition) => Ok(Paused),
(Paused, ReturnFromPause) => Ok(Active),
(Active, Complete) => Ok(Completed),
(Active, Abandon) => Ok(Abandoned),
(Active, Violate) => Ok(Violated),
(Paused, Abandon) => Ok(Abandoned),
(Violated, RecoverExplicitly) => Ok(Active),
(Violated, Abandon) => Ok(Abandoned),
_ => Err(TransitionError::IllegalCommitmentTransition { current, action }),
}
}
pub fn start_transition(
runtime_state: RuntimeState,
commitment_state: CommitmentState,
) -> Result<CompositeTransition, TransitionError> {
let next_runtime = transition_runtime(runtime_state, RuntimeAction::StartTransition)?;
let next_commitment =
transition_commitment(commitment_state, CommitmentAction::PauseForTransition)?;
Ok(CompositeTransition {
runtime_state: next_runtime,
commitment_state: next_commitment,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn planning_can_only_activate_with_valid_policy() {
assert_eq!(
transition_runtime(
RuntimeState::Planning,
RuntimeAction::Activate {
policy_accepted: true,
}
),
Ok(RuntimeState::Active)
);
assert_eq!(
transition_runtime(
RuntimeState::Planning,
RuntimeAction::Activate {
policy_accepted: false,
}
),
Err(TransitionError::PolicyRejected)
);
}
#[test]
fn active_can_enter_transition_or_review_or_locked() {
assert_eq!(
transition_runtime(RuntimeState::Active, RuntimeAction::StartTransition),
Ok(RuntimeState::Transition)
);
assert_eq!(
transition_runtime(RuntimeState::Active, RuntimeAction::CompleteForReview),
Ok(RuntimeState::Review)
);
assert_eq!(
transition_runtime(RuntimeState::Active, RuntimeAction::SevereViolation),
Ok(RuntimeState::Locked)
);
}
#[test]
fn locked_cannot_directly_become_active() {
let err = transition_runtime(
RuntimeState::Locked,
RuntimeAction::Activate {
policy_accepted: true,
},
)
.unwrap_err();
assert_eq!(
err,
TransitionError::IllegalRuntimeTransition {
current: RuntimeState::Locked,
action: RuntimeAction::Activate {
policy_accepted: true,
},
}
);
assert_eq!(
err.to_string(),
"illegal runtime transition from Locked via Activate { policy_accepted: true }"
);
}
#[test]
fn admin_override_cannot_exit_to_admin_override() {
let err = transition_runtime(
RuntimeState::AdminOverride,
RuntimeAction::ExitAdminOverride(RuntimeState::AdminOverride),
)
.unwrap_err();
assert_eq!(
err,
TransitionError::InvalidAdminOverrideExitTarget {
target: RuntimeState::AdminOverride,
}
);
}
#[test]
fn admin_override_cannot_exit_to_active_or_transition() {
for target in [RuntimeState::Active, RuntimeState::Transition] {
let err = transition_runtime(
RuntimeState::AdminOverride,
RuntimeAction::ExitAdminOverride(target),
)
.unwrap_err();
assert_eq!(
err,
TransitionError::InvalidAdminOverrideExitTarget { target }
);
}
}
#[test]
fn admin_override_can_exit_to_locked_or_planning() {
assert_eq!(
transition_runtime(
RuntimeState::AdminOverride,
RuntimeAction::ExitAdminOverride(RuntimeState::Locked),
),
Ok(RuntimeState::Locked)
);
assert_eq!(
transition_runtime(
RuntimeState::AdminOverride,
RuntimeAction::ExitAdminOverride(RuntimeState::Planning),
),
Ok(RuntimeState::Planning)
);
}
#[test]
fn commitment_violation_recovery_is_explicit() {
assert_eq!(
transition_commitment(
CommitmentState::Violated,
CommitmentAction::RecoverExplicitly
),
Ok(CommitmentState::Active)
);
let err =
transition_commitment(CommitmentState::Violated, CommitmentAction::ReturnFromPause)
.unwrap_err();
assert_eq!(
err,
TransitionError::IllegalCommitmentTransition {
current: CommitmentState::Violated,
action: CommitmentAction::ReturnFromPause,
}
);
assert_eq!(
err.to_string(),
"illegal commitment transition from Violated via ReturnFromPause"
);
}
#[test]
fn start_transition_moves_runtime_and_commitment_together() {
assert_eq!(
start_transition(RuntimeState::Active, CommitmentState::Active),
Ok(CompositeTransition {
runtime_state: RuntimeState::Transition,
commitment_state: CommitmentState::Paused,
})
);
}
#[test]
fn start_transition_fails_before_returning_partial_state() {
assert_eq!(
start_transition(RuntimeState::Active, CommitmentState::Draft),
Err(TransitionError::IllegalCommitmentTransition {
current: CommitmentState::Draft,
action: CommitmentAction::PauseForTransition,
})
);
assert_eq!(
start_transition(RuntimeState::Locked, CommitmentState::Active),
Err(TransitionError::IllegalRuntimeTransition {
current: RuntimeState::Locked,
action: RuntimeAction::StartTransition,
})
);
}
}
-113
View File
@@ -1,113 +0,0 @@
use super::WindowSnapshot;
use crate::domain::EvidenceHealth;
use regex::Regex;
use std::{process::Command, process::Output, str};
pub fn get_title_clean() -> String {
get_snapshot().title
}
pub fn get_snapshot() -> WindowSnapshot {
let window_info = get_window_info();
let re = Regex::new(r"-?\d+([:.]\d+)+%?").unwrap();
let title = re.replace_all(&window_info.title, "").to_string();
let class = (window_info.class != "none").then_some(window_info.class);
let health = if window_info.wid.is_empty() {
EvidenceHealth::Unavailable("xdotool active window unavailable".to_string())
} else {
EvidenceHealth::Available
};
WindowSnapshot {
title,
class,
health,
}
}
pub fn minimize_other(title: &str) {
let window_info = get_window_info();
if window_info.wid.is_empty() {
return;
}
if window_info.title != title {
run_xdotool(&["windowminimize", &window_info.wid]);
}
}
struct WindowInfo {
title: String,
class: String,
wid: String,
}
fn is_valid_window_id(wid: &str) -> bool {
!wid.is_empty() && wid.bytes().all(|byte| byte.is_ascii_digit())
}
fn run_xdotool(args: &[&str]) -> Option<String> {
let output = Command::new("xdotool").args(args).output();
let Ok(Output {
status,
stdout,
stderr: _,
}) = output
else {
return None;
};
if status.code() != Some(0) {
return None;
}
let Ok(output_str) = str::from_utf8(&stdout) else {
return None;
};
Some(output_str.trim().to_string())
}
fn get_window_info() -> WindowInfo {
let none = WindowInfo {
title: "none".to_string(),
class: "none".to_string(),
wid: "".to_string(),
};
let Some(wid) = run_xdotool(&["getactivewindow"]) else {
return none;
};
if !is_valid_window_id(&wid) {
return none;
};
let Some(class) = run_xdotool(&["getwindowclassname", &wid]) else {
return none;
};
let Some(title) = run_xdotool(&["getwindowname", &wid]) else {
return none;
};
WindowInfo { title, class, wid }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn valid_window_id_accepts_ascii_digits_only() {
assert!(is_valid_window_id("12345"));
assert!(is_valid_window_id("0"));
assert!(!is_valid_window_id(""));
assert!(!is_valid_window_id("12abc"));
assert!(!is_valid_window_id(" 123"));
assert!(!is_valid_window_id("123 "));
assert!(!is_valid_window_id("123"));
}
}
-45
View File
@@ -1,45 +0,0 @@
use crate::domain::EvidenceHealth;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct WindowSnapshot {
pub title: String,
pub class: Option<String>,
pub health: EvidenceHealth,
}
impl WindowSnapshot {
pub fn unavailable(reason: impl Into<String>) -> Self {
Self {
title: "none".to_string(),
class: None,
health: EvidenceHealth::Unavailable(reason.into()),
}
}
}
#[cfg(target_os = "windows")]
mod windows;
#[cfg(target_os = "windows")]
pub use windows::*;
#[cfg(target_os = "linux")]
mod linux;
#[cfg(target_os = "linux")]
pub use linux::*;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn unavailable_snapshot_records_reason_without_evidence() {
assert_eq!(
WindowSnapshot::unavailable("no active window"),
WindowSnapshot {
title: "none".to_string(),
class: None,
health: EvidenceHealth::Unavailable("no active window".to_string()),
}
);
}
}
-68
View File
@@ -1,68 +0,0 @@
use super::WindowSnapshot;
use crate::domain::EvidenceHealth;
use regex::Regex;
use std::{ffi::OsString, os::windows::ffi::OsStringExt};
use winapi::shared::windef::HWND;
use winapi::um::winuser::{GetForegroundWindow, GetWindowTextW, ShowWindow, SW_MINIMIZE};
pub fn get_title_clean() -> String {
get_snapshot().title
}
pub fn get_snapshot() -> WindowSnapshot {
let window_info = get_window_info();
if window_info.hwnd.is_null() {
return WindowSnapshot::unavailable("foreground window unavailable");
}
let re = Regex::new(r"-?\d+([:.]\d+)+%?").unwrap();
let title = re.replace_all(&window_info.title, "").to_string();
let health = if window_info.title.is_empty() {
EvidenceHealth::Degraded("foreground window title unavailable".to_string())
} else {
EvidenceHealth::Degraded("window class unavailable on current Windows adapter".to_string())
};
WindowSnapshot {
title,
class: None,
health,
}
}
pub fn minimize_other(title: &str) {
let window_info = get_window_info();
if window_info.hwnd.is_null() {
return;
}
if window_info.title != title {
unsafe {
ShowWindow(window_info.hwnd, SW_MINIMIZE);
}
}
}
struct WindowInfo {
title: String,
hwnd: HWND,
}
fn get_window_info() -> WindowInfo {
unsafe {
let hwnd = GetForegroundWindow();
if hwnd.is_null() {
return WindowInfo {
title: String::new(),
hwnd,
};
}
let mut text: [u16; 512] = [0; 512];
let len = GetWindowTextW(hwnd, text.as_mut_ptr(), text.len() as i32) as usize;
let title = OsString::from_wide(&text[..len])
.to_string_lossy()
.into_owned();
WindowInfo { title, hwnd }
}
}