1use anyhow::{Context, Result, bail};
2use std::collections::BTreeSet;
3
4use crate::colors;
5use crate::config::Config;
6use crate::env::EnvConfig;
7
8use super::commands::current_unix_timestamp;
9use super::detect::detect_os_id;
10use super::execution::print_item_outcome;
11use super::manifest::load_sys_preset;
12use super::resources::{self, SystemDriver};
13use super::run_manifest::{SysRunEntry, SysRunManifest};
14use super::{
15 SysDriverKind, SysInstalledRow, SysItem, SysItemMode, SysItemOutcome, SysItemStatus,
16 SysUpdateRow, SysUpgradeReport,
17};
18
19#[derive(Clone, Copy, Debug, PartialEq, Eq)]
20pub(super) enum SysAction {
21 Apply,
22 Remove,
23}
24
25#[derive(Clone, Copy, Debug, PartialEq, Eq)]
26enum ManagedOutputMode {
27 Explicit,
28 Upgrade { verbose: bool },
29}
30
31impl ManagedOutputMode {
32 fn is_explicit(self) -> bool {
33 matches!(self, Self::Explicit)
34 }
35
36 fn show_all_outcomes(self) -> bool {
37 matches!(self, Self::Explicit | Self::Upgrade { verbose: true })
38 }
39}
40
41pub async fn handle_apply(config: &Config, item: Option<&str>, dry_run: bool) -> Result<()> {
42 let report = run_managed(
43 config,
44 item,
45 SysAction::Apply,
46 dry_run,
47 ManagedOutputMode::Explicit,
48 None,
49 )
50 .await?;
51 if report.failed > 0 {
52 bail!(
53 "{} managed system configuration item(s) failed",
54 report.failed
55 );
56 }
57 Ok(())
58}
59
60pub async fn handle_uninstall(config: &Config, item: &str, dry_run: bool) -> Result<()> {
61 let report = run_managed(
62 config,
63 Some(item),
64 SysAction::Remove,
65 dry_run,
66 ManagedOutputMode::Explicit,
67 None,
68 )
69 .await?;
70 if report.failed > 0 {
71 bail!("failed to remove managed system configuration `{item}`");
72 }
73 Ok(())
74}
75
76pub async fn handle_upgrade_managed(
77 config: &Config,
78 verbose: bool,
79 sep: &mut crate::output::SectionSeparator,
80) -> Result<SysUpgradeReport> {
81 let mut report = handle_upgrade_managed_target(config, None, verbose, sep).await?;
82 if let Some(outcome) = super::profile_commands::sync_composed_profile(config).await? {
83 let changed = matches!(
84 outcome.status,
85 SysItemStatus::Updated | SysItemStatus::NeedsAction
86 );
87 if changed || verbose {
88 sep.begin();
89 println!("{}", colors::bold("System Shell Profile"));
90 print_item_outcome(&outcome, 14);
91 }
92 if changed {
93 report.updated += 1;
94 } else {
95 report.skipped += 1;
96 }
97 }
98 Ok(report)
99}
100
101pub async fn handle_upgrade_managed_target(
102 config: &Config,
103 item: Option<&str>,
104 verbose: bool,
105 sep: &mut crate::output::SectionSeparator,
106) -> Result<SysUpgradeReport> {
107 run_managed(
108 config,
109 item,
110 SysAction::Apply,
111 false,
112 ManagedOutputMode::Upgrade { verbose },
113 Some(sep),
114 )
115 .await
116}
117
118pub async fn managed_updates(config: &Config) -> Result<Vec<SysUpdateRow>> {
119 let os_id = detect_os_id().await?;
120 managed_updates_for_os(config, &os_id).await
121}
122
123pub(crate) async fn installed_managed(config: &Config) -> Result<Vec<SysInstalledRow>> {
124 let os_id = detect_os_id().await?;
125 installed_managed_for_os(config, &os_id).await
126}
127
128async fn installed_managed_for_os(config: &Config, os_id: &str) -> Result<Vec<SysInstalledRow>> {
129 let run_manifest = SysRunManifest::load(config.shine_dir()).await?;
130 let mut rows = run_manifest
131 .entries
132 .into_iter()
133 .filter(|entry| entry.os_id == os_id && entry.managed)
134 .map(|entry| SysInstalledRow {
135 item_id: entry.item_id,
136 label: entry.label,
137 })
138 .collect::<Vec<_>>();
139 rows.sort_by(|left, right| {
140 left.label
141 .cmp(&right.label)
142 .then_with(|| left.item_id.cmp(&right.item_id))
143 });
144 Ok(rows)
145}
146
147async fn managed_updates_for_os(config: &Config, os_id: &str) -> Result<Vec<SysUpdateRow>> {
148 let run_manifest = SysRunManifest::load(config.shine_dir()).await?;
149 let recorded = run_manifest
150 .entries
151 .iter()
152 .filter(|entry| entry.os_id == os_id && entry.managed)
153 .collect::<Vec<_>>();
154 if recorded.is_empty() {
155 return Ok(Vec::new());
156 }
157
158 let loaded = load_sys_preset(config, os_id).await?;
159 let env = EnvConfig::load_or_init(config).await?;
160 let preset_root = &loaded.root;
161 let mut updates = Vec::new();
162
163 for entry in recorded {
164 let Some(item) = loaded.manifest.items.iter().find(|item| {
165 item.id == entry.item_id
166 && item.mode == SysItemMode::Managed
167 && item.driver != SysDriverKind::Script
168 }) else {
169 continue;
170 };
171 let context = resources::DriverContext {
172 config,
173 os_id,
174 item,
175 preset_root,
176 env: env.as_map(),
177 dry_run: true,
178 };
179 let details = resources::BuiltinDriver::new(item.driver)
180 .update_details(&context, entry.receipt.as_ref())?;
181 if !details.is_empty() {
182 updates.push(SysUpdateRow {
183 item_id: item.id.clone(),
184 label: item.label.clone(),
185 details,
186 });
187 }
188 }
189
190 Ok(updates)
191}
192
193async fn run_managed(
194 config: &Config,
195 requested: Option<&str>,
196 action: SysAction,
197 dry_run: bool,
198 output_mode: ManagedOutputMode,
199 sep: Option<&mut crate::output::SectionSeparator>,
200) -> Result<SysUpgradeReport> {
201 let os_id = detect_os_id().await?;
202 run_managed_for_os(config, &os_id, requested, action, dry_run, output_mode, sep).await
203}
204
205async fn run_managed_for_os(
206 config: &Config,
207 os_id: &str,
208 requested: Option<&str>,
209 action: SysAction,
210 dry_run: bool,
211 output_mode: ManagedOutputMode,
212 mut sep: Option<&mut crate::output::SectionSeparator>,
213) -> Result<SysUpgradeReport> {
214 let mut run_manifest = SysRunManifest::load(config.shine_dir()).await?;
215
216 if action == SysAction::Remove
219 && let Some(item_id) = requested
220 && let Some(entry) = run_manifest
221 .entries
222 .iter()
223 .find(|entry| entry.os_id == os_id && entry.item_id == item_id)
224 .cloned()
225 && let Some(receipt) = entry.receipt.as_ref()
226 && receipt.driver() != SysDriverKind::Script
227 {
228 if let Some(sep) = &mut sep {
229 sep.begin();
230 }
231 println!("{}", colors::bold("Remove Managed System Config"));
232 println!(" {} {}", colors::symbol("•"), entry.label);
233 if receipt.requires_admin() && !dry_run && !authorize_admin(1).await? {
234 return Ok(SysUpgradeReport {
235 failed: 1,
236 ..SysUpgradeReport::default()
237 });
238 }
239 let driver = resources::BuiltinDriver::new(receipt.driver());
240 match driver.remove(None, receipt, dry_run).await {
241 Ok(outcome) => {
242 let status = if dry_run || !outcome.changed {
243 SysItemStatus::Skipped
244 } else {
245 SysItemStatus::Updated
246 };
247 print_item_outcome(
248 &SysItemOutcome {
249 item_id: item_id.to_string(),
250 label: entry.label,
251 status,
252 detail: outcome.detail,
253 logs: Vec::new(),
254 },
255 14,
256 );
257 if !dry_run {
258 run_manifest.entries.retain(|candidate| {
259 !(candidate.os_id == os_id && candidate.item_id == item_id)
260 });
261 run_manifest.save(config.shine_dir()).await?;
262 }
263 return Ok(SysUpgradeReport {
264 updated: usize::from(!dry_run && outcome.changed),
265 skipped: usize::from(dry_run || !outcome.changed),
266 failed: 0,
267 });
268 }
269 Err(error) => {
270 print_item_outcome(
271 &SysItemOutcome {
272 item_id: item_id.to_string(),
273 label: entry.label,
274 status: SysItemStatus::Failed,
275 detail: format!("{error:#}"),
276 logs: Vec::new(),
277 },
278 14,
279 );
280 return Ok(SysUpgradeReport {
281 failed: 1,
282 ..SysUpgradeReport::default()
283 });
284 }
285 }
286 }
287
288 if requested.is_none()
289 && !run_manifest
290 .entries
291 .iter()
292 .any(|entry| entry.os_id == os_id && entry.managed)
293 {
294 return Ok(SysUpgradeReport::default());
295 }
296 let loaded = load_sys_preset(config, os_id).await?;
297 let mut selected: Vec<&SysItem> = Vec::new();
298
299 if let Some(item_id) = requested {
300 let item = loaded
301 .manifest
302 .items
303 .iter()
304 .find(|candidate| candidate.id == item_id)
305 .with_context(|| format!("unknown sys item `{item_id}`"))?;
306 if item.mode != SysItemMode::Managed {
307 bail!("sys item `{item_id}` is not managed and cannot be reapplied");
308 }
309 selected.push(item);
310 } else {
311 let enabled: BTreeSet<&str> = run_manifest
312 .entries
313 .iter()
314 .filter(|entry| entry.os_id == os_id && entry.managed)
315 .map(|entry| entry.item_id.as_str())
316 .collect();
317 selected.extend(loaded.manifest.items.iter().filter(|item| {
318 item.mode == SysItemMode::Managed && enabled.contains(item.id.as_str())
319 }));
320 }
321
322 if selected.is_empty() {
323 if output_mode.is_explicit() {
324 println!(
325 "{}",
326 colors::dim("No managed system configuration items selected.")
327 );
328 }
329 return Ok(SysUpgradeReport::default());
330 }
331
332 let show_all_outcomes = output_mode.show_all_outcomes();
333 let mut section_started = false;
334 if show_all_outcomes {
335 begin_managed_section(&mut sep, action, &selected, true);
336 section_started = true;
337 }
338
339 let env = EnvConfig::load_or_init(config).await?;
340 let script_dir = &loaded.root;
341
342 if dry_run {
343 let mut failed = 0usize;
344 for item in &selected {
345 let missing = item
346 .required_env
347 .iter()
348 .filter(|key| env.get(key).is_none_or(str::is_empty))
349 .cloned()
350 .collect::<Vec<_>>();
351 if !missing.is_empty() {
352 failed += 1;
353 eprintln!(
354 " {} {}: missing environment variable(s): {}",
355 colors::symbol("✗"),
356 item.id,
357 missing.join(", ")
358 );
359 continue;
360 }
361 let context = resources::DriverContext {
362 config,
363 os_id,
364 item,
365 preset_root: script_dir,
366 env: env.as_map(),
367 dry_run: true,
368 };
369 match resources::BuiltinDriver::new(item.driver)
370 .plan(&context, action == SysAction::Remove)
371 {
372 Ok(plan) => println!(
373 " {} {}{}{}",
374 colors::dim("[dry-run]"),
375 plan.description,
376 if plan.requires_admin { " [admin]" } else { "" },
377 plan.restart_hint
378 .as_deref()
379 .map(|hint| format!(" [restart required: {hint}]"))
380 .unwrap_or_default()
381 ),
382 Err(error) => {
383 failed += 1;
384 eprintln!(" {} {}: {error:#}", colors::symbol_stderr("✗"), item.id);
385 }
386 }
387 }
388 return Ok(SysUpgradeReport {
389 skipped: selected.len() - failed,
390 failed,
391 ..SysUpgradeReport::default()
392 });
393 }
394
395 let mut needs_admin = false;
396 for item in &selected {
397 if !item.requires_admin {
398 continue;
399 }
400 if action != SysAction::Apply {
401 needs_admin = true;
402 break;
403 }
404 let previous_receipt = run_manifest
405 .entries
406 .iter()
407 .find(|entry| entry.os_id == os_id && entry.item_id == item.id)
408 .and_then(|entry| entry.receipt.as_ref());
409 let context = resources::DriverContext {
410 config,
411 os_id,
412 item,
413 preset_root: script_dir,
414 env: env.as_map(),
415 dry_run: false,
416 };
417 let up_to_date = resources::BuiltinDriver::new(item.driver)
418 .is_up_to_date(&context, previous_receipt)
419 .await
420 .unwrap_or(false);
421 if !up_to_date {
422 needs_admin = true;
423 break;
424 }
425 }
426 if needs_admin {
427 if !section_started {
428 begin_managed_section(&mut sep, action, &selected, false);
429 section_started = true;
430 }
431 if !authorize_admin(selected.len()).await? {
432 return Ok(SysUpgradeReport {
433 failed: selected.len(),
434 ..SysUpgradeReport::default()
435 });
436 }
437 }
438
439 let mut report = SysUpgradeReport::default();
440
441 for item in &selected {
442 let missing = item
443 .required_env
444 .iter()
445 .filter(|key| env.get(key).is_none_or(str::is_empty))
446 .cloned()
447 .collect::<Vec<_>>();
448 if !missing.is_empty() {
449 let outcome = SysItemOutcome {
450 item_id: item.id.clone(),
451 label: item.label.clone(),
452 status: SysItemStatus::Failed,
453 detail: format!("missing environment variable(s): {}", missing.join(", ")),
454 logs: Vec::new(),
455 };
456 if !section_started {
457 begin_managed_section(&mut sep, action, &selected, false);
458 section_started = true;
459 }
460 print_item_outcome(&outcome, item.label.len().max(14));
461 report.failed += 1;
462 continue;
463 }
464
465 let previous_receipt = run_manifest
466 .entries
467 .iter()
468 .find(|entry| entry.os_id == os_id && entry.item_id == item.id)
469 .and_then(|entry| entry.receipt.as_ref());
470
471 let mut next_receipt = None;
472 let mut restart_hint = None;
473 let context = resources::DriverContext {
474 config,
475 os_id,
476 item,
477 preset_root: script_dir,
478 env: env.as_map(),
479 dry_run: false,
480 };
481 let driver = resources::BuiltinDriver::new(item.driver);
482 let result = match action {
483 SysAction::Apply => driver.apply(&context, previous_receipt).await,
484 SysAction::Remove => match previous_receipt {
485 Some(receipt) => driver.remove(Some(&context), receipt, false).await,
486 None => Err(anyhow::anyhow!(
487 "managed item `{}` has no receipt to remove",
488 item.id
489 )),
490 },
491 };
492 let outcome = match result {
493 Ok(resource) => {
494 next_receipt = resource.receipt;
495 restart_hint = resource.restart_hint;
496 SysItemOutcome {
497 item_id: item.id.clone(),
498 label: item.label.clone(),
499 status: if resource.changed {
500 SysItemStatus::Updated
501 } else {
502 SysItemStatus::AlreadyInstalled
503 },
504 detail: resource.detail,
505 logs: Vec::new(),
506 }
507 }
508 Err(error) => SysItemOutcome {
509 item_id: item.id.clone(),
510 label: item.label.clone(),
511 status: SysItemStatus::Failed,
512 detail: format!("{error:#}"),
513 logs: Vec::new(),
514 },
515 };
516 if should_print_managed_outcome(show_all_outcomes, outcome.status) {
517 if !section_started {
518 begin_managed_section(&mut sep, action, &selected, false);
519 section_started = true;
520 }
521 print_item_outcome(&outcome, item.label.len().max(14));
522 if let Some(hint) = restart_hint
523 && outcome.status != SysItemStatus::Failed
524 {
525 println!(" {} {}", colors::symbol("!"), colors::yellow(&hint));
526 }
527 }
528 if outcome.status == SysItemStatus::Failed {
529 report.failed += 1;
530 continue;
531 }
532
533 match outcome.status {
534 SysItemStatus::Updated | SysItemStatus::Installed | SysItemStatus::Completed => {
535 report.updated += 1;
536 }
537 _ => report.skipped += 1,
538 }
539
540 if action == SysAction::Remove {
541 run_manifest
542 .entries
543 .retain(|entry| !(entry.os_id == os_id && entry.item_id == item.id));
544 } else {
545 run_manifest.upsert(SysRunEntry {
546 os_id: os_id.to_string(),
547 item_id: item.id.clone(),
548 label: item.label.clone(),
549 status: outcome.status,
550 detail: outcome.detail.clone(),
551 updated_at: current_unix_timestamp().to_string(),
552 managed: true,
553 profile_enabled: false,
554 receipt: next_receipt,
555 });
556 }
557 }
558
559 run_manifest.save(config.shine_dir()).await?;
560 Ok(report)
561}
562
563async fn authorize_admin(item_count: usize) -> Result<bool> {
564 crate::privilege::ensure_admin(item_count).await
565}
566
567fn begin_managed_section(
568 sep: &mut Option<&mut crate::output::SectionSeparator>,
569 action: SysAction,
570 selected: &[&SysItem],
571 show_selection: bool,
572) {
573 if let Some(sep) = sep {
574 sep.begin();
575 }
576 println!(
577 "{}",
578 colors::bold(match action {
579 SysAction::Apply => "Managed System Configs",
580 SysAction::Remove => "Remove Managed System Config",
581 })
582 );
583 if show_selection {
584 for item in selected {
585 println!(" {} {}", colors::symbol("•"), item.label);
586 }
587 }
588}
589
590fn should_print_managed_outcome(show_all: bool, status: SysItemStatus) -> bool {
591 show_all
592 || !matches!(
593 status,
594 SysItemStatus::AlreadyInstalled | SysItemStatus::Skipped
595 )
596}
597
598#[cfg(test)]
599mod tests {
600 use super::*;
601 use crate::config::Config;
602 use crate::sys::run_manifest::SYS_MANIFEST_FILE;
603 use std::path::PathBuf;
604 use tokio::fs;
605
606 async fn make_temp_dir() -> PathBuf {
607 crate::test_support::make_temp_dir("shine-sys").await
608 }
609
610 #[cfg(any())]
611 #[tokio::test]
612 async fn managed_item_apply_upgrade_and_uninstall_lifecycle() {
613 let dir = make_temp_dir().await;
614 let os_dir = dir.join("presets/sys/fakeos");
615 fs::create_dir_all(&os_dir).await.unwrap();
616 fs::write(
617 os_dir.join("shine.toml"),
618 r#"
619version = 2
620
621description = "Managed test"
622
623[[items]]
624id = "managed-test"
625label = "Managed test"
626mode = "managed"
627required_env = ["ACTION_LOG"]
628"#,
629 )
630 .await
631 .unwrap();
632 fs::write(
633 os_dir.join("init.sh"),
634 r#"#!/bin/bash
635set -eu
636printf '%s\n' "$2" >> "$ACTION_LOG"
637printf 'SHINE_SYS_STATUS\t%s\t%s\n' "updated" "$2"
638"#,
639 )
640 .await
641 .unwrap();
642
643 let action_log = dir.join("actions");
644 let mut config = Config::new_for_test(&dir);
645 config.is_external_presets = true;
646 config.allow_sys_code = true;
647 config
648 .env
649 .insert("ACTION_LOG".to_string(), action_log.display().to_string());
650
651 run_managed_for_os(
652 &config,
653 "fakeos",
654 Some("managed-test"),
655 SysAction::Apply,
656 false,
657 ManagedOutputMode::Explicit,
658 None,
659 )
660 .await
661 .unwrap();
662 let first_manifest = SysRunManifest::load(config.shine_dir()).await.unwrap();
663 assert!(
664 first_manifest
665 .entries
666 .iter()
667 .any(|entry| { entry.item_id == "managed-test" && entry.managed })
668 );
669
670 let report = run_managed_for_os(
671 &config,
672 "fakeos",
673 None,
674 SysAction::Apply,
675 false,
676 ManagedOutputMode::Upgrade { verbose: false },
677 None,
678 )
679 .await
680 .unwrap();
681 assert_eq!(report.updated, 1);
682 run_managed_for_os(
683 &config,
684 "fakeos",
685 Some("managed-test"),
686 SysAction::Remove,
687 false,
688 ManagedOutputMode::Explicit,
689 None,
690 )
691 .await
692 .unwrap();
693
694 let actions = fs::read_to_string(&action_log).await.unwrap();
695 assert_eq!(
696 actions.lines().collect::<Vec<_>>(),
697 ["apply", "apply", "remove"]
698 );
699 let final_manifest = SysRunManifest::load(config.shine_dir()).await.unwrap();
700 assert!(final_manifest.entries.is_empty());
701
702 fs::remove_dir_all(&dir).await.unwrap();
703 }
704
705 #[tokio::test]
706 async fn managed_updates_reports_split_dns_env_change() {
707 let dir = make_temp_dir().await;
708 let os_dir = dir.join("presets/sys/macos");
709 fs::create_dir_all(&os_dir).await.unwrap();
710 fs::write(
711 os_dir.join("shine.toml"),
712 r#"
713version = 2
714
715description = "Managed test"
716
717[[items]]
718id = "split-dns"
719label = "Private split DNS"
720mode = "managed"
721driver = "split-dns"
722requires_admin = true
723required_env = ["PRIVATE_DNS_DOMAIN", "PRIVATE_DNS_SERVERS"]
724
725[items.config]
726domain_env = "PRIVATE_DNS_DOMAIN"
727servers_env = "PRIVATE_DNS_SERVERS"
728"#,
729 )
730 .await
731 .unwrap();
732 fs::write(os_dir.join("init.sh"), "#!/bin/bash\n")
733 .await
734 .unwrap();
735 fs::write(
736 dir.join(SYS_MANIFEST_FILE),
737 r#"
738[[entries]]
739os_id = "macos"
740item_id = "split-dns"
741label = "Private split DNS"
742status = "updated"
743updated_at = "1"
744managed = true
745
746[entries.receipt]
747driver = "split-dns"
748version = 1
749os_id = "macos"
750item_id = "split-dns"
751domain = "private.example"
752servers = ["10.0.0.2"]
753resource = "/etc/resolver/private.example"
754"#,
755 )
756 .await
757 .unwrap();
758
759 let mut config = Config::new_for_test(&dir);
760 config.is_external_presets = true;
761 config.env.insert(
762 "PRIVATE_DNS_DOMAIN".to_string(),
763 "private.example".to_string(),
764 );
765 config
766 .env
767 .insert("PRIVATE_DNS_SERVERS".to_string(), "10.0.0.3".to_string());
768
769 let updates = managed_updates_for_os(&config, "macos").await.unwrap();
770
771 assert_eq!(updates.len(), 1);
772 assert_eq!(updates[0].item_id, "split-dns");
773 assert_eq!(updates[0].details, ["Servers: 10.0.0.2 -> 10.0.0.3"]);
774
775 fs::remove_dir_all(&dir).await.unwrap();
776 }
777
778 #[tokio::test]
779 async fn installed_managed_lists_only_current_os_managed_entries() {
780 let dir = make_temp_dir().await;
781 fs::write(
782 dir.join(SYS_MANIFEST_FILE),
783 r#"
784[[entries]]
785os_id = "macos"
786item_id = "split-dns"
787label = "Private split DNS"
788status = "already-installed"
789updated_at = "1"
790managed = true
791
792[[entries]]
793os_id = "macos"
794item_id = "homebrew"
795label = "Homebrew"
796status = "installed"
797updated_at = "1"
798
799[[entries]]
800os_id = "ubuntu"
801item_id = "split-dns"
802label = "Ubuntu split DNS"
803status = "installed"
804updated_at = "1"
805managed = true
806"#,
807 )
808 .await
809 .unwrap();
810 let config = Config::new_for_test(&dir);
811
812 let rows = installed_managed_for_os(&config, "macos").await.unwrap();
813
814 assert_eq!(
815 rows,
816 [SysInstalledRow {
817 item_id: "split-dns".to_string(),
818 label: "Private split DNS".to_string(),
819 }]
820 );
821 fs::remove_dir_all(&dir).await.unwrap();
822 }
823
824 #[tokio::test]
825 async fn builtin_receipt_uninstalls_after_preset_is_deleted() {
826 let dir = make_temp_dir().await;
827 let os_dir = dir.join("presets/sys/fakeos");
828 fs::create_dir_all(&os_dir).await.unwrap();
829 let destination = dir.join("managed-output.txt");
830 fs::write(os_dir.join("desired.txt"), "managed")
831 .await
832 .unwrap();
833 fs::write(os_dir.join("init.sh"), "#!/bin/bash\n")
834 .await
835 .unwrap();
836 fs::write(
837 os_dir.join("shine.toml"),
838 format!(
839 r#"
840version = 2
841
842description = "Managed resource test"
843
844[[items]]
845id = "managed-file-test"
846label = "Managed file test"
847mode = "managed"
848driver = "managed-file"
849
850[items.config]
851source = "desired.txt"
852target = {:?}
853"#,
854 destination.display().to_string()
855 ),
856 )
857 .await
858 .unwrap();
859
860 let mut config = Config::new_for_test(&dir);
861 config.is_external_presets = true;
862 let applied = run_managed_for_os(
863 &config,
864 "fakeos",
865 Some("managed-file-test"),
866 SysAction::Apply,
867 false,
868 ManagedOutputMode::Explicit,
869 None,
870 )
871 .await
872 .unwrap();
873 assert_eq!(applied.updated, 1);
874 assert_eq!(fs::read_to_string(&destination).await.unwrap(), "managed");
875
876 let unchanged = run_managed_for_os(
877 &config,
878 "fakeos",
879 None,
880 SysAction::Apply,
881 false,
882 ManagedOutputMode::Upgrade { verbose: false },
883 None,
884 )
885 .await
886 .unwrap();
887 assert_eq!(unchanged.updated, 0);
888 assert_eq!(unchanged.skipped, 1);
889
890 fs::remove_dir_all(&os_dir).await.unwrap();
891 let removed = run_managed_for_os(
892 &config,
893 "fakeos",
894 Some("managed-file-test"),
895 SysAction::Remove,
896 false,
897 ManagedOutputMode::Explicit,
898 None,
899 )
900 .await
901 .unwrap();
902 assert_eq!(removed.updated, 1);
903 assert!(!destination.exists());
904 assert!(
905 SysRunManifest::load(config.shine_dir())
906 .await
907 .unwrap()
908 .entries
909 .is_empty()
910 );
911
912 fs::remove_dir_all(&dir).await.unwrap();
913 }
914
915 #[test]
916 fn managed_upgrade_output_hides_only_no_op_rows_by_default() {
917 assert!(!should_print_managed_outcome(
918 false,
919 SysItemStatus::AlreadyInstalled
920 ));
921 assert!(!should_print_managed_outcome(false, SysItemStatus::Skipped));
922
923 for status in [
924 SysItemStatus::Installed,
925 SysItemStatus::Updated,
926 SysItemStatus::NeedsAction,
927 SysItemStatus::Completed,
928 SysItemStatus::Failed,
929 ] {
930 assert!(should_print_managed_outcome(false, status), "{status:?}");
931 }
932 }
933
934 #[test]
935 fn managed_upgrade_verbose_output_includes_no_op_rows() {
936 assert!(should_print_managed_outcome(
937 true,
938 SysItemStatus::AlreadyInstalled
939 ));
940 assert!(should_print_managed_outcome(true, SysItemStatus::Skipped));
941 }
942}