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:
-10
@@ -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
|
||||
.superpowers/
|
||||
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
A personal focus operating system: treat each work session as an explicit
|
||||
commitment (next action, success condition, timebox), and make drift visible.
|
||||
|
||||
This is the Go reimagining. The original Rust implementation is preserved under
|
||||
`legacy/` for reference. See `docs/superpowers/specs/` for the design.
|
||||
See `docs/superpowers/specs/` for the design.
|
||||
|
||||
## Run
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Package domain holds the core commitment types and validation, ported from
|
||||
// the original Rust implementation. These are pure data types with no I/O.
|
||||
// Package domain holds the core commitment types and validation. These are
|
||||
// pure data types with no I/O.
|
||||
package domain
|
||||
|
||||
import (
|
||||
|
||||
@@ -30,8 +30,8 @@ type Source interface {
|
||||
Watch(ctx context.Context, onChange func(WindowSnapshot))
|
||||
}
|
||||
|
||||
// titleNoise matches the legacy clock/ratio/percent runs that pollute bucket
|
||||
// keys (e.g. "1:23", "50.5%", "-3.0"). Ported verbatim from the Rust impl.
|
||||
// titleNoise matches the clock/ratio/percent runs that pollute bucket
|
||||
// keys (e.g. "1:23", "50.5%", "-3.0").
|
||||
var titleNoise = regexp.MustCompile(`-?\d+([:.]\d+)+%?`)
|
||||
|
||||
// extraSpace collapses the whitespace runs left behind by the scrubs below.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// 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
|
||||
|
||||
import (
|
||||
@@ -9,8 +9,8 @@ import (
|
||||
)
|
||||
|
||||
// RuntimeAction enumerates runtime transitions. Activate's policy acceptance
|
||||
// and admin-override exit targets from the Rust enum are flattened into
|
||||
// distinct actions so the action type stays a simple value.
|
||||
// and admin-override exit targets are flattened into distinct actions so the
|
||||
// action type stays a simple value.
|
||||
type RuntimeAction string
|
||||
|
||||
const (
|
||||
|
||||
Generated
-1102
File diff suppressed because it is too large
Load Diff
@@ -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"] }
|
||||
@@ -1,6 +0,0 @@
|
||||
[Desktop Entry]
|
||||
Name=AntiDrift
|
||||
Exec=antidrift
|
||||
Terminal=false
|
||||
Type=Application
|
||||
StartupNotify=false
|
||||
@@ -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! ";
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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()),
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user