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::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    // ── Shell Presets ────────────────────────────────────────────────────────
156    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    // ── App Configs ──────────────────────────────────────────────────────────
189    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}