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 && file.status == "update available"
208                    })
209                    .collect::<Vec<_>>();
210                files.sort_by_key(|file| file.file.command_name.clone());
211                for file in files {
212                    print_update_separator(printed);
213                    print_shell_update_row(file);
214                    render::print_shell_update_diff(config, file).await?;
215                    printed = true;
216                }
217            }
218            InfoRef::ShellFile { category, command } => {
219                if let Some(file) = diffs.shell_files.iter().find(|file| {
220                    file.category.name == category
221                        && file.file.command_name == command
222                        && file.status == "update available"
223                }) {
224                    print_shell_update_row(file);
225                    render::print_shell_update_diff(config, file).await?;
226                    printed = true;
227                }
228            }
229        }
230    }
231
232    let generator_failed =
233        print_generator_notice(&diffs.app_files, &generator_categories, run_generators);
234    let generator_incomplete = diffs.app_files.iter().any(|file| {
235        generator_categories.contains(&file.category.name)
236            && file.assessment_diagnostic == Some("app_generator_not_evaluated")
237    });
238
239    if !printed && !generator_incomplete {
240        println!(
241            "{}",
242            colors::dim(&format!("No update available for {target}."))
243        );
244    }
245
246    if generator_failed {
247        bail!("one or more App generators could not be evaluated");
248    }
249    Ok(())
250}
251
252fn print_update_separator(printed: bool) {
253    if printed {
254        println!();
255    }
256}
257
258fn print_shell_update_row(file: &collect::ShellInfoFile) {
259    println!(
260        "  {}  {}/{}  {}",
261        colors::symbol("↑"),
262        file.category.name,
263        file.file.command_name,
264        colors::status_label("update available", "↑"),
265    );
266}
267
268fn print_app_update_row(config: &Config, file: &collect::AppInfoFile) {
269    println!(
270        "  {}  {}  {}  {}  {}",
271        colors::symbol("↑"),
272        app_file_label(file),
273        colors::dim("→"),
274        colors::dim(&path_display::format_home(
275            &file.destination,
276            &config.home_dir
277        )),
278        colors::status_label("update available", "↑"),
279    );
280}
281
282pub async fn handle_info(
283    config: &Config,
284    target: &str,
285    diff: bool,
286    verbose: bool,
287    run_generators: bool,
288) -> Result<()> {
289    let mut app_files = collect::collect_app_files(config, false, Vec::new()).await?;
290    let shell_files = collect::collect_shell_files(config).await?;
291
292    let candidates = resolve::build_candidates(&app_files, &shell_files);
293    let refs = match resolve::resolve_target(target, &candidates) {
294        Ok(refs) => refs,
295        Err(installed_error) => {
296            if verbose || (diff && !run_generators) {
297                return Err(installed_error.context(
298                    "--verbose requires an installed target; --diff for an available App also requires --run-generators",
299                ));
300            }
301            return handle_available_info(config, target, installed_error, run_generators, diff)
302                .await;
303        }
304    };
305
306    if run_generators {
307        let categories = app_categories_for_refs(&refs);
308        if categories.is_empty() {
309            bail!("--run-generators requires an App target");
310        }
311        app_files = collect::collect_app_files(config, true, categories).await?;
312    }
313    let generator_categories = app_categories_for_refs(&refs);
314
315    crate::config::print_presets_note(config);
316
317    let mut first = true;
318    for item in refs {
319        if !first {
320            println!();
321        }
322        first = false;
323        match item {
324            InfoRef::AppCategory(category) => {
325                let mut files: Vec<_> = app_files
326                    .iter()
327                    .filter(|f| f.category.name == category)
328                    .cloned()
329                    .collect();
330                files.sort_by_key(|f| f.file.source_rel.clone());
331                for (index, file) in files.iter().enumerate() {
332                    if index > 0 {
333                        println!();
334                    }
335                    render::print_app_file(config, file, diff, verbose).await?;
336                }
337            }
338            InfoRef::AppFile { category, source } => {
339                let file = app_files
340                    .iter()
341                    .find(|f| f.category.name == category && f.file.source_rel == source)
342                    .ok_or_else(|| anyhow::anyhow!("installed app config not found"))?;
343                render::print_app_file(config, file, diff, verbose).await?;
344            }
345            InfoRef::ShellCategory(category) => {
346                let mut files: Vec<_> = shell_files
347                    .iter()
348                    .filter(|f| f.category.name == category)
349                    .cloned()
350                    .collect();
351                files.sort_by_key(|f| f.file.command_name.clone());
352                for (index, file) in files.iter().enumerate() {
353                    if index > 0 {
354                        println!();
355                    }
356                    render::print_shell_file(config, file, diff, verbose).await?;
357                }
358            }
359            InfoRef::ShellFile { category, command } => {
360                let file = shell_files
361                    .iter()
362                    .find(|f| f.category.name == category && f.file.command_name == command)
363                    .ok_or_else(|| anyhow::anyhow!("installed shell preset not found"))?;
364                render::print_shell_file(config, file, diff, verbose).await?;
365            }
366        }
367    }
368
369    let generator_failed =
370        print_generator_notice(&app_files, &generator_categories, run_generators);
371    if generator_failed {
372        bail!("one or more App generators could not be evaluated");
373    }
374
375    Ok(())
376}
377
378async fn handle_available_info(
379    config: &Config,
380    target: &str,
381    installed_error: anyhow::Error,
382    run_generators: bool,
383    diff: bool,
384) -> Result<()> {
385    let target = target.trim();
386    if let Some(rest) = target.strip_prefix("app/") {
387        let category = rest.split('/').next().unwrap_or_default();
388        if category.is_empty() || rest.contains('/') {
389            return Err(installed_error);
390        }
391        return Box::pin(apps::handle_info(config, category, run_generators, diff)).await;
392    }
393    if let Some(rest) = target.strip_prefix("shell/") {
394        if rest.is_empty() {
395            return Err(installed_error);
396        }
397        if run_generators {
398            bail!("--run-generators requires an App target");
399        }
400        return Box::pin(shells::handle_info(config, rest)).await;
401    }
402
403    let app_matches = apps::load_active_categories(config, Some(target))
404        .await?
405        .into_iter()
406        .any(|category| category.name == target);
407    let shell_categories = shells::metadata::load_active_categories(config, None).await?;
408    let shell_matches = shell_categories.iter().any(|category| {
409        category.name == target
410            || category
411                .files
412                .iter()
413                .any(|file| file.command_name == target)
414    });
415
416    match (app_matches, shell_matches) {
417        (true, false) => Box::pin(apps::handle_info(config, target, run_generators, diff)).await,
418        (false, true) if run_generators => bail!("--run-generators requires an App target"),
419        (false, true) => Box::pin(shells::handle_info(config, target)).await,
420        (true, true) => {
421            bail!("ambiguous available target `{target}`; use `app/{target}` or `shell/{target}`")
422        }
423        (false, false) => Err(installed_error),
424    }
425}