Skip to main content

cli/apps/
refresh.rs

1//! Explicit refresh of manifest-owned generated app files.
2
3use anyhow::{Result, bail};
4use std::path::Path;
5
6use crate::colors;
7use crate::config::Config;
8use crate::presentation::TerminalInteraction;
9use shine_core::runtime::{
10    AppFileAction, AppRefreshPlanRequest, PlanningInputVersions, RuntimeEvent, RuntimeObserver,
11};
12
13use super::report::{print_install_error, print_install_success};
14
15pub async fn handle_refresh(
16    config: &Config,
17    category: &str,
18    file_selector: Option<&str>,
19    force: bool,
20) -> Result<()> {
21    handle_refresh_approved(config, category, file_selector, force, true).await
22}
23
24pub async fn handle_refresh_approved(
25    config: &Config,
26    category: &str,
27    file_selector: Option<&str>,
28    force: bool,
29    yes: bool,
30) -> Result<()> {
31    crate::config::print_presets_note(config);
32    let plan_request = AppRefreshPlanRequest {
33        category: category.to_string(),
34        file: file_selector.map(Path::new).map(Path::to_path_buf),
35        force,
36        input_versions: PlanningInputVersions::default(),
37    };
38    let reviewed = crate::lifecycle_plan::review_plans(
39        config,
40        [crate::lifecycle_plan::LifecyclePlanRequest::app_refresh(
41            plan_request.clone(),
42            config,
43        )],
44        yes,
45    )
46    .await?
47    .into_iter()
48    .next()
49    .expect("one reviewed App refresh Plan");
50    let runtime = crate::lifecycle_plan::prepare_runtime(config, &reviewed).await?;
51
52    println!(
53        "{}",
54        colors::bold(&format!("Refreshing app generators: {category}"))
55    );
56    let mut observer = RefreshObserver;
57    let mut interaction = TerminalInteraction;
58    let report = match crate::lifecycle_plan::execute_reviewed(
59        config,
60        runtime,
61        reviewed,
62        shine_core::frontend::ExecutionOptions::default(),
63        &mut observer,
64        &mut interaction,
65    )
66    .await?
67    {
68        shine_core::frontend::OperationDetails::AppRefresh(report) => *report,
69        _ => unreachable!("reviewed operation result type"),
70    };
71    let single_label = report
72        .files
73        .first()
74        .filter(|_| report.files.len() == 1)
75        .map(|file| format!("{category}/{}", file.source.display()));
76    let mut updated = 0;
77    let mut unchanged = 0;
78    let mut failed = 0;
79    for file in report.files {
80        let label = format!("{category}/{}", file.source.display());
81        match file.action {
82            AppFileAction::Installed | AppFileAction::BackedUp => {
83                print_install_success(&label, "", &file.destination, config);
84                updated += 1;
85            }
86            AppFileAction::Unchanged => {
87                println!(
88                    "  {} {label}  {}",
89                    colors::dim("-"),
90                    colors::dim("already up to date")
91                );
92                unchanged += 1;
93            }
94            AppFileAction::UserModified => {
95                eprintln!(
96                    "  {} {label}: user-modified, kept (use --force to overwrite)",
97                    colors::symbol("!")
98                );
99                failed += 1;
100            }
101            AppFileAction::Failed => {
102                print_install_error(&label, &anyhow::anyhow!(file.error.unwrap_or_default()));
103                failed += 1;
104            }
105            _ => unchanged += 1,
106        }
107    }
108
109    println!(
110        "{}",
111        refresh_summary_text(single_label.as_deref(), updated, unchanged, failed)
112    );
113    if failed > 0 {
114        bail!("{failed} generated app file(s) failed to refresh");
115    }
116    Ok(())
117}
118
119fn refresh_summary_text(
120    single_label: Option<&str>,
121    updated: usize,
122    unchanged: usize,
123    failed: usize,
124) -> String {
125    let total = updated + unchanged + failed;
126    if total == 1
127        && let Some(label) = single_label
128    {
129        if failed == 1 {
130            return format!(
131                "{} {}",
132                colors::symbol("!"),
133                colors::yellow(&format!("Refresh incomplete: {label} failed"))
134            );
135        }
136        if updated == 1 {
137            return format!(
138                "{} {}",
139                colors::symbol("✓"),
140                colors::green(&format!("Refresh complete: {label} updated"))
141            );
142        }
143        return format!(
144            "{} {}",
145            colors::symbol("✓"),
146            colors::green(&format!("Already up to date: {label}"))
147        );
148    }
149
150    let mut parts = Vec::new();
151    crate::output::push_count(&mut parts, updated, colors::green, "updated");
152    crate::output::push_count(&mut parts, unchanged, colors::dim, "unchanged");
153    crate::output::push_count(&mut parts, failed, colors::yellow, "failed");
154    let detail = if parts.is_empty() {
155        colors::dim("nothing changed")
156    } else {
157        parts.join(&colors::dim(", "))
158    };
159    let (symbol, conclusion) = if failed > 0 {
160        ("!", "Refresh incomplete")
161    } else {
162        ("✓", "Refresh complete")
163    };
164    format!("{} {conclusion}: {detail}", colors::symbol(symbol))
165}
166
167struct RefreshObserver;
168
169impl RuntimeObserver for RefreshObserver {
170    fn emit(&mut self, event: RuntimeEvent) {
171        match event {
172            RuntimeEvent::Warning { detail, .. } => eprintln!("  {} {detail}", colors::symbol("!")),
173            RuntimeEvent::ProcessOutput { text, .. } => {
174                for line in text.lines() {
175                    println!("     {}", colors::dim(line));
176                }
177            }
178            RuntimeEvent::Progress {
179                code: "app_hook_completed",
180                target,
181            } => {
182                println!(
183                    "  {} {}: post-upgrade hook completed",
184                    colors::symbol("✓"),
185                    target.trim_start_matches("app/")
186                );
187            }
188            _ => {}
189        }
190    }
191}
192
193#[cfg(all(test, unix))]
194mod tests {
195    use super::*;
196    use crate::apps::metadata;
197    use crate::apps::{handle_install, handle_upgrade_installed};
198    use crate::install_core::manifest::AppManifest;
199    use crate::status::{FileStatus, app_entry_status};
200    use std::os::unix::fs::PermissionsExt;
201    use tokio::fs;
202
203    async fn write_fixture(root: &Path, two_files: bool) -> Config {
204        let mut config = Config::new_for_test(root);
205        config.is_external_presets = true;
206        config
207            .env
208            .insert("SOURCE_URL".to_string(), "https://example.test".to_string());
209        let app_dir = config.presets_dir().join("app/sample");
210        fs::create_dir_all(&app_dir).await.unwrap();
211        let second = if two_files {
212            r#"
213
214[[files]]
215source = "second.txt"
216generator = { script = "second.sh", env = ["SOURCE_URL"], when_env = "SOURCE_URL", auto = false }
217"#
218        } else {
219            ""
220        };
221        fs::write(
222            app_dir.join("shine.toml"),
223            format!(
224                r#"description = "sample"
225dest = "{}"
226
227[permissions]
228schema_version = 1
229filesystem = [
230  {{ access = ["execute"], base = "preset", path = "first.sh" }},
231  {{ access = ["execute"], base = "preset", path = "second.sh" }},
232]
233environment = [{{ name = "SOURCE_URL", sensitivity = "secret" }}]
234
235[[files]]
236source = "first.txt"
237generator = {{ script = "first.sh", env = ["SOURCE_URL"], when_env = "SOURCE_URL", auto = false }}
238{second}"#,
239                root.join("dest").display()
240            ),
241        )
242        .await
243        .unwrap();
244        fs::write(app_dir.join("first.txt"), b"fallback-first\n")
245            .await
246            .unwrap();
247        fs::write(app_dir.join("first.payload"), b"first-v1\n")
248            .await
249            .unwrap();
250        write_generator(&app_dir.join("first.sh"), "first").await;
251        if two_files {
252            fs::write(app_dir.join("second.txt"), b"fallback-second\n")
253                .await
254                .unwrap();
255            fs::write(app_dir.join("second.payload"), b"second-v1\n")
256                .await
257                .unwrap();
258            write_generator(&app_dir.join("second.sh"), "second").await;
259        }
260        crate::trust::grant_current_for_test(&config, "app/sample").await;
261        config
262    }
263
264    async fn write_generator(path: &Path, stem: &str) {
265        fs::write(
266            path,
267            format!(
268                "#!/bin/sh\nprintf x >> '{counter}'\ncat '{payload}'\n",
269                counter = path
270                    .parent()
271                    .unwrap()
272                    .join(format!("{stem}.runs"))
273                    .display(),
274                payload = path
275                    .parent()
276                    .unwrap()
277                    .join(format!("{stem}.payload"))
278                    .display()
279            ),
280        )
281        .await
282        .unwrap();
283        fs::set_permissions(path, std::fs::Permissions::from_mode(0o700))
284            .await
285            .unwrap();
286    }
287
288    #[test]
289    fn refresh_summary_names_single_files_and_distinguishes_failures() {
290        assert_eq!(
291            refresh_summary_text(Some("sample/first.txt"), 1, 0, 0),
292            "✓ Refresh complete: sample/first.txt updated"
293        );
294        assert_eq!(
295            refresh_summary_text(Some("sample/first.txt"), 0, 1, 0),
296            "✓ Already up to date: sample/first.txt"
297        );
298        assert_eq!(
299            refresh_summary_text(Some("sample/first.txt"), 0, 0, 1),
300            "! Refresh incomplete: sample/first.txt failed"
301        );
302        assert_eq!(
303            refresh_summary_text(None, 2, 1, 0),
304            "✓ Refresh complete: 2 updated, 1 unchanged"
305        );
306        assert_eq!(
307            refresh_summary_text(None, 1, 0, 1),
308            "! Refresh incomplete: 1 updated, 1 failed"
309        );
310    }
311
312    #[tokio::test]
313    async fn manual_generator_skips_status_and_upgrade_but_refreshes_explicitly() {
314        let root = crate::test_support::make_temp_dir("shine-refresh").await;
315        let config = write_fixture(&root, false).await;
316        handle_install(&config, Some("sample"), false, false)
317            .await
318            .unwrap();
319
320        let app_dir = config.presets_dir().join("app/sample");
321        let dest = root.join("dest/first.txt");
322        assert_eq!(
323            fs::read_to_string(app_dir.join("first.runs"))
324                .await
325                .unwrap(),
326            "x"
327        );
328        fs::write(app_dir.join("first.payload"), b"first-v2\n")
329            .await
330            .unwrap();
331
332        let categories = metadata::load_active_categories(&config, Some("sample"))
333            .await
334            .unwrap();
335        let cat = &categories[0];
336        let file = &cat.files[0];
337        let manifest = AppManifest::load(&shine_core::runtime::RealHost, config.shine_dir())
338            .await
339            .unwrap();
340        let entry = manifest.find_by_dest(&dest).unwrap();
341        assert_eq!(
342            app_entry_status(&config, cat, file, entry, &config.env).await,
343            FileStatus::UpToDate
344        );
345        let mut separator = crate::output::SectionSeparator::new();
346        let report = handle_upgrade_installed(&config, false, &mut separator)
347            .await
348            .unwrap();
349        assert_eq!(report.updated, 0);
350        assert_eq!(
351            fs::read_to_string(app_dir.join("first.runs"))
352                .await
353                .unwrap(),
354            "x"
355        );
356        assert_eq!(fs::read(&dest).await.unwrap(), b"first-v1\n");
357
358        crate::trust::grant_current_for_test(&config, "app/sample").await;
359        handle_refresh(&config, "sample", Some("first.txt"), false)
360            .await
361            .unwrap();
362        assert_eq!(fs::read(&dest).await.unwrap(), b"first-v2\n");
363        assert_eq!(
364            fs::read_to_string(app_dir.join("first.runs"))
365                .await
366                .unwrap(),
367            "xx"
368        );
369        fs::remove_dir_all(root).await.unwrap();
370    }
371
372    #[tokio::test]
373    async fn automatic_generator_status_reports_refresh_without_execution() {
374        let root = crate::test_support::make_temp_dir("shine-refresh-status").await;
375        let config = write_fixture(&root, false).await;
376        let metadata_path = config.presets_dir().join("app/sample/shine.toml");
377        let metadata = fs::read_to_string(&metadata_path)
378            .await
379            .unwrap()
380            .replace("auto = false", "auto = true");
381        fs::write(&metadata_path, metadata).await.unwrap();
382        crate::trust::grant_current_for_test(&config, "app/sample").await;
383        handle_install(&config, Some("sample"), false, false)
384            .await
385            .unwrap();
386
387        let categories = metadata::load_active_categories(&config, Some("sample"))
388            .await
389            .unwrap();
390        let rows = crate::status::build_app_rows(&config, &categories)
391            .await
392            .unwrap();
393        assert_eq!(rows[0].file_status, FileStatus::GeneratorNotEvaluated);
394        assert_eq!(
395            fs::read_to_string(config.presets_dir().join("app/sample/first.runs"))
396                .await
397                .unwrap(),
398            "x",
399            "read-only status must not execute an automatic generator"
400        );
401        fs::remove_dir_all(root).await.unwrap();
402    }
403
404    #[tokio::test]
405    async fn explicit_generator_evaluation_materializes_desired_content_before_install() {
406        let root = crate::test_support::make_temp_dir("shine-generator-preview").await;
407        let config = write_fixture(&root, false).await;
408        let mut runtime = crate::core_runtime::from_config(&config).await.unwrap();
409        runtime.context_mut_for_cli().env = config.env.clone();
410        let inspections = runtime
411            .inspect_apps_with_options(
412                shine_core::runtime::AppInspectionOptions {
413                    run_generators: true,
414                    categories: vec!["sample".to_string()],
415                },
416                &mut shine_core::runtime::NullObserver,
417            )
418            .await
419            .unwrap();
420        assert_eq!(
421            inspections[0].desired_content.as_deref(),
422            Some(b"first-v1\n".as_slice())
423        );
424        assert!(!root.join("dest/first.txt").exists());
425        assert_eq!(
426            fs::read_to_string(config.presets_dir().join("app/sample/first.runs"))
427                .await
428                .unwrap(),
429            "x"
430        );
431        fs::remove_dir_all(root).await.unwrap();
432    }
433
434    #[tokio::test]
435    async fn explicit_generator_evaluation_updates_status_without_writing_destination() {
436        let root = crate::test_support::make_temp_dir("shine-generator-evaluation").await;
437        let config = write_fixture(&root, false).await;
438        crate::trust::grant_current_for_test(&config, "app/sample").await;
439        handle_install(&config, Some("sample"), false, false)
440            .await
441            .unwrap();
442        let app_dir = config.presets_dir().join("app/sample");
443        let destination = root.join("dest/first.txt");
444        fs::write(app_dir.join("first.payload"), b"first-v2\n")
445            .await
446            .unwrap();
447        crate::trust::grant_current_for_test(&config, "app/sample").await;
448
449        let categories = metadata::load_active_categories(&config, Some("sample"))
450            .await
451            .unwrap();
452        let (rows, lifecycle, _) =
453            crate::status::build_app_rows_with_lifecycle_options(&config, &categories, true)
454                .await
455                .unwrap();
456        assert_eq!(rows[0].file_status, FileStatus::UpdateAvail);
457        assert!(!rows[0].upgrade_available);
458        assert_eq!(rows[0].refresh_sources, ["first.txt"]);
459        assert_eq!(rows[0].status_text, "refresh available");
460        assert_eq!(
461            lifecycle.outcomes[0].diagnostic_codes,
462            ["app_manual_refresh_required"]
463        );
464        assert_eq!(fs::read(&destination).await.unwrap(), b"first-v1\n");
465        assert_eq!(
466            fs::read_to_string(app_dir.join("first.runs"))
467                .await
468                .unwrap(),
469            "xx",
470            "explicit evaluation must execute the selected generator exactly once"
471        );
472        fs::remove_dir_all(root).await.unwrap();
473    }
474
475    #[tokio::test]
476    async fn refresh_selector_and_force_preserve_other_generated_files() {
477        let root = crate::test_support::make_temp_dir("shine-refresh").await;
478        let config = write_fixture(&root, true).await;
479        handle_install(&config, Some("sample"), false, false)
480            .await
481            .unwrap();
482        let app_dir = config.presets_dir().join("app/sample");
483        let first_dest = root.join("dest/first.txt");
484        let second_dest = root.join("dest/second.txt");
485        fs::write(app_dir.join("first.payload"), b"first-v2\n")
486            .await
487            .unwrap();
488        fs::write(app_dir.join("second.payload"), b"second-v2\n")
489            .await
490            .unwrap();
491        fs::write(&first_dest, b"user edit\n").await.unwrap();
492        crate::trust::grant_current_for_test(&config, "app/sample").await;
493
494        assert!(
495            handle_refresh(&config, "sample", Some("first.txt"), false)
496                .await
497                .is_err()
498        );
499        assert_eq!(fs::read(&first_dest).await.unwrap(), b"user edit\n");
500        handle_refresh(&config, "sample", Some("first.txt"), true)
501            .await
502            .unwrap();
503        assert_eq!(fs::read(&first_dest).await.unwrap(), b"first-v2\n");
504        assert_eq!(fs::read(&second_dest).await.unwrap(), b"second-v1\n");
505        assert_eq!(
506            fs::read_to_string(app_dir.join("second.runs"))
507                .await
508                .unwrap(),
509            "x",
510            "single-file refresh must not run other generators"
511        );
512        fs::remove_dir_all(root).await.unwrap();
513    }
514
515    #[tokio::test]
516    async fn refresh_keeps_last_good_file_and_continues_after_generator_failure() {
517        let root = crate::test_support::make_temp_dir("shine-refresh").await;
518        let config = write_fixture(&root, true).await;
519        handle_install(&config, Some("sample"), false, false)
520            .await
521            .unwrap();
522        let app_dir = config.presets_dir().join("app/sample");
523        let first_dest = root.join("dest/first.txt");
524        let second_dest = root.join("dest/second.txt");
525        fs::write(app_dir.join("first.sh"), b"#!/bin/sh\nexit 1\n")
526            .await
527            .unwrap();
528        fs::write(app_dir.join("second.payload"), b"second-v2\n")
529            .await
530            .unwrap();
531        crate::trust::grant_current_for_test(&config, "app/sample").await;
532
533        assert!(
534            handle_refresh(&config, "sample", None, false)
535                .await
536                .is_err()
537        );
538        assert_eq!(
539            fs::read(&first_dest).await.unwrap(),
540            b"first-v1\n",
541            "failed generator must retain the last-known-good file"
542        );
543        assert_eq!(
544            fs::read(&second_dest).await.unwrap(),
545            b"second-v2\n",
546            "a failed generator must not prevent later selected files refreshing"
547        );
548        fs::remove_dir_all(root).await.unwrap();
549    }
550
551    #[tokio::test]
552    async fn refresh_requires_the_generator_condition_env() {
553        let root = crate::test_support::make_temp_dir("shine-refresh").await;
554        let mut config = write_fixture(&root, false).await;
555        handle_install(&config, Some("sample"), false, false)
556            .await
557            .unwrap();
558        config.env.remove("SOURCE_URL");
559        let dest = root.join("dest/first.txt");
560
561        assert!(
562            handle_refresh(&config, "sample", Some("first.txt"), false)
563                .await
564                .is_err()
565        );
566        assert_eq!(fs::read(&dest).await.unwrap(), b"first-v1\n");
567        fs::remove_dir_all(root).await.unwrap();
568    }
569}