shine-cli 1.1.1

Cross-platform CLI for managed shell commands, app configs, and machine setup
Documentation
mod collect;
mod render;
mod resolve;

use crate::config::Config;
use crate::status::FileStatus;
use crate::{apps, colors, path_display, shells};
use anyhow::{Result, bail};
use resolve::InfoRef;

pub(crate) struct UpdateDiffs {
    app_files: Vec<collect::AppInfoFile>,
    shell_files: Vec<collect::ShellInfoFile>,
}

impl UpdateDiffs {
    pub(crate) async fn collect(config: &Config) -> Result<Self> {
        Ok(Self {
            app_files: collect::collect_app_files(config).await?,
            shell_files: collect::collect_shell_files(config).await?,
        })
    }

    pub(crate) async fn print_shell_for_row(&self, config: &Config, label: &str) -> Result<()> {
        for file in self.shell_files.iter().filter(|file| {
            file.status == "update available"
                && format!("{}/{}", file.category.name, file.file.command_name) == label
        }) {
            render::print_shell_update_diff(config, file).await?;
        }
        Ok(())
    }

    pub(crate) async fn print_app_for_row(&self, config: &Config, label: &str) -> Result<()> {
        for file in self.app_files.iter().filter(|file| {
            if file.status != FileStatus::UpdateAvail {
                return false;
            }
            if file.category.has_explicit_files
                && file.category.list_mode == crate::apps::AppListMode::Files
            {
                app_file_label(file) == label
            } else {
                file.category.name == label
            }
        }) {
            render::print_app_update_diff(config, file).await?;
        }
        Ok(())
    }
}

fn app_file_label(file: &collect::AppInfoFile) -> String {
    file.file
        .display_name
        .clone()
        .unwrap_or_else(|| format!("{}/{}", file.category.name, file.file.source_rel.display()))
}

pub async fn handle_update_target(config: &Config, target: &str) -> Result<()> {
    crate::config::print_presets_note(config);
    let diffs = UpdateDiffs::collect(config).await?;

    if diffs.app_files.is_empty() && diffs.shell_files.is_empty() {
        bail!("nothing installed yet. Run `shine shell install` or `shine app install`.");
    }

    let candidates = resolve::build_candidates(&diffs.app_files, &diffs.shell_files);
    let refs = resolve::resolve_target(target, &candidates)?;
    let mut printed = false;

    for item in refs {
        match item {
            InfoRef::AppCategory(category) => {
                let mut files = diffs
                    .app_files
                    .iter()
                    .filter(|file| {
                        file.category.name == category && file.status == FileStatus::UpdateAvail
                    })
                    .collect::<Vec<_>>();
                files.sort_by_key(|file| file.file.source_rel.clone());
                for file in files {
                    print_update_separator(printed);
                    print_app_update_row(config, file);
                    render::print_app_update_diff(config, file).await?;
                    printed = true;
                }
            }
            InfoRef::AppFile { category, source } => {
                if let Some(file) = diffs.app_files.iter().find(|file| {
                    file.category.name == category
                        && file.file.source_rel == source
                        && file.status == FileStatus::UpdateAvail
                }) {
                    print_app_update_row(config, file);
                    render::print_app_update_diff(config, file).await?;
                    printed = true;
                }
            }
            InfoRef::ShellCategory(category) => {
                let mut files = diffs
                    .shell_files
                    .iter()
                    .filter(|file| {
                        file.category.name == category && file.status == "update available"
                    })
                    .collect::<Vec<_>>();
                files.sort_by_key(|file| file.file.command_name.clone());
                for file in files {
                    print_update_separator(printed);
                    print_shell_update_row(file);
                    render::print_shell_update_diff(config, file).await?;
                    printed = true;
                }
            }
            InfoRef::ShellFile { category, command } => {
                if let Some(file) = diffs.shell_files.iter().find(|file| {
                    file.category.name == category
                        && file.file.command_name == command
                        && file.status == "update available"
                }) {
                    print_shell_update_row(file);
                    render::print_shell_update_diff(config, file).await?;
                    printed = true;
                }
            }
        }
    }

    if !printed {
        println!(
            "{}",
            colors::dim(&format!("No update available for {target}."))
        );
    }

    Ok(())
}

fn print_update_separator(printed: bool) {
    if printed {
        println!();
    }
}

fn print_shell_update_row(file: &collect::ShellInfoFile) {
    println!(
        "  {}  {}/{}  {}",
        colors::symbol("↑"),
        file.category.name,
        file.file.command_name,
        colors::status_label("update available", "↑"),
    );
}

fn print_app_update_row(config: &Config, file: &collect::AppInfoFile) {
    println!(
        "  {}  {}  {}  {}  {}",
        colors::symbol("↑"),
        app_file_label(file),
        colors::dim("→"),
        colors::dim(&path_display::format_home(
            &file.destination,
            &config.home_dir
        )),
        colors::status_label("update available", "↑"),
    );
}

pub async fn handle_info(config: &Config, target: &str, diff: bool, verbose: bool) -> Result<()> {
    let app_files = collect::collect_app_files(config).await?;
    let shell_files = collect::collect_shell_files(config).await?;

    let candidates = resolve::build_candidates(&app_files, &shell_files);
    let refs = match resolve::resolve_target(target, &candidates) {
        Ok(refs) => refs,
        Err(installed_error) => {
            if diff || verbose {
                return Err(installed_error
                    .context("--diff and --verbose require an installed app or shell target"));
            }
            return handle_available_info(config, target, installed_error).await;
        }
    };

    crate::config::print_presets_note(config);

    let mut first = true;
    for item in refs {
        if !first {
            println!();
        }
        first = false;
        match item {
            InfoRef::AppCategory(category) => {
                let mut files: Vec<_> = app_files
                    .iter()
                    .filter(|f| f.category.name == category)
                    .cloned()
                    .collect();
                files.sort_by_key(|f| f.file.source_rel.clone());
                for (index, file) in files.iter().enumerate() {
                    if index > 0 {
                        println!();
                    }
                    render::print_app_file(config, file, diff, verbose).await?;
                }
            }
            InfoRef::AppFile { category, source } => {
                let file = app_files
                    .iter()
                    .find(|f| f.category.name == category && f.file.source_rel == source)
                    .ok_or_else(|| anyhow::anyhow!("installed app config not found"))?;
                render::print_app_file(config, file, diff, verbose).await?;
            }
            InfoRef::ShellCategory(category) => {
                let mut files: Vec<_> = shell_files
                    .iter()
                    .filter(|f| f.category.name == category)
                    .cloned()
                    .collect();
                files.sort_by_key(|f| f.file.command_name.clone());
                for (index, file) in files.iter().enumerate() {
                    if index > 0 {
                        println!();
                    }
                    render::print_shell_file(config, file, diff, verbose).await?;
                }
            }
            InfoRef::ShellFile { category, command } => {
                let file = shell_files
                    .iter()
                    .find(|f| f.category.name == category && f.file.command_name == command)
                    .ok_or_else(|| anyhow::anyhow!("installed shell preset not found"))?;
                render::print_shell_file(config, file, diff, verbose).await?;
            }
        }
    }

    Ok(())
}

async fn handle_available_info(
    config: &Config,
    target: &str,
    installed_error: anyhow::Error,
) -> Result<()> {
    let target = target.trim();
    if let Some(rest) = target.strip_prefix("app/") {
        let category = rest.split('/').next().unwrap_or_default();
        if category.is_empty() || rest.contains('/') {
            return Err(installed_error);
        }
        return Box::pin(apps::handle_info(config, category)).await;
    }
    if let Some(rest) = target.strip_prefix("shell/") {
        if rest.is_empty() {
            return Err(installed_error);
        }
        return Box::pin(shells::handle_info(config, rest)).await;
    }

    let app_matches = apps::load_active_categories(config, Some(target))
        .await?
        .into_iter()
        .any(|category| category.name == target);
    let shell_categories = shells::metadata::load_active_categories(config, None).await?;
    let shell_matches = shell_categories.iter().any(|category| {
        category.name == target
            || category
                .files
                .iter()
                .any(|file| file.command_name == target)
    });

    match (app_matches, shell_matches) {
        (true, false) => Box::pin(apps::handle_info(config, target)).await,
        (false, true) => Box::pin(shells::handle_info(config, target)).await,
        (true, true) => {
            bail!("ambiguous available target `{target}`; use `app/{target}` or `shell/{target}`")
        }
        (false, false) => Err(installed_error),
    }
}