1use super::metadata;
2use crate::colors;
3
4pub(super) fn style_dim(value: &str) -> String {
5 colors::dim(value)
6}
7
8pub(super) fn style_green(value: &str) -> String {
9 colors::green(value)
10}
11
12pub(super) fn style_yellow(value: &str) -> String {
13 colors::yellow(value)
14}
15
16pub(super) fn style_bold(value: &str) -> String {
17 colors::bold(value)
18}
19
20pub(super) fn style_symbol(value: &str) -> String {
21 colors::symbol(value)
22}
23
24#[derive(Debug, Default)]
25pub struct ShellUpgradeReport {
26 pub updated_targets: Vec<String>,
30 pub updated_categories: Vec<String>,
33 pub snapshots_updated: usize,
34 pub templates_updated: usize,
35 pub links_created: usize,
36 pub links_updated: usize,
37 pub link_conflicts: usize,
38 pub path_changed: bool,
39}
40
41pub(super) fn shell_cache_summary_parts(
42 report: &shine_core::runtime::ShellCacheReport,
43) -> Vec<String> {
44 cache_summary_parts(
45 report.created.len(),
46 report.overwritten.len(),
47 report.skipped.len(),
48 )
49}
50
51fn cache_summary_parts(created: usize, overwritten: usize, skipped: usize) -> Vec<String> {
52 let mut parts: Vec<String> = Vec::new();
53 if created > 0 {
54 parts.push(colors::green(&format_file_action(created, "created")));
55 }
56 if overwritten > 0 {
57 parts.push(colors::green(&format_file_action(overwritten, "updated")));
58 }
59 if skipped > 0 {
60 parts.push(colors::dim(&format_file_action(skipped, "skipped")));
61 }
62 parts
63}
64
65pub(super) fn unlink_report_summary_parts(
66 unlink_report: &crate::bin_links::UnlinkReport,
67) -> Vec<String> {
68 let mut parts: Vec<String> = Vec::new();
69 if !unlink_report.removed.is_empty() {
70 parts.push(colors::green(&format!(
71 "{} removed",
72 unlink_report.removed.len()
73 )));
74 }
75 if !unlink_report.skipped.is_empty() {
76 parts.push(colors::dim(&format!(
77 "{} skipped",
78 unlink_report.skipped.len()
79 )));
80 }
81 parts
82}
83
84pub(super) fn shell_cache_remove_summary_parts(
85 report: &shine_core::runtime::ShellCacheReport,
86) -> Vec<String> {
87 let mut parts = Vec::new();
88 if !report.removed.is_empty() {
89 parts.push(colors::green(&format_file_action(
90 report.removed.len(),
91 "removed",
92 )));
93 }
94 if !report.skipped.is_empty() {
95 parts.push(colors::dim(&format_file_action(
96 report.skipped.len(),
97 "skipped",
98 )));
99 }
100 parts
101}
102
103pub(super) fn link_report_summary_parts(link_report: &crate::bin_links::LinkReport) -> Vec<String> {
104 let mut parts: Vec<String> = Vec::new();
105 if !link_report.created.is_empty() {
106 parts.push(colors::green(&format!(
107 "{} created",
108 link_report.created.len()
109 )));
110 }
111 if !link_report.overwritten.is_empty() {
112 parts.push(colors::green(&format!(
113 "{} updated",
114 link_report.overwritten.len()
115 )));
116 }
117 if !link_report.skipped.is_empty() {
118 parts.push(colors::dim(&format!(
119 "{} up to date",
120 link_report.skipped.len()
121 )));
122 }
123 if !link_report.conflicts.is_empty() {
124 parts.push(colors::yellow(&format!(
125 "{} conflicts",
126 link_report.conflicts.len()
127 )));
128 }
129 if parts.is_empty() {
130 parts.push(colors::dim("0 linked"));
131 }
132 parts
133}
134
135#[cfg(test)]
136pub(super) fn upgrade_link_report_summary_parts(
137 link_report: &crate::bin_links::LinkReport,
138 verbose: bool,
139) -> Vec<String> {
140 let mut parts: Vec<String> = Vec::new();
141 if !link_report.created.is_empty() {
142 parts.push(colors::green(&format!(
143 "{} created",
144 link_report.created.len()
145 )));
146 }
147 if !link_report.overwritten.is_empty() {
148 parts.push(colors::green(&format!(
149 "{} updated",
150 link_report.overwritten.len()
151 )));
152 }
153 if verbose && !link_report.skipped.is_empty() {
154 parts.push(colors::dim(&format!(
155 "{} up to date",
156 link_report.skipped.len()
157 )));
158 }
159 if !link_report.conflicts.is_empty() {
160 parts.push(colors::yellow(&format!(
161 "{} conflicts",
162 link_report.conflicts.len()
163 )));
164 }
165 parts
166}
167
168fn format_file_action(count: usize, action: &str) -> String {
169 let noun = if count == 1 { "file" } else { "files" };
170 format!("{count} {noun} {action}")
171}
172
173pub async fn handle_list(config: &crate::config::Config) -> anyhow::Result<()> {
174 handle_list_with_presets_note(config, true).await
175}
176
177#[doc(hidden)]
178pub async fn handle_list_with_presets_note(
179 config: &crate::config::Config,
180 print_presets_note: bool,
181) -> anyhow::Result<()> {
182 if print_presets_note {
183 crate::config::print_presets_note(config);
184 }
185 let categories = if config.is_external_presets {
186 metadata::load_installed_categories(config, None).await?
187 } else {
188 metadata::load_embedded_categories(None)?
189 };
190
191 if categories.is_empty() {
192 println!("{}", colors::dim("No shell preset categories found."));
193 return Ok(());
194 }
195
196 println!("{}\n", colors::bold("Shell Preset Categories"));
197
198 let bun_available = crate::platform::command_exists_on_path("bun");
199
200 for cat in &categories {
201 let word = if cat.files.len() == 1 {
202 "script"
203 } else {
204 "scripts"
205 };
206 println!(
207 " {} {}",
208 cat.name,
209 colors::dim(&format!("{} {}", cat.files.len(), word))
210 );
211
212 let names: Vec<&str> = cat.files.iter().map(|s| s.command_name.as_str()).collect();
213 let max_name = names.iter().map(|s| s.len()).max().unwrap_or(0);
214 let gap = 4;
215 let desc_col = max_name + gap;
216 let continuation_indent = " ".repeat(4 + desc_col);
217
218 for (script, name) in cat.files.iter().zip(names.iter()) {
219 let padding = " ".repeat(desc_col - name.len());
220 match script.description.as_slice() {
221 [] => println!(" {name}"),
222 [first, rest @ ..] => {
223 println!(" {name}{padding}{first}");
224 for line in rest {
225 if line.is_empty() {
226 println!();
227 } else {
228 println!("{continuation_indent}{line}");
229 }
230 }
231 }
232 }
233 if script.runtime == crate::bin_links::LinkRuntime::Bun {
234 let status = if bun_available {
235 colors::green("available")
236 } else {
237 colors::yellow("not found on PATH")
238 };
239 println!(
240 "{continuation_indent}{} {status}",
241 colors::dim("runtime: bun ยท")
242 );
243 }
244 println!();
245 }
246 }
247
248 println!(
249 "{}",
250 colors::dim("Run `shine install shell/<CATEGORY>` to install a specific category.")
251 );
252 println!(
253 "{}",
254 colors::dim("Run `shine shell install` to install all.")
255 );
256 println!();
257 println!(
258 "{}",
259 colors::dim(
260 "After installation, commands are available directly by name (e.g. `setproxy`)."
261 )
262 );
263
264 Ok(())
265}
266
267pub async fn handle_info(config: &crate::config::Config, target: &str) -> anyhow::Result<()> {
268 use anyhow::bail;
269
270 crate::config::print_presets_note(config);
271 let categories = metadata::load_active_categories(config, None).await?;
272 let target = target.trim();
273 if target.is_empty() {
274 bail!("shell info target must not be empty");
275 }
276
277 let (category, files) = if let Some(category) = categories.iter().find(|cat| cat.name == target)
278 {
279 (category, category.files.iter().collect::<Vec<_>>())
280 } else if let Some((category_name, command_name)) = target.split_once('/') {
281 let Some(category) = categories.iter().find(|cat| cat.name == category_name) else {
282 bail!("shell preset category not found: {category_name}");
283 };
284 let Some(file) = category
285 .files
286 .iter()
287 .find(|file| file.command_name == command_name)
288 else {
289 bail!("shell preset command not found: {target}");
290 };
291 (category, vec![file])
292 } else {
293 let matches = categories
294 .iter()
295 .flat_map(|category| {
296 category
297 .files
298 .iter()
299 .filter(move |file| file.command_name == target)
300 .map(move |file| (category, file))
301 })
302 .collect::<Vec<_>>();
303 match matches.as_slice() {
304 [] => bail!(
305 "shell preset target not found: {target}\n\nRun `shine shell list` to see available presets."
306 ),
307 [(category, file)] => (*category, vec![*file]),
308 _ => {
309 let choices = matches
310 .iter()
311 .map(|(category, file)| format!("{}/{}", category.name, file.command_name))
312 .collect::<Vec<_>>()
313 .join(", ");
314 bail!("ambiguous shell preset target `{target}`; use one of: {choices}");
315 }
316 }
317 };
318
319 let rows = crate::status::build_shell_rows(config).await?;
320 let selected_command = (files.len() == 1).then(|| files[0].command_name.clone());
321 println!("{}", colors::bold(&category.name));
322 if let Some(description) = &category.description {
323 println!(" {}", colors::dim(description));
324 }
325
326 let bun_available = crate::platform::command_exists_on_path("bun");
327 let mut any_installed = false;
328 for file in files {
329 let label = format!("{}/{}", category.name, file.command_name);
330 let row = rows.iter().find(|row| row.label == label);
331 any_installed |= row.is_some_and(|row| row.status_text != "not installed");
332 println!();
333 println!(" {}", colors::bold(&file.command_name));
334 println!(
335 " {:<12} shell/{}/{}",
336 "Source",
337 category.name,
338 file.source_rel.display()
339 );
340 let runtime = match file.runtime {
341 crate::bin_links::LinkRuntime::Native => "native".to_string(),
342 crate::bin_links::LinkRuntime::Bun if bun_available => "bun (available)".to_string(),
343 crate::bin_links::LinkRuntime::Bun => "bun (not found on PATH)".to_string(),
344 };
345 println!(" {:<12} {runtime}", "Runtime");
346 println!(
347 " {:<12} {}",
348 "Transforms",
349 if file.transforms.is_empty() {
350 "none".to_string()
351 } else {
352 file.transforms.join(", ")
353 }
354 );
355 println!(
356 " {:<12} {}",
357 "Environment",
358 if file.env.is_empty() {
359 "none".to_string()
360 } else {
361 file.env
362 .iter()
363 .map(crate::env::EnvVarSpec::to_with_arg)
364 .collect::<Vec<_>>()
365 .join(", ")
366 }
367 );
368 println!(
369 " {:<12} {}",
370 "Status",
371 row.map_or("not installed", |row| row.status_text)
372 );
373 for (index, line) in file.description.iter().enumerate() {
374 println!(
375 " {:<12} {}",
376 if index == 0 { "Description" } else { "" },
377 line
378 );
379 }
380 }
381
382 println!();
383 if any_installed {
384 let repair_target = selected_command.as_ref().map_or_else(
385 || format!("shell/{}", category.name),
386 |command| format!("shell/{}/{command}", category.name),
387 );
388 println!(
389 "{}",
390 colors::dim(&format!(
391 "Run `shine install {repair_target} --replace-managed` to repair this target."
392 ))
393 );
394 } else {
395 let install_target = selected_command.as_ref().map_or_else(
396 || category.name.clone(),
397 |command| format!("{}/{command}", category.name),
398 );
399 println!(
400 "{}",
401 colors::dim(&format!(
402 "Run `shine shell install {install_target}` to install this target."
403 ))
404 );
405 }
406 Ok(())
407}
408
409#[cfg(test)]
410mod info_tests {
411 use super::*;
412
413 #[tokio::test]
414 async fn embedded_shell_info_accepts_category_command_and_canonical_target() {
415 let dir = crate::test_support::make_temp_dir("shine-shell-info").await;
416 let config = crate::test_support::test_config(&dir);
417
418 handle_info(&config, "proxy").await.unwrap();
419 handle_info(&config, "setproxy").await.unwrap();
420 handle_info(&config, "proxy/setproxy").await.unwrap();
421
422 tokio::fs::remove_dir_all(dir).await.unwrap();
423 }
424
425 #[tokio::test]
426 async fn shell_info_rejects_unknown_and_empty_targets() {
427 let dir = crate::test_support::make_temp_dir("shine-shell-info-errors").await;
428 let config = crate::test_support::test_config(&dir);
429
430 assert!(handle_info(&config, "").await.is_err());
431 assert!(handle_info(&config, "not-a-preset").await.is_err());
432
433 tokio::fs::remove_dir_all(dir).await.unwrap();
434 }
435}
436
437#[cfg(test)]
438mod lifecycle_render_tests {
439 use super::*;
440
441 #[test]
442 fn shell_lifecycle_summary_parts_keep_legacy_wording() {
443 let report = crate::bin_links::LinkReport {
444 created: vec!["created".into()],
445 overwritten: vec!["updated".into()],
446 skipped: vec!["current".into()],
447 conflicts: Vec::new(),
448 };
449 assert_eq!(
450 link_report_summary_parts(&report),
451 vec!["1 created", "1 updated", "1 up to date"]
452 );
453 assert_eq!(
454 upgrade_link_report_summary_parts(&report, false),
455 vec!["1 created", "1 updated"]
456 );
457 }
458}