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    /// Category-level lifecycle identities corresponding to `updated_targets`.
11    /// Summary counts use these so discovery and upgrade reporting agree.
12    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}