Skip to main content

mockforge_core/
persona_lifecycle_time.rs

1//! Lifecycle time manager for automatic lifecycle state updates
2//!
3//! This module provides integration between time travel and persona lifecycle states.
4//! When virtual time advances, it automatically checks and updates persona lifecycle
5//! states based on transition rules.
6
7use crate::time_travel::{get_global_clock, VirtualClock};
8use chrono::{DateTime, Utc};
9#[cfg(feature = "data")]
10use mockforge_data::persona_lifecycle::PersonaLifecycle;
11use std::sync::Arc;
12use tracing::{debug, info, warn};
13
14/// Manager for updating persona lifecycles when time changes
15///
16/// This manager registers callbacks with the virtual clock to automatically
17/// update persona lifecycle states when virtual time advances.
18pub struct LifecycleTimeManager {
19    /// Callback to update persona lifecycles
20    /// Takes (old_time, new_time) and returns list of updated personas
21    update_callback: Arc<dyn Fn(DateTime<Utc>, DateTime<Utc>) -> Vec<String> + Send + Sync>,
22}
23
24impl LifecycleTimeManager {
25    /// Create a new lifecycle time manager
26    ///
27    /// # Arguments
28    /// * `update_callback` - Function that updates persona lifecycles and returns list of updated persona IDs
29    pub fn new<F>(update_callback: F) -> Self
30    where
31        F: Fn(DateTime<Utc>, DateTime<Utc>) -> Vec<String> + Send + Sync + 'static,
32    {
33        Self {
34            update_callback: Arc::new(update_callback),
35        }
36    }
37
38    /// Register with the global virtual clock
39    ///
40    /// This will automatically update persona lifecycles whenever time changes.
41    pub fn register_with_clock(&self) {
42        if let Some(clock) = get_global_clock() {
43            self.register_with_clock_instance(&clock);
44        } else {
45            warn!("No global virtual clock found, lifecycle time manager not registered");
46        }
47    }
48
49    /// Register with a specific virtual clock instance
50    ///
51    /// This allows registering with a clock that may not be in the global registry.
52    pub fn register_with_clock_instance(&self, clock: &VirtualClock) {
53        let callback = self.update_callback.clone();
54        clock.on_time_change(move |old_time, new_time| {
55            debug!("Time changed from {} to {}, updating persona lifecycles", old_time, new_time);
56            let updated = callback(old_time, new_time);
57            if !updated.is_empty() {
58                info!("Updated {} persona lifecycle states: {:?}", updated.len(), updated);
59            }
60        });
61        info!("LifecycleTimeManager registered with virtual clock");
62    }
63}
64
65/// Check if a persona lifecycle should transition based on elapsed time
66///
67/// # Arguments
68/// * `lifecycle` - The persona lifecycle to check
69/// * `current_time` - The current virtual time
70///
71/// # Returns
72/// `true` if the lifecycle state was updated, `false` otherwise
73pub fn check_and_update_lifecycle_transitions(
74    lifecycle: &mut PersonaLifecycle,
75    current_time: DateTime<Utc>,
76) -> bool {
77    let old_state = lifecycle.current_state;
78    let elapsed = current_time - lifecycle.state_entered_at;
79
80    // Check each transition rule
81    for rule in &lifecycle.transition_rules {
82        // Check if enough time has passed
83        if let Some(after_days) = rule.after_days {
84            let required_duration = chrono::Duration::days(after_days as i64);
85            if elapsed < required_duration {
86                continue; // Not enough time has passed
87            }
88        }
89
90        // Evaluate condition against persona metadata if present
91        if let Some(condition) = &rule.condition {
92            if !evaluate_lifecycle_condition(condition, &lifecycle.metadata) {
93                debug!(
94                    "Condition '{}' not met for persona {}, skipping transition",
95                    condition, lifecycle.persona_id
96                );
97                continue;
98            }
99        }
100
101        // Transition to the new state
102        lifecycle.current_state = rule.to;
103        lifecycle.state_entered_at = current_time;
104        lifecycle.state_history.push((current_time, rule.to));
105
106        info!(
107            "Persona {} lifecycle transitioned: {:?} -> {:?}",
108            lifecycle.persona_id, old_state, rule.to
109        );
110
111        return true; // State was updated
112    }
113
114    false // No transition occurred
115}
116
117/// Evaluate a lifecycle condition expression against persona metadata
118///
119/// Supports simple comparison expressions like:
120/// - `"payment_failed_count > 2"`
121/// - `"login_count >= 10"`
122/// - `"subscription_tier == premium"`
123/// - `"active == true"`
124///
125/// The left side is looked up as a key in the metadata map.
126/// Numeric comparisons use f64; string comparisons use equality/inequality.
127fn evaluate_lifecycle_condition(
128    condition: &str,
129    metadata: &std::collections::HashMap<String, serde_json::Value>,
130) -> bool {
131    let expr = condition.trim();
132
133    // Handle literal true/false
134    if expr.eq_ignore_ascii_case("true") {
135        return true;
136    }
137    if expr.eq_ignore_ascii_case("false") {
138        return false;
139    }
140
141    // Parse "variable operator value" expressions
142    // Try two-character operators first (>=, <=, ==, !=), then single-character (>, <)
143    let operators = [">=", "<=", "!=", "==", ">", "<"];
144    let mut parts: Option<(&str, &str, &str)> = None;
145
146    for op in &operators {
147        if let Some(idx) = expr.find(op) {
148            let var = expr[..idx].trim();
149            let val = expr[idx + op.len()..].trim();
150            if !var.is_empty() && !val.is_empty() {
151                parts = Some((var, op, val));
152                break;
153            }
154        }
155    }
156
157    let (variable, operator, threshold_str) = match parts {
158        Some(p) => p,
159        None => {
160            debug!(expression = expr, "Unrecognized condition expression, defaulting to true");
161            return true;
162        }
163    };
164
165    // Look up the variable in metadata
166    let meta_value = match metadata.get(variable) {
167        Some(val) => val,
168        None => {
169            debug!(
170                variable = variable,
171                "Condition variable not found in persona metadata, defaulting to false"
172            );
173            return false;
174        }
175    };
176
177    // Try numeric comparison first
178    if let Some(actual_num) = meta_value.as_f64() {
179        if let Ok(threshold_num) = threshold_str.parse::<f64>() {
180            return match operator {
181                ">" => actual_num > threshold_num,
182                "<" => actual_num < threshold_num,
183                ">=" => actual_num >= threshold_num,
184                "<=" => actual_num <= threshold_num,
185                "==" => (actual_num - threshold_num).abs() < f64::EPSILON,
186                "!=" => (actual_num - threshold_num).abs() >= f64::EPSILON,
187                _ => true,
188            };
189        }
190    }
191
192    // Fall back to string comparison
193    let actual_str = match meta_value {
194        serde_json::Value::String(s) => s.as_str(),
195        serde_json::Value::Bool(b) => {
196            if *b {
197                "true"
198            } else {
199                "false"
200            }
201        }
202        _ => {
203            debug!(variable = variable, "Cannot compare non-string/non-numeric metadata value");
204            return false;
205        }
206    };
207
208    match operator {
209        "==" => actual_str == threshold_str,
210        "!=" => actual_str != threshold_str,
211        _ => {
212            debug!(operator = operator, "Operator not supported for string comparison");
213            false
214        }
215    }
216}