Skip to main content

dot_agent_core/history/
operation.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use std::path::PathBuf;
4
5/// Unique identifier for an operation
6#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
7pub struct OperationId(String);
8
9impl OperationId {
10    pub fn new() -> Self {
11        let id = format!("op-{}", uuid::Uuid::new_v4().simple());
12        Self(id)
13    }
14
15    pub fn from_string(s: impl Into<String>) -> Self {
16        Self(s.into())
17    }
18
19    pub fn as_str(&self) -> &str {
20        &self.0
21    }
22}
23
24impl Default for OperationId {
25    fn default() -> Self {
26        Self::new()
27    }
28}
29
30impl std::fmt::Display for OperationId {
31    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
32        write!(f, "{}", self.0)
33    }
34}
35
36/// Source information for tracking where a profile came from
37#[derive(Debug, Clone, Serialize, Deserialize)]
38#[serde(tag = "type", rename_all = "snake_case")]
39pub enum SourceInfo {
40    Local {
41        path: PathBuf,
42    },
43    Git {
44        url: String,
45        branch: Option<String>,
46        commit: Option<String>,
47    },
48    Marketplace {
49        id: String,
50        version: String,
51    },
52}
53
54/// Type of operation performed
55#[derive(Debug, Clone, Serialize, Deserialize)]
56#[serde(tag = "type", rename_all = "snake_case")]
57pub enum OperationType {
58    /// Install a profile to a target
59    Install {
60        profile: String,
61        source: Option<SourceInfo>,
62        target: PathBuf,
63        options: InstallOperationOptions,
64    },
65    /// Remove a profile from a target
66    Remove { profile: String, target: PathBuf },
67    /// Upgrade a profile at a target
68    Upgrade {
69        profile: String,
70        source: Option<SourceInfo>,
71        target: PathBuf,
72        from_checkpoint: Option<String>,
73    },
74    /// Fusion of multiple profiles into a new one
75    Fusion {
76        inputs: Vec<FusionInput>,
77        output_profile: String,
78    },
79    /// Apply a rule to create a new profile
80    RuleApply {
81        rule_name: String,
82        source_profile: String,
83        output_profile: String,
84    },
85    /// User manual edit (auto-detected)
86    UserEdit {
87        target: PathBuf,
88        files_changed: Vec<String>,
89        auto_detected: bool,
90    },
91    /// Manual snapshot (user-triggered)
92    ManualSnapshot {
93        target: PathBuf,
94        description: Option<String>,
95    },
96}
97
98/// Options used during install operation
99#[derive(Debug, Clone, Default, Serialize, Deserialize)]
100pub struct InstallOperationOptions {
101    pub force: bool,
102    pub dry_run: bool,
103    pub no_prefix: bool,
104    pub no_merge: bool,
105}
106
107/// Input specification for fusion operation
108#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct FusionInput {
110    pub profile: String,
111    pub category: Option<String>,
112}
113
114/// A single operation in the history graph
115#[derive(Debug, Clone, Serialize, Deserialize)]
116pub struct Operation {
117    /// Unique identifier
118    pub id: OperationId,
119    /// Type of operation with its specific data
120    pub operation_type: OperationType,
121    /// Parent operation ID (None for root)
122    pub parent: Option<OperationId>,
123    /// Checkpoint ID created after this operation
124    pub checkpoint_id: String,
125    /// When the operation was performed
126    pub timestamp: DateTime<Utc>,
127    /// Optional description
128    pub description: Option<String>,
129}
130
131impl Operation {
132    pub fn new(
133        operation_type: OperationType,
134        parent: Option<OperationId>,
135        checkpoint_id: String,
136    ) -> Self {
137        Self {
138            id: OperationId::new(),
139            operation_type,
140            parent,
141            checkpoint_id,
142            timestamp: Utc::now(),
143            description: None,
144        }
145    }
146
147    pub fn with_description(mut self, description: impl Into<String>) -> Self {
148        self.description = Some(description.into());
149        self
150    }
151
152    /// Get a human-readable summary of this operation
153    pub fn summary(&self) -> String {
154        match &self.operation_type {
155            OperationType::Install {
156                profile, target, ..
157            } => {
158                format!("install {} -> {}", profile, target.display())
159            }
160            OperationType::Remove { profile, target } => {
161                format!("remove {} from {}", profile, target.display())
162            }
163            OperationType::Upgrade {
164                profile, target, ..
165            } => {
166                format!("upgrade {} at {}", profile, target.display())
167            }
168            OperationType::Fusion {
169                inputs,
170                output_profile,
171            } => {
172                let input_strs: Vec<_> = inputs
173                    .iter()
174                    .map(|i| {
175                        if let Some(cat) = &i.category {
176                            format!("{}:{}", i.profile, cat)
177                        } else {
178                            i.profile.clone()
179                        }
180                    })
181                    .collect();
182                format!("fusion [{}] -> {}", input_strs.join(", "), output_profile)
183            }
184            OperationType::RuleApply {
185                rule_name,
186                source_profile,
187                output_profile,
188            } => {
189                format!(
190                    "rule-apply {} to {} -> {}",
191                    rule_name, source_profile, output_profile
192                )
193            }
194            OperationType::UserEdit {
195                target,
196                files_changed,
197                ..
198            } => {
199                format!(
200                    "user-edit at {} ({} files)",
201                    target.display(),
202                    files_changed.len()
203                )
204            }
205            OperationType::ManualSnapshot { target, .. } => {
206                format!("snapshot {}", target.display())
207            }
208        }
209    }
210
211    /// Check if this operation is auto-detected (user edit)
212    pub fn is_auto_detected(&self) -> bool {
213        matches!(
214            &self.operation_type,
215            OperationType::UserEdit {
216                auto_detected: true,
217                ..
218            }
219        )
220    }
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226
227    #[test]
228    fn operation_id_generation() {
229        let id1 = OperationId::new();
230        let id2 = OperationId::new();
231        assert_ne!(id1, id2);
232        assert!(id1.as_str().starts_with("op-"));
233    }
234
235    #[test]
236    fn operation_summary() {
237        let op = Operation::new(
238            OperationType::Install {
239                profile: "test-profile".into(),
240                source: None,
241                target: PathBuf::from("/home/user/.claude"),
242                options: Default::default(),
243            },
244            None,
245            "cp-001".into(),
246        );
247        assert!(op.summary().contains("install"));
248        assert!(op.summary().contains("test-profile"));
249    }
250
251    #[test]
252    fn fusion_input_summary() {
253        let op = Operation::new(
254            OperationType::Fusion {
255                inputs: vec![
256                    FusionInput {
257                        profile: "base".into(),
258                        category: Some("agents".into()),
259                    },
260                    FusionInput {
261                        profile: "rust".into(),
262                        category: Some("rules".into()),
263                    },
264                ],
265                output_profile: "mixed".into(),
266            },
267            None,
268            "cp-002".into(),
269        );
270        let summary = op.summary();
271        assert!(summary.contains("base:agents"));
272        assert!(summary.contains("rust:rules"));
273        assert!(summary.contains("mixed"));
274    }
275}