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