claude_code_switcher/
snapshots.rs

1use crate::settings::ClaudeSettings;
2use anyhow::{Result, anyhow};
3use chrono::Utc;
4use serde::{Deserialize, Serialize};
5use std::fs;
6use std::path::PathBuf;
7use uuid::Uuid;
8
9/// Scope for snapshots
10#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
11pub enum SnapshotScope {
12    /// Only environment variables
13    Env,
14    /// Common settings (exclude environment)
15    #[default]
16    Common,
17    /// All settings
18    All,
19}
20
21impl std::str::FromStr for SnapshotScope {
22    type Err = anyhow::Error;
23
24    fn from_str(s: &str) -> Result<Self> {
25        match s.to_lowercase().as_str() {
26            "env" => Ok(SnapshotScope::Env),
27            "common" => Ok(SnapshotScope::Common),
28            "all" => Ok(SnapshotScope::All),
29            _ => Err(anyhow!(
30                "Invalid scope '{}'. Must be one of: env, common, all",
31                s
32            )),
33        }
34    }
35}
36
37impl std::fmt::Display for SnapshotScope {
38    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
39        match self {
40            SnapshotScope::Env => write!(f, "env"),
41            SnapshotScope::Common => write!(f, "common"),
42            SnapshotScope::All => write!(f, "all"),
43        }
44    }
45}
46
47/// A snapshot of Claude Code settings
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct Snapshot {
50    /// Unique identifier
51    pub id: String,
52
53    /// User-friendly name
54    pub name: String,
55
56    /// Optional description
57    pub description: Option<String>,
58
59    /// The settings data
60    pub settings: ClaudeSettings,
61
62    /// When this snapshot was created
63    pub created_at: String,
64
65    /// When this snapshot was last modified
66    pub updated_at: String,
67
68    /// Scope of this snapshot
69    pub scope: SnapshotScope,
70
71    /// Version for future compatibility
72    pub version: u32,
73}
74
75impl Snapshot {
76    /// Create a new snapshot
77    pub fn new(
78        name: String,
79        settings: ClaudeSettings,
80        scope: SnapshotScope,
81        description: Option<String>,
82    ) -> Self {
83        let now = Utc::now().format("%Y-%m-%d %H:%M:%S UTC").to_string();
84
85        Self {
86            id: Uuid::new_v4().to_string(),
87            name,
88            description,
89            settings,
90            created_at: now.clone(),
91            updated_at: now,
92            scope,
93            version: 1,
94        }
95    }
96
97    /// Update the timestamp
98    pub fn touch(&mut self) {
99        let now = Utc::now().format("%Y-%m-%d %H:%M:%S UTC").to_string();
100        self.updated_at = now;
101    }
102}
103
104/// Store for managing snapshots
105#[derive(Debug, Clone)]
106pub struct SnapshotStore {
107    /// Directory where snapshots are stored
108    pub snapshots_dir: PathBuf,
109}
110
111impl SnapshotStore {
112    /// Create a new snapshot store
113    pub fn new(snapshots_dir: PathBuf) -> Self {
114        Self { snapshots_dir }
115    }
116
117    /// Ensure the snapshots directory exists
118    pub fn ensure_dir(&self) -> Result<()> {
119        if !self.snapshots_dir.exists() {
120            fs::create_dir_all(&self.snapshots_dir).map_err(|e| {
121                anyhow!(
122                    "Failed to create snapshots directory {}: {}",
123                    self.snapshots_dir.display(),
124                    e
125                )
126            })?;
127        }
128        Ok(())
129    }
130
131    /// Get the path for a snapshot file
132    pub fn snapshot_path(&self, snapshot_id: &str) -> PathBuf {
133        self.snapshots_dir.join(format!("{}.json", snapshot_id))
134    }
135
136    /// Save a snapshot
137    pub fn save(&self, snapshot: &Snapshot) -> Result<()> {
138        self.ensure_dir()?;
139
140        let path = self.snapshot_path(&snapshot.id);
141        let content = serde_json::to_string_pretty(snapshot)
142            .map_err(|e| anyhow!("Failed to serialize snapshot: {}", e))?;
143
144        fs::write(&path, content)
145            .map_err(|e| anyhow!("Failed to write snapshot file {}: {}", path.display(), e))?;
146
147        Ok(())
148    }
149
150    /// Load a snapshot by ID
151    pub fn load(&self, snapshot_id: &str) -> Result<Snapshot> {
152        let path = self.snapshot_path(snapshot_id);
153
154        if !path.exists() {
155            return Err(anyhow!("Snapshot '{}' not found", snapshot_id));
156        }
157
158        let content = fs::read_to_string(&path)
159            .map_err(|e| anyhow!("Failed to read snapshot file {}: {}", path.display(), e))?;
160
161        let snapshot: Snapshot = serde_json::from_str(&content)
162            .map_err(|e| anyhow!("Failed to parse snapshot file {}: {}", path.display(), e))?;
163
164        Ok(snapshot)
165    }
166
167    /// Load a snapshot by name
168    pub fn load_by_name(&self, name: &str) -> Result<Snapshot> {
169        let snapshots = self.list()?;
170
171        for snapshot in snapshots {
172            if snapshot.name == name {
173                return Ok(snapshot);
174            }
175        }
176
177        Err(anyhow!("Snapshot '{}' not found", name))
178    }
179
180    /// List all snapshots
181    pub fn list(&self) -> Result<Vec<Snapshot>> {
182        if !self.snapshots_dir.exists() {
183            return Ok(Vec::new());
184        }
185
186        let mut snapshots = Vec::new();
187
188        for entry in fs::read_dir(&self.snapshots_dir)? {
189            let entry = entry?;
190            let path = entry.path();
191
192            if path.extension().and_then(|s| s.to_str()) == Some("json") {
193                match self.load(path.file_stem().and_then(|s| s.to_str()).unwrap_or("")) {
194                    Ok(snapshot) => snapshots.push(snapshot),
195                    Err(_) => {
196                        // Skip invalid snapshot files
197                        continue;
198                    }
199                }
200            }
201        }
202
203        // Sort by creation date (newest first)
204        snapshots.sort_by(|a, b| b.created_at.cmp(&a.created_at));
205
206        Ok(snapshots)
207    }
208
209    /// Delete a snapshot
210    pub fn delete(&self, snapshot_id: &str) -> Result<()> {
211        let path = self.snapshot_path(snapshot_id);
212
213        if !path.exists() {
214            return Err(anyhow!("Snapshot '{}' not found", snapshot_id));
215        }
216
217        fs::remove_file(&path)
218            .map_err(|e| anyhow!("Failed to delete snapshot file {}: {}", path.display(), e))?;
219
220        Ok(())
221    }
222
223    /// Delete a snapshot by name
224    pub fn delete_by_name(&self, name: &str) -> Result<()> {
225        let snapshots = self.list()?;
226
227        for snapshot in snapshots {
228            if snapshot.name == name {
229                return self.delete(&snapshot.id);
230            }
231        }
232
233        Err(anyhow!("Snapshot '{}' not found", name))
234    }
235
236    /// Check if a snapshot exists
237    pub fn exists(&self, snapshot_id: &str) -> bool {
238        self.snapshot_path(snapshot_id).exists()
239    }
240
241    /// Check if a snapshot with the given name exists
242    pub fn exists_by_name(&self, name: &str) -> bool {
243        self.list()
244            .map(|snapshots| snapshots.iter().any(|s| s.name == name))
245            .unwrap_or(false)
246    }
247
248    /// Get all snapshot names
249    pub fn list_names(&self) -> Result<Vec<String>> {
250        let snapshots = self.list()?;
251        Ok(snapshots.into_iter().map(|s| s.name).collect())
252    }
253}
254
255/// Filter settings by scope
256pub fn filter_settings_by_scope(settings: ClaudeSettings, scope: &SnapshotScope) -> ClaudeSettings {
257    match scope {
258        SnapshotScope::Env => ClaudeSettings {
259            env: settings.env,
260            ..Default::default()
261        },
262        SnapshotScope::Common => ClaudeSettings {
263            env: settings.env,
264            model: settings.model,
265            output_style: settings.output_style,
266            include_co_authored_by: settings.include_co_authored_by,
267            permissions: settings.permissions,
268            hooks: settings.hooks,
269            status_line: settings.status_line,
270            subagent_model: settings.subagent_model,
271            ..Default::default()
272        },
273        SnapshotScope::All => settings,
274    }
275}