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::{
7    AppRow, FileStatus, ShellRow, build_app_rows, build_app_rows_with_lifecycle_options,
8    build_shell_rows,
9};
10use crate::sys;
11use anyhow::{Context, Result};
12use std::collections::{BTreeMap, BTreeSet};
13
14const SHELL_PRESET_PRESENT_LINK_MISSING: &str = "preset present, bin symlink missing";
15
16async fn build_update_app_rows(
17    config: &Config,
18    categories: &[crate::apps::AppCategory],
19    run_generators: bool,
20) -> Result<(
21    Vec<AppRow>,
22    shine_core::lifecycle::LifecycleResultV1,
23    Vec<shine_core::runtime::AppFileInspection>,
24)> {
25    if !run_generators {
26        return build_app_rows_with_lifecycle_options(config, categories, false).await;
27    }
28    let (static_rows, static_lifecycle, static_inspections) =
29        build_app_rows_with_lifecycle_options(config, categories, false).await?;
30    let installed = static_rows
31        .iter()
32        .filter(|row| row.file_status != FileStatus::NotInstalled)
33        .map(|row| row.category.as_str())
34        .collect::<BTreeSet<_>>();
35    let selected = categories
36        .iter()
37        .filter(|category| installed.contains(category.name.as_str()))
38        .cloned()
39        .collect::<Vec<_>>();
40    if selected.is_empty() {
41        return Ok((static_rows, static_lifecycle, static_inspections));
42    }
43    build_app_rows_with_lifecycle_options(config, &selected, true).await
44}
45
46fn print_generator_notice(rows: &[AppRow], run_generators: bool) -> (bool, bool) {
47    let not_evaluated = rows
48        .iter()
49        .any(|row| row.file_status == FileStatus::GeneratorNotEvaluated);
50    let failures = rows
51        .iter()
52        .filter(|row| {
53            matches!(
54                row.file_status,
55                FileStatus::GeneratorEvaluationFailed | FileStatus::GeneratorTrustRequired
56            )
57        })
58        .collect::<Vec<_>>();
59    if !run_generators && not_evaluated {
60        println!();
61        println!(
62            "{}",
63            colors::yellow("! Generated configuration was not evaluated.")
64        );
65        println!(
66            "{}",
67            colors::dim(
68                "  Update status may be incomplete. Re-run with `--run-generators` to evaluate generator output."
69            )
70        );
71    }
72    for row in &failures {
73        println!();
74        println!(
75            "{}",
76            colors::yellow(&format!("! {}: {}", row.label, row.status_text))
77        );
78    }
79    (not_evaluated || !failures.is_empty(), !failures.is_empty())
80}
81
82pub async fn handle_update_list(config: &Config, diff: bool, run_generators: bool) -> Result<bool> {
83    let shell_rows = build_shell_rows(config).await?;
84    let shell_lifecycle = crate::shells::collect_update_lifecycle_result(config).await?;
85    let pending_shell = shell_lifecycle
86        .outcomes
87        .iter()
88        .filter(|outcome| outcome.status == shine_core::lifecycle::LifecycleStatus::Pending)
89        .map(|outcome| outcome.target.as_str())
90        .collect::<BTreeSet<_>>();
91    let update_shell: Vec<&ShellRow> = shell_rows
92        .iter()
93        .filter(|r| {
94            r.is_installed
95                && pending_shell.contains(
96                    format!(
97                        "shell/{}/{}",
98                        r.category,
99                        r.label.split('/').next_back().unwrap_or(&r.label)
100                    )
101                    .as_str(),
102                )
103        })
104        .collect();
105
106    let cats = load_active_categories(config, None)
107        .await
108        .context("loading active App Presets for update")?;
109    let (app_rows, app_lifecycle, app_inspections) =
110        build_update_app_rows(config, &cats, run_generators).await?;
111    let pending_app = app_lifecycle
112        .outcomes
113        .iter()
114        .filter(|outcome| outcome.status == shine_core::lifecycle::LifecycleStatus::Pending)
115        .map(|outcome| outcome.target.as_str())
116        .collect::<BTreeSet<_>>();
117    let update_app_rows: Vec<&AppRow> = app_rows
118        .iter()
119        .filter(|r| {
120            r.upgrade_available && pending_app.contains(format!("app/{}", r.category).as_str())
121        })
122        .collect();
123    let update_app = app_update_categories(&update_app_rows);
124    let actionable_app_rows = app_rows
125        .iter()
126        .filter(|row| {
127            (row.upgrade_available || !row.refresh_sources.is_empty())
128                && pending_app.contains(format!("app/{}", row.category).as_str())
129        })
130        .collect::<Vec<_>>();
131    let actionable_app = app_update_categories(&actionable_app_rows);
132    let refresh_commands = app_refresh_commands(&actionable_app);
133    let update_sys = sys::managed_updates(config)
134        .await
135        .context("checking managed Sys Presets for update")?;
136
137    let any_update =
138        !update_shell.is_empty() || !actionable_app.is_empty() || !update_sys.is_empty();
139    let has_generator_attention = app_rows.iter().any(|row| {
140        matches!(
141            row.file_status,
142            FileStatus::GeneratorNotEvaluated
143                | FileStatus::GeneratorEvaluationFailed
144                | FileStatus::GeneratorTrustRequired
145        )
146    });
147    if !any_update && !has_generator_attention {
148        return Ok(false);
149    }
150
151    crate::config::print_presets_note(config);
152
153    if !diff {
154        let shell_names = shell_categories(&update_shell);
155        let app_names = actionable_app
156            .keys()
157            .map(|category| (*category).to_string())
158            .collect::<Vec<_>>();
159        let sys_names = sorted_names(update_sys.iter().map(|row| row.item_id.clone()).collect());
160
161        let mut separator = output::SectionSeparator::new();
162        print_name_section(&mut separator, "Shell Presets", &shell_names);
163        print_name_section(&mut separator, "App Configs", &app_names);
164        print_name_section(&mut separator, "System Configs", &sys_names);
165        let (_, generator_failed) = print_generator_notice(&app_rows, run_generators);
166        let upgrade_app_names = update_app
167            .keys()
168            .map(|category| (*category).to_string())
169            .collect::<Vec<_>>();
170        print_action_hints(
171            &update_targets(&shell_names, &upgrade_app_names, &sys_names),
172            &refresh_commands,
173        );
174        if generator_failed {
175            anyhow::bail!("one or more App generators could not be evaluated");
176        }
177        return Ok(true);
178    }
179
180    let update_diffs = UpdateDiffs::collect_with_app_inspections(config, app_inspections).await?;
181
182    if !update_shell.is_empty() {
183        println!("{}", colors::bold("Shell Presets"));
184
185        let label_width = update_shell
186            .iter()
187            .map(|r| r.label.len())
188            .max()
189            .unwrap_or(0);
190
191        for row in &update_shell {
192            let pad = " ".repeat(label_width.saturating_sub(row.label.len()));
193            println!(
194                "  {}  {}{}  {}",
195                row.symbol,
196                row.label,
197                pad,
198                colors::status_label(row.status_text, row.status_sym),
199            );
200            update_diffs.print_shell_for_row(config, &row.label).await?;
201        }
202    }
203
204    if !actionable_app.is_empty() {
205        if !update_shell.is_empty() {
206            println!();
207        }
208        println!("{}", colors::bold("App Configs"));
209
210        let label_width = actionable_app
211            .keys()
212            .map(|category| category.len())
213            .max()
214            .unwrap_or(0);
215
216        for (category, rows) in &actionable_app {
217            let pad = " ".repeat(label_width.saturating_sub(category.len()));
218            let (status_text, status_sym) = app_category_action_status(rows);
219            println!(
220                "  {}  {}{}  {}",
221                colors::symbol("↑"),
222                category,
223                pad,
224                colors::status_label(status_text, status_sym),
225            );
226            for row in rows {
227                print_app_update_detail(row);
228                update_diffs.print_app_for_row(config, &row.label).await?;
229            }
230        }
231    }
232
233    if !update_sys.is_empty() {
234        if !update_shell.is_empty() || !actionable_app.is_empty() {
235            println!();
236        }
237        println!("{}", colors::bold("System Configs"));
238        for row in &update_sys {
239            println!(
240                "  {}  {}  {}  {}",
241                colors::symbol("↑"),
242                row.label,
243                colors::dim(&format!("({})", row.item_id)),
244                colors::status_label("update available", "↑"),
245            );
246            for detail in &row.details {
247                println!("     {}", colors::dim(detail));
248            }
249        }
250    }
251
252    let (_, generator_failed) = print_generator_notice(&app_rows, run_generators);
253    if any_update {
254        let shell_names = shell_categories(&update_shell);
255        let app_names = update_app
256            .keys()
257            .map(|category| (*category).to_string())
258            .collect::<Vec<_>>();
259        let sys_names = sorted_names(update_sys.iter().map(|row| row.item_id.clone()).collect());
260        print_action_hints(
261            &update_targets(&shell_names, &app_names, &sys_names),
262            &refresh_commands,
263        );
264    }
265    if generator_failed {
266        anyhow::bail!("one or more App generators could not be evaluated");
267    }
268
269    Ok(true)
270}
271
272fn print_action_hints(upgrade_targets: &[String], refresh_commands: &[String]) {
273    if upgrade_targets.is_empty() && refresh_commands.is_empty() {
274        return;
275    }
276    println!();
277    if !upgrade_targets.is_empty() {
278        println!("{}", colors::dim(&update_hint_text(upgrade_targets)));
279    }
280    for command in refresh_commands {
281        println!(
282            "{}",
283            colors::dim(&format!(
284                "Run `{command}` to refresh generated configuration."
285            ))
286        );
287    }
288}
289
290fn update_hint_text(targets: &[String]) -> String {
291    let command = match targets {
292        [target] => format!("shine upgrade {target}"),
293        _ => "shine upgrade".to_string(),
294    };
295    format!("Run `{command}` to apply updates.")
296}
297
298fn update_targets(shell: &[String], app: &[String], sys: &[String]) -> Vec<String> {
299    shell
300        .iter()
301        .map(|category| format!("shell/{category}"))
302        .chain(app.iter().map(|category| format!("app/{category}")))
303        .chain(sys.iter().map(|item| format!("sys/{item}")))
304        .collect()
305}
306
307fn app_update_categories<'a>(rows: &[&'a AppRow]) -> BTreeMap<&'a str, Vec<&'a AppRow>> {
308    let mut categories = BTreeMap::new();
309    for row in rows {
310        categories
311            .entry(row.category.as_str())
312            .or_insert_with(Vec::new)
313            .push(*row);
314    }
315    categories
316}
317
318fn app_refresh_commands(rows: &BTreeMap<&str, Vec<&AppRow>>) -> Vec<String> {
319    rows.iter()
320        .flat_map(|(category, rows)| {
321            rows.iter().flat_map(move |row| {
322                row.refresh_sources.iter().map(move |source| {
323                    format!(
324                        "shine app refresh {} {}",
325                        crate::shell_quote::quote_if_needed(category),
326                        crate::shell_quote::quote_if_needed(source)
327                    )
328                })
329            })
330        })
331        .collect::<BTreeSet<_>>()
332        .into_iter()
333        .collect()
334}
335
336fn app_category_action_status(rows: &[&AppRow]) -> (&'static str, &'static str) {
337    let upgrade_available = rows.iter().any(|row| row.upgrade_available);
338    let refresh_available = rows.iter().any(|row| !row.refresh_sources.is_empty());
339    let text = match (upgrade_available, refresh_available) {
340        (true, true) => "update and refresh available",
341        (false, true) => "refresh available",
342        _ => "update available",
343    };
344    (text, "↑")
345}
346
347fn print_app_update_detail(row: &AppRow) {
348    let destination = row
349        .dest
350        .as_deref()
351        .map(|dest| format!("  {}  {}", colors::dim("→"), colors::dim(dest)))
352        .unwrap_or_default();
353    println!(
354        "     {}  {}{}  {}",
355        colors::symbol("↑"),
356        row.label,
357        destination,
358        colors::status_label(row.status_text, "↑"),
359    );
360}
361
362pub async fn handle_status_list(config: &Config, diff: bool, run_generators: bool) -> Result<()> {
363    crate::config::print_presets_note(config);
364    let shell_rows = build_shell_rows(config).await?;
365    let installed_shell: Vec<&ShellRow> = shell_rows.iter().filter(|r| r.is_installed).collect();
366    let all_shell: Vec<&ShellRow> = shell_rows.iter().collect();
367
368    let cats = load_active_categories(config, None)
369        .await
370        .context("loading active App Presets for status")?;
371    let (app_rows, _, app_inspections) =
372        build_update_app_rows(config, &cats, run_generators).await?;
373    let installed_app: Vec<&AppRow> = app_rows
374        .iter()
375        .filter(|r| r.file_status != FileStatus::NotInstalled)
376        .collect();
377    let all_app: Vec<&AppRow> = app_rows.iter().collect();
378    let update_sys = sys::managed_updates(config)
379        .await
380        .context("checking managed Sys Presets for status")?;
381
382    let any = !installed_shell.is_empty() || !installed_app.is_empty() || !update_sys.is_empty();
383
384    if !any {
385        println!(
386            "{}",
387            colors::dim("Nothing installed yet. Run `shine shell install` or `shine app install`.")
388        );
389        return Ok(());
390    }
391
392    let update_diffs = if diff {
393        Some(UpdateDiffs::collect_with_app_inspections(config, app_inspections).await?)
394    } else {
395        None
396    };
397    let shell_statuses = if diff {
398        installed_shell
399            .iter()
400            .map(|row| ShellLifecycleStatus {
401                category: row.category.clone(),
402                detail_label: row.label.clone(),
403                status_sym: row.status_sym,
404                status_text: row.status_text,
405            })
406            .collect()
407    } else {
408        shell_category_statuses(&all_shell)
409    };
410    let app_statuses = if diff {
411        installed_app
412            .iter()
413            .map(|row| AppLifecycleStatus {
414                category: row.category.clone(),
415                detail_label: row.label.clone(),
416                sym: row.sym,
417                status_text: row.status_text,
418                file_status: row.file_status,
419                dest: row.dest.clone(),
420                upgrade_available: row.upgrade_available,
421                refresh_sources: row.refresh_sources.clone(),
422            })
423            .collect()
424    } else {
425        app_category_statuses(&all_app)
426    };
427
428    // ── Shell Presets ────────────────────────────────────────────────────────
429    if !installed_shell.is_empty() {
430        println!("{}", colors::bold("Shell Presets"));
431
432        let label_width = if diff {
433            installed_shell.iter().map(|row| row.label.len()).max()
434        } else {
435            shell_statuses.iter().map(|row| row.category.len()).max()
436        }
437        .unwrap_or(0);
438
439        for row in &shell_statuses {
440            let label = if diff {
441                &row.detail_label
442            } else {
443                &row.category
444            };
445            let pad = " ".repeat(label_width.saturating_sub(label.len()));
446            let run_hint = if row.status_sym == "↑" {
447                format!("  {}", colors::dim("run `shine upgrade`"))
448            } else {
449                String::new()
450            };
451            println!(
452                "  {}  {}{}  {}{}",
453                colors::symbol(row.status_sym),
454                label,
455                pad,
456                colors::status_label(row.status_text, row.status_sym),
457                run_hint,
458            );
459            if diff
460                && row.status_sym == "↑"
461                && let Some(diffs) = &update_diffs
462            {
463                diffs.print_shell_for_row(config, &row.detail_label).await?;
464            }
465        }
466    }
467
468    // ── App Configs ──────────────────────────────────────────────────────────
469    if !installed_app.is_empty() {
470        if !installed_shell.is_empty() {
471            println!();
472        }
473        println!("{}", colors::bold("App Configs"));
474
475        let label_width = if diff {
476            installed_app.iter().map(|row| row.label.len()).max()
477        } else {
478            app_statuses.iter().map(|row| row.category.len()).max()
479        }
480        .unwrap_or(0);
481
482        let mut up_to_date = 0usize;
483        let mut update_available = 0usize;
484        let mut refresh_available = 0usize;
485        let mut user_modified = 0usize;
486        let mut missing = 0usize;
487
488        for row in &app_statuses {
489            let label = if diff {
490                &row.detail_label
491            } else {
492                &row.category
493            };
494            let pad = " ".repeat(label_width.saturating_sub(label.len()));
495            let dest_part = if diff {
496                row.dest
497                    .as_deref()
498                    .map(|d| format!("  {}  {}", colors::dim("→"), colors::dim(d)))
499                    .unwrap_or_default()
500            } else {
501                String::new()
502            };
503
504            let run_hint = app_status_run_hint(row);
505
506            println!(
507                "  {}  {}{}{}  {}{}",
508                colors::symbol(row.sym),
509                label,
510                pad,
511                dest_part,
512                colors::status_label(row.status_text, row.sym),
513                run_hint,
514            );
515
516            if diff
517                && row.file_status == FileStatus::UpdateAvail
518                && let Some(diffs) = &update_diffs
519            {
520                diffs.print_app_for_row(config, &row.detail_label).await?;
521            }
522
523            match row.file_status {
524                FileStatus::Missing => missing += 1,
525                FileStatus::UserModified | FileStatus::Partial => user_modified += 1,
526                FileStatus::UpdateAvail => {
527                    if row.upgrade_available {
528                        update_available += 1;
529                    }
530                    if !row.refresh_sources.is_empty() {
531                        refresh_available += 1;
532                    }
533                    if !row.upgrade_available && row.refresh_sources.is_empty() {
534                        update_available += 1;
535                    }
536                }
537                FileStatus::GeneratorNotEvaluated
538                | FileStatus::GeneratorEvaluationFailed
539                | FileStatus::GeneratorTrustRequired => user_modified += 1,
540                FileStatus::UpToDate => up_to_date += 1,
541                FileStatus::NotInstalled => {}
542            }
543        }
544
545        let parts = app_status_summary_parts(
546            up_to_date,
547            update_available,
548            refresh_available,
549            user_modified,
550            missing,
551        );
552        if !parts.is_empty() {
553            output::footer("Summary", &parts);
554        }
555    }
556
557    if !update_sys.is_empty() {
558        if !installed_shell.is_empty() || !installed_app.is_empty() {
559            println!();
560        }
561        println!("{}", colors::bold("System Configs"));
562        for row in &update_sys {
563            println!(
564                "  {}  {}  {}  {}  {}",
565                colors::symbol("↑"),
566                row.label,
567                colors::dim(&format!("({})", row.item_id)),
568                colors::status_label("update available", "↑"),
569                colors::dim("run `shine upgrade`"),
570            );
571            for detail in &row.details {
572                println!("     {}", colors::dim(detail));
573            }
574        }
575    }
576
577    let (_, generator_failed) = print_generator_notice(&app_rows, run_generators);
578    if generator_failed {
579        anyhow::bail!("one or more App generators could not be evaluated");
580    }
581
582    Ok(())
583}
584
585pub async fn handle_list(config: &Config) -> Result<()> {
586    crate::config::print_presets_note(config);
587    let shell_rows = build_shell_rows(config).await?;
588    let installed_shell: Vec<String> = shell_rows
589        .iter()
590        .filter(|r| should_show_shell_in_simple_list(r))
591        .map(|r| r.category.clone())
592        .collect::<BTreeSet<_>>()
593        .into_iter()
594        .collect();
595
596    let cats_result = load_active_categories(config, None).await;
597    let installed_app = match cats_result {
598        Ok(cats) => {
599            let app_rows = build_app_rows(config, &cats).await?;
600            installed_app_categories(&app_rows)
601        }
602        Err(_) => Vec::new(),
603    };
604    let installed_sys = sys::installed_managed(config).await?;
605    let installed_sys: Vec<String> = installed_sys
606        .iter()
607        .map(|row| row.item_id.clone())
608        .collect();
609
610    let installed_shell = sorted_names(installed_shell);
611    let installed_app = sorted_names(installed_app);
612    let installed_sys = sorted_names(installed_sys);
613
614    let any = !installed_shell.is_empty() || !installed_app.is_empty() || !installed_sys.is_empty();
615
616    if !any {
617        println!(
618            "{}",
619            colors::dim(
620                "Nothing installed yet. Run `shine shell install`, `shine app install`, or `shine sys list`."
621            )
622        );
623        return Ok(());
624    }
625
626    let mut separator = output::SectionSeparator::new();
627    print_name_section(&mut separator, "Shell Presets", &installed_shell);
628    print_name_section(&mut separator, "App Configs", &installed_app);
629    print_name_section(&mut separator, "System Configs", &installed_sys);
630
631    Ok(())
632}
633
634fn print_name_section(separator: &mut output::SectionSeparator, title: &str, names: &[String]) {
635    if names.is_empty() {
636        return;
637    }
638
639    separator.begin();
640    println!("{} {}", colors::cyan("==>"), colors::bold(title));
641    output::print_columns(names);
642}
643
644fn installed_app_categories(rows: &[AppRow]) -> Vec<String> {
645    rows.iter()
646        .filter(|row| row.file_status != FileStatus::NotInstalled)
647        .map(|row| row.category.clone())
648        .collect::<BTreeSet<_>>()
649        .into_iter()
650        .collect()
651}
652
653fn shell_categories(rows: &[&ShellRow]) -> Vec<String> {
654    rows.iter()
655        .map(|row| row.category.clone())
656        .collect::<BTreeSet<_>>()
657        .into_iter()
658        .collect()
659}
660
661struct ShellLifecycleStatus {
662    category: String,
663    detail_label: String,
664    status_sym: &'static str,
665    status_text: &'static str,
666}
667
668fn shell_category_statuses(rows: &[&ShellRow]) -> Vec<ShellLifecycleStatus> {
669    let mut grouped: BTreeMap<&str, Vec<&ShellRow>> = BTreeMap::new();
670    for row in rows {
671        grouped.entry(&row.category).or_default().push(row);
672    }
673    grouped
674        .into_iter()
675        .filter_map(|(category, rows)| {
676            if !rows.iter().any(|row| row.is_installed) {
677                return None;
678            }
679            if rows.len() == 1 {
680                let row = rows[0];
681                return Some(ShellLifecycleStatus {
682                    category: category.to_string(),
683                    detail_label: row.label.clone(),
684                    status_sym: row.status_sym,
685                    status_text: row.status_text,
686                });
687            }
688            let selected = rows
689                .iter()
690                .filter(|row| row.is_installed)
691                .max_by_key(|row| shell_status_priority(row.status_sym))
692                .expect("grouped shell category is non-empty");
693            let partially_installed = rows.iter().any(|row| !row.is_installed);
694            let (status_sym, status_text) = if partially_installed && selected.status_sym == "✓" {
695                ("~", "partial install")
696            } else {
697                (selected.status_sym, selected.status_text)
698            };
699            Some(ShellLifecycleStatus {
700                category: category.to_string(),
701                detail_label: category.to_string(),
702                status_sym,
703                status_text,
704            })
705        })
706        .collect()
707}
708
709fn shell_status_priority(sym: &str) -> usize {
710    match sym {
711        "!" => 4,
712        "~" => 3,
713        "↑" => 2,
714        "✓" => 1,
715        _ => 0,
716    }
717}
718
719struct AppLifecycleStatus {
720    category: String,
721    detail_label: String,
722    sym: &'static str,
723    status_text: &'static str,
724    file_status: FileStatus,
725    dest: Option<String>,
726    upgrade_available: bool,
727    refresh_sources: Vec<String>,
728}
729
730fn app_category_statuses(rows: &[&AppRow]) -> Vec<AppLifecycleStatus> {
731    let mut grouped: BTreeMap<&str, Vec<&AppRow>> = BTreeMap::new();
732    for row in rows {
733        grouped.entry(&row.category).or_default().push(row);
734    }
735    grouped
736        .into_iter()
737        .filter_map(|(category, rows)| {
738            let has_installed = rows
739                .iter()
740                .any(|row| row.file_status != FileStatus::NotInstalled);
741            if !has_installed {
742                return None;
743            }
744            if rows.len() == 1 {
745                let row = rows[0];
746                return Some(AppLifecycleStatus {
747                    category: category.to_string(),
748                    detail_label: row.label.clone(),
749                    sym: row.sym,
750                    status_text: row.status_text,
751                    file_status: row.file_status,
752                    dest: row.dest.clone(),
753                    upgrade_available: row.upgrade_available,
754                    refresh_sources: row.refresh_sources.clone(),
755                });
756            }
757            let has_not_installed = rows
758                .iter()
759                .any(|row| row.file_status == FileStatus::NotInstalled);
760            let installed_max = rows
761                .iter()
762                .map(|row| row.file_status)
763                .filter(|status| *status != FileStatus::NotInstalled)
764                .max()
765                .expect("installed app category has an installed row");
766            let status = if has_not_installed && installed_max == FileStatus::UpToDate {
767                FileStatus::Partial
768            } else {
769                installed_max
770            };
771            let (sym, mut status_text) = match status {
772                FileStatus::Missing => ("!", "destination missing"),
773                FileStatus::UserModified => ("~", "user modified"),
774                FileStatus::Partial => ("~", "partial install"),
775                FileStatus::UpdateAvail => ("↑", "update available"),
776                FileStatus::GeneratorNotEvaluated => ("!", "generator not evaluated"),
777                FileStatus::GeneratorEvaluationFailed => ("!", "generator evaluation failed"),
778                FileStatus::GeneratorTrustRequired => ("!", "generator trust required"),
779                FileStatus::UpToDate => ("✓", "up-to-date"),
780                FileStatus::NotInstalled => unreachable!(),
781            };
782            let upgrade_available = rows.iter().any(|row| row.upgrade_available);
783            let refresh_sources = rows
784                .iter()
785                .flat_map(|row| row.refresh_sources.iter().cloned())
786                .collect::<BTreeSet<_>>()
787                .into_iter()
788                .collect::<Vec<_>>();
789            if status == FileStatus::UpdateAvail {
790                status_text = match (upgrade_available, refresh_sources.is_empty()) {
791                    (true, false) => "update and refresh available",
792                    (false, false) => "refresh available",
793                    _ => status_text,
794                };
795            }
796            Some(AppLifecycleStatus {
797                category: category.to_string(),
798                detail_label: category.to_string(),
799                sym,
800                status_text,
801                file_status: status,
802                dest: None,
803                upgrade_available,
804                refresh_sources,
805            })
806        })
807        .collect()
808}
809
810fn sorted_names(mut names: Vec<String>) -> Vec<String> {
811    names.sort_by(|left, right| {
812        left.to_lowercase()
813            .cmp(&right.to_lowercase())
814            .then_with(|| left.cmp(right))
815    });
816    names
817}
818
819fn should_show_shell_in_simple_list(row: &ShellRow) -> bool {
820    row.is_installed && row.status_text != SHELL_PRESET_PRESENT_LINK_MISSING
821}
822
823fn app_status_summary_parts(
824    up_to_date: usize,
825    update_available: usize,
826    refresh_available: usize,
827    user_modified: usize,
828    missing: usize,
829) -> Vec<String> {
830    let mut parts = Vec::new();
831    output::push_count(&mut parts, up_to_date, colors::green, "up-to-date");
832    output::push_count(
833        &mut parts,
834        update_available,
835        colors::cyan,
836        "update available",
837    );
838    output::push_count(
839        &mut parts,
840        refresh_available,
841        colors::cyan,
842        "refresh available",
843    );
844    output::push_count(&mut parts, user_modified, colors::yellow, "user-modified");
845    output::push_count(&mut parts, missing, colors::yellow, "destination missing");
846    parts
847}
848
849fn app_status_run_hint(row: &AppLifecycleStatus) -> String {
850    let mut commands = Vec::new();
851    if row.upgrade_available {
852        commands.push(format!(
853            "run `shine upgrade app/{}`",
854            crate::shell_quote::quote_if_needed(&row.category)
855        ));
856    }
857    commands.extend(row.refresh_sources.iter().map(|source| {
858        format!(
859            "run `shine app refresh {} {}`",
860            crate::shell_quote::quote_if_needed(&row.category),
861            crate::shell_quote::quote_if_needed(source)
862        )
863    }));
864    if commands.is_empty() {
865        String::new()
866    } else {
867        format!("  {}", colors::dim(&commands.join("; ")))
868    }
869}
870
871#[cfg(test)]
872mod tests {
873    use super::*;
874
875    fn shell_row(status_text: &'static str, is_installed: bool) -> ShellRow {
876        ShellRow {
877            category: "proxy".to_string(),
878            symbol: String::new(),
879            label: "proxy/setproxy".to_string(),
880            status_sym: "~",
881            status_text,
882            is_installed,
883            link_conflict: false,
884            changes: Vec::new(),
885        }
886    }
887
888    fn app_row(category: &str, file_status: FileStatus) -> AppRow {
889        AppRow {
890            category: category.to_string(),
891            sym: "✓",
892            label: category.to_string(),
893            simple_label: category.to_string(),
894            dest: None,
895            status_text: "up-to-date",
896            file_status,
897            upgrade_available: file_status == FileStatus::UpdateAvail,
898            refresh_sources: Vec::new(),
899        }
900    }
901
902    #[test]
903    fn update_rows_group_app_files_by_category() {
904        let first = app_row("clash-verge", FileStatus::UpdateAvail);
905        let mut second = app_row("clash-verge", FileStatus::UpdateAvail);
906        second.label = "clash-verge/rules/lan.list".to_string();
907        let other = app_row("surge", FileStatus::UpdateAvail);
908        let grouped = app_update_categories(&[&first, &second, &other]);
909
910        assert_eq!(grouped.len(), 2);
911        assert_eq!(grouped["clash-verge"].len(), 2);
912        assert_eq!(grouped["surge"].len(), 1);
913    }
914
915    #[test]
916    fn update_rows_collapse_shell_commands_to_their_category() {
917        let first = shell_row("update available", true);
918        let mut second = shell_row("update available", true);
919        second.label = "proxy/usetproxy".to_string();
920
921        assert_eq!(shell_categories(&[&first, &second]), vec!["proxy"]);
922    }
923
924    #[test]
925    fn manual_generator_updates_render_refresh_commands_instead_of_upgrade_targets() {
926        let mut row = app_row("surge", FileStatus::UpdateAvail);
927        row.upgrade_available = false;
928        row.refresh_sources = vec!["subscription-proxies.conf".to_string()];
929        let grouped = app_update_categories(&[&row]);
930
931        assert_eq!(
932            app_refresh_commands(&grouped),
933            ["shine app refresh surge subscription-proxies.conf"]
934        );
935        assert_eq!(
936            app_category_action_status(&grouped["surge"]),
937            ("refresh available", "↑")
938        );
939    }
940
941    #[test]
942    fn mixed_app_updates_retain_both_upgrade_and_refresh_actions() {
943        let automatic = app_row("sample", FileStatus::UpdateAvail);
944        let mut manual = app_row("sample", FileStatus::UpdateAvail);
945        manual.upgrade_available = false;
946        manual.refresh_sources = vec!["generated.conf".to_string()];
947        let grouped = app_update_categories(&[&automatic, &manual]);
948
949        assert_eq!(
950            app_category_action_status(&grouped["sample"]),
951            ("update and refresh available", "↑")
952        );
953        assert_eq!(
954            app_refresh_commands(&grouped),
955            ["shine app refresh sample generated.conf"]
956        );
957    }
958
959    #[test]
960    fn update_hint_targets_the_only_pending_category() {
961        let targets = update_targets(&[], &["clash-verge".to_string()], &[]);
962
963        assert_eq!(targets, ["app/clash-verge"]);
964        assert_eq!(
965            update_hint_text(&targets),
966            "Run `shine upgrade app/clash-verge` to apply updates."
967        );
968    }
969
970    #[test]
971    fn update_hint_keeps_global_upgrade_for_multiple_categories() {
972        let targets = update_targets(
973            &["proxy".to_string()],
974            &["clash-verge".to_string()],
975            &["split-dns".to_string()],
976        );
977
978        assert_eq!(targets, ["shell/proxy", "app/clash-verge", "sys/split-dns"]);
979        assert_eq!(
980            update_hint_text(&targets),
981            "Run `shine upgrade` to apply updates."
982        );
983    }
984
985    #[test]
986    fn default_shell_status_collapses_commands_and_reports_partial_install() {
987        let mut installed = shell_row("up-to-date", true);
988        installed.status_sym = "✓";
989        let mut missing = shell_row("not installed", false);
990        missing.label = "proxy/usetproxy".to_string();
991        missing.status_sym = "✗";
992
993        let statuses = shell_category_statuses(&[&installed, &missing]);
994
995        assert_eq!(statuses.len(), 1);
996        assert_eq!(statuses[0].category, "proxy");
997        assert_eq!(statuses[0].status_text, "partial install");
998    }
999
1000    #[test]
1001    fn default_app_status_collapses_files_and_reports_partial_install() {
1002        let installed = app_row("surge", FileStatus::UpToDate);
1003        let missing = app_row("surge", FileStatus::NotInstalled);
1004
1005        let statuses = app_category_statuses(&[&installed, &missing]);
1006
1007        assert_eq!(statuses.len(), 1);
1008        assert_eq!(statuses[0].category, "surge");
1009        assert_eq!(statuses[0].file_status, FileStatus::Partial);
1010    }
1011
1012    #[test]
1013    fn simple_list_hides_preset_present_when_bin_symlink_missing() {
1014        let row = shell_row(SHELL_PRESET_PRESENT_LINK_MISSING, true);
1015
1016        assert!(!should_show_shell_in_simple_list(&row));
1017    }
1018
1019    #[test]
1020    fn simple_list_keeps_other_installed_shell_states() {
1021        assert!(should_show_shell_in_simple_list(&shell_row(
1022            "up-to-date",
1023            true
1024        )));
1025        assert!(should_show_shell_in_simple_list(&shell_row(
1026            "bin symlink present, preset missing",
1027            true
1028        )));
1029        assert!(should_show_shell_in_simple_list(&shell_row(
1030            "update available",
1031            true
1032        )));
1033    }
1034
1035    #[test]
1036    fn simple_list_hides_uninstalled_shell_rows() {
1037        let row = shell_row("not installed", false);
1038
1039        assert!(!should_show_shell_in_simple_list(&row));
1040    }
1041
1042    #[test]
1043    fn simple_list_collapses_installed_app_files_to_their_category() {
1044        let rows = vec![
1045            app_row("surge", FileStatus::UpToDate),
1046            app_row("surge", FileStatus::Missing),
1047            app_row("ghostty", FileStatus::NotInstalled),
1048        ];
1049
1050        assert_eq!(installed_app_categories(&rows), vec!["surge"]);
1051    }
1052
1053    #[test]
1054    fn simple_list_shows_partially_installed_app_categories() {
1055        let rows = vec![
1056            app_row("surge", FileStatus::NotInstalled),
1057            app_row("surge", FileStatus::UserModified),
1058        ];
1059
1060        assert_eq!(installed_app_categories(&rows), vec!["surge"]);
1061    }
1062
1063    #[test]
1064    fn simple_list_sorts_names_case_insensitively() {
1065        assert_eq!(
1066            sorted_names(vec![
1067                "surge".to_string(),
1068                "JetBrains".to_string(),
1069                "ghostty".to_string(),
1070            ]),
1071            vec!["ghostty", "JetBrains", "surge"]
1072        );
1073    }
1074
1075    #[test]
1076    fn app_status_summary_parts_includes_only_nonzero_counts() {
1077        assert_eq!(
1078            app_status_summary_parts(3, 1, 0, 0, 0),
1079            vec!["3 up-to-date".to_string(), "1 update available".to_string()]
1080        );
1081    }
1082
1083    #[test]
1084    fn app_status_summary_parts_empty_when_all_zero() {
1085        assert!(app_status_summary_parts(0, 0, 0, 0, 0).is_empty());
1086    }
1087
1088    #[test]
1089    fn app_status_summary_parts_reports_all_five_counters() {
1090        assert_eq!(
1091            app_status_summary_parts(1, 2, 3, 4, 5),
1092            vec![
1093                "1 up-to-date".to_string(),
1094                "2 update available".to_string(),
1095                "3 refresh available".to_string(),
1096                "4 user-modified".to_string(),
1097                "5 destination missing".to_string(),
1098            ]
1099        );
1100    }
1101}