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: Vec<&AppRow> = app_rows
118        .iter()
119        .filter(|r| {
120            r.file_status == FileStatus::UpdateAvail
121                && pending_app.contains(format!("app/{}", r.category).as_str())
122        })
123        .collect();
124    let update_app = app_update_categories(&update_app);
125    let update_sys = sys::managed_updates(config)
126        .await
127        .context("checking managed Sys Presets for update")?;
128
129    let any_update = !update_shell.is_empty() || !update_app.is_empty() || !update_sys.is_empty();
130    let has_generator_attention = app_rows.iter().any(|row| {
131        matches!(
132            row.file_status,
133            FileStatus::GeneratorNotEvaluated
134                | FileStatus::GeneratorEvaluationFailed
135                | FileStatus::GeneratorTrustRequired
136        )
137    });
138    if !any_update && !has_generator_attention {
139        return Ok(false);
140    }
141
142    crate::config::print_presets_note(config);
143
144    if !diff {
145        let shell_names = shell_categories(&update_shell);
146        let app_names = update_app
147            .keys()
148            .map(|category| (*category).to_string())
149            .collect::<Vec<_>>();
150        let sys_names = sorted_names(update_sys.iter().map(|row| row.item_id.clone()).collect());
151
152        let mut separator = output::SectionSeparator::new();
153        print_name_section(&mut separator, "Shell Presets", &shell_names);
154        print_name_section(&mut separator, "App Configs", &app_names);
155        print_name_section(&mut separator, "System Configs", &sys_names);
156        let (_, generator_failed) = print_generator_notice(&app_rows, run_generators);
157        if any_update {
158            print_update_hint(&update_targets(&shell_names, &app_names, &sys_names));
159        }
160        if generator_failed {
161            anyhow::bail!("one or more App generators could not be evaluated");
162        }
163        return Ok(true);
164    }
165
166    let update_diffs = UpdateDiffs::collect_with_app_inspections(config, app_inspections).await?;
167
168    if !update_shell.is_empty() {
169        println!("{}", colors::bold("Shell Presets"));
170
171        let label_width = update_shell
172            .iter()
173            .map(|r| r.label.len())
174            .max()
175            .unwrap_or(0);
176
177        for row in &update_shell {
178            let pad = " ".repeat(label_width.saturating_sub(row.label.len()));
179            println!(
180                "  {}  {}{}  {}",
181                row.symbol,
182                row.label,
183                pad,
184                colors::status_label(row.status_text, row.status_sym),
185            );
186            update_diffs.print_shell_for_row(config, &row.label).await?;
187        }
188    }
189
190    if !update_app.is_empty() {
191        if !update_shell.is_empty() {
192            println!();
193        }
194        println!("{}", colors::bold("App Configs"));
195
196        let label_width = update_app
197            .keys()
198            .map(|category| category.len())
199            .max()
200            .unwrap_or(0);
201
202        for (category, rows) in &update_app {
203            let pad = " ".repeat(label_width.saturating_sub(category.len()));
204            println!(
205                "  {}  {}{}  {}",
206                colors::symbol("↑"),
207                category,
208                pad,
209                colors::status_label("update available", "↑"),
210            );
211            for row in rows {
212                print_app_update_detail(row);
213                update_diffs.print_app_for_row(config, &row.label).await?;
214            }
215        }
216    }
217
218    if !update_sys.is_empty() {
219        if !update_shell.is_empty() || !update_app.is_empty() {
220            println!();
221        }
222        println!("{}", colors::bold("System Configs"));
223        for row in &update_sys {
224            println!(
225                "  {}  {}  {}  {}",
226                colors::symbol("↑"),
227                row.label,
228                colors::dim(&format!("({})", row.item_id)),
229                colors::status_label("update available", "↑"),
230            );
231            for detail in &row.details {
232                println!("     {}", colors::dim(detail));
233            }
234        }
235    }
236
237    let (_, generator_failed) = print_generator_notice(&app_rows, run_generators);
238    if any_update {
239        let shell_names = shell_categories(&update_shell);
240        let app_names = update_app
241            .keys()
242            .map(|category| (*category).to_string())
243            .collect::<Vec<_>>();
244        let sys_names = sorted_names(update_sys.iter().map(|row| row.item_id.clone()).collect());
245        print_update_hint(&update_targets(&shell_names, &app_names, &sys_names));
246    }
247    if generator_failed {
248        anyhow::bail!("one or more App generators could not be evaluated");
249    }
250
251    Ok(true)
252}
253
254fn print_update_hint(targets: &[String]) {
255    println!();
256    println!("{}", colors::dim(&update_hint_text(targets)));
257}
258
259fn update_hint_text(targets: &[String]) -> String {
260    let command = match targets {
261        [target] => format!("shine upgrade {target}"),
262        _ => "shine upgrade".to_string(),
263    };
264    format!("Run `{command}` to apply updates.")
265}
266
267fn update_targets(shell: &[String], app: &[String], sys: &[String]) -> Vec<String> {
268    shell
269        .iter()
270        .map(|category| format!("shell/{category}"))
271        .chain(app.iter().map(|category| format!("app/{category}")))
272        .chain(sys.iter().map(|item| format!("sys/{item}")))
273        .collect()
274}
275
276fn app_update_categories<'a>(rows: &[&'a AppRow]) -> BTreeMap<&'a str, Vec<&'a AppRow>> {
277    let mut categories = BTreeMap::new();
278    for row in rows {
279        categories
280            .entry(row.category.as_str())
281            .or_insert_with(Vec::new)
282            .push(*row);
283    }
284    categories
285}
286
287fn print_app_update_detail(row: &AppRow) {
288    let destination = row
289        .dest
290        .as_deref()
291        .map(|dest| format!("  {}  {}", colors::dim("→"), colors::dim(dest)))
292        .unwrap_or_default();
293    println!(
294        "     {}  {}{}  {}",
295        colors::symbol("↑"),
296        row.label,
297        destination,
298        colors::status_label("update available", "↑"),
299    );
300}
301
302pub async fn handle_status_list(config: &Config, diff: bool, run_generators: bool) -> Result<()> {
303    crate::config::print_presets_note(config);
304    let shell_rows = build_shell_rows(config).await?;
305    let installed_shell: Vec<&ShellRow> = shell_rows.iter().filter(|r| r.is_installed).collect();
306    let all_shell: Vec<&ShellRow> = shell_rows.iter().collect();
307
308    let cats = load_active_categories(config, None)
309        .await
310        .context("loading active App Presets for status")?;
311    let (app_rows, _, app_inspections) =
312        build_update_app_rows(config, &cats, run_generators).await?;
313    let installed_app: Vec<&AppRow> = app_rows
314        .iter()
315        .filter(|r| r.file_status != FileStatus::NotInstalled)
316        .collect();
317    let all_app: Vec<&AppRow> = app_rows.iter().collect();
318    let update_sys = sys::managed_updates(config)
319        .await
320        .context("checking managed Sys Presets for status")?;
321
322    let any = !installed_shell.is_empty() || !installed_app.is_empty() || !update_sys.is_empty();
323
324    if !any {
325        println!(
326            "{}",
327            colors::dim("Nothing installed yet. Run `shine shell install` or `shine app install`.")
328        );
329        return Ok(());
330    }
331
332    let update_diffs = if diff {
333        Some(UpdateDiffs::collect_with_app_inspections(config, app_inspections).await?)
334    } else {
335        None
336    };
337    let shell_statuses = if diff {
338        installed_shell
339            .iter()
340            .map(|row| ShellLifecycleStatus {
341                category: row.category.clone(),
342                detail_label: row.label.clone(),
343                status_sym: row.status_sym,
344                status_text: row.status_text,
345            })
346            .collect()
347    } else {
348        shell_category_statuses(&all_shell)
349    };
350    let app_statuses = if diff {
351        installed_app
352            .iter()
353            .map(|row| AppLifecycleStatus {
354                category: row.category.clone(),
355                detail_label: row.label.clone(),
356                sym: row.sym,
357                status_text: row.status_text,
358                file_status: row.file_status,
359                dest: row.dest.clone(),
360            })
361            .collect()
362    } else {
363        app_category_statuses(&all_app)
364    };
365
366    // ── Shell Presets ────────────────────────────────────────────────────────
367    if !installed_shell.is_empty() {
368        println!("{}", colors::bold("Shell Presets"));
369
370        let label_width = if diff {
371            installed_shell.iter().map(|row| row.label.len()).max()
372        } else {
373            shell_statuses.iter().map(|row| row.category.len()).max()
374        }
375        .unwrap_or(0);
376
377        for row in &shell_statuses {
378            let label = if diff {
379                &row.detail_label
380            } else {
381                &row.category
382            };
383            let pad = " ".repeat(label_width.saturating_sub(label.len()));
384            let run_hint = if row.status_sym == "↑" {
385                format!("  {}", colors::dim("run `shine upgrade`"))
386            } else {
387                String::new()
388            };
389            println!(
390                "  {}  {}{}  {}{}",
391                colors::symbol(row.status_sym),
392                label,
393                pad,
394                colors::status_label(row.status_text, row.status_sym),
395                run_hint,
396            );
397            if diff
398                && row.status_sym == "↑"
399                && let Some(diffs) = &update_diffs
400            {
401                diffs.print_shell_for_row(config, &row.detail_label).await?;
402            }
403        }
404    }
405
406    // ── App Configs ──────────────────────────────────────────────────────────
407    if !installed_app.is_empty() {
408        if !installed_shell.is_empty() {
409            println!();
410        }
411        println!("{}", colors::bold("App Configs"));
412
413        let label_width = if diff {
414            installed_app.iter().map(|row| row.label.len()).max()
415        } else {
416            app_statuses.iter().map(|row| row.category.len()).max()
417        }
418        .unwrap_or(0);
419
420        let mut up_to_date = 0usize;
421        let mut update_available = 0usize;
422        let mut user_modified = 0usize;
423        let mut missing = 0usize;
424
425        for row in &app_statuses {
426            let label = if diff {
427                &row.detail_label
428            } else {
429                &row.category
430            };
431            let pad = " ".repeat(label_width.saturating_sub(label.len()));
432            let dest_part = if diff {
433                row.dest
434                    .as_deref()
435                    .map(|d| format!("  {}  {}", colors::dim("→"), colors::dim(d)))
436                    .unwrap_or_default()
437            } else {
438                String::new()
439            };
440
441            let run_hint = if row.sym == "↑" {
442                format!("  {}", colors::dim("run `shine upgrade`"))
443            } else {
444                String::new()
445            };
446
447            println!(
448                "  {}  {}{}{}  {}{}",
449                colors::symbol(row.sym),
450                label,
451                pad,
452                dest_part,
453                colors::status_label(row.status_text, row.sym),
454                run_hint,
455            );
456
457            if diff
458                && row.file_status == FileStatus::UpdateAvail
459                && let Some(diffs) = &update_diffs
460            {
461                diffs.print_app_for_row(config, &row.detail_label).await?;
462            }
463
464            match row.file_status {
465                FileStatus::Missing => missing += 1,
466                FileStatus::UserModified | FileStatus::Partial => user_modified += 1,
467                FileStatus::UpdateAvail => update_available += 1,
468                FileStatus::GeneratorNotEvaluated
469                | FileStatus::GeneratorEvaluationFailed
470                | FileStatus::GeneratorTrustRequired => user_modified += 1,
471                FileStatus::UpToDate => up_to_date += 1,
472                FileStatus::NotInstalled => {}
473            }
474        }
475
476        let parts = app_status_summary_parts(up_to_date, update_available, user_modified, missing);
477        if !parts.is_empty() {
478            output::footer("Summary", &parts);
479        }
480    }
481
482    if !update_sys.is_empty() {
483        if !installed_shell.is_empty() || !installed_app.is_empty() {
484            println!();
485        }
486        println!("{}", colors::bold("System Configs"));
487        for row in &update_sys {
488            println!(
489                "  {}  {}  {}  {}  {}",
490                colors::symbol("↑"),
491                row.label,
492                colors::dim(&format!("({})", row.item_id)),
493                colors::status_label("update available", "↑"),
494                colors::dim("run `shine upgrade`"),
495            );
496            for detail in &row.details {
497                println!("     {}", colors::dim(detail));
498            }
499        }
500    }
501
502    let (_, generator_failed) = print_generator_notice(&app_rows, run_generators);
503    if generator_failed {
504        anyhow::bail!("one or more App generators could not be evaluated");
505    }
506
507    Ok(())
508}
509
510pub async fn handle_list(config: &Config) -> Result<()> {
511    crate::config::print_presets_note(config);
512    let shell_rows = build_shell_rows(config).await?;
513    let installed_shell: Vec<String> = shell_rows
514        .iter()
515        .filter(|r| should_show_shell_in_simple_list(r))
516        .map(|r| r.category.clone())
517        .collect::<BTreeSet<_>>()
518        .into_iter()
519        .collect();
520
521    let cats_result = load_active_categories(config, None).await;
522    let installed_app = match cats_result {
523        Ok(cats) => {
524            let app_rows = build_app_rows(config, &cats).await?;
525            installed_app_categories(&app_rows)
526        }
527        Err(_) => Vec::new(),
528    };
529    let installed_sys = sys::installed_managed(config).await?;
530    let installed_sys: Vec<String> = installed_sys
531        .iter()
532        .map(|row| row.item_id.clone())
533        .collect();
534
535    let installed_shell = sorted_names(installed_shell);
536    let installed_app = sorted_names(installed_app);
537    let installed_sys = sorted_names(installed_sys);
538
539    let any = !installed_shell.is_empty() || !installed_app.is_empty() || !installed_sys.is_empty();
540
541    if !any {
542        println!(
543            "{}",
544            colors::dim(
545                "Nothing installed yet. Run `shine shell install`, `shine app install`, or `shine sys list`."
546            )
547        );
548        return Ok(());
549    }
550
551    let mut separator = output::SectionSeparator::new();
552    print_name_section(&mut separator, "Shell Presets", &installed_shell);
553    print_name_section(&mut separator, "App Configs", &installed_app);
554    print_name_section(&mut separator, "System Configs", &installed_sys);
555
556    Ok(())
557}
558
559fn print_name_section(separator: &mut output::SectionSeparator, title: &str, names: &[String]) {
560    if names.is_empty() {
561        return;
562    }
563
564    separator.begin();
565    println!("{} {}", colors::cyan("==>"), colors::bold(title));
566    output::print_columns(names);
567}
568
569fn installed_app_categories(rows: &[AppRow]) -> Vec<String> {
570    rows.iter()
571        .filter(|row| row.file_status != FileStatus::NotInstalled)
572        .map(|row| row.category.clone())
573        .collect::<BTreeSet<_>>()
574        .into_iter()
575        .collect()
576}
577
578fn shell_categories(rows: &[&ShellRow]) -> Vec<String> {
579    rows.iter()
580        .map(|row| row.category.clone())
581        .collect::<BTreeSet<_>>()
582        .into_iter()
583        .collect()
584}
585
586struct ShellLifecycleStatus {
587    category: String,
588    detail_label: String,
589    status_sym: &'static str,
590    status_text: &'static str,
591}
592
593fn shell_category_statuses(rows: &[&ShellRow]) -> Vec<ShellLifecycleStatus> {
594    let mut grouped: BTreeMap<&str, Vec<&ShellRow>> = BTreeMap::new();
595    for row in rows {
596        grouped.entry(&row.category).or_default().push(row);
597    }
598    grouped
599        .into_iter()
600        .filter_map(|(category, rows)| {
601            if !rows.iter().any(|row| row.is_installed) {
602                return None;
603            }
604            if rows.len() == 1 {
605                let row = rows[0];
606                return Some(ShellLifecycleStatus {
607                    category: category.to_string(),
608                    detail_label: row.label.clone(),
609                    status_sym: row.status_sym,
610                    status_text: row.status_text,
611                });
612            }
613            let selected = rows
614                .iter()
615                .filter(|row| row.is_installed)
616                .max_by_key(|row| shell_status_priority(row.status_sym))
617                .expect("grouped shell category is non-empty");
618            let partially_installed = rows.iter().any(|row| !row.is_installed);
619            let (status_sym, status_text) = if partially_installed && selected.status_sym == "✓" {
620                ("~", "partial install")
621            } else {
622                (selected.status_sym, selected.status_text)
623            };
624            Some(ShellLifecycleStatus {
625                category: category.to_string(),
626                detail_label: category.to_string(),
627                status_sym,
628                status_text,
629            })
630        })
631        .collect()
632}
633
634fn shell_status_priority(sym: &str) -> usize {
635    match sym {
636        "!" => 4,
637        "~" => 3,
638        "↑" => 2,
639        "✓" => 1,
640        _ => 0,
641    }
642}
643
644struct AppLifecycleStatus {
645    category: String,
646    detail_label: String,
647    sym: &'static str,
648    status_text: &'static str,
649    file_status: FileStatus,
650    dest: Option<String>,
651}
652
653fn app_category_statuses(rows: &[&AppRow]) -> Vec<AppLifecycleStatus> {
654    let mut grouped: BTreeMap<&str, Vec<&AppRow>> = BTreeMap::new();
655    for row in rows {
656        grouped.entry(&row.category).or_default().push(row);
657    }
658    grouped
659        .into_iter()
660        .filter_map(|(category, rows)| {
661            let has_installed = rows
662                .iter()
663                .any(|row| row.file_status != FileStatus::NotInstalled);
664            if !has_installed {
665                return None;
666            }
667            if rows.len() == 1 {
668                let row = rows[0];
669                return Some(AppLifecycleStatus {
670                    category: category.to_string(),
671                    detail_label: row.label.clone(),
672                    sym: row.sym,
673                    status_text: row.status_text,
674                    file_status: row.file_status,
675                    dest: row.dest.clone(),
676                });
677            }
678            let has_not_installed = rows
679                .iter()
680                .any(|row| row.file_status == FileStatus::NotInstalled);
681            let installed_max = rows
682                .iter()
683                .map(|row| row.file_status)
684                .filter(|status| *status != FileStatus::NotInstalled)
685                .max()
686                .expect("installed app category has an installed row");
687            let status = if has_not_installed && installed_max == FileStatus::UpToDate {
688                FileStatus::Partial
689            } else {
690                installed_max
691            };
692            let (sym, status_text) = match status {
693                FileStatus::Missing => ("!", "destination missing"),
694                FileStatus::UserModified => ("~", "user modified"),
695                FileStatus::Partial => ("~", "partial install"),
696                FileStatus::UpdateAvail => ("↑", "update available"),
697                FileStatus::GeneratorNotEvaluated => ("!", "generator not evaluated"),
698                FileStatus::GeneratorEvaluationFailed => ("!", "generator evaluation failed"),
699                FileStatus::GeneratorTrustRequired => ("!", "generator trust required"),
700                FileStatus::UpToDate => ("✓", "up-to-date"),
701                FileStatus::NotInstalled => unreachable!(),
702            };
703            Some(AppLifecycleStatus {
704                category: category.to_string(),
705                detail_label: category.to_string(),
706                sym,
707                status_text,
708                file_status: status,
709                dest: None,
710            })
711        })
712        .collect()
713}
714
715fn sorted_names(mut names: Vec<String>) -> Vec<String> {
716    names.sort_by(|left, right| {
717        left.to_lowercase()
718            .cmp(&right.to_lowercase())
719            .then_with(|| left.cmp(right))
720    });
721    names
722}
723
724fn should_show_shell_in_simple_list(row: &ShellRow) -> bool {
725    row.is_installed && row.status_text != SHELL_PRESET_PRESENT_LINK_MISSING
726}
727
728fn app_status_summary_parts(
729    up_to_date: usize,
730    update_available: usize,
731    user_modified: usize,
732    missing: usize,
733) -> Vec<String> {
734    let mut parts = Vec::new();
735    output::push_count(&mut parts, up_to_date, colors::green, "up-to-date");
736    output::push_count(
737        &mut parts,
738        update_available,
739        colors::cyan,
740        "update available",
741    );
742    output::push_count(&mut parts, user_modified, colors::yellow, "user-modified");
743    output::push_count(&mut parts, missing, colors::yellow, "destination missing");
744    parts
745}
746
747#[cfg(test)]
748mod tests {
749    use super::*;
750
751    fn shell_row(status_text: &'static str, is_installed: bool) -> ShellRow {
752        ShellRow {
753            category: "proxy".to_string(),
754            symbol: String::new(),
755            label: "proxy/setproxy".to_string(),
756            status_sym: "~",
757            status_text,
758            is_installed,
759            link_conflict: false,
760            changes: Vec::new(),
761        }
762    }
763
764    fn app_row(category: &str, file_status: FileStatus) -> AppRow {
765        AppRow {
766            category: category.to_string(),
767            sym: "✓",
768            label: category.to_string(),
769            simple_label: category.to_string(),
770            dest: None,
771            status_text: "up-to-date",
772            file_status,
773        }
774    }
775
776    #[test]
777    fn update_rows_group_app_files_by_category() {
778        let first = app_row("clash-verge", FileStatus::UpdateAvail);
779        let mut second = app_row("clash-verge", FileStatus::UpdateAvail);
780        second.label = "clash-verge/rules/lan.list".to_string();
781        let other = app_row("surge", FileStatus::UpdateAvail);
782        let grouped = app_update_categories(&[&first, &second, &other]);
783
784        assert_eq!(grouped.len(), 2);
785        assert_eq!(grouped["clash-verge"].len(), 2);
786        assert_eq!(grouped["surge"].len(), 1);
787    }
788
789    #[test]
790    fn update_rows_collapse_shell_commands_to_their_category() {
791        let first = shell_row("update available", true);
792        let mut second = shell_row("update available", true);
793        second.label = "proxy/usetproxy".to_string();
794
795        assert_eq!(shell_categories(&[&first, &second]), vec!["proxy"]);
796    }
797
798    #[test]
799    fn update_hint_targets_the_only_pending_category() {
800        let targets = update_targets(&[], &["clash-verge".to_string()], &[]);
801
802        assert_eq!(targets, ["app/clash-verge"]);
803        assert_eq!(
804            update_hint_text(&targets),
805            "Run `shine upgrade app/clash-verge` to apply updates."
806        );
807    }
808
809    #[test]
810    fn update_hint_keeps_global_upgrade_for_multiple_categories() {
811        let targets = update_targets(
812            &["proxy".to_string()],
813            &["clash-verge".to_string()],
814            &["split-dns".to_string()],
815        );
816
817        assert_eq!(targets, ["shell/proxy", "app/clash-verge", "sys/split-dns"]);
818        assert_eq!(
819            update_hint_text(&targets),
820            "Run `shine upgrade` to apply updates."
821        );
822    }
823
824    #[test]
825    fn default_shell_status_collapses_commands_and_reports_partial_install() {
826        let mut installed = shell_row("up-to-date", true);
827        installed.status_sym = "✓";
828        let mut missing = shell_row("not installed", false);
829        missing.label = "proxy/usetproxy".to_string();
830        missing.status_sym = "✗";
831
832        let statuses = shell_category_statuses(&[&installed, &missing]);
833
834        assert_eq!(statuses.len(), 1);
835        assert_eq!(statuses[0].category, "proxy");
836        assert_eq!(statuses[0].status_text, "partial install");
837    }
838
839    #[test]
840    fn default_app_status_collapses_files_and_reports_partial_install() {
841        let installed = app_row("surge", FileStatus::UpToDate);
842        let missing = app_row("surge", FileStatus::NotInstalled);
843
844        let statuses = app_category_statuses(&[&installed, &missing]);
845
846        assert_eq!(statuses.len(), 1);
847        assert_eq!(statuses[0].category, "surge");
848        assert_eq!(statuses[0].file_status, FileStatus::Partial);
849    }
850
851    #[test]
852    fn simple_list_hides_preset_present_when_bin_symlink_missing() {
853        let row = shell_row(SHELL_PRESET_PRESENT_LINK_MISSING, true);
854
855        assert!(!should_show_shell_in_simple_list(&row));
856    }
857
858    #[test]
859    fn simple_list_keeps_other_installed_shell_states() {
860        assert!(should_show_shell_in_simple_list(&shell_row(
861            "up-to-date",
862            true
863        )));
864        assert!(should_show_shell_in_simple_list(&shell_row(
865            "bin symlink present, preset missing",
866            true
867        )));
868        assert!(should_show_shell_in_simple_list(&shell_row(
869            "update available",
870            true
871        )));
872    }
873
874    #[test]
875    fn simple_list_hides_uninstalled_shell_rows() {
876        let row = shell_row("not installed", false);
877
878        assert!(!should_show_shell_in_simple_list(&row));
879    }
880
881    #[test]
882    fn simple_list_collapses_installed_app_files_to_their_category() {
883        let rows = vec![
884            app_row("surge", FileStatus::UpToDate),
885            app_row("surge", FileStatus::Missing),
886            app_row("ghostty", FileStatus::NotInstalled),
887        ];
888
889        assert_eq!(installed_app_categories(&rows), vec!["surge"]);
890    }
891
892    #[test]
893    fn simple_list_shows_partially_installed_app_categories() {
894        let rows = vec![
895            app_row("surge", FileStatus::NotInstalled),
896            app_row("surge", FileStatus::UserModified),
897        ];
898
899        assert_eq!(installed_app_categories(&rows), vec!["surge"]);
900    }
901
902    #[test]
903    fn simple_list_sorts_names_case_insensitively() {
904        assert_eq!(
905            sorted_names(vec![
906                "surge".to_string(),
907                "JetBrains".to_string(),
908                "ghostty".to_string(),
909            ]),
910            vec!["ghostty", "JetBrains", "surge"]
911        );
912    }
913
914    #[test]
915    fn app_status_summary_parts_includes_only_nonzero_counts() {
916        assert_eq!(
917            app_status_summary_parts(3, 1, 0, 0),
918            vec!["3 up-to-date".to_string(), "1 update available".to_string()]
919        );
920    }
921
922    #[test]
923    fn app_status_summary_parts_empty_when_all_zero() {
924        assert!(app_status_summary_parts(0, 0, 0, 0).is_empty());
925    }
926
927    #[test]
928    fn app_status_summary_parts_reports_all_four_counters() {
929        assert_eq!(
930            app_status_summary_parts(1, 2, 3, 4),
931            vec![
932                "1 up-to-date".to_string(),
933                "2 update available".to_string(),
934                "3 user-modified".to_string(),
935                "4 destination missing".to_string(),
936            ]
937        );
938    }
939}