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