Skip to main content

cli/apps/
refresh.rs

1//! Explicit refresh of manifest-owned generated app files.
2
3use anyhow::{Result, bail};
4use std::collections::BTreeSet;
5use std::path::Path;
6
7use crate::colors;
8use crate::config::Config;
9use crate::env::EnvConfig;
10use crate::install_core::file_ops::InstallOutcome;
11use crate::install_core::manifest::{AppEntry, AppManifest};
12
13use super::hooks::{HookPhase, run_app_hooks};
14use super::metadata;
15use super::report::{print_install_error, print_install_success};
16use super::{
17    desired_content_hash, install_prepared_content, installed_content_hash,
18    materialize_file_content, resolve_install_destination,
19};
20
21pub async fn handle_refresh(
22    config: &Config,
23    category: &str,
24    file_selector: Option<&str>,
25    force: bool,
26) -> Result<()> {
27    crate::config::print_presets_note(config);
28    let categories = metadata::load_active_categories(config, Some(category)).await?;
29    let cat = categories
30        .iter()
31        .find(|cat| cat.name == category)
32        .ok_or_else(|| anyhow::anyhow!("app preset category not found: {category}"))?;
33    let env = EnvConfig::load_or_init(config).await?;
34    let env_map = env.as_map();
35    let mut manifest = AppManifest::load(config.shine_dir()).await?;
36
37    let candidates = if let Some(selector) = file_selector {
38        let file = cat
39            .files
40            .iter()
41            .find(|file| file.source_rel == Path::new(selector))
42            .ok_or_else(|| anyhow::anyhow!("app '{category}' file not found: {selector}"))?;
43        if file.generator.is_none() {
44            bail!("app '{category}' file is not generated: {selector}");
45        }
46        vec![file]
47    } else {
48        cat.files
49            .iter()
50            .filter(|file| file.generator.is_some())
51            .collect::<Vec<_>>()
52    };
53
54    if candidates.is_empty() {
55        bail!("app '{category}' has no generated files");
56    }
57
58    let mut selected = Vec::new();
59    for file in candidates {
60        let destination = resolve_install_destination(cat, file, config)?;
61        let Some(entry) = manifest.find_by_dest(&destination).cloned() else {
62            if file_selector.is_some() {
63                bail!(
64                    "app '{category}' generated file is not installed: {}",
65                    file.source_rel.display()
66                );
67            }
68            continue;
69        };
70        selected.push((file, destination, entry));
71    }
72    if selected.is_empty() {
73        bail!(
74            "app '{category}' has no installed generated files; run `shine install app/{category}` first"
75        );
76    }
77
78    println!(
79        "{}",
80        colors::bold(&format!("Refreshing app generators: {category}"))
81    );
82    let mut updated = 0usize;
83    let mut unchanged = 0usize;
84    let mut failed = 0usize;
85
86    for (file, destination, entry) in selected {
87        let label = format!("{category}/{}", file.source_rel.display());
88        let generator = file.generator.as_ref().expect("candidate has generator");
89        if !env_map.contains_key(&generator.when_env) {
90            eprintln!(
91                "  {} {label}: generator requires config env '{}'",
92                colors::symbol_stderr("✗"),
93                generator.when_env
94            );
95            failed += 1;
96            continue;
97        }
98
99        let content = match materialize_file_content(config, cat, file, env_map).await {
100            Ok(content) => content,
101            Err(error) => {
102                print_install_error(&label, &error);
103                failed += 1;
104                continue;
105            }
106        };
107        let desired_hash = match desired_content_hash(file, &content) {
108            Ok(hash) => hash,
109            Err(error) => {
110                print_install_error(&label, &error);
111                failed += 1;
112                continue;
113            }
114        };
115
116        let (destination_exists, current_hash) = match tokio::fs::read(&destination).await {
117            Ok(bytes) => match installed_content_hash(file, &bytes) {
118                Ok(hash) => (true, hash),
119                Err(error) => {
120                    if !force {
121                        print_install_error(&label, &error);
122                        failed += 1;
123                        continue;
124                    }
125                    (true, None)
126                }
127            },
128            Err(error) if error.kind() == std::io::ErrorKind::NotFound => (false, None),
129            Err(error) => {
130                print_install_error(&label, &error.into());
131                failed += 1;
132                continue;
133            }
134        };
135
136        if current_hash == Some(entry.content_hash) && desired_hash == entry.content_hash {
137            println!(
138                "  {} {label}  {}",
139                colors::dim("-"),
140                colors::dim("already up to date")
141            );
142            unchanged += 1;
143            continue;
144        }
145        if destination_exists && current_hash != Some(entry.content_hash) && !force {
146            eprintln!(
147                "  {} {label}: user-modified, kept (use --force to overwrite)",
148                colors::symbol("!")
149            );
150            failed += 1;
151            continue;
152        }
153
154        match install_prepared_content(file, &content, &destination, true, false, true).await {
155            Ok(InstallOutcome::Installed { hash })
156            | Ok(InstallOutcome::BackedUpAndInstalled { hash, .. }) => {
157                print_install_success(&label, "", &destination, config);
158                manifest.upsert(AppEntry {
159                    source: entry.source,
160                    destination,
161                    backup: entry.backup,
162                    content_hash: hash,
163                    install_strategy: file.install_strategy.clone(),
164                    uses_env: true,
165                    requires_admin: file.requires_admin,
166                });
167                updated += 1;
168            }
169            Ok(InstallOutcome::AlreadyManaged) => {
170                unchanged += 1;
171            }
172            Ok(InstallOutcome::DryRun) => unreachable!("refresh is never a dry run"),
173            Err(error) => {
174                print_install_error(&label, &error);
175                failed += 1;
176            }
177        }
178    }
179
180    if updated > 0 {
181        manifest.save(config.shine_dir()).await?;
182        run_app_hooks(
183            config,
184            |name| categories.iter().find(|cat| cat.name == name),
185            &BTreeSet::from([category.to_string()]),
186            HookPhase::PostUpgrade,
187            true,
188        )
189        .await;
190    }
191
192    println!(
193        "{}",
194        colors::dim(&format!(
195            "Refresh complete: {updated} updated, {unchanged} unchanged, {failed} failed"
196        ))
197    );
198    if failed > 0 {
199        bail!("{failed} generated app file(s) failed to refresh");
200    }
201    Ok(())
202}
203
204#[cfg(all(test, unix))]
205mod tests {
206    use super::*;
207    use crate::apps::{handle_install, handle_upgrade_installed};
208    use crate::status::{FileStatus, app_entry_status};
209    use std::os::unix::fs::PermissionsExt;
210    use tokio::fs;
211
212    async fn write_fixture(root: &Path, two_files: bool) -> Config {
213        let mut config = Config::new_for_test(root);
214        config.is_external_presets = true;
215        config.allow_app_hooks = true;
216        config
217            .env
218            .insert("SOURCE_URL".to_string(), "https://example.test".to_string());
219        let app_dir = config.presets_dir().join("app/sample");
220        fs::create_dir_all(&app_dir).await.unwrap();
221        let second = if two_files {
222            r#"
223
224[[files]]
225source = "second.txt"
226generator = { script = "second.sh", env = ["SOURCE_URL"], when_env = "SOURCE_URL", auto = false }
227"#
228        } else {
229            ""
230        };
231        fs::write(
232            app_dir.join("shine.toml"),
233            format!(
234                r#"description = "sample"
235dest = "{}"
236
237[[files]]
238source = "first.txt"
239generator = {{ script = "first.sh", env = ["SOURCE_URL"], when_env = "SOURCE_URL", auto = false }}
240{second}"#,
241                root.join("dest").display()
242            ),
243        )
244        .await
245        .unwrap();
246        fs::write(app_dir.join("first.txt"), b"fallback-first\n")
247            .await
248            .unwrap();
249        fs::write(app_dir.join("first.payload"), b"first-v1\n")
250            .await
251            .unwrap();
252        write_generator(&app_dir.join("first.sh"), "first").await;
253        if two_files {
254            fs::write(app_dir.join("second.txt"), b"fallback-second\n")
255                .await
256                .unwrap();
257            fs::write(app_dir.join("second.payload"), b"second-v1\n")
258                .await
259                .unwrap();
260            write_generator(&app_dir.join("second.sh"), "second").await;
261        }
262        config
263    }
264
265    async fn write_generator(path: &Path, stem: &str) {
266        fs::write(
267            path,
268            format!(
269                "#!/bin/sh\nprintf x >> '{counter}'\ncat '{payload}'\n",
270                counter = path
271                    .parent()
272                    .unwrap()
273                    .join(format!("{stem}.runs"))
274                    .display(),
275                payload = path
276                    .parent()
277                    .unwrap()
278                    .join(format!("{stem}.payload"))
279                    .display()
280            ),
281        )
282        .await
283        .unwrap();
284        fs::set_permissions(path, std::fs::Permissions::from_mode(0o700))
285            .await
286            .unwrap();
287    }
288
289    #[tokio::test]
290    async fn manual_generator_skips_status_and_upgrade_but_refreshes_explicitly() {
291        let root = crate::test_support::make_temp_dir("shine-refresh").await;
292        let config = write_fixture(&root, false).await;
293        handle_install(&config, Some("sample"), false, false)
294            .await
295            .unwrap();
296
297        let app_dir = config.presets_dir().join("app/sample");
298        let dest = root.join("dest/first.txt");
299        assert_eq!(
300            fs::read_to_string(app_dir.join("first.runs"))
301                .await
302                .unwrap(),
303            "x"
304        );
305        fs::write(app_dir.join("first.payload"), b"first-v2\n")
306            .await
307            .unwrap();
308
309        let categories = metadata::load_active_categories(&config, Some("sample"))
310            .await
311            .unwrap();
312        let cat = &categories[0];
313        let file = &cat.files[0];
314        let manifest = AppManifest::load(config.shine_dir()).await.unwrap();
315        let entry = manifest.find_by_dest(&dest).unwrap();
316        assert_eq!(
317            app_entry_status(&config, cat, file, entry, &config.env).await,
318            FileStatus::UpToDate
319        );
320        let mut separator = crate::output::SectionSeparator::new();
321        let report = handle_upgrade_installed(&config, false, &mut separator)
322            .await
323            .unwrap();
324        assert_eq!(report.updated, 0);
325        assert_eq!(
326            fs::read_to_string(app_dir.join("first.runs"))
327                .await
328                .unwrap(),
329            "x"
330        );
331        assert_eq!(fs::read(&dest).await.unwrap(), b"first-v1\n");
332
333        handle_refresh(&config, "sample", Some("first.txt"), false)
334            .await
335            .unwrap();
336        assert_eq!(fs::read(&dest).await.unwrap(), b"first-v2\n");
337        assert_eq!(
338            fs::read_to_string(app_dir.join("first.runs"))
339                .await
340                .unwrap(),
341            "xx"
342        );
343        fs::remove_dir_all(root).await.unwrap();
344    }
345
346    #[tokio::test]
347    async fn refresh_selector_and_force_preserve_other_generated_files() {
348        let root = crate::test_support::make_temp_dir("shine-refresh").await;
349        let config = write_fixture(&root, true).await;
350        handle_install(&config, Some("sample"), false, false)
351            .await
352            .unwrap();
353        let app_dir = config.presets_dir().join("app/sample");
354        let first_dest = root.join("dest/first.txt");
355        let second_dest = root.join("dest/second.txt");
356        fs::write(app_dir.join("first.payload"), b"first-v2\n")
357            .await
358            .unwrap();
359        fs::write(app_dir.join("second.payload"), b"second-v2\n")
360            .await
361            .unwrap();
362        fs::write(&first_dest, b"user edit\n").await.unwrap();
363
364        assert!(
365            handle_refresh(&config, "sample", Some("first.txt"), false)
366                .await
367                .is_err()
368        );
369        assert_eq!(fs::read(&first_dest).await.unwrap(), b"user edit\n");
370        handle_refresh(&config, "sample", Some("first.txt"), true)
371            .await
372            .unwrap();
373        assert_eq!(fs::read(&first_dest).await.unwrap(), b"first-v2\n");
374        assert_eq!(fs::read(&second_dest).await.unwrap(), b"second-v1\n");
375        assert_eq!(
376            fs::read_to_string(app_dir.join("second.runs"))
377                .await
378                .unwrap(),
379            "x",
380            "single-file refresh must not run other generators"
381        );
382        fs::remove_dir_all(root).await.unwrap();
383    }
384
385    #[tokio::test]
386    async fn refresh_keeps_last_good_file_and_continues_after_generator_failure() {
387        let root = crate::test_support::make_temp_dir("shine-refresh").await;
388        let config = write_fixture(&root, true).await;
389        handle_install(&config, Some("sample"), false, false)
390            .await
391            .unwrap();
392        let app_dir = config.presets_dir().join("app/sample");
393        let first_dest = root.join("dest/first.txt");
394        let second_dest = root.join("dest/second.txt");
395        fs::write(app_dir.join("first.sh"), b"#!/bin/sh\nexit 1\n")
396            .await
397            .unwrap();
398        fs::write(app_dir.join("second.payload"), b"second-v2\n")
399            .await
400            .unwrap();
401
402        assert!(
403            handle_refresh(&config, "sample", None, false)
404                .await
405                .is_err()
406        );
407        assert_eq!(
408            fs::read(&first_dest).await.unwrap(),
409            b"first-v1\n",
410            "failed generator must retain the last-known-good file"
411        );
412        assert_eq!(
413            fs::read(&second_dest).await.unwrap(),
414            b"second-v2\n",
415            "a failed generator must not prevent later selected files refreshing"
416        );
417        fs::remove_dir_all(root).await.unwrap();
418    }
419
420    #[tokio::test]
421    async fn refresh_requires_the_generator_condition_env() {
422        let root = crate::test_support::make_temp_dir("shine-refresh").await;
423        let mut config = write_fixture(&root, false).await;
424        handle_install(&config, Some("sample"), false, false)
425            .await
426            .unwrap();
427        config.env.remove("SOURCE_URL");
428        let dest = root.join("dest/first.txt");
429
430        assert!(
431            handle_refresh(&config, "sample", Some("first.txt"), false)
432                .await
433                .is_err()
434        );
435        assert_eq!(fs::read(&dest).await.unwrap(), b"first-v1\n");
436        fs::remove_dir_all(root).await.unwrap();
437    }
438}