Skip to main content

cli/shells/
report.rs

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}