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 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 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}