Skip to main content

cli/shells/
report.rs

1use super::metadata;
2use crate::colors;
3
4#[derive(Debug, Default)]
5pub struct ShellUpgradeReport {
6    /// User-facing shell targets that were brought up to date. A target is
7    /// counted once even when upgrading it also rewrites a preset source,
8    /// rendered template, and bin launcher.
9    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}