1use crate::apps::load_active_categories;
2use crate::colors;
3use crate::config::Config;
4use crate::info::UpdateDiffs;
5use crate::output;
6use crate::status::{AppRow, FileStatus, ShellRow, build_app_rows, build_shell_rows};
7use crate::sys;
8use anyhow::Result;
9use std::collections::{BTreeMap, BTreeSet};
10
11const SHELL_PRESET_PRESENT_LINK_MISSING: &str = "preset present, bin symlink missing";
12
13pub async fn handle_update_list(config: &Config, diff: bool) -> Result<bool> {
14 let shell_rows = build_shell_rows(config).await?;
15 let update_shell: Vec<&ShellRow> = shell_rows
16 .iter()
17 .filter(|r| r.is_installed && r.status_sym == "↑")
18 .collect();
19
20 let cats_result = load_active_categories(config, None).await;
21 let app_rows = match cats_result {
22 Ok(cats) => build_app_rows(config, &cats).await?,
23 Err(_) => Vec::new(),
24 };
25 let update_app: Vec<&AppRow> = app_rows
26 .iter()
27 .filter(|r| r.file_status == FileStatus::UpdateAvail)
28 .collect();
29 let update_app = app_update_categories(&update_app);
30 let update_sys = sys::managed_updates(config).await.unwrap_or_default();
31
32 let any = !update_shell.is_empty() || !update_app.is_empty() || !update_sys.is_empty();
33 if !any {
34 return Ok(false);
35 }
36
37 crate::config::print_presets_note(config);
38
39 if !diff {
40 let shell_names = sorted_names(update_shell.iter().map(|row| row.label.clone()).collect());
41 let app_names = update_app
42 .keys()
43 .map(|category| (*category).to_string())
44 .collect::<Vec<_>>();
45 let sys_names = sorted_names(update_sys.iter().map(|row| row.item_id.clone()).collect());
46
47 let mut separator = output::SectionSeparator::new();
48 print_name_section(&mut separator, "Shell Presets", &shell_names);
49 print_name_section(&mut separator, "App Configs", &app_names);
50 print_name_section(&mut separator, "System Configs", &sys_names);
51 print_update_hint();
52 return Ok(true);
53 }
54
55 let update_diffs = UpdateDiffs::collect(config).await?;
56
57 if !update_shell.is_empty() {
58 println!("{}", colors::bold("Shell Presets"));
59
60 let label_width = update_shell
61 .iter()
62 .map(|r| r.label.len())
63 .max()
64 .unwrap_or(0);
65
66 for row in &update_shell {
67 let pad = " ".repeat(label_width.saturating_sub(row.label.len()));
68 println!(
69 " {} {}{} {}",
70 row.symbol,
71 row.label,
72 pad,
73 colors::status_label(row.status_text, row.status_sym),
74 );
75 update_diffs.print_shell_for_row(config, &row.label).await?;
76 }
77 }
78
79 if !update_app.is_empty() {
80 if !update_shell.is_empty() {
81 println!();
82 }
83 println!("{}", colors::bold("App Configs"));
84
85 let label_width = update_app
86 .keys()
87 .map(|category| category.len())
88 .max()
89 .unwrap_or(0);
90
91 for (category, rows) in &update_app {
92 let pad = " ".repeat(label_width.saturating_sub(category.len()));
93 println!(
94 " {} {}{} {}",
95 colors::symbol("↑"),
96 category,
97 pad,
98 colors::status_label("update available", "↑"),
99 );
100 for row in rows {
101 print_app_update_detail(row);
102 update_diffs.print_app_for_row(config, &row.label).await?;
103 }
104 }
105 }
106
107 if !update_sys.is_empty() {
108 if !update_shell.is_empty() || !update_app.is_empty() {
109 println!();
110 }
111 println!("{}", colors::bold("System Configs"));
112 for row in &update_sys {
113 println!(
114 " {} {} {} {}",
115 colors::symbol("↑"),
116 row.label,
117 colors::dim(&format!("({})", row.item_id)),
118 colors::status_label("update available", "↑"),
119 );
120 for detail in &row.details {
121 println!(" {}", colors::dim(detail));
122 }
123 }
124 }
125
126 print_update_hint();
127
128 Ok(true)
129}
130
131fn print_update_hint() {
132 println!();
133 println!("{}", colors::dim("Run `shine upgrade` to apply updates."));
134}
135
136fn app_update_categories<'a>(rows: &[&'a AppRow]) -> BTreeMap<&'a str, Vec<&'a AppRow>> {
137 let mut categories = BTreeMap::new();
138 for row in rows {
139 categories
140 .entry(row.category.as_str())
141 .or_insert_with(Vec::new)
142 .push(*row);
143 }
144 categories
145}
146
147fn print_app_update_detail(row: &AppRow) {
148 let destination = row
149 .dest
150 .as_deref()
151 .map(|dest| format!(" {} {}", colors::dim("→"), colors::dim(dest)))
152 .unwrap_or_default();
153 println!(
154 " {} {}{} {}",
155 colors::symbol("↑"),
156 row.label,
157 destination,
158 colors::status_label("update available", "↑"),
159 );
160}
161
162pub async fn handle_status_list(config: &Config, diff: bool) -> Result<()> {
163 crate::config::print_presets_note(config);
164 let shell_rows = build_shell_rows(config).await?;
165 let installed_shell: Vec<&ShellRow> = shell_rows.iter().filter(|r| r.is_installed).collect();
166
167 let cats_result = load_active_categories(config, None).await;
168 let app_rows = match cats_result {
169 Ok(cats) => build_app_rows(config, &cats).await?,
170 Err(_) => Vec::new(),
171 };
172 let installed_app: Vec<&AppRow> = app_rows
173 .iter()
174 .filter(|r| r.file_status != FileStatus::NotInstalled)
175 .collect();
176 let update_sys = sys::managed_updates(config).await.unwrap_or_default();
177
178 let any = !installed_shell.is_empty() || !installed_app.is_empty() || !update_sys.is_empty();
179
180 if !any {
181 println!(
182 "{}",
183 colors::dim("Nothing installed yet. Run `shine shell install` or `shine app install`.")
184 );
185 return Ok(());
186 }
187
188 let update_diffs = if diff {
189 Some(UpdateDiffs::collect(config).await?)
190 } else {
191 None
192 };
193
194 if !installed_shell.is_empty() {
196 println!("{}", colors::bold("Shell Presets"));
197
198 let label_width = installed_shell
199 .iter()
200 .map(|r| r.label.len())
201 .max()
202 .unwrap_or(0);
203
204 for row in &installed_shell {
205 let pad = " ".repeat(label_width.saturating_sub(row.label.len()));
206 let run_hint = if row.status_sym == "↑" {
207 format!(" {}", colors::dim("run `shine upgrade`"))
208 } else {
209 String::new()
210 };
211 println!(
212 " {} {}{} {}{}",
213 row.symbol,
214 row.label,
215 pad,
216 colors::status_label(row.status_text, row.status_sym),
217 run_hint,
218 );
219 if row.status_sym == "↑"
220 && let Some(diffs) = &update_diffs
221 {
222 diffs.print_shell_for_row(config, &row.label).await?;
223 }
224 }
225 }
226
227 if !installed_app.is_empty() {
229 if !installed_shell.is_empty() {
230 println!();
231 }
232 println!("{}", colors::bold("App Configs"));
233
234 let label_width = installed_app
235 .iter()
236 .map(|r| r.label.len())
237 .max()
238 .unwrap_or(0);
239
240 let mut up_to_date = 0usize;
241 let mut update_available = 0usize;
242 let mut user_modified = 0usize;
243 let mut missing = 0usize;
244
245 for row in &installed_app {
246 let pad = " ".repeat(label_width.saturating_sub(row.label.len()));
247 let dest_part = row
248 .dest
249 .as_deref()
250 .map(|d| format!(" {} {}", colors::dim("→"), colors::dim(d)))
251 .unwrap_or_default();
252
253 let run_hint = if row.sym == "↑" {
254 format!(" {}", colors::dim("run `shine upgrade`"))
255 } else {
256 String::new()
257 };
258
259 println!(
260 " {} {}{}{} {}{}",
261 colors::symbol(row.sym),
262 row.label,
263 pad,
264 dest_part,
265 colors::status_label(row.status_text, row.sym),
266 run_hint,
267 );
268
269 if row.file_status == FileStatus::UpdateAvail
270 && let Some(diffs) = &update_diffs
271 {
272 diffs.print_app_for_row(config, &row.label).await?;
273 }
274
275 match row.file_status {
276 FileStatus::Missing => missing += 1,
277 FileStatus::UserModified | FileStatus::Partial => user_modified += 1,
278 FileStatus::UpdateAvail => update_available += 1,
279 FileStatus::UpToDate => up_to_date += 1,
280 FileStatus::NotInstalled => {}
281 }
282 }
283
284 let parts = app_status_summary_parts(up_to_date, update_available, user_modified, missing);
285 if !parts.is_empty() {
286 output::footer("Summary", &parts);
287 }
288 }
289
290 if !update_sys.is_empty() {
291 if !installed_shell.is_empty() || !installed_app.is_empty() {
292 println!();
293 }
294 println!("{}", colors::bold("System Configs"));
295 for row in &update_sys {
296 println!(
297 " {} {} {} {} {}",
298 colors::symbol("↑"),
299 row.label,
300 colors::dim(&format!("({})", row.item_id)),
301 colors::status_label("update available", "↑"),
302 colors::dim("run `shine upgrade`"),
303 );
304 for detail in &row.details {
305 println!(" {}", colors::dim(detail));
306 }
307 }
308 }
309
310 Ok(())
311}
312
313pub async fn handle_list(config: &Config) -> Result<()> {
314 crate::config::print_presets_note(config);
315 let shell_rows = build_shell_rows(config).await?;
316 let installed_shell: Vec<String> = shell_rows
317 .iter()
318 .filter(|r| should_show_shell_in_simple_list(r))
319 .map(|r| r.label.clone())
320 .collect();
321
322 let cats_result = load_active_categories(config, None).await;
323 let installed_app = match cats_result {
324 Ok(cats) => {
325 let app_rows = build_app_rows(config, &cats).await?;
326 installed_app_categories(&app_rows)
327 }
328 Err(_) => Vec::new(),
329 };
330 let installed_sys = sys::installed_managed(config).await?;
331 let installed_sys: Vec<String> = installed_sys
332 .iter()
333 .map(|row| row.item_id.clone())
334 .collect();
335
336 let installed_shell = sorted_names(installed_shell);
337 let installed_app = sorted_names(installed_app);
338 let installed_sys = sorted_names(installed_sys);
339
340 let any = !installed_shell.is_empty() || !installed_app.is_empty() || !installed_sys.is_empty();
341
342 if !any {
343 println!(
344 "{}",
345 colors::dim(
346 "Nothing installed yet. Run `shine shell install`, `shine app install`, or `shine sys list`."
347 )
348 );
349 return Ok(());
350 }
351
352 let mut separator = output::SectionSeparator::new();
353 print_name_section(&mut separator, "Shell Presets", &installed_shell);
354 print_name_section(&mut separator, "App Configs", &installed_app);
355 print_name_section(&mut separator, "System Configs", &installed_sys);
356
357 Ok(())
358}
359
360fn print_name_section(separator: &mut output::SectionSeparator, title: &str, names: &[String]) {
361 if names.is_empty() {
362 return;
363 }
364
365 separator.begin();
366 println!("{} {}", colors::cyan("==>"), colors::bold(title));
367 output::print_columns(names);
368}
369
370fn installed_app_categories(rows: &[AppRow]) -> Vec<String> {
371 rows.iter()
372 .filter(|row| row.file_status != FileStatus::NotInstalled)
373 .map(|row| row.category.clone())
374 .collect::<BTreeSet<_>>()
375 .into_iter()
376 .collect()
377}
378
379fn sorted_names(mut names: Vec<String>) -> Vec<String> {
380 names.sort_by(|left, right| {
381 left.to_lowercase()
382 .cmp(&right.to_lowercase())
383 .then_with(|| left.cmp(right))
384 });
385 names
386}
387
388fn should_show_shell_in_simple_list(row: &ShellRow) -> bool {
389 row.is_installed && row.status_text != SHELL_PRESET_PRESENT_LINK_MISSING
390}
391
392fn app_status_summary_parts(
393 up_to_date: usize,
394 update_available: usize,
395 user_modified: usize,
396 missing: usize,
397) -> Vec<String> {
398 let mut parts = Vec::new();
399 output::push_count(&mut parts, up_to_date, colors::green, "up-to-date");
400 output::push_count(
401 &mut parts,
402 update_available,
403 colors::cyan,
404 "update available",
405 );
406 output::push_count(&mut parts, user_modified, colors::yellow, "user-modified");
407 output::push_count(&mut parts, missing, colors::yellow, "destination missing");
408 parts
409}
410
411#[cfg(test)]
412mod tests {
413 use super::*;
414
415 fn shell_row(status_text: &'static str, is_installed: bool) -> ShellRow {
416 ShellRow {
417 symbol: String::new(),
418 label: "proxy/setproxy".to_string(),
419 status_sym: "~",
420 status_text,
421 is_installed,
422 }
423 }
424
425 fn app_row(category: &str, file_status: FileStatus) -> AppRow {
426 AppRow {
427 category: category.to_string(),
428 sym: "✓",
429 label: category.to_string(),
430 simple_label: category.to_string(),
431 dest: None,
432 status_text: "up-to-date",
433 file_status,
434 }
435 }
436
437 #[test]
438 fn update_rows_group_app_files_by_category() {
439 let first = app_row("clash-verge", FileStatus::UpdateAvail);
440 let mut second = app_row("clash-verge", FileStatus::UpdateAvail);
441 second.label = "clash-verge/rules/lan.list".to_string();
442 let other = app_row("surge", FileStatus::UpdateAvail);
443 let grouped = app_update_categories(&[&first, &second, &other]);
444
445 assert_eq!(grouped.len(), 2);
446 assert_eq!(grouped["clash-verge"].len(), 2);
447 assert_eq!(grouped["surge"].len(), 1);
448 }
449
450 #[test]
451 fn simple_list_hides_preset_present_when_bin_symlink_missing() {
452 let row = shell_row(SHELL_PRESET_PRESENT_LINK_MISSING, true);
453
454 assert!(!should_show_shell_in_simple_list(&row));
455 }
456
457 #[test]
458 fn simple_list_keeps_other_installed_shell_states() {
459 assert!(should_show_shell_in_simple_list(&shell_row(
460 "up-to-date",
461 true
462 )));
463 assert!(should_show_shell_in_simple_list(&shell_row(
464 "bin symlink present, preset missing",
465 true
466 )));
467 assert!(should_show_shell_in_simple_list(&shell_row(
468 "update available",
469 true
470 )));
471 }
472
473 #[test]
474 fn simple_list_hides_uninstalled_shell_rows() {
475 let row = shell_row("not installed", false);
476
477 assert!(!should_show_shell_in_simple_list(&row));
478 }
479
480 #[test]
481 fn simple_list_collapses_installed_app_files_to_their_category() {
482 let rows = vec![
483 app_row("surge", FileStatus::UpToDate),
484 app_row("surge", FileStatus::Missing),
485 app_row("ghostty", FileStatus::NotInstalled),
486 ];
487
488 assert_eq!(installed_app_categories(&rows), vec!["surge"]);
489 }
490
491 #[test]
492 fn simple_list_shows_partially_installed_app_categories() {
493 let rows = vec![
494 app_row("surge", FileStatus::NotInstalled),
495 app_row("surge", FileStatus::UserModified),
496 ];
497
498 assert_eq!(installed_app_categories(&rows), vec!["surge"]);
499 }
500
501 #[test]
502 fn simple_list_sorts_names_case_insensitively() {
503 assert_eq!(
504 sorted_names(vec![
505 "surge".to_string(),
506 "JetBrains".to_string(),
507 "ghostty".to_string(),
508 ]),
509 vec!["ghostty", "JetBrains", "surge"]
510 );
511 }
512
513 #[test]
514 fn app_status_summary_parts_includes_only_nonzero_counts() {
515 assert_eq!(
516 app_status_summary_parts(3, 1, 0, 0),
517 vec!["3 up-to-date".to_string(), "1 update available".to_string()]
518 );
519 }
520
521 #[test]
522 fn app_status_summary_parts_empty_when_all_zero() {
523 assert!(app_status_summary_parts(0, 0, 0, 0).is_empty());
524 }
525
526 #[test]
527 fn app_status_summary_parts_reports_all_four_counters() {
528 assert_eq!(
529 app_status_summary_parts(1, 2, 3, 4),
530 vec![
531 "1 up-to-date".to_string(),
532 "2 update available".to_string(),
533 "3 user-modified".to_string(),
534 "4 destination missing".to_string(),
535 ]
536 );
537 }
538}