Skip to main content

cli/info/
mod.rs

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}