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        )
188        .await;
189    }
190
191    println!(
192        "{}",
193        colors::dim(&format!(
194            "Refresh complete: {updated} updated, {unchanged} unchanged, {failed} failed"
195        ))
196    );
197    if failed > 0 {
198        bail!("{failed} generated app file(s) failed to refresh");
199    }
200    Ok(())
201}
202
203#[cfg(all(test, unix))]
204mod tests {
205    use super::*;
206    use crate::apps::{handle_install, handle_upgrade_installed};
207    use crate::status::{FileStatus, app_entry_status};
208    use std::os::unix::fs::PermissionsExt;
209    use tokio::fs;
210
211    async fn write_fixture(root: &Path, two_files: bool) -> Config {
212        let mut config = Config::new_for_test(root);
213        config.is_external_presets = true;
214        config.allow_app_hooks = true;
215        config
216            .env
217            .insert("SOURCE_URL".to_string(), "https://example.test".to_string());
218        let app_dir = config.presets_dir().join("app/sample");
219        fs::create_dir_all(&app_dir).await.unwrap();
220        let second = if two_files {
221            r#"
222
223[[files]]
224source = "second.txt"
225generator = { script = "second.sh", env = ["SOURCE_URL"], when_env = "SOURCE_URL", auto = false }
226"#
227        } else {
228            ""
229        };
230        fs::write(
231            app_dir.join("shine.toml"),
232            format!(
233                r#"description = "sample"
234dest = "{}"
235
236[[files]]
237source = "first.txt"
238generator = {{ script = "first.sh", env = ["SOURCE_URL"], when_env = "SOURCE_URL", auto = false }}
239{second}"#,
240                root.join("dest").display()
241            ),
242        )
243        .await
244        .unwrap();
245        fs::write(app_dir.join("first.txt"), b"fallback-first\n")
246            .await
247            .unwrap();
248        fs::write(app_dir.join("first.payload"), b"first-v1\n")
249            .await
250            .unwrap();
251        write_generator(&app_dir.join("first.sh"), "first").await;
252        if two_files {
253            fs::write(app_dir.join("second.txt"), b"fallback-second\n")
254                .await
255                .unwrap();
256            fs::write(app_dir.join("second.payload"), b"second-v1\n")
257                .await
258                .unwrap();
259            write_generator(&app_dir.join("second.sh"), "second").await;
260        }
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    #[tokio::test]
289    async fn manual_generator_skips_status_and_upgrade_but_refreshes_explicitly() {
290        let root = crate::test_support::make_temp_dir("shine-refresh").await;
291        let config = write_fixture(&root, false).await;
292        handle_install(&config, Some("sample"), false, false)
293            .await
294            .unwrap();
295
296        let app_dir = config.presets_dir().join("app/sample");
297        let dest = root.join("dest/first.txt");
298        assert_eq!(
299            fs::read_to_string(app_dir.join("first.runs"))
300                .await
301                .unwrap(),
302            "x"
303        );
304        fs::write(app_dir.join("first.payload"), b"first-v2\n")
305            .await
306            .unwrap();
307
308        let categories = metadata::load_active_categories(&config, Some("sample"))
309            .await
310            .unwrap();
311        let cat = &categories[0];
312        let file = &cat.files[0];
313        let manifest = AppManifest::load(config.shine_dir()).await.unwrap();
314        let entry = manifest.find_by_dest(&dest).unwrap();
315        assert_eq!(
316            app_entry_status(&config, cat, file, entry, &config.env).await,
317            FileStatus::UpToDate
318        );
319        let mut separator = crate::output::SectionSeparator::new();
320        let report = handle_upgrade_installed(&config, false, &mut separator)
321            .await
322            .unwrap();
323        assert_eq!(report.updated, 0);
324        assert_eq!(
325            fs::read_to_string(app_dir.join("first.runs"))
326                .await
327                .unwrap(),
328            "x"
329        );
330        assert_eq!(fs::read(&dest).await.unwrap(), b"first-v1\n");
331
332        handle_refresh(&config, "sample", Some("first.txt"), false)
333            .await
334            .unwrap();
335        assert_eq!(fs::read(&dest).await.unwrap(), b"first-v2\n");
336        assert_eq!(
337            fs::read_to_string(app_dir.join("first.runs"))
338                .await
339                .unwrap(),
340            "xx"
341        );
342        fs::remove_dir_all(root).await.unwrap();
343    }
344
345    #[tokio::test]
346    async fn refresh_selector_and_force_preserve_other_generated_files() {
347        let root = crate::test_support::make_temp_dir("shine-refresh").await;
348        let config = write_fixture(&root, true).await;
349        handle_install(&config, Some("sample"), false, false)
350            .await
351            .unwrap();
352        let app_dir = config.presets_dir().join("app/sample");
353        let first_dest = root.join("dest/first.txt");
354        let second_dest = root.join("dest/second.txt");
355        fs::write(app_dir.join("first.payload"), b"first-v2\n")
356            .await
357            .unwrap();
358        fs::write(app_dir.join("second.payload"), b"second-v2\n")
359            .await
360            .unwrap();
361        fs::write(&first_dest, b"user edit\n").await.unwrap();
362
363        assert!(
364            handle_refresh(&config, "sample", Some("first.txt"), false)
365                .await
366                .is_err()
367        );
368        assert_eq!(fs::read(&first_dest).await.unwrap(), b"user edit\n");
369        handle_refresh(&config, "sample", Some("first.txt"), true)
370            .await
371            .unwrap();
372        assert_eq!(fs::read(&first_dest).await.unwrap(), b"first-v2\n");
373        assert_eq!(fs::read(&second_dest).await.unwrap(), b"second-v1\n");
374        assert_eq!(
375            fs::read_to_string(app_dir.join("second.runs"))
376                .await
377                .unwrap(),
378            "x",
379            "single-file refresh must not run other generators"
380        );
381        fs::remove_dir_all(root).await.unwrap();
382    }
383
384    #[tokio::test]
385    async fn refresh_keeps_last_good_file_and_continues_after_generator_failure() {
386        let root = crate::test_support::make_temp_dir("shine-refresh").await;
387        let config = write_fixture(&root, true).await;
388        handle_install(&config, Some("sample"), false, false)
389            .await
390            .unwrap();
391        let app_dir = config.presets_dir().join("app/sample");
392        let first_dest = root.join("dest/first.txt");
393        let second_dest = root.join("dest/second.txt");
394        fs::write(app_dir.join("first.sh"), b"#!/bin/sh\nexit 1\n")
395            .await
396            .unwrap();
397        fs::write(app_dir.join("second.payload"), b"second-v2\n")
398            .await
399            .unwrap();
400
401        assert!(
402            handle_refresh(&config, "sample", None, false)
403                .await
404                .is_err()
405        );
406        assert_eq!(
407            fs::read(&first_dest).await.unwrap(),
408            b"first-v1\n",
409            "failed generator must retain the last-known-good file"
410        );
411        assert_eq!(
412            fs::read(&second_dest).await.unwrap(),
413            b"second-v2\n",
414            "a failed generator must not prevent later selected files refreshing"
415        );
416        fs::remove_dir_all(root).await.unwrap();
417    }
418
419    #[tokio::test]
420    async fn refresh_requires_the_generator_condition_env() {
421        let root = crate::test_support::make_temp_dir("shine-refresh").await;
422        let mut config = write_fixture(&root, false).await;
423        handle_install(&config, Some("sample"), false, false)
424            .await
425            .unwrap();
426        config.env.remove("SOURCE_URL");
427        let dest = root.join("dest/first.txt");
428
429        assert!(
430            handle_refresh(&config, "sample", Some("first.txt"), false)
431                .await
432                .is_err()
433        );
434        assert_eq!(fs::read(&dest).await.unwrap(), b"first-v1\n");
435        fs::remove_dir_all(root).await.unwrap();
436    }
437}