Skip to main content

oxicode/cli/commands/
reset.rs

1//! Reset subcommand handler and helper utilities.
2
3use anyhow::Result;
4use std::path::{Path, PathBuf};
5
6/// Target descriptor for the reset command.
7struct ResetTarget {
8    label: String,
9    path: PathBuf,
10    description: String,
11}
12
13/// Handle `oxicode reset [--yes] [--include-project]`
14///
15/// Factory-reset: deletes ALL oxicode data.
16/// Optionally also deletes the project-local `.oxicode/` directory.
17pub fn handle_reset(yes: bool, include_project: bool) -> Result<()> {
18    use std::io::{self, Write};
19
20    // ── Collect targets ──────────────────────────────────────────
21    // Canonical home (`$OXICODE_HOME`, else `$OXI_HOME/oxicode`, else
22    // `~/.oxi/oxicode`). The legacy `~/.oxicode` is only touched when the
23    // canonical home does not exist (pre-unified-layout installs).
24    let oxicode_dir = oxicode_catalog::oxi_home::oxicode_home()
25        .ok_or_else(|| anyhow::anyhow!("Cannot determine oxicode home directory"))?;
26    let home =
27        dirs::home_dir().ok_or_else(|| anyhow::anyhow!("Cannot determine home directory"))?;
28
29    let config_oxicode_dir = dirs::config_dir()
30        .unwrap_or_else(|| home.join(".config"))
31        .join("oxicode");
32    let cache_oxicode_dir = dirs::cache_dir()
33        .unwrap_or_else(|| home.join(".cache"))
34        .join("oxicode");
35
36    let project_oxicode = std::env::current_dir().unwrap_or_default().join(".oxicode");
37    let (targets, project_target) = collect_reset_targets(
38        &oxicode_dir,
39        oxicode_catalog::oxi_home::legacy_home_dir().as_deref(),
40        oxicode_catalog::oxi_home::migration_journal_path().as_deref(),
41        &config_oxicode_dir,
42        &cache_oxicode_dir,
43        &project_oxicode,
44        include_project,
45    );
46
47    let total_count = targets.len() + usize::from(project_target.is_some());
48    if total_count == 0 {
49        println!("Nothing to reset — no oxicode data found.");
50        return Ok(());
51    }
52
53    // ── Calculate total size ─────────────────────────────────────
54    let mut total_bytes: u64 = 0;
55    for t in &targets {
56        total_bytes += dir_size_bytes(&t.path);
57    }
58    if let Some(ref pt) = project_target {
59        total_bytes += dir_size_bytes(&pt.path);
60    }
61
62    // ── Show what will be deleted ────────────────────────────────
63    eprintln!();
64    eprintln!("     ⚠ Warning: The following will be permanently deleted:");
65    eprintln!();
66    for (i, t) in targets.iter().enumerate() {
67        eprintln!(
68            "       {}. {} ({})",
69            i + 1,
70            display_path(&t.path),
71            dir_size_human(&t.path)
72        );
73        eprintln!("          {}", t.description);
74    }
75    if let Some(ref pt) = project_target {
76        eprintln!(
77            "       {}. {} ({})",
78            total_count,
79            display_path(&pt.path),
80            dir_size_human(&pt.path)
81        );
82        eprintln!("          {}", pt.description);
83    }
84    eprintln!();
85    eprintln!(
86        "     Total: {} item(s), {}",
87        total_count,
88        bytes_human(total_bytes)
89    );
90    eprintln!();
91    eprintln!(
92        "     This cannot be undone. All sessions, skills, extensions, and settings will be deleted."
93    );
94    eprintln!();
95
96    if !yes {
97        eprint!("     Type RESET to continue: ");
98        io::stdout().flush()?;
99        let mut input = String::new();
100        io::stdin().read_line(&mut input)?;
101        if input.trim() != "RESET" {
102            eprintln!();
103            eprintln!();
104            eprintln!("     Cancelled.");
105            return Ok(());
106        }
107    }
108
109    // ── Delete ───────────────────────────────────────────────────
110    eprintln!();
111    let mut errors = Vec::new();
112
113    for t in &targets {
114        eprint!("     ● Deleting {}...", t.label);
115        io::stdout().flush()?;
116        match remove_path(&t.path) {
117            Ok(()) => eprintln!(" done"),
118            Err(e) => {
119                eprintln!(" failed");
120                eprintln!("       ✗ {}: {}", t.label, e);
121                errors.push(format!("{}: {}", t.label, e));
122            }
123        }
124    }
125    if let Some(ref pt) = project_target {
126        eprint!("     ● Deleting {}...", pt.label);
127        io::stdout().flush()?;
128        match remove_path(&pt.path) {
129            Ok(()) => eprintln!(" done"),
130            Err(e) => {
131                eprintln!(" failed");
132                eprintln!("       ✗ {}: {}", pt.label, e);
133                errors.push(format!("{}: {}", pt.label, e));
134            }
135        }
136    }
137
138    eprintln!();
139    if errors.is_empty() {
140        eprintln!("     ✓ All oxicode data has been reset.");
141        eprintln!("     → Run 'oxicode setup' to reconfigure.");
142    } else {
143        eprintln!("     ⚠ {} item(s) failed to delete:", errors.len());
144        for err in &errors {
145            eprintln!("       • {}", err);
146        }
147        eprintln!("     Some data may need manual cleanup.");
148    }
149
150    Ok(())
151}
152
153/// Pure target collection for the reset command (testable with injected
154/// paths).
155///
156/// - The canonical oxicode home is reset when it exists; the legacy
157///   `~/.oxicode` only when the canonical home is absent.
158/// - The home-layout migration journal is collected when present (it is
159///   stale once the canonical home is gone).
160/// - `config_dir`/`cache_dir` handling is unchanged (always collected when
161///   present).
162/// - The project-local `.oxicode/` is collected only with `include_project`.
163fn collect_reset_targets(
164    oxicode_dir: &Path,
165    legacy_dir: Option<&Path>,
166    journal_path: Option<&Path>,
167    config_dir: &Path,
168    cache_dir: &Path,
169    project_dir: &Path,
170    include_project: bool,
171) -> (Vec<ResetTarget>, Option<ResetTarget>) {
172    let mut targets: Vec<ResetTarget> = vec![];
173
174    // Canonical home (or legacy, when canonical is absent) — split into
175    // sub-items for clarity.
176    let home_dir_to_reset: Option<&Path> = if oxicode_dir.exists() {
177        Some(oxicode_dir)
178    } else {
179        legacy_dir
180    };
181    if let Some(reset_dir) = home_dir_to_reset {
182        let sub_items = [
183            ("settings.toml", "global settings"),
184            ("settings.json", "global settings (JSON)"),
185            ("auth.json", "credentials (API keys, OAuth tokens)"),
186            ("sessions", "session history"),
187            ("skills", "skills"),
188            ("extensions", "extensions"),
189            ("packages", "packages"),
190        ];
191        let mut has_sub = false;
192        for (name, desc) in &sub_items {
193            let p = reset_dir.join(name);
194            if p.exists() {
195                has_sub = true;
196                targets.push(ResetTarget {
197                    label: display_path(&p),
198                    path: p,
199                    description: desc.to_string(),
200                });
201            }
202        }
203        // If no known sub-items found, target the whole directory
204        if !has_sub {
205            targets.push(ResetTarget {
206                label: display_path(reset_dir),
207                path: reset_dir.to_path_buf(),
208                description: "oxicode home (settings, sessions, skills, extensions, packages)"
209                    .to_string(),
210            });
211        }
212    }
213
214    // Home-layout migration journal.
215    if let Some(journal) = journal_path
216        && journal.exists()
217    {
218        targets.push(ResetTarget {
219            label: display_path(journal),
220            path: journal.to_path_buf(),
221            description: "home-layout migration journal".to_string(),
222        });
223    }
224
225    // ~/.config/oxicode/ — MCP config, alternative auth location
226    if config_dir.exists() {
227        targets.push(ResetTarget {
228            label: display_path(config_dir),
229            path: config_dir.to_path_buf(),
230            description: "MCP config, credentials".to_string(),
231        });
232    }
233
234    // ~/.cache/oxicode/ — logs
235    if cache_dir.exists() {
236        targets.push(ResetTarget {
237            label: display_path(cache_dir),
238            path: cache_dir.to_path_buf(),
239            description: "logs, cache".to_string(),
240        });
241    }
242
243    let mut project_target: Option<ResetTarget> = None;
244    if include_project && project_dir.exists() {
245        project_target = Some(ResetTarget {
246            label: display_path(project_dir),
247            path: project_dir.to_path_buf(),
248            description: "project settings".to_string(),
249        });
250    }
251
252    (targets, project_target)
253}
254
255/// Remove a file or directory (including all contents).
256pub fn remove_path(path: &Path) -> Result<()> {
257    if path.is_dir() {
258        std::fs::remove_dir_all(path)?;
259    } else {
260        std::fs::remove_file(path)?;
261    }
262    Ok(())
263}
264
265/// Display path with ~/ abbreviation for home directory.
266pub fn display_path(path: &Path) -> String {
267    if let Some(home) = dirs::home_dir() {
268        let home_str = home.to_string_lossy();
269        let path_str = path.to_string_lossy();
270        if let Some(rest) = path_str.strip_prefix(home_str.as_ref()) {
271            return format!("~{}", rest);
272        }
273    }
274    path.display().to_string()
275}
276
277/// Calculate total bytes in a directory or file.
278pub fn dir_size_bytes(path: &Path) -> u64 {
279    let mut total: u64 = 0;
280    if path.is_dir() {
281        if let Ok(entries) = walkdir_recursive(path) {
282            for entry in entries {
283                if let Ok(meta) = std::fs::metadata(&entry)
284                    && meta.is_file()
285                {
286                    total += meta.len();
287                }
288            }
289        }
290    } else if let Ok(meta) = std::fs::metadata(path) {
291        total = meta.len();
292    }
293    total
294}
295
296/// Calculate a human-readable directory or file size.
297pub fn dir_size_human(path: &Path) -> String {
298    bytes_human(dir_size_bytes(path))
299}
300
301/// Format bytes as a human-readable string.
302pub fn bytes_human(bytes: u64) -> String {
303    if bytes == 0 {
304        return "0 B".to_string();
305    }
306    const KB: u64 = 1024;
307    const MB: u64 = 1024 * KB;
308    const GB: u64 = 1024 * MB;
309    if bytes >= GB {
310        format!("{:.1} GB", bytes as f64 / GB as f64)
311    } else if bytes >= MB {
312        format!("{:.1} MB", bytes as f64 / MB as f64)
313    } else if bytes >= KB {
314        format!("{:.1} KB", bytes as f64 / KB as f64)
315    } else {
316        format!("{} B", bytes)
317    }
318}
319
320/// Walk a directory recursively, collecting all file paths.
321pub fn walkdir_recursive(dir: &Path) -> Result<Vec<PathBuf>> {
322    let mut result = Vec::new();
323    if dir.is_dir() {
324        for entry in std::fs::read_dir(dir)? {
325            let entry = entry?;
326            let path = entry.path();
327            if path.is_dir() {
328                result.extend(walkdir_recursive(&path)?);
329            } else {
330                result.push(path);
331            }
332        }
333    }
334    Ok(result)
335}
336
337#[cfg(test)]
338mod tests {
339    use super::*;
340
341    /// Canonical home present → legacy home is NOT targeted.
342    #[test]
343    fn canonical_home_wins_over_legacy() {
344        let tmp = tempfile::tempdir().unwrap();
345        let canonical = tmp.path().join("canonical-home");
346        let legacy = tmp.path().join("legacy-home");
347        let config = tmp.path().join("config");
348        let cache = tmp.path().join("cache");
349        let project = tmp.path().join("project").join(".oxicode");
350        std::fs::create_dir_all(&canonical).unwrap();
351        std::fs::create_dir_all(&legacy).unwrap();
352        std::fs::write(canonical.join("auth.json"), "{}").unwrap();
353        std::fs::write(legacy.join("auth.json"), "{}").unwrap();
354
355        let (targets, _) = collect_reset_targets(
356            &canonical,
357            Some(&legacy),
358            None,
359            &config,
360            &cache,
361            &project,
362            false,
363        );
364
365        let paths: Vec<&Path> = targets.iter().map(|t| t.path.as_path()).collect();
366        assert!(paths.contains(&canonical.join("auth.json").as_path()));
367        assert!(
368            !paths.iter().any(|p| p.starts_with(&legacy)),
369            "legacy home must not be reset while the canonical home exists"
370        );
371    }
372
373    /// Canonical home absent → legacy home is targeted.
374    #[test]
375    fn legacy_home_targeted_when_canonical_absent() {
376        let tmp = tempfile::tempdir().unwrap();
377        let canonical = tmp.path().join("canonical-home");
378        let legacy = tmp.path().join("legacy-home");
379        let config = tmp.path().join("config");
380        let cache = tmp.path().join("cache");
381        let project = tmp.path().join("project").join(".oxicode");
382        std::fs::create_dir_all(&legacy).unwrap();
383        std::fs::write(legacy.join("auth.json"), "{}").unwrap();
384
385        let (targets, _) = collect_reset_targets(
386            &canonical,
387            Some(&legacy),
388            None,
389            &config,
390            &cache,
391            &project,
392            false,
393        );
394
395        let paths: Vec<&Path> = targets.iter().map(|t| t.path.as_path()).collect();
396        assert!(paths.contains(&legacy.join("auth.json").as_path()));
397        assert!(!paths.iter().any(|p| p.starts_with(&canonical)));
398    }
399
400    /// Journal is collected when present; project dir only with the flag.
401    #[test]
402    fn journal_and_project_targeting() {
403        let tmp = tempfile::tempdir().unwrap();
404        let canonical = tmp.path().join("canonical-home");
405        let config = tmp.path().join("config");
406        let cache = tmp.path().join("cache");
407        let project = tmp.path().join("project").join(".oxicode");
408        std::fs::create_dir_all(&canonical).unwrap();
409        let journal = tmp.path().join("oxicode.migration-journal.json");
410        std::fs::write(&journal, "{}").unwrap();
411
412        let (targets, project_target) = collect_reset_targets(
413            &canonical,
414            None,
415            Some(&journal),
416            &config,
417            &cache,
418            &project,
419            false,
420        );
421        assert!(targets.iter().any(|t| t.path == journal));
422        assert!(project_target.is_none());
423
424        std::fs::create_dir_all(&project).unwrap();
425        let (_, project_target) = collect_reset_targets(
426            &canonical,
427            None,
428            Some(&journal),
429            &config,
430            &cache,
431            &project,
432            true,
433        );
434        assert!(project_target.is_some());
435    }
436}