gimmetool 0.1.3

A multi-repo manager that lets you jump between projects, pin favorites, clean stale branches, and alias repos — all from one CLI
use anyhow::{Context, Result};
use std::fs;
use std::io::{self, BufRead, Write};
use std::path::{Path, PathBuf};

use crate::config;
use crate::xdg;

const SHELL_FUNCTION: &str = include_str!("../scripts/shell.sh");

const SOURCE_LINE: &str = r#"source "$HOME/.config/gimme/shell.sh""#;

enum Shell {
  Bash,
  Zsh,
}

pub fn run() {
  let gimme_dir = match xdg::gimme_dir() {
    Some(d) => d,
    None => {
      eprintln!("ERROR Could not determine home directory.");
      return;
    }
  };

  let shell = detect_shell();
  install_shell_function(&gimme_dir, shell.as_ref());

  let config_exists = config::find_config_file().is_some();
  if config_exists {
    let path = config::find_config_file().unwrap();
    eprintln!(
      "Config already exists at {}, skipping wizard.",
      path.display()
    );
  } else {
    run_wizard(&gimme_dir);
  }

  if let Some(ref sh) = shell {
    eprintln!();
    let rc = rc_file_for(sh);
    eprintln!("Restart your shell or run: source {}", rc.display());
  }

  if !config_exists {
    print_getting_started();
  }
}

// --- Shell Detection ---

fn detect_shell() -> Option<Shell> {
  let shell_env = std::env::var("SHELL").unwrap_or_default();

  if shell_env.ends_with("bash") {
    return Some(Shell::Bash);
  }
  if shell_env.ends_with("zsh") {
    return Some(Shell::Zsh);
  }

  eprintln!("WARN Could not detect a supported shell (bash/zsh) from $SHELL=\"{shell_env}\".");
  eprintln!("     You can manually source the shell function from: <gimme_dir>/shell.sh");
  None
}

fn rc_file_for(shell: &Shell) -> PathBuf {
  let home = dirs::home_dir().unwrap_or_default();
  match shell {
    Shell::Bash => home.join(".bashrc"),
    Shell::Zsh => home.join(".zshrc"),
  }
}

fn shell_name(shell: &Shell) -> &'static str {
  match shell {
    Shell::Bash => "bash",
    Shell::Zsh => "zsh",
  }
}

// --- Shell Function Installation ---

fn install_shell_function(gimme_dir: &Path, shell: Option<&Shell>) {
  if let Err(e) = write_shell_function(gimme_dir) {
    eprintln!("ERROR Failed to write shell function: {e}");
    return;
  }

  let Some(shell) = shell else { return };

  eprintln!("Detected shell: {}", shell_name(shell));

  let rc = rc_file_for(shell);
  match shim_rc_file(&rc) {
    Ok(true) => eprintln!("Added source line to {}", rc.display()),
    Ok(false) => eprintln!("{} already configured, no changes needed.", rc.display()),
    Err(e) => eprintln!("ERROR Failed to update {}: {e}", rc.display()),
  }
}

fn write_shell_function(gimme_dir: &Path) -> Result<()> {
  fs::create_dir_all(gimme_dir).with_context(|| format!("creating {}", gimme_dir.display()))?;

  let dest = gimme_dir.join("shell.sh");
  let verb = if dest.exists() { "Updated" } else { "Wrote" };

  fs::write(&dest, SHELL_FUNCTION).with_context(|| format!("writing {}", dest.display()))?;

  eprintln!("{verb} shell function at {}", dest.display());
  Ok(())
}

/// Returns Ok(true) if line was added, Ok(false) if already present.
fn shim_rc_file(rc_path: &Path) -> Result<bool> {
  let contents = fs::read_to_string(rc_path).unwrap_or_default();

  if contents.contains(SOURCE_LINE) {
    return Ok(false);
  }

  let mut file = fs::OpenOptions::new()
    .create(true)
    .append(true)
    .open(rc_path)
    .with_context(|| format!("opening {}", rc_path.display()))?;

  writeln!(file)?;
  writeln!(file, "# gimme shell integration")?;
  writeln!(file, "{SOURCE_LINE}")?;

  Ok(true)
}

// --- First-Run Wizard ---

fn run_wizard(gimme_dir: &Path) {
  eprintln!();
  let mut folders = Vec::new();

  let first = prompt("Where do you keep your repositories?", "~/code");
  folders.push(first);

  loop {
    let extra = prompt("Add another search folder? (path or Enter to skip)", "");
    if extra.is_empty() {
      break;
    }
    folders.push(extra);
  }

  let config_path = gimme_dir.join("config.yaml");
  match write_initial_config(&config_path, &folders) {
    Ok(()) => {
      eprintln!();
      eprintln!("Wrote config to {}", config_path.display());
      eprintln!("  Search folders: {}", folders.join(", "));
      eprintln!("  Protected branches: main, master");
    }
    Err(e) => {
      eprintln!("ERROR Failed to write config: {e}");
    }
  }
}

fn write_initial_config(path: &Path, search_folders: &[String]) -> Result<()> {
  if let Some(parent) = path.parent() {
    fs::create_dir_all(parent)?;
  }

  let mut config = config::Config::default();
  config.search_folders = search_folders.to_vec();

  let yaml = serde_yaml::to_string(&config)?;
  fs::write(path, yaml)?;
  Ok(())
}

fn prompt(question: &str, default: &str) -> String {
  if default.is_empty() {
    eprint!("{question} ");
  } else {
    eprint!("{question} [{default}]: ");
  }
  io::stderr().flush().ok();

  let stdin = io::stdin();
  let mut line = String::new();
  stdin.lock().read_line(&mut line).ok();

  let trimmed = line.trim();
  if trimmed.is_empty() {
    default.to_string()
  } else {
    trimmed.to_string()
  }
}

// --- Getting Started ---

fn print_getting_started() {
  eprintln!();
  eprintln!("Setup complete! Here are some things you can do:");
  eprintln!();
  eprintln!("  gimme <repo>                    Jump to a repository");
  eprintln!("  gimme list                      See all your repositories");
  eprintln!("  gimme config add alias k kern   Create shortcuts for repos");
  eprintln!("  gimme pin                       Pin the current repo for priority searching");
  eprintln!("  gimme clean -b                  Clean up merged branches");
  eprintln!();
  eprintln!("Run gimme --help for the full list.");
}