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