use crate::apps::load_active_categories;
use crate::colors;
use crate::config::Config;
use crate::info::UpdateDiffs;
use crate::output;
use crate::status::{
AppRow, FileStatus, ShellRow, build_app_rows_with_lifecycle_options, build_shell_rows,
};
use crate::sys;
use anyhow::{Context, Result};
use shine_core::frontend::{
CapabilityKindV1, FrontendServiceError, InventoryReportV1, InventoryRequest,
};
use std::collections::{BTreeMap, BTreeSet};
#[cfg(test)]
const SHELL_PRESET_PRESENT_LINK_MISSING: &str = "preset present, bin symlink missing";
async fn build_update_app_rows(
config: &Config,
categories: &[crate::apps::AppCategory],
run_generators: bool,
) -> Result<(
Vec<AppRow>,
shine_core::lifecycle::LifecycleResultV1,
Vec<shine_core::runtime::AppFileInspection>,
)> {
if !run_generators {
return build_app_rows_with_lifecycle_options(config, categories, false).await;
}
let (static_rows, static_lifecycle, static_inspections) =
build_app_rows_with_lifecycle_options(config, categories, false).await?;
let installed = static_rows
.iter()
.filter(|row| row.file_status != FileStatus::NotInstalled)
.map(|row| row.category.as_str())
.collect::<BTreeSet<_>>();
let selected = categories
.iter()
.filter(|category| installed.contains(category.name.as_str()))
.cloned()
.collect::<Vec<_>>();
if selected.is_empty() {
return Ok((static_rows, static_lifecycle, static_inspections));
}
build_app_rows_with_lifecycle_options(config, &selected, true).await
}
fn print_generator_notice(rows: &[AppRow], run_generators: bool) -> (bool, bool) {
let not_evaluated = rows
.iter()
.any(|row| row.file_status == FileStatus::GeneratorNotEvaluated);
let failures = rows
.iter()
.filter(|row| {
matches!(
row.file_status,
FileStatus::GeneratorEvaluationFailed | FileStatus::GeneratorTrustRequired
)
})
.collect::<Vec<_>>();
if !run_generators && not_evaluated {
println!();
println!(
"{}",
colors::yellow("! Generated configuration was not evaluated.")
);
println!(
"{}",
colors::dim(
" Update status may be incomplete. Re-run with `--run-generators` to evaluate generator output."
)
);
}
for row in &failures {
println!();
println!(
"{}",
colors::yellow(&format!("! {}: {}", row.label, row.status_text))
);
}
(not_evaluated || !failures.is_empty(), !failures.is_empty())
}
pub async fn handle_update_list(config: &Config, diff: bool, run_generators: bool) -> Result<bool> {
let shell_rows = build_shell_rows(config).await?;
let shell_lifecycle = crate::shells::collect_update_lifecycle_result(config).await?;
let pending_shell = shell_lifecycle
.outcomes
.iter()
.filter(|outcome| outcome.status == shine_core::lifecycle::LifecycleStatus::Pending)
.map(|outcome| outcome.target.as_str())
.collect::<BTreeSet<_>>();
let update_shell: Vec<&ShellRow> = shell_rows
.iter()
.filter(|r| {
r.is_installed
&& pending_shell.contains(
format!(
"shell/{}/{}",
r.category,
r.label.split('/').next_back().unwrap_or(&r.label)
)
.as_str(),
)
})
.collect();
let cats = load_active_categories(config, None)
.await
.context("loading active App Presets for update")?;
let (app_rows, app_lifecycle, app_inspections) =
build_update_app_rows(config, &cats, run_generators).await?;
let pending_app = app_lifecycle
.outcomes
.iter()
.filter(|outcome| outcome.status == shine_core::lifecycle::LifecycleStatus::Pending)
.map(|outcome| outcome.target.as_str())
.collect::<BTreeSet<_>>();
let update_app_rows: Vec<&AppRow> = app_rows
.iter()
.filter(|r| {
r.upgrade_available && pending_app.contains(format!("app/{}", r.category).as_str())
})
.collect();
let update_app = app_update_categories(&update_app_rows);
let actionable_app_rows = app_rows
.iter()
.filter(|row| {
(row.upgrade_available || !row.refresh_sources.is_empty())
&& pending_app.contains(format!("app/{}", row.category).as_str())
})
.collect::<Vec<_>>();
let actionable_app = app_update_categories(&actionable_app_rows);
let refresh_commands = app_refresh_commands(&actionable_app);
let update_sys = sys::managed_updates(config)
.await
.context("checking managed Sys Presets for update")?;
let shell_attention = shell_rows
.iter()
.filter(|row| row.is_installed && (row.link_conflict || row.preset_missing))
.collect::<Vec<_>>();
let any_update =
!update_shell.is_empty() || !actionable_app.is_empty() || !update_sys.is_empty();
let has_generator_attention = app_rows.iter().any(|row| {
matches!(
row.file_status,
FileStatus::GeneratorNotEvaluated
| FileStatus::GeneratorEvaluationFailed
| FileStatus::GeneratorTrustRequired
)
});
if !any_update && !has_generator_attention && shell_attention.is_empty() {
return Ok(false);
}
crate::config::print_presets_note(config);
print_shell_attention(&shell_attention);
if !diff {
let shell_names = shell_categories(&update_shell);
let app_names = actionable_app
.keys()
.map(|category| (*category).to_string())
.collect::<Vec<_>>();
let sys_names = sorted_names(update_sys.iter().map(|row| row.item_id.clone()).collect());
let mut separator = output::SectionSeparator::new();
print_name_section(&mut separator, "Shell Presets", &shell_names);
print_name_section(&mut separator, "App Configs", &app_names);
print_name_section(&mut separator, "System Configs", &sys_names);
let (_, generator_failed) = print_generator_notice(&app_rows, run_generators);
let upgrade_app_names = update_app
.keys()
.map(|category| (*category).to_string())
.collect::<Vec<_>>();
print_action_hints(
&update_targets(&shell_names, &upgrade_app_names, &sys_names),
&refresh_commands,
);
if generator_failed {
anyhow::bail!("one or more App generators could not be evaluated");
}
return Ok(true);
}
let update_diffs = UpdateDiffs::collect_with_app_inspections(config, app_inspections).await?;
if !update_shell.is_empty() {
println!("{}", colors::bold("Shell Presets"));
let label_width = update_shell
.iter()
.map(|r| r.label.len())
.max()
.unwrap_or(0);
for row in &update_shell {
let pad = " ".repeat(label_width.saturating_sub(row.label.len()));
println!(
" {} {}{} {}",
row.symbol,
row.label,
pad,
colors::status_label(row.status_text, row.status_sym),
);
update_diffs.print_shell_for_row(config, &row.label).await?;
}
}
if !actionable_app.is_empty() {
if !update_shell.is_empty() {
println!();
}
println!("{}", colors::bold("App Configs"));
let label_width = actionable_app
.keys()
.map(|category| category.len())
.max()
.unwrap_or(0);
for (category, rows) in &actionable_app {
let pad = " ".repeat(label_width.saturating_sub(category.len()));
let (status_text, status_sym) = app_category_action_status(rows);
println!(
" {} {}{} {}",
colors::symbol("↑"),
category,
pad,
colors::status_label(status_text, status_sym),
);
for row in rows {
print_app_update_detail(row);
update_diffs.print_app_for_row(config, &row.label).await?;
}
}
}
if !update_sys.is_empty() {
if !update_shell.is_empty() || !actionable_app.is_empty() {
println!();
}
println!("{}", colors::bold("System Configs"));
for row in &update_sys {
println!(
" {} {} {} {}",
colors::symbol("↑"),
row.label,
colors::dim(&format!("({})", row.item_id)),
colors::status_label("update available", "↑"),
);
for detail in &row.details {
println!(" {}", colors::dim(detail));
}
}
}
let (_, generator_failed) = print_generator_notice(&app_rows, run_generators);
if any_update {
let shell_names = shell_categories(&update_shell);
let app_names = update_app
.keys()
.map(|category| (*category).to_string())
.collect::<Vec<_>>();
let sys_names = sorted_names(update_sys.iter().map(|row| row.item_id.clone()).collect());
print_action_hints(
&update_targets(&shell_names, &app_names, &sys_names),
&refresh_commands,
);
}
if generator_failed {
anyhow::bail!("one or more App generators could not be evaluated");
}
Ok(true)
}
fn shell_attention_lines(rows: &[&ShellRow]) -> Vec<String> {
let mut lines = Vec::new();
for row in rows
.iter()
.filter(|row| row.is_installed && (row.link_conflict || row.preset_missing))
{
lines.push(format!(" ! shell/{} {}", row.label, row.status_text));
if row.link_conflict {
lines.push(" Launcher preserved; resolve its ownership conflict before upgrading this target.".to_string());
}
if row.preset_missing {
lines.push(format!(" Restore the Preset or review `shine shell uninstall {}` to remove the installed command.", row.label));
}
}
lines
}
fn print_shell_attention(rows: &[&ShellRow]) {
let lines = shell_attention_lines(rows);
if lines.is_empty() {
return;
}
println!("{}", colors::bold("Shell attention required"));
for line in lines {
println!("{line}");
}
println!();
}
fn print_action_hints(upgrade_targets: &[String], refresh_commands: &[String]) {
if upgrade_targets.is_empty() && refresh_commands.is_empty() {
return;
}
println!();
if !upgrade_targets.is_empty() {
println!("{}", colors::dim(&update_hint_text(upgrade_targets)));
}
for command in refresh_commands {
println!(
"{}",
colors::dim(&format!(
"Run `{command}` to refresh generated configuration."
))
);
}
}
fn update_hint_text(targets: &[String]) -> String {
let command = match targets {
[target] => format!("shine upgrade {target}"),
_ => "shine upgrade".to_string(),
};
format!("Run `{command}` to apply updates.")
}
fn update_targets(shell: &[String], app: &[String], sys: &[String]) -> Vec<String> {
shell
.iter()
.map(|category| format!("shell/{category}"))
.chain(app.iter().map(|category| format!("app/{category}")))
.chain(sys.iter().map(|item| format!("sys/{item}")))
.collect()
}
fn app_update_categories<'a>(rows: &[&'a AppRow]) -> BTreeMap<&'a str, Vec<&'a AppRow>> {
let mut categories = BTreeMap::new();
for row in rows {
categories
.entry(row.category.as_str())
.or_insert_with(Vec::new)
.push(*row);
}
categories
}
fn app_refresh_commands(rows: &BTreeMap<&str, Vec<&AppRow>>) -> Vec<String> {
rows.iter()
.flat_map(|(category, rows)| {
rows.iter().flat_map(move |row| {
row.refresh_sources.iter().map(move |source| {
format!(
"shine app refresh {} {}",
crate::shell_quote::quote_if_needed(category),
crate::shell_quote::quote_if_needed(source)
)
})
})
})
.collect::<BTreeSet<_>>()
.into_iter()
.collect()
}
fn app_category_action_status(rows: &[&AppRow]) -> (&'static str, &'static str) {
let upgrade_available = rows.iter().any(|row| row.upgrade_available);
let refresh_available = rows.iter().any(|row| !row.refresh_sources.is_empty());
let text = match (upgrade_available, refresh_available) {
(true, true) => "update and refresh available",
(false, true) => "refresh available",
_ => "update available",
};
(text, "↑")
}
fn print_app_update_detail(row: &AppRow) {
let destination = row
.dest
.as_deref()
.map(|dest| format!(" {} {}", colors::dim("→"), colors::dim(dest)))
.unwrap_or_default();
println!(
" {} {}{} {}",
colors::symbol("↑"),
row.label,
destination,
colors::status_label(row.status_text, "↑"),
);
}
pub async fn handle_status_list(config: &Config, diff: bool, run_generators: bool) -> Result<()> {
crate::config::print_presets_note(config);
let shell_rows = build_shell_rows(config).await?;
let installed_shell: Vec<&ShellRow> = shell_rows.iter().filter(|r| r.is_installed).collect();
let all_shell: Vec<&ShellRow> = shell_rows.iter().collect();
print_shell_attention(
&installed_shell
.iter()
.copied()
.filter(|row| row.link_conflict || row.preset_missing)
.collect::<Vec<_>>(),
);
let cats = load_active_categories(config, None)
.await
.context("loading active App Presets for status")?;
let (app_rows, _, app_inspections) =
build_update_app_rows(config, &cats, run_generators).await?;
let installed_app: Vec<&AppRow> = app_rows
.iter()
.filter(|r| r.file_status != FileStatus::NotInstalled)
.collect();
let all_app: Vec<&AppRow> = app_rows.iter().collect();
let update_sys = sys::managed_updates(config)
.await
.context("checking managed Sys Presets for status")?;
let any = !installed_shell.is_empty() || !installed_app.is_empty() || !update_sys.is_empty();
if !any {
println!(
"{}",
colors::dim("Nothing installed yet. Run `shine shell install` or `shine app install`.")
);
return Ok(());
}
let update_diffs = if diff {
Some(UpdateDiffs::collect_with_app_inspections(config, app_inspections).await?)
} else {
None
};
let shell_statuses = if diff {
installed_shell
.iter()
.map(|row| ShellLifecycleStatus {
category: row.category.clone(),
detail_label: row.label.clone(),
status_sym: row.status_sym,
status_text: row.status_text,
})
.collect()
} else {
shell_category_statuses(&all_shell)
};
let app_statuses = if diff {
installed_app
.iter()
.map(|row| AppLifecycleStatus {
category: row.category.clone(),
detail_label: row.label.clone(),
sym: row.sym,
status_text: row.status_text,
file_status: row.file_status,
dest: row.dest.clone(),
upgrade_available: row.upgrade_available,
refresh_sources: row.refresh_sources.clone(),
})
.collect()
} else {
app_category_statuses(&all_app)
};
if !installed_shell.is_empty() {
println!("{}", colors::bold("Shell Presets"));
let label_width = if diff {
installed_shell.iter().map(|row| row.label.len()).max()
} else {
shell_statuses.iter().map(|row| row.category.len()).max()
}
.unwrap_or(0);
for row in &shell_statuses {
let label = if diff {
&row.detail_label
} else {
&row.category
};
let pad = " ".repeat(label_width.saturating_sub(label.len()));
let run_hint = if row.status_sym == "↑" {
format!(" {}", colors::dim("run `shine upgrade`"))
} else {
String::new()
};
println!(
" {} {}{} {}{}",
colors::symbol(row.status_sym),
label,
pad,
colors::status_label(row.status_text, row.status_sym),
run_hint,
);
if diff
&& row.status_sym == "↑"
&& let Some(diffs) = &update_diffs
{
diffs.print_shell_for_row(config, &row.detail_label).await?;
}
}
}
if !installed_app.is_empty() {
if !installed_shell.is_empty() {
println!();
}
println!("{}", colors::bold("App Configs"));
let label_width = if diff {
installed_app.iter().map(|row| row.label.len()).max()
} else {
app_statuses.iter().map(|row| row.category.len()).max()
}
.unwrap_or(0);
let mut up_to_date = 0usize;
let mut update_available = 0usize;
let mut refresh_available = 0usize;
let mut user_modified = 0usize;
let mut missing = 0usize;
for row in &app_statuses {
let label = if diff {
&row.detail_label
} else {
&row.category
};
let pad = " ".repeat(label_width.saturating_sub(label.len()));
let dest_part = if diff {
row.dest
.as_deref()
.map(|d| format!(" {} {}", colors::dim("→"), colors::dim(d)))
.unwrap_or_default()
} else {
String::new()
};
let run_hint = app_status_run_hint(row);
println!(
" {} {}{}{} {}{}",
colors::symbol(row.sym),
label,
pad,
dest_part,
colors::status_label(row.status_text, row.sym),
run_hint,
);
if diff
&& row.file_status == FileStatus::UpdateAvail
&& let Some(diffs) = &update_diffs
{
diffs.print_app_for_row(config, &row.detail_label).await?;
}
match row.file_status {
FileStatus::Missing => missing += 1,
FileStatus::UserModified | FileStatus::Partial => user_modified += 1,
FileStatus::UpdateAvail => {
if row.upgrade_available {
update_available += 1;
}
if !row.refresh_sources.is_empty() {
refresh_available += 1;
}
if !row.upgrade_available && row.refresh_sources.is_empty() {
update_available += 1;
}
}
FileStatus::GeneratorNotEvaluated
| FileStatus::GeneratorEvaluationFailed
| FileStatus::GeneratorTrustRequired => user_modified += 1,
FileStatus::UpToDate => up_to_date += 1,
FileStatus::NotInstalled => {}
}
}
let parts = app_status_summary_parts(
up_to_date,
update_available,
refresh_available,
user_modified,
missing,
);
if !parts.is_empty() {
output::footer("Summary", &parts);
}
}
if !update_sys.is_empty() {
if !installed_shell.is_empty() || !installed_app.is_empty() {
println!();
}
println!("{}", colors::bold("System Configs"));
for row in &update_sys {
println!(
" {} {} {} {} {}",
colors::symbol("↑"),
row.label,
colors::dim(&format!("({})", row.item_id)),
colors::status_label("update available", "↑"),
colors::dim("run `shine upgrade`"),
);
for detail in &row.details {
println!(" {}", colors::dim(detail));
}
}
}
let (_, generator_failed) = print_generator_notice(&app_rows, run_generators);
if generator_failed {
anyhow::bail!("one or more App generators could not be evaluated");
}
Ok(())
}
pub async fn handle_list(config: &Config) -> Result<()> {
crate::config::print_presets_note(config);
let service = crate::core_runtime::frontend_from_config(config).await?;
let shell_inventory = service
.inventory(InventoryRequest::for_kind(CapabilityKindV1::Shell))
.await
.map_err(FrontendServiceError::into_source)?;
let installed_shell = installed_shell_categories_from_inventory(&shell_inventory);
let installed_app = match service
.inventory(InventoryRequest::for_kind(CapabilityKindV1::App))
.await
{
Ok(report) => installed_app_categories_from_inventory(&report),
Err(error) if error.diagnostic().code == "frontend_inventory_app_source_failed" => {
Vec::new()
}
Err(error) => return Err(error.into_source()),
};
let os_id = sys::detect_os_id().await?;
let sys_inventory = service
.inventory(
InventoryRequest::for_kind(CapabilityKindV1::Sys)
.with_sys_os_id(os_id)
.installed_only(),
)
.await
.map_err(FrontendServiceError::into_source)?;
let installed_sys = installed_sys_items_from_inventory(&sys_inventory);
let installed_shell = sorted_names(installed_shell);
let installed_app = sorted_names(installed_app);
let installed_sys = sorted_names(installed_sys);
let any = !installed_shell.is_empty() || !installed_app.is_empty() || !installed_sys.is_empty();
if !any {
println!(
"{}",
colors::dim(
"Nothing installed yet. Run `shine shell install`, `shine app install`, or `shine sys list`."
)
);
return Ok(());
}
let mut separator = output::SectionSeparator::new();
print_name_section(&mut separator, "Shell Presets", &installed_shell);
print_name_section(&mut separator, "App Configs", &installed_app);
print_name_section(&mut separator, "System Configs", &installed_sys);
Ok(())
}
fn shell_inventory_category(target: &str) -> Option<String> {
let remainder = target.strip_prefix("shell/")?;
let (category, command) = remainder.split_once('/')?;
(!category.is_empty() && !command.is_empty()).then(|| category.to_string())
}
fn installed_shell_categories_from_inventory(report: &InventoryReportV1) -> Vec<String> {
report
.items
.iter()
.filter(|item| item.available && item.installed)
.filter_map(|item| shell_inventory_category(&item.target))
.collect::<BTreeSet<_>>()
.into_iter()
.collect()
}
fn installed_app_categories_from_inventory(report: &InventoryReportV1) -> Vec<String> {
report
.items
.iter()
.filter(|item| item.available && item.installed)
.filter_map(|item| item.target.strip_prefix("app/").map(str::to_string))
.collect()
}
fn installed_sys_items_from_inventory(report: &InventoryReportV1) -> Vec<String> {
report
.items
.iter()
.filter(|item| item.installed)
.filter_map(|item| item.target.strip_prefix("sys/").map(str::to_string))
.collect()
}
fn print_name_section(separator: &mut output::SectionSeparator, title: &str, names: &[String]) {
if names.is_empty() {
return;
}
separator.begin();
println!("{} {}", colors::cyan("==>"), colors::bold(title));
output::print_columns(names);
}
#[cfg(test)]
fn installed_app_categories(rows: &[AppRow]) -> Vec<String> {
rows.iter()
.filter(|row| row.file_status != FileStatus::NotInstalled)
.map(|row| row.category.clone())
.collect::<BTreeSet<_>>()
.into_iter()
.collect()
}
fn shell_categories(rows: &[&ShellRow]) -> Vec<String> {
rows.iter()
.map(|row| row.category.clone())
.collect::<BTreeSet<_>>()
.into_iter()
.collect()
}
struct ShellLifecycleStatus {
category: String,
detail_label: String,
status_sym: &'static str,
status_text: &'static str,
}
fn shell_category_statuses(rows: &[&ShellRow]) -> Vec<ShellLifecycleStatus> {
let mut grouped: BTreeMap<&str, Vec<&ShellRow>> = BTreeMap::new();
for row in rows {
grouped.entry(&row.category).or_default().push(row);
}
grouped
.into_iter()
.filter_map(|(category, rows)| {
if !rows.iter().any(|row| row.is_installed) {
return None;
}
if rows.len() == 1 {
let row = rows[0];
return Some(ShellLifecycleStatus {
category: category.to_string(),
detail_label: row.label.clone(),
status_sym: row.status_sym,
status_text: row.status_text,
});
}
let selected = rows
.iter()
.filter(|row| row.is_installed)
.max_by_key(|row| shell_status_priority(row.status_sym))
.expect("grouped shell category is non-empty");
let partially_installed = rows.iter().any(|row| !row.is_installed);
let (status_sym, status_text) = if partially_installed && selected.status_sym == "✓" {
("~", "partial install")
} else {
(selected.status_sym, selected.status_text)
};
Some(ShellLifecycleStatus {
category: category.to_string(),
detail_label: category.to_string(),
status_sym,
status_text,
})
})
.collect()
}
fn shell_status_priority(sym: &str) -> usize {
match sym {
"!" => 4,
"~" => 3,
"↑" => 2,
"✓" => 1,
_ => 0,
}
}
struct AppLifecycleStatus {
category: String,
detail_label: String,
sym: &'static str,
status_text: &'static str,
file_status: FileStatus,
dest: Option<String>,
upgrade_available: bool,
refresh_sources: Vec<String>,
}
fn app_category_statuses(rows: &[&AppRow]) -> Vec<AppLifecycleStatus> {
let mut grouped: BTreeMap<&str, Vec<&AppRow>> = BTreeMap::new();
for row in rows {
grouped.entry(&row.category).or_default().push(row);
}
grouped
.into_iter()
.filter_map(|(category, rows)| {
let has_installed = rows
.iter()
.any(|row| row.file_status != FileStatus::NotInstalled);
if !has_installed {
return None;
}
if rows.len() == 1 {
let row = rows[0];
return Some(AppLifecycleStatus {
category: category.to_string(),
detail_label: row.label.clone(),
sym: row.sym,
status_text: row.status_text,
file_status: row.file_status,
dest: row.dest.clone(),
upgrade_available: row.upgrade_available,
refresh_sources: row.refresh_sources.clone(),
});
}
let has_not_installed = rows
.iter()
.any(|row| row.file_status == FileStatus::NotInstalled);
let installed_max = rows
.iter()
.map(|row| row.file_status)
.filter(|status| *status != FileStatus::NotInstalled)
.max()
.expect("installed app category has an installed row");
let status = if has_not_installed && installed_max == FileStatus::UpToDate {
FileStatus::Partial
} else {
installed_max
};
let (sym, mut status_text) = match status {
FileStatus::Missing => ("!", "destination missing"),
FileStatus::UserModified => ("~", "user modified"),
FileStatus::Partial => ("~", "partial install"),
FileStatus::UpdateAvail => ("↑", "update available"),
FileStatus::GeneratorNotEvaluated => ("!", "generator not evaluated"),
FileStatus::GeneratorEvaluationFailed => ("!", "generator evaluation failed"),
FileStatus::GeneratorTrustRequired => ("!", "generator trust required"),
FileStatus::UpToDate => ("✓", "up-to-date"),
FileStatus::NotInstalled => unreachable!(),
};
let upgrade_available = rows.iter().any(|row| row.upgrade_available);
let refresh_sources = rows
.iter()
.flat_map(|row| row.refresh_sources.iter().cloned())
.collect::<BTreeSet<_>>()
.into_iter()
.collect::<Vec<_>>();
if status == FileStatus::UpdateAvail {
status_text = match (upgrade_available, refresh_sources.is_empty()) {
(true, false) => "update and refresh available",
(false, false) => "refresh available",
_ => status_text,
};
}
Some(AppLifecycleStatus {
category: category.to_string(),
detail_label: category.to_string(),
sym,
status_text,
file_status: status,
dest: None,
upgrade_available,
refresh_sources,
})
})
.collect()
}
fn sorted_names(mut names: Vec<String>) -> Vec<String> {
names.sort_by(|left, right| {
left.to_lowercase()
.cmp(&right.to_lowercase())
.then_with(|| left.cmp(right))
});
names
}
#[cfg(test)]
fn should_show_shell_in_simple_list(row: &ShellRow) -> bool {
row.is_installed && row.status_text != SHELL_PRESET_PRESENT_LINK_MISSING
}
fn app_status_summary_parts(
up_to_date: usize,
update_available: usize,
refresh_available: usize,
user_modified: usize,
missing: usize,
) -> Vec<String> {
let mut parts = Vec::new();
output::push_count(&mut parts, up_to_date, colors::green, "up-to-date");
output::push_count(
&mut parts,
update_available,
colors::cyan,
"update available",
);
output::push_count(
&mut parts,
refresh_available,
colors::cyan,
"refresh available",
);
output::push_count(&mut parts, user_modified, colors::yellow, "user-modified");
output::push_count(&mut parts, missing, colors::yellow, "destination missing");
parts
}
fn app_status_run_hint(row: &AppLifecycleStatus) -> String {
let mut commands = Vec::new();
if row.upgrade_available {
commands.push(format!(
"run `shine upgrade app/{}`",
crate::shell_quote::quote_if_needed(&row.category)
));
}
commands.extend(row.refresh_sources.iter().map(|source| {
format!(
"run `shine app refresh {} {}`",
crate::shell_quote::quote_if_needed(&row.category),
crate::shell_quote::quote_if_needed(source)
)
}));
if commands.is_empty() {
String::new()
} else {
format!(" {}", colors::dim(&commands.join("; ")))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn shell_row(status_text: &'static str, is_installed: bool) -> ShellRow {
ShellRow {
category: "proxy".to_string(),
symbol: String::new(),
label: "proxy/setproxy".to_string(),
status_sym: "~",
status_text,
is_installed,
link_conflict: false,
preset_missing: false,
changes: Vec::new(),
}
}
fn app_row(category: &str, file_status: FileStatus) -> AppRow {
AppRow {
category: category.to_string(),
sym: "✓",
label: category.to_string(),
simple_label: category.to_string(),
dest: None,
status_text: "up-to-date",
file_status,
upgrade_available: file_status == FileStatus::UpdateAvail,
refresh_sources: Vec::new(),
}
}
#[test]
fn shell_attention_reports_conflicts_and_missing_presets_without_upgrade_hint() {
let mut conflict = shell_row("launcher ownership conflict", true);
conflict.link_conflict = true;
let mut missing = shell_row("preset missing; installed entry preserved", true);
missing.preset_missing = true;
let current = shell_row("up-to-date", true);
let lines = shell_attention_lines(&[&conflict, &missing, ¤t]).join("\n");
assert!(lines.contains("launcher ownership conflict"));
assert!(lines.contains("preset missing"));
assert!(lines.contains("shine shell uninstall"));
assert!(!lines.contains("shine upgrade"));
assert!(!lines.contains("up-to-date"));
assert!(shell_attention_lines(&[¤t]).is_empty());
}
#[test]
fn update_rows_group_app_files_by_category() {
let first = app_row("clash-verge", FileStatus::UpdateAvail);
let mut second = app_row("clash-verge", FileStatus::UpdateAvail);
second.label = "clash-verge/rules/lan.list".to_string();
let other = app_row("surge", FileStatus::UpdateAvail);
let grouped = app_update_categories(&[&first, &second, &other]);
assert_eq!(grouped.len(), 2);
assert_eq!(grouped["clash-verge"].len(), 2);
assert_eq!(grouped["surge"].len(), 1);
}
#[test]
fn update_rows_collapse_shell_commands_to_their_category() {
let first = shell_row("update available", true);
let mut second = shell_row("update available", true);
second.label = "proxy/usetproxy".to_string();
assert_eq!(shell_categories(&[&first, &second]), vec!["proxy"]);
}
#[test]
fn manual_generator_updates_render_refresh_commands_instead_of_upgrade_targets() {
let mut row = app_row("surge", FileStatus::UpdateAvail);
row.upgrade_available = false;
row.refresh_sources = vec!["subscription-proxies.conf".to_string()];
let grouped = app_update_categories(&[&row]);
assert_eq!(
app_refresh_commands(&grouped),
["shine app refresh surge subscription-proxies.conf"]
);
assert_eq!(
app_category_action_status(&grouped["surge"]),
("refresh available", "↑")
);
}
#[test]
fn mixed_app_updates_retain_both_upgrade_and_refresh_actions() {
let automatic = app_row("sample", FileStatus::UpdateAvail);
let mut manual = app_row("sample", FileStatus::UpdateAvail);
manual.upgrade_available = false;
manual.refresh_sources = vec!["generated.conf".to_string()];
let grouped = app_update_categories(&[&automatic, &manual]);
assert_eq!(
app_category_action_status(&grouped["sample"]),
("update and refresh available", "↑")
);
assert_eq!(
app_refresh_commands(&grouped),
["shine app refresh sample generated.conf"]
);
}
#[test]
fn update_hint_targets_the_only_pending_category() {
let targets = update_targets(&[], &["clash-verge".to_string()], &[]);
assert_eq!(targets, ["app/clash-verge"]);
assert_eq!(
update_hint_text(&targets),
"Run `shine upgrade app/clash-verge` to apply updates."
);
}
#[test]
fn update_hint_keeps_global_upgrade_for_multiple_categories() {
let targets = update_targets(
&["proxy".to_string()],
&["clash-verge".to_string()],
&["split-dns".to_string()],
);
assert_eq!(targets, ["shell/proxy", "app/clash-verge", "sys/split-dns"]);
assert_eq!(
update_hint_text(&targets),
"Run `shine upgrade` to apply updates."
);
}
#[test]
fn default_shell_status_collapses_commands_and_reports_partial_install() {
let mut installed = shell_row("up-to-date", true);
installed.status_sym = "✓";
let mut missing = shell_row("not installed", false);
missing.label = "proxy/usetproxy".to_string();
missing.status_sym = "✗";
let statuses = shell_category_statuses(&[&installed, &missing]);
assert_eq!(statuses.len(), 1);
assert_eq!(statuses[0].category, "proxy");
assert_eq!(statuses[0].status_text, "partial install");
}
#[test]
fn default_app_status_collapses_files_and_reports_partial_install() {
let installed = app_row("surge", FileStatus::UpToDate);
let missing = app_row("surge", FileStatus::NotInstalled);
let statuses = app_category_statuses(&[&installed, &missing]);
assert_eq!(statuses.len(), 1);
assert_eq!(statuses[0].category, "surge");
assert_eq!(statuses[0].file_status, FileStatus::Partial);
}
#[test]
fn simple_list_hides_preset_present_when_bin_symlink_missing() {
let row = shell_row(SHELL_PRESET_PRESENT_LINK_MISSING, true);
assert!(!should_show_shell_in_simple_list(&row));
}
#[test]
fn simple_list_keeps_other_installed_shell_states() {
assert!(should_show_shell_in_simple_list(&shell_row(
"up-to-date",
true
)));
assert!(should_show_shell_in_simple_list(&shell_row(
"bin symlink present, preset missing",
true
)));
assert!(should_show_shell_in_simple_list(&shell_row(
"update available",
true
)));
}
#[test]
fn simple_list_hides_uninstalled_shell_rows() {
let row = shell_row("not installed", false);
assert!(!should_show_shell_in_simple_list(&row));
}
#[test]
fn simple_list_collapses_installed_app_files_to_their_category() {
let rows = vec![
app_row("surge", FileStatus::UpToDate),
app_row("surge", FileStatus::Missing),
app_row("ghostty", FileStatus::NotInstalled),
];
assert_eq!(installed_app_categories(&rows), vec!["surge"]);
}
#[test]
fn simple_list_shows_partially_installed_app_categories() {
let rows = vec![
app_row("surge", FileStatus::NotInstalled),
app_row("surge", FileStatus::UserModified),
];
assert_eq!(installed_app_categories(&rows), vec!["surge"]);
}
#[test]
fn frontend_inventory_projection_preserves_legacy_list_visibility() {
let report = InventoryReportV1 {
schema_version: shine_core::frontend::INVENTORY_REPORT_SCHEMA_VERSION,
items: vec![
shine_core::frontend::CapabilityInventoryItemV1 {
target: "app/installed".to_string(),
kind: CapabilityKindV1::App,
available: true,
installed: true,
},
shine_core::frontend::CapabilityInventoryItemV1 {
target: "app/orphan".to_string(),
kind: CapabilityKindV1::App,
available: false,
installed: true,
},
shine_core::frontend::CapabilityInventoryItemV1 {
target: "shell/tools/one".to_string(),
kind: CapabilityKindV1::Shell,
available: true,
installed: true,
},
shine_core::frontend::CapabilityInventoryItemV1 {
target: "shell/tools/two".to_string(),
kind: CapabilityKindV1::Shell,
available: true,
installed: true,
},
shine_core::frontend::CapabilityInventoryItemV1 {
target: "shell/orphan/tool".to_string(),
kind: CapabilityKindV1::Shell,
available: false,
installed: true,
},
shine_core::frontend::CapabilityInventoryItemV1 {
target: "sys/managed".to_string(),
kind: CapabilityKindV1::Sys,
available: false,
installed: true,
},
],
diagnostics: Vec::new(),
};
assert_eq!(
installed_app_categories_from_inventory(&report),
["installed"]
);
assert_eq!(
installed_shell_categories_from_inventory(&report),
["tools"]
);
assert_eq!(installed_sys_items_from_inventory(&report), ["managed"]);
}
#[test]
fn simple_list_sorts_names_case_insensitively() {
assert_eq!(
sorted_names(vec![
"surge".to_string(),
"JetBrains".to_string(),
"ghostty".to_string(),
]),
vec!["ghostty", "JetBrains", "surge"]
);
}
#[test]
fn app_status_summary_parts_includes_only_nonzero_counts() {
assert_eq!(
app_status_summary_parts(3, 1, 0, 0, 0),
vec!["3 up-to-date".to_string(), "1 update available".to_string()]
);
}
#[test]
fn app_status_summary_parts_empty_when_all_zero() {
assert!(app_status_summary_parts(0, 0, 0, 0, 0).is_empty());
}
#[test]
fn app_status_summary_parts_reports_all_five_counters() {
assert_eq!(
app_status_summary_parts(1, 2, 3, 4, 5),
vec![
"1 up-to-date".to_string(),
"2 update available".to_string(),
"3 refresh available".to_string(),
"4 user-modified".to_string(),
"5 destination missing".to_string(),
]
);
}
}