Skip to main content

cli/apps/
info.rs

1use super::metadata;
2use crate::colors;
3use crate::config::Config;
4use crate::path_display;
5use anyhow::Result;
6
7use super::AppListMode;
8
9pub async fn handle_info(
10    config: &Config,
11    category: &str,
12    run_generators: bool,
13    diff: bool,
14) -> Result<()> {
15    crate::config::print_presets_note(config);
16    let mut observer = shine_core::runtime::NullObserver;
17    let inspections = crate::core_runtime::frontend_from_config(config)
18        .await?
19        .inspect_apps_with_options(
20            shine_core::runtime::AppInspectionOptions {
21                run_generators,
22                categories: vec![category.to_string()],
23            },
24            &mut observer,
25        )
26        .await
27        .map_err(shine_core::frontend::FrontendServiceError::into_source)?
28        .files;
29    let selected = inspections
30        .iter()
31        .filter(|inspection| inspection.category.name == category)
32        .collect::<Vec<_>>();
33    let cat = selected
34        .first()
35        .map(|inspection| &inspection.category)
36        .ok_or_else(|| anyhow::anyhow!("app preset category not found: {category}"))?;
37
38    // Header
39    if let Some(desc) = &cat.description {
40        println!("{}  {}", colors::bold(&cat.name), colors::dim(desc));
41    } else {
42        println!("{}", colors::bold(&cat.name));
43    }
44    println!();
45
46    if let Some(dest_root) = &cat.destination_root {
47        println!(
48            "  {}  {}",
49            colors::dim("Destination"),
50            path_display::format_tilde_path(dest_root, &config.home_dir)
51        );
52    }
53    println!("  {}  {}", colors::dim("Files      "), cat.files.len());
54    println!();
55
56    let col_width = cat
57        .files
58        .iter()
59        .map(|f| f.source_rel.display().to_string().len())
60        .max()
61        .unwrap_or(0);
62
63    let mut any_installed = false;
64
65    for inspection in &selected {
66        let file = &inspection.file;
67        let source_name = file.source_rel.display().to_string();
68        let padding = " ".repeat(col_width.saturating_sub(source_name.len()));
69        let dest_str = match &inspection.destination {
70            Some(dest) => {
71                let directly_installed = inspection
72                    .manifest_entry
73                    .as_ref()
74                    .is_some_and(|entry| entry.destination == *dest);
75                let status = if directly_installed {
76                    any_installed = true;
77                    match inspection.status {
78                        shine_core::runtime::InspectionFileStatus::Partial => {
79                            format!("  {}", colors::yellow("installed, missing managed keys"))
80                        }
81                        shine_core::runtime::InspectionFileStatus::UserModified => {
82                            format!("  {}", colors::yellow("installed, user-modified"))
83                        }
84                        shine_core::runtime::InspectionFileStatus::Missing => {
85                            format!("  {}", colors::yellow("installed, missing on disk"))
86                        }
87                        shine_core::runtime::InspectionFileStatus::UpdateAvail => {
88                            format!("  {}", colors::yellow("installed, update available"))
89                        }
90                        shine_core::runtime::InspectionFileStatus::GeneratorNotEvaluated => {
91                            format!("  {}", colors::yellow("generator not evaluated"))
92                        }
93                        shine_core::runtime::InspectionFileStatus::GeneratorEvaluationFailed => {
94                            format!("  {}", colors::yellow("generator evaluation failed"))
95                        }
96                        shine_core::runtime::InspectionFileStatus::GeneratorTrustRequired => {
97                            format!("  {}", colors::yellow("generator trust required"))
98                        }
99                        _ => format!("  {}", colors::green("installed, up to date")),
100                    }
101                } else {
102                    String::new()
103                };
104                format!(
105                    "{}  {}{}",
106                    colors::dim("→"),
107                    colors::dim(&path_display::format_home(dest, &config.home_dir)),
108                    status
109                )
110            }
111            None => colors::dim("(destination unresolvable)"),
112        };
113
114        let file_desc = file
115            .description
116            .as_deref()
117            .map(|d| format!("  {}", colors::dim(d)))
118            .unwrap_or_default();
119
120        println!("  {source_name}{padding}  {dest_str}{file_desc}");
121        if diff {
122            crate::info::print_app_inspection_diff(config, inspection).await?;
123        }
124    }
125
126    if !run_generators && cat.files.iter().any(|file| file.generator.is_some()) {
127        println!();
128        println!(
129            "{}",
130            colors::yellow("! Generated configuration was not evaluated.")
131        );
132        println!(
133            "{}",
134            colors::dim(
135                "  Status may be incomplete. Re-run with `--run-generators` to evaluate generator output."
136            )
137        );
138    }
139    let generator_failures = selected
140        .iter()
141        .filter(|inspection| {
142            matches!(
143                inspection.assessment_diagnostic,
144                Some("app_generator_evaluation_failed" | "app_generator_trust_required")
145            )
146        })
147        .collect::<Vec<_>>();
148    for inspection in &generator_failures {
149        println!();
150        let reason = if inspection.assessment_diagnostic == Some("app_generator_trust_required") {
151            "generator trust required"
152        } else {
153            "generator evaluation failed"
154        };
155        println!(
156            "{}",
157            colors::yellow(&format!(
158                "! {}: {reason}",
159                inspection.file.source_rel.display()
160            ))
161        );
162        if let Some(error) = &inspection.assessment_error {
163            println!("{}", colors::dim(&format!("  {error}")));
164        }
165    }
166
167    println!();
168    if any_installed {
169        println!(
170            "{}",
171            colors::dim(&format!(
172                "Installed. Run `shine install app/{category} --replace-managed` to repair managed files."
173            ))
174        );
175    } else {
176        println!(
177            "{}",
178            colors::dim(&format!(
179                "Not installed. Run `shine install app/{category}` to install."
180            ))
181        );
182    }
183
184    if !generator_failures.is_empty() {
185        anyhow::bail!("one or more App generators could not be evaluated");
186    }
187    Ok(())
188}
189
190pub async fn handle_list(config: &Config) -> Result<()> {
191    handle_list_with_presets_note(config, true).await
192}
193
194#[doc(hidden)]
195pub async fn handle_list_with_presets_note(
196    config: &Config,
197    print_presets_note: bool,
198) -> Result<()> {
199    if print_presets_note {
200        crate::config::print_presets_note(config);
201    }
202    let categories = metadata::load_active_categories(config, None).await?;
203
204    if categories.is_empty() {
205        println!("{}", colors::dim("No app preset categories found."));
206        return Ok(());
207    }
208
209    println!("{}\n", colors::bold("App Preset Categories"));
210
211    let name_width = categories.iter().map(|c| c.name.len()).max().unwrap_or(0);
212
213    for cat in &categories {
214        let effective_desc = cat.description.as_deref().or_else(|| {
215            if cat.files.len() == 1 {
216                cat.files[0].description.as_deref()
217            } else {
218                None
219            }
220        });
221
222        let name_pad = " ".repeat(name_width.saturating_sub(cat.name.len()));
223        let file_count = if cat.files.len() > 1 {
224            format!("  {}", colors::dim(&format!("{} files", cat.files.len())))
225        } else {
226            String::new()
227        };
228
229        let desc_part = effective_desc.map(|d| format!("  {d}")).unwrap_or_default();
230
231        println!("  {}{}{}{}", cat.name, name_pad, desc_part, file_count);
232
233        // Per-file rows for explicit multi-file categories
234        if cat.has_explicit_files && cat.list_mode == AppListMode::Files && cat.files.len() > 1 {
235            for file in &cat.files {
236                let name = file.source_rel.display().to_string();
237                if let Some(desc) = &file.description {
238                    println!("    {}  {}", colors::dim(&name), colors::dim(desc));
239                } else {
240                    println!("    {}", colors::dim(&name));
241                }
242            }
243        }
244    }
245
246    println!();
247    println!(
248        "{}",
249        colors::dim("Run `shine install app/<CATEGORY>` to install a specific category.")
250    );
251    println!("{}", colors::dim("Run `shine app install` to install all."));
252
253    Ok(())
254}
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259
260    #[test]
261    fn list_uses_embedded_metadata_for_vim() {
262        let categories = metadata::load_embedded_categories(Some("vim")).unwrap();
263        let vim = categories.iter().find(|c| c.name == "vim").unwrap();
264        assert!(vim.uses_metadata);
265        assert_eq!(vim.destination_root.as_deref(), Some("~/.vim"));
266    }
267}