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