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