1use crate::apps::load_active_categories;
2use crate::colors;
3use crate::config::Config;
4use crate::info::UpdateDiffs;
5use crate::output;
6use crate::status::{AppRow, FileStatus, ShellRow, build_app_rows, build_shell_rows};
7use crate::sys;
8use anyhow::Result;
9use std::collections::BTreeSet;
10
11const SHELL_PRESET_PRESENT_LINK_MISSING: &str = "preset present, bin symlink missing";
12
13pub async fn handle_update_list(config: &Config, diff: bool) -> Result<bool> {
14 let shell_rows = build_shell_rows(config).await?;
15 let update_shell: Vec<&ShellRow> = shell_rows
16 .iter()
17 .filter(|r| r.is_installed && r.status_sym == "↑")
18 .collect();
19
20 let cats_result = load_active_categories(config, None).await;
21 let app_rows = match cats_result {
22 Ok(cats) => build_app_rows(config, &cats).await?,
23 Err(_) => Vec::new(),
24 };
25 let update_app: Vec<&AppRow> = app_rows
26 .iter()
27 .filter(|r| r.file_status == FileStatus::UpdateAvail)
28 .collect();
29 let update_sys = sys::managed_updates(config).await.unwrap_or_default();
30
31 let any = !update_shell.is_empty() || !update_app.is_empty() || !update_sys.is_empty();
32 if !any {
33 return Ok(false);
34 }
35
36 let update_diffs = if diff {
37 Some(UpdateDiffs::collect(config).await?)
38 } else {
39 None
40 };
41
42 crate::config::print_presets_note(config);
43
44 if !update_shell.is_empty() {
45 println!("{}", colors::bold("Shell Presets"));
46
47 let label_width = update_shell
48 .iter()
49 .map(|r| r.label.len())
50 .max()
51 .unwrap_or(0);
52
53 for row in &update_shell {
54 let pad = " ".repeat(label_width.saturating_sub(row.label.len()));
55 println!(
56 " {} {}{} {} {}",
57 row.symbol,
58 row.label,
59 pad,
60 colors::status_label(row.status_text, row.status_sym),
61 colors::dim("run `shine upgrade`"),
62 );
63 if let Some(diffs) = &update_diffs {
64 diffs.print_shell_for_row(config, &row.label).await?;
65 }
66 }
67 }
68
69 if !update_app.is_empty() {
70 if !update_shell.is_empty() {
71 println!();
72 }
73 println!("{}", colors::bold("App Configs"));
74
75 let label_width = update_app.iter().map(|r| r.label.len()).max().unwrap_or(0);
76
77 for row in &update_app {
78 let pad = " ".repeat(label_width.saturating_sub(row.label.len()));
79 let dest_part = row
80 .dest
81 .as_deref()
82 .map(|d| format!(" {} {}", colors::dim("→"), colors::dim(d)))
83 .unwrap_or_default();
84
85 println!(
86 " {} {}{}{} {} {}",
87 colors::symbol(row.sym),
88 row.label,
89 pad,
90 dest_part,
91 colors::status_label(row.status_text, row.sym),
92 colors::dim("run `shine upgrade`"),
93 );
94 if let Some(diffs) = &update_diffs {
95 diffs.print_app_for_row(config, &row.label).await?;
96 }
97 }
98 }
99
100 if !update_sys.is_empty() {
101 if !update_shell.is_empty() || !update_app.is_empty() {
102 println!();
103 }
104 println!("{}", colors::bold("System Configs"));
105 for row in &update_sys {
106 println!(
107 " {} {} {} {} {}",
108 colors::symbol("↑"),
109 row.label,
110 colors::dim(&format!("({})", row.item_id)),
111 colors::status_label("update available", "↑"),
112 colors::dim("run `shine upgrade`"),
113 );
114 for detail in &row.details {
115 println!(" {}", colors::dim(detail));
116 }
117 }
118 }
119
120 Ok(true)
121}
122
123pub async fn handle_status_list(config: &Config, diff: bool) -> Result<()> {
124 crate::config::print_presets_note(config);
125 let shell_rows = build_shell_rows(config).await?;
126 let installed_shell: Vec<&ShellRow> = shell_rows.iter().filter(|r| r.is_installed).collect();
127
128 let cats_result = load_active_categories(config, None).await;
129 let app_rows = match cats_result {
130 Ok(cats) => build_app_rows(config, &cats).await?,
131 Err(_) => Vec::new(),
132 };
133 let installed_app: Vec<&AppRow> = app_rows
134 .iter()
135 .filter(|r| r.file_status != FileStatus::NotInstalled)
136 .collect();
137 let update_sys = sys::managed_updates(config).await.unwrap_or_default();
138
139 let any = !installed_shell.is_empty() || !installed_app.is_empty() || !update_sys.is_empty();
140
141 if !any {
142 println!(
143 "{}",
144 colors::dim("Nothing installed yet. Run `shine shell install` or `shine app install`.")
145 );
146 return Ok(());
147 }
148
149 let update_diffs = if diff {
150 Some(UpdateDiffs::collect(config).await?)
151 } else {
152 None
153 };
154
155 if !installed_shell.is_empty() {
157 println!("{}", colors::bold("Shell Presets"));
158
159 let label_width = installed_shell
160 .iter()
161 .map(|r| r.label.len())
162 .max()
163 .unwrap_or(0);
164
165 for row in &installed_shell {
166 let pad = " ".repeat(label_width.saturating_sub(row.label.len()));
167 let run_hint = if row.status_sym == "↑" {
168 format!(" {}", colors::dim("run `shine upgrade`"))
169 } else {
170 String::new()
171 };
172 println!(
173 " {} {}{} {}{}",
174 row.symbol,
175 row.label,
176 pad,
177 colors::status_label(row.status_text, row.status_sym),
178 run_hint,
179 );
180 if row.status_sym == "↑"
181 && let Some(diffs) = &update_diffs
182 {
183 diffs.print_shell_for_row(config, &row.label).await?;
184 }
185 }
186 }
187
188 if !installed_app.is_empty() {
190 if !installed_shell.is_empty() {
191 println!();
192 }
193 println!("{}", colors::bold("App Configs"));
194
195 let label_width = installed_app
196 .iter()
197 .map(|r| r.label.len())
198 .max()
199 .unwrap_or(0);
200
201 let mut up_to_date = 0usize;
202 let mut update_available = 0usize;
203 let mut user_modified = 0usize;
204 let mut missing = 0usize;
205
206 for row in &installed_app {
207 let pad = " ".repeat(label_width.saturating_sub(row.label.len()));
208 let dest_part = row
209 .dest
210 .as_deref()
211 .map(|d| format!(" {} {}", colors::dim("→"), colors::dim(d)))
212 .unwrap_or_default();
213
214 let run_hint = if row.sym == "↑" {
215 format!(" {}", colors::dim("run `shine upgrade`"))
216 } else {
217 String::new()
218 };
219
220 println!(
221 " {} {}{}{} {}{}",
222 colors::symbol(row.sym),
223 row.label,
224 pad,
225 dest_part,
226 colors::status_label(row.status_text, row.sym),
227 run_hint,
228 );
229
230 if row.file_status == FileStatus::UpdateAvail
231 && let Some(diffs) = &update_diffs
232 {
233 diffs.print_app_for_row(config, &row.label).await?;
234 }
235
236 match row.file_status {
237 FileStatus::Missing => missing += 1,
238 FileStatus::UserModified | FileStatus::Partial => user_modified += 1,
239 FileStatus::UpdateAvail => update_available += 1,
240 FileStatus::UpToDate => up_to_date += 1,
241 FileStatus::NotInstalled => {}
242 }
243 }
244
245 let parts = app_status_summary_parts(up_to_date, update_available, user_modified, missing);
246 if !parts.is_empty() {
247 output::footer("Summary", &parts);
248 }
249 }
250
251 if !update_sys.is_empty() {
252 if !installed_shell.is_empty() || !installed_app.is_empty() {
253 println!();
254 }
255 println!("{}", colors::bold("System Configs"));
256 for row in &update_sys {
257 println!(
258 " {} {} {} {} {}",
259 colors::symbol("↑"),
260 row.label,
261 colors::dim(&format!("({})", row.item_id)),
262 colors::status_label("update available", "↑"),
263 colors::dim("run `shine upgrade`"),
264 );
265 for detail in &row.details {
266 println!(" {}", colors::dim(detail));
267 }
268 }
269 }
270
271 Ok(())
272}
273
274pub async fn handle_list(config: &Config) -> Result<()> {
275 crate::config::print_presets_note(config);
276 let shell_rows = build_shell_rows(config).await?;
277 let installed_shell: Vec<String> = shell_rows
278 .iter()
279 .filter(|r| should_show_shell_in_simple_list(r))
280 .map(|r| r.label.clone())
281 .collect();
282
283 let cats_result = load_active_categories(config, None).await;
284 let installed_app = match cats_result {
285 Ok(cats) => {
286 let app_rows = build_app_rows(config, &cats).await?;
287 installed_app_categories(&app_rows)
288 }
289 Err(_) => Vec::new(),
290 };
291 let installed_sys = sys::installed_managed(config).await?;
292 let installed_sys: Vec<String> = installed_sys
293 .iter()
294 .map(|row| row.item_id.clone())
295 .collect();
296
297 let installed_shell = sorted_names(installed_shell);
298 let installed_app = sorted_names(installed_app);
299 let installed_sys = sorted_names(installed_sys);
300
301 let any = !installed_shell.is_empty() || !installed_app.is_empty() || !installed_sys.is_empty();
302
303 if !any {
304 println!(
305 "{}",
306 colors::dim(
307 "Nothing installed yet. Run `shine shell install`, `shine app install`, or `shine sys list`."
308 )
309 );
310 return Ok(());
311 }
312
313 let mut separator = output::SectionSeparator::new();
314 print_name_section(&mut separator, "Shell Presets", &installed_shell);
315 print_name_section(&mut separator, "App Configs", &installed_app);
316 print_name_section(&mut separator, "System Configs", &installed_sys);
317
318 Ok(())
319}
320
321fn print_name_section(separator: &mut output::SectionSeparator, title: &str, names: &[String]) {
322 if names.is_empty() {
323 return;
324 }
325
326 separator.begin();
327 println!("{} {}", colors::cyan("==>"), colors::bold(title));
328 output::print_columns(names);
329}
330
331fn installed_app_categories(rows: &[AppRow]) -> Vec<String> {
332 rows.iter()
333 .filter(|row| row.file_status != FileStatus::NotInstalled)
334 .map(|row| row.category.clone())
335 .collect::<BTreeSet<_>>()
336 .into_iter()
337 .collect()
338}
339
340fn sorted_names(mut names: Vec<String>) -> Vec<String> {
341 names.sort_by(|left, right| {
342 left.to_lowercase()
343 .cmp(&right.to_lowercase())
344 .then_with(|| left.cmp(right))
345 });
346 names
347}
348
349fn should_show_shell_in_simple_list(row: &ShellRow) -> bool {
350 row.is_installed && row.status_text != SHELL_PRESET_PRESENT_LINK_MISSING
351}
352
353fn app_status_summary_parts(
354 up_to_date: usize,
355 update_available: usize,
356 user_modified: usize,
357 missing: usize,
358) -> Vec<String> {
359 let mut parts = Vec::new();
360 output::push_count(&mut parts, up_to_date, colors::green, "up-to-date");
361 output::push_count(
362 &mut parts,
363 update_available,
364 colors::cyan,
365 "update available",
366 );
367 output::push_count(&mut parts, user_modified, colors::yellow, "user-modified");
368 output::push_count(&mut parts, missing, colors::yellow, "destination missing");
369 parts
370}
371
372#[cfg(test)]
373mod tests {
374 use super::*;
375
376 fn shell_row(status_text: &'static str, is_installed: bool) -> ShellRow {
377 ShellRow {
378 symbol: String::new(),
379 label: "proxy/setproxy".to_string(),
380 status_sym: "~",
381 status_text,
382 is_installed,
383 }
384 }
385
386 fn app_row(category: &str, file_status: FileStatus) -> AppRow {
387 AppRow {
388 category: category.to_string(),
389 sym: "✓",
390 label: category.to_string(),
391 simple_label: category.to_string(),
392 dest: None,
393 status_text: "up-to-date",
394 file_status,
395 }
396 }
397
398 #[test]
399 fn simple_list_hides_preset_present_when_bin_symlink_missing() {
400 let row = shell_row(SHELL_PRESET_PRESENT_LINK_MISSING, true);
401
402 assert!(!should_show_shell_in_simple_list(&row));
403 }
404
405 #[test]
406 fn simple_list_keeps_other_installed_shell_states() {
407 assert!(should_show_shell_in_simple_list(&shell_row(
408 "up-to-date",
409 true
410 )));
411 assert!(should_show_shell_in_simple_list(&shell_row(
412 "bin symlink present, preset missing",
413 true
414 )));
415 assert!(should_show_shell_in_simple_list(&shell_row(
416 "update available",
417 true
418 )));
419 }
420
421 #[test]
422 fn simple_list_hides_uninstalled_shell_rows() {
423 let row = shell_row("not installed", false);
424
425 assert!(!should_show_shell_in_simple_list(&row));
426 }
427
428 #[test]
429 fn simple_list_collapses_installed_app_files_to_their_category() {
430 let rows = vec![
431 app_row("surge", FileStatus::UpToDate),
432 app_row("surge", FileStatus::Missing),
433 app_row("ghostty", FileStatus::NotInstalled),
434 ];
435
436 assert_eq!(installed_app_categories(&rows), vec!["surge"]);
437 }
438
439 #[test]
440 fn simple_list_shows_partially_installed_app_categories() {
441 let rows = vec![
442 app_row("surge", FileStatus::NotInstalled),
443 app_row("surge", FileStatus::UserModified),
444 ];
445
446 assert_eq!(installed_app_categories(&rows), vec!["surge"]);
447 }
448
449 #[test]
450 fn simple_list_sorts_names_case_insensitively() {
451 assert_eq!(
452 sorted_names(vec![
453 "surge".to_string(),
454 "JetBrains".to_string(),
455 "ghostty".to_string(),
456 ]),
457 vec!["ghostty", "JetBrains", "surge"]
458 );
459 }
460
461 #[test]
462 fn app_status_summary_parts_includes_only_nonzero_counts() {
463 assert_eq!(
464 app_status_summary_parts(3, 1, 0, 0),
465 vec!["3 up-to-date".to_string(), "1 update available".to_string()]
466 );
467 }
468
469 #[test]
470 fn app_status_summary_parts_empty_when_all_zero() {
471 assert!(app_status_summary_parts(0, 0, 0, 0).is_empty());
472 }
473
474 #[test]
475 fn app_status_summary_parts_reports_all_four_counters() {
476 assert_eq!(
477 app_status_summary_parts(1, 2, 3, 4),
478 vec![
479 "1 up-to-date".to_string(),
480 "2 update available".to_string(),
481 "3 user-modified".to_string(),
482 "4 destination missing".to_string(),
483 ]
484 );
485 }
486}