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