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}