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::{BTreeMap, 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_app = app_update_categories(&update_app);
30 let update_sys = sys::managed_updates(config).await.unwrap_or_default();
31
32 let any = !update_shell.is_empty() || !update_app.is_empty() || !update_sys.is_empty();
33 if !any {
34 return Ok(false);
35 }
36
37 crate::config::print_presets_note(config);
38
39 if !diff {
40 let shell_names = shell_categories(&update_shell);
41 let app_names = update_app
42 .keys()
43 .map(|category| (*category).to_string())
44 .collect::<Vec<_>>();
45 let sys_names = sorted_names(update_sys.iter().map(|row| row.item_id.clone()).collect());
46
47 let mut separator = output::SectionSeparator::new();
48 print_name_section(&mut separator, "Shell Presets", &shell_names);
49 print_name_section(&mut separator, "App Configs", &app_names);
50 print_name_section(&mut separator, "System Configs", &sys_names);
51 print_update_hint();
52 return Ok(true);
53 }
54
55 let update_diffs = UpdateDiffs::collect(config).await?;
56
57 if !update_shell.is_empty() {
58 println!("{}", colors::bold("Shell Presets"));
59
60 let label_width = update_shell
61 .iter()
62 .map(|r| r.label.len())
63 .max()
64 .unwrap_or(0);
65
66 for row in &update_shell {
67 let pad = " ".repeat(label_width.saturating_sub(row.label.len()));
68 println!(
69 " {} {}{} {}",
70 row.symbol,
71 row.label,
72 pad,
73 colors::status_label(row.status_text, row.status_sym),
74 );
75 update_diffs.print_shell_for_row(config, &row.label).await?;
76 }
77 }
78
79 if !update_app.is_empty() {
80 if !update_shell.is_empty() {
81 println!();
82 }
83 println!("{}", colors::bold("App Configs"));
84
85 let label_width = update_app
86 .keys()
87 .map(|category| category.len())
88 .max()
89 .unwrap_or(0);
90
91 for (category, rows) in &update_app {
92 let pad = " ".repeat(label_width.saturating_sub(category.len()));
93 println!(
94 " {} {}{} {}",
95 colors::symbol("↑"),
96 category,
97 pad,
98 colors::status_label("update available", "↑"),
99 );
100 for row in rows {
101 print_app_update_detail(row);
102 update_diffs.print_app_for_row(config, &row.label).await?;
103 }
104 }
105 }
106
107 if !update_sys.is_empty() {
108 if !update_shell.is_empty() || !update_app.is_empty() {
109 println!();
110 }
111 println!("{}", colors::bold("System Configs"));
112 for row in &update_sys {
113 println!(
114 " {} {} {} {}",
115 colors::symbol("↑"),
116 row.label,
117 colors::dim(&format!("({})", row.item_id)),
118 colors::status_label("update available", "↑"),
119 );
120 for detail in &row.details {
121 println!(" {}", colors::dim(detail));
122 }
123 }
124 }
125
126 print_update_hint();
127
128 Ok(true)
129}
130
131fn print_update_hint() {
132 println!();
133 println!("{}", colors::dim("Run `shine upgrade` to apply updates."));
134}
135
136fn app_update_categories<'a>(rows: &[&'a AppRow]) -> BTreeMap<&'a str, Vec<&'a AppRow>> {
137 let mut categories = BTreeMap::new();
138 for row in rows {
139 categories
140 .entry(row.category.as_str())
141 .or_insert_with(Vec::new)
142 .push(*row);
143 }
144 categories
145}
146
147fn print_app_update_detail(row: &AppRow) {
148 let destination = row
149 .dest
150 .as_deref()
151 .map(|dest| format!(" {} {}", colors::dim("→"), colors::dim(dest)))
152 .unwrap_or_default();
153 println!(
154 " {} {}{} {}",
155 colors::symbol("↑"),
156 row.label,
157 destination,
158 colors::status_label("update available", "↑"),
159 );
160}
161
162pub async fn handle_status_list(config: &Config, diff: bool) -> Result<()> {
163 crate::config::print_presets_note(config);
164 let shell_rows = build_shell_rows(config).await?;
165 let installed_shell: Vec<&ShellRow> = shell_rows.iter().filter(|r| r.is_installed).collect();
166 let all_shell: Vec<&ShellRow> = shell_rows.iter().collect();
167
168 let cats_result = load_active_categories(config, None).await;
169 let app_rows = match cats_result {
170 Ok(cats) => build_app_rows(config, &cats).await?,
171 Err(_) => Vec::new(),
172 };
173 let installed_app: Vec<&AppRow> = app_rows
174 .iter()
175 .filter(|r| r.file_status != FileStatus::NotInstalled)
176 .collect();
177 let all_app: Vec<&AppRow> = app_rows.iter().collect();
178 let update_sys = sys::managed_updates(config).await.unwrap_or_default();
179
180 let any = !installed_shell.is_empty() || !installed_app.is_empty() || !update_sys.is_empty();
181
182 if !any {
183 println!(
184 "{}",
185 colors::dim("Nothing installed yet. Run `shine shell install` or `shine app install`.")
186 );
187 return Ok(());
188 }
189
190 let update_diffs = if diff {
191 Some(UpdateDiffs::collect(config).await?)
192 } else {
193 None
194 };
195 let shell_statuses = if diff {
196 installed_shell
197 .iter()
198 .map(|row| ShellLifecycleStatus {
199 category: row.category.clone(),
200 detail_label: row.label.clone(),
201 status_sym: row.status_sym,
202 status_text: row.status_text,
203 })
204 .collect()
205 } else {
206 shell_category_statuses(&all_shell)
207 };
208 let app_statuses = if diff {
209 installed_app
210 .iter()
211 .map(|row| AppLifecycleStatus {
212 category: row.category.clone(),
213 detail_label: row.label.clone(),
214 sym: row.sym,
215 status_text: row.status_text,
216 file_status: row.file_status,
217 dest: row.dest.clone(),
218 })
219 .collect()
220 } else {
221 app_category_statuses(&all_app)
222 };
223
224 if !installed_shell.is_empty() {
226 println!("{}", colors::bold("Shell Presets"));
227
228 let label_width = if diff {
229 installed_shell.iter().map(|row| row.label.len()).max()
230 } else {
231 shell_statuses.iter().map(|row| row.category.len()).max()
232 }
233 .unwrap_or(0);
234
235 for row in &shell_statuses {
236 let label = if diff {
237 &row.detail_label
238 } else {
239 &row.category
240 };
241 let pad = " ".repeat(label_width.saturating_sub(label.len()));
242 let run_hint = if row.status_sym == "↑" {
243 format!(" {}", colors::dim("run `shine upgrade`"))
244 } else {
245 String::new()
246 };
247 println!(
248 " {} {}{} {}{}",
249 colors::symbol(row.status_sym),
250 label,
251 pad,
252 colors::status_label(row.status_text, row.status_sym),
253 run_hint,
254 );
255 if diff
256 && row.status_sym == "↑"
257 && let Some(diffs) = &update_diffs
258 {
259 diffs.print_shell_for_row(config, &row.detail_label).await?;
260 }
261 }
262 }
263
264 if !installed_app.is_empty() {
266 if !installed_shell.is_empty() {
267 println!();
268 }
269 println!("{}", colors::bold("App Configs"));
270
271 let label_width = if diff {
272 installed_app.iter().map(|row| row.label.len()).max()
273 } else {
274 app_statuses.iter().map(|row| row.category.len()).max()
275 }
276 .unwrap_or(0);
277
278 let mut up_to_date = 0usize;
279 let mut update_available = 0usize;
280 let mut user_modified = 0usize;
281 let mut missing = 0usize;
282
283 for row in &app_statuses {
284 let label = if diff {
285 &row.detail_label
286 } else {
287 &row.category
288 };
289 let pad = " ".repeat(label_width.saturating_sub(label.len()));
290 let dest_part = if diff {
291 row.dest
292 .as_deref()
293 .map(|d| format!(" {} {}", colors::dim("→"), colors::dim(d)))
294 .unwrap_or_default()
295 } else {
296 String::new()
297 };
298
299 let run_hint = if row.sym == "↑" {
300 format!(" {}", colors::dim("run `shine upgrade`"))
301 } else {
302 String::new()
303 };
304
305 println!(
306 " {} {}{}{} {}{}",
307 colors::symbol(row.sym),
308 label,
309 pad,
310 dest_part,
311 colors::status_label(row.status_text, row.sym),
312 run_hint,
313 );
314
315 if diff
316 && row.file_status == FileStatus::UpdateAvail
317 && let Some(diffs) = &update_diffs
318 {
319 diffs.print_app_for_row(config, &row.detail_label).await?;
320 }
321
322 match row.file_status {
323 FileStatus::Missing => missing += 1,
324 FileStatus::UserModified | FileStatus::Partial => user_modified += 1,
325 FileStatus::UpdateAvail => update_available += 1,
326 FileStatus::UpToDate => up_to_date += 1,
327 FileStatus::NotInstalled => {}
328 }
329 }
330
331 let parts = app_status_summary_parts(up_to_date, update_available, user_modified, missing);
332 if !parts.is_empty() {
333 output::footer("Summary", &parts);
334 }
335 }
336
337 if !update_sys.is_empty() {
338 if !installed_shell.is_empty() || !installed_app.is_empty() {
339 println!();
340 }
341 println!("{}", colors::bold("System Configs"));
342 for row in &update_sys {
343 println!(
344 " {} {} {} {} {}",
345 colors::symbol("↑"),
346 row.label,
347 colors::dim(&format!("({})", row.item_id)),
348 colors::status_label("update available", "↑"),
349 colors::dim("run `shine upgrade`"),
350 );
351 for detail in &row.details {
352 println!(" {}", colors::dim(detail));
353 }
354 }
355 }
356
357 Ok(())
358}
359
360pub async fn handle_list(config: &Config) -> Result<()> {
361 crate::config::print_presets_note(config);
362 let shell_rows = build_shell_rows(config).await?;
363 let installed_shell: Vec<String> = shell_rows
364 .iter()
365 .filter(|r| should_show_shell_in_simple_list(r))
366 .map(|r| r.category.clone())
367 .collect::<BTreeSet<_>>()
368 .into_iter()
369 .collect();
370
371 let cats_result = load_active_categories(config, None).await;
372 let installed_app = match cats_result {
373 Ok(cats) => {
374 let app_rows = build_app_rows(config, &cats).await?;
375 installed_app_categories(&app_rows)
376 }
377 Err(_) => Vec::new(),
378 };
379 let installed_sys = sys::installed_managed(config).await?;
380 let installed_sys: Vec<String> = installed_sys
381 .iter()
382 .map(|row| row.item_id.clone())
383 .collect();
384
385 let installed_shell = sorted_names(installed_shell);
386 let installed_app = sorted_names(installed_app);
387 let installed_sys = sorted_names(installed_sys);
388
389 let any = !installed_shell.is_empty() || !installed_app.is_empty() || !installed_sys.is_empty();
390
391 if !any {
392 println!(
393 "{}",
394 colors::dim(
395 "Nothing installed yet. Run `shine shell install`, `shine app install`, or `shine sys list`."
396 )
397 );
398 return Ok(());
399 }
400
401 let mut separator = output::SectionSeparator::new();
402 print_name_section(&mut separator, "Shell Presets", &installed_shell);
403 print_name_section(&mut separator, "App Configs", &installed_app);
404 print_name_section(&mut separator, "System Configs", &installed_sys);
405
406 Ok(())
407}
408
409fn print_name_section(separator: &mut output::SectionSeparator, title: &str, names: &[String]) {
410 if names.is_empty() {
411 return;
412 }
413
414 separator.begin();
415 println!("{} {}", colors::cyan("==>"), colors::bold(title));
416 output::print_columns(names);
417}
418
419fn installed_app_categories(rows: &[AppRow]) -> Vec<String> {
420 rows.iter()
421 .filter(|row| row.file_status != FileStatus::NotInstalled)
422 .map(|row| row.category.clone())
423 .collect::<BTreeSet<_>>()
424 .into_iter()
425 .collect()
426}
427
428fn shell_categories(rows: &[&ShellRow]) -> Vec<String> {
429 rows.iter()
430 .map(|row| row.category.clone())
431 .collect::<BTreeSet<_>>()
432 .into_iter()
433 .collect()
434}
435
436struct ShellLifecycleStatus {
437 category: String,
438 detail_label: String,
439 status_sym: &'static str,
440 status_text: &'static str,
441}
442
443fn shell_category_statuses(rows: &[&ShellRow]) -> Vec<ShellLifecycleStatus> {
444 let mut grouped: BTreeMap<&str, Vec<&ShellRow>> = BTreeMap::new();
445 for row in rows {
446 grouped.entry(&row.category).or_default().push(row);
447 }
448 grouped
449 .into_iter()
450 .filter_map(|(category, rows)| {
451 if !rows.iter().any(|row| row.is_installed) {
452 return None;
453 }
454 if rows.len() == 1 {
455 let row = rows[0];
456 return Some(ShellLifecycleStatus {
457 category: category.to_string(),
458 detail_label: row.label.clone(),
459 status_sym: row.status_sym,
460 status_text: row.status_text,
461 });
462 }
463 let selected = rows
464 .iter()
465 .filter(|row| row.is_installed)
466 .max_by_key(|row| shell_status_priority(row.status_sym))
467 .expect("grouped shell category is non-empty");
468 let partially_installed = rows.iter().any(|row| !row.is_installed);
469 let (status_sym, status_text) = if partially_installed && selected.status_sym == "✓" {
470 ("~", "partial install")
471 } else {
472 (selected.status_sym, selected.status_text)
473 };
474 Some(ShellLifecycleStatus {
475 category: category.to_string(),
476 detail_label: category.to_string(),
477 status_sym,
478 status_text,
479 })
480 })
481 .collect()
482}
483
484fn shell_status_priority(sym: &str) -> usize {
485 match sym {
486 "!" => 4,
487 "~" => 3,
488 "↑" => 2,
489 "✓" => 1,
490 _ => 0,
491 }
492}
493
494struct AppLifecycleStatus {
495 category: String,
496 detail_label: String,
497 sym: &'static str,
498 status_text: &'static str,
499 file_status: FileStatus,
500 dest: Option<String>,
501}
502
503fn app_category_statuses(rows: &[&AppRow]) -> Vec<AppLifecycleStatus> {
504 let mut grouped: BTreeMap<&str, Vec<&AppRow>> = BTreeMap::new();
505 for row in rows {
506 grouped.entry(&row.category).or_default().push(row);
507 }
508 grouped
509 .into_iter()
510 .filter_map(|(category, rows)| {
511 let has_installed = rows
512 .iter()
513 .any(|row| row.file_status != FileStatus::NotInstalled);
514 if !has_installed {
515 return None;
516 }
517 if rows.len() == 1 {
518 let row = rows[0];
519 return Some(AppLifecycleStatus {
520 category: category.to_string(),
521 detail_label: row.label.clone(),
522 sym: row.sym,
523 status_text: row.status_text,
524 file_status: row.file_status,
525 dest: row.dest.clone(),
526 });
527 }
528 let has_not_installed = rows
529 .iter()
530 .any(|row| row.file_status == FileStatus::NotInstalled);
531 let installed_max = rows
532 .iter()
533 .map(|row| row.file_status)
534 .filter(|status| *status != FileStatus::NotInstalled)
535 .max()
536 .expect("installed app category has an installed row");
537 let status = if has_not_installed && installed_max == FileStatus::UpToDate {
538 FileStatus::Partial
539 } else {
540 installed_max
541 };
542 let (sym, status_text) = match status {
543 FileStatus::Missing => ("!", "destination missing"),
544 FileStatus::UserModified => ("~", "user modified"),
545 FileStatus::Partial => ("~", "partial install"),
546 FileStatus::UpdateAvail => ("↑", "update available"),
547 FileStatus::UpToDate => ("✓", "up-to-date"),
548 FileStatus::NotInstalled => unreachable!(),
549 };
550 Some(AppLifecycleStatus {
551 category: category.to_string(),
552 detail_label: category.to_string(),
553 sym,
554 status_text,
555 file_status: status,
556 dest: None,
557 })
558 })
559 .collect()
560}
561
562fn sorted_names(mut names: Vec<String>) -> Vec<String> {
563 names.sort_by(|left, right| {
564 left.to_lowercase()
565 .cmp(&right.to_lowercase())
566 .then_with(|| left.cmp(right))
567 });
568 names
569}
570
571fn should_show_shell_in_simple_list(row: &ShellRow) -> bool {
572 row.is_installed && row.status_text != SHELL_PRESET_PRESENT_LINK_MISSING
573}
574
575fn app_status_summary_parts(
576 up_to_date: usize,
577 update_available: usize,
578 user_modified: usize,
579 missing: usize,
580) -> Vec<String> {
581 let mut parts = Vec::new();
582 output::push_count(&mut parts, up_to_date, colors::green, "up-to-date");
583 output::push_count(
584 &mut parts,
585 update_available,
586 colors::cyan,
587 "update available",
588 );
589 output::push_count(&mut parts, user_modified, colors::yellow, "user-modified");
590 output::push_count(&mut parts, missing, colors::yellow, "destination missing");
591 parts
592}
593
594#[cfg(test)]
595mod tests {
596 use super::*;
597
598 fn shell_row(status_text: &'static str, is_installed: bool) -> ShellRow {
599 ShellRow {
600 category: "proxy".to_string(),
601 symbol: String::new(),
602 label: "proxy/setproxy".to_string(),
603 status_sym: "~",
604 status_text,
605 is_installed,
606 changes: Vec::new(),
607 }
608 }
609
610 fn app_row(category: &str, file_status: FileStatus) -> AppRow {
611 AppRow {
612 category: category.to_string(),
613 sym: "✓",
614 label: category.to_string(),
615 simple_label: category.to_string(),
616 dest: None,
617 status_text: "up-to-date",
618 file_status,
619 }
620 }
621
622 #[test]
623 fn update_rows_group_app_files_by_category() {
624 let first = app_row("clash-verge", FileStatus::UpdateAvail);
625 let mut second = app_row("clash-verge", FileStatus::UpdateAvail);
626 second.label = "clash-verge/rules/lan.list".to_string();
627 let other = app_row("surge", FileStatus::UpdateAvail);
628 let grouped = app_update_categories(&[&first, &second, &other]);
629
630 assert_eq!(grouped.len(), 2);
631 assert_eq!(grouped["clash-verge"].len(), 2);
632 assert_eq!(grouped["surge"].len(), 1);
633 }
634
635 #[test]
636 fn update_rows_collapse_shell_commands_to_their_category() {
637 let first = shell_row("update available", true);
638 let mut second = shell_row("update available", true);
639 second.label = "proxy/usetproxy".to_string();
640
641 assert_eq!(shell_categories(&[&first, &second]), vec!["proxy"]);
642 }
643
644 #[test]
645 fn default_shell_status_collapses_commands_and_reports_partial_install() {
646 let mut installed = shell_row("up-to-date", true);
647 installed.status_sym = "✓";
648 let mut missing = shell_row("not installed", false);
649 missing.label = "proxy/usetproxy".to_string();
650 missing.status_sym = "✗";
651
652 let statuses = shell_category_statuses(&[&installed, &missing]);
653
654 assert_eq!(statuses.len(), 1);
655 assert_eq!(statuses[0].category, "proxy");
656 assert_eq!(statuses[0].status_text, "partial install");
657 }
658
659 #[test]
660 fn default_app_status_collapses_files_and_reports_partial_install() {
661 let installed = app_row("surge", FileStatus::UpToDate);
662 let missing = app_row("surge", FileStatus::NotInstalled);
663
664 let statuses = app_category_statuses(&[&installed, &missing]);
665
666 assert_eq!(statuses.len(), 1);
667 assert_eq!(statuses[0].category, "surge");
668 assert_eq!(statuses[0].file_status, FileStatus::Partial);
669 }
670
671 #[test]
672 fn simple_list_hides_preset_present_when_bin_symlink_missing() {
673 let row = shell_row(SHELL_PRESET_PRESENT_LINK_MISSING, true);
674
675 assert!(!should_show_shell_in_simple_list(&row));
676 }
677
678 #[test]
679 fn simple_list_keeps_other_installed_shell_states() {
680 assert!(should_show_shell_in_simple_list(&shell_row(
681 "up-to-date",
682 true
683 )));
684 assert!(should_show_shell_in_simple_list(&shell_row(
685 "bin symlink present, preset missing",
686 true
687 )));
688 assert!(should_show_shell_in_simple_list(&shell_row(
689 "update available",
690 true
691 )));
692 }
693
694 #[test]
695 fn simple_list_hides_uninstalled_shell_rows() {
696 let row = shell_row("not installed", false);
697
698 assert!(!should_show_shell_in_simple_list(&row));
699 }
700
701 #[test]
702 fn simple_list_collapses_installed_app_files_to_their_category() {
703 let rows = vec![
704 app_row("surge", FileStatus::UpToDate),
705 app_row("surge", FileStatus::Missing),
706 app_row("ghostty", FileStatus::NotInstalled),
707 ];
708
709 assert_eq!(installed_app_categories(&rows), vec!["surge"]);
710 }
711
712 #[test]
713 fn simple_list_shows_partially_installed_app_categories() {
714 let rows = vec![
715 app_row("surge", FileStatus::NotInstalled),
716 app_row("surge", FileStatus::UserModified),
717 ];
718
719 assert_eq!(installed_app_categories(&rows), vec!["surge"]);
720 }
721
722 #[test]
723 fn simple_list_sorts_names_case_insensitively() {
724 assert_eq!(
725 sorted_names(vec![
726 "surge".to_string(),
727 "JetBrains".to_string(),
728 "ghostty".to_string(),
729 ]),
730 vec!["ghostty", "JetBrains", "surge"]
731 );
732 }
733
734 #[test]
735 fn app_status_summary_parts_includes_only_nonzero_counts() {
736 assert_eq!(
737 app_status_summary_parts(3, 1, 0, 0),
738 vec!["3 up-to-date".to_string(), "1 update available".to_string()]
739 );
740 }
741
742 #[test]
743 fn app_status_summary_parts_empty_when_all_zero() {
744 assert!(app_status_summary_parts(0, 0, 0, 0).is_empty());
745 }
746
747 #[test]
748 fn app_status_summary_parts_reports_all_four_counters() {
749 assert_eq!(
750 app_status_summary_parts(1, 2, 3, 4),
751 vec![
752 "1 up-to-date".to_string(),
753 "2 update available".to_string(),
754 "3 user-modified".to_string(),
755 "4 destination missing".to_string(),
756 ]
757 );
758 }
759}