limb 0.2.0

A focused CLI for git worktree management
Documentation
//! Implements `limb doctor`. Diagnoses setup and emits actionable fixes.

use std::path::Path;
use std::time::SystemTime;

use anyhow::Result;

use crate::config::{Global, Hooks, Repo};
use crate::context::Context;
use crate::discover;
use crate::refresh;
use crate::style;

/// Runs `limb doctor`.
///
/// Walks through the current repo, global config, per-repo config, and
/// `projects.roots` discovery; reports each check as `ok` / `missing` and
/// emits an aggregated list of problems at the end with concrete
/// fix suggestions.
///
/// # Errors
///
/// Returns an error. With exit code 1. If any problem was found. The
/// message itself is `{N} problem(s) found`; the details are printed to
/// stderr before the error returns.
pub fn run(ctx: &Context) -> Result<()> {
    let mut report = Report::default();

    let global = ctx.global().unwrap_or_else(|e| {
        report.problem(format!("global config: {e:#}"));
        Global::defaults()
    });

    let repo = ctx.repo().ok();
    let repo_config = ctx
        .repo_config()
        .map_err(|e| report.problem(format!("repo config: {e:#}")))
        .ok()
        .flatten();

    section_repo(repo.as_deref());
    section_global(&global, &mut report);
    section_repo_config(repo_config.as_ref(), &mut report);
    if !global.projects_roots.is_empty() {
        section_discovered(&global, &mut report);
        section_freshness(&global, &mut report);
    }

    report.emit()
}

fn section_repo(repo: Option<&Path>) {
    println!("repo:");
    match repo {
        Some(r) => println!("  path: {}", r.display()),
        None => println!("  (not in a git repository)"),
    }
}

fn section_global(global: &Global, report: &mut Report) {
    println!("global config:");
    let Some(source) = &global.source else {
        println!("  (none. Using built-in defaults)");
        return;
    };
    println!("  source: {}", source.display());
    println!("  theme: {}", global.ui_theme);
    println!("  shell prefix: {}", global.shell_prefix);
    println!("  git default remote: {}", global.git_default_remote);
    if let Some(base) = &global.git_default_base {
        println!("  git default base: {base}");
    }
    if global.projects_roots.is_empty() {
        println!("  projects.roots: (none)");
        return;
    }
    println!("  projects.roots: {}", global.projects_roots.len());
    for root in &global.projects_roots {
        let ok = root.is_dir();
        let tag = if ok { "ok" } else { "missing" };
        println!("    - {} [{tag}]", root.display());
        if !ok {
            report.problem(format!(
                "projects root does not exist: {}; \
                 try: remove it from ~/.config/limb/config.toml, or `mkdir -p {}`",
                root.display(),
                root.display()
            ));
        }
    }
}

fn section_repo_config(repo_config: Option<&Repo>, report: &mut Report) {
    println!("repo config:");
    let Some(r) = repo_config else {
        println!("  (no .limb.toml in this repo or any ancestor)");
        return;
    };
    println!("  source: {}", r.source.display());
    println!("  root: {}", r.root.display());
    println!("  base_dir: {}", r.worktrees_base_dir.display());
    section_shared(r, report);
    section_templates(r);
    check_hooks("repo hooks", &r.hooks, &r.root, report);
}

fn section_shared(r: &Repo, report: &mut Report) {
    if r.worktrees_shared.is_empty() {
        println!("  shared files: (none)");
        return;
    }
    let source = r.resolved_shared_source();
    println!(
        "  shared files: {} from {}",
        r.worktrees_shared.len(),
        source.display()
    );
    if !source.is_dir() {
        report.problem(format!(
            "shared source dir does not exist: {}; \
             try: `mkdir -p {}` and add the files listed under worktrees.shared",
            source.display(),
            source.display()
        ));
    }
    for f in &r.worktrees_shared {
        let full = source.join(f);
        let tag = if full.exists() { "ok" } else { "missing" };
        println!("    - {} [{tag}]", f.display());
        if !full.exists() {
            report.note(format!(
                "shared file missing: {}; `limb setup` will skip it",
                full.display()
            ));
        }
    }
}

fn section_templates(r: &Repo) {
    if r.templates.is_empty() {
        println!("  templates: (none)");
        return;
    }
    println!("  templates: {}", r.templates.len());
    for name in r.templates.keys() {
        println!("    - {name}");
    }
}

fn section_discovered(global: &Global, report: &mut Report) {
    println!("discovered repos:");
    let mut total = 0_usize;
    for root in &global.projects_roots {
        if !root.is_dir() {
            continue;
        }
        match discover::repos_under(root) {
            Ok(repos) => {
                println!("  {}: {}", root.display(), repos.len());
                total += repos.len();
            }
            Err(e) => report.problem(format!("cannot scan {}: {e:#}", root.display())),
        }
    }
    println!("  total: {total}");
}

fn section_freshness(global: &Global, report: &mut Report) {
    println!("freshness:");
    let now = SystemTime::now();
    let warn = global.refresh_doctor_warn;
    let error = global.refresh_doctor_error;

    for root in &global.projects_roots {
        if !root.is_dir() {
            continue;
        }
        let Ok(repos) = discover::repos_under(root) else {
            continue;
        };
        for repo in &repos {
            let Some(anchor) = discover::anchor_for(repo) else {
                continue;
            };
            let name = discover::repo_name(repo);
            match refresh::fetched_at(&anchor) {
                None => {
                    println!("  {name}: never fetched");
                    report.note(format!(
                        "{} has no FETCH_HEAD; \
                         try: `limb update --fetch-only` (or `--all`)",
                        repo.display()
                    ));
                }
                Some(t) => {
                    let age = now.duration_since(t).unwrap_or_default();
                    let short = refresh::render_age(now, Some(t));
                    println!("  {name}: {short}");
                    if age > error {
                        report.problem(format!(
                            "{} not fetched in {}; \
                             try: `limb update --fetch-only --all`",
                            repo.display(),
                            refresh::render_age_long(age)
                        ));
                    } else if age > warn {
                        report.note(format!(
                            "{} fetched {} ago",
                            repo.display(),
                            refresh::render_age_long(age)
                        ));
                    }
                }
            }
        }
    }
}

fn check_hooks(label: &str, hooks: &Hooks, root: &Path, report: &mut Report) {
    let entries = [
        ("pre_add", hooks.pre_add.as_deref()),
        ("post_add", hooks.post_add.as_deref()),
        ("pre_remove", hooks.pre_remove.as_deref()),
        ("post_remove", hooks.post_remove.as_deref()),
    ];
    if !entries.iter().any(|(_, p)| p.is_some()) {
        println!("  {label}: (none)");
        return;
    }
    println!("  {label}:");
    for (name, maybe) in entries {
        let Some(path) = maybe else { continue };
        let full = root.join(path);
        let tag = if full.is_file() { "ok" } else { "missing" };
        println!("    - {name}: {} [{tag}]", path.display());
        if !full.is_file() {
            report.problem(format!(
                "hook script missing: {} ({name}); \
                 try: create the script and `chmod +x {}`",
                full.display(),
                full.display()
            ));
        }
    }
}

#[derive(Default)]
struct Report {
    problems: Vec<String>,
    notes: Vec<String>,
}

impl Report {
    fn problem(&mut self, msg: String) {
        self.problems.push(msg);
    }

    fn note(&mut self, msg: String) {
        self.notes.push(msg);
    }

    fn emit(self) -> Result<()> {
        if !self.notes.is_empty() {
            let s = style::DIM;
            anstream::println!();
            anstream::println!("{s}notes:{s:#}");
            for n in &self.notes {
                anstream::println!("  {s}- {n}{s:#}");
            }
        }
        if self.problems.is_empty() {
            let ok = style::OK;
            anstream::println!();
            anstream::println!("{ok}✓ all checks passed{ok:#}");
            Ok(())
        } else {
            let e = style::ERROR;
            anstream::eprintln!();
            anstream::eprintln!("{e}problems:{e:#}");
            for p in &self.problems {
                anstream::eprintln!("  {e}✗{e:#} {p}");
            }
            anyhow::bail!("{} problem(s) found", self.problems.len())
        }
    }
}