1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use std::path::PathBuf;
4
5#[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#[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#[derive(Debug, Clone, Serialize, Deserialize)]
56#[serde(tag = "type", rename_all = "snake_case")]
57pub enum OperationType {
58 Install {
60 profile: String,
61 source: Option<SourceInfo>,
62 target: PathBuf,
63 options: InstallOperationOptions,
64 },
65 Remove { profile: String, target: PathBuf },
67 Upgrade {
69 profile: String,
70 source: Option<SourceInfo>,
71 target: PathBuf,
72 from_checkpoint: Option<String>,
73 },
74 Fusion {
76 inputs: Vec<FusionInput>,
77 output_profile: String,
78 },
79 RuleApply {
81 rule_name: String,
82 source_profile: String,
83 output_profile: String,
84 },
85 UserEdit {
87 target: PathBuf,
88 files_changed: Vec<String>,
89 auto_detected: bool,
90 },
91 ManualSnapshot {
93 target: PathBuf,
94 description: Option<String>,
95 },
96}
97
98#[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#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct FusionInput {
110 pub profile: String,
111 pub category: Option<String>,
112}
113
114#[derive(Debug, Clone, Serialize, Deserialize)]
116pub struct Operation {
117 pub id: OperationId,
119 pub operation_type: OperationType,
121 pub parent: Option<OperationId>,
123 pub checkpoint_id: String,
125 pub timestamp: DateTime<Utc>,
127 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 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 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}