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 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 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}