limb 0.1.0

A focused CLI for git worktree management
Documentation
//! Runtime state threaded through every subcommand.
//!
//! [`Context`] wraps the parsed [`Cli`] together with helpers that resolve
//! the current git repo and configs lazily.

use std::path::PathBuf;

use anyhow::{Context as _, Result};

use crate::cancel::Cancel;
use crate::cli::Cli;
use crate::config::{Global, Repo};

/// Per-invocation runtime state.
///
/// Derived from [`Cli`] at startup; every subcommand receives a shared
/// reference. All fields are lazily consumed. For example,
/// [`Self::repo_config`] is only called by commands that care about
/// `.limb.toml`.
#[allow(clippy::struct_excessive_bools)]
pub struct Context {
    repo_hint: Option<PathBuf>,
    config_override: Option<PathBuf>,
    pub json: bool,
    pub no_color: bool,
    pub yes: bool,
    pub quiet: bool,
    pub cancel: Cancel,
}

impl Context {
    /// Builds a [`Context`] from parsed CLI arguments.
    ///
    /// Honours the `NO_COLOR` env var in addition to `--no-color`.
    #[must_use]
    pub fn from_cli(cli: &Cli, cancel: Cancel) -> Self {
        let no_color = cli.no_color || std::env::var_os("NO_COLOR").is_some();
        Self {
            repo_hint: cli.repo.clone(),
            config_override: cli.config.clone(),
            json: cli.json,
            no_color,
            yes: cli.yes,
            quiet: cli.quiet,
            cancel,
        }
    }

    /// Resolves the current git repo.
    ///
    /// Uses `--repo` if given, otherwise walks upward from the current
    /// working directory looking for a `.git` entry or bare-clone marker.
    ///
    /// # Errors
    ///
    /// Returns an error if the current directory cannot be read or no
    /// git repository is found.
    pub fn repo(&self) -> Result<PathBuf> {
        let start = match &self.repo_hint {
            Some(p) => p.clone(),
            None => std::env::current_dir().context("read current dir")?,
        };
        for ancestor in start.ancestors() {
            if ancestor.join(".git").exists() {
                return Ok(ancestor.to_path_buf());
            }
            if ancestor.join("HEAD").is_file()
                && ancestor.join("objects").is_dir()
                && ancestor.join("refs").is_dir()
            {
                return Ok(ancestor.to_path_buf());
            }
        }
        anyhow::bail!(
            "not a git repository at {}; try: cd into a repo or pass --repo <path>",
            start.display()
        )
    }

    /// Loads the resolved [`Global`] config (honours `--config`).
    ///
    /// # Errors
    ///
    /// Returns an error if the config file exists but cannot be parsed.
    pub fn global(&self) -> Result<Global> {
        Global::load(self.config_override.as_deref())
    }

    /// Loads the nearest [`Repo`] config, walking up from the current repo.
    ///
    /// # Errors
    ///
    /// Returns an error if the current repo cannot be resolved or if a
    /// `.limb.toml` is found but cannot be parsed.
    pub fn repo_config(&self) -> Result<Option<Repo>> {
        let repo = self.repo()?;
        Repo::discover(&repo)
    }

    /// Like [`Self::repo_config`], but silently returns `None` on any
    /// error. Useful for subcommands where per-repo config is purely
    /// optional enrichment.
    #[must_use]
    pub fn repo_config_optional(&self) -> Option<Repo> {
        self.repo()
            .ok()
            .and_then(|r| Repo::discover(&r).ok().flatten())
    }
}