1use 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}