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