Skip to main content

cli/sys/
managed.rs

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    // Built-in resources can be removed entirely from their recorded receipt,
217    // even when the originating preset no longer exists.
218    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}