1use crate::cli::ResetArgs;
7use crate::config::{Config, WorkspaceConfig, WorkspaceManager};
8use crate::errors::{AppError, Result};
9use crate::output::Output;
10use chrono::{DateTime, Utc};
11use std::io::{self, BufRead, Write};
12use std::path::PathBuf;
13
14enum ResetScope {
16 Everything,
17 ConfigOnly,
18 AllWorkspaces,
19 Workspace(PathBuf),
20}
21
22struct WorkspaceDetail {
24 root_path: PathBuf,
25 orgs: Vec<String>,
26 last_synced: Option<String>,
27 dot_dir: PathBuf,
28 cache_size: Option<u64>,
29}
30
31struct ResetTarget {
33 config_dir: PathBuf,
34 config_file: Option<PathBuf>,
35 workspaces: Vec<WorkspaceDetail>,
36}
37
38impl ResetTarget {
39 fn is_empty(&self) -> bool {
40 self.config_file.is_none() && self.workspaces.is_empty()
41 }
42
43 fn has_workspaces(&self) -> bool {
44 !self.workspaces.is_empty()
45 }
46}
47
48pub async fn run(args: &ResetArgs, output: &Output) -> Result<()> {
50 let target = discover_targets()?;
51
52 if target.is_empty() {
53 output.info("Nothing to reset — gisa is not configured.");
54 return Ok(());
55 }
56
57 if args.force {
59 display_detailed_targets(&ResetScope::Everything, &target, output);
60 execute_reset(&ResetScope::Everything, &target, output)?;
61 return Ok(());
62 }
63
64 let scope = prompt_scope(&target)?;
66 display_detailed_targets(&scope, &target, output);
67
68 if !confirm("\nAre you sure? [y/N] ")? {
69 output.info("Reset cancelled.");
70 return Ok(());
71 }
72
73 execute_reset(&scope, &target, output)?;
74 Ok(())
75}
76
77fn discover_targets() -> Result<ResetTarget> {
79 let config_path = Config::default_path()?;
80 let config_dir = config_path
81 .parent()
82 .ok_or_else(|| AppError::config("Cannot determine config directory"))?
83 .to_path_buf();
84
85 let config_file = if config_path.exists() {
86 Some(config_path)
87 } else {
88 None
89 };
90
91 let workspaces = WorkspaceManager::list()?
92 .iter()
93 .map(build_workspace_detail)
94 .collect::<Result<Vec<_>>>()?;
95
96 Ok(ResetTarget {
97 config_dir,
98 config_file,
99 workspaces,
100 })
101}
102
103fn build_workspace_detail(ws: &WorkspaceConfig) -> Result<WorkspaceDetail> {
105 let dot_dir = WorkspaceManager::dot_dir(&ws.root_path);
106 let cache_file = WorkspaceManager::cache_path(&ws.root_path);
107
108 let cache_size = if cache_file.exists() {
109 std::fs::metadata(&cache_file).map(|m| m.len()).ok()
110 } else {
111 None
112 };
113
114 Ok(WorkspaceDetail {
115 root_path: ws.root_path.clone(),
116 orgs: ws.orgs.clone(),
117 last_synced: ws.last_synced.clone(),
118 dot_dir,
119 cache_size,
120 })
121}
122
123fn display_detailed_targets(scope: &ResetScope, target: &ResetTarget, output: &Output) {
125 output.warn("The following will be permanently deleted:");
126
127 match scope {
128 ResetScope::Everything => {
129 if let Some(ref path) = target.config_file {
130 output.info(&format!(" Global config: {}", path.display()));
131 }
132 for ws in &target.workspaces {
133 display_workspace_detail(ws, output);
134 }
135 }
136 ResetScope::ConfigOnly => {
137 if let Some(ref path) = target.config_file {
138 output.info(&format!(" Global config: {}", path.display()));
139 }
140 }
141 ResetScope::AllWorkspaces => {
142 for ws in &target.workspaces {
143 display_workspace_detail(ws, output);
144 }
145 }
146 ResetScope::Workspace(path) => {
147 if let Some(ws) = target.workspaces.iter().find(|w| w.root_path == *path) {
148 display_workspace_detail(ws, output);
149 }
150 }
151 }
152}
153
154fn display_workspace_detail(ws: &WorkspaceDetail, output: &Output) {
156 let path_display = crate::config::workspace::tilde_collapse_path(&ws.root_path);
157 output.info(&format!(" Workspace at {}:", path_display));
158
159 if ws.orgs.is_empty() {
160 output.info(" Orgs: (all)");
161 } else {
162 output.info(&format!(
163 " Orgs: {} ({})",
164 ws.orgs.join(", "),
165 ws.orgs.len()
166 ));
167 }
168
169 let synced = ws
170 .last_synced
171 .as_deref()
172 .map(humanize_timestamp)
173 .unwrap_or_else(|| "never".to_string());
174 output.info(&format!(" Last synced: {}", synced));
175
176 if let Some(size) = ws.cache_size {
177 output.info(&format!(" Cache: {}", format_bytes(size)));
178 }
179
180 output.info(&format!(" Config dir: {}", ws.dot_dir.display()));
181}
182
183fn execute_reset(scope: &ResetScope, target: &ResetTarget, output: &Output) -> Result<()> {
185 let mut had_errors = false;
186
187 match scope {
188 ResetScope::Everything => {
189 for ws in &target.workspaces {
190 had_errors |= !remove_workspace_dir(ws, output);
191 }
192 if let Some(ref path) = target.config_file {
193 had_errors |= !remove_file(path, "config", output);
194 }
195 try_remove_empty_dir(&target.config_dir, output);
196 }
197 ResetScope::ConfigOnly => {
198 if let Some(ref path) = target.config_file {
199 had_errors |= !remove_file(path, "config", output);
200 }
201 }
202 ResetScope::AllWorkspaces => {
203 for ws in &target.workspaces {
204 had_errors |= !remove_workspace_dir(ws, output);
205 }
206 }
207 ResetScope::Workspace(path) => {
208 if let Some(ws) = target.workspaces.iter().find(|w| w.root_path == *path) {
209 had_errors |= !remove_workspace_dir(ws, output);
210 } else {
211 output.warn(&format!("Workspace '{}' not found.", path.display()));
212 had_errors = true;
213 }
214 }
215 }
216
217 if had_errors {
218 Err(AppError::config(
219 "Reset completed with one or more removal errors.",
220 ))
221 } else {
222 match scope {
223 ResetScope::Everything => {
224 output.success("Reset complete. Run 'gisa init' to start fresh.");
225 }
226 ResetScope::ConfigOnly => {
227 output.success("Global config removed.");
228 }
229 ResetScope::AllWorkspaces => {
230 output.success("All workspaces removed.");
231 }
232 ResetScope::Workspace(path) => {
233 output.success(&format!("Workspace '{}' removed.", path.display()));
234 }
235 }
236 Ok(())
237 }
238}
239
240fn remove_workspace_dir(ws: &WorkspaceDetail, output: &Output) -> bool {
241 let path_display = crate::config::workspace::tilde_collapse_path(&ws.root_path);
242 match std::fs::remove_dir_all(&ws.dot_dir) {
243 Ok(()) => {
244 let _ = Config::remove_from_registry(&path_display);
246 output.success(&format!("Removed workspace config at {}", path_display));
247 true
248 }
249 Err(e) => {
250 output.warn(&format!(
251 "Failed to remove workspace config at {}: {}",
252 path_display, e
253 ));
254 false
255 }
256 }
257}
258
259fn remove_file(path: &PathBuf, label: &str, output: &Output) -> bool {
260 match std::fs::remove_file(path) {
261 Ok(()) => {
262 output.success(&format!("Removed {}: {}", label, path.display()));
263 true
264 }
265 Err(e) => {
266 output.warn(&format!("Failed to remove {}: {}", label, e));
267 false
268 }
269 }
270}
271
272fn try_remove_empty_dir(dir: &PathBuf, output: &Output) {
273 if dir.exists() {
274 match std::fs::remove_dir(dir) {
275 Ok(()) => output.verbose(&format!("Removed directory: {}", dir.display())),
276 Err(_) => output.verbose(&format!(
277 "Config directory not empty, leaving: {}",
278 dir.display()
279 )),
280 }
281 }
282}
283
284fn prompt_scope(target: &ResetTarget) -> Result<ResetScope> {
288 eprintln!("What would you like to reset?");
289
290 let mut options: Vec<(&str, ResetScope)> = Vec::new();
291
292 if target.config_file.is_some() && target.has_workspaces() {
293 options.push((
294 "Everything (global config + all workspaces)",
295 ResetScope::Everything,
296 ));
297 }
298
299 if target.config_file.is_some() {
300 options.push(("Global config only", ResetScope::ConfigOnly));
301 }
302
303 if target.workspaces.len() > 1 {
304 options.push(("All workspaces", ResetScope::AllWorkspaces));
305 }
306
307 if target.has_workspaces() {
308 options.push((
309 "A specific workspace",
310 ResetScope::Workspace(PathBuf::new()),
311 ));
312 }
313
314 if options.len() == 1 {
316 let (_, scope) = options.remove(0);
317 return match scope {
318 ResetScope::Workspace(_) => prompt_workspace(&target.workspaces),
319 other => Ok(other),
320 };
321 }
322
323 for (i, (label, _)) in options.iter().enumerate() {
324 eprintln!(" {}. {}", i + 1, label);
325 }
326
327 let choice = prompt_number("> ", options.len())?;
328 let (_, scope) = options.remove(choice - 1);
329
330 match scope {
331 ResetScope::Workspace(_) => prompt_workspace(&target.workspaces),
332 other => Ok(other),
333 }
334}
335
336fn prompt_workspace(workspaces: &[WorkspaceDetail]) -> Result<ResetScope> {
338 eprintln!("\nSelect a workspace to delete:");
339 for (i, ws) in workspaces.iter().enumerate() {
340 let path_display = crate::config::workspace::tilde_collapse_path(&ws.root_path);
341 let orgs = if ws.orgs.is_empty() {
342 "all orgs".to_string()
343 } else {
344 format!("{} org(s)", ws.orgs.len())
345 };
346 let synced = ws
347 .last_synced
348 .as_deref()
349 .map(humanize_timestamp)
350 .unwrap_or_else(|| "never synced".to_string());
351 eprintln!(" {}. {} ({}, {})", i + 1, path_display, orgs, synced);
352 }
353
354 let choice = prompt_number("> ", workspaces.len())?;
355 Ok(ResetScope::Workspace(
356 workspaces[choice - 1].root_path.clone(),
357 ))
358}
359
360fn prompt_number(prompt: &str, max: usize) -> Result<usize> {
362 loop {
363 eprint!("{}", prompt);
364 io::stderr().flush()?;
365
366 let stdin = io::stdin();
367 let mut line = String::new();
368 let bytes_read = stdin.lock().read_line(&mut line)?;
369 if bytes_read == 0 {
370 return Err(AppError::Interrupted);
371 }
372
373 match line.trim().parse::<usize>() {
374 Ok(n) if n >= 1 && n <= max => return Ok(n),
375 _ => eprintln!("Please enter a number between 1 and {}.", max),
376 }
377 }
378}
379
380fn confirm(prompt: &str) -> Result<bool> {
382 eprint!("{}", prompt);
383 io::stderr().flush()?;
384
385 let stdin = io::stdin();
386 let mut line = String::new();
387 stdin.lock().read_line(&mut line)?;
388
389 let answer = line.trim().to_lowercase();
390 Ok(answer == "y" || answer == "yes")
391}
392
393fn humanize_timestamp(ts: &str) -> String {
397 let parsed = ts
398 .parse::<DateTime<Utc>>()
399 .or_else(|_| DateTime::parse_from_rfc3339(ts).map(|dt| dt.with_timezone(&Utc)));
400
401 let Ok(dt) = parsed else {
402 return ts.to_string();
403 };
404
405 let duration = Utc::now().signed_duration_since(dt);
406
407 if duration.num_days() > 30 {
408 format!("{}mo ago", duration.num_days() / 30)
409 } else if duration.num_days() > 0 {
410 format!("{}d ago", duration.num_days())
411 } else if duration.num_hours() > 0 {
412 format!("{}h ago", duration.num_hours())
413 } else if duration.num_minutes() > 0 {
414 format!("{}m ago", duration.num_minutes())
415 } else {
416 "just now".to_string()
417 }
418}
419
420fn format_bytes(bytes: u64) -> String {
422 if bytes >= 1_048_576 {
423 format!("{:.1} MB", bytes as f64 / 1_048_576.0)
424 } else if bytes >= 1024 {
425 format!("{:.1} KB", bytes as f64 / 1024.0)
426 } else {
427 format!("{} B", bytes)
428 }
429}
430
431#[cfg(test)]
432#[path = "reset_tests.rs"]
433mod tests;