shine-cli 2.0.0

Give personal automation a reviewable lifecycle
Documentation
use crate::apps::{AppCategory, AppFile};
use crate::config::Config;
use crate::env::EnvConfig;
use crate::shells::metadata::ShellCategory;
use crate::status::{FileStatus, UpdateChange};
use anyhow::Result;
use shine_core::runtime::NullObserver;
use std::path::PathBuf;

#[derive(Clone)]
pub(crate) struct AppInfoFile {
    pub(crate) category: AppCategory,
    pub(crate) file: AppFile,
    pub(crate) destination: PathBuf,
    pub(crate) status: FileStatus,
    pub(crate) manifest_entry: Option<crate::install_core::AppEntry>,
    pub(crate) desired_content: Option<Vec<u8>>,
    pub(crate) current_content: Option<Vec<u8>>,
    pub(crate) changes: Vec<UpdateChange>,
    pub(crate) assessment_error: Option<String>,
    pub(crate) assessment_diagnostic: Option<&'static str>,
}

#[derive(Clone)]
pub(super) struct ShellInfoFile {
    pub(super) category: ShellCategory,
    pub(super) file: crate::shells::metadata::ShellFile,
    pub(super) source_path: PathBuf,
    pub(super) installed_source_path: PathBuf,
    pub(super) rendered_path: PathBuf,
    pub(super) link_path: PathBuf,
    pub(super) link_target: Option<PathBuf>,
    pub(super) desired_content: Option<Vec<u8>>,
    pub(super) current_content: Option<Vec<u8>>,
    pub(super) status: &'static str,
    pub(super) changes: Vec<UpdateChange>,
}

pub(super) async fn collect_app_files(
    config: &Config,
    run_generators: bool,
    categories: Vec<String>,
) -> Result<Vec<AppInfoFile>> {
    let mut runtime = crate::core_runtime::from_config(config).await?;
    let env = EnvConfig::load_or_init(config).await.ok();
    if let Some(env) = env {
        runtime.context_mut_for_cli().env = env.as_map().clone();
    }
    let inspections = runtime
        .inspect_apps_with_options(
            shine_core::runtime::AppInspectionOptions {
                run_generators,
                categories,
            },
            &mut NullObserver,
        )
        .await?;
    Ok(app_info_files_from_inspections(inspections))
}

pub(super) fn app_info_files_from_inspections(
    inspections: Vec<shine_core::runtime::AppFileInspection>,
) -> Vec<AppInfoFile> {
    inspections
        .into_iter()
        .filter(|file| file.status != FileStatus::NotInstalled)
        .filter_map(app_info_file_from_inspection)
        .collect()
}

pub(crate) fn app_info_file_from_inspection(
    file: shine_core::runtime::AppFileInspection,
) -> Option<AppInfoFile> {
    Some(AppInfoFile {
        category: file.category,
        file: file.file,
        destination: file.destination?,
        status: file.status,
        manifest_entry: file.manifest_entry,
        desired_content: file.desired_content,
        current_content: file.current_content,
        changes: file.changes,
        assessment_error: file.assessment_error,
        assessment_diagnostic: file.assessment_diagnostic,
    })
}

pub(super) async fn collect_shell_files(config: &Config) -> Result<Vec<ShellInfoFile>> {
    let mut runtime = crate::core_runtime::from_config(config).await?;
    if let Ok(env) = EnvConfig::load_or_init(config).await {
        runtime.context_mut_for_cli().env = env.as_map().clone();
    }
    Ok(runtime
        .inspect_shells()
        .await?
        .into_iter()
        .filter(|file| file.installed)
        .map(|file| ShellInfoFile {
            category: file.category,
            file: file.file,
            source_path: file.source_path,
            installed_source_path: file.installed_source_path,
            rendered_path: file.rendered_path,
            link_path: file.link_path,
            link_target: file.link_target,
            desired_content: file.desired_content,
            current_content: file.current_content,
            status: file.status_text,
            changes: file.changes,
        })
        .collect())
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::shells::handle_install;
    use tokio::fs;

    #[cfg(windows)]
    #[tokio::test]
    async fn renamed_docker_category_has_no_legacy_alias() {
        let dir = std::env::temp_dir().join(format!("shine-info-{}", uuid::Uuid::new_v4()));
        tokio::fs::create_dir_all(&dir).await.unwrap();

        let config = Config::new_for_test(&dir);
        tokio::fs::create_dir_all(config.shine_dir()).await.unwrap();

        let files = collect_app_files(&config, false, Vec::new()).await.unwrap();
        assert!(files.iter().all(|file| file.category.name != "docker"));

        tokio::fs::remove_dir_all(&dir).await.unwrap();
    }

    #[tokio::test]
    async fn shell_info_excludes_uninstalled_snapshot_siblings() {
        let dir = crate::test_support::make_temp_dir("shine-info-shell").await;
        let mut config = Config::new_for_test(&dir);
        config.is_external_presets = true;
        let source_extension = if config.shell_type == crate::shells::ShellType::PowerShell {
            "ps1"
        } else {
            "sh"
        };
        let first_source = format!("one.{source_extension}");
        let second_source = format!("two.{source_extension}");
        let category = dir.join("presets/shell/custom");
        fs::create_dir_all(&category).await.unwrap();
        fs::write(
            category.join("shine.toml"),
            format!(
                "[[files]]\nsource = \"{first_source}\"\ntarget = \"one\"\npermissions = {{ schema_version = 1 }}\n\n[[files]]\nsource = \"{second_source}\"\ntarget = \"two\"\npermissions = {{ schema_version = 1 }}\n"
            ),
        )
        .await
        .unwrap();
        fs::write(category.join(first_source), b"echo one\n")
            .await
            .unwrap();
        fs::write(category.join(second_source), b"echo two\n")
            .await
            .unwrap();
        fs::create_dir_all(config.bin_dir()).await.unwrap();

        handle_install(&config, Some("custom/one"), false)
            .await
            .unwrap();

        let files = collect_shell_files(&config).await.unwrap();
        assert!(files.iter().any(|file| file.file.command_name == "one"));
        assert!(files.iter().all(|file| file.file.command_name != "two"));

        fs::remove_dir_all(&dir).await.unwrap();
    }
}