1mod collect;
2mod render;
3mod resolve;
4
5use crate::config::Config;
6use crate::status::FileStatus;
7use crate::{apps, colors, path_display, shells};
8use anyhow::{Context, Result, bail};
9use resolve::InfoRef;
10
11pub(crate) async fn print_app_inspection_diff(
12 config: &Config,
13 inspection: &shine_core::runtime::AppFileInspection,
14) -> Result<()> {
15 let item = collect::app_info_file_from_inspection(inspection.clone())
16 .context("App inspection destination is unavailable")?;
17 render::print_app_expected_diff(config, &item).await
18}
19
20pub(crate) struct UpdateDiffs {
21 app_files: Vec<collect::AppInfoFile>,
22 shell_files: Vec<collect::ShellInfoFile>,
23}
24
25impl UpdateDiffs {
26 pub(crate) async fn collect(config: &Config, run_generators: bool) -> Result<Self> {
27 Ok(Self {
28 app_files: collect::collect_app_files(config, run_generators, Vec::new()).await?,
29 shell_files: collect::collect_shell_files(config).await?,
30 })
31 }
32
33 pub(crate) async fn collect_with_app_inspections(
34 config: &Config,
35 inspections: Vec<shine_core::runtime::AppFileInspection>,
36 ) -> Result<Self> {
37 Ok(Self {
38 app_files: collect::app_info_files_from_inspections(inspections),
39 shell_files: collect::collect_shell_files(config).await?,
40 })
41 }
42
43 pub(crate) async fn print_shell_for_row(&self, config: &Config, label: &str) -> Result<()> {
44 for file in self.shell_files.iter().filter(|file| {
45 file.status == "update available"
46 && format!("{}/{}", file.category.name, file.file.command_name) == label
47 }) {
48 render::print_shell_update_diff(config, file).await?;
49 }
50 Ok(())
51 }
52
53 pub(crate) async fn print_app_for_row(&self, config: &Config, label: &str) -> Result<()> {
54 for file in self.app_files.iter().filter(|file| {
55 if file.status != FileStatus::UpdateAvail {
56 return false;
57 }
58 if file.category.has_explicit_files
59 && file.category.list_mode == crate::apps::AppListMode::Files
60 {
61 app_file_label(file) == label
62 } else {
63 file.category.name == label
64 }
65 }) {
66 render::print_app_update_diff(config, file).await?;
67 }
68 Ok(())
69 }
70}
71
72fn app_file_label(file: &collect::AppInfoFile) -> String {
73 file.file
74 .display_name
75 .clone()
76 .unwrap_or_else(|| format!("{}/{}", file.category.name, file.file.source_rel.display()))
77}
78
79fn app_categories_for_refs(refs: &[InfoRef]) -> Vec<String> {
80 refs.iter()
81 .filter_map(|item| match item {
82 InfoRef::AppCategory(category) | InfoRef::AppFile { category, .. } => {
83 Some(category.clone())
84 }
85 InfoRef::ShellCategory(_) | InfoRef::ShellFile { .. } => None,
86 })
87 .collect::<std::collections::BTreeSet<_>>()
88 .into_iter()
89 .collect()
90}
91
92fn print_generator_notice(
93 files: &[collect::AppInfoFile],
94 categories: &[String],
95 run_generators: bool,
96) -> bool {
97 let relevant = files
98 .iter()
99 .filter(|file| categories.is_empty() || categories.contains(&file.category.name))
100 .collect::<Vec<_>>();
101 let not_evaluated = relevant
102 .iter()
103 .any(|file| file.assessment_diagnostic == Some("app_generator_not_evaluated"));
104 let failures = relevant
105 .iter()
106 .filter(|file| {
107 matches!(
108 file.assessment_diagnostic,
109 Some("app_generator_evaluation_failed" | "app_generator_trust_required")
110 )
111 })
112 .collect::<Vec<_>>();
113
114 if !run_generators && not_evaluated {
115 println!();
116 println!(
117 "{}",
118 colors::yellow("! Generated configuration was not evaluated.")
119 );
120 println!(
121 "{}",
122 colors::dim(
123 " Update status may be incomplete. Re-run with `--run-generators` to evaluate generator output."
124 )
125 );
126 }
127 for file in &failures {
128 println!();
129 let reason = if file.assessment_diagnostic == Some("app_generator_trust_required") {
130 "generator trust required"
131 } else {
132 "generator evaluation failed"
133 };
134 println!(
135 "{}",
136 colors::yellow(&format!(
137 "! {}/{}: {reason}",
138 file.category.name,
139 file.file.source_rel.display()
140 ))
141 );
142 if let Some(error) = &file.assessment_error {
143 println!("{}", colors::dim(&format!(" {error}")));
144 }
145 }
146 !failures.is_empty()
147}
148
149pub async fn handle_update_target(
150 config: &Config,
151 target: &str,
152 run_generators: bool,
153) -> Result<()> {
154 crate::config::print_presets_note(config);
155 let mut diffs = UpdateDiffs::collect(config, false).await?;
156
157 if diffs.app_files.is_empty() && diffs.shell_files.is_empty() {
158 bail!("nothing installed yet. Run `shine shell install` or `shine app install`.");
159 }
160
161 let candidates = resolve::build_candidates(&diffs.app_files, &diffs.shell_files);
162 let refs = resolve::resolve_target(target, &candidates)?;
163 if run_generators {
164 let categories = app_categories_for_refs(&refs);
165 if categories.is_empty() {
166 bail!("--run-generators requires an App target");
167 }
168 diffs.app_files = collect::collect_app_files(config, true, categories).await?;
169 }
170 let generator_categories = app_categories_for_refs(&refs);
171 let mut printed = false;
172
173 for item in refs {
174 match item {
175 InfoRef::AppCategory(category) => {
176 let mut files = diffs
177 .app_files
178 .iter()
179 .filter(|file| {
180 file.category.name == category && file.status == FileStatus::UpdateAvail
181 })
182 .collect::<Vec<_>>();
183 files.sort_by_key(|file| file.file.source_rel.clone());
184 for file in files {
185 print_update_separator(printed);
186 print_app_update_row(config, file);
187 render::print_app_update_diff(config, file).await?;
188 printed = true;
189 }
190 }
191 InfoRef::AppFile { category, source } => {
192 if let Some(file) = diffs.app_files.iter().find(|file| {
193 file.category.name == category
194 && file.file.source_rel == source
195 && file.status == FileStatus::UpdateAvail
196 }) {
197 print_app_update_row(config, file);
198 render::print_app_update_diff(config, file).await?;
199 printed = true;
200 }
201 }
202 InfoRef::ShellCategory(category) => {
203 let mut files = diffs
204 .shell_files
205 .iter()
206 .filter(|file| {
207 file.category.name == category
208 && (file.status == "update available" || file.attention_required)
209 })
210 .collect::<Vec<_>>();
211 files.sort_by_key(|file| file.file.command_name.clone());
212 for file in files {
213 print_update_separator(printed);
214 print_shell_update_row(file);
215 if !file.attention_required {
216 render::print_shell_update_diff(config, file).await?;
217 }
218 printed = true;
219 }
220 }
221 InfoRef::ShellFile { category, command } => {
222 if let Some(file) = diffs.shell_files.iter().find(|file| {
223 file.category.name == category
224 && file.file.command_name == command
225 && (file.status == "update available" || file.attention_required)
226 }) {
227 print_shell_update_row(file);
228 if !file.attention_required {
229 render::print_shell_update_diff(config, file).await?;
230 }
231 printed = true;
232 }
233 }
234 }
235 }
236
237 let generator_failed =
238 print_generator_notice(&diffs.app_files, &generator_categories, run_generators);
239 let generator_incomplete = diffs.app_files.iter().any(|file| {
240 generator_categories.contains(&file.category.name)
241 && file.assessment_diagnostic == Some("app_generator_not_evaluated")
242 });
243
244 if !printed && !generator_incomplete {
245 println!(
246 "{}",
247 colors::dim(&format!("No update available for {target}."))
248 );
249 }
250
251 if generator_failed {
252 bail!("one or more App generators could not be evaluated");
253 }
254 Ok(())
255}
256
257fn print_update_separator(printed: bool) {
258 if printed {
259 println!();
260 }
261}
262
263fn print_shell_update_row(file: &collect::ShellInfoFile) {
264 let symbol = if file.attention_required { "!" } else { "↑" };
265 println!(
266 " {} {}/{} {}",
267 colors::symbol(symbol),
268 file.category.name,
269 file.file.command_name,
270 colors::status_label(file.status, symbol)
271 );
272 if file.attention_required {
273 println!(
274 " Installed files are preserved. Restore the Preset or inspect the launcher before retrying; use `shine shell uninstall {}/{}` for explicit removal.",
275 file.category.name, file.file.command_name
276 );
277 }
278}
279
280fn print_app_update_row(config: &Config, file: &collect::AppInfoFile) {
281 println!(
282 " {} {} {} {} {}",
283 colors::symbol("↑"),
284 app_file_label(file),
285 colors::dim("→"),
286 colors::dim(&path_display::format_home(
287 &file.destination,
288 &config.home_dir
289 )),
290 colors::status_label("update available", "↑"),
291 );
292}
293
294pub async fn handle_info(
295 config: &Config,
296 target: &str,
297 diff: bool,
298 verbose: bool,
299 run_generators: bool,
300) -> Result<()> {
301 let mut app_files = collect::collect_app_files(config, false, Vec::new()).await?;
302 let shell_files = collect::collect_shell_files(config).await?;
303
304 let candidates = resolve::build_candidates(&app_files, &shell_files);
305 let refs = match resolve::resolve_target(target, &candidates) {
306 Ok(refs) => refs,
307 Err(installed_error) => {
308 if verbose || (diff && !run_generators) {
309 return Err(installed_error.context(
310 "--verbose requires an installed target; --diff for an available App also requires --run-generators",
311 ));
312 }
313 return handle_available_info(config, target, installed_error, run_generators, diff)
314 .await;
315 }
316 };
317
318 if run_generators {
319 let categories = app_categories_for_refs(&refs);
320 if categories.is_empty() {
321 bail!("--run-generators requires an App target");
322 }
323 app_files = collect::collect_app_files(config, true, categories).await?;
324 }
325 let generator_categories = app_categories_for_refs(&refs);
326
327 crate::config::print_presets_note(config);
328
329 let mut first = true;
330 for item in refs {
331 if !first {
332 println!();
333 }
334 first = false;
335 match item {
336 InfoRef::AppCategory(category) => {
337 let mut files: Vec<_> = app_files
338 .iter()
339 .filter(|f| f.category.name == category)
340 .cloned()
341 .collect();
342 files.sort_by_key(|f| f.file.source_rel.clone());
343 for (index, file) in files.iter().enumerate() {
344 if index > 0 {
345 println!();
346 }
347 render::print_app_file(config, file, diff, verbose).await?;
348 }
349 }
350 InfoRef::AppFile { category, source } => {
351 let file = app_files
352 .iter()
353 .find(|f| f.category.name == category && f.file.source_rel == source)
354 .ok_or_else(|| anyhow::anyhow!("installed app config not found"))?;
355 render::print_app_file(config, file, diff, verbose).await?;
356 }
357 InfoRef::ShellCategory(category) => {
358 let mut files: Vec<_> = shell_files
359 .iter()
360 .filter(|f| f.category.name == category)
361 .cloned()
362 .collect();
363 files.sort_by_key(|f| f.file.command_name.clone());
364 for (index, file) in files.iter().enumerate() {
365 if index > 0 {
366 println!();
367 }
368 render::print_shell_file(config, file, diff, verbose).await?;
369 }
370 }
371 InfoRef::ShellFile { category, command } => {
372 let file = shell_files
373 .iter()
374 .find(|f| f.category.name == category && f.file.command_name == command)
375 .ok_or_else(|| anyhow::anyhow!("installed shell preset not found"))?;
376 render::print_shell_file(config, file, diff, verbose).await?;
377 }
378 }
379 }
380
381 let generator_failed =
382 print_generator_notice(&app_files, &generator_categories, run_generators);
383 if generator_failed {
384 bail!("one or more App generators could not be evaluated");
385 }
386
387 Ok(())
388}
389
390async fn handle_available_info(
391 config: &Config,
392 target: &str,
393 installed_error: anyhow::Error,
394 run_generators: bool,
395 diff: bool,
396) -> Result<()> {
397 let target = target.trim();
398 if let Some(rest) = target.strip_prefix("app/") {
399 let category = rest.split('/').next().unwrap_or_default();
400 if category.is_empty() || rest.contains('/') {
401 return Err(installed_error);
402 }
403 return Box::pin(apps::handle_info(config, category, run_generators, diff)).await;
404 }
405 if let Some(rest) = target.strip_prefix("shell/") {
406 if rest.is_empty() {
407 return Err(installed_error);
408 }
409 if run_generators {
410 bail!("--run-generators requires an App target");
411 }
412 return Box::pin(shells::handle_info(config, rest)).await;
413 }
414
415 let app_matches = apps::load_active_categories(config, Some(target))
416 .await?
417 .into_iter()
418 .any(|category| category.name == target);
419 let shell_categories = shells::metadata::load_active_categories(config, None).await?;
420 let shell_matches = shell_categories.iter().any(|category| {
421 category.name == target
422 || category
423 .files
424 .iter()
425 .any(|file| file.command_name == target)
426 });
427
428 match (app_matches, shell_matches) {
429 (true, false) => Box::pin(apps::handle_info(config, target, run_generators, diff)).await,
430 (false, true) if run_generators => bail!("--run-generators requires an App target"),
431 (false, true) => Box::pin(shells::handle_info(config, target)).await,
432 (true, true) => {
433 bail!("ambiguous available target `{target}`; use `app/{target}` or `shell/{target}`")
434 }
435 (false, false) => Err(installed_error),
436 }
437}