rpk 0.2.2

A lightweight, cross-platform cli package manager.
use std::{
    fs,
    process,
    sync::{
        atomic::{AtomicUsize, Ordering},
        Arc,
    },
};

use anyhow::{bail, Context as _, Result};
use inquire::{Select, Text};
use itertools::Itertools;
use tabled::{
    settings::{object::Rows, Color, Padding, Style},
    Table,
    Tabled,
};
use tracing::debug;
use url::Url;
use walkdir::WalkDir;
use yansi::Paint;

use crate::{
    commands,
    config::{Config, EditableConfig, LockedConfig, Package, Source},
    context::Context,
    manager::{restore_package, restore_packages, sync_package, sync_packages},
    provider::Github,
    util::{remove_file_if_exists, rm_rf, Emojify, Shorten as _},
};

pub fn init(ctx: &Context, from: Option<Url>) -> Result<()> {
    if ctx.config_file.exists() {
        bail!("config file already exists: {}", ctx.config_file.display());
    }

    remove_file_if_exists(&ctx.lock_file)
        .with_context(|| format!("failed to remove lock file {}", &ctx.lock_file.display(),))?;

    match from {
        Some(url) => {
            let body = ureq::get(url.as_str()).call()?.into_string()?;
            debug!("fetched config file: {}", body);
            // Parse and validate the downloaded config file.
            toml::from_str::<Config>(&body)?;
            fs::write(&ctx.config_file, body)?;
        }
        None => {
            Config::load(ctx)?;
        }
    }

    ctx.log_header("Initialized", ctx.config_file.shorten()?);
    sync(ctx, false)
}

pub fn list(ctx: &Context) -> Result<(), anyhow::Error> {
    let lcfg = LockedConfig::load(ctx)?;
    ctx.log_header_v("Loaded", ctx.lock_file.shorten()?);

    #[derive(Debug, Tabled)]
    #[tabled(rename_all = "UPPERCASE")]
    struct Item {
        pkg:         String,
        version:     String,
        description: String,
    }

    let items = lcfg
        .pkgs
        .into_values()
        .sorted_by(|a, b| a.name.cmp(&b.name))
        .map(|lpkg| Item {
            pkg:         lpkg.name,
            version:     lpkg.version,
            description: lpkg.desc.map(|s| s.emojify()).unwrap_or_default(),
        });

    let mut table = Table::new(items);
    table
        .with(Style::empty())
        .modify(Rows::first(), Color::BOLD)
        .with(Padding::new(0, 4, 0, 0));
    println!("{table}");

    Ok(())
}

pub fn add(ctx: &Context, mut pkg: Package) -> Result<()> {
    let mut ecfg = EditableConfig::load(ctx)?;
    ctx.log_header_v("Loaded", ctx.config_file.shorten()?);

    if ecfg.contains(&pkg.name) {
        bail!("package {} already exists", pkg.name.blue());
    }

    let lpkg = sync_package(ctx, &pkg, None, false)?;
    pkg.desc = lpkg.desc.clone();

    ecfg.upsert(&pkg)?;

    let mut lcfg = LockedConfig::load(ctx)?;
    lcfg.upsert(lpkg);

    ecfg.save()?;
    lcfg.save()?;
    ctx.log_header_v("Locked", ctx.lock_file.shorten()?);

    Ok(())
}

pub fn sync(ctx: &Context, update: bool) -> Result<(), anyhow::Error> {
    let cfg = Config::load(ctx)?;
    ctx.log_header_v("Loaded", ctx.config_file.shorten()?);
    let mut lcfg = LockedConfig::load(ctx)?;
    ctx.log_header_v("Loaded", ctx.lock_file.shorten()?);

    sync_packages(ctx, &cfg, &mut lcfg, update)?;

    lcfg.save()?;
    ctx.log_header_v("Locked", ctx.lock_file.shorten()?);

    Ok(())
}

pub fn restore(ctx: &Context, package: Option<String>) -> Result<(), anyhow::Error> {
    let lcfg = LockedConfig::load(ctx)?;
    ctx.log_header_v("Loaded", ctx.lock_file.shorten()?);

    match package {
        Some(pkg) => {
            let lpkg = lcfg
                .pkgs
                .get(&pkg)
                .with_context(|| format!("package {} not found", pkg))?;
            restore_package(ctx, lpkg)?;
        }
        None => restore_packages(lcfg)?,
    }

    Ok(())
}

pub fn update(ctx: &Context, package: Option<String>) -> Result<(), anyhow::Error> {
    match package {
        Some(package) => {
            let cfg = Config::load(ctx)?;
            ctx.log_header_v("Loaded", ctx.config_file.shorten()?);

            let pkg = cfg
                .pkgs
                .values()
                .find(|pkg| pkg.name == package)
                .cloned()
                .with_context(|| format!("package {} not found", package))?;

            let mut lcfg = LockedConfig::load(ctx)?;
            let old_lpkg = lcfg.pkgs.get(&package);

            // Sync the package.
            let new_lpkg = sync_package(ctx, &pkg, old_lpkg, true)?;

            // Update the package in the lock file.
            lcfg.upsert(new_lpkg);
            lcfg.save()?;
            ctx.log_header_v("Locked", ctx.lock_file.shorten()?);
            Ok(())
        }
        None => sync(ctx, true),
    }
}

pub fn cleanup(ctx: &Context, clear_cache: bool) -> Result<()> {
    let lcfg = LockedConfig::load(ctx)?;

    for entry in WalkDir::new(&ctx.data_dir).max_depth(2) {
        let entry = entry?;
        match entry.depth() {
            1 => match entry.file_name().to_str() {
                Some(name) if lcfg.pkgs.contains_key(name) => {
                    continue;
                }
                _ => {
                    rm_rf(entry.path())?;
                    ctx.log_status("Removed", entry.path().shorten()?);
                }
            },
            2 => {
                let mut parts = entry
                    .path()
                    .components()
                    .map(|c| c.as_os_str().to_str())
                    .rev();
                match (parts.next(), parts.next()) {
                    (Some(Some(version)), Some(Some(name))) => match lcfg.pkgs.get(name) {
                        Some(lpkg) if lpkg.version == version => {
                            continue;
                        }
                        _ => {
                            rm_rf(entry.path())?;
                            ctx.log_status("Removed", entry.path().shorten()?);
                        }
                    },
                    _ => {
                        rm_rf(entry.path())?;
                        ctx.log_status("Removed", entry.path().shorten()?);
                    }
                }
            }
            _ => continue,
        }
    }

    if clear_cache {
        for entry in fs::read_dir(&ctx.cache_dir)? {
            let entry = entry?;
            rm_rf(&entry.path())?;
            ctx.log_status("Removed", entry.path().shorten()?);
        }
    }

    Ok(())
}

pub fn search(query: String, top: u8, ctx: &Context) -> Result<(), anyhow::Error> {
    let gh = Github::new(ctx.clone())?;
    let repos = gh.search_repo(&query, top)?;

    let stars_width = Arc::new(AtomicUsize::new(0));
    let fullname_width = Arc::new(AtomicUsize::new(0));

    // Items list
    let items: Vec<_> = repos
        .into_iter()
        .flat_map(|repo| {
            Some(RepoItem {
                name:           repo.name,
                desc:           repo.description.unwrap_or_default().emojify(),
                stars:          repo
                    .stargazers_count
                    .map(|x| format!("{x}"))
                    .unwrap_or_default(),
                stars_width:    stars_width.clone(),
                fullname:       repo.full_name?,
                fullname_width: fullname_width.clone(),
            })
        })
        .inspect(|item| {
            stars_width.fetch_max(item.stars.len(), Ordering::Relaxed);
            fullname_width.fetch_max(item.fullname.len(), Ordering::Relaxed);
        })
        .collect();

    let page_size = items.len().min(25);
    let answer = Select::new("Select a package ", items)
        .with_page_size(page_size)
        .prompt();

    use inquire::InquireError::*;
    let answer = match answer {
        Err(OperationCanceled | OperationInterrupted) => process::exit(1),
        answer => answer?,
    };

    let name = Text::new("Choose package name?")
        .with_initial_value(&answer.name)
        .prompt()?;

    let bins = Text::new("Choose binary name(s)?")
        .with_initial_value(&answer.name)
        .prompt()?;

    let bins: Vec<_> = bins.split(' ').map(str::trim).map(str::to_owned).collect();
    let bins = if bins.is_empty() {
        vec![name.clone()]
    } else {
        bins
    };

    let pkg = Package {
        name,
        bins,
        source: Source::Github { repo: answer.fullname },
        version: None,
        desc: match answer.desc.is_empty() {
            false => Some(answer.desc.emojify()),
            true => None,
        },
        enabled: true.into(),
    };

    debug!("selected: {:?}", pkg);
    commands::add(ctx, pkg)?;

    Ok(())
}

struct RepoItem {
    name:           String,
    fullname:       String,
    desc:           String,
    stars:          String,
    stars_width:    Arc<AtomicUsize>,
    fullname_width: Arc<AtomicUsize>,
}

impl std::fmt::Display for RepoItem {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        let Self { stars, fullname, desc, .. } = self;
        let stars_width = self.stars_width.load(Ordering::Relaxed);
        let fullname_width = self.fullname_width.load(Ordering::Relaxed);
        f.write_fmt(format_args!(
            "{stars:stars_width$}  {fullname:fullname_width$}  {desc}",
        ))
    }
}