use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct OperationId(String);
impl OperationId {
pub fn new() -> Self {
let id = format!("op-{}", uuid::Uuid::new_v4().simple());
Self(id)
}
pub fn from_string(s: impl Into<String>) -> Self {
Self(s.into())
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl Default for OperationId {
fn default() -> Self {
Self::new()
}
}
impl std::fmt::Display for OperationId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum SourceInfo {
Local {
path: PathBuf,
},
Git {
url: String,
branch: Option<String>,
commit: Option<String>,
},
Marketplace {
id: String,
version: String,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum OperationType {
Install {
profile: String,
source: Option<SourceInfo>,
target: PathBuf,
options: InstallOperationOptions,
},
Remove { profile: String, target: PathBuf },
Upgrade {
profile: String,
source: Option<SourceInfo>,
target: PathBuf,
from_checkpoint: Option<String>,
},
Fusion {
inputs: Vec<FusionInput>,
output_profile: String,
},
RuleApply {
rule_name: String,
source_profile: String,
output_profile: String,
},
UserEdit {
target: PathBuf,
files_changed: Vec<String>,
auto_detected: bool,
},
ManualSnapshot {
target: PathBuf,
description: Option<String>,
},
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct InstallOperationOptions {
pub force: bool,
pub dry_run: bool,
pub no_prefix: bool,
pub no_merge: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FusionInput {
pub profile: String,
pub category: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Operation {
pub id: OperationId,
pub operation_type: OperationType,
pub parent: Option<OperationId>,
pub checkpoint_id: String,
pub timestamp: DateTime<Utc>,
pub description: Option<String>,
}
impl Operation {
pub fn new(
operation_type: OperationType,
parent: Option<OperationId>,
checkpoint_id: String,
) -> Self {
Self {
id: OperationId::new(),
operation_type,
parent,
checkpoint_id,
timestamp: Utc::now(),
description: None,
}
}
pub fn with_description(mut self, description: impl Into<String>) -> Self {
self.description = Some(description.into());
self
}
pub fn summary(&self) -> String {
match &self.operation_type {
OperationType::Install {
profile, target, ..
} => {
format!("install {} -> {}", profile, target.display())
}
OperationType::Remove { profile, target } => {
format!("remove {} from {}", profile, target.display())
}
OperationType::Upgrade {
profile, target, ..
} => {
format!("upgrade {} at {}", profile, target.display())
}
OperationType::Fusion {
inputs,
output_profile,
} => {
let input_strs: Vec<_> = inputs
.iter()
.map(|i| {
if let Some(cat) = &i.category {
format!("{}:{}", i.profile, cat)
} else {
i.profile.clone()
}
})
.collect();
format!("fusion [{}] -> {}", input_strs.join(", "), output_profile)
}
OperationType::RuleApply {
rule_name,
source_profile,
output_profile,
} => {
format!(
"rule-apply {} to {} -> {}",
rule_name, source_profile, output_profile
)
}
OperationType::UserEdit {
target,
files_changed,
..
} => {
format!(
"user-edit at {} ({} files)",
target.display(),
files_changed.len()
)
}
OperationType::ManualSnapshot { target, .. } => {
format!("snapshot {}", target.display())
}
}
}
pub fn is_auto_detected(&self) -> bool {
matches!(
&self.operation_type,
OperationType::UserEdit {
auto_detected: true,
..
}
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn operation_id_generation() {
let id1 = OperationId::new();
let id2 = OperationId::new();
assert_ne!(id1, id2);
assert!(id1.as_str().starts_with("op-"));
}
#[test]
fn operation_summary() {
let op = Operation::new(
OperationType::Install {
profile: "test-profile".into(),
source: None,
target: PathBuf::from("/home/user/.claude"),
options: Default::default(),
},
None,
"cp-001".into(),
);
assert!(op.summary().contains("install"));
assert!(op.summary().contains("test-profile"));
}
#[test]
fn fusion_input_summary() {
let op = Operation::new(
OperationType::Fusion {
inputs: vec![
FusionInput {
profile: "base".into(),
category: Some("agents".into()),
},
FusionInput {
profile: "rust".into(),
category: Some("rules".into()),
},
],
output_profile: "mixed".into(),
},
None,
"cp-002".into(),
);
let summary = op.summary();
assert!(summary.contains("base:agents"));
assert!(summary.contains("rust:rules"));
assert!(summary.contains("mixed"));
}
}