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