Skip to main content

cli/shells/
report.rs

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