tapes-v2 0.1.2-beta

Swap your whole rice in one command. Eject it with one more. Atomic dotfiles switching for Linux.
use crate::{
    AppState, cli::eject::eject_tapes, hooks::run_hooks, toml::TapeManifest, util::{
        atomic_swap, find_targets, get_tapes_data, get_tapes_default_path, load_manifest, save_state_data, yes_or_no_prompt,
    },
};
use anyhow::bail;
use dirs::{config_dir, home_dir};
use log::{error, info};
use serde::{Deserialize, Serialize};
use std::{
    collections::HashMap,
    fs::File,
    path::{Path, PathBuf},
};

use std::fs;
use std::os::unix::fs as unix_fs;

pub fn run(
    app: &mut AppState,
    name: Option<String>,
    path: Option<PathBuf>,
    dry_run: bool,
    no_hooks: bool,
    quiet: bool,
) -> Result<(), anyhow::Error> {
    info!("starting insert command");
    info!("Current tapes: {:?}", app.active_tape);

    let config_dir = config_dir().expect("config dir should exist");
    let home = home_dir().expect("Home dir should exist");
    let new_config_dir = home.join(".config.tapes");

    if app.active_tape.is_some() && !dry_run {
        if !yes_or_no_prompt("Another tape is currently inserted.\nEject?") {
            bail!("Another tape is currently inserted, doing nothing, exiting");
        };

        eject_tapes(app, &config_dir, &new_config_dir, quiet)?;
    }

    let (tape_path, manifest) = resolve_tape(name.as_ref(), path)?;
    let tape_name = manifest.tape.name.clone();

    if config_dir.is_symlink() {
        bail!("{config_dir:?} is a symlink, prone to fail");
    }

    app.path = Some(tape_path);
    app.active_tape = Some(tape_name);

    let targets = resolve_targets(app, &new_config_dir)?;
    app.targets = Some(targets);

    if dry_run {
        println!("Created {new_config_dir:?}...");
        return Ok(());
    }

    if new_config_dir.exists() {
        info!("{new_config_dir:?} exists - something has gone wrong");
        bail!(".config.tapes exists, did something go wrong? bug report here https://github.com/futurekismo-oss/tapes-v2/issues");
    }

    fs::create_dir(&new_config_dir)?;
    info!("Created {new_config_dir:?}");

    let marker_path = new_config_dir.join(".tapes-managed");
    File::create(&marker_path)?;
    info!("Created: {marker_path:?}");

    if let Err(err) = symlink_targets(
        app.targets.as_ref().expect("app.targets should exist"),
        quiet,
    ) {
        error!("symlink_targets failed: {err}");
        return Err(err);
    }

    if let Err(err) = atomic_swap(&config_dir, &new_config_dir) {
        error!("atomic swap failed: {err}");
        return Err(err.into());
    }
    info!("atomic swapped: {config_dir:?} -> {new_config_dir:?}");

    println!(
        "Inserted: {}",
        app.active_tape.as_deref().unwrap().to_string()
    );

    save_state_data(app)?;

    if !no_hooks {
        if let Some(hooks) = manifest.hooks {
            if hooks.insert.is_empty() {
                return Ok(());
            }
            println!("Running hooks");
            run_hooks(hooks.insert, quiet)
                .map_err(|error| anyhow::anyhow!("Failed to run insert hooks: {}", error))?;
        }
    }

    Ok(())
}

pub fn resolve_tape(
    name: Option<&String>,
    path: Option<PathBuf>,
) -> anyhow::Result<(PathBuf, TapeManifest)> {
    if let Some(name) = name {
        let manifest = get_tapes_data(name)?;
        return Ok((get_tapes_default_path()?.join(name), manifest));
    }

    if let Some(p) = path {
        return Ok((p.canonicalize()?, load_manifest(&p)?));
    }

    if Path::new("./tape.toml").exists() {
        println!("Found tape.toml in current dir. \nInserting...");
        let cwd = std::env::current_dir()?;
        return Ok((cwd.clone(), load_manifest(&cwd)?));
    }

    anyhow::bail!("No path provided and no tape.toml found in the current directory")
}

#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct Target {
    target_path: PathBuf,
    symlink_dest: PathBuf,
    from_tapes: bool,
}

fn resolve_targets(
    app: &AppState,
    new_config_dir: &Path,
) -> Result<HashMap<String, Target>, anyhow::Error> {
    let config_dir = config_dir().expect("Config dir should exist");

    let configs = find_targets(&config_dir)?
        .into_iter()
        .map(|p| map_target(p, new_config_dir, false));

    let tapes_path = app.path.as_deref().expect("path just set");
    let tapes = find_targets(tapes_path)?
        .into_iter()
        .map(|p| map_target(p, new_config_dir, true));

    Ok(configs.chain(tapes).collect())
}

fn map_target(target_path: PathBuf, new_config_dir: &Path, from_tapes: bool) -> (String, Target) {
    let file_name = target_path.file_name().unwrap().to_owned();
    let target_name = file_name.to_string_lossy().into_owned();
    let symlink_dest = new_config_dir.join(&file_name);

    let resolved_path = if from_tapes {
        target_path
    } else {
        symlink_dest.clone()
    };

    let target = Target {
        target_path: resolved_path,
        symlink_dest,
        from_tapes,
    };

    (target_name, target)
}

fn symlink_targets(
    targets_map: &HashMap<String, Target>,
    quiet: bool,
) -> Result<(), anyhow::Error> {
    let targets: Vec<&Target> = targets_map.values().collect();

    for target in &targets {
        if let Err(e) = unix_fs::symlink(&target.target_path, &target.symlink_dest) {
            error!(
                "Error: {e}, failed to symlink {:?} -> {:?}",
                target.target_path, target.symlink_dest
            );
            continue;
        }

        if !quiet {
            println!(
                "symlinking {:?} -> {:?}",
                target.target_path, target.symlink_dest
            );
        }
    }

    Ok(())
}