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