shine-cli 1.3.0

Cross-platform CLI for managed shell commands, app configs, and machine setup
Documentation
use anyhow::{Context, Result, bail};
use console::style;
use dialoguer::MultiSelect;
use std::collections::{BTreeMap, BTreeSet};

use crate::colors;

use super::{
    ResolvedSelection, SelectionSource, SysItem, SysItemMode, SysManifest, sys_init_theme,
};

pub(super) fn resolve_selection(
    manifest: &SysManifest,
    preset: Option<&str>,
    interactive: bool,
) -> Result<ResolvedSelection> {
    if let Some(profile_name) = preset {
        return Ok(ResolvedSelection {
            item_ids: profile_items(manifest, profile_name)?.to_vec(),
            source: SelectionSource::Profile(profile_name.to_string()),
        });
    }

    if manifest.items.is_empty() {
        return Ok(ResolvedSelection {
            item_ids: Vec::new(),
            source: SelectionSource::NoItems,
        });
    }

    if interactive {
        return select_items_interactively(manifest);
    }

    let Some(default_profile) = manifest.default_profile.as_deref() else {
        bail!("sys bootstrap requires `default_profile` for non-interactive runs");
    };

    Ok(ResolvedSelection {
        item_ids: profile_items(manifest, default_profile)?.to_vec(),
        source: SelectionSource::DefaultProfile(default_profile.to_string()),
    })
}

fn profile_items<'a>(manifest: &'a SysManifest, profile_name: &str) -> Result<&'a [String]> {
    let profile = manifest
        .profiles
        .get(profile_name)
        .with_context(|| format!("unknown sys bootstrap profile `{profile_name}`"))?;
    Ok(&profile.items)
}

fn default_flags(manifest: &SysManifest) -> Vec<bool> {
    if let Some(default_profile) = manifest.default_profile.as_deref()
        && let Some(profile) = manifest.profiles.get(default_profile)
    {
        let item_set: BTreeSet<&str> = profile.items.iter().map(String::as_str).collect();
        return manifest
            .items
            .iter()
            .map(|item| item_set.contains(item.id.as_str()))
            .collect();
    }

    manifest.items.iter().map(|item| item.default).collect()
}

fn select_items_interactively(manifest: &SysManifest) -> Result<ResolvedSelection> {
    print_interactive_header(manifest);

    let init_items = manifest
        .items
        .iter()
        .filter(|item| item.mode == SysItemMode::Init)
        .collect::<Vec<_>>();
    let default_by_id = manifest
        .items
        .iter()
        .zip(default_flags(manifest))
        .map(|(item, selected)| (item.id.as_str(), selected))
        .collect::<BTreeMap<_, _>>();
    let labels: Vec<String> = init_items
        .iter()
        .map(|item| format_interactive_item(item))
        .collect();
    let defaults = init_items
        .iter()
        .map(|item| {
            default_by_id
                .get(item.id.as_str())
                .copied()
                .unwrap_or(false)
        })
        .collect::<Vec<_>>();

    let selection = MultiSelect::with_theme(&sys_init_theme())
        .with_prompt("Select system init items")
        .items(&labels)
        .defaults(&defaults)
        .report(false)
        .interact()?;

    let item_ids = selection
        .into_iter()
        .map(|index| init_items[index].id.clone())
        .collect();

    Ok(ResolvedSelection {
        item_ids,
        source: SelectionSource::Interactive,
    })
}

pub(super) fn format_interactive_item(item: &SysItem) -> String {
    let label = style(item.label.as_str()).for_stderr().bold().to_string();
    if item.description.is_empty() {
        return label;
    }

    let description = style(item.description.as_str())
        .for_stderr()
        .dim()
        .to_string();
    format!("{label}  ยท  {description}")
}

fn print_interactive_header(manifest: &SysManifest) {
    if let Some(default_profile) = manifest.default_profile.as_deref() {
        println!(
            "{}",
            colors::dim(&format!("Default profile: {default_profile}"))
        );
    }
    println!("{}", colors::dim("Use Space to toggle, Enter to confirm."));
    println!();
}

pub(super) fn format_item_ids(item_ids: &[String]) -> String {
    if item_ids.is_empty() {
        "(none)".to_string()
    } else {
        item_ids.join(", ")
    }
}