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