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 = sorted_names(update_shell.iter().map(|row| row.label.clone()).collect());
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
167    let cats_result = load_active_categories(config, None).await;
168    let app_rows = match cats_result {
169        Ok(cats) => build_app_rows(config, &cats).await?,
170        Err(_) => Vec::new(),
171    };
172    let installed_app: Vec<&AppRow> = app_rows
173        .iter()
174        .filter(|r| r.file_status != FileStatus::NotInstalled)
175        .collect();
176    let update_sys = sys::managed_updates(config).await.unwrap_or_default();
177
178    let any = !installed_shell.is_empty() || !installed_app.is_empty() || !update_sys.is_empty();
179
180    if !any {
181        println!(
182            "{}",
183            colors::dim("Nothing installed yet. Run `shine shell install` or `shine app install`.")
184        );
185        return Ok(());
186    }
187
188    let update_diffs = if diff {
189        Some(UpdateDiffs::collect(config).await?)
190    } else {
191        None
192    };
193
194    // ── Shell Presets ────────────────────────────────────────────────────────
195    if !installed_shell.is_empty() {
196        println!("{}", colors::bold("Shell Presets"));
197
198        let label_width = installed_shell
199            .iter()
200            .map(|r| r.label.len())
201            .max()
202            .unwrap_or(0);
203
204        for row in &installed_shell {
205            let pad = " ".repeat(label_width.saturating_sub(row.label.len()));
206            let run_hint = if row.status_sym == "↑" {
207                format!("  {}", colors::dim("run `shine upgrade`"))
208            } else {
209                String::new()
210            };
211            println!(
212                "  {}  {}{}  {}{}",
213                row.symbol,
214                row.label,
215                pad,
216                colors::status_label(row.status_text, row.status_sym),
217                run_hint,
218            );
219            if row.status_sym == "↑"
220                && let Some(diffs) = &update_diffs
221            {
222                diffs.print_shell_for_row(config, &row.label).await?;
223            }
224        }
225    }
226
227    // ── App Configs ──────────────────────────────────────────────────────────
228    if !installed_app.is_empty() {
229        if !installed_shell.is_empty() {
230            println!();
231        }
232        println!("{}", colors::bold("App Configs"));
233
234        let label_width = installed_app
235            .iter()
236            .map(|r| r.label.len())
237            .max()
238            .unwrap_or(0);
239
240        let mut up_to_date = 0usize;
241        let mut update_available = 0usize;
242        let mut user_modified = 0usize;
243        let mut missing = 0usize;
244
245        for row in &installed_app {
246            let pad = " ".repeat(label_width.saturating_sub(row.label.len()));
247            let dest_part = row
248                .dest
249                .as_deref()
250                .map(|d| format!("  {}  {}", colors::dim("→"), colors::dim(d)))
251                .unwrap_or_default();
252
253            let run_hint = if row.sym == "↑" {
254                format!("  {}", colors::dim("run `shine upgrade`"))
255            } else {
256                String::new()
257            };
258
259            println!(
260                "  {}  {}{}{}  {}{}",
261                colors::symbol(row.sym),
262                row.label,
263                pad,
264                dest_part,
265                colors::status_label(row.status_text, row.sym),
266                run_hint,
267            );
268
269            if row.file_status == FileStatus::UpdateAvail
270                && let Some(diffs) = &update_diffs
271            {
272                diffs.print_app_for_row(config, &row.label).await?;
273            }
274
275            match row.file_status {
276                FileStatus::Missing => missing += 1,
277                FileStatus::UserModified | FileStatus::Partial => user_modified += 1,
278                FileStatus::UpdateAvail => update_available += 1,
279                FileStatus::UpToDate => up_to_date += 1,
280                FileStatus::NotInstalled => {}
281            }
282        }
283
284        let parts = app_status_summary_parts(up_to_date, update_available, user_modified, missing);
285        if !parts.is_empty() {
286            output::footer("Summary", &parts);
287        }
288    }
289
290    if !update_sys.is_empty() {
291        if !installed_shell.is_empty() || !installed_app.is_empty() {
292            println!();
293        }
294        println!("{}", colors::bold("System Configs"));
295        for row in &update_sys {
296            println!(
297                "  {}  {}  {}  {}  {}",
298                colors::symbol("↑"),
299                row.label,
300                colors::dim(&format!("({})", row.item_id)),
301                colors::status_label("update available", "↑"),
302                colors::dim("run `shine upgrade`"),
303            );
304            for detail in &row.details {
305                println!("     {}", colors::dim(detail));
306            }
307        }
308    }
309
310    Ok(())
311}
312
313pub async fn handle_list(config: &Config) -> Result<()> {
314    crate::config::print_presets_note(config);
315    let shell_rows = build_shell_rows(config).await?;
316    let installed_shell: Vec<String> = shell_rows
317        .iter()
318        .filter(|r| should_show_shell_in_simple_list(r))
319        .map(|r| r.label.clone())
320        .collect();
321
322    let cats_result = load_active_categories(config, None).await;
323    let installed_app = match cats_result {
324        Ok(cats) => {
325            let app_rows = build_app_rows(config, &cats).await?;
326            installed_app_categories(&app_rows)
327        }
328        Err(_) => Vec::new(),
329    };
330    let installed_sys = sys::installed_managed(config).await?;
331    let installed_sys: Vec<String> = installed_sys
332        .iter()
333        .map(|row| row.item_id.clone())
334        .collect();
335
336    let installed_shell = sorted_names(installed_shell);
337    let installed_app = sorted_names(installed_app);
338    let installed_sys = sorted_names(installed_sys);
339
340    let any = !installed_shell.is_empty() || !installed_app.is_empty() || !installed_sys.is_empty();
341
342    if !any {
343        println!(
344            "{}",
345            colors::dim(
346                "Nothing installed yet. Run `shine shell install`, `shine app install`, or `shine sys list`."
347            )
348        );
349        return Ok(());
350    }
351
352    let mut separator = output::SectionSeparator::new();
353    print_name_section(&mut separator, "Shell Presets", &installed_shell);
354    print_name_section(&mut separator, "App Configs", &installed_app);
355    print_name_section(&mut separator, "System Configs", &installed_sys);
356
357    Ok(())
358}
359
360fn print_name_section(separator: &mut output::SectionSeparator, title: &str, names: &[String]) {
361    if names.is_empty() {
362        return;
363    }
364
365    separator.begin();
366    println!("{} {}", colors::cyan("==>"), colors::bold(title));
367    output::print_columns(names);
368}
369
370fn installed_app_categories(rows: &[AppRow]) -> Vec<String> {
371    rows.iter()
372        .filter(|row| row.file_status != FileStatus::NotInstalled)
373        .map(|row| row.category.clone())
374        .collect::<BTreeSet<_>>()
375        .into_iter()
376        .collect()
377}
378
379fn sorted_names(mut names: Vec<String>) -> Vec<String> {
380    names.sort_by(|left, right| {
381        left.to_lowercase()
382            .cmp(&right.to_lowercase())
383            .then_with(|| left.cmp(right))
384    });
385    names
386}
387
388fn should_show_shell_in_simple_list(row: &ShellRow) -> bool {
389    row.is_installed && row.status_text != SHELL_PRESET_PRESENT_LINK_MISSING
390}
391
392fn app_status_summary_parts(
393    up_to_date: usize,
394    update_available: usize,
395    user_modified: usize,
396    missing: usize,
397) -> Vec<String> {
398    let mut parts = Vec::new();
399    output::push_count(&mut parts, up_to_date, colors::green, "up-to-date");
400    output::push_count(
401        &mut parts,
402        update_available,
403        colors::cyan,
404        "update available",
405    );
406    output::push_count(&mut parts, user_modified, colors::yellow, "user-modified");
407    output::push_count(&mut parts, missing, colors::yellow, "destination missing");
408    parts
409}
410
411#[cfg(test)]
412mod tests {
413    use super::*;
414
415    fn shell_row(status_text: &'static str, is_installed: bool) -> ShellRow {
416        ShellRow {
417            symbol: String::new(),
418            label: "proxy/setproxy".to_string(),
419            status_sym: "~",
420            status_text,
421            is_installed,
422        }
423    }
424
425    fn app_row(category: &str, file_status: FileStatus) -> AppRow {
426        AppRow {
427            category: category.to_string(),
428            sym: "✓",
429            label: category.to_string(),
430            simple_label: category.to_string(),
431            dest: None,
432            status_text: "up-to-date",
433            file_status,
434        }
435    }
436
437    #[test]
438    fn update_rows_group_app_files_by_category() {
439        let first = app_row("clash-verge", FileStatus::UpdateAvail);
440        let mut second = app_row("clash-verge", FileStatus::UpdateAvail);
441        second.label = "clash-verge/rules/lan.list".to_string();
442        let other = app_row("surge", FileStatus::UpdateAvail);
443        let grouped = app_update_categories(&[&first, &second, &other]);
444
445        assert_eq!(grouped.len(), 2);
446        assert_eq!(grouped["clash-verge"].len(), 2);
447        assert_eq!(grouped["surge"].len(), 1);
448    }
449
450    #[test]
451    fn simple_list_hides_preset_present_when_bin_symlink_missing() {
452        let row = shell_row(SHELL_PRESET_PRESENT_LINK_MISSING, true);
453
454        assert!(!should_show_shell_in_simple_list(&row));
455    }
456
457    #[test]
458    fn simple_list_keeps_other_installed_shell_states() {
459        assert!(should_show_shell_in_simple_list(&shell_row(
460            "up-to-date",
461            true
462        )));
463        assert!(should_show_shell_in_simple_list(&shell_row(
464            "bin symlink present, preset missing",
465            true
466        )));
467        assert!(should_show_shell_in_simple_list(&shell_row(
468            "update available",
469            true
470        )));
471    }
472
473    #[test]
474    fn simple_list_hides_uninstalled_shell_rows() {
475        let row = shell_row("not installed", false);
476
477        assert!(!should_show_shell_in_simple_list(&row));
478    }
479
480    #[test]
481    fn simple_list_collapses_installed_app_files_to_their_category() {
482        let rows = vec![
483            app_row("surge", FileStatus::UpToDate),
484            app_row("surge", FileStatus::Missing),
485            app_row("ghostty", FileStatus::NotInstalled),
486        ];
487
488        assert_eq!(installed_app_categories(&rows), vec!["surge"]);
489    }
490
491    #[test]
492    fn simple_list_shows_partially_installed_app_categories() {
493        let rows = vec![
494            app_row("surge", FileStatus::NotInstalled),
495            app_row("surge", FileStatus::UserModified),
496        ];
497
498        assert_eq!(installed_app_categories(&rows), vec!["surge"]);
499    }
500
501    #[test]
502    fn simple_list_sorts_names_case_insensitively() {
503        assert_eq!(
504            sorted_names(vec![
505                "surge".to_string(),
506                "JetBrains".to_string(),
507                "ghostty".to_string(),
508            ]),
509            vec!["ghostty", "JetBrains", "surge"]
510        );
511    }
512
513    #[test]
514    fn app_status_summary_parts_includes_only_nonzero_counts() {
515        assert_eq!(
516            app_status_summary_parts(3, 1, 0, 0),
517            vec!["3 up-to-date".to_string(), "1 update available".to_string()]
518        );
519    }
520
521    #[test]
522    fn app_status_summary_parts_empty_when_all_zero() {
523        assert!(app_status_summary_parts(0, 0, 0, 0).is_empty());
524    }
525
526    #[test]
527    fn app_status_summary_parts_reports_all_four_counters() {
528        assert_eq!(
529            app_status_summary_parts(1, 2, 3, 4),
530            vec![
531                "1 up-to-date".to_string(),
532                "2 update available".to_string(),
533                "3 user-modified".to_string(),
534                "4 destination missing".to_string(),
535            ]
536        );
537    }
538}