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