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