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