Skip to main content

cli/
list.rs

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    // ── Shell Presets ────────────────────────────────────────────────────────
225    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    // ── App Configs ──────────────────────────────────────────────────────────
265    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}