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