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 let plan_request = reviewed_app_refresh_request(&reviewed.request);
52
53 println!(
54 "{}",
55 colors::bold(&format!("Refreshing app generators: {category}"))
56 );
57 let mut observer = RefreshObserver;
58 let mut interaction = TerminalInteraction;
59 let report = runtime
60 .refresh_app_generators_approved(
61 plan_request,
62 &reviewed.approval,
63 &mut observer,
64 &mut interaction,
65 )
66 .await?;
67 let single_label = report
68 .files
69 .first()
70 .filter(|_| report.files.len() == 1)
71 .map(|file| format!("{category}/{}", file.source.display()));
72 let mut updated = 0;
73 let mut unchanged = 0;
74 let mut failed = 0;
75 for file in report.files {
76 let label = format!("{category}/{}", file.source.display());
77 match file.action {
78 AppFileAction::Installed | AppFileAction::BackedUp => {
79 print_install_success(&label, "", &file.destination, config);
80 updated += 1;
81 }
82 AppFileAction::Unchanged => {
83 println!(
84 " {} {label} {}",
85 colors::dim("-"),
86 colors::dim("already up to date")
87 );
88 unchanged += 1;
89 }
90 AppFileAction::UserModified => {
91 eprintln!(
92 " {} {label}: user-modified, kept (use --force to overwrite)",
93 colors::symbol("!")
94 );
95 failed += 1;
96 }
97 AppFileAction::Failed => {
98 print_install_error(&label, &anyhow::anyhow!(file.error.unwrap_or_default()));
99 failed += 1;
100 }
101 _ => unchanged += 1,
102 }
103 }
104
105 println!(
106 "{}",
107 refresh_summary_text(single_label.as_deref(), updated, unchanged, failed)
108 );
109 if failed > 0 {
110 bail!("{failed} generated app file(s) failed to refresh");
111 }
112 Ok(())
113}
114
115fn reviewed_app_refresh_request(
116 request: &crate::lifecycle_plan::LifecyclePlanRequest,
117) -> AppRefreshPlanRequest {
118 match request {
119 crate::lifecycle_plan::LifecyclePlanRequest::AppRefresh(request) => request.clone(),
120 _ => unreachable!("reviewed App refresh Plan must retain its refresh request"),
121 }
122}
123
124fn refresh_summary_text(
125 single_label: Option<&str>,
126 updated: usize,
127 unchanged: usize,
128 failed: usize,
129) -> String {
130 let total = updated + unchanged + failed;
131 if total == 1
132 && let Some(label) = single_label
133 {
134 if failed == 1 {
135 return format!(
136 "{} {}",
137 colors::symbol("!"),
138 colors::yellow(&format!("Refresh incomplete: {label} failed"))
139 );
140 }
141 if updated == 1 {
142 return format!(
143 "{} {}",
144 colors::symbol("✓"),
145 colors::green(&format!("Refresh complete: {label} updated"))
146 );
147 }
148 return format!(
149 "{} {}",
150 colors::symbol("✓"),
151 colors::green(&format!("Already up to date: {label}"))
152 );
153 }
154
155 let mut parts = Vec::new();
156 crate::output::push_count(&mut parts, updated, colors::green, "updated");
157 crate::output::push_count(&mut parts, unchanged, colors::dim, "unchanged");
158 crate::output::push_count(&mut parts, failed, colors::yellow, "failed");
159 let detail = if parts.is_empty() {
160 colors::dim("nothing changed")
161 } else {
162 parts.join(&colors::dim(", "))
163 };
164 let (symbol, conclusion) = if failed > 0 {
165 ("!", "Refresh incomplete")
166 } else {
167 ("✓", "Refresh complete")
168 };
169 format!("{} {conclusion}: {detail}", colors::symbol(symbol))
170}
171
172struct RefreshObserver;
173
174impl RuntimeObserver for RefreshObserver {
175 fn emit(&mut self, event: RuntimeEvent) {
176 match event {
177 RuntimeEvent::Warning { detail, .. } => eprintln!(" {} {detail}", colors::symbol("!")),
178 RuntimeEvent::ProcessOutput { text, .. } => {
179 for line in text.lines() {
180 println!(" {}", colors::dim(line));
181 }
182 }
183 RuntimeEvent::Progress {
184 code: "app_hook_completed",
185 target,
186 } => {
187 println!(
188 " {} {}: post-upgrade hook completed",
189 colors::symbol("✓"),
190 target.trim_start_matches("app/")
191 );
192 }
193 _ => {}
194 }
195 }
196}
197
198#[cfg(all(test, unix))]
199mod tests {
200 use super::*;
201 use crate::apps::metadata;
202 use crate::apps::{handle_install, handle_upgrade_installed};
203 use crate::install_core::manifest::AppManifest;
204 use crate::status::{FileStatus, app_entry_status};
205 use shine_core::runtime::OpaqueSecretVersion;
206 use std::os::unix::fs::PermissionsExt;
207 use tokio::fs;
208
209 async fn write_fixture(root: &Path, two_files: bool) -> Config {
210 let mut config = Config::new_for_test(root);
211 config.is_external_presets = true;
212 config
213 .env
214 .insert("SOURCE_URL".to_string(), "https://example.test".to_string());
215 let app_dir = config.presets_dir().join("app/sample");
216 fs::create_dir_all(&app_dir).await.unwrap();
217 let second = if two_files {
218 r#"
219
220[[files]]
221source = "second.txt"
222generator = { script = "second.sh", env = ["SOURCE_URL"], when_env = "SOURCE_URL", auto = false }
223"#
224 } else {
225 ""
226 };
227 fs::write(
228 app_dir.join("shine.toml"),
229 format!(
230 r#"description = "sample"
231dest = "{}"
232
233[permissions]
234schema_version = 1
235filesystem = [
236 {{ access = ["execute"], base = "preset", path = "first.sh" }},
237 {{ access = ["execute"], base = "preset", path = "second.sh" }},
238]
239environment = [{{ name = "SOURCE_URL", sensitivity = "secret" }}]
240
241[[files]]
242source = "first.txt"
243generator = {{ script = "first.sh", env = ["SOURCE_URL"], when_env = "SOURCE_URL", auto = false }}
244{second}"#,
245 root.join("dest").display()
246 ),
247 )
248 .await
249 .unwrap();
250 fs::write(app_dir.join("first.txt"), b"fallback-first\n")
251 .await
252 .unwrap();
253 fs::write(app_dir.join("first.payload"), b"first-v1\n")
254 .await
255 .unwrap();
256 write_generator(&app_dir.join("first.sh"), "first").await;
257 if two_files {
258 fs::write(app_dir.join("second.txt"), b"fallback-second\n")
259 .await
260 .unwrap();
261 fs::write(app_dir.join("second.payload"), b"second-v1\n")
262 .await
263 .unwrap();
264 write_generator(&app_dir.join("second.sh"), "second").await;
265 }
266 crate::trust::grant_current_for_test(&config, "app/sample").await;
267 config
268 }
269
270 async fn write_generator(path: &Path, stem: &str) {
271 fs::write(
272 path,
273 format!(
274 "#!/bin/sh\nprintf x >> '{counter}'\ncat '{payload}'\n",
275 counter = path
276 .parent()
277 .unwrap()
278 .join(format!("{stem}.runs"))
279 .display(),
280 payload = path
281 .parent()
282 .unwrap()
283 .join(format!("{stem}.payload"))
284 .display()
285 ),
286 )
287 .await
288 .unwrap();
289 fs::set_permissions(path, std::fs::Permissions::from_mode(0o700))
290 .await
291 .unwrap();
292 }
293
294 #[test]
295 fn refresh_execution_reuses_the_reviewed_input_versions() {
296 let mut input_versions = PlanningInputVersions::default();
297 input_versions
298 .insert_secret_version("SOURCE_URL", OpaqueSecretVersion::new("test-version"));
299 let request =
300 crate::lifecycle_plan::LifecyclePlanRequest::AppRefresh(AppRefreshPlanRequest {
301 category: "sample".to_string(),
302 file: Some(Path::new("first.txt").to_path_buf()),
303 force: false,
304 input_versions: input_versions.clone(),
305 });
306
307 assert_eq!(
308 reviewed_app_refresh_request(&request).input_versions,
309 input_versions
310 );
311 }
312
313 #[test]
314 fn refresh_summary_names_single_files_and_distinguishes_failures() {
315 assert_eq!(
316 refresh_summary_text(Some("sample/first.txt"), 1, 0, 0),
317 "✓ Refresh complete: sample/first.txt updated"
318 );
319 assert_eq!(
320 refresh_summary_text(Some("sample/first.txt"), 0, 1, 0),
321 "✓ Already up to date: sample/first.txt"
322 );
323 assert_eq!(
324 refresh_summary_text(Some("sample/first.txt"), 0, 0, 1),
325 "! Refresh incomplete: sample/first.txt failed"
326 );
327 assert_eq!(
328 refresh_summary_text(None, 2, 1, 0),
329 "✓ Refresh complete: 2 updated, 1 unchanged"
330 );
331 assert_eq!(
332 refresh_summary_text(None, 1, 0, 1),
333 "! Refresh incomplete: 1 updated, 1 failed"
334 );
335 }
336
337 #[tokio::test]
338 async fn manual_generator_skips_status_and_upgrade_but_refreshes_explicitly() {
339 let root = crate::test_support::make_temp_dir("shine-refresh").await;
340 let config = write_fixture(&root, false).await;
341 handle_install(&config, Some("sample"), false, false)
342 .await
343 .unwrap();
344
345 let app_dir = config.presets_dir().join("app/sample");
346 let dest = root.join("dest/first.txt");
347 assert_eq!(
348 fs::read_to_string(app_dir.join("first.runs"))
349 .await
350 .unwrap(),
351 "x"
352 );
353 fs::write(app_dir.join("first.payload"), b"first-v2\n")
354 .await
355 .unwrap();
356
357 let categories = metadata::load_active_categories(&config, Some("sample"))
358 .await
359 .unwrap();
360 let cat = &categories[0];
361 let file = &cat.files[0];
362 let manifest = AppManifest::load(&shine_core::runtime::RealHost, config.shine_dir())
363 .await
364 .unwrap();
365 let entry = manifest.find_by_dest(&dest).unwrap();
366 assert_eq!(
367 app_entry_status(&config, cat, file, entry, &config.env).await,
368 FileStatus::UpToDate
369 );
370 let mut separator = crate::output::SectionSeparator::new();
371 let report = handle_upgrade_installed(&config, false, &mut separator)
372 .await
373 .unwrap();
374 assert_eq!(report.updated, 0);
375 assert_eq!(
376 fs::read_to_string(app_dir.join("first.runs"))
377 .await
378 .unwrap(),
379 "x"
380 );
381 assert_eq!(fs::read(&dest).await.unwrap(), b"first-v1\n");
382
383 crate::trust::grant_current_for_test(&config, "app/sample").await;
384 handle_refresh(&config, "sample", Some("first.txt"), false)
385 .await
386 .unwrap();
387 assert_eq!(fs::read(&dest).await.unwrap(), b"first-v2\n");
388 assert_eq!(
389 fs::read_to_string(app_dir.join("first.runs"))
390 .await
391 .unwrap(),
392 "xx"
393 );
394 fs::remove_dir_all(root).await.unwrap();
395 }
396
397 #[tokio::test]
398 async fn automatic_generator_status_reports_refresh_without_execution() {
399 let root = crate::test_support::make_temp_dir("shine-refresh-status").await;
400 let config = write_fixture(&root, false).await;
401 let metadata_path = config.presets_dir().join("app/sample/shine.toml");
402 let metadata = fs::read_to_string(&metadata_path)
403 .await
404 .unwrap()
405 .replace("auto = false", "auto = true");
406 fs::write(&metadata_path, metadata).await.unwrap();
407 crate::trust::grant_current_for_test(&config, "app/sample").await;
408 handle_install(&config, Some("sample"), false, false)
409 .await
410 .unwrap();
411
412 let categories = metadata::load_active_categories(&config, Some("sample"))
413 .await
414 .unwrap();
415 let rows = crate::status::build_app_rows(&config, &categories)
416 .await
417 .unwrap();
418 assert_eq!(rows[0].file_status, FileStatus::GeneratorNotEvaluated);
419 assert_eq!(
420 fs::read_to_string(config.presets_dir().join("app/sample/first.runs"))
421 .await
422 .unwrap(),
423 "x",
424 "read-only status must not execute an automatic generator"
425 );
426 fs::remove_dir_all(root).await.unwrap();
427 }
428
429 #[tokio::test]
430 async fn explicit_generator_evaluation_materializes_desired_content_before_install() {
431 let root = crate::test_support::make_temp_dir("shine-generator-preview").await;
432 let config = write_fixture(&root, false).await;
433 let mut runtime = crate::core_runtime::from_config(&config).await.unwrap();
434 runtime.context_mut_for_cli().env = config.env.clone();
435 let inspections = runtime
436 .inspect_apps_with_options(
437 shine_core::runtime::AppInspectionOptions {
438 run_generators: true,
439 categories: vec!["sample".to_string()],
440 },
441 &mut shine_core::runtime::NullObserver,
442 )
443 .await
444 .unwrap();
445 assert_eq!(
446 inspections[0].desired_content.as_deref(),
447 Some(b"first-v1\n".as_slice())
448 );
449 assert!(!root.join("dest/first.txt").exists());
450 assert_eq!(
451 fs::read_to_string(config.presets_dir().join("app/sample/first.runs"))
452 .await
453 .unwrap(),
454 "x"
455 );
456 fs::remove_dir_all(root).await.unwrap();
457 }
458
459 #[tokio::test]
460 async fn explicit_generator_evaluation_updates_status_without_writing_destination() {
461 let root = crate::test_support::make_temp_dir("shine-generator-evaluation").await;
462 let config = write_fixture(&root, false).await;
463 crate::trust::grant_current_for_test(&config, "app/sample").await;
464 handle_install(&config, Some("sample"), false, false)
465 .await
466 .unwrap();
467 let app_dir = config.presets_dir().join("app/sample");
468 let destination = root.join("dest/first.txt");
469 fs::write(app_dir.join("first.payload"), b"first-v2\n")
470 .await
471 .unwrap();
472 crate::trust::grant_current_for_test(&config, "app/sample").await;
473
474 let categories = metadata::load_active_categories(&config, Some("sample"))
475 .await
476 .unwrap();
477 let (rows, lifecycle, _) =
478 crate::status::build_app_rows_with_lifecycle_options(&config, &categories, true)
479 .await
480 .unwrap();
481 assert_eq!(rows[0].file_status, FileStatus::UpdateAvail);
482 assert!(!rows[0].upgrade_available);
483 assert_eq!(rows[0].refresh_sources, ["first.txt"]);
484 assert_eq!(rows[0].status_text, "refresh available");
485 assert_eq!(
486 lifecycle.outcomes[0].diagnostic_codes,
487 ["app_manual_refresh_required"]
488 );
489 assert_eq!(fs::read(&destination).await.unwrap(), b"first-v1\n");
490 assert_eq!(
491 fs::read_to_string(app_dir.join("first.runs"))
492 .await
493 .unwrap(),
494 "xx",
495 "explicit evaluation must execute the selected generator exactly once"
496 );
497 fs::remove_dir_all(root).await.unwrap();
498 }
499
500 #[tokio::test]
501 async fn refresh_selector_and_force_preserve_other_generated_files() {
502 let root = crate::test_support::make_temp_dir("shine-refresh").await;
503 let config = write_fixture(&root, true).await;
504 handle_install(&config, Some("sample"), false, false)
505 .await
506 .unwrap();
507 let app_dir = config.presets_dir().join("app/sample");
508 let first_dest = root.join("dest/first.txt");
509 let second_dest = root.join("dest/second.txt");
510 fs::write(app_dir.join("first.payload"), b"first-v2\n")
511 .await
512 .unwrap();
513 fs::write(app_dir.join("second.payload"), b"second-v2\n")
514 .await
515 .unwrap();
516 fs::write(&first_dest, b"user edit\n").await.unwrap();
517 crate::trust::grant_current_for_test(&config, "app/sample").await;
518
519 assert!(
520 handle_refresh(&config, "sample", Some("first.txt"), false)
521 .await
522 .is_err()
523 );
524 assert_eq!(fs::read(&first_dest).await.unwrap(), b"user edit\n");
525 handle_refresh(&config, "sample", Some("first.txt"), true)
526 .await
527 .unwrap();
528 assert_eq!(fs::read(&first_dest).await.unwrap(), b"first-v2\n");
529 assert_eq!(fs::read(&second_dest).await.unwrap(), b"second-v1\n");
530 assert_eq!(
531 fs::read_to_string(app_dir.join("second.runs"))
532 .await
533 .unwrap(),
534 "x",
535 "single-file refresh must not run other generators"
536 );
537 fs::remove_dir_all(root).await.unwrap();
538 }
539
540 #[tokio::test]
541 async fn refresh_keeps_last_good_file_and_continues_after_generator_failure() {
542 let root = crate::test_support::make_temp_dir("shine-refresh").await;
543 let config = write_fixture(&root, true).await;
544 handle_install(&config, Some("sample"), false, false)
545 .await
546 .unwrap();
547 let app_dir = config.presets_dir().join("app/sample");
548 let first_dest = root.join("dest/first.txt");
549 let second_dest = root.join("dest/second.txt");
550 fs::write(app_dir.join("first.sh"), b"#!/bin/sh\nexit 1\n")
551 .await
552 .unwrap();
553 fs::write(app_dir.join("second.payload"), b"second-v2\n")
554 .await
555 .unwrap();
556 crate::trust::grant_current_for_test(&config, "app/sample").await;
557
558 assert!(
559 handle_refresh(&config, "sample", None, false)
560 .await
561 .is_err()
562 );
563 assert_eq!(
564 fs::read(&first_dest).await.unwrap(),
565 b"first-v1\n",
566 "failed generator must retain the last-known-good file"
567 );
568 assert_eq!(
569 fs::read(&second_dest).await.unwrap(),
570 b"second-v2\n",
571 "a failed generator must not prevent later selected files refreshing"
572 );
573 fs::remove_dir_all(root).await.unwrap();
574 }
575
576 #[tokio::test]
577 async fn refresh_requires_the_generator_condition_env() {
578 let root = crate::test_support::make_temp_dir("shine-refresh").await;
579 let mut config = write_fixture(&root, false).await;
580 handle_install(&config, Some("sample"), false, false)
581 .await
582 .unwrap();
583 config.env.remove("SOURCE_URL");
584 let dest = root.join("dest/first.txt");
585
586 assert!(
587 handle_refresh(&config, "sample", Some("first.txt"), false)
588 .await
589 .is_err()
590 );
591 assert_eq!(fs::read(&dest).await.unwrap(), b"first-v1\n");
592 fs::remove_dir_all(root).await.unwrap();
593 }
594}