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