mod ai;
mod browse;
mod browse_tui;
mod chezmoi;
mod config;
mod doctor;
mod external_render;
mod fetch_state;
mod git;
mod helptags;
mod link;
mod loader;
mod lockfile;
mod merge_conflicts;
mod plugin_scan;
mod profile;
mod profile_tui;
mod tui;
mod update_log;

use crate::config::parse_config;
use crate::git::Repo;
use crate::loader::generate_loader;
use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use tokio::task::JoinSet;

// Clap 4 styling: section headers / usage / literals / placeholders を色分けする。
// `const` で渡せるようにビルダ経由で作成 (clap 4.5+ は Styles::styled() が const)。
const CLI_STYLES: clap::builder::styling::Styles = {
    use clap::builder::styling::{AnsiColor, Effects, Styles};
    Styles::styled()
        .header(AnsiColor::BrightCyan.on_default().effects(Effects::BOLD))
        .usage(AnsiColor::BrightGreen.on_default().effects(Effects::BOLD))
        .literal(AnsiColor::BrightBlue.on_default().effects(Effects::BOLD))
        .placeholder(AnsiColor::Magenta.on_default())
        .error(AnsiColor::BrightRed.on_default().effects(Effects::BOLD))
        .valid(AnsiColor::BrightGreen.on_default())
        .invalid(AnsiColor::BrightYellow.on_default())
};

#[derive(Parser)]
#[command(
    author,
    version,
    about = "Fast Neovim plugin manager with pre-compiled loader and merge optimization",
    long_about = "\
rvpm clones plugins in parallel, links merge=true plugins into a single\n\
runtime-path entry, and pre-compiles a loader.lua that sources everything\n\
without runtime glob cost. Inspired by lazy.nvim but adds merge and\n\
ahead-of-time file-list compilation on top.\n\
\n\
Run `rvpm init --write` once after your first `rvpm sync` to wire the\n\
generated loader.lua into your Neovim init.lua.",
    styles = CLI_STYLES,
)]
struct Cli {
    #[command(subcommand)]
    command: Option<Commands>,
}

#[derive(Subcommand)]
enum Commands {
    /// Clone/pull plugins and regenerate loader.lua
    ///
    /// With --prune, also delete any plugin directories under the repos
    /// cache that are no longer referenced by config.toml.
    Sync {
        /// Delete unused plugin directories after syncing
        #[arg(long)]
        prune: bool,
        /// Error out if any non-dev plugin is missing from rvpm.lock
        /// (strict reproducibility for CI / fresh machines)
        #[arg(long)]
        frozen: bool,
        /// Ignore rvpm.lock entirely: pull latest and do not write the lockfile
        #[arg(long)]
        no_lock: bool,
        /// Run each plugin's `build` command even when git HEAD did not move.
        /// By default `sync` skips build for plugins whose pull was a no-op,
        /// which makes "nothing changed" syncs much faster. Use this when you
        /// need to rerun e.g. `:TSUpdate` or a manual rebuild step.
        ///
        /// Accepts an optional query to limit rebuild scope to plugins whose
        /// `url` or `name` contains the substring. Useful when iterating on a
        /// single plugin's `build` command:
        ///
        ///   rvpm sync --rebuild                    # all plugins
        ///   rvpm sync --rebuild nvim-treesitter    # only matching ones
        #[arg(long, num_args = 0..=1, default_missing_value = "", value_name = "QUERY")]
        rebuild: Option<String>,
        /// Force-refresh every plugin's git state regardless of the fetch
        /// cache (`options.fetch_interval`). Useful before checking for
        /// held-back plugins when you want a guaranteed fresh remote read.
        #[arg(long, conflicts_with = "no_refresh")]
        refresh: bool,
        /// Skip git fetch for every plugin regardless of the fetch cache
        /// (offline mode). Plugins whose local HEAD already matches the
        /// effective rev complete instantly; others fall through to a local
        /// checkout that errors if the commit isn't already available.
        #[arg(long, conflicts_with = "refresh")]
        no_refresh: bool,
    },

    /// Regenerate loader.lua only (no git)
    ///
    /// Useful after editing per-plugin init/before/after.lua or tweaking
    /// TOML triggers — skips the clone/pull phase entirely.
    Generate,

    /// Delete plugin directories no longer referenced by config.toml
    ///
    /// Walks `{cache_root}/plugins/repos/` and removes every clone whose
    /// plugin is no longer in `config.toml`. Does not run git operations,
    /// so it is much faster than `sync --prune` on large configs
    /// (hundreds of plugins).
    Clean,

    /// Add a plugin and sync
    ///
    /// Accepts the same trigger flags as `set` to configure the plugin
    /// in one shot: `rvpm add owner/repo --on-cmd Foo`
    Add {
        /// Plugin repo: owner/repo, URL, or local path
        repo: String,

        /// Friendly name (optional)
        #[arg(long)]
        name: Option<String>,

        /// Set lazy flag
        #[arg(long)]
        lazy: Option<bool>,

        /// Set on_cmd. Comma-separated or JSON array.
        #[arg(long)]
        on_cmd: Option<String>,

        /// Set on_ft. Comma-separated or JSON array.
        #[arg(long)]
        on_ft: Option<String>,

        /// Set on_map. Comma-separated or JSON array/object.
        #[arg(long)]
        on_map: Option<String>,

        /// Set on_event. Comma-separated or JSON array.
        #[arg(long)]
        on_event: Option<String>,

        /// Set rev (branch/tag/commit)
        #[arg(long)]
        rev: Option<String>,

        /// Accept auto-scanned on_cmd / on_map without prompting.
        /// Overrides `options.auto_lazy` for this call (== "always").
        /// Useful for non-TTY scripts that want lazy-by-default.
        #[arg(long, conflicts_with = "no_lazy")]
        auto_lazy: bool,

        /// Skip the auto-scan entirely for this invocation.
        /// Overrides `options.auto_lazy` for this call (== "never").
        /// This does not override explicit `--lazy` / `--on-*` flags —
        /// if the plugin is lazy via those, it stays lazy.
        #[arg(long)]
        no_lazy: bool,

        /// AI backend for this `add`. Replaces the static-scan + auto-lazy path:
        /// the chosen CLI (`claude` / `gemini` / `codex`) reads the plugin's
        /// README + your config and proposes the full `[[plugins]]` block plus
        /// any per-plugin hook files. Overrides `options.ai` for this call.
        #[arg(long, value_enum, conflicts_with = "no_ai")]
        ai: Option<crate::config::AiBackend>,

        /// Force the static-scan path even if `options.ai` is set in config.
        #[arg(long)]
        no_ai: bool,
    },

    /// Tune an existing plugin's config with the AI backend
    ///
    /// Like `add --ai`, but for plugins **already configured** in
    /// `config.toml`. Picks one entry (by `[query]` fuzzy match or
    /// interactive select), feeds the current `[[plugins]]` block plus
    /// the cloned plugin's README/doc and any existing per-plugin hook
    /// files to the AI CLI, and asks for two parallel proposals per
    /// section: a fresh redesign and a merged variant that preserves
    /// your edits.
    ///
    /// At Apply time you pick per-section: `Use FRESH` (overwrite),
    /// `Use MERGED` (overwrite, keep your edits), or `Keep existing`
    /// (no change). Same choice applies to the `[[plugins]]` entry
    /// itself. Use the Chat action to tell the AI to keep a particular
    /// field, or just pick `Keep existing` for that section.
    Tune {
        /// Fuzzy match plugin url (omit to pick interactively)
        query: Option<String>,

        /// AI backend for this `tune`. Overrides `options.ai` for this call.
        #[arg(long, value_enum, conflicts_with = "no_ai")]
        ai: Option<crate::config::AiBackend>,

        /// Force-disable AI for this call. `tune` is AI-only, so this
        /// effectively errors out — provided for symmetry with `add`.
        #[arg(long)]
        no_ai: bool,
    },

    /// Edit per-plugin or global hook files in $EDITOR
    ///
    /// Without flags, prompts which plugin and file to edit.
    /// With --init / --before / --after, opens that file directly.
    /// With --global, edits global hooks instead of per-plugin files:
    ///   --init   = Neovim's own init.lua (`~/.config/<appname>/init.lua`)
    ///   --before = `<config_root>/before.lua`
    ///   --after  = `<config_root>/after.lua`
    Edit {
        /// Fuzzy match plugin url (omit to pick interactively)
        query: Option<String>,

        /// Open init.lua directly (per-plugin path, or Neovim's own with --global)
        #[arg(long)]
        init: bool,

        /// Open before.lua directly
        #[arg(long)]
        before: bool,

        /// Open after.lua directly
        #[arg(long)]
        after: bool,

        /// Edit global hooks instead of per-plugin files
        #[arg(long)]
        global: bool,
    },

    /// Tweak a plugin's options interactively
    ///
    /// Walks through lazy / merge / on_* / rev with fuzzy-select and
    /// ESC-cancellable prompts. Pick `[ Open config.toml in $EDITOR ]`
    /// to drop into raw TOML editing when you need table-form on_map
    /// or complex `cond` expressions.
    Set {
        /// Fuzzy match plugin url (omit to pick interactively)
        query: Option<String>,

        /// Set lazy flag non-interactively
        #[arg(long)]
        lazy: Option<bool>,

        /// Set merge flag non-interactively
        #[arg(long)]
        merge: Option<bool>,

        /// Set on_cmd. Comma-separated (`"Foo,Bar"`) or JSON array
        /// (`'["Foo","Bar"]'`).
        #[arg(long)]
        on_cmd: Option<String>,

        /// Set on_ft. Comma-separated or JSON array.
        #[arg(long)]
        on_ft: Option<String>,

        /// Set on_map. Comma-separated lhs list, JSON array of
        /// strings, or JSON array/object with full `{ lhs, mode, desc }`
        /// form. Example: --on-map '{"lhs":"<space>d","mode":["n","x"]}'
        #[arg(long)]
        on_map: Option<String>,

        /// Set on_event. Comma-separated or JSON array. Supports the
        /// `"User Xxx"` shorthand for User events with patterns.
        #[arg(long)]
        on_event: Option<String>,

        /// Set on_path glob list. Comma-separated or JSON array.
        #[arg(long)]
        on_path: Option<String>,

        /// Set on_source (plugin names). Comma-separated or JSON array.
        #[arg(long)]
        on_source: Option<String>,

        /// Set rev (branch/tag/commit) non-interactively
        #[arg(long)]
        rev: Option<String>,
    },

    /// Update (git pull) installed plugins
    Update {
        /// Fuzzy match plugin url (omit to update all)
        query: Option<String>,
    },

    /// Remove a plugin and delete its directory
    Remove {
        /// Fuzzy match plugin url (omit to pick interactively)
        query: Option<String>,
    },

    /// Show plugin list (TUI by default, plain text with --no-tui)
    ///
    /// TUI keys: [q] quit  [j/k] move  [e] edit  [s] set  [S] sync all
    /// [u] update selected  [U] update all  [g] regenerate  [d] remove{n}
    /// With --no-tui: prints a sorted plain-text status line per plugin
    /// (pipe-friendly for scripting).
    List {
        /// Print plain text instead of launching the TUI
        #[arg(long)]
        no_tui: bool,
    },

    /// Open config.toml in $EDITOR
    ///
    /// Runs `sync` automatically after the editor exits.
    Config,

    /// Print or write the init.lua loader snippet
    ///
    /// Without --write: prints the exact `dofile(vim.fn.expand("..."))`
    /// line for your current config. Copy it into your Neovim init.lua.
    ///
    /// With --write: appends the snippet to `$NVIM_APPNAME`'s init.lua
    /// (defaults to `~/.config/nvim/init.lua`). If init.lua does not
    /// exist it is created with a header comment. Idempotent — a no-op
    /// if the loader is already referenced.
    Init {
        /// Append to init.lua (creates the file if missing)
        #[arg(long)]
        write: bool,
    },
    /// Browse and install Neovim plugins from GitHub
    Browse,

    /// Diagnose rvpm's config, state, and environment
    ///
    /// Inspects config.toml, the plugin cache, generated loader.lua, Neovim
    /// init.lua wiring, and required external tools (nvim / git / chezmoi /
    /// $EDITOR). Exits 0 on all-ok, 1 on any error, 2 on warn-only.
    Doctor,

    /// Profile Neovim startup time per plugin
    ///
    /// Spawns `nvim --headless --startuptime <tmp> +qa` N times, parses the
    /// output, and attributes each sourced file back to its plugin via path
    /// prefix match. By default emits phase markers into a temporarily
    /// instrumented loader.lua so that the TUI can show a per-phase
    /// breakdown (3=before / 4=init / 5=rtp / 6=eager / 7=lazy triggers /
    /// 9=after). The original loader.lua is restored on exit.
    /// Pipe-friendly plain text with `--no-tui`, JSON with `--json`.
    Profile {
        /// Number of nvim runs to average (default 3, max 20)
        #[arg(long, default_value_t = 3)]
        runs: usize,

        /// Limit plain / JSON output to top N plugins (TUI ignores this)
        #[arg(long)]
        top: Option<usize>,

        /// Emit the averaged report as JSON to stdout
        #[arg(long, conflicts_with = "no_tui")]
        json: bool,

        /// Plain text output instead of the TUI
        #[arg(long)]
        no_tui: bool,

        /// Treat all plugins as merge=false for this measurement, so each
        /// plugin's files source from their own repos/<canonical>/ path
        /// instead of the shared merged/ dir. Lets you see per-plugin load
        /// time even for plugins that are normally merged, and compare the
        /// cost of merging. merged/ itself is not touched.
        #[arg(long)]
        no_merge: bool,

        /// Skip phase-marker instrumentation. Faster and avoids swapping
        /// loader.lua during the profile run. You lose the phase timeline
        /// and per-plugin init/trig columns, but raw per-plugin self ms
        /// is still measured.
        #[arg(long)]
        no_instrument: bool,
    },

    /// Show recent sync/update/add changes
    ///
    /// Reads the per-run history persisted under `<cache_root>/update_log.json`
    /// and prints a human-readable digest of plugin commit changes.
    /// Pass a substring to filter by plugin name.
    Log {
        /// Case-insensitive substring filter on plugin display name
        query: Option<String>,

        /// How many recent runs to show (default: 1, max: 20)
        #[arg(long, default_value_t = crate::update_log::DEFAULT_LAST)]
        last: usize,

        /// Show full commit body in addition to subject (currently subject-only)
        #[arg(long)]
        full: bool,

        /// Inline `git diff` for changed README/CHANGELOG/doc files
        #[arg(long)]
        diff: bool,
    },
}

#[tokio::main]
async fn main() -> Result<()> {
    let cli = Cli::parse();

    match cli.command.unwrap_or(Commands::List { no_tui: false }) {
        Commands::Sync {
            prune,
            frozen,
            no_lock,
            rebuild,
            refresh,
            no_refresh,
        } => {
            run_sync(prune, frozen, no_lock, rebuild, refresh, no_refresh).await?;
        }
        Commands::Generate => {
            run_generate().await?;
        }
        Commands::Clean => {
            run_clean()?;
        }
        Commands::Add {
            repo,
            name,
            lazy,
            on_cmd,
            on_ft,
            on_map,
            on_event,
            rev,
            auto_lazy,
            no_lazy,
            ai,
            no_ai,
        } => {
            let policy_override = if auto_lazy {
                Some(crate::config::AutoLazyPolicy::Always)
            } else if no_lazy {
                Some(crate::config::AutoLazyPolicy::Never)
            } else {
                None
            };
            // `--no-ai` は config の `ai` を打ち消して Off に固定。`--ai <backend>` は
            // 明示指定 (Off 含む)。両方無指定 (None) なら config 値を使う。
            let ai_override = if no_ai {
                Some(crate::config::AiBackend::Off)
            } else {
                ai
            };
            run_add(
                repo,
                name,
                lazy,
                on_cmd,
                on_ft,
                on_map,
                on_event,
                rev,
                policy_override,
                ai_override,
            )
            .await?;
        }
        Commands::Tune { query, ai, no_ai } => {
            // `--no-ai` は config の `ai` を打ち消して Off に固定。`--ai <backend>` は
            // 明示指定。両方無指定 (None) なら config 値を使う。
            // tune は AI 専用なので Off に解決した場合は run_tune 内で error する。
            let ai_override = if no_ai {
                Some(crate::config::AiBackend::Off)
            } else {
                ai
            };
            run_tune(query, ai_override).await?;
        }
        Commands::Edit {
            query,
            init,
            before,
            after,
            global,
        } => {
            if run_edit(query, init, before, after, global).await? {
                run_generate().await?;
            }
        }
        Commands::Set {
            query,
            lazy,
            merge,
            on_cmd,
            on_ft,
            on_map,
            on_event,
            on_path,
            on_source,
            rev,
        } => {
            if run_set(
                query, lazy, merge, on_cmd, on_ft, on_map, on_event, on_path, on_source, rev,
            )
            .await?
            {
                run_generate().await?;
            }
        }
        Commands::Update { query } => {
            run_update(query).await?;
        }
        Commands::Remove { query } => {
            run_remove(query).await?;
        }
        Commands::List { no_tui } => {
            // list / browse 間を相互に `b` / `l` キーで行き来できるように、
            // フラグが立っている限りループで切り替える。`--no-tui` は常に false を返すので即抜ける。
            let mut nt = no_tui;
            loop {
                if run_list(nt).await? && run_browse().await? {
                    nt = false;
                    continue;
                }
                break;
            }
        }
        Commands::Config => {
            if run_config().await? {
                run_generate().await?;
            }
        }
        Commands::Init { write } => {
            run_init(write).await?;
        }
        Commands::Browse => loop {
            if run_browse().await? && run_list(false).await? {
                continue;
            }
            break;
        },
        Commands::Doctor => {
            let code = run_doctor().await?;
            if code != 0 {
                std::process::exit(code);
            }
        }
        Commands::Log {
            query,
            last,
            full,
            diff,
        } => {
            run_log(query, last, full, diff).await?;
        }
        Commands::Profile {
            runs,
            top,
            json,
            no_tui,
            no_merge,
            no_instrument,
        } => {
            run_profile(runs, top, json, no_tui, no_merge, no_instrument).await?;
        }
    }

    Ok(())
}

use crate::tui::{PluginStatus, TuiState};
use crossterm::{
    execute,
    terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use ratatui::backend::CrosstermBackend;
use tokio::sync::mpsc;

/// cond + merge=true の組み合わせを検出し、silent に merge を無効化する。
/// (cond が false のとき merged rtp に中身が残ると矛盾するため。警告は
/// 出さない — 自明に整合させているだけで、ユーザーアクションは不要。)
fn disable_merge_if_cond(plugin: &mut crate::config::Plugin) {
    if plugin.cond.is_some() && plugin.merge {
        plugin.merge = false;
    }
}

/// プラグインの clone 先パスを解決する。
/// `plugin.dst` が `~/...` 形式の場合は home dir に展開する (dev プラグインで頻出)。
fn resolve_plugin_dst(plugin: &crate::config::Plugin, cache_root: &Path) -> PathBuf {
    if let Some(d) = &plugin.dst {
        expand_tilde(d)
    } else {
        resolve_repos_dir(cache_root).join(plugin.canonical_path())
    }
}

/// `query` で url を fuzzy / 部分一致で 1 件選ぶ、または非対話なら FuzzySelect TUI を出す。
///
/// 戻り値:
///   - `Ok(Some(url))` — 選択成功
///   - `Ok(None)`     — interactive 選択で user が ESC キャンセルした
///   - `Err(_)`       — `query` 指定時に該当 plugin が無かった (Plugin not found)
///
/// `run_remove` / `run_set` / `run_tune` に同じ if/else ブロックが散らばっていたのを
/// 共通化したもの (Gemini PR #100 review 指摘)。caller 側でキャンセル時の戻り値が
/// 違うので (`Ok(())` / `Ok(false)` / etc) `Option` で受けて呼び元が分岐する。
fn select_plugin_url(
    plugins: &[crate::config::Plugin],
    query: Option<&str>,
    prompt: &str,
) -> Result<Option<String>> {
    if let Some(q) = query {
        // 曖昧 partial match を防ぐ (CodeRabbit PR #100 指摘):
        // 複数の plugin を含む query を黙って先頭採用すると、`rvpm tune cmp`
        // で `cmp-buffer` / `cmp-cmdline` ... のうちどれが書き換えられるか
        // 予測不能になり、mutating コマンドで重大事故が起きる。
        //
        // 解決順序:
        //   1. 完全一致 (`p.url == q`) があれば即採用 (1 個だけ通過)。
        //   2. 部分一致が 1 件 → 採用。0 件 → "Plugin not found"。
        //   3. 部分一致が複数 → match 一覧を見せて refine を促す error。
        if let Some(p) = plugins.iter().find(|p| p.url == q) {
            return Ok(Some(p.url.clone()));
        }
        let partial: Vec<&str> = plugins
            .iter()
            .filter(|p| p.url.contains(q))
            .map(|p| p.url.as_str())
            .collect();
        match partial.len() {
            0 => Err(anyhow::anyhow!("Plugin not found")),
            1 => Ok(Some(partial[0].to_string())),
            _ => {
                let listing = partial
                    .iter()
                    .map(|u| format!("  - {u}"))
                    .collect::<Vec<_>>()
                    .join("\n");
                Err(anyhow::anyhow!(
                    "Query '{q}' matches multiple plugins; refine your query or omit it to pick interactively:\n{listing}"
                ))
            }
        }
    } else {
        let urls: Vec<String> = plugins.iter().map(|p| p.url.clone()).collect();
        let selection = FuzzySelect::with_theme(&dialoguer::theme::ColorfulTheme::default())
            .with_prompt(prompt)
            .default(0)
            .items(&urls)
            .interact_opt()?;
        Ok(selection.map(|idx| urls[idx].clone()))
    }
}

/// プラグインの build ステップを実行する。`build` (shell) と `build_lua` の両方が
/// 設定されていれば、shell → lua の順に実行する。どちらか一方でも失敗したら
/// 即座にエラー文字列を返す。両方未設定なら `None`。
async fn execute_build_command(
    plugin: &crate::config::Plugin,
    dst_path: &Path,
    config: &crate::config::Config,
    cache_root: &Path,
) -> Option<String> {
    // 早期 return: build step が一つも無ければ rtp 計算自体スキップ (Gemini #99
    // 指摘の short-circuit。大きな depends グラフでの無駄走査を避ける)。
    if plugin.build.is_none() && plugin.build_lua.is_none() {
        return None;
    }

    let rtp_dirs = collect_build_rtp(plugin, dst_path, config, cache_root);

    // 1. shell コマンド (従来挙動)。失敗時は即 return。
    if let Some(err) = run_build_shell(plugin, dst_path, &rtp_dirs).await {
        return Some(err);
    }

    // 2. Lua callable (#97)。例: blink.cmp v2 の `require('blink.cmp').build():wait(...)`。
    if let Some(err) = run_build_lua(plugin, dst_path, &rtp_dirs).await {
        return Some(err);
    }

    None
}

/// build 実行時の rtp 候補一覧 (対象プラグイン + transitive depends パス)。
/// 既存の shell build もこれを使ってきたので、Lua build 側も同じ rtp で揃える。
fn collect_build_rtp(
    plugin: &crate::config::Plugin,
    dst_path: &Path,
    config: &crate::config::Config,
    cache_root: &Path,
) -> Vec<PathBuf> {
    let mut rtp_dirs = vec![dst_path.to_path_buf()];
    let mut visited = std::collections::HashSet::new();
    let mut stack: Vec<String> = plugin.depends.iter().flatten().cloned().collect();
    while let Some(dep) = stack.pop() {
        if !visited.insert(dep.clone()) {
            continue;
        }
        if let Some(dep_plugin) = config
            .plugins
            .iter()
            .find(|p| p.display_name() == dep || p.url == dep)
        {
            let dep_path = resolve_plugin_dst(dep_plugin, cache_root);
            rtp_dirs.push(dep_path);
            if let Some(deeper) = &dep_plugin.depends {
                stack.extend(deeper.clone());
            }
        }
    }
    rtp_dirs
}

/// shell `build` を実行する。未設定 / 成功なら `None`、失敗なら error message。
async fn run_build_shell(
    plugin: &crate::config::Plugin,
    dst_path: &Path,
    rtp_dirs: &[PathBuf],
) -> Option<String> {
    let build_cmd = plugin.build.as_ref()?;
    let (prog, args) = parse_build_command(build_cmd, rtp_dirs);
    let build_timeout = std::time::Duration::from_secs(300); // 5 minutes
    let mut child = match tokio::process::Command::new(&prog)
        .args(&args)
        .current_dir(dst_path)
        .stdout(std::process::Stdio::piped())
        .stderr(std::process::Stdio::piped())
        .spawn()
    {
        Ok(c) => c,
        Err(e) => {
            return Some(format!("build spawn failed: {}", e));
        }
    };
    match tokio::time::timeout(build_timeout, child.wait()).await {
        Ok(Ok(status)) if !status.success() => {
            Some(format!("build failed (exit code: {:?})", status.code()))
        }
        Ok(Err(e)) => Some(format!("build error: {}", e)),
        Err(_) => {
            let _ = child.kill().await;
            Some(format!("build timed out ({}s)", build_timeout.as_secs()))
        }
        _ => None,
    }
}

/// `build_lua` に **lazy.nvim 流の `function() ... end` で囲まれた匿名関数式** が
/// 渡された場合、その body だけを取り出して返す。
///
/// なぜ必要か: rvpm は `build_lua` の文字列を **statement context の Lua スニペット**
/// として exec する (`-l <tmp.lua>`)。素の `function() ... end` は statement では
/// 「function NAME() ... end」と解釈されるので名前必須 (E5112) になる。
/// user は lazy.nvim の `build = function() require(...).build():wait(...) end` から
/// 流用しがちなので、その形を検出して body へ unwrap する。
///
/// **対応する記述**:
/// - 1 行 / 複数行どちらも (regex `(?s)` で `.` が改行マッチ)
/// - `function()` 直後・`end` 直前の任意の空白/改行
/// - `end` の後ろの trailing line コメント (`-- ...`) と空白
/// - 入れ子の `function() ... end` は body 内に **そのまま保持** (lazy quantifier
///   `.*?` + 末尾 anchor `$` で「最後の一番外の `end`」を拾うため)
///
/// **マッチしない**:
/// - `function name() ... end` (named function 宣言は valid statement なので unwrap 不要)
/// - 末尾に statement や `;` が続くケース (lazy.nvim 流ではない)
fn unwrap_anonymous_lua_function(code: &str) -> Option<String> {
    use regex::Regex;
    use std::sync::OnceLock;
    static RE: OnceLock<Regex> = OnceLock::new();
    let re = RE.get_or_init(|| {
        // (?s): . が \n にマッチする dot-all モード。
        // (.*?): lazy match で末尾 anchor `$` と組み合わせて「最後の `end`」を拾う。
        // 末尾の trailing line コメント (`-- ...`) は optional。
        Regex::new(r"(?s)^\s*function\s*\(\s*\)\s*(.*?)\s*end\s*(?:--[^\n\r]*)?\s*$").unwrap()
    });
    re.captures(code)
        .map(|c| c.get(1).map_or("", |m| m.as_str()).trim().to_string())
}

/// Lua callable `build_lua` を実行する (#97)。
///
/// `nvim --headless -u NONE -l <tmp.lua>` で起動し、rtp に対象プラグイン +
/// transitive deps を append した上で user の Lua スニペットを呼ぶ。
///
/// `-u NONE` で user init.lua はスキップするが、`vim.fn.stdpath("data")` 等の
/// real env は維持されるので blink.cmp v2 のように `~/.local/share/nvim/site/lib/`
/// 等にファイルを置きたいケースでも期待通りの場所に届く。
///
/// nvim が PATH に無ければ warn + skip (resilience、helptags と同じ方針)。
async fn run_build_lua(
    plugin: &crate::config::Plugin,
    dst_path: &Path,
    rtp_dirs: &[PathBuf],
) -> Option<String> {
    let raw_lua_code = plugin.build_lua.as_ref()?;

    // user は lazy.nvim の `build = function() ... end` 流れで
    // `build_lua = "function() ... end"` と書きがちだが、Lua の statement context
    // で `function()` (匿名) は名前必須なので E5112 エラーになる。
    // → `function() X end` の wrapping を検出したら body だけを残す。
    let lua_code = unwrap_anonymous_lua_function(raw_lua_code)
        .map(std::borrow::Cow::Owned)
        .unwrap_or(std::borrow::Cow::Borrowed(raw_lua_code.as_str()));

    // rtp_dirs を `vim.opt.rtp:append(...)` の連続で前置する。生のパスを Lua
    // 文字列リテラルに埋め込むので、backslash は `/` に正規化、引用符は escape。
    let mut script = String::new();
    for dir in rtp_dirs {
        let p = dir
            .to_string_lossy()
            .replace('\\', "/")
            .replace('"', "\\\"");
        script.push_str(&format!("vim.opt.rtp:append(\"{p}\")\n"));
    }
    script.push_str(&lua_code);
    script.push('\n');

    // tempfile crate に temp ファイル管理を委ねる (Gemini #99 指摘): manual な
    // SystemTime + process::id 組立ては、process が中断された場合にゴミが残る。
    // `NamedTempFile` は drop で自動削除 + プラットフォームに合った安全な命名を行う。
    let tmp_file = match tempfile::Builder::new()
        .prefix("rvpm-build-")
        .suffix(".lua")
        .tempfile()
    {
        Ok(f) => f,
        Err(e) => return Some(format!("build_lua: failed to create temp script: {e}")),
    };
    let tmp_path = tmp_file.path().to_path_buf();
    // async 経路なので blocking な std::fs::write は tokio::fs::write に置換
    // (Gemini #99 review): executor を塞いで他プラグインの並列 build を妨げないように。
    if let Err(e) = tokio::fs::write(&tmp_path, &script).await {
        return Some(format!("build_lua: failed to write temp script: {e}"));
    }

    let build_timeout = std::time::Duration::from_secs(300);
    let child = match tokio::process::Command::new("nvim")
        .args(["--headless", "-u", "NONE", "-l"])
        .arg(&tmp_path)
        .current_dir(dst_path)
        .stdout(std::process::Stdio::piped())
        .stderr(std::process::Stdio::piped())
        // tokio Command の default は `kill_on_drop = false` なので、timeout 時に
        // future が drop されても child の nvim は走り続けてしまう (Gemini High
        // 指摘の resource leak)。`true` で drop = kill 連動させる。
        .kill_on_drop(true)
        .spawn()
    {
        Ok(c) => c,
        Err(e) => {
            return Some(format!(
                "build_lua: failed to spawn `nvim --headless` ({e}); install nvim or remove `build_lua` from this plugin"
            ));
        }
    };

    let wait_result = tokio::time::timeout(build_timeout, child.wait_with_output()).await;
    // tmp_file は scope を抜ける時 drop で自動削除されるので明示削除は不要。
    drop(tmp_file);

    match wait_result {
        Ok(Ok(out)) if !out.status.success() => {
            let stderr = String::from_utf8_lossy(&out.stderr);
            Some(format!(
                "build_lua failed (exit code: {:?}): {}",
                out.status.code(),
                stderr.trim()
            ))
        }
        Ok(Ok(_)) => None,
        Ok(Err(e)) => Some(format!("build_lua error: {e}")),
        Err(_) => Some(format!(
            "build_lua timed out ({}s)",
            build_timeout.as_secs()
        )),
    }
}

/// A plugin that sync left at a lockfile-pinned commit while its remote
/// has advanced. Reported in the sync summary so users know `rvpm update`
/// would actually change something.
#[derive(Debug, Clone, PartialEq, Eq)]
struct HeldBackPin {
    name: String,
    pinned: String,
    remote: String,
}

/// Decide whether a just-synced plugin is being "held back" by a lockfile
/// pin. Returns `Some((pinned_commit, remote_tip))` when:
///
/// - the plugin has **no explicit `rev`** (not the user's choice),
/// - the **lockfile contributed** an effective rev (pin came from the lockfile),
/// - and the pinned HEAD is **behind the remote tracking tip**.
///
/// Returns `None` otherwise — explicit `rev`, no lockfile contribution,
/// already at remote tip, or either commit unknown (treat unknowns as
/// "not held back" to avoid false positives).
///
/// This is the pure classification step; the caller is responsible for
/// composing the plugin display name and emitting the summary line.
fn classify_held_back(
    plugin_rev: Option<&str>,
    effective_rev: Option<&str>,
    head_commit: Option<&str>,
    remote_head: Option<&str>,
) -> Option<(String, String)> {
    if plugin_rev.is_some() {
        return None;
    }
    effective_rev?;
    let head = head_commit?;
    let remote = remote_head?;
    if head != remote {
        Some((head.to_string(), remote.to_string()))
    } else {
        None
    }
}

/// 現在の plugin が `--rebuild [QUERY]` のスコープに入るか判定する。
///
/// `rebuild_filter_lc` は **呼び出し側で小文字化済み** の前提。`run_sync` は N
/// プラグインを回すので、N 回同じ query を lowercase するコストを避けるため
/// 事前正規化を外側に持たせている。
///
/// - `None` (フラグ未指定) → 常に false (build 強制しない)
/// - `Some("")` (フラグのみ、値無し) → 常に true (全 plugin)
/// - `Some(q)` (フラグ + query) → plugin の url / name の
///   いずれかに q が含まれれば true。url 側で決着すれば name の lowercase
///   allocation は走らない (短絡評価)。
fn matches_rebuild_filter(plugin: &crate::config::Plugin, rebuild_filter_lc: Option<&str>) -> bool {
    match rebuild_filter_lc {
        None => false,
        Some("") => true,
        Some(qlc) => {
            plugin.url.to_ascii_lowercase().contains(qlc)
                || plugin
                    .name
                    .as_deref()
                    .is_some_and(|n| n.to_ascii_lowercase().contains(qlc))
        }
    }
}

async fn run_sync(
    prune: bool,
    frozen: bool,
    no_lock: bool,
    rebuild: Option<String>,
    refresh: bool,
    no_refresh: bool,
) -> Result<()> {
    // `--frozen` は lockfile に依存した strict check、`--no-lock` は lockfile を
    // 完全無視する escape hatch。両方立つと「strict のつもりが silently latest を
    // pull する」矛盾状態になるので、fail fast させる (CI の思い込みを防ぐ)。
    if frozen && no_lock {
        anyhow::bail!(
            "--frozen cannot be combined with --no-lock (they contradict: one requires the lockfile, the other ignores it)"
        );
    }
    // `--refresh` / `--no-refresh` は clap 側の `conflicts_with` で既に弾かれて
    // いるが念のため。
    if refresh && no_refresh {
        anyhow::bail!("--refresh cannot be combined with --no-refresh");
    }
    let refresh_mode = if refresh {
        crate::fetch_state::RefreshMode::Force
    } else if no_refresh {
        crate::fetch_state::RefreshMode::Skip
    } else {
        crate::fetch_state::RefreshMode::Auto
    };

    let config_path = rvpm_config_path();
    let toml_content = std::fs::read_to_string(&config_path)
        .with_context(|| format!("Failed to read config file: {}", config_path.display()))?;

    let mut config_data = parse_config(&toml_content)?;
    crate::config::sort_plugins(&mut config_data.plugins)?;
    for plugin in config_data.plugins.iter_mut() {
        disable_merge_if_cond(plugin);
    }
    let config = Arc::new(config_data);

    let cache_root = resolve_cache_root(config.options.cache_root.as_deref());
    let merged_dir = resolve_merged_dir(&cache_root);

    // lockfile: sync 前に load、各 plugin 処理時に効く rev を引き、sync 後の HEAD
    // で上書きして終端で save。`--no-lock` 時は load/save 両方スキップ。
    // `--frozen` は全 non-dev plugin に lockfile entry があることを要求する
    // (無ければ sync 開始前に bail) — CI / fresh machine で strict 再現を狙うケース。
    let config_root_for_lock = resolve_config_root(config.options.config_root.as_deref());
    let lockfile_path = resolve_lockfile_path(&config_root_for_lock);
    let mut lockfile = if no_lock {
        crate::lockfile::LockFile::default()
    } else {
        crate::lockfile::LockFile::load(&lockfile_path)
    };
    if frozen && !no_lock {
        // 2 種類の問題を区別して報告する:
        // - **missing**: lockfile にエントリ自体が無い
        // - **stale**: エントリはあるが `url` が config と食い違っている
        //   (例: 同 display_name で別リポジトリに差し替えた)
        //   → 古い commit を適用すると sync がコケるので未ロック相当として拒否。
        let mut issues: Vec<String> = Vec::new();
        for plugin in config.plugins.iter().filter(|p| !p.dev) {
            match lockfile.find(&plugin.display_name()) {
                None => issues.push(format!("{} (missing)", plugin.display_name())),
                Some(entry) if !urls_match(&entry.url, &plugin.url) => issues.push(format!(
                    "{} (stale: lockfile url={}, config url={})",
                    plugin.display_name(),
                    entry.url,
                    plugin.url,
                )),
                Some(_) => {}
            }
        }
        if !issues.is_empty() {
            anyhow::bail!(
                "--frozen: {} plugin(s) not reproducible from {}:\n  {}",
                issues.len(),
                lockfile_path.display(),
                issues.join("\n  "),
            );
        }
    }
    // plugin name -> 該当 LockEntry の lookup。`commit` に加えて `url` も
    // 保持する理由は、同じ display_name で別リポジトリに差し替えられたケース
    // (例: `owner/foo.nvim` → `different-owner/foo.nvim`) で古い commit を適用
    // しないように URL の一致を確認してから checkout 対象とするため。URL 不一致
    // なら未ロック扱い (sync 完了後の新 HEAD で lockfile 側が正しく上書きされる)。
    let locked_entries: std::collections::HashMap<String, crate::lockfile::LockEntry> = if no_lock {
        std::collections::HashMap::new()
    } else {
        lockfile
            .plugins
            .iter()
            .map(|e| (e.name.clone(), e.clone()))
            .collect()
    };

    // fetch cache: per-plugin 最終 fetch 時刻を読み込んで、staleness window 内の
    // プラグインの fetch をスキップするための判定テーブルを作る。
    // - state の場所は `<cache_root>/fetch_state.json`
    // - `options.fetch_interval` でウィンドウサイズ、CLI の `--refresh` / `--no-refresh`
    //   で上書き可能
    // - state 読み込みは lockfile と同じく malformed 時は empty fallback (resilience)
    let fetch_state_path = resolve_fetch_state_path(&cache_root);
    let mut fetch_state = crate::fetch_state::FetchState::load(&fetch_state_path);
    let fetch_interval =
        crate::fetch_state::resolve_fetch_interval(config.options.fetch_interval.as_deref());
    let now_sys = std::time::SystemTime::now();
    // name → &FetchEntry の lookup map を先に組んで、plugin ごとの find を O(1) に
    // する (fetch_state.find は線形スキャンなので、200+ plugin 構成で pre-compute
    // 全体が O(N^2) になってしまう)。URL 不一致 (= 同じ name で別リポジトリに
    // 差し替えられた等) は lockfile と同じく「未管理」扱いにして last_fetched を
    // 無視する — 旧 URL のタイムスタンプで fetch を省略して、新 URL の fetch を
    // 取りこぼすのを防ぐ。
    let fetch_lookup: std::collections::HashMap<&str, &crate::fetch_state::FetchEntry> =
        fetch_state
            .entries
            .iter()
            .map(|e| (e.name.as_str(), e))
            .collect();
    let fetch_decisions: std::collections::HashMap<String, bool> = config
        .plugins
        .iter()
        .filter(|p| !p.dev)
        .map(|p| {
            let name = p.display_name();
            let last = fetch_lookup
                .get(name.as_str())
                .filter(|e| urls_match(&e.url, &p.url))
                .map(|e| e.last_fetched.as_str());
            (
                name,
                crate::fetch_state::should_fetch(last, now_sys, fetch_interval, refresh_mode),
            )
        })
        .collect();
    let is_hard_skip = refresh_mode == crate::fetch_state::RefreshMode::Skip;

    if merged_dir.exists() {
        let _ = std::fs::remove_dir_all(&merged_dir);
    }
    std::fs::create_dir_all(&merged_dir)?;

    let icons = crate::tui::Icons::from_style(config.options.icons);

    enable_raw_mode()?;
    let mut stdout = std::io::stdout();
    execute!(stdout, EnterAlternateScreen)?;
    let backend = CrosstermBackend::new(stdout);
    let mut terminal = ratatui::Terminal::new(backend)?;

    let urls: Vec<String> = config.plugins.iter().map(|p| p.url.clone()).collect();
    let mut tui_state = TuiState::new(urls);
    let (tx, mut rx) = mpsc::channel::<(String, PluginStatus)>(100);

    let concurrency = resolve_concurrency(config.options.concurrency);
    let semaphore = Arc::new(tokio::sync::Semaphore::new(concurrency));
    let mut set = JoinSet::new();

    // `--rebuild [QUERY]` のクエリは plugin 数だけループで照合するので、
    // ここで 1 回だけ lowercase して per-plugin の再計算を避ける。
    // `None` / `Some("")` はそのまま、`Some(q)` は `Some(q.to_lowercase())`。
    let rebuild_filter_lc: Option<String> = rebuild.as_deref().map(|q| q.to_ascii_lowercase());

    for plugin in config.plugins.iter() {
        // dev プラグインは sync をスキップ (ローカル開発中のためリセットしない)
        if plugin.dev {
            let dst_path = resolve_plugin_dst(plugin, &cache_root);
            if !dst_path.exists() {
                let _ = tx.try_send((
                    plugin.url.clone(),
                    PluginStatus::Failed(format!(
                        "dev directory not found: {}",
                        dst_path.display()
                    )),
                ));
            } else {
                let _ = tx.try_send((plugin.url.clone(), PluginStatus::Finished));
            }
            continue;
        }
        let plugin = plugin.clone();
        let cache_root = cache_root.clone();
        let tx = tx.clone();
        let sem = semaphore.clone();

        // effective rev: plugin.rev (explicit) > lockfile commit (URL 一致時のみ) > None.
        // URL 不一致時 (同名で別リポジトリに差し替え等) は未ロック扱いにして、誤った
        // commit を適用して sync がコケるのを防ぐ — sync 完了後に新 HEAD で lockfile
        // 側が上書きされて正しい URL/commit の対応に直る。
        // `--no-lock` 時は locked_entries が空なので plugin.rev だけ効く。
        let effective_rev: Option<String> = plugin.rev.clone().or_else(|| {
            locked_entries
                .get(&plugin.display_name())
                .filter(|e| urls_match(&e.url, &plugin.url))
                .map(|e| e.commit.clone())
        });

        // pre-computed fetch decision: true なら通常フロー、false なら fast-path
        // 候補。clone 欠損 or HEAD != effective_rev で fast-path が使えないときは、
        // `--no-refresh` (is_hard_skip) なら error、それ以外なら full flow にフォール。
        let want_fetch = *fetch_decisions.get(&plugin.display_name()).unwrap_or(&true);
        let hard_skip = is_hard_skip;

        // --rebuild [QUERY] のスコープ判定は closure 外で済ませて bool を move する
        // (`rebuild_filter: &Option<String>` のライフタイムを async move に引き込まない)。
        // query は外側で lowercase 済み (`rebuild_filter_lc`)。
        let force_rebuild = matches_rebuild_filter(&plugin, rebuild_filter_lc.as_deref());

        let config_for_build = config.clone();
        set.spawn(async move {
            let _permit = sem.acquire_owned().await.unwrap();
            let dst_path = resolve_plugin_dst(&plugin, &cache_root);
            let _ = tx
                .send((
                    plugin.url.clone(),
                    PluginStatus::Syncing("Syncing...".to_string()),
                ))
                .await;
            let repo = Repo::new(&plugin.url, &dst_path, effective_rev.as_deref());
            // sync 実行判定:
            //   want_fetch=true                         → full flow (git fetch + checkout)
            //   want_fetch=false, HEAD == target        → no-op fast path
            //   want_fetch=false, local checkout 可能    → fast path (fetch なし checkout だけ)
            //   want_fetch=false, local で満たせない     → hard_skip なら error、それ以外は full flow
            //
            // effective_rev が branch/tag でも local DB で SHA に解決してから比較
            // するので `rev = "main"` / `rev = "v1.2.3"` 系でも fast path が効く。
            // None (pin 無し) は「HEAD 自体を target」として no-op 扱い。
            //
            // HEAD != target でも commit が local にあれば、fetch せず checkout
            // だけで満たせる (最近の window 内で別 rev へ切替えたケースなど) ので
            // `checkout_locally` を試して成功すれば fast path 継続、失敗したら
            // full flow に fall through する。
            let mut fetched = false;
            let res: Result<Option<crate::git::GitChange>> = if want_fetch {
                fetched = true;
                repo.sync().await
            } else {
                let head_now = if dst_path.exists() {
                    repo.head_commit().await.ok()
                } else {
                    None
                };
                let target_sha: Option<String> = match effective_rev.as_deref() {
                    None => head_now.clone(),
                    Some(rev) => repo.resolve_revision_locally(rev).await.ok().flatten(),
                };
                if head_now.is_some() && head_now == target_sha {
                    Ok(None)
                } else if let Some(rev) = effective_rev.as_deref().filter(|_| dst_path.exists()) {
                    // HEAD != target だけど clone はある → local-only checkout を試す。
                    // 成功 = commit が local DB にあって切替えできた (fetched=false のまま)。
                    // 失敗 = commit が local に無い → full flow か error。
                    match repo.checkout_locally(rev).await {
                        Ok(change) => Ok(change),
                        Err(e) => {
                            if hard_skip {
                                Err(anyhow::anyhow!(
                                    "{}: --no-refresh cannot be satisfied locally (rev '{}' not in local object DB: {})",
                                    plugin.display_name(),
                                    rev,
                                    e,
                                ))
                            } else {
                                fetched = true;
                                repo.sync().await
                            }
                        }
                    }
                } else if hard_skip {
                    Err(anyhow::anyhow!(
                        "{}: --no-refresh cannot be satisfied (plugin not cloned)",
                        plugin.display_name()
                    ))
                } else {
                    fetched = true;
                    repo.sync().await
                }
            };
            match res {
                Ok(change) => {
                    // build は HEAD が動いたとき (= fresh clone or pull で新 commit を
                    // 取得したとき) だけ実行する。no-op sync で毎回 build を回すのは
                    // 200+ プラグイン構成では体感で遅い。`--rebuild` で従来挙動 (常に
                    // 全 build プラグインで実行) に戻せるので、`:TSUpdate` 等を強制
                    // 走らせたいときの逃げ道は確保。
                    let has_any_build = plugin.build.is_some() || plugin.build_lua.is_some();
                    let should_build = has_any_build && (force_rebuild || change.is_some());
                    let build_warn = if should_build {
                        // status 表示は shell コマンドを優先 (具体的に何が走るか
                        // user が分かるから)。shell が無く lua のみなら "(lua build)"
                        // で済ます — Lua スニペット全文は冗長なので。
                        let label = match plugin.build.as_deref() {
                            Some(cmd) if plugin.build_lua.is_some() => format!("{cmd} + (lua)"),
                            Some(cmd) => cmd.to_string(),
                            None => "(lua build)".to_string(),
                        };
                        let _ = tx
                            .send((
                                plugin.url.clone(),
                                PluginStatus::Syncing(format!("Building: {label}")),
                            ))
                            .await;
                        execute_build_command(&plugin, &dst_path, &config_for_build, &cache_root)
                            .await
                    } else {
                        None
                    };
                    if let Some(ref err) = build_warn {
                        let _ = tx
                            .send((
                                plugin.url.clone(),
                                PluginStatus::Syncing(format!("Build warning: {}", err)),
                            ))
                            .await;
                    }
                    // lockfile 記録用に現在の HEAD commit を確定させる。
                    // GitChange は HEAD が動いた時のみ返されるので no-op sync でも
                    // lockfile エントリが新規作成できるよう別途取得する。
                    // 失敗しても sync 本体の成否には影響させない (resilience)。
                    let head_commit = repo.head_commit().await.ok();
                    // held-back 判定用に remote tracking tip を読む (HEAD は動かさない)。
                    // 失敗時は None → classify_held_back 側で「判定不能」扱いになり、
                    // 結果として held_back リストには積まれない (resilience)。
                    //
                    // fast-path (fetched = false) で分類すると local の remote tracking
                    // ref が stale なので、window 中に上流が動いたケースで false positive
                    // を出す。fast path では一律 None にして、window 超過で full fetch
                    // したタイミングで再評価する。
                    //
                    // 分類が None 確定の前提条件 (explicit rev / lockfile 寄与なし) では
                    // gix open 自体をスキップして 200+ プラグイン構成の I/O を減らす。
                    let held_back = if fetched {
                        let remote_head = if plugin.rev.is_none() && effective_rev.is_some() {
                            repo.remote_head().await.ok().flatten()
                        } else {
                            None
                        };
                        classify_held_back(
                            plugin.rev.as_deref(),
                            effective_rev.as_deref(),
                            head_commit.as_deref(),
                            remote_head.as_deref(),
                        )
                        .map(|(pinned, remote)| HeldBackPin {
                            name: plugin.display_name(),
                            pinned,
                            remote,
                        })
                    } else {
                        None
                    };
                    let _ = tx.send((plugin.url.clone(), PluginStatus::Finished)).await;
                    Ok((plugin, dst_path, build_warn, change, head_commit, held_back, fetched))
                }
                Err(e) => {
                    let _ = tx
                        .send((plugin.url.clone(), PluginStatus::Failed(e.to_string())))
                        .await;
                    Err(e)
                }
            }
        });
    }

    // 全タスクを spawn し終えたので元の tx を drop。
    // これにより全タスク完了後に rx が閉じ、channel のリークを防ぐ。
    drop(tx);

    // dev プラグインは sync しないが loader には含めるので先に scripts を作る
    let mut plugin_scripts = Vec::new();
    let mut merge_conflicts: Vec<crate::merge_conflicts::MergeConflictReport> = Vec::new();
    // merged/ 相対 path → 勝者 plugin 名。順次 merge しながら積み上げて、
    // 後続 plugin の衝突時に勝者を lookup するのに使う。
    let mut merge_ownership: std::collections::HashMap<PathBuf, String> =
        std::collections::HashMap::new();
    let config_root = resolve_config_root(config.options.config_root.as_deref());
    for plugin in config.plugins.iter().filter(|p| p.dev) {
        let dst_path = resolve_plugin_dst(plugin, &cache_root);
        let plugin_config_dir = resolve_plugin_config_dir(&config_root, plugin);
        if plugin.merge && !plugin.lazy {
            merge_and_record(
                &dst_path,
                &merged_dir,
                &plugin.display_name(),
                &mut merge_ownership,
                &mut merge_conflicts,
            );
        }
        plugin_scripts.push(build_plugin_scripts(plugin, &dst_path, &plugin_config_dir));
    }

    let mut build_warnings: Vec<(String, String)> = Vec::new();
    let mut sync_changes: Vec<crate::update_log::ChangeRecord> = Vec::new();
    // lockfile pin で最新から置いてけぼりになっているプラグインを集めて、sync 末尾に
    // まとめて「`rvpm update` で進めて」と案内する (罠の緩和)。
    let mut held_back: Vec<HeldBackPin> = Vec::new();
    let mut finished_tasks = 0;
    let dev_count = config.plugins.iter().filter(|p| p.dev).count();
    let total_tasks = config.plugins.len() - dev_count;

    while finished_tasks < total_tasks {
        terminal.draw(|f| tui_state.draw(f, "syncing...", &icons))?;

        // sync/update 中のイベントキューを drain してスクロール操作を受け付ける
        while crossterm::event::poll(std::time::Duration::from_millis(0))? {
            if let crossterm::event::Event::Key(key) = crossterm::event::read()? {
                tui_state.handle_scroll_key(key, terminal.size()?.height);
            }
        }

        tokio::select! {
            Some((url, status)) = rx.recv() => { tui_state.update_status(&url, status); }
            Some(res) = set.join_next() => {
                finished_tasks += 1;
                if let Ok(Ok((plugin, dst_path, build_warn, git_change, head_commit, pin, fetched))) = res {
                    if let Some(warn) = build_warn {
                        build_warnings.push((plugin.url.clone(), warn));
                    }
                    if let Some(change) = git_change {
                        sync_changes.push(change_record_from(&plugin, change));
                    }
                    if let Some(p) = pin {
                        held_back.push(p);
                    }
                    // fetch を実行したプラグインだけ last_fetched を更新する。
                    // fast-path で素通ししたプラグインは元のタイムスタンプを保つ
                    // ので次回も window 判定に同じ起点を使う。
                    if fetched {
                        fetch_state.upsert(crate::fetch_state::FetchEntry {
                            name: plugin.display_name(),
                            url: plugin.url.clone(),
                            last_fetched: crate::fetch_state::now_rfc3339(),
                        });
                    }
                    // lockfile 更新: sync 完了後の現 HEAD を pin として記録。
                    // `--no-lock` 時もこの in-memory 更新は行うが、terminal save は飛ばすので
                    // ディスクには書かれない (lockfile 自体が default 空インスタンスのまま)。
                    if let Some(commit) = head_commit {
                        lockfile.upsert(crate::lockfile::LockEntry {
                            name: plugin.display_name(),
                            url: plugin.url.clone(),
                            commit,
                        });
                    }
                    // lazy プラグインは merge しない (trigger 前に merged/ 経由で
                    // lua モジュールが rtp に漏れて lazy の意味がなくなるため)
                    if plugin.merge && !plugin.lazy {
                        merge_and_record(
                            &dst_path,
                            &merged_dir,
                            &plugin.display_name(),
                            &mut merge_ownership,
                            &mut merge_conflicts,
                        );
                    }
                    let config_root = resolve_config_root(config.options.config_root.as_deref());
                    let plugin_config_dir = resolve_plugin_config_dir(&config_root, &plugin);
                    let scripts = build_plugin_scripts(&plugin, &dst_path, &plugin_config_dir);
                    plugin_scripts.push(scripts);
                }
            }
            _ = tokio::time::sleep(std::time::Duration::from_millis(50)) => {}
        }
    }

    // JoinSet は完了順で返すので plugin_scripts が依存順になっていない。
    // config.plugins の順序 (sort_plugins 済み) に合わせて re-sort する。
    plugin_scripts.sort_by_key(|ps| {
        config
            .plugins
            .iter()
            .position(|p| p.display_name() == ps.name)
            .unwrap_or(usize::MAX)
    });

    // lazy → eager 昇格後に merge が必要なプラグインを追加で merge する。
    // sync 時点では lazy のため merge されなかったが、depends/on_source により
    // eager に昇格されるプラグインは merged/ にリンクが必要。
    let promoted = crate::loader::promote_lazy_to_eager(&mut plugin_scripts);
    if !promoted.is_empty() {
        for ps in &plugin_scripts {
            if promoted.contains(&ps.name) && ps.merge {
                let dst = PathBuf::from(&ps.path);
                merge_and_record(
                    &dst,
                    &merged_dir,
                    &ps.name,
                    &mut merge_ownership,
                    &mut merge_conflicts,
                );
            }
        }
    }

    terminal.draw(|f| tui_state.draw(f, "syncing...", &icons))?;
    tokio::time::sleep(std::time::Duration::from_millis(500)).await;
    // TUI cleanup — 各ステップが失敗しても次を続行してターミナルを確実に復元する
    let _ = disable_raw_mode();
    let _ = execute!(terminal.backend_mut(), LeaveAlternateScreen);
    let _ = terminal.show_cursor();

    // sync 結果のサマリーを出力 (TUI 閉じた後なので見える)
    // plugins 順で出力して決定的な順序を保つ
    let failed: Vec<_> = tui_state
        .plugins
        .iter()
        .filter_map(|url| match tui_state.status_map.get(url) {
            Some(PluginStatus::Failed(msg)) => Some((url.as_str(), msg.as_str())),
            _ => None,
        })
        .collect();
    if !failed.is_empty() {
        eprintln!("\n{} error(s):", failed.len());
        for (url, msg) in &failed {
            eprintln!("  \u{2717} {}: {}", url, msg);
        }
    }
    if !build_warnings.is_empty() {
        eprintln!("\n{} build warning(s):", build_warnings.len());
        for (url, msg) in &build_warnings {
            eprintln!("  \u{26a0} {}: {}", url, msg);
        }
    }
    if !promoted.is_empty() {
        let mut sorted_promoted: Vec<_> = promoted.iter().collect();
        sorted_promoted.sort();
        eprintln!("\n{} plugin(s) promoted lazy -> eager:", promoted.len());
        for name in &sorted_promoted {
            eprintln!("  -> {}", name);
        }
    }
    // lockfile pin で最新から置いてけぼりのプラグインを案内する。`rvpm sync` は
    // pin を尊重する (= update しない) 設計なので、rev を設定していないユーザーが
    // 「sync したのに古いまま」と感じる罠を緩和する目的。
    if !held_back.is_empty() {
        held_back.sort_by(|a, b| a.name.cmp(&b.name));
        eprintln!(
            "\n{} plugin(s) held at lockfile pin (no `rev` set). Run `rvpm update` to advance:",
            held_back.len()
        );
        for pin in &held_back {
            eprintln!(
                "  -> {}  {} -> {}",
                pin.name,
                crate::update_log::short_hash(&pin.pinned),
                crate::update_log::short_hash(&pin.remote),
            );
        }
    }
    println!("Generating loader.lua...");
    let loader_path = resolve_loader_path(&cache_root);
    write_loader_to_path(
        &merged_dir,
        &plugin_scripts,
        &loader_path,
        &build_loader_options(&config_root),
    )?;
    println!("Done! -> {}", loader_path.display());

    if config.options.auto_helptags {
        println!("Generating helptags...");
        let report = crate::helptags::build_helptags(&plugin_scripts, &merged_dir).await?;
        match (report.ran, report.exit_code) {
            (true, Some(0)) => {
                println!(
                    "Done! helptags built for {} doc director(y/ies)",
                    report.target_count
                );
            }
            (true, Some(code)) => {
                eprintln!(
                    "\u{26a0} helptags: nvim exited with code {} ({} target(s) attempted)",
                    code, report.target_count
                );
            }
            (true, None) => {
                eprintln!(
                    "\u{26a0} helptags: nvim terminated without exit code ({} target(s) attempted)",
                    report.target_count
                );
            }
            (false, _) => {
                // build_helptags 内で warn を流済み (nvim 不在 / target 0)。
            }
        }
    }

    // 未使用 plugin ディレクトリの処理:
    //  - `--prune` フラグまたは `options.auto_clean = true` で自動削除
    //  - それ以外なら警告のみ (rvpm clean で後処理できる旨を案内)
    let force = prune || config.options.auto_clean;
    let (count, unused) = maybe_prune_unused_repos(&config, &cache_root, force);
    if !force && count > 0 {
        println!();
        println!(
            "\u{26a0} Found {} unused plugin {}:",
            count,
            plural("directory", "directories", count),
        );
        for path in &unused {
            println!("    {}", path.display());
        }
        println!(
            "  Run `rvpm clean` (fast, no git) or `rvpm sync --prune` to delete them,\n  \
             or set `auto_clean = true` under `[options]` to do it automatically."
        );
    }

    print_merge_conflicts(&merge_conflicts);
    // 直近 sync の衝突 snapshot を atomic write (empty でも上書き)。
    // doctor が読む。失敗しても本処理は止めない (resilience)。
    let mc_path = resolve_merge_conflicts_path(&cache_root);
    if let Err(e) = crate::merge_conflicts::save_snapshot(&mc_path, merge_conflicts.clone()) {
        eprintln!(
            "\u{26a0} failed to save {}: {} (doctor state may be stale)",
            mc_path.display(),
            e
        );
    }

    // 並列 spawn 完了順で積まれているので plugin 名で安定 sort。
    // `rvpm log` の出力が同じ sync 結果に対して常に同じになる。
    sync_changes.sort_by(|a, b| a.name.cmp(&b.name));
    record_changes_or_warn(&cache_root, "sync", sync_changes);

    // lockfile save: config.toml から外されたプラグインの entry を drop してから
    // atomic write。`--no-lock` 時は完全スキップ (ディスクに触らない = 既存の
    // lockfile がユーザーの dotfile にあってもそのまま保つ)。
    // `options.chezmoi = true` なら source 側に書いて `chezmoi apply` で target
    // へ反映する (config.toml / hook ファイルと同じ流儀)。
    if !no_lock {
        let active: std::collections::HashSet<String> = config
            .plugins
            .iter()
            .filter(|p| !p.dev)
            .map(|p| p.display_name())
            .collect();
        lockfile.retain_by_names(&active);
        let wp = chezmoi::write_path(config.options.chezmoi, &lockfile_path).await;
        if let Err(e) = lockfile.save(&wp) {
            eprintln!(
                "\u{26a0} failed to save {}: {} (lockfile not updated)",
                wp.display(),
                e
            );
        } else {
            chezmoi::apply(&wp, &lockfile_path).await;
        }
    }

    // fetch cache の永続化は lockfile とは独立。`--no-lock` でも保存する
    // (ephemeral cache は dotfile 管理の reproducibility と無関係で、ユーザー
    // マシン単位のローカル最適化だけの話なので)。config.toml から外された
    // プラグインの entry は drop する。
    let active_names: std::collections::HashSet<String> = config
        .plugins
        .iter()
        .filter(|p| !p.dev)
        .map(|p| p.display_name())
        .collect();
    fetch_state.retain_by_names(&active_names);
    if let Err(e) = fetch_state.save(&fetch_state_path) {
        eprintln!(
            "\u{26a0} failed to save {}: {} (fetch cache may be stale)",
            fetch_state_path.display(),
            e
        );
    }

    print_init_lua_hint_if_missing(&config);
    Ok(())
}

/// `rvpm clean` — git 操作なしで、config.toml に無いプラグインディレクトリだけを削除する。
/// プラグイン数が多い環境で `sync --prune` が重いケースの受け皿。
/// 非同期処理は無いので `async` は付けない (clippy::unused_async 回避)。
fn run_clean() -> Result<()> {
    let config_path = rvpm_config_path();
    let toml_content = std::fs::read_to_string(&config_path)
        .with_context(|| format!("Failed to read config file: {}", config_path.display()))?;
    let config = parse_config(&toml_content)?;

    let cache_root = resolve_cache_root(config.options.cache_root.as_deref());
    let repos_dir = resolve_repos_dir(&cache_root);
    if !repos_dir.exists() {
        println!(
            "No repos directory at {} — nothing to clean.",
            repos_dir.display()
        );
        return Ok(());
    }

    // force=true で即削除。空なら helper は (0, []) を返すので別メッセージを出す。
    let (count, _leftover) = maybe_prune_unused_repos(&config, &cache_root, true);
    if count == 0 {
        println!(
            "No unused plugin directories under {}.",
            repos_dir.display()
        );
    }
    Ok(())
}

async fn run_generate() -> Result<()> {
    let config_path = rvpm_config_path();
    let toml_content = std::fs::read_to_string(&config_path)
        .with_context(|| format!("Failed to read config file: {}", config_path.display()))?;
    let mut config = parse_config(&toml_content)?;
    crate::config::sort_plugins(&mut config.plugins)?;
    for plugin in config.plugins.iter_mut() {
        disable_merge_if_cond(plugin);
    }
    let cache_root = resolve_cache_root(config.options.cache_root.as_deref());
    let merged_dir = resolve_merged_dir(&cache_root);
    let loader_path = resolve_loader_path(&cache_root);

    let mut plugin_scripts = Vec::new();
    let config_root = resolve_config_root(config.options.config_root.as_deref());
    for plugin in &config.plugins {
        let dst_path = resolve_plugin_dst(plugin, &cache_root);
        let plugin_config_dir = resolve_plugin_config_dir(&config_root, plugin);
        plugin_scripts.push(build_plugin_scripts(plugin, &dst_path, &plugin_config_dir));
    }

    // lazy → eager 昇格を適用。generate 単独実行時は merged/ が stale な可能性が
    // あるため、全 eager + merge プラグインを再構築する。
    crate::loader::promote_lazy_to_eager(&mut plugin_scripts);
    if merged_dir.exists() {
        let _ = std::fs::remove_dir_all(&merged_dir);
    }
    std::fs::create_dir_all(&merged_dir)?;
    let mut merge_conflicts: Vec<crate::merge_conflicts::MergeConflictReport> = Vec::new();
    let mut merge_ownership: std::collections::HashMap<PathBuf, String> =
        std::collections::HashMap::new();
    for ps in &plugin_scripts {
        if !ps.lazy && ps.merge {
            let dst = PathBuf::from(&ps.path);
            if dst.exists() {
                merge_and_record(
                    &dst,
                    &merged_dir,
                    &ps.name,
                    &mut merge_ownership,
                    &mut merge_conflicts,
                );
            }
        }
    }

    println!("Generating loader.lua...");
    write_loader_to_path(
        &merged_dir,
        &plugin_scripts,
        &loader_path,
        &build_loader_options(&config_root),
    )?;
    println!("Done! -> {}", loader_path.display());

    if config.options.auto_helptags {
        println!("Generating helptags...");
        let report = crate::helptags::build_helptags(&plugin_scripts, &merged_dir).await?;
        match (report.ran, report.exit_code) {
            (true, Some(0)) => {
                println!(
                    "Done! helptags built for {} doc director(y/ies)",
                    report.target_count
                );
            }
            (true, Some(code)) => {
                eprintln!(
                    "\u{26a0} helptags: nvim exited with code {} ({} target(s) attempted)",
                    code, report.target_count
                );
            }
            (true, None) => {
                eprintln!(
                    "\u{26a0} helptags: nvim terminated without exit code ({} target(s) attempted)",
                    report.target_count
                );
            }
            (false, _) => {
                // build_helptags 内で warn を流済み (nvim 不在 / target 0)。
            }
        }
    }

    // `options.auto_clean = true` なら config から外されたプラグインディレクトリも
    // 自動削除 (git 操作は行わないので generate 自体のコストは増えない)。
    if config.options.auto_clean {
        let _ = maybe_prune_unused_repos(&config, &cache_root, true);
    }

    print_merge_conflicts(&merge_conflicts);
    // 直近 generate の衝突 snapshot を保存 (sync と同じ扱い)。
    let mc_path = resolve_merge_conflicts_path(&cache_root);
    if let Err(e) = crate::merge_conflicts::save_snapshot(&mc_path, merge_conflicts.clone()) {
        eprintln!(
            "\u{26a0} failed to save {}: {} (doctor state may be stale)",
            mc_path.display(),
            e
        );
    }
    print_init_lua_hint_if_missing(&config);
    Ok(())
}

/// `rvpm doctor` エントリポイント。config を読み、各チェックを走らせて
/// 診断レポートを stdout に出し、exit code を返す。
async fn run_doctor() -> Result<i32> {
    let config_path = rvpm_config_path();
    let toml_content = match std::fs::read_to_string(&config_path) {
        Ok(c) => c,
        Err(e) => {
            println!("rvpm doctor — diagnostic report");
            println!();
            println!("Config");
            println!(
                "  \u{2717} config.toml              — failed to read: {}",
                e
            );
            println!("      hint: run `rvpm init --write` or create the file");
            println!();
            println!("Summary: 0 ok  ·  0 warn  ·  1 error   (exit 1)");
            return Ok(1);
        }
    };
    let mut config = match parse_config(&toml_content) {
        Ok(c) => c,
        Err(e) => {
            println!("rvpm doctor — diagnostic report");
            println!();
            println!("Config");
            println!("  \u{2717} config.toml              — parse error: {}", e);
            println!();
            println!("Summary: 0 ok  ·  0 warn  ·  1 error   (exit 1)");
            return Ok(1);
        }
    };
    // sort_plugins は副作用で stderr に出るがエラーにはならない。doctor は
    // 自前で cycles / missing refs を検出するので sort_plugins は呼ばない。
    for plugin in config.plugins.iter_mut() {
        disable_merge_if_cond(plugin);
    }

    let cache_root = resolve_cache_root(config.options.cache_root.as_deref());
    let merged_dir = resolve_merged_dir(&cache_root);
    let loader_path = resolve_loader_path(&cache_root);
    let init_lua_path = nvim_init_lua_path();
    let repos_dir = resolve_repos_dir(&cache_root);

    // 未使用 repo の検出 (find_unused_repos を再利用)。repos_dir が無い場合は空。
    let mut unused: Vec<PathBuf> = if repos_dir.exists() {
        find_unused_repos(&config, &cache_root, &repos_dir).unwrap_or_default()
    } else {
        Vec::new()
    };
    unused.sort();

    // appname coherence
    let rvpm_env = std::env::var("RVPM_APPNAME").ok();
    let nvim_env = std::env::var("NVIM_APPNAME").ok();
    let resolved = appname();

    // resolve_dst は doctor 内で clone() 可能なクロージャに閉じ込める
    let cache_root_for_fn = cache_root.clone();
    let resolve_dst = Box::new(move |p: &crate::config::Plugin| -> PathBuf {
        resolve_plugin_dst(p, &cache_root_for_fn)
    });

    // helptags チェック用の target list を、本物の loader 生成と同じ規則で構築する。
    // merged + lazy + non-merge eager だけが個別の `:helptags` 対象になる。
    // lazy → eager 昇格も考慮するため、build_plugin_scripts → promote まで通す。
    let config_root_for_scripts = resolve_config_root(config.options.config_root.as_deref());
    let mut plugin_scripts: Vec<crate::loader::PluginScripts> = Vec::new();
    for plugin in &config.plugins {
        let dst = resolve_plugin_dst(plugin, &cache_root);
        let plugin_config_dir = resolve_plugin_config_dir(&config_root_for_scripts, plugin);
        plugin_scripts.push(build_plugin_scripts(plugin, &dst, &plugin_config_dir));
    }
    crate::loader::promote_lazy_to_eager(&mut plugin_scripts);
    let helptag_targets = crate::helptags::collect_helptag_targets(&plugin_scripts, &merged_dir);
    // collect_helptag_targets と同じイテレーションでラベルを並べる (順序を揃える)。
    let mut helptag_target_labels: Vec<String> = Vec::with_capacity(helptag_targets.len());
    if merged_dir.join("doc").is_dir() {
        helptag_target_labels.push("merged".to_string());
    }
    for ps in &plugin_scripts {
        if ps.merge && !ps.lazy {
            continue;
        }
        if PathBuf::from(&ps.path).join("doc").is_dir() {
            helptag_target_labels.push(ps.name.clone());
        }
    }
    debug_assert_eq!(helptag_targets.len(), helptag_target_labels.len());

    let merge_conflicts_path = resolve_merge_conflicts_path(&cache_root);
    let ctx = crate::doctor::CheckContext {
        config: &config,
        config_path: &config_path,
        loader_path: &loader_path,
        init_lua_path: &init_lua_path,
        merged_dir: &merged_dir,
        merge_conflicts_path: &merge_conflicts_path,
        unused_cache_dirs: unused,
        appname_resolved: resolved,
        rvpm_appname_env: rvpm_env,
        nvim_appname_env: nvim_env,
        resolver: Box::new(crate::doctor::SystemResolver),
        resolve_dst,
        helptag_targets,
        helptag_target_labels,
    };

    let diagnostics = crate::doctor::run_checks(&ctx).await;
    let icons = crate::tui::Icons::from_style(config.options.icons);
    let output = crate::doctor::render(&diagnostics, &icons);
    print!("{}", output);

    let summary = crate::doctor::Summary::from(&diagnostics);
    Ok(summary.exit_code())
}

/// 全プラグインの git 状態を並列で調べ、url -> PluginStatus のマップを返す。
/// 全プラグインのステータスチェックを並列で spawn し、受信用 channel と
/// JoinSet を返す。呼び出し側は progressive に受信して描画するか、一括で
/// await して完了を待つか選べる。
fn spawn_status_check(
    config: &config::Config,
    cache_root: &Path,
) -> (mpsc::Receiver<(String, PluginStatus)>, JoinSet<()>) {
    let (tx, rx) = mpsc::channel::<(String, PluginStatus)>(100);
    let mut set = JoinSet::new();
    for plugin in config.plugins.iter() {
        let plugin = plugin.clone();
        let cache_root = cache_root.to_path_buf();
        let tx = tx.clone();
        set.spawn(async move {
            let dst_path = resolve_plugin_dst(&plugin, &cache_root);
            let repo = Repo::new(&plugin.url, &dst_path, plugin.rev.as_deref());
            let git_status = repo.get_status().await;
            let plugin_status = match git_status {
                crate::git::RepoStatus::Clean => PluginStatus::Finished,
                crate::git::RepoStatus::NotInstalled => PluginStatus::Failed("Missing".to_string()),
                crate::git::RepoStatus::Modified => PluginStatus::Syncing("Modified".to_string()),
                crate::git::RepoStatus::Error(e) => PluginStatus::Failed(e),
            };
            let _ = tx.send((plugin.url.clone(), plugin_status)).await;
        });
    }
    drop(tx);
    (rx, set)
}

async fn fetch_plugin_statuses(
    config: &config::Config,
    cache_root: &Path,
) -> std::collections::HashMap<String, PluginStatus> {
    let (mut rx, mut set) = spawn_status_check(config, cache_root);
    while set.join_next().await.is_some() {}
    let mut result = std::collections::HashMap::new();
    while let Ok((url, status)) = rx.try_recv() {
        result.insert(url, status);
    }
    result
}

async fn run_list(no_tui: bool) -> Result<bool> {
    let config_path = rvpm_config_path();
    let toml_content = std::fs::read_to_string(&config_path)?;
    let mut config = parse_config(&toml_content)?;
    // `cache_root` は起動時の spawn_status_check / no_tui 用。以降は
    // reload_state 内で再 resolve されるので外側では不変で良い。
    // `config_root` は描画 (per-plugin hook アイコン) で毎フレーム使うので
    // `let mut` で保持し、reload! マクロで `c` 編集後の新しい値に差し替える。
    let cache_root = resolve_cache_root(config.options.cache_root.as_deref());
    let mut config_root = resolve_config_root(config.options.config_root.as_deref());
    let mut icons = crate::tui::Icons::from_style(config.options.icons);

    if no_tui {
        // 非対話モード: plain text 出力 (旧 status コマンド相当)
        println!("Checking plugin status...");
        let statuses = fetch_plugin_statuses(&config, &cache_root).await;
        let mut rows: Vec<(String, PluginStatus)> = statuses.into_iter().collect();
        rows.sort_by(|a, b| a.0.cmp(&b.0));
        for (url, status) in rows {
            match status {
                PluginStatus::Finished => println!("  [Clean]     {}", url),
                PluginStatus::Failed(msg) if msg == "Missing" => println!("  [Missing]   {}", url),
                PluginStatus::Syncing(msg) if msg.contains("Modified") => {
                    println!("  [Modified]  {}", url)
                }
                PluginStatus::Syncing(msg) => println!("  [Outdated]  {} ({})", url, msg),
                PluginStatus::Failed(msg) => println!("  [Error]     {} ({})", url, msg),
                PluginStatus::Waiting => println!("  [Waiting]   {}", url),
            }
        }
        return Ok(false);
    }

    enable_raw_mode()?;
    let mut stdout = std::io::stdout();
    execute!(stdout, EnterAlternateScreen)?;
    let backend = CrosstermBackend::new(stdout);
    let mut terminal = ratatui::Terminal::new(backend)?;

    // 先頭に空文字 sentinel を入れて [ Global hooks ] の仮想行にする (rvpm edit
    // と同じ感覚で list TUI からも `e` キーで global edit に飛べるように)。
    // empty URL は spawn_status_check の対象 (= config.plugins) には含まれないため
    // 「Waiting」のままになる心配は無く、ここで Finished に印を付けるだけで OK。
    let mut urls: Vec<String> = vec![String::new()];
    urls.extend(config.plugins.iter().map(|p| p.url.clone()));
    let mut tui_state = TuiState::new(urls);
    tui_state
        .status_map
        .insert(String::new(), PluginStatus::Finished);

    // バックグラウンドでステータスチェック開始 (TUI は即表示)
    let (mut rx, mut set) = spawn_status_check(&config, &cache_root);
    let mut bg_done = false;

    // `b` キーで browse TUI に遷移したいフラグ。ループ終了後 main.rs 側に返す。
    let mut goto_browse = false;

    // サブコマンド実行前の TUI 退避 (raw mode OFF + 通常スクリーン復帰 + カーソル表示)。
    fn leave_tui(
        terminal: &mut ratatui::Terminal<CrosstermBackend<std::io::Stdout>>,
    ) -> Result<()> {
        disable_raw_mode()?;
        execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
        terminal.show_cursor()?;
        Ok(())
    }

    // サブコマンド完了後に TUI を復帰して状態一式を差し替えるためのローカル
    // マクロ。複数の外側変数を同時にムーブ代入するためクロージャにできず、
    // マクロで反復を畳んでいる。
    macro_rules! reload {
        () => {{
            let (c, s, new_rx, new_set, new_config_root) =
                reload_state(&config_path, &mut terminal, &icons)?;
            icons = crate::tui::Icons::from_style(c.options.icons);
            config = c;
            tui_state = s;
            rx = new_rx;
            set = new_set;
            config_root = new_config_root;
            bg_done = false;
        }};
    }

    /// メッセージを表示して任意のキー入力を待つ。
    fn wait_for_keypress(message: &str) -> Result<()> {
        use std::io::Write;
        print!("{}", message);
        std::io::stdout().flush()?;
        crossterm::terminal::enable_raw_mode()?;
        // run_sync / run_update の TUI 終了直後は crossterm の入力キューに
        // 残留イベント (Resize / KeyRelease / sync 中のスクロール連打) が
        // 残りうるので、read の前に一度 drain する。
        while crossterm::event::poll(std::time::Duration::ZERO).unwrap_or(false) {
            let _ = crossterm::event::read();
        }
        // blocking read ではなくタイムアウト付き poll で読むことで、
        // 想定外の環境でも確実に戻ってくるようにする。
        let res = loop {
            match crossterm::event::poll(std::time::Duration::from_millis(100)) {
                Ok(true) => match crossterm::event::read() {
                    Ok(crossterm::event::Event::Key(key))
                        if key.kind == crossterm::event::KeyEventKind::Press =>
                    {
                        break Ok(());
                    }
                    Ok(_) => {}
                    Err(e) => break Err(e.into()),
                },
                Ok(false) => {}
                Err(e) => break Err(e.into()),
            }
        };
        let _ = crossterm::terminal::disable_raw_mode();
        println!();
        res
    }

    // アクション後に config を再読み込みして TUI を復帰し、
    // ステータスチェックはバックグラウンドで走らせる。
    // 失敗しても TUI 状態は戻せるように、alt screen への復帰を最初に行う。
    //
    // fetch_plugin_statuses を同期 await にすると、gix を使った status
    // 取得が Windows で秒単位かかる場合や何らかの理由で詰まった場合に、
    // TUI が完全に無描画のまま固まって見える。起動時と同じ progressive
    // 更新パターンに揃え、main loop 側で受信して描画させる。
    type ReloadState = (
        config::Config,
        TuiState,
        mpsc::Receiver<(String, PluginStatus)>,
        JoinSet<()>,
        PathBuf,
    );
    fn reload_state(
        config_path: &Path,
        terminal: &mut ratatui::Terminal<CrosstermBackend<std::io::Stdout>>,
        _icons: &crate::tui::Icons,
    ) -> Result<ReloadState> {
        // ── 1. 先に TUI に復帰 ──
        // show_cursor() を事前に呼んでいるので hide_cursor() で戻す。
        // clear() は ratatui の内部バッファを無効化して全セル再描画を強制する
        // (これをしないとサブプロセスが alt screen 外で行った描画のせいで
        //  差分描画が崩れた画面を残したまま戻る)。
        enable_raw_mode()?;
        execute!(terminal.backend_mut(), EnterAlternateScreen)?;
        terminal.clear()?;
        terminal.hide_cursor()?;

        // ── 2. config 再読み込み + status は background で開始 ──
        // `c` ハンドラで options.cache_root / options.config_root が変わった
        // 場合にも追従できるよう、ここで毎回 resolve し直して返す。
        let toml_content = std::fs::read_to_string(config_path)?;
        let config = parse_config(&toml_content)?;
        let cache_root = resolve_cache_root(config.options.cache_root.as_deref());
        let config_root = resolve_config_root(config.options.config_root.as_deref());
        // run_list の入口と同じく [ Global hooks ] sentinel を先頭に積む
        let mut urls: Vec<String> = vec![String::new()];
        urls.extend(config.plugins.iter().map(|p| p.url.clone()));
        let mut tui_state = TuiState::new(urls);
        tui_state
            .status_map
            .insert(String::new(), PluginStatus::Finished);
        let (rx, set) = spawn_status_check(&config, &cache_root);

        // ── 3. 復帰直後に残留イベントを drain ──
        // wait_for_keypress で押したキーの release や連打分が残ると、main
        // loop の最初の poll() で拾われて意図しないアクションが起動して
        // しまうことがあるため、ここで捨てる。
        while crossterm::event::poll(std::time::Duration::ZERO).unwrap_or(false) {
            let _ = crossterm::event::read();
        }

        Ok((config, tui_state, rx, set, config_root))
    }

    // env vars (RVPM_APPNAME / NVIM_APPNAME) は run_list 実行中に変わらないので
    // ループ外で 1 回だけ resolve。draw_list の毎フレーム再呼び出しを避ける。
    let init_lua = nvim_init_lua_path();

    loop {
        // バックグラウンドのステータス更新を非ブロッキングで受信
        if !bg_done {
            while let Ok((url, status)) = rx.try_recv() {
                tui_state.update_status(&url, status);
            }
            if set.is_empty() {
                bg_done = true;
            }
            // JoinSet のタスク完了も drain
            while let Some(Ok(_)) = set.try_join_next() {}
        }

        terminal
            .draw(|f| tui_state.draw_list(f, &config, &config_root, &icons, Some(&init_lua)))?;

        if crossterm::event::poll(std::time::Duration::from_millis(50))?
            && let crossterm::event::Event::Key(key) = crossterm::event::read()?
        {
            if key.kind != crossterm::event::KeyEventKind::Press {
                continue;
            }

            // ── 検索モード: インライン入力 ──
            if tui_state.search_mode {
                match key.code {
                    crossterm::event::KeyCode::Esc => tui_state.search_cancel(),
                    crossterm::event::KeyCode::Enter => tui_state.search_confirm(),
                    crossterm::event::KeyCode::Backspace => tui_state.search_backspace(),
                    crossterm::event::KeyCode::Char(c) => tui_state.search_type(c),
                    _ => {}
                }
                continue;
            }

            match key.code {
                crossterm::event::KeyCode::Char('q') | crossterm::event::KeyCode::Esc => break,

                // ── Ctrl 修飾キー (plain match より先に判定) ──
                crossterm::event::KeyCode::Char('d')
                    if key
                        .modifiers
                        .contains(crossterm::event::KeyModifiers::CONTROL) =>
                {
                    tui_state.move_down(10);
                }
                crossterm::event::KeyCode::Char('u')
                    if key
                        .modifiers
                        .contains(crossterm::event::KeyModifiers::CONTROL) =>
                {
                    tui_state.move_up(10);
                }
                crossterm::event::KeyCode::Char('f')
                    if key
                        .modifiers
                        .contains(crossterm::event::KeyModifiers::CONTROL) =>
                {
                    tui_state.move_down(20);
                }
                crossterm::event::KeyCode::Char('b')
                    if key
                        .modifiers
                        .contains(crossterm::event::KeyModifiers::CONTROL) =>
                {
                    tui_state.move_up(20);
                }

                // ── vim-like navigation ──
                crossterm::event::KeyCode::Char('j') | crossterm::event::KeyCode::Down => {
                    tui_state.next()
                }
                crossterm::event::KeyCode::Char('k') | crossterm::event::KeyCode::Up => {
                    tui_state.previous()
                }
                crossterm::event::KeyCode::Char('g') | crossterm::event::KeyCode::Home => {
                    tui_state.go_top();
                }
                crossterm::event::KeyCode::Char('G') | crossterm::event::KeyCode::End => {
                    tui_state.go_bottom();
                }
                crossterm::event::KeyCode::Char('/') => {
                    tui_state.start_search();
                }
                crossterm::event::KeyCode::Char('?') => {
                    tui_state.show_help = !tui_state.show_help;
                }
                crossterm::event::KeyCode::Char('n') => tui_state.search_next(),
                crossterm::event::KeyCode::Char('N') => tui_state.search_prev(),

                // ── actions ──
                crossterm::event::KeyCode::Char('b') => {
                    goto_browse = true;
                    break;
                }
                crossterm::event::KeyCode::Char('c') => {
                    leave_tui(&mut terminal)?;
                    if run_config().await? {
                        run_generate().await?;
                    }
                    reload!();
                }
                crossterm::event::KeyCode::Char('e') => {
                    if let Some(url) = tui_state.selected_url() {
                        leave_tui(&mut terminal)?;
                        // [ Global hooks ] sentinel: per-plugin ではなく global edit に飛ばす
                        let edited = if url.is_empty() {
                            run_edit(None, false, false, false, true).await?
                        } else {
                            run_edit(Some(url), false, false, false, false).await?
                        };
                        if edited {
                            run_generate().await?;
                        }
                        reload!();
                    }
                }
                crossterm::event::KeyCode::Char('s') => {
                    if let Some(url) = tui_state.selected_url() {
                        if url.is_empty() {
                            // sentinel 行では set 対象が無いので何もしない
                            continue;
                        }
                        leave_tui(&mut terminal)?;
                        if run_set(
                            Some(url),
                            None,
                            None,
                            None,
                            None,
                            None,
                            None,
                            None,
                            None,
                            None,
                        )
                        .await?
                        {
                            run_generate().await?;
                        }
                        reload!();
                    }
                }
                crossterm::event::KeyCode::Char('t') => {
                    if let Some(url) = tui_state.selected_url() {
                        if url.is_empty() {
                            // sentinel 行は per-plugin tune の対象外
                            continue;
                        }
                        leave_tui(&mut terminal)?;
                        // `ai_override=None` で options.ai に従う。`run_tune` は AI=Off /
                        // missing plugin dir などの早期失敗で `Err` を返すが eprintln せず
                        // 抜けてくるので、ここで明示的に表示しないと user は「Press any
                        // key…」だけ見せられて理由が分からない (Gemini PR #101 指摘)。
                        if let Err(e) = run_tune(Some(url), None).await {
                            eprintln!("\nError: {e}");
                        }
                        wait_for_keypress("\nPress any key to return to list...")?;
                        reload!();
                    }
                }
                crossterm::event::KeyCode::Char('S') => {
                    leave_tui(&mut terminal)?;
                    let _ = run_sync(false, false, false, None, false, false).await;
                    wait_for_keypress("\nPress any key to return to list...")?;
                    reload!();
                }
                crossterm::event::KeyCode::Char('R') => {
                    leave_tui(&mut terminal)?;
                    // list TUI の `R` キーは全プラグイン rebuild。Some("") が "all" を意味する。
                    let _ = run_sync(false, false, false, Some(String::new()), false, false).await;
                    wait_for_keypress("\nPress any key to return to list...")?;
                    reload!();
                }
                crossterm::event::KeyCode::Char('u') => {
                    if let Some(url) = tui_state.selected_url() {
                        if url.is_empty() {
                            continue;
                        }
                        leave_tui(&mut terminal)?;
                        let _ = run_update(Some(url)).await;
                        wait_for_keypress("\nPress any key to return to list...")?;
                        reload!();
                    }
                }
                crossterm::event::KeyCode::Char('U') => {
                    leave_tui(&mut terminal)?;
                    let _ = run_update(None).await;
                    wait_for_keypress("\nPress any key to return to list...")?;
                    reload!();
                }
                crossterm::event::KeyCode::Char('d') => {
                    if let Some(url) = tui_state.selected_url() {
                        if url.is_empty() {
                            continue;
                        }
                        leave_tui(&mut terminal)?;
                        let _ = run_remove(Some(url)).await;
                        reload!();
                    }
                }
                _ => {}
            }
        }
    }

    disable_raw_mode()?;
    execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
    terminal.show_cursor()?;
    Ok(goto_browse)
}

async fn run_update(query: Option<String>) -> Result<()> {
    let config_path = rvpm_config_path();
    let toml_content = std::fs::read_to_string(&config_path)
        .with_context(|| format!("Failed to read config file: {}", config_path.display()))?;
    let config_data = parse_config(&toml_content)?;
    let icons = crate::tui::Icons::from_style(config_data.options.icons);
    let config = Arc::new(config_data);
    let cache_root = resolve_cache_root(config.options.cache_root.as_deref());

    let target_plugins: Vec<_> = config
        .plugins
        .iter()
        .filter(|p| {
            // dev プラグインは update スキップ
            if p.dev {
                return false;
            }
            if let Some(q) = &query {
                p.url.contains(q.as_str())
                    || p.name
                        .as_deref()
                        .map(|n| n.contains(q.as_str()))
                        .unwrap_or(false)
            } else {
                true
            }
        })
        .cloned()
        .collect();

    if target_plugins.is_empty() {
        println!("No plugins matched the query.");
        return Ok(());
    }

    // `update` は「意図的に最新を取りに行く」操作なので lockfile は **checkout 側には
    // 使わない** (pull 後の新 HEAD で lockfile を上書きするだけ)。config.toml の
    // `rev` は explicit pin なので従来通り従う (gix_checkout がそのまま動く)。
    // query で絞って部分 update する場合も、他プラグインの lockfile entry は残す。
    let config_root_for_lock = resolve_config_root(config.options.config_root.as_deref());
    let lockfile_path = resolve_lockfile_path(&config_root_for_lock);
    let mut lockfile = crate::lockfile::LockFile::load(&lockfile_path);

    let concurrency = resolve_concurrency(config.options.concurrency);
    let semaphore = Arc::new(tokio::sync::Semaphore::new(concurrency));

    let urls: Vec<String> = target_plugins.iter().map(|p| p.url.clone()).collect();
    enable_raw_mode()?;
    let mut stdout = std::io::stdout();
    execute!(stdout, EnterAlternateScreen)?;
    let backend = CrosstermBackend::new(stdout);
    let mut terminal = ratatui::Terminal::new(backend)?;
    let mut tui_state = TuiState::new(urls);
    let (tx, mut rx) = mpsc::channel::<(String, PluginStatus)>(100);

    let mut set = JoinSet::new();

    for plugin in target_plugins.iter() {
        let plugin = plugin.clone();
        let cache_root = cache_root.clone();
        let tx = tx.clone();
        let sem = semaphore.clone();

        set.spawn(async move {
            let _permit = sem.acquire_owned().await.unwrap();
            let dst_path = resolve_plugin_dst(&plugin, &cache_root);
            let _ = tx
                .send((
                    plugin.url.clone(),
                    PluginStatus::Syncing("Updating...".to_string()),
                ))
                .await;
            let repo = Repo::new(&plugin.url, &dst_path, plugin.rev.as_deref());
            let res = repo.update().await;
            match res {
                Ok(change) => {
                    let head_commit = repo.head_commit().await.ok();
                    let _ = tx.send((plugin.url.clone(), PluginStatus::Finished)).await;
                    Ok((plugin, change, head_commit))
                }
                Err(e) => {
                    let _ = tx
                        .send((plugin.url.clone(), PluginStatus::Failed(e.to_string())))
                        .await;
                    Err(e)
                }
            }
        });
    }

    drop(tx);

    let total_tasks = target_plugins.len();
    let mut finished_tasks = 0;
    let mut update_changes: Vec<crate::update_log::ChangeRecord> = Vec::new();

    while finished_tasks < total_tasks {
        terminal.draw(|f| tui_state.draw(f, "updating...", &icons))?;

        // sync/update 中のイベントキューを drain してスクロール操作を受け付ける
        while crossterm::event::poll(std::time::Duration::from_millis(0))? {
            if let crossterm::event::Event::Key(key) = crossterm::event::read()? {
                tui_state.handle_scroll_key(key, terminal.size()?.height);
            }
        }

        tokio::select! {
            Some((url, status)) = rx.recv() => { tui_state.update_status(&url, status); }
            Some(res) = set.join_next() => {
                finished_tasks += 1;
                if let Ok(Ok((plugin, change, head_commit))) = res {
                    if let Some(change) = change {
                        update_changes.push(change_record_from(&plugin, change));
                    }
                    if let Some(commit) = head_commit {
                        lockfile.upsert(crate::lockfile::LockEntry {
                            name: plugin.display_name(),
                            url: plugin.url.clone(),
                            commit,
                        });
                    }
                }
            }
            _ = tokio::time::sleep(std::time::Duration::from_millis(50)) => {}
        }
    }
    terminal.draw(|f| tui_state.draw(f, "updating...", &icons))?;
    tokio::time::sleep(std::time::Duration::from_millis(500)).await;
    let _ = disable_raw_mode();
    let _ = execute!(terminal.backend_mut(), LeaveAlternateScreen);
    let _ = terminal.show_cursor();

    // 並列 spawn 完了順で積まれているので plugin 名で安定 sort (sync と同じ理由)。
    update_changes.sort_by(|a, b| a.name.cmp(&b.name));
    record_changes_or_warn(&cache_root, "update", update_changes);

    // lockfile save: 部分 update (query 指定) の場合は `retain_by_names` を
    // **かけない** — 更新対象外のプラグインの entry を削りたくないため。
    // ここでは単に新 HEAD を反映した lockfile を atomic write するだけ。
    // (config.toml から外されたプラグインの整理は次の full `rvpm sync` で行う)。
    // chezmoi 連携: source 側に書いてから `chezmoi apply` で target に反映。
    let wp = chezmoi::write_path(config.options.chezmoi, &lockfile_path).await;
    if let Err(e) = lockfile.save(&wp) {
        eprintln!(
            "\u{26a0} failed to save {}: {} (lockfile not updated)",
            wp.display(),
            e
        );
    } else {
        chezmoi::apply(&wp, &lockfile_path).await;
    }

    println!("Update complete. Regenerating loader.lua...");
    run_generate().await?;
    Ok(())
}

use toml_edit::{DocumentMut, Item, table, value};

/// `rvpm add` の scan 後に決まる、config.toml に書き込むべき trigger 候補。
struct AddTriggerSuggestion {
    /// on_cmd に入れる文字列 (exact 名 + `/regex/` の mixed list、ソート済)。
    on_cmd: Vec<String>,
    /// on_map に入れる候補 (lhs の enumerate のみ、regex 提案なし: 記号混じりで LCP 無意味)。
    on_map: Vec<crate::config::MapSpec>,
}

impl AddTriggerSuggestion {
    fn is_empty(&self) -> bool {
        self.on_cmd.is_empty() && self.on_map.is_empty()
    }
}

/// 非 TTY script でも `rvpm add --auto-lazy` が安定動作するよう、TTY 判定は
/// `stdin` / `stdout` の両方を見る。どちらかでも非 TTY なら prompt を出さない。
fn is_interactive_tty() -> bool {
    use std::io::IsTerminal;
    std::io::stdin().is_terminal() && std::io::stdout().is_terminal()
}

/// scan 結果から suggestion を組み立てる。0 件なら `None` — 提案する中身が無い。
fn build_add_suggestion(scan: &crate::plugin_scan::ScanResult) -> Option<AddTriggerSuggestion> {
    let on_cmd = crate::plugin_scan::suggest_cmd_triggers_smart(&scan.commands, 3);
    // on_map は regex 化せず enumerate のみ (lhs に記号混じりで LCP 無意味)。
    let on_map: Vec<crate::config::MapSpec> = scan
        .user_maps
        .iter()
        .map(|m| crate::config::MapSpec {
            lhs: m.lhs.clone(),
            mode: m.modes.clone(),
            desc: None,
        })
        .collect();
    let s = AddTriggerSuggestion { on_cmd, on_map };
    if s.is_empty() { None } else { Some(s) }
}

/// 対話プロンプトで user に選ばせて、適用する trigger を返す。
///   - `Some(suggestion)` → 適用
///   - `None`             → eager のまま
fn prompt_lazy_decision(
    display_name: &str,
    suggestion: &AddTriggerSuggestion,
) -> Option<AddTriggerSuggestion> {
    use dialoguer::{Select, theme::ColorfulTheme};

    println!();
    println!("[{}] detected lazy triggers:", display_name);
    if !suggestion.on_cmd.is_empty() {
        println!("  on_cmd = {}", toml_array_preview(&suggestion.on_cmd));
    }
    if !suggestion.on_map.is_empty() {
        let lhs: Vec<String> = suggestion.on_map.iter().map(|m| m.lhs.clone()).collect();
        println!("  on_map = {}", toml_array_preview(&lhs));
    }

    let choices = ["accept (lazy-load)", "skip (eager install)"];
    let sel = Select::with_theme(&ColorfulTheme::default())
        .with_prompt("How should rvpm install this plugin?")
        .items(choices.as_slice())
        .default(0)
        .interact()
        .ok()?;

    match sel {
        0 => Some(AddTriggerSuggestion {
            on_cmd: suggestion.on_cmd.clone(),
            on_map: suggestion.on_map.clone(),
        }),
        _ => None,
    }
}

fn toml_array_preview(items: &[String]) -> String {
    let quoted: Vec<String> = items.iter().map(|s| format!("\"{}\"", s)).collect();
    format!("[{}]", quoted.join(", "))
}

/// `rvpm add` 時の effective policy を決定:
///   - CLI `--lazy` / `--no-lazy` (policy_override) が最優先
///   - なければ `config.options.auto_lazy`
fn resolve_add_lazy_policy(
    policy_override: Option<crate::config::AutoLazyPolicy>,
    config: &crate::config::Config,
) -> crate::config::AutoLazyPolicy {
    policy_override.unwrap_or(config.options.auto_lazy)
}

/// scan 結果 + policy に基づいて suggestion を適用するか決める。
/// Never → None、Always → そのまま採用、Ask → TTY なら prompt / 非 TTY なら skip。
fn decide_add_lazy_apply(
    suggestion: AddTriggerSuggestion,
    policy: crate::config::AutoLazyPolicy,
    display_name: &str,
) -> Option<AddTriggerSuggestion> {
    use crate::config::AutoLazyPolicy;
    match policy {
        AutoLazyPolicy::Never => None,
        AutoLazyPolicy::Always => Some(suggestion),
        AutoLazyPolicy::Ask => {
            if is_interactive_tty() {
                prompt_lazy_decision(display_name, &suggestion)
            } else {
                None
            }
        }
    }
}

/// `replace_plugin_entry_with_ai_toml` の挙動切替。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum MergeMode {
    /// **Merge** (additive). 既存 entry に AI 提案 key を上書き / 追加するが、
    /// 提案に無い既存 key は保持。`rvpm add --ai` 用 — stub entry には CLI flag で
    /// 入れた値しか無いので merge も replace も結果は同じだが、
    /// 安全側に倒している。
    Merge,
    /// **Replace** (destructive). AI 提案に無い既存 key も `preserved_keys` で
    /// 明示保護されていない限り削除。`rvpm tune` 用 — user は「AI に全部書き直して」
    /// と頼んでいるので、AI が omit した stale field (`on_cmd`, `rev` 等) も
    /// 落とすのが期待値 (CodeRabbit PR #100 指摘)。`url` は `preserved_keys` 扱いで
    /// 常に `stored_url` に強制リセット。
    Replace,
}

/// AI mode 用 (#93): AI が返した `[[plugins]]` ブロック (1 entry のみ) を、既存
/// entry に書き込む。`mode` で merge / destructive replace を切り替える。
///
/// 設計ポイント:
///   - **stub entry の文書中の位置 / 装飾 (改行・空白) を保つ** — `remove + insert`
///     方式は toml_edit が新 entry を `[vars]` の直後など想定外の位置にレンダリング
///     してしまう (user 報告: 既存 [[plugins]] の末尾ではなく `[vars]` 直後に配置される)。
///   - **user の明示 CLI flag を尊重** — `preserved_keys` に挙げたキーは AI 提案で
///     上書きしない (`rvpm add owner/repo --rev v1.0 --ai claude` で `--rev` を残す)。
///     `url` は `stored_url` に強制リセット (canonical 化)。
///   - **`Replace` mode** では更に、`preserved_keys` にも proposal にも無い既存 key を
///     明示削除する。これが無いと `tune` で AI が "この field はもう不要だから消して"
///     と返したつもりが反映されない (CodeRabbit PR #100 指摘)。
fn replace_plugin_entry_with_ai_toml(
    doc: &mut DocumentMut,
    stored_url: &str,
    proposal_toml: &str,
    preserved_keys: &[&str],
    mode: MergeMode,
) -> anyhow::Result<()> {
    use anyhow::{Context, anyhow};

    let proposal_doc: DocumentMut = proposal_toml
        .parse::<DocumentMut>()
        .context("AI proposal TOML failed to parse")?;
    let proposal_plugins = proposal_doc
        .get("plugins")
        .and_then(|p| p.as_array_of_tables())
        .ok_or_else(|| anyhow!("AI proposal missing [[plugins]] array"))?;
    if proposal_plugins.len() != 1 {
        return Err(anyhow!(
            "AI proposal must contain exactly 1 plugin entry; got {}",
            proposal_plugins.len()
        ));
    }
    let new_entry = proposal_plugins.get(0).unwrap();

    // 既存 doc の plugins 配列から url 一致を探して in-place マージ。
    let plugins = doc
        .get_mut("plugins")
        .and_then(|p| p.as_array_of_tables_mut())
        .ok_or_else(|| anyhow!("config.toml missing [[plugins]] array"))?;
    for i in 0..plugins.len() {
        let existing_url = plugins
            .get(i)
            .and_then(|t| t.get("url"))
            .and_then(|v| v.as_str())
            .unwrap_or("");
        if existing_url != stored_url {
            continue;
        }
        let existing = plugins.get_mut(i).unwrap();

        // Replace mode: AI 提案にも preserved_keys にも無い既存 key を消す。
        // url は preserved 扱いで常に保持される (下で強制リセットされるが、ここでは消さない)。
        if mode == MergeMode::Replace {
            let proposal_keys: std::collections::HashSet<&str> =
                new_entry.iter().map(|(k, _)| k).collect();
            let to_remove: Vec<String> = existing
                .iter()
                .map(|(k, _)| k.to_string())
                .filter(|k| {
                    k != "url"
                        && !preserved_keys.contains(&k.as_str())
                        && !proposal_keys.contains(k.as_str())
                })
                .collect();
            for k in to_remove {
                existing.remove(&k);
            }
        }

        // AI 提案の各キーを既存 entry に書き込む。
        // ただし `preserved_keys` (user が CLI flag で明示したもの) は skip。
        // url は最後に強制セット。
        for (key, value_item) in new_entry.iter() {
            if key == "url" {
                continue;
            }
            if preserved_keys.contains(&key) {
                continue;
            }
            existing[key] = value_item.clone();
        }
        existing["url"] = value(stored_url);
        return Ok(());
    }
    Err(anyhow!(
        "could not find stub [[plugins]] entry with url=`{stored_url}` in config.toml"
    ))
}

/// 既存 config.toml 内の `[[plugins]]` のうち url が一致するエントリに
/// `on_cmd` / `on_map` を書き込む。toml_edit で in-place patch。
fn patch_plugin_entry_triggers(
    doc: &mut DocumentMut,
    stored_url: &str,
    applied: &AddTriggerSuggestion,
) {
    let Some(plugins) = doc
        .get_mut("plugins")
        .and_then(|p| p.as_array_of_tables_mut())
    else {
        return;
    };
    for t in plugins.iter_mut() {
        let url = t.get("url").and_then(|v| v.as_str()).unwrap_or_default();
        if url != stored_url {
            continue;
        }
        // 既存 on_cmd / on_map がある場合は user が CLI フラグ (`--on-cmd …`) で
        // 明示指定した or 過去の add / 手編集で既に書いていたもの。scan 結果で
        // 上書きすると user 設定を破壊するので skip (PR #91 review 指摘)。
        if !applied.on_cmd.is_empty() && t.get("on_cmd").is_none() {
            let mut arr = toml_edit::Array::new();
            for s in &applied.on_cmd {
                arr.push(s.as_str());
            }
            t["on_cmd"] = value(arr);
        }
        if !applied.on_map.is_empty() && t.get("on_map").is_none() {
            // 全 entry が default mode (n) なら string 配列、それ以外なら table 配列。
            // MapSpec::mode の default は ["n"]。modes が `["n"]` ジャストなら string で十分。
            let all_default = applied
                .on_map
                .iter()
                .all(|m| m.mode == vec!["n".to_string()]);
            if all_default {
                let mut arr = toml_edit::Array::new();
                for m in &applied.on_map {
                    arr.push(m.lhs.as_str());
                }
                t["on_map"] = value(arr);
            } else {
                let mut arr = toml_edit::Array::new();
                for m in &applied.on_map {
                    let mut tb = toml_edit::InlineTable::new();
                    tb.insert("lhs", m.lhs.as_str().into());
                    let mut modes_arr = toml_edit::Array::new();
                    for md in &m.mode {
                        modes_arr.push(md.as_str());
                    }
                    tb.insert("mode", toml_edit::Value::from(modes_arr));
                    arr.push(toml_edit::Value::InlineTable(tb));
                }
                t["on_map"] = value(arr);
            }
        }
        break;
    }
}

#[allow(clippy::too_many_arguments)]
async fn run_add(
    repo: String,
    name: Option<String>,
    lazy: Option<bool>,
    on_cmd: Option<String>,
    on_ft: Option<String>,
    on_map: Option<String>,
    on_event: Option<String>,
    rev: Option<String>,
    policy_override: Option<crate::config::AutoLazyPolicy>,
    ai_override: Option<crate::config::AiBackend>,
) -> Result<()> {
    let config_path = rvpm_config_path();
    ensure_config_exists(&config_path)?;
    let toml_content = std::fs::read_to_string(&config_path)?;
    let mut doc = toml_content.parse::<DocumentMut>()?;
    // url_style は DocumentMut から直接読む (parse_config 経由だと Tera 展開と
    // 全フィールドのデシリアライズが走って無駄。ここでは add に必要な
    // option だけ拾えればよい)。値が無効 / 読めないなら default (Short)。
    let url_style = doc
        .get("options")
        .and_then(|o| o.get("url_style"))
        .and_then(|v| v.as_str())
        .and_then(|s| match s {
            "short" => Some(crate::config::UrlStyle::Short),
            "full" => Some(crate::config::UrlStyle::Full),
            _ => None,
        })
        .unwrap_or_default();
    if doc.get("plugins").is_none() {
        doc["plugins"] = toml_edit::ArrayOfTables::new().into();
    }
    let plugins = doc["plugins"]
        .as_array_of_tables_mut()
        .context("plugins is not an array of tables")?;
    // 重複検出: browse TUI と同じ `installed_full_name` で両辺を正規化して比較。
    // https / short / ssh / 大文字小文字 / `.git` / 末尾 `/` の揺れを吸収。
    // どちらかが GitHub URL と認識できない (gitlab 等) なら生文字列一致に fallback。
    let incoming_normalized = installed_full_name(&repo);
    for p in plugins.iter() {
        let existing_url = p.get("url").and_then(|v| v.as_str()).unwrap_or("");
        let existing_normalized = installed_full_name(existing_url);
        let matches = match (&incoming_normalized, &existing_normalized) {
            (Some(a), Some(b)) => a == b,
            _ => existing_url == repo,
        };
        if matches {
            println!("Plugin already exists: {}", existing_url);
            return Ok(());
        }
    }
    // 書き込み URL は options.url_style に従って整形 (GitHub 以外はそのまま)。
    let stored_url = format_plugin_url(&repo, url_style);

    // user が明示的に CLI flag で指定したキー一覧 — AI mode 適用時はこれを尊重して
    // 上書きしない (#95 review CodeRabbit Major)。`url` は常に保護 (canonical 化される)。
    let preserved_keys: Vec<&'static str> = {
        let mut k = vec!["url"];
        if name.is_some() {
            k.push("name");
        }
        if lazy.is_some() {
            k.push("lazy");
        }
        if rev.is_some() {
            k.push("rev");
        }
        if on_cmd.is_some() {
            k.push("on_cmd");
        }
        if on_ft.is_some() {
            k.push("on_ft");
        }
        if on_map.is_some() {
            k.push("on_map");
        }
        if on_event.is_some() {
            k.push("on_event");
        }
        k
    };

    let mut new_plugin = table();
    new_plugin["url"] = value(&stored_url);
    if let Some(n) = name {
        new_plugin["name"] = value(n);
    }
    if let Some(l) = lazy {
        new_plugin["lazy"] = value(l);
    }
    if let Some(r) = &rev {
        new_plugin["rev"] = value(r.as_str());
    }
    if let Item::Table(t) = new_plugin {
        plugins.push(t);
    }
    // on_* フラグがあれば set_plugin_list_field / set_plugin_map_field で追加。
    // 検索キーは上で書き込んだ `stored_url` に揃える必要がある — `repo` のままだと
    // `options.url_style = "full"` で `owner/repo` → `https://github.com/owner/repo`
    // に書き換えたとき entry 名とキーが一致せず、no-op になる。
    let maybe_parse = |raw: Option<String>| -> Result<Option<Vec<String>>> {
        raw.map(|s| parse_cli_string_list(&s)).transpose()
    };
    if let Some(items) = maybe_parse(on_cmd)? {
        set_plugin_list_field(&mut doc, &stored_url, "on_cmd", items)?;
    }
    if let Some(items) = maybe_parse(on_ft)? {
        set_plugin_list_field(&mut doc, &stored_url, "on_ft", items)?;
    }
    if let Some(raw) = on_map {
        let specs = parse_on_map_cli(&raw)?;
        set_plugin_map_field(&mut doc, &stored_url, specs)?;
    }
    if let Some(items) = maybe_parse(on_event)? {
        set_plugin_list_field(&mut doc, &stored_url, "on_event", items)?;
    }

    let toml_content = doc.to_string();
    let chezmoi_enabled = read_chezmoi_flag(&config_path);
    chezmoi::write_routed(chezmoi_enabled, &config_path, &toml_content).await?;
    println!("Added plugin to config: {}", stored_url);

    // 追加したプラグインだけ clone + merge し、loader.lua を再生成する
    let config_data = parse_config(&toml_content)?;
    let cache_root = resolve_cache_root(config_data.options.cache_root.as_deref());
    let merged_dir = resolve_merged_dir(&cache_root);

    // `stored_url` は format_plugin_url で canonical 化された URL なので、
    // config.toml に書き込んだ entry とそのまま一致する。`repo` (入力のまま) で
    // 引くと url_style="full" のときミスマッチで clone が走らなくなる。
    let mut add_changes: Vec<crate::update_log::ChangeRecord> = Vec::new();
    // 新規追加したプラグインの HEAD を lockfile にも記録する (dotfiles
    // にコミットすれば他マシンでも同じ commit を再現できる)。
    let config_root_for_lock = resolve_config_root(config_data.options.config_root.as_deref());
    let lockfile_path = resolve_lockfile_path(&config_root_for_lock);
    let mut lockfile = crate::lockfile::LockFile::load(&lockfile_path);
    let mut lockfile_dirty = false;
    if let Some(mut plugin) = config_data
        .plugins
        .iter()
        .find(|p| p.url == stored_url)
        .cloned()
    {
        disable_merge_if_cond(&mut plugin);
        let dst_path = resolve_plugin_dst(&plugin, &cache_root);

        println!("Syncing {}...", plugin.display_name());
        let git_repo = Repo::new(&plugin.url, &dst_path, plugin.rev.as_deref());
        match git_repo.sync().await {
            Err(e) => {
                eprintln!("Warning: failed to sync '{}': {}", plugin.display_name(), e);
            }
            Ok(change) => {
                if let Some(c) = change {
                    add_changes.push(change_record_from(&plugin, c));
                }
                if let Some(err) =
                    execute_build_command(&plugin, &dst_path, &config_data, &cache_root).await
                {
                    eprintln!("Warning: {}: {}", plugin.display_name(), err);
                }
                // lockfile 記録 (no-op sync でも head_commit は読める)
                if !plugin.dev
                    && let Ok(commit) = git_repo.head_commit().await
                {
                    lockfile.upsert(crate::lockfile::LockEntry {
                        name: plugin.display_name(),
                        url: plugin.url.clone(),
                        commit,
                    });
                    lockfile_dirty = true;
                }

                // run_add 直後に merge する必要は無い: 末尾で `run_generate()` を呼び、
                // そこで merged/ を rm -rf して全 eager+merge を再構築するため。
                // (旧実装はここで merge していたが run_generate に上書きされて冗長)
                let _ = (&plugin, &dst_path, &merged_dir);

                // ── ここで mode 分岐 ────────────────────────────
                // AI 設定が `Off` 以外なら静的 scan を skip して AI 経路 (#93)。
                // それ以外は従来の auto-lazy scan + prompt (#87) に流す。
                let effective_ai = ai_override.unwrap_or(config_data.options.ai);
                if let Ok(backend) = crate::ai::Backend::try_from(effective_ai) {
                    let cfg_root = resolve_config_root(config_data.options.config_root.as_deref());
                    let plugin_cfg_dir = resolve_plugin_config_dir(&cfg_root, &plugin);
                    match crate::ai::run_ai_add(
                        backend,
                        &stored_url,
                        &dst_path,
                        &plugin_cfg_dir,
                        &cfg_root,
                        &config_path,
                        &config_data.options.ai_language,
                        config_data.options.chezmoi,
                    )
                    .await
                    {
                        Ok(outcome) => match outcome.outcome {
                            crate::ai::ChatOutcome::Applied { written_hooks } => {
                                // user が `[[plugins]]` セクションで "Keep existing" を選んだら
                                // `plugin_entry_toml` は None — stub entry をそのまま残す。
                                if let Some(entry_toml) = outcome.plugin_entry_toml {
                                    let latest = std::fs::read_to_string(&config_path)?;
                                    let mut doc_patch = latest.parse::<DocumentMut>()?;
                                    if let Err(e) = replace_plugin_entry_with_ai_toml(
                                        &mut doc_patch,
                                        &stored_url,
                                        &entry_toml,
                                        &preserved_keys,
                                        MergeMode::Merge,
                                    ) {
                                        eprintln!(
                                            "\u{26a0} failed to apply AI proposal: {e}. Stub entry remains."
                                        );
                                    } else {
                                        let patched = doc_patch.to_string();
                                        chezmoi::write_routed(
                                            config_data.options.chezmoi,
                                            &config_path,
                                            &patched,
                                        )
                                        .await?;
                                        println!(
                                            "Applied AI-proposed config for {} ({} hook file(s) created/overwritten).",
                                            plugin.display_name(),
                                            written_hooks.len()
                                        );
                                    }
                                } else {
                                    println!(
                                        "Kept existing entry for {} ({} hook file(s) created/overwritten).",
                                        plugin.display_name(),
                                        written_hooks.len()
                                    );
                                }
                            }
                            crate::ai::ChatOutcome::Skipped => {
                                eprintln!(
                                    "AI proposal skipped — stub entry kept in config.toml. \
                                     Edit manually or rerun `rvpm add {repo} --no-ai` for static-scan mode."
                                );
                            }
                            crate::ai::ChatOutcome::HandedOff => {
                                eprintln!(
                                    "Handed off to {} CLI. rvpm exits — that session controls config.toml from here.",
                                    backend.label()
                                );
                            }
                        },
                        Err(e) => {
                            eprintln!(
                                "\u{26a0} AI add failed: {e:#}. Stub entry kept; rerun with `--no-ai` for static-scan mode."
                            );
                            eprintln!(
                                "\n  Debug knobs (env vars):\n\
                                 \x20 RVPM_AI_DUMP_PROMPT=/tmp/p.md   write the prompt to a file and skip the AI call\n\
                                 \x20 RVPM_AI_NO_MERGED=1             drop the `_merged` variant requirement (helps if\n\
                                 \x20                                 the backend's loop-detection trips on near-duplicate\n\
                                 \x20                                 fresh+merged output, e.g. gemini's _recoverFromLoop)\n\
                                 \x20 RVPM_AI_TIMEOUT_SECS=600        raise the per-call timeout (default 300)"
                            );
                        }
                    }
                } else {
                    // ── auto-lazy scan + prompt (#87) — 従来路 ──────────
                    let policy = resolve_add_lazy_policy(policy_override, &config_data);
                    let skip_for_explicit_eager = plugin.lazy_raw == Some(false);
                    if !skip_for_explicit_eager
                        && !matches!(policy, crate::config::AutoLazyPolicy::Never)
                        && let Some(suggestion) =
                            build_add_suggestion(&crate::plugin_scan::scan_plugin(&dst_path))
                        && let Some(applied) =
                            decide_add_lazy_apply(suggestion, policy, &plugin.display_name())
                    {
                        let latest = std::fs::read_to_string(&config_path)?;
                        let mut doc_patch = latest.parse::<DocumentMut>()?;
                        patch_plugin_entry_triggers(&mut doc_patch, &stored_url, &applied);
                        let patched = doc_patch.to_string();
                        let wp =
                            chezmoi::write_path(config_data.options.chezmoi, &config_path).await;
                        std::fs::write(&wp, &patched)?;
                        chezmoi::apply(&wp, &config_path).await;
                        println!(
                            "Recorded lazy triggers for {} in config.toml.",
                            plugin.display_name()
                        );
                    }
                }
            }
        }
    }

    record_changes_or_warn(&cache_root, "add", add_changes);

    if lockfile_dirty {
        // chezmoi 連携: source 側に書いてから `chezmoi apply` で target に反映。
        let wp = chezmoi::write_path(config_data.options.chezmoi, &lockfile_path).await;
        if let Err(e) = lockfile.save(&wp) {
            eprintln!(
                "\u{26a0} failed to save {}: {} (lockfile not updated)",
                wp.display(),
                e
            );
        } else {
            chezmoi::apply(&wp, &lockfile_path).await;
        }
    }

    run_generate().await?;
    Ok(())
}

use dialoguer::{FuzzySelect, Select};

/// `rvpm config` — config.toml を $EDITOR で直接開く。
/// ファイルが無ければテンプレートで自動作成してから開く。
/// 常に `Ok(true)` を返すので呼び出し側で sync を走らせる前提。
async fn run_config() -> Result<bool> {
    let config_path = rvpm_config_path();
    ensure_config_exists(&config_path)?;
    let chezmoi_enabled = read_chezmoi_flag(&config_path);
    let edit_target = chezmoi::write_path(chezmoi_enabled, &config_path).await;
    println!("Opening {}", edit_target.display());
    open_editor_at_line(&edit_target, 1)?;
    chezmoi::apply(&edit_target, &config_path).await;
    Ok(true)
}

/// `rvpm init` — Neovim init.lua に loader.lua を繋ぐ dofile 行を案内 or 自動追記する。
async fn run_init(write: bool) -> Result<()> {
    // config.toml がなければテンプレートで自動作成 (add / config と同じ)
    let config_path = rvpm_config_path();
    ensure_config_exists(&config_path)?;
    let toml_content = std::fs::read_to_string(&config_path)
        .with_context(|| format!("Failed to read config file: {}", config_path.display()))?;
    let config = parse_config(&toml_content)?;

    let snippet = loader_init_snippet(&config);
    let init_lua_path = nvim_init_lua_path();

    if write {
        // `config` は既にパース済みなので再読込せずそのまま使う。
        // 親ディレクトリ作成は write_init_lua_snippet が新規作成時に行うので不要。
        let wp = chezmoi::write_path(config.options.chezmoi, &init_lua_path).await;
        let result = write_init_lua_snippet(&wp, &snippet)?;
        match result {
            WriteInitResult::Created => {
                println!("\u{2714} Created {} with rvpm loader.", wp.display());
                println!("  Snippet: {}", snippet);
            }
            WriteInitResult::Appended => {
                println!("\u{2714} Appended rvpm loader to {}.", wp.display());
                println!("  Snippet: {}", snippet);
            }
            WriteInitResult::AlreadyConfigured => {
                println!(
                    "\u{2714} {} already references rvpm loader. No changes.",
                    wp.display()
                );
            }
        }
        // 実際に source 側を書き換えたときだけ chezmoi apply する。変更なしの
        // AlreadyConfigured で apply すると、target 側でユーザーが手で編集した
        // 差分を上書きしてしまう恐れがある。
        if result != WriteInitResult::AlreadyConfigured {
            chezmoi::apply(&wp, &init_lua_path).await;
        }
    } else {
        println!("-- Add this to your Neovim init.lua:");
        println!("{}", snippet);
        println!();
        println!("Target: {}", init_lua_path.display());
        println!("Or run `rvpm init --write` to append it automatically.");
    }
    Ok(())
}

async fn run_edit(
    query: Option<String>,
    flag_init: bool,
    flag_before: bool,
    flag_after: bool,
    flag_global: bool,
) -> Result<bool> {
    // --global: グローバル hooks。init.lua は **Neovim 本体の init.lua**
    // (~/.config/<appname>/init.lua) を指す — `rvpm init` と同じ対象。before.lua /
    // after.lua は rvpm の <config_root> 配下。per-plugin の init/before/after と
    // 同じ 3 択になり、`rvpm edit --init --global` で Neovim 本体 init.lua を
    // 直接開ける。
    if flag_global {
        // config_root を決めるため config.toml を先読み (存在しなければデフォルト)。
        let config_path = rvpm_config_path();
        let config_root = if config_path.exists() {
            let toml_content = std::fs::read_to_string(&config_path)?;
            let config = parse_config(&toml_content)?;
            resolve_config_root(config.options.config_root.as_deref())
        } else {
            resolve_config_root(None)
        };
        let config_dir = config_root.clone();
        std::fs::create_dir_all(&config_dir)?;
        let nvim_init = nvim_init_lua_path();

        // (file_name, target_path) のペア。before/after は config_dir 配下、
        // init.lua のみ Neovim 本体の path に飛ばす。
        let target = if flag_init {
            nvim_init.clone()
        } else if flag_before {
            config_dir.join("before.lua")
        } else if flag_after {
            config_dir.join("after.lua")
        } else {
            let entries: [(&str, PathBuf); 3] = [
                ("init.lua", nvim_init.clone()),
                ("before.lua", config_dir.join("before.lua")),
                ("after.lua", config_dir.join("after.lua")),
            ];
            let display_items: Vec<String> = entries
                .iter()
                .map(|(label, path)| {
                    let icon = if path.exists() {
                        "\u{25cf}"
                    } else {
                        "\u{25cb}"
                    };
                    format!("{} {}", icon, label)
                })
                .collect();
            let sel = Select::with_theme(&dialoguer::theme::ColorfulTheme::default())
                .with_prompt("Select global hook to edit (\u{25cf}=exists \u{25cb}=new)")
                .default(0)
                .items(&display_items)
                .interact_opt()?;
            match sel {
                Some(index) => entries[index].1.clone(),
                None => return Ok(false),
            }
        };

        let chezmoi_enabled = read_chezmoi_flag(&config_path);
        let edit_target = chezmoi::write_path(chezmoi_enabled, &target).await;
        if let Some(parent) = edit_target.parent() {
            std::fs::create_dir_all(parent)?;
        }
        println!("\n>> Editing global hook: {}", edit_target.display());
        let editor = std::env::var("EDITOR").unwrap_or_else(|_| "nvim".to_string());
        std::process::Command::new(editor)
            .arg(&edit_target)
            .status()?;
        chezmoi::apply(&edit_target, &target).await;
        return Ok(true);
    }

    // per-plugin edit
    let config_path = rvpm_config_path();
    let toml_content = std::fs::read_to_string(&config_path)?;
    let config = parse_config(&toml_content)?;

    // 対話モード: plugin 選択肢に [ Global hooks ] sentinel を追加
    // 各プラグインの init/before/after.lua 存在をサークルアイコンで表示
    let config_root = resolve_config_root(config.options.config_root.as_deref());
    // global hook のアイコン表示用 (実使用は run_edit --global 経由)
    let config_dir = config_root.clone();

    let plugin = if let Some(q) = query {
        config
            .plugins
            .iter()
            .find(|p| p.url == q || p.url.contains(&q))
            .context("Plugin not found")?
    } else {
        // URL の最大幅を揃えてサークルを右に並べる
        let global_label = "[ Global hooks ]".to_string();
        let max_url_len = config
            .plugins
            .iter()
            .map(|p| p.url.len())
            .max()
            .unwrap_or(20)
            .max(global_label.len());

        let global_indicators = global_hook_indicators(&config_dir, &nvim_init_lua_path());
        let mut items: Vec<String> = vec![format!(
            "{:<width$}  {}",
            global_label,
            global_indicators,
            width = max_url_len
        )];
        let mut urls: Vec<String> = vec![String::new()]; // sentinel placeholder

        for p in config.plugins.iter() {
            let plugin_config_dir = resolve_plugin_config_dir(&config_root, p);
            let indicators = hook_indicators(&plugin_config_dir);
            let has_any = plugin_config_dir.join("init.lua").exists()
                || plugin_config_dir.join("before.lua").exists()
                || plugin_config_dir.join("after.lua").exists();
            let suffix = if has_any {
                format!("  {}", indicators)
            } else {
                String::new()
            };
            items.push(format!("{:<width$}{}", p.url, suffix, width = max_url_len));
            urls.push(p.url.clone());
        }

        let selection = FuzzySelect::with_theme(&dialoguer::theme::ColorfulTheme::default())
            .with_prompt("Select plugin to edit (I=init B=before A=after)")
            .default(0)
            .items(&items)
            .interact_opt()?;
        match selection {
            Some(0) => {
                return Box::pin(run_edit(None, false, false, false, true)).await;
            }
            Some(index) => config
                .plugins
                .iter()
                .find(|p| p.url == urls[index])
                .unwrap(),
            None => return Ok(false),
        }
    };

    println!("\n>> Editing configuration for: {}", plugin.url);

    let plugin_config_dir = resolve_plugin_config_dir(&config_root, plugin);

    // --init / --before / --after フラグがあれば対話式をスキップ
    let file_name = if flag_init {
        "init.lua"
    } else if flag_before {
        "before.lua"
    } else if flag_after {
        "after.lua"
    } else {
        let file_names = ["init.lua", "before.lua", "after.lua"];
        let display_items: Vec<String> = file_names
            .iter()
            .map(|f| file_with_icon(&plugin_config_dir, f))
            .collect();
        let file_selection = Select::with_theme(&dialoguer::theme::ColorfulTheme::default())
            .with_prompt("Select file to edit (\u{25cf}=exists \u{25cb}=new)")
            .default(0)
            .items(&display_items)
            .interact_opt()?;
        match file_selection {
            Some(index) => file_names[index],
            None => return Ok(false),
        }
    };
    let target_file = plugin_config_dir.join(file_name);
    let chezmoi_enabled = read_chezmoi_flag(&config_path);
    let edit_target = chezmoi::write_path(chezmoi_enabled, &target_file).await;
    if let Some(parent) = edit_target.parent() {
        std::fs::create_dir_all(parent)?;
    }
    let editor = std::env::var("EDITOR").unwrap_or_else(|_| "nvim".to_string());
    std::process::Command::new(editor)
        .arg(&edit_target)
        .status()?;
    chezmoi::apply(&edit_target, &target_file).await;
    Ok(true)
}

#[allow(clippy::too_many_arguments)]
async fn run_set(
    query: Option<String>,
    lazy: Option<bool>,
    merge: Option<bool>,
    on_cmd: Option<String>,
    on_ft: Option<String>,
    on_map: Option<String>,
    on_event: Option<String>,
    on_path: Option<String>,
    on_source: Option<String>,
    rev: Option<String>,
) -> Result<bool> {
    let config_path = rvpm_config_path();
    let toml_content = std::fs::read_to_string(&config_path)?;
    let config = parse_config(&toml_content)?;

    let Some(selected_repo_url) =
        select_plugin_url(&config.plugins, query.as_deref(), "Select plugin to set")?
    else {
        return Ok(false);
    };

    println!("\n>> Setting options for: {}", selected_repo_url);
    let mut doc = toml_content.parse::<DocumentMut>()?;
    let mut modified = false;

    let any_flag_set = lazy.is_some()
        || merge.is_some()
        || on_cmd.is_some()
        || on_ft.is_some()
        || on_map.is_some()
        || on_event.is_some()
        || on_path.is_some()
        || on_source.is_some()
        || rev.is_some();

    if any_flag_set {
        // Option<String> → Result<Option<Vec<String>>> へ (malformed JSON はエラー)
        let maybe_parse = |raw: Option<String>| -> Result<Option<Vec<String>>> {
            raw.map(|s| parse_cli_string_list(&s)).transpose()
        };

        update_plugin_config(
            &mut doc,
            &selected_repo_url,
            lazy,
            merge,
            maybe_parse(on_cmd)?,
            maybe_parse(on_ft)?,
            rev,
        )?;
        // on_map は table 形式 (mode/desc) をサポートするため専用パーサを通す
        if let Some(raw) = on_map {
            let specs = parse_on_map_cli(&raw)?;
            set_plugin_map_field(&mut doc, &selected_repo_url, specs)?;
        }
        if let Some(items) = maybe_parse(on_event)? {
            set_plugin_list_field(&mut doc, &selected_repo_url, "on_event", items)?;
        }
        if let Some(items) = maybe_parse(on_path)? {
            set_plugin_list_field(&mut doc, &selected_repo_url, "on_path", items)?;
        }
        if let Some(items) = maybe_parse(on_source)? {
            set_plugin_list_field(&mut doc, &selected_repo_url, "on_source", items)?;
        }
        modified = true;
    } else {
        // 現在のプラグインを探して既存値をプレフィルに使う
        let current_plugin = config
            .plugins
            .iter()
            .find(|p| p.url == selected_repo_url)
            .cloned();
        let list_field_value = |field: &str| -> String {
            let Some(p) = current_plugin.as_ref() else {
                return String::new();
            };
            // on_map は MapSpec の lhs だけを列挙する (mode/desc は手書き編集に委ねる)
            let items: Option<Vec<String>> = match field {
                "on_cmd" => p.on_cmd.clone(),
                "on_ft" => p.on_ft.clone(),
                "on_map" => p
                    .on_map
                    .as_ref()
                    .map(|v| v.iter().map(|m| m.lhs.clone()).collect()),
                "on_event" => p.on_event.clone(),
                "on_path" => p.on_path.clone(),
                "on_source" => p.on_source.clone(),
                _ => None,
            };
            items.map(|v| v.join(", ")).unwrap_or_default()
        };

        const EDITOR_SENTINEL: &str = "[ Open config.toml in $EDITOR ]";
        let options = vec![
            EDITOR_SENTINEL,
            "lazy",
            "merge",
            "on_cmd",
            "on_ft",
            "on_map",
            "on_event",
            "on_path",
            "on_source",
            "rev",
        ];
        let selection = Select::with_theme(&dialoguer::theme::ColorfulTheme::default())
            .with_prompt("Select option to set")
            .default(0)
            .items(&options)
            .interact_opt()?;
        match selection {
            Some(index) => {
                match options[index] {
                    s if s == EDITOR_SENTINEL => {
                        // 対応 editor なら plugin の url 行にジャンプ
                        let line = find_plugin_line_in_toml(&toml_content, &selected_repo_url);
                        let cz = read_chezmoi_flag(&config_path);
                        let ep = chezmoi::write_path(cz, &config_path).await;
                        open_editor_at_line(&ep, line)?;
                        chezmoi::apply(&ep, &config_path).await;
                        // ユーザーが何を編集したか分からないので常に変更ありと見なす
                        return Ok(true);
                    }
                    "lazy" | "merge" => {
                        let current = current_plugin
                            .as_ref()
                            .map(|p| {
                                if options[index] == "lazy" {
                                    p.lazy
                                } else {
                                    p.merge
                                }
                            })
                            .unwrap_or(false);
                        let default_idx = if current { 0 } else { 1 };
                        let val = Select::with_theme(&dialoguer::theme::ColorfulTheme::default())
                            .with_prompt(format!(
                                "Set {} to (current: {})",
                                options[index], current
                            ))
                            .items(["true", "false"])
                            .default(default_idx)
                            .interact_opt()?;
                        if let Some(v) = val {
                            update_plugin_config(
                                &mut doc,
                                &selected_repo_url,
                                if options[index] == "lazy" {
                                    Some(v == 0)
                                } else {
                                    None
                                },
                                if options[index] == "merge" {
                                    Some(v == 0)
                                } else {
                                    None
                                },
                                None,
                                None,
                                None,
                            )?;
                            modified = true;
                        } else {
                            return Ok(false);
                        }
                    }
                    "on_map" => {
                        // on_map は table 形式 (mode/desc) もあるので edit mode を先に聞く
                        let modes = &[
                            "Edit lhs list only (CLI, mode/desc lost)",
                            "Open config.toml in $EDITOR",
                        ];
                        let mode_sel =
                            Select::with_theme(&dialoguer::theme::ColorfulTheme::default())
                                .with_prompt("on_map edit mode")
                                .items(modes)
                                .default(0)
                                .interact_opt()?;
                        match mode_sel {
                            Some(0) => {
                                // CLI: lhs のみ編集 (既存の簡易フロー)
                                let existing = list_field_value("on_map");
                                let val = read_input_with_esc(
                                    "Enter on_map lhs values (comma separated, Esc to cancel)",
                                    &existing,
                                )?;
                                match val {
                                    Some(v) if !v.is_empty() => {
                                        let items: Vec<String> = v
                                            .split(',')
                                            .map(|s| s.trim().to_string())
                                            .filter(|s| !s.is_empty())
                                            .collect();
                                        set_plugin_list_field(
                                            &mut doc,
                                            &selected_repo_url,
                                            "on_map",
                                            items,
                                        )?;
                                        modified = true;
                                    }
                                    _ => return Ok(false),
                                }
                            }
                            Some(1) => {
                                let line =
                                    find_plugin_line_in_toml(&toml_content, &selected_repo_url);
                                let cz = read_chezmoi_flag(&config_path);
                                let ep = chezmoi::write_path(cz, &config_path).await;
                                open_editor_at_line(&ep, line)?;
                                chezmoi::apply(&ep, &config_path).await;
                                return Ok(true);
                            }
                            _ => return Ok(false),
                        }
                    }
                    field @ ("on_cmd" | "on_ft" | "on_event" | "on_path" | "on_source") => {
                        let existing = list_field_value(field);
                        let val = read_input_with_esc(
                            &format!("Enter {} (comma separated, Esc to cancel)", field),
                            &existing,
                        )?;
                        match val {
                            Some(v) if !v.is_empty() => {
                                let items: Vec<String> = v
                                    .split(',')
                                    .map(|s| s.trim().to_string())
                                    .filter(|s| !s.is_empty())
                                    .collect();
                                set_plugin_list_field(&mut doc, &selected_repo_url, field, items)?;
                                modified = true;
                            }
                            _ => return Ok(false),
                        }
                    }
                    "rev" => {
                        let existing = current_plugin
                            .as_ref()
                            .and_then(|p| p.rev.clone())
                            .unwrap_or_default();
                        let val = read_input_with_esc(
                            "Enter rev (branch/tag/hash, Esc to cancel)",
                            &existing,
                        )?;
                        match val {
                            Some(v) if !v.is_empty() => {
                                update_plugin_config(
                                    &mut doc,
                                    &selected_repo_url,
                                    None,
                                    None,
                                    None,
                                    None,
                                    Some(v),
                                )?;
                                modified = true;
                            }
                            _ => return Ok(false),
                        }
                    }
                    _ => {}
                }
            }
            None => return Ok(false),
        }
    }

    if modified {
        let chezmoi_enabled = read_chezmoi_flag(&config_path);
        chezmoi::write_routed(chezmoi_enabled, &config_path, doc.to_string()).await?;
        println!("Updated config for: {}", selected_repo_url);
        return Ok(true);
    }
    Ok(false)
}

/// 英語の単数/複数形切替。表示メッセージで使う小さなヘルパー。
fn plural<'a>(singular: &'a str, plural: &'a str, n: usize) -> &'a str {
    if n == 1 { singular } else { plural }
}

/// `sync --prune` / `generate` (auto_clean) / 両方の末尾で使う共通の「後片付け」。
/// 未使用 repo を検出し、`force` が true なら `prune_unused_repos` で削除する。
/// 戻り値は検出された未使用の件数。0 以外なら呼び出し側で警告メッセージを
/// 出せるよう、発見はしたが削除していないケースを区別できるようにする。
fn maybe_prune_unused_repos(
    config: &config::Config,
    cache_root: &Path,
    force: bool,
) -> (usize, Vec<PathBuf>) {
    let repos_dir = resolve_repos_dir(cache_root);
    if !repos_dir.exists() {
        return (0, Vec::new());
    }
    let unused = find_unused_repos(config, cache_root, &repos_dir).unwrap_or_default();
    if unused.is_empty() {
        return (0, Vec::new());
    }
    let count = unused.len();
    if force {
        prune_unused_repos(&unused);
        (count, Vec::new()) // 削除済みなのでパスは返さない
    } else {
        (count, unused)
    }
}

/// 未使用 repo ディレクトリを削除する共通処理。`sync --prune` と `clean` 両方から呼ばれる。
/// 削除失敗は eprintln で警告のみ出し、処理を続ける (resilience 原則)。
fn prune_unused_repos(unused: &[PathBuf]) {
    println!();
    println!(
        "Pruning {} unused plugin {}:",
        unused.len(),
        plural("directory", "directories", unused.len()),
    );
    for path in unused {
        println!("  - {}", path.display());
        if let Err(e) = std::fs::remove_dir_all(path) {
            eprintln!("    \u{26a0} failed: {}", e);
        }
    }
}

/// `{cache_root}/plugins/repos/` 配下で、config.toml に載っていないプラグイン
/// ディレクトリを列挙する (削除候補)。
///
/// 判定ルール:
/// - 使用中セットには `resolve_plugin_dst()` の結果 (= `plugin.dst` があれば
///   それ、無ければ canonical_path ベースの既定) のうち `repos_dir` 配下の
///   ものだけを入れる。`dst` を別ツリーに逃がしてる plugin は対象外になる。
/// - `.git` を持つ候補ディレクトリを検出し、**使用中パス自身またはその
///   子孫であれば保護する** (= プラグイン本体の clone の中に置かれた
///   submodule の `.git` を誤削除しない)。
fn find_unused_repos(
    config: &config::Config,
    cache_root: &Path,
    repos_dir: &Path,
) -> Result<Vec<PathBuf>> {
    let mut unused = Vec::new();
    let mut used_paths: Vec<PathBuf> = Vec::new();
    for plugin in &config.plugins {
        let dst = resolve_plugin_dst(plugin, cache_root);
        // custom dst がツリー外 (`~/dev/...` 等) の場合は repos_dir のスキャン
        // 対象外なので used 判定にも不要。
        if dst.starts_with(repos_dir) {
            used_paths.push(dst);
        }
    }
    for entry in walkdir::WalkDir::new(repos_dir)
        .into_iter()
        .filter_map(|e| e.ok())
        .filter(|e| e.file_name() == ".git")
    {
        let git_dir = entry.path();
        if let Some(repo_root) = git_dir.parent()
            && !used_paths
                .iter()
                .any(|used| repo_root == used || repo_root.starts_with(used))
        {
            unused.push(repo_root.to_path_buf());
        }
    }
    Ok(unused)
}

fn remove_plugin_from_toml(doc: &mut DocumentMut, url: &str) -> Result<()> {
    let plugins = doc["plugins"]
        .as_array_of_tables_mut()
        .context("plugins is not an array of tables")?;
    let idx = plugins
        .iter()
        .position(|p| p.get("url").and_then(|v| v.as_str()) == Some(url))
        .context("Plugin not found in config")?;
    plugins.remove(idx);
    Ok(())
}

async fn run_remove(query: Option<String>) -> Result<()> {
    let config_path = rvpm_config_path();
    let toml_content = std::fs::read_to_string(&config_path)?;
    let config = parse_config(&toml_content)?;

    let Some(selected_url) =
        select_plugin_url(&config.plugins, query.as_deref(), "Select plugin to remove")?
    else {
        return Ok(());
    };

    let confirm = dialoguer::Confirm::with_theme(&dialoguer::theme::ColorfulTheme::default())
        .with_prompt(format!("Remove plugin '{}'?", selected_url))
        .default(false)
        .interact()?;

    if !confirm {
        println!("Cancelled.");
        return Ok(());
    }

    let mut doc = toml_content.parse::<DocumentMut>()?;
    remove_plugin_from_toml(&mut doc, &selected_url)?;
    let chezmoi_enabled = read_chezmoi_flag(&config_path);
    chezmoi::write_routed(chezmoi_enabled, &config_path, doc.to_string()).await?;
    println!("Removed '{}' from config.", selected_url);

    let cache_root = resolve_cache_root(config.options.cache_root.as_deref());
    let plugin = config
        .plugins
        .iter()
        .find(|p| p.url == selected_url)
        .unwrap();
    let dst_path = resolve_plugin_dst(plugin, &cache_root);

    if dst_path.exists() {
        std::fs::remove_dir_all(&dst_path)?;
        println!("Deleted directory: {}", dst_path.display());
    }

    println!("Regenerating loader.lua...");
    run_generate().await?;
    Ok(())
}

/// 既存 config.toml の DocumentMut から、指定 url の `[[plugins]]` entry を
/// **TOML テキストとして** 抜き出す (AI tune の prompt に貼るため)。
///
/// 単純に `existing.to_string()` だと `[[plugins]]` ヘッダが付かないので、
/// `[[plugins]]\n` を頭に明示的に貼って、その後に table の各 key/value を
/// 通常レンダリングで連結する。toml_edit が table 内の元 formatting (空白 /
/// 改行 / コメント) をできる限り保つので、user が手で書いた config の見た目を
/// AI に正しく見せられる。
fn extract_plugin_entry_toml(doc: &DocumentMut, url: &str) -> Option<String> {
    let plugins = doc.get("plugins").and_then(|p| p.as_array_of_tables())?;
    let entry = plugins
        .iter()
        .find(|t| t.get("url").and_then(|v| v.as_str()) == Some(url))?;
    // `[[plugins]]` header を含めて build。
    let mut out = String::from("[[plugins]]\n");
    out.push_str(&entry.to_string());
    Some(out)
}

async fn run_tune(
    query: Option<String>,
    ai_override: Option<crate::config::AiBackend>,
) -> Result<()> {
    let config_path = rvpm_config_path();
    ensure_config_exists(&config_path)?;
    let toml_content = std::fs::read_to_string(&config_path)?;
    let config = parse_config(&toml_content)?;

    if config.plugins.is_empty() {
        return Err(anyhow::anyhow!(
            "No plugins in config.toml. Use `rvpm add <repo>` first."
        ));
    }

    // AI backend を解決。`--no-ai` (Off) や config が Off なら error
    // (tune は AI 専用 — non-AI 経路を提供する意味がない、`set` で代替できる)。
    let effective_ai = ai_override.unwrap_or(config.options.ai);
    let backend = crate::ai::Backend::try_from(effective_ai).map_err(|_| {
        anyhow::anyhow!(
            "rvpm tune requires an AI backend. Set `options.ai` in config.toml \
             or pass `--ai <claude|gemini|codex>`."
        )
    })?;

    let Some(selected_url) =
        select_plugin_url(&config.plugins, query.as_deref(), "Select plugin to tune")?
    else {
        return Ok(());
    };

    let plugin = config
        .plugins
        .iter()
        .find(|p| p.url == selected_url)
        .cloned()
        .context("plugin disappeared after selection")?;

    let cache_root = resolve_cache_root(config.options.cache_root.as_deref());
    let dst_path = resolve_plugin_dst(&plugin, &cache_root);
    if !dst_path.exists() {
        return Err(anyhow::anyhow!(
            "Plugin directory does not exist: {}. Run `rvpm sync` first so the AI can read the README/doc.",
            dst_path.display()
        ));
    }

    // 現在の `[[plugins]]` entry を TOML テキストとして抜き出す。
    let doc = toml_content.parse::<DocumentMut>()?;
    let current_entry_toml = extract_plugin_entry_toml(&doc, &selected_url).ok_or_else(|| {
        anyhow::anyhow!("could not extract current entry for `{selected_url}` from config.toml")
    })?;

    let config_root = resolve_config_root(config.options.config_root.as_deref());
    let plugin_cfg_dir = resolve_plugin_config_dir(&config_root, &plugin);

    println!(
        "\u{1f527} Tuning {} with {} ...",
        plugin.display_name(),
        backend.label()
    );

    match crate::ai::run_ai_tune(
        backend,
        &selected_url,
        &dst_path,
        &plugin_cfg_dir,
        &config_root,
        &config_path,
        &current_entry_toml,
        &config.options.ai_language,
        config.options.chezmoi,
    )
    .await
    {
        Ok(outcome) => match outcome.outcome {
            crate::ai::ChatOutcome::Applied { written_hooks } => {
                // user が `[[plugins]]` セクションで "Keep existing entry" を選んだら
                // `plugin_entry_toml` は None — config.toml は触らず、hook ファイル更新のみ。
                if let Some(entry_toml) = outcome.plugin_entry_toml {
                    let latest = std::fs::read_to_string(&config_path)?;
                    let mut doc_patch = latest.parse::<DocumentMut>()?;
                    // user は preview で fresh / merged を per-section に選択済み。
                    // `Replace` mode で AI が omit した stale field (e.g. 古い `on_cmd`) を消す。
                    if let Err(e) = replace_plugin_entry_with_ai_toml(
                        &mut doc_patch,
                        &selected_url,
                        &entry_toml,
                        &[],
                        MergeMode::Replace,
                    ) {
                        eprintln!(
                            "\u{26a0} failed to apply AI proposal: {e}. Existing entry kept."
                        );
                    } else {
                        let patched = doc_patch.to_string();
                        chezmoi::write_routed(config.options.chezmoi, &config_path, &patched)
                            .await?;
                        println!(
                            "Tuned {} ({} hook file(s) created/overwritten).",
                            plugin.display_name(),
                            written_hooks.len()
                        );
                    }
                } else {
                    println!(
                        "Kept existing entry for {} ({} hook file(s) created/overwritten).",
                        plugin.display_name(),
                        written_hooks.len()
                    );
                }
            }
            crate::ai::ChatOutcome::Skipped => {
                eprintln!("AI proposal skipped \u{2014} existing entry kept in config.toml.");
            }
            crate::ai::ChatOutcome::HandedOff => {
                eprintln!(
                    "Handed off to {} CLI. rvpm exits \u{2014} that session controls config.toml from here.",
                    backend.label()
                );
            }
        },
        Err(e) => {
            eprintln!("\u{26a0} AI tune failed: {e:#}. Existing entry kept unchanged.");
            eprintln!(
                "\n  Debug knobs (env vars):\n\
                 \x20 RVPM_AI_DUMP_PROMPT=/tmp/p.md   write the prompt to a file and skip the AI call\n\
                 \x20 RVPM_AI_NO_MERGED=1             drop the `_merged` variant requirement (force off)\n\
                 \x20 RVPM_AI_FORCE_MERGED=1          force `_merged` on for Gemini (auto-disabled\n\
                 \x20                                 by default because gemini-cli v0.39's loop guard\n\
                 \x20                                 aborts on near-duplicate fresh+merged output)\n\
                 \x20 RVPM_AI_TIMEOUT_SECS=600        raise the per-call timeout (default 300)"
            );
        }
    }

    run_generate().await?;
    Ok(())
}

/// 指定プラグインの任意のリスト型フィールド (on_cmd / on_ft / on_map / on_event / on_path / on_source 等) を設定する。
/// 要素が1つの場合は文字列として、2つ以上の場合は配列として書き込む (TOML の string | string[] を活用)。
fn set_plugin_list_field(
    doc: &mut DocumentMut,
    url: &str,
    field: &str,
    values: Vec<String>,
) -> Result<()> {
    let plugins = doc["plugins"]
        .as_array_of_tables_mut()
        .context("plugins is not an array of tables")?;
    let plugin_table = plugins
        .iter_mut()
        .find(|p| p.get("url").and_then(|v| v.as_str()) == Some(url))
        .context("Could not find plugin in toml_edit document")?;
    if values.len() == 1 {
        plugin_table[field] = value(values.into_iter().next().unwrap());
    } else {
        let mut array = toml_edit::Array::new();
        for v in values {
            array.push(v);
        }
        plugin_table[field] = value(array);
    }
    Ok(())
}

/// `--on-cmd` / `--on-ft` / `--on-event` / `--on-path` / `--on-source` の
/// 入力文字列を `Vec<String>` に正規化する。
///
/// 受け付ける形式:
/// - `"Foo"`                 → `["Foo"]`
/// - `"Foo,Bar,Baz"`         → `["Foo", "Bar", "Baz"]` (空要素は無視)
/// - `'["Foo", "Bar"]'`      → `["Foo", "Bar"]` (JSON 配列)
///
/// JSON っぽく `[` で始まっていて parse に失敗すると明示エラー。
fn parse_cli_string_list(input: &str) -> Result<Vec<String>> {
    let trimmed = input.trim();
    if trimmed.starts_with('[') {
        return serde_json::from_str::<Vec<String>>(trimmed)
            .with_context(|| format!("invalid JSON string array: {}", trimmed));
    }
    Ok(trimmed
        .split(',')
        .map(|s| s.trim().to_string())
        .filter(|s| !s.is_empty())
        .collect())
}

/// `--on-map` CLI flag の入力を `Vec<MapSpec>` に変換する。
///
/// 受け付ける形式 (すべて同じ flag で混在可能):
/// - `"<leader>f"`                       (単純な文字列)
/// - `"<leader>f, <leader>g"`            (カンマ区切り)
/// - `'["<leader>f", "<leader>g"]'`      (JSON 文字列配列)
/// - `'{ "lhs": "<space>d", "mode": ["n", "x"], "desc": "..." }'`  (JSON object 単体)
/// - `'[{ ... }, "<leader>f", { ... }]'`  (JSON mixed array)
fn parse_on_map_cli(input: &str) -> Result<Vec<crate::config::MapSpec>> {
    let trimmed = input.trim();
    let first = trimmed.chars().next().unwrap_or(' ');

    // JSON 解析を試みる (配列 or オブジェクト先頭)
    if first == '[' || first == '{' {
        let value: serde_json::Value = serde_json::from_str(trimmed)
            .with_context(|| format!("invalid JSON for --on-map: {}", trimmed))?;
        return match value {
            serde_json::Value::Array(items) => items
                .into_iter()
                .map(map_spec_from_json_value)
                .collect::<Result<Vec<_>>>(),
            serde_json::Value::Object(_) => Ok(vec![map_spec_from_json_value(value)?]),
            _ => anyhow::bail!("--on-map JSON must be an object or array"),
        };
    }

    // 単純: カンマ区切り (空要素は無視) → 全部 lhs のみの MapSpec
    Ok(trimmed
        .split(',')
        .map(|s| s.trim().to_string())
        .filter(|s| !s.is_empty())
        .map(|lhs| crate::config::MapSpec {
            lhs,
            mode: Vec::new(),
            desc: None,
        })
        .collect())
}

fn map_spec_from_json_value(value: serde_json::Value) -> Result<crate::config::MapSpec> {
    use crate::config::MapSpec;
    match value {
        serde_json::Value::String(lhs) => Ok(MapSpec {
            lhs,
            mode: Vec::new(),
            desc: None,
        }),
        serde_json::Value::Object(map) => {
            let lhs = map
                .get("lhs")
                .and_then(|v| v.as_str())
                .map(String::from)
                .context("map spec missing required `lhs` field")?;
            let mode = match map.get("mode") {
                Some(serde_json::Value::String(s)) => vec![s.clone()],
                Some(serde_json::Value::Array(arr)) => arr
                    .iter()
                    .filter_map(|v| v.as_str().map(String::from))
                    .collect(),
                Some(_) => anyhow::bail!("`mode` must be a string or array of strings"),
                None => Vec::new(),
            };
            let desc = map.get("desc").and_then(|v| v.as_str()).map(String::from);
            Ok(MapSpec { lhs, mode, desc })
        }
        _ => anyhow::bail!("map spec must be a string or object"),
    }
}

/// `Vec<MapSpec>` を TOML の `on_map` フィールドに書き込む。
/// - 1 要素かつ simple (mode/desc なし) → plain string
/// - それ以外 → 配列 (要素ごとに simple なら string、詳細なら inline table)
fn set_plugin_map_field(
    doc: &mut DocumentMut,
    url: &str,
    specs: Vec<crate::config::MapSpec>,
) -> Result<()> {
    let plugins = doc["plugins"]
        .as_array_of_tables_mut()
        .context("plugins is not an array of tables")?;
    let plugin_table = plugins
        .iter_mut()
        .find(|p| p.get("url").and_then(|v| v.as_str()) == Some(url))
        .context("Could not find plugin in toml_edit document")?;

    let is_simple = |s: &crate::config::MapSpec| s.mode.is_empty() && s.desc.is_none();

    if specs.len() == 1 && is_simple(&specs[0]) {
        plugin_table["on_map"] = value(specs.into_iter().next().unwrap().lhs);
        return Ok(());
    }

    let mut array = toml_edit::Array::new();
    for spec in specs {
        if is_simple(&spec) {
            array.push(spec.lhs);
        } else {
            let mut inline = toml_edit::InlineTable::new();
            inline.insert("lhs", spec.lhs.into());
            if !spec.mode.is_empty() {
                let mut mode_arr = toml_edit::Array::new();
                for m in spec.mode {
                    mode_arr.push(m);
                }
                inline.insert("mode", toml_edit::Value::Array(mode_arr));
            }
            if let Some(desc) = spec.desc {
                inline.insert("desc", desc.into());
            }
            array.push(toml_edit::Value::InlineTable(inline));
        }
    }
    plugin_table["on_map"] = value(array);
    Ok(())
}

fn update_plugin_config(
    doc: &mut DocumentMut,
    url: &str,
    lazy: Option<bool>,
    merge: Option<bool>,
    on_cmd: Option<Vec<String>>,
    on_ft: Option<Vec<String>>,
    rev: Option<String>,
) -> Result<()> {
    if let Some(l) = lazy {
        let plugins = doc["plugins"]
            .as_array_of_tables_mut()
            .context("plugins is not an array of tables")?;
        let plugin_table = plugins
            .iter_mut()
            .find(|p| p.get("url").and_then(|v| v.as_str()) == Some(url))
            .context("Could not find plugin in toml_edit document")?;
        plugin_table["lazy"] = value(l);
    }
    if let Some(m) = merge {
        let plugins = doc["plugins"]
            .as_array_of_tables_mut()
            .context("plugins is not an array of tables")?;
        let plugin_table = plugins
            .iter_mut()
            .find(|p| p.get("url").and_then(|v| v.as_str()) == Some(url))
            .context("Could not find plugin in toml_edit document")?;
        plugin_table["merge"] = value(m);
    }
    if let Some(cmds) = on_cmd {
        set_plugin_list_field(doc, url, "on_cmd", cmds)?;
    }
    if let Some(fts) = on_ft {
        set_plugin_list_field(doc, url, "on_ft", fts)?;
    }
    if let Some(r) = rev {
        let plugins = doc["plugins"]
            .as_array_of_tables_mut()
            .context("plugins is not an array of tables")?;
        let plugin_table = plugins
            .iter_mut()
            .find(|p| p.get("url").and_then(|v| v.as_str()) == Some(url))
            .context("Could not find plugin in toml_edit document")?;
        plugin_table["rev"] = value(r);
    }
    Ok(())
}

/// `<config_root>/before.lua` / `after.lua` を検出して LoaderOptions を構築する。
fn build_loader_options(config_root: &Path) -> crate::loader::LoaderOptions {
    crate::loader::LoaderOptions {
        global_before: find_lua(config_root, "before.lua"),
        global_after: find_lua(config_root, "after.lua"),
        profile: None,
    }
}

fn write_loader_to_path(
    merged_dir: &Path,
    scripts: &[crate::loader::PluginScripts],
    loader_path: &Path,
    loader_opts: &crate::loader::LoaderOptions,
) -> Result<()> {
    if let Some(parent) = loader_path.parent() {
        std::fs::create_dir_all(parent)?;
    }
    let lua = generate_loader(merged_dir, scripts, loader_opts);
    std::fs::write(loader_path, lua)?;
    Ok(())
}

/// デフォルト並列数。GitHub の rate limit を避けるため控えめに。
const DEFAULT_CONCURRENCY: usize = 13;

fn resolve_concurrency(config_value: Option<usize>) -> usize {
    config_value.unwrap_or(DEFAULT_CONCURRENCY)
}

// ====================================================================
// Paths: `.config` / `.cache` をクロスプラットフォームで固定する。
//
// Windows でも `dirs::config_dir()` (≒ `%APPDATA%`) ではなく明示的に
// `~/.config` / `~/.cache` を使う。理由:
//   - Neovim の config 慣習と一致 (`~/.config/nvim`)
//   - dotfiles を WSL / Linux / Windows で同じパス構造で共有できる
//   - 単一の mental model で済む
//
// ユーザー側で別のパスにしたければ TOML の options で上書きできる:
//   - options.cache_root  → 全キャッシュの root (plugins/ と browse/ が配下)
//   - options.config_root → 全コンフィグの root (config.toml / 全 global hook /
//                           plugins/ が配下)
//
// config_root と cache_root は対称構造:
//   <config_root>/config.toml
//   <config_root>/before.lua / after.lua             (global hooks)
//   <config_root>/plugins/<host>/<owner>/<repo>/     (per-plugin hooks)
//   <cache_root>/plugins/{repos,merged,loader.lua}   (plugins 本体)
//   <cache_root>/browse/                             (browse キャッシュ)
//
// $RVPM_APPNAME > $NVIM_APPNAME > "nvim" の順で appname が決まり、
// デフォルトパスの末尾に appname が入る:
//   ~/.config/rvpm/<appname>/
//   ~/.cache/rvpm/<appname>/
// ====================================================================

/// `~/.config/rvpm/config.toml` (固定)
/// $RVPM_APPNAME → $NVIM_APPNAME → "nvim" の優先順で appname を決定。
/// 無効な値 (空文字、パス区切り含む、"." / "..") は "nvim" に fallback。
pub(crate) fn appname() -> String {
    let raw = std::env::var("RVPM_APPNAME")
        .or_else(|_| std::env::var("NVIM_APPNAME"))
        .unwrap_or_default();
    if is_valid_appname(&raw) {
        raw
    } else {
        "nvim".to_string()
    }
}

/// appname が path segment として安全か検証。
fn is_valid_appname(name: &str) -> bool {
    !name.is_empty()
        && name != "."
        && name != ".."
        && !name.contains('/')
        && !name.contains('\\')
        && !name.contains('\0')
}

fn rvpm_config_path() -> PathBuf {
    let home = dirs::home_dir().expect("Could not find home directory");
    home.join(".config")
        .join("rvpm")
        .join(appname())
        .join("config.toml")
}

/// `config.toml` から `options.chezmoi` フラグだけを軽量に読み出す。
/// `parse_config` は Tera 展開 + topological sort を行う重量級処理なので、
/// mutate 系コマンドがフラグ 1 つを見るためだけに呼ぶのは無駄。
/// toml_edit で該当キーだけ直接参照する。
/// ファイルが存在しない / パースできない / キーが無い場合は `false`。
fn read_chezmoi_flag(config_path: &Path) -> bool {
    let Ok(content) = std::fs::read_to_string(config_path) else {
        return false;
    };
    let Ok(doc) = content.parse::<DocumentMut>() else {
        return false;
    };
    doc.get("options")
        .and_then(|o| o.get("chezmoi"))
        .and_then(|v| v.as_bool())
        .unwrap_or(false)
}

/// config.toml が存在しなければ最小テンプレートで新規作成する。
/// 既に存在する場合は何もしない (冪等)。作成した場合は true を返す。
fn ensure_config_exists(config_path: &Path) -> Result<bool> {
    if config_path.exists() {
        return Ok(false);
    }
    if let Some(parent) = config_path.parent() {
        std::fs::create_dir_all(parent)?;
    }
    let template = "\
# rvpm config — https://github.com/yukimemi/rvpm#configuration
[options]
";
    std::fs::write(config_path, template)?;
    println!("Created {}", config_path.display());
    Ok(true)
}

/// doctor 用の pub 再公開 (元 `expand_tilde` を module 外から使いたいため)。
pub(crate) fn expand_tilde_public(path: &str) -> PathBuf {
    expand_tilde(path)
}

/// doctor 用の pub 再公開 (元 `init_lua_references_rvpm_loader` の同義)。
pub(crate) fn init_lua_references_rvpm_loader_public(init_lua_path: &Path) -> bool {
    init_lua_references_rvpm_loader(init_lua_path)
}

/// `~` / `~/foo` / `~\foo` 形式を home dir に展開する。
/// それ以外はそのまま PathBuf に変換。
fn expand_tilde(path: &str) -> PathBuf {
    if path == "~" {
        return dirs::home_dir().expect("Could not find home directory");
    }
    if let Some(rest) = path.strip_prefix("~/").or_else(|| path.strip_prefix("~\\")) {
        return dirs::home_dir()
            .expect("Could not find home directory")
            .join(rest);
    }
    PathBuf::from(path)
}

/// rvpm のキャッシュ root を決定する。
/// `options.cache_root` が設定されていればそれを tilde 展開して返す。
/// 未設定なら `~/.cache/rvpm/<appname>` (デフォルト)。
/// この配下に `plugins/{repos,merged,loader.lua}` が配置される。
fn resolve_cache_root(config_cache_root: Option<&str>) -> PathBuf {
    match config_cache_root {
        Some(raw) => expand_tilde(raw),
        None => {
            let home = dirs::home_dir().expect("Could not find home directory");
            home.join(".cache").join("rvpm").join(appname())
        }
    }
}

/// config.toml / global hook / per-plugin hook の親 root を決定する。
/// `options.config_root` が設定されていればそれを tilde 展開して返す。
/// 未設定なら `~/.config/rvpm/<appname>` (デフォルト)。
fn resolve_config_root(config_root: Option<&str>) -> PathBuf {
    match config_root {
        Some(raw) => expand_tilde(raw),
        None => {
            let home = dirs::home_dir().expect("Could not find home directory");
            home.join(".config").join("rvpm").join(appname())
        }
    }
}

/// 指定プラグインの per-plugin hook ディレクトリ (`<config_root>/plugins/<host>/<owner>/<repo>`)
/// を返す。
fn resolve_plugin_config_dir(config_root: &Path, plugin: &config::Plugin) -> PathBuf {
    config_root.join("plugins").join(plugin.canonical_path())
}

/// loader.lua のパス。常に `<cache_root>/plugins/loader.lua`。
fn resolve_loader_path(cache_root: &Path) -> PathBuf {
    cache_root.join("plugins").join("loader.lua")
}

/// lockfile (`rvpm.lock`) のパス。dotfiles にコミットされる想定のため、
/// `cache_root` ではなく `config_root` (= `~/.config/rvpm/<appname>/`) 直下。
/// `options.config_root` が上書きされていればそれに追従する。
fn resolve_lockfile_path(config_root: &Path) -> PathBuf {
    config_root.join("rvpm.lock")
}

/// `rvpm log` の永続化先。常に `<cache_root>/update_log.json` (loader.lua の親
/// ディレクトリと同階層 = cache_root 直下、`plugins/` 配下ではない)。
fn resolve_update_log_path(cache_root: &Path) -> PathBuf {
    cache_root.join("update_log.json")
}

/// `rvpm doctor` が読む最新 sync の merge 衝突スナップショット。
/// `<cache_root>/merge_conflicts.json` 固定 (`update_log.json` と同じ場所)。
fn resolve_merge_conflicts_path(cache_root: &Path) -> PathBuf {
    cache_root.join("merge_conflicts.json")
}

/// プラグイン単位の最終 fetch 時刻キャッシュ。
/// `<cache_root>/fetch_state.json` 固定 (update_log / merge_conflicts と同じ場所)。
fn resolve_fetch_state_path(cache_root: &Path) -> PathBuf {
    cache_root.join("fetch_state.json")
}

/// `Plugin` + `GitChange` から永続化向けの `ChangeRecord` を組み立てる小ヘルパー。
/// run_sync / run_update / run_add で共通利用。
fn change_record_from(
    plugin: &crate::config::Plugin,
    change: crate::git::GitChange,
) -> crate::update_log::ChangeRecord {
    crate::update_log::ChangeRecord {
        name: plugin.display_name(),
        url: plugin.url.clone(),
        from: change.from,
        to: change.to,
        subjects: change.subjects,
        breaking_subjects: change.breaking_subjects,
        doc_files_changed: change.doc_files_changed,
    }
}

/// `record_run` を呼び、書き込み失敗時は警告を出すだけで panic / 操作中断はしない。
/// 1 件の git 操作も発生しなかった (= changes が空) 場合でも run の事実は残す。
fn record_changes_or_warn(
    cache_root: &Path,
    command: &str,
    changes: Vec<crate::update_log::ChangeRecord>,
) {
    let path = resolve_update_log_path(cache_root);
    if let Err(e) = crate::update_log::record_run(&path, command, changes) {
        eprintln!(
            "\u{26a0} update_log: failed to record {} run at {}: {}",
            command,
            path.display(),
            e
        );
    }
}

/// repos の親ディレクトリ。`<cache_root>/plugins/repos`。
fn resolve_repos_dir(cache_root: &Path) -> PathBuf {
    cache_root.join("plugins").join("repos")
}

/// merged ディレクトリ。`<cache_root>/plugins/merged`。
fn resolve_merged_dir(cache_root: &Path) -> PathBuf {
    cache_root.join("plugins").join("merged")
}

/// link.rs::merge_plugin を呼び出して、発生した衝突を勝者 (先に同じ path を
/// 置いた plugin) とセットで `conflicts` に積む。merge 自体が失敗した場合は
/// stderr に warn を流すがエラーにはしない (resilience)。
///
/// `ownership` は merged/ 上の relative path → 勝者 plugin 名の shared map。
/// 各 plugin 処理前に呼び出し側で 1 つだけ作り、順次 merge_and_record に
/// 渡すことで、後続 plugin の衝突時に勝者を lookup できる。
fn merge_and_record(
    src: &Path,
    dst_root: &Path,
    plugin_name: &str,
    ownership: &mut std::collections::HashMap<PathBuf, String>,
    conflicts: &mut Vec<crate::merge_conflicts::MergeConflictReport>,
) {
    match crate::link::merge_plugin(src, dst_root) {
        Ok(result) => {
            // 今回新規配置したファイルを ownership に登録 (勝者 = この plugin)。
            for placed in result.placed {
                ownership.insert(placed, plugin_name.to_string());
            }
            // 衝突の勝者を ownership から lookup。
            for c in result.conflicts {
                let winner = ownership.get(&c.relative).cloned();
                let rel = c.relative.to_string_lossy().replace('\\', "/");
                conflicts.push(crate::merge_conflicts::MergeConflictReport {
                    loser: plugin_name.to_string(),
                    winner,
                    relative: rel,
                });
            }
        }
        Err(e) => {
            eprintln!("\u{26a0} merge failed for {}: {}", plugin_name, e);
        }
    }
}

/// 収集した衝突を plugin ごとにグループ化して stderr にサマリ出力する。
/// 各ファイル行には `(kept: <winner>)` を付けて、どちらが first-wins で
/// 残ったかをユーザーが即座に分かるようにする。
fn print_merge_conflicts(conflicts: &[crate::merge_conflicts::MergeConflictReport]) {
    if conflicts.is_empty() {
        return;
    }
    let mut by_plugin: std::collections::BTreeMap<
        &str,
        Vec<&crate::merge_conflicts::MergeConflictReport>,
    > = std::collections::BTreeMap::new();
    for r in conflicts {
        by_plugin.entry(r.loser.as_str()).or_default().push(r);
    }
    eprintln!();
    eprintln!(
        "\u{26a0} {} merge conflict(s) across {} plugin(s) — first-wins, later entries skipped:",
        conflicts.len(),
        by_plugin.len(),
    );
    for (plugin, reports) in &by_plugin {
        eprintln!(
            "  {} ({} file{}):",
            plugin,
            reports.len(),
            plural_s(reports.len())
        );
        for r in reports {
            let winner = r.winner.as_deref().unwrap_or("<unknown>");
            eprintln!("    {}  (kept: {})", r.relative, winner);
        }
    }
}

fn plural_s(n: usize) -> &'static str {
    if n == 1 { "" } else { "s" }
}

// ====================================================================
// rvpm init: Neovim init.lua に loader をつなぐためのヘルパー
// ====================================================================

/// `$NVIM_APPNAME` を考慮して init.lua のパスを返す (pure function、テスト容易性のため env は外から注入)。
fn nvim_init_lua_path_for_appname(appname: Option<&str>) -> PathBuf {
    let appname = appname.unwrap_or("nvim");
    let home = dirs::home_dir().expect("Could not find home directory");
    home.join(".config").join(appname).join("init.lua")
}

/// 実行時の `$NVIM_APPNAME` 環境変数を見て init.lua のパスを返す。
fn nvim_init_lua_path() -> PathBuf {
    let appname = std::env::var("NVIM_APPNAME").ok();
    nvim_init_lua_path_for_appname(appname.as_deref())
}

/// loader.lua を参照する `dofile(...)` 行を config から生成する。
/// 優先順位: `options.cache_root`/plugins/loader.lua > `~/.cache/rvpm/<appname>/plugins/loader.lua`
/// tilde 形式を保持することで dotfiles のマシン間共有を妨げない。
fn loader_init_snippet(config: &config::Config) -> String {
    let raw_path = if let Some(base) = &config.options.cache_root {
        format!("{}/plugins/loader.lua", base.trim_end_matches(['/', '\\']))
    } else {
        format!("~/.cache/rvpm/{}/plugins/loader.lua", appname())
    };
    // Windows のバックスラッシュを Lua 文字列リテラルで安全な '/' に正規化。
    let raw_path = raw_path.replace('\\', "/");
    format!("dofile(vim.fn.expand(\"{}\"))", raw_path)
}

/// init.lua が rvpm の loader を参照しているかを緩く検出する。
/// 同じ行内に `rvpm` と `loader.lua` が両方出ていれば真。
fn init_lua_references_rvpm_loader(init_lua_path: &Path) -> bool {
    let Ok(content) = std::fs::read_to_string(init_lua_path) else {
        return false;
    };
    content
        .lines()
        .any(|line| line.contains("rvpm") && line.contains("loader.lua"))
}

#[derive(Debug, PartialEq, Eq)]
enum WriteInitResult {
    /// init.lua が存在しなかったので新規作成した
    Created,
    /// 既存 init.lua に末尾追記した
    Appended,
    /// 既に loader を参照していて変更不要だった
    AlreadyConfigured,
}

/// init.lua に loader snippet を書き込む (冪等)。
fn write_init_lua_snippet(init_lua_path: &Path, snippet: &str) -> Result<WriteInitResult> {
    if init_lua_path.exists() {
        if init_lua_references_rvpm_loader(init_lua_path) {
            return Ok(WriteInitResult::AlreadyConfigured);
        }
        let mut content = std::fs::read_to_string(init_lua_path)?;
        if !content.is_empty() && !content.ends_with('\n') {
            content.push('\n');
        }
        content.push_str("\n-- rvpm loader (auto-added by `rvpm init --write`)\n");
        content.push_str(snippet);
        content.push('\n');
        std::fs::write(init_lua_path, content)?;
        Ok(WriteInitResult::Appended)
    } else {
        if let Some(parent) = init_lua_path.parent() {
            std::fs::create_dir_all(parent)?;
        }
        let content = format!(
            "-- Neovim config (auto-created by `rvpm init --write`)\n\n-- rvpm loader\n{}\n",
            snippet
        );
        std::fs::write(init_lua_path, content)?;
        Ok(WriteInitResult::Created)
    }
}

/// `rvpm sync` / `rvpm generate` / `rvpm add` 等の末尾で呼ぶ hint 表示。
/// init.lua が loader を参照していない (or 未作成) なら案内を出す。
fn print_init_lua_hint_if_missing(config: &config::Config) {
    let init_lua_path = nvim_init_lua_path();
    if !init_lua_path.exists() {
        println!();
        println!(
            "\u{26a0} Neovim init.lua not found at {}",
            init_lua_path.display()
        );
        println!("  Run `rvpm init --write` to create one with the rvpm loader.");
        return;
    }
    if !init_lua_references_rvpm_loader(&init_lua_path) {
        let snippet = loader_init_snippet(config);
        println!();
        println!(
            "\u{26a0} {} doesn't reference rvpm loader yet.",
            init_lua_path.display()
        );
        println!("  Add this line:");
        println!("    {}", snippet);
        println!("  Or run `rvpm init --write` to do it automatically.");
    }
}

/// config.toml 上で指定プラグイン (url 一致) の `url = "..."` 行の行番号 (1-indexed) を返す。
/// 見つからなければ 1 を返す (ファイル先頭)。
/// whitespace の入り方に寛容: `url="..."`, `url = "..."`, `url  =   "..."` など全部拾う。
fn find_plugin_line_in_toml(toml_content: &str, url: &str) -> usize {
    let needle = format!("\"{}\"", url);
    for (i, line) in toml_content.lines().enumerate() {
        let trimmed = line.trim_start();
        if !trimmed.starts_with("url") {
            continue;
        }
        // "url" の後は空白 or "=" しか来ないはず (他のフィールド名は "url..." で始まらない)
        let rest = trimmed["url".len()..].trim_start();
        if !rest.starts_with('=') {
            continue;
        }
        if line.contains(&needle) {
            return i + 1;
        }
    }
    1
}

/// `$EDITOR` が `+<line>` 形式の行ジャンプをサポートするか簡易判定。
/// nvim/vim/vi/nano/emacs ファミリーは真。VS Code / helix 等は偽。
fn editor_supports_line_jump(editor_cmd: &str) -> bool {
    // Unix の Path は `\` をパス区切りと認識しないため、手動で両方で split する
    let file_name = editor_cmd.rsplit(['/', '\\']).next().unwrap_or(editor_cmd);
    let base = file_name
        .rsplit_once('.')
        .map(|(stem, _)| stem)
        .unwrap_or(file_name)
        .to_lowercase();
    matches!(base.as_str(), "nvim" | "vim" | "vi" | "nano" | "emacs")
}

/// `$EDITOR` (未設定なら "nvim") でファイルを開く。対応している editor なら指定行にジャンプ。
fn open_editor_at_line(path: &Path, line: usize) -> Result<()> {
    let editor = std::env::var("EDITOR").unwrap_or_else(|_| "nvim".to_string());
    let mut cmd = std::process::Command::new(&editor);
    if editor_supports_line_jump(&editor) {
        cmd.arg(format!("+{}", line));
    }
    cmd.arg(path);
    cmd.status()?;
    Ok(())
}

/// ESC キーで None を返し、Enter キーで入力文字列を Some で返すテキスト入力。
/// crossterm の raw mode を一時的に有効化して使用する。
/// `initial` を渡すと、その値を初期入力として表示・編集できる。
fn read_input_with_esc(prompt: &str, initial: &str) -> Result<Option<String>> {
    use crossterm::event::{Event, KeyCode, KeyEventKind, KeyModifiers};
    use std::io::Write;

    let mut input = String::from(initial);
    print!("{}: {}", prompt, input);
    std::io::stdout().flush()?;

    crossterm::terminal::enable_raw_mode()?;

    let result = loop {
        match crossterm::event::read()? {
            Event::Key(key) if key.kind == KeyEventKind::Press => match key.code {
                KeyCode::Esc => {
                    break Ok(None);
                }
                KeyCode::Enter => {
                    break Ok(Some(input.clone()));
                }
                KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
                    break Err(anyhow::anyhow!("Interrupted"));
                }
                KeyCode::Char(c) => {
                    input.push(c);
                    print!("{}", c);
                    std::io::stdout().flush()?;
                }
                KeyCode::Backspace if !input.is_empty() => {
                    input.pop();
                    print!("\x08 \x08");
                    std::io::stdout().flush()?;
                }
                _ => {}
            },
            _ => {}
        }
    };

    crossterm::terminal::disable_raw_mode()?;
    println!();
    result
}

/// init/before/after.lua の存在チェックしてサークルアイコンの文字列を返す
/// 例: "● ○ ●" (init あり、before なし、after あり)
fn hook_indicators(dir: &Path) -> String {
    let i = if dir.join("init.lua").exists() {
        "\u{25cf}"
    } else {
        "\u{25cb}"
    };
    let b = if dir.join("before.lua").exists() {
        "\u{25cf}"
    } else {
        "\u{25cb}"
    };
    let a = if dir.join("after.lua").exists() {
        "\u{25cf}"
    } else {
        "\u{25cb}"
    };
    format!("{} {} {}", i, b, a)
}

/// global hooks 用のサークルアイコン。`init.lua` だけ Neovim 本体の場所
/// (`nvim_init_lua_path()`) を見て、`before.lua` / `after.lua` は `<config_root>`
/// 配下を見る — `rvpm edit --global` の対応と同じ。
fn global_hook_indicators(config_root: &Path, init_lua_path: &Path) -> String {
    let i = if init_lua_path.exists() {
        "\u{25cf}"
    } else {
        "\u{25cb}"
    };
    let b = if config_root.join("before.lua").exists() {
        "\u{25cf}"
    } else {
        "\u{25cb}"
    };
    let a = if config_root.join("after.lua").exists() {
        "\u{25cf}"
    } else {
        "\u{25cb}"
    };
    format!("{} {} {}", i, b, a)
}

/// ファイル名に存在アイコンを付ける
fn file_with_icon(dir: &Path, name: &str) -> String {
    let icon = if dir.join(name).exists() {
        "\u{25cf}"
    } else {
        "\u{25cb}"
    };
    format!("{} {}", icon, name)
}

fn find_lua(dir: &Path, name: &str) -> Option<String> {
    let path = dir.join(name);
    if path.exists() {
        Some(path.to_string_lossy().to_string())
    } else {
        None
    }
}

/// 指定ディレクトリ配下を再帰的に walk し、`.vim` / `.lua` ファイルをソートして返す。
/// lazy.nvim の Util.walk + source_runtime のフィルタと同等。
/// ディレクトリが存在しない場合は空配列を返す (Resilience)。
/// `colors/` ディレクトリからカラースキーム名 (ファイル名から拡張子を除去) を収集する。
/// 例: `colors/catppuccin.lua` → `"catppuccin"`, `colors/catppuccin-latte.vim` → `"catppuccin-latte"`
/// build コマンドを解析して (実行プログラム, 引数リスト) を返す。
/// `:` で始まる場合は Neovim コマンドとして実行。rtp_dirs (自身 + 依存先) を
/// rtp に追加してコマンドや autoload 関数を使えるようにする。
/// それ以外はシェルコマンドとして `sh -c "..."` (Windows: `cmd /C "..."`) に変換。
fn parse_build_command(build_cmd: &str, rtp_dirs: &[PathBuf]) -> (String, Vec<String>) {
    if let Some(vim_cmd) = build_cmd.strip_prefix(':') {
        let rtp_cmds: Vec<String> = rtp_dirs
            .iter()
            .map(|d| format!("set rtp+={}", d.to_string_lossy().replace('\\', "/")))
            .collect();
        let rtp_cmd = rtp_cmds.join(" | ");
        (
            "nvim".to_string(),
            vec![
                "--headless".to_string(),
                "--cmd".to_string(),
                rtp_cmd,
                "-c".to_string(),
                vim_cmd.to_string(),
                "-c".to_string(),
                "qa!".to_string(),
            ],
        )
    } else if cfg!(windows) {
        (
            "cmd".to_string(),
            vec!["/C".to_string(), build_cmd.to_string()],
        )
    } else {
        (
            "sh".to_string(),
            vec!["-c".to_string(), build_cmd.to_string()],
        )
    }
}

fn collect_colorschemes(plugin_path: &Path) -> Vec<String> {
    let dir = plugin_path.join("colors");
    if !dir.exists() {
        return Vec::new();
    }
    let mut names: Vec<String> = std::fs::read_dir(&dir)
        .into_iter()
        .flatten()
        .filter_map(|e| e.ok())
        .filter(|e| e.file_type().map(|ft| ft.is_file()).unwrap_or(false))
        .filter_map(|e| {
            let path = e.path();
            let ext = path.extension()?.to_str()?;
            if ext == "lua" || ext == "vim" {
                Some(path.file_stem()?.to_string_lossy().to_string())
            } else {
                None
            }
        })
        .collect();
    names.sort();
    names.dedup();
    names
}

/// `<plugin_path>/denops/<name>/main.{ts,js}` を走査して denops プラグイン情報を返す。
/// denops.vim は main.ts を優先的に discover するため、存在すれば main.ts、
/// なければ main.js を採用する (どちらも無ければ対象外)。
fn collect_denops_plugins(plugin_path: &Path) -> Vec<crate::loader::DenopsPlugin> {
    let dir = plugin_path.join("denops");
    if !dir.exists() {
        return Vec::new();
    }
    let mut plugins: Vec<crate::loader::DenopsPlugin> = std::fs::read_dir(&dir)
        .into_iter()
        .flatten()
        .filter_map(|e| e.ok())
        // `Path::is_dir()` は symlink を follow するので、dev plugin や
        // mono-repo で symlink された `denops/<name>/` も拾える。
        // `DirEntry::file_type()` だと symlink 自体を見てしまい skip される。
        .filter(|e| e.path().is_dir())
        .filter_map(|e| {
            let sub = e.path();
            let name = sub.file_name()?.to_string_lossy().to_string();
            for candidate in ["main.ts", "main.js"] {
                let script = sub.join(candidate);
                if script.is_file() {
                    return Some(crate::loader::DenopsPlugin {
                        name,
                        main_script: script.to_string_lossy().replace('\\', "/"),
                    });
                }
            }
            None
        })
        .collect();
    plugins.sort_by(|a, b| a.name.cmp(&b.name));
    plugins
}

fn collect_source_files(plugin_path: &Path, subdir: &str) -> Vec<String> {
    let dir = plugin_path.join(subdir);
    if !dir.exists() {
        return Vec::new();
    }
    let mut files: Vec<String> = walkdir::WalkDir::new(&dir)
        .into_iter()
        .filter_map(|e| e.ok())
        .filter(|e| e.file_type().is_file())
        .filter(|e| {
            e.path()
                .extension()
                .and_then(|ext| ext.to_str())
                .map(|ext| ext == "lua" || ext == "vim")
                .unwrap_or(false)
        })
        .map(|e| e.path().to_string_lossy().replace('\\', "/"))
        .collect();
    files.sort();
    files
}

/// Plugin の実ディスク情報から PluginScripts を構築するヘルパー。
/// run_sync / run_generate で重複していたロジックを集約。
fn build_plugin_scripts(
    plugin: &crate::config::Plugin,
    plugin_path: &Path,
    plugin_config_dir: &Path,
) -> crate::loader::PluginScripts {
    // on_cmd / on_map / on_event の /regex/ 展開用に 1 回だけ静的スキャン (#85, #88)。
    // 対象は plugin/, ftplugin/, after/plugin/, lua/ 配下の .vim / .lua のみで、
    // load 経路に影響しないので dead plugin でもコストは小さい。
    let scan = crate::plugin_scan::scan_plugin(plugin_path);
    crate::loader::PluginScripts {
        name: plugin.display_name(),
        path: plugin_path.to_string_lossy().replace('\\', "/"),
        merge: plugin.merge,
        init: find_lua(plugin_config_dir, "init.lua"),
        before: find_lua(plugin_config_dir, "before.lua"),
        after: find_lua(plugin_config_dir, "after.lua"),
        plugin_files: collect_source_files(plugin_path, "plugin"),
        ftdetect_files: collect_source_files(plugin_path, "ftdetect"),
        after_plugin_files: collect_source_files(plugin_path, "after/plugin"),
        lazy: plugin.lazy,
        on_cmd: plugin.on_cmd.clone(),
        on_ft: plugin.on_ft.clone(),
        on_map: plugin.on_map.clone(),
        on_event: plugin.on_event.clone(),
        on_path: plugin.on_path.clone(),
        on_source: plugin.on_source.clone(),
        depends: plugin.depends.clone(),
        colorschemes: collect_colorschemes(plugin_path),
        denops_plugins: collect_denops_plugins(plugin_path),
        defined_commands: scan.commands,
        defined_plug_maps: scan.plug_maps,
        defined_user_events: scan.user_events,
        cond: plugin.cond.clone(),
    }
}

/// 入力 URL を GitHub の `owner/repo` 形式 (大文字小文字保持) に正規化する。
///
/// - `owner/repo` → `Some("owner/repo")`
/// - `https://github.com/Owner/Repo(.git)?(/)?` → `Some("Owner/Repo")`
/// - `git@github.com:Owner/Repo.git` → `Some("Owner/Repo")`
/// - `https://gitlab.com/...` 等の非 GitHub URL → `None`
///
/// 重複検出用 (`installed_full_name`) と config.toml 書き出し
/// (`format_plugin_url`) の両方で再利用する。大文字小文字は保持するので、
/// 比較目的では呼び出し側で `.to_lowercase()` すること。
fn github_owner_repo(url: &str) -> Option<String> {
    let trimmed = url
        .trim()
        .trim_end_matches('/')
        .trim_end_matches(".git")
        .trim_end_matches('/');
    if let Some(rest) = trimmed.strip_prefix("git@github.com:") {
        let parts: Vec<&str> = rest.split('/').collect();
        if parts.len() >= 2 {
            return Some(format!("{}/{}", parts[0], parts[1]));
        }
        return None;
    }
    for prefix in ["https://github.com/", "http://github.com/"] {
        if let Some(rest) = trimmed.strip_prefix(prefix) {
            let parts: Vec<&str> = rest.split('/').collect();
            if parts.len() >= 2 {
                return Some(format!("{}/{}", parts[0], parts[1]));
            }
            return None;
        }
    }
    if trimmed.contains("://") {
        return None;
    }
    // スキーム無しの 2 セグメント形式は **owner/repo** のみを受理し、ローカル
    // パスは除外する。`./foo`, `../foo`, `~/foo`, `/foo`, `\foo`,
    // `C:/foo` (Windows drive letter) 等を GitHub shorthand と誤認しない
    // ようガードする。
    if looks_like_local_path(trimmed) {
        return None;
    }
    if trimmed.contains('/') && !trimmed.contains(' ') {
        let parts: Vec<&str> = trimmed.split('/').collect();
        if parts.len() == 2 && is_valid_github_owner(parts[0]) && is_valid_github_repo(parts[1]) {
            return Some(format!("{}/{}", parts[0], parts[1]));
        }
    }
    None
}

/// ローカルパスっぽい文字列を判定する (GitHub shorthand と区別するため)。
/// - 先頭が `./` / `../` / `~` / `/` / `\`
/// - Windows のドライブレター (`C:`, `D:` 等) で始まる
fn looks_like_local_path(s: &str) -> bool {
    if s.starts_with("./") || s.starts_with("../") {
        return true;
    }
    if s.starts_with('~') || s.starts_with('/') || s.starts_with('\\') {
        return true;
    }
    // Windows drive letter: `C:`, `d:` 等
    let bytes = s.as_bytes();
    if bytes.len() >= 2 && bytes[1] == b':' && bytes[0].is_ascii_alphabetic() {
        return true;
    }
    false
}

/// GitHub owner 名の許容文字集合 (alphanumeric と hyphen)。
/// 先頭 `-` は GitHub 側が拒否するので弾く。
fn is_valid_github_owner(s: &str) -> bool {
    !s.is_empty() && !s.starts_with('-') && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
}

/// GitHub repo 名の許容文字集合 (alphanumeric と `- _ .`)。
/// owner よりやや緩く、`.` や `_` を受ける。
fn is_valid_github_repo(s: &str) -> bool {
    !s.is_empty()
        && s.chars()
            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
}

/// Plugin URL を GitHub の `owner/repo` 形式 (小文字) に正規化する。
/// GitHub の `full_name` は大文字小文字非依存なので lowercase で揃える。
/// - `"owner/repo"` → `Some("owner/repo")`
/// - `"https://github.com/Owner/Repo(.git)?"` → `Some("owner/repo")`
/// - `"git@github.com:Owner/Repo.git"` → `Some("owner/repo")`
/// - GitHub 以外 (gitlab 等) → None
fn installed_full_name(url: &str) -> Option<String> {
    github_owner_repo(url).map(|s| s.to_lowercase())
}

/// `config.toml` の url と lockfile / 他 plugin の url が同じリポジトリを
/// 指しているかを判定する。両方が GitHub URL として認識できれば正規化した
/// `owner/repo` で比較 (`owner/foo` と `https://github.com/owner/foo.git` を
/// 同一視)、そうでなければ生文字列で一致を見る。`rvpm add` の重複検出と同じ方針。
fn urls_match(a: &str, b: &str) -> bool {
    match (installed_full_name(a), installed_full_name(b)) {
        (Some(x), Some(y)) => x == y,
        _ => a == b,
    }
}

/// `rvpm add` が `config.toml` に書き込む URL を `options.url_style` に従って整形する。
/// GitHub リポジトリと認識できる入力は `Short` / `Full` で書き換え、
/// 認識できないもの (gitlab など) は入力をそのまま返す。
fn format_plugin_url(input: &str, style: crate::config::UrlStyle) -> String {
    use crate::config::UrlStyle;
    match github_owner_repo(input) {
        Some(owner_repo) => match style {
            UrlStyle::Short => owner_repo,
            UrlStyle::Full => format!("https://github.com/{}", owner_repo),
        },
        None => input.to_string(),
    }
}

/// `rvpm log [query] [--last N] [--full] [--diff]` 本体。
///
/// 永続化 JSON を読み、`--diff` が指定されていれば対象 doc files の patch を
/// `git diff <from>..<to> -- <file>` で 1 ファイルずつ取得し、整形して stdout に出す。
async fn run_log(query: Option<String>, last: usize, full: bool, diff: bool) -> Result<()> {
    // config.toml は **1 回だけ** 読む。resilience 原則: 壊れていても log は見える
    // べきなので `Option<Config>` にして以降は参照使い回し。
    let config_path = rvpm_config_path();
    let config: Option<crate::config::Config> = if config_path.exists() {
        match std::fs::read_to_string(&config_path) {
            Ok(toml_content) => parse_config(&toml_content).ok(),
            Err(_) => None,
        }
    } else {
        None
    };

    let cache_root = config
        .as_ref()
        .map(|c| resolve_cache_root(c.options.cache_root.as_deref()))
        .unwrap_or_else(|| resolve_cache_root(None));
    let icons = config.as_ref().map(|c| c.options.icons).unwrap_or_default();
    let log_path = resolve_update_log_path(&cache_root);

    let log = crate::update_log::load_log(&log_path);
    // 上限を超える `--last` は MAX_RUNS に丸める。
    let last = last.clamp(1, crate::update_log::MAX_RUNS);
    // query の lowercase も 1 回だけ。
    let query_lower: Option<String> = query.as_deref().map(|q| q.to_lowercase());
    let matches_query = |name: &str| -> bool {
        match &query_lower {
            Some(q) => name.to_lowercase().contains(q.as_str()),
            None => true,
        }
    };

    // `--diff` 用の patch 取得は表示順 (新しい run から最大 `last` 件) にだけ実施し、
    // クエリでフィルタされたプラグインに限る (無駄な git diff を避ける)。
    // key は (url, from, to, file) で run を区別する。`--last 2 --diff` 時に同じ
    // plugin の同じ doc file が複数 run で変わっていても patch が上書きされない。
    let mut diffs: std::collections::HashMap<crate::update_log::DiffKey, String> =
        std::collections::HashMap::new();
    if diff {
        let mut shown = 0;
        for run in log.runs.iter().rev() {
            if shown >= last {
                break;
            }
            if !run.changes.iter().any(|c| matches_query(&c.name)) {
                continue;
            }
            shown += 1;
            for change in &run.changes {
                if !matches_query(&change.name) {
                    continue;
                }
                // 新規 clone (from = None) は from..to を作れないので skip
                let Some(from) = change.from.as_deref() else {
                    continue;
                };
                if change.doc_files_changed.is_empty() {
                    continue;
                }
                // dst_path は事前パース済み config から解決。config が無ければ skip。
                let Some(cfg) = config.as_ref() else { continue };
                let Some(plugin) = cfg.plugins.iter().find(|p| p.url == change.url) else {
                    continue;
                };
                let dst_path = resolve_plugin_dst(plugin, &cache_root);
                for file in &change.doc_files_changed {
                    let output = tokio::process::Command::new("git")
                        .arg("-C")
                        .arg(&dst_path)
                        .args(["diff", &format!("{}..{}", from, change.to), "--", file])
                        .output()
                        .await;
                    if let Ok(out) = output
                        && out.status.success()
                    {
                        let patch = String::from_utf8_lossy(&out.stdout).to_string();
                        diffs.insert(
                            crate::update_log::DiffKey {
                                url: change.url.clone(),
                                from: from.to_string(),
                                to: change.to.clone(),
                                file: file.clone(),
                            },
                            patch,
                        );
                    }
                }
            }
        }
    }

    let opts = crate::update_log::LogRenderOptions {
        last,
        query: query.as_deref(),
        full,
        diff,
        diffs,
        icons,
        now: std::time::SystemTime::now(),
    };
    let rendered = crate::update_log::render_log(&log, &opts);
    print!("{}", rendered);
    Ok(())
}

/// loader.lua を一時的に差し替える間、panic / 非正常終了でも原本を戻す Drop guard。
///
/// `rvpm profile` は計測のため profile_mode=true で再生成した loader.lua を
/// 現役パスに上書きし、終了時に元の内容へ戻す。途中で rvpm が落ちても、次回の
/// sync / generate / profile が marker 残骸や不正な状態にならないよう、Drop で
/// 原本復元を試みる。
struct LoaderSwapGuard {
    loader_path: PathBuf,
    backup_path: PathBuf,
    committed: bool,
}

impl LoaderSwapGuard {
    fn create(loader_path: PathBuf) -> Result<Self> {
        let backup_path = loader_path.with_extension("lua.bak");
        if backup_path.exists() {
            let _ = std::fs::remove_file(&backup_path);
        }
        if loader_path.exists() {
            std::fs::rename(&loader_path, &backup_path).with_context(|| {
                format!("failed to back up loader.lua to {}", backup_path.display())
            })?;
        }
        Ok(Self {
            loader_path,
            backup_path,
            committed: false,
        })
    }

    fn commit(mut self) -> Result<()> {
        self.restore()?;
        self.committed = true;
        Ok(())
    }

    fn restore(&self) -> Result<()> {
        if !self.backup_path.exists() {
            return Ok(());
        }
        if self.loader_path.exists() {
            let _ = std::fs::remove_file(&self.loader_path);
        }
        std::fs::rename(&self.backup_path, &self.loader_path).with_context(|| {
            format!(
                "failed to restore loader.lua from {}",
                self.backup_path.display()
            )
        })
    }
}

impl Drop for LoaderSwapGuard {
    fn drop(&mut self) {
        if self.committed {
            return;
        }
        if let Err(e) = self.restore() {
            eprintln!(
                "\u{26a0} rvpm profile: failed to auto-restore loader.lua on drop: {} — run `rvpm generate` to rebuild",
                e
            );
        }
    }
}

/// 起動時に前回 crash 由来の `loader.lua.bak` があれば検出して復元する。
///
/// ただし現在の loader.lua が `rvpm-profile-markers-` を含まない (= 既にクリーン
/// な generate で上書き済み) ケースでは、戻すべきでない古い bak を捨てる。
/// これをしないと「crash 後にユーザが `rvpm generate` で loader を更新 → 次の
/// profile 起動で古い bak が上書きしてロールバックしてしまう」事故が起きる。
fn recover_stale_loader_backup(loader_path: &Path) {
    let backup_path = loader_path.with_extension("lua.bak");
    if !backup_path.exists() {
        return;
    }
    // 現状の loader.lua が instrumented なら bak は生きた退避、そうでなければ
    // generate/sync で再生成済み → bak は不要。
    let current_is_instrumented = std::fs::read_to_string(loader_path)
        .map(|s| s.contains("rvpm-profile-markers-"))
        .unwrap_or(false);
    if !current_is_instrumented {
        eprintln!(
            "\u{26a0} rvpm: removing stale loader.lua.bak (current loader.lua is already clean)"
        );
        let _ = std::fs::remove_file(&backup_path);
        return;
    }
    eprintln!(
        "\u{26a0} rvpm: detected stale loader.lua.bak from a previous crashed profile run — restoring",
    );
    if loader_path.exists() {
        let _ = std::fs::remove_file(loader_path);
    }
    if let Err(e) = std::fs::rename(&backup_path, loader_path) {
        eprintln!(
            "\u{26a0} rvpm: could not auto-restore ({}). Run `rvpm generate` to rebuild.",
            e
        );
    }
}

/// `rvpm profile` 本体。
///
/// 流れ:
///   1. 前回 crash の `.bak` 検出 → 自動復元
///   2. `--no-instrument` で無ければ loader.lua を退避 + instrumented loader に差し替え
///   3. marker 空 .vim を tmp dir に事前作成
///   4. `nvim --headless --startuptime` を N 回実行
///   5. commit で原本復元、marker dir 削除
///   6. TUI / plain / JSON で出力
async fn run_profile(
    runs: usize,
    top: Option<usize>,
    json: bool,
    no_tui: bool,
    no_merge: bool,
    no_instrument: bool,
) -> Result<()> {
    let runs = runs.clamp(1, 20);

    // `--no-merge` は loader 側の force_unmerge に乗せる設計なので、instrumented
    // 版の loader.lua が生成されないと効かない。silent に無視すると「計測結果が
    // merged のままなのに no_merge=true で表示される」矛盾になるので fail fast。
    if no_merge && no_instrument {
        anyhow::bail!(
            "--no-merge requires loader instrumentation (it is applied at generate time); remove --no-instrument to use --no-merge"
        );
    }

    let config_path = rvpm_config_path();
    let toml_content = std::fs::read_to_string(&config_path)
        .with_context(|| format!("Failed to read config file: {}", config_path.display()))?;
    let mut config = parse_config(&toml_content)?;
    crate::config::sort_plugins(&mut config.plugins)?;
    for plugin in config.plugins.iter_mut() {
        disable_merge_if_cond(plugin);
    }

    let cache_root = resolve_cache_root(config.options.cache_root.as_deref());
    let merged_dir = resolve_merged_dir(&cache_root);
    let loader_path = resolve_loader_path(&cache_root);
    // [user config] グループに入れるパス。rvpm 側 (`~/.config/rvpm/<appname>`) と
    // Neovim 側 (`~/.config/<appname>/`) 両方を候補にして、init.lua が [runtime] に
    // 落ちないようにする。
    let rvpm_config_root = resolve_config_root(config.options.config_root.as_deref());
    let nvim_config_root = nvim_init_lua_path()
        .parent()
        .map(|p| p.to_path_buf())
        .unwrap_or_default();
    let user_config_roots: Vec<std::path::PathBuf> = [rvpm_config_root, nvim_config_root]
        .into_iter()
        .filter(|p| !p.as_os_str().is_empty())
        .collect();

    recover_stale_loader_backup(&loader_path);

    // PluginPathEntry を組み立てる (profile.rs の path 帰属判定に渡す)
    let plugin_entries: Vec<crate::profile::PluginPathEntry> = config
        .plugins
        .iter()
        .map(|p| {
            let root = resolve_plugin_dst(p, &cache_root)
                .to_string_lossy()
                .to_string();
            crate::profile::PluginPathEntry {
                name: p.display_name(),
                root,
                lazy: p.lazy,
            }
        })
        .collect();

    // instrumented loader を書き出す (no_instrument 時は skip)。
    // marker_dir は `tempfile::TempDir` で取る — panic / early return / Ctrl-C で
    // 自動削除されるので、手動 remove_dir_all の漏れで tmp が汚染されない。
    let mut marker_dir_guard: Option<tempfile::TempDir> = None;
    let mut marker_dir: Option<PathBuf> = None;
    let mut swap_guard: Option<LoaderSwapGuard> = None;

    if !no_instrument {
        let tmp = tempfile::Builder::new()
            .prefix("rvpm-profile-markers-")
            .tempdir()
            .context("failed to create marker dir")?;
        let tmp_path = tmp.path().to_path_buf();

        let config_root = resolve_config_root(config.options.config_root.as_deref());
        let mut plugin_scripts = Vec::new();
        for plugin in &config.plugins {
            let dst_path = resolve_plugin_dst(plugin, &cache_root);
            let plugin_config_dir = resolve_plugin_config_dir(&config_root, plugin);
            plugin_scripts.push(build_plugin_scripts(plugin, &dst_path, &plugin_config_dir));
        }
        crate::loader::promote_lazy_to_eager(&mut plugin_scripts);

        // marker .vim 空ファイルを事前作成
        let expected = crate::loader::expected_markers(&plugin_scripts);
        for name in &expected {
            let f = tmp_path.join(format!("{}.vim", name));
            std::fs::write(&f, b"")
                .with_context(|| format!("failed to create marker {}", f.display()))?;
        }

        let guard = LoaderSwapGuard::create(loader_path.clone())?;
        let profile_opts = crate::loader::ProfileOptions {
            marker_dir: tmp_path.to_string_lossy().replace('\\', "/"),
            force_unmerge: no_merge,
        };
        let mut loader_opts = build_loader_options(&config_root);
        loader_opts.profile = Some(profile_opts);
        write_loader_to_path(&merged_dir, &plugin_scripts, &loader_path, &loader_opts)?;
        swap_guard = Some(guard);
        marker_dir = Some(tmp_path);
        marker_dir_guard = Some(tmp);
    }

    if !json && !no_tui {
        let mode = if no_instrument {
            "raw --startuptime"
        } else if no_merge {
            "instrumented + no-merge"
        } else {
            "instrumented"
        };
        eprintln!(
            "\u{26a1} rvpm profile: measuring nvim startup ({} run{}, {})…",
            runs,
            if runs == 1 { "" } else { "s" },
            mode
        );
    }

    let cfg = crate::profile::ProfileRunConfig {
        runs,
        plugins: plugin_entries,
        merged_dir,
        loader_path: loader_path.clone(),
        user_config_roots,
        marker_dir: marker_dir.clone(),
        no_merge,
        no_instrument,
    };

    // Ctrl-C 対応: profile 実行中にユーザが中断したら、Drop を待たず明示的に
    // swap_guard を commit して loader.lua を戻す。tokio::signal::ctrl_c は
    // SIGINT を tokio runtime 内で捕まえるため、panic!=unwind にならない環境
    // (release --abort=abort) でも Drop が走らないので、この手動 cleanup が必要。
    let report = tokio::select! {
        res = crate::profile::run_profile(cfg) => {
            res.context("profile run failed")?
        }
        _ = tokio::signal::ctrl_c() => {
            if let Some(g) = swap_guard.take() {
                let _ = g.commit();
            }
            drop(marker_dir_guard.take());
            anyhow::bail!("profile interrupted (Ctrl-C)");
        }
    };

    // 計測完了 → 原本復元を手動で commit (drop より前に明示的に行う)
    if let Some(g) = swap_guard.take() {
        g.commit()?;
    }
    // marker_dir_guard (TempDir) の drop で自動削除されるので、旧 remove_dir_all
    // の明示呼び出しは不要。marker_dir の PathBuf は値を保持するだけの
    // 副作用なし変数。
    drop(marker_dir_guard.take());
    let _ = marker_dir;

    if json {
        // `--top` は plain / JSON 両方に適用したいので、JSON 側でも truncate して
        // 出力する。元の report は mutate せず clone を加工する。
        let mut report = report.clone();
        if let Some(n) = top {
            report.plugins.truncate(n);
        }
        let v = crate::profile::report_to_json(&report);
        println!("{}", serde_json::to_string_pretty(&v)?);
        return Ok(());
    }

    if no_tui {
        crate::profile_tui::print_plain(&report, top);
        return Ok(());
    }

    crate::profile_tui::run(report)?;
    Ok(())
}

async fn run_browse() -> Result<bool> {
    use crate::browse_tui::BrowseTuiState;

    // cache_root / installed set / readme_command を config から解決。
    // resilience 原則: config.toml が壊れていても browse TUI は defaults で開く。
    let config_path = rvpm_config_path();
    let defaults = || {
        (
            resolve_cache_root(None),
            std::collections::HashSet::<String>::new(),
            None::<Vec<String>>,
        )
    };
    // `c` ハンドラで options.cache_root が変わった場合も追従できるよう `let mut`。
    let (mut cache_root, installed, readme_command) = 'resolve: {
        if !config_path.exists() {
            break 'resolve defaults();
        }
        let toml_content = match std::fs::read_to_string(&config_path) {
            Ok(s) => s,
            Err(e) => {
                eprintln!("\u{26a0} failed to read {}: {}", config_path.display(), e);
                break 'resolve defaults();
            }
        };
        match parse_config(&toml_content) {
            Ok(config) => {
                let cache = resolve_cache_root(config.options.cache_root.as_deref());
                let set: std::collections::HashSet<String> = config
                    .plugins
                    .iter()
                    .filter_map(|p| installed_full_name(&p.url))
                    .collect();
                let cmd = config
                    .options
                    .browse
                    .readme_command
                    .filter(|v| !v.is_empty());
                (cache, set, cmd)
            }
            Err(e) => {
                eprintln!(
                    "\u{26a0} failed to parse {}: {}. Opening browse with defaults.",
                    config_path.display(),
                    e
                );
                defaults()
            }
        }
    };

    let mut state = BrowseTuiState::new();
    state.installed = installed;
    state.readme_command = readme_command;

    // 初期表示: 人気プラグインをバックグラウンドで取得
    let cache_root_bg = cache_root.clone();
    let popular = tokio::task::spawn_blocking(move || crate::browse::fetch_popular(&cache_root_bg));

    enable_raw_mode()?;
    let mut stdout = std::io::stdout();
    execute!(stdout, EnterAlternateScreen)?;
    let backend = CrosstermBackend::new(stdout);
    let mut terminal = ratatui::Terminal::new(backend)?;

    // 人気プラグインの結果を待つ
    if let Ok(Ok(repos)) = popular.await {
        state.set_plugins(repos);
    }

    // README を非同期で取得するためのチャネル
    let (readme_tx, mut readme_rx) = tokio::sync::mpsc::channel::<(String, String)>(1);
    // 外部 renderer (`options.browse.readme_command`) の結果用チャネル。
    // 成功時は `Rendered(key, text)`、失敗時は `Warning(message)` をユーザーに
    // 見せる (title bar の message)。capacity 2 は resize 連打時の drop 防止。
    enum RenderMsg {
        Rendered((String, usize, u16), ratatui::text::Text<'static>),
        Warning(String),
    }
    let (render_tx, mut render_rx) = tokio::sync::mpsc::channel::<RenderMsg>(2);
    let mut last_selected: Option<String> = None;
    // README pane の scroll が変化したら terminal.clear() して diff を無効化する。
    // highlight-code の styled span が zellij 等で残骸を残す問題への belt-and-suspenders。
    let mut last_readme_scroll: u16 = state.readme_scroll;
    // 外部 renderer task の重複 spawn 防止用。`(full_name, content_len, width)`
    // がこれと同じなら再スポーンしない。selection / content / resize いずれかの
    // 変化で key が動けば次のループで spawn される。
    let mut last_render_spawned: Option<(String, usize, u16)> = None;

    // `l` キーで list TUI に遷移したいフラグ。ループ終了後 main.rs 側に返す。
    let mut goto_list = false;

    loop {
        if state.readme_scroll != last_readme_scroll {
            terminal.clear()?;
            last_readme_scroll = state.readme_scroll;
        }
        terminal.draw(|f| state.draw(f))?;

        // README 非同期受信
        if let Ok((full_name, content)) = readme_rx.try_recv()
            && state
                .selected_repo()
                .map(|r| r.full_name == full_name)
                .unwrap_or(false)
        {
            state.readme_content = Some(content);
            state.readme_loading = false;
            // 新 content で external_key_current が変わるので、下の統一 spawn 判定に任せる。
        }

        // 外部 renderer の結果 or 警告を受信。
        if let Ok(msg) = render_rx.try_recv() {
            match msg {
                RenderMsg::Rendered(key, text) => {
                    if state.external_key_matches(&key) {
                        state.readme_external_rendered = Some(text);
                        state.readme_external_key = Some(key);
                    }
                }
                RenderMsg::Warning(text) => {
                    state.message = Some(text);
                }
            }
        }

        // 選択変更時に README を非同期取得。
        // 注意: 外部 render spawn より **先に** やる。そうしないと新 repo が
        // selected、readme_content はまだ旧 repo の内容、という状況で
        // external_key_current() が (新 full_name, 旧 content_len) の混成 key を
        // 吐いて、そのまま spawn されると stale な render が混入する恐れがある。
        let current_selected = state.selected_repo().map(|r| r.full_name.clone());
        if current_selected != last_selected {
            last_selected = current_selected.clone();
            if let Some(repo) = state.selected_repo().cloned() {
                state.readme_loading = true;
                state.readme_content = None;
                state.readme_scroll = 0;
                // 新 repo 用に外部 render state もクリアし、spawn debounce key もリセット。
                // こうしないと新 repo + 旧 content_len が偶然一致したケースで
                // last_render_spawned が spawn を抑制してしまう。
                state.readme_external_rendered = None;
                state.readme_external_key = None;
                last_render_spawned = None;
                // Clear widget だけでは ansi-to-tui の styled span の残骸が
                // 一部ホスト (zellij 等) で残ることがあるため、選択変更時は
                // ratatui の内部バッファを明示的に無効化して全セル再描画を強制する。
                terminal.clear()?;
                let tx = readme_tx.clone();
                let cache_root_bg = cache_root.clone();
                tokio::task::spawn_blocking(move || {
                    let content = crate::browse::fetch_readme(&cache_root_bg, &repo)
                        .unwrap_or_else(|e| format!("Error: {}", e));
                    let _ = tx.blocking_send((repo.full_name.clone(), content));
                });
            }
        }

        // 選択変更 / content 受信 / resize いずれかで key が動いたら、
        // 未 spawn の key なら外部 renderer task を spawn する。
        // `last_render_spawned` が実際に飛ばしたキーの記憶役。
        if let Some(cmd) = state.readme_command.as_ref()
            && state.readme_content.is_some()
            && let Some(key) = state.external_key_current()
            && last_render_spawned.as_ref() != Some(&key)
            && let Some(source) = state.build_external_source()
        {
            last_render_spawned = Some(key.clone());
            let cmd = cmd.clone();
            let w = state.readme_visible_width;
            let h = state.readme_visible_height;
            let tx = render_tx.clone();
            tokio::task::spawn_blocking(move || {
                match crate::external_render::render(&cmd, &source, w, h) {
                    Ok(Some(text)) => {
                        let _ = tx.blocking_send(RenderMsg::Rendered(key, text));
                    }
                    Ok(None) => {
                        let _ = tx.blocking_send(RenderMsg::Warning(
                            "readme_command produced no output (fell back to built-in)".to_string(),
                        ));
                    }
                    Err(e) => {
                        let _ = tx.blocking_send(RenderMsg::Warning(format!(
                            "readme_command failed: {} (fell back to built-in)",
                            e
                        )));
                    }
                }
            });
        }

        // キー入力処理
        if crossterm::event::poll(std::time::Duration::from_millis(50))?
            && let crossterm::event::Event::Key(key) = crossterm::event::read()?
        {
            if key.kind != crossterm::event::KeyEventKind::Press {
                continue;
            }

            // `/` ローカルインクリメンタル検索モード
            if state.search_mode {
                match key.code {
                    crossterm::event::KeyCode::Esc => state.search_cancel(),
                    crossterm::event::KeyCode::Enter => state.search_confirm(),
                    crossterm::event::KeyCode::Backspace => state.search_backspace(),
                    crossterm::event::KeyCode::Char(c) => state.search_type(c),
                    _ => {}
                }
                continue;
            }

            // `S` GitHub API 検索モード (旧 `/` の挙動)
            if state.api_search_mode {
                match key.code {
                    crossterm::event::KeyCode::Esc => {
                        // API 入力だけキャンセル。既存の local `/` 検索の
                        // pattern / matches は保持して n/N を引き続き使えるようにする。
                        state.api_search_mode = false;
                        state.search_input.clear();
                    }
                    crossterm::event::KeyCode::Enter => {
                        state.api_search_mode = false;
                        let query = state.search_input.clone();
                        state.search_input.clear();
                        state.message = Some(format!("Searching '{}'...", query));
                        terminal.draw(|f| state.draw(f))?;
                        let cache_root_bg = cache_root.clone();
                        let result = tokio::task::spawn_blocking(move || {
                            crate::browse::search_plugins(&cache_root_bg, &query)
                        })
                        .await;
                        match result {
                            Ok(Ok(repos)) => {
                                state.message = Some(format!("{} results", repos.len()));
                                state.set_plugins(repos);
                                last_selected = None; // 新しい結果で README 再取得を強制
                            }
                            Ok(Err(e)) => {
                                state.message = Some(format!("Error: {}", e));
                            }
                            Err(e) => {
                                state.message = Some(format!("Error: {}", e));
                            }
                        }
                    }
                    crossterm::event::KeyCode::Backspace => {
                        state.search_input.pop();
                    }
                    crossterm::event::KeyCode::Char(c) => {
                        state.search_input.push(c);
                    }
                    _ => {}
                }
                continue;
            }

            match key.code {
                crossterm::event::KeyCode::Char('q') | crossterm::event::KeyCode::Esc => break,
                crossterm::event::KeyCode::Tab => {
                    state.toggle_focus();
                }
                crossterm::event::KeyCode::Char('?') => {
                    state.show_help = !state.show_help;
                }
                crossterm::event::KeyCode::Char('/') => {
                    state.start_search();
                }
                crossterm::event::KeyCode::Char('S') => {
                    state.start_api_search();
                }
                crossterm::event::KeyCode::Char('n') => {
                    state.search_next();
                }
                crossterm::event::KeyCode::Char('N') => {
                    state.search_prev();
                }

                // ── Navigation: focus-aware ──
                crossterm::event::KeyCode::Char('j') | crossterm::event::KeyCode::Down => {
                    match state.focus {
                        browse_tui::Focus::List => state.next(),
                        browse_tui::Focus::Readme => state.scroll_readme_down(1),
                    }
                }
                crossterm::event::KeyCode::Char('k') | crossterm::event::KeyCode::Up => {
                    match state.focus {
                        browse_tui::Focus::List => state.previous(),
                        browse_tui::Focus::Readme => state.scroll_readme_up(1),
                    }
                }
                crossterm::event::KeyCode::Char('g') | crossterm::event::KeyCode::Home => {
                    match state.focus {
                        browse_tui::Focus::List => state.go_top(),
                        browse_tui::Focus::Readme => state.readme_scroll = 0,
                    }
                }
                crossterm::event::KeyCode::Char('G') | crossterm::event::KeyCode::End => {
                    match state.focus {
                        browse_tui::Focus::List => state.go_bottom(),
                        browse_tui::Focus::Readme => state.scroll_readme_to_bottom(),
                    }
                }
                crossterm::event::KeyCode::Char('d')
                    if key
                        .modifiers
                        .contains(crossterm::event::KeyModifiers::CONTROL) =>
                {
                    match state.focus {
                        browse_tui::Focus::List => state.move_down(10),
                        browse_tui::Focus::Readme => state.scroll_readme_down(10),
                    }
                }
                crossterm::event::KeyCode::Char('u')
                    if key
                        .modifiers
                        .contains(crossterm::event::KeyModifiers::CONTROL) =>
                {
                    match state.focus {
                        browse_tui::Focus::List => state.move_up(10),
                        browse_tui::Focus::Readme => state.scroll_readme_up(10),
                    }
                }
                crossterm::event::KeyCode::Char('f')
                    if key
                        .modifiers
                        .contains(crossterm::event::KeyModifiers::CONTROL) =>
                {
                    match state.focus {
                        browse_tui::Focus::List => state.move_down(20),
                        browse_tui::Focus::Readme => state.scroll_readme_down(20),
                    }
                }
                crossterm::event::KeyCode::Char('b')
                    if key
                        .modifiers
                        .contains(crossterm::event::KeyModifiers::CONTROL) =>
                {
                    match state.focus {
                        browse_tui::Focus::List => state.move_up(20),
                        browse_tui::Focus::Readme => state.scroll_readme_up(20),
                    }
                }

                // ── Actions ──
                crossterm::event::KeyCode::Char('l') => {
                    goto_list = true;
                    break;
                }
                crossterm::event::KeyCode::Char('c') => {
                    let _ = disable_raw_mode();
                    let _ = execute!(terminal.backend_mut(), LeaveAlternateScreen);
                    let _ = terminal.show_cursor();
                    // エラーを握り潰さず、message に出して TUI で確認できるようにする。
                    let config_result = run_config().await;
                    let changed = matches!(&config_result, Ok(true));
                    let mut gen_err: Option<String> = None;
                    if changed {
                        if let Err(e) = run_generate().await {
                            gen_err = Some(e.to_string());
                        } else if let Ok(toml) = std::fs::read_to_string(&config_path)
                            && let Ok(new_config) = parse_config(&toml)
                        {
                            // options.cache_root が書き換わった可能性があるので
                            // 再 resolve。installed (`✓`) と readme_command も更新。
                            cache_root =
                                resolve_cache_root(new_config.options.cache_root.as_deref());
                            state.installed = new_config
                                .plugins
                                .iter()
                                .filter_map(|p| installed_full_name(&p.url))
                                .collect();
                            state.readme_command = new_config
                                .options
                                .browse
                                .readme_command
                                .filter(|v| !v.is_empty());
                        }
                    }
                    execute!(terminal.backend_mut(), EnterAlternateScreen)?;
                    enable_raw_mode()?;
                    terminal.clear()?;
                    terminal.hide_cursor()?;
                    // エディタ退出時に残った KeyRelease / Resize イベントが
                    // そのまま次の match に流れると誤発火する (例: 編集中の
                    // 大文字 `S` が API search トリガーになる等) ので drain。
                    while crossterm::event::poll(std::time::Duration::ZERO).unwrap_or(false) {
                        let _ = crossterm::event::read();
                    }
                    state.message = Some(match (&config_result, &gen_err) {
                        (Err(e), _) => format!("Config edit failed: {}", e),
                        (Ok(true), Some(e)) => format!("Config saved; regenerate failed: {}", e),
                        (Ok(true), None) => "Config saved; loader regenerated".to_string(),
                        (Ok(false), _) => "Config unchanged".to_string(),
                    });
                }
                crossterm::event::KeyCode::Char('s') => {
                    state.sort_mode = state.sort_mode.next();
                    state.sort_plugins();
                    state.message = Some(format!("Sort: {}", state.sort_mode.label()));
                }
                crossterm::event::KeyCode::Char('R') => {
                    crate::browse::clear_search_cache(&cache_root);
                    state.message = Some("Cache cleared. Searching...".to_string());
                    terminal.draw(|f| state.draw(f))?;
                    let cache_root_bg = cache_root.clone();
                    let result = tokio::task::spawn_blocking(move || {
                        crate::browse::fetch_popular(&cache_root_bg)
                    })
                    .await;
                    match result {
                        Ok(Ok(repos)) => {
                            state.message = Some(format!("{} plugins", repos.len()));
                            state.set_plugins(repos);
                            last_selected = None; // 新しい結果で README 再取得を強制
                        }
                        _ => {
                            state.message = Some("Refresh failed".to_string());
                        }
                    }
                }
                crossterm::event::KeyCode::Char('o') => {
                    // ブラウザで開く
                    if let Some(repo) = state.selected_repo() {
                        let url = repo.html_url.clone();
                        let _ = open::that(&url);
                    }
                }
                crossterm::event::KeyCode::Enter => {
                    // config.toml に追加 (installed なら警告のみ)
                    if let Some(repo) = state.selected_repo().cloned() {
                        if state.is_installed(&repo) {
                            state.message = Some(format!("already installed: {}", repo.full_name));
                            continue;
                        }
                        let url = repo.full_name.clone();
                        let _ = disable_raw_mode();
                        let _ = execute!(terminal.backend_mut(), LeaveAlternateScreen);
                        let _ = terminal.show_cursor();

                        println!("Adding {}...", url);
                        // run_add の最小版: config.toml に追記して sync
                        // browse で選ぶ user ほど「scan 結果で提案してほしい」派が
                        // 多い — policy_override は渡さず、config `options.auto_lazy`
                        // (デフォルト `Ask`) に委ねる。browse は always TTY なので
                        // `Ask` なら対話プロンプトが自然に出る。
                        // browse から add する場合も config の `options.ai` を尊重する
                        // (user が AI mode を default にしてれば browse 経由でも AI 経路)。
                        let result = run_add(
                            url.clone(),
                            None,
                            None,
                            None,
                            None,
                            None,
                            None,
                            None,
                            None,
                            None,
                        )
                        .await;
                        let added = result.is_ok();
                        match result {
                            Ok(_) => println!("Added {} successfully!", url),
                            Err(e) => eprintln!("Failed to add {}: {}", url, e),
                        }

                        // TUI に戻る
                        print!("\nPress any key to return to browse...");
                        use std::io::Write;
                        std::io::stdout().flush().ok();
                        enable_raw_mode()?;
                        loop {
                            if let crossterm::event::Event::Key(k) = crossterm::event::read()?
                                && k.kind == crossterm::event::KeyEventKind::Press
                            {
                                break;
                            }
                        }
                        disable_raw_mode()?;
                        execute!(terminal.backend_mut(), EnterAlternateScreen)?;
                        enable_raw_mode()?;
                        // ratatui の内部バッファは LeaveAlternateScreen 中に
                        // run_add() 内の sync TUI や println! が行った描画を知らない。
                        // clear() で全セル再描画を強制し、hide_cursor() で
                        // 先に show_cursor() した状態を戻す。
                        terminal.clear()?;
                        terminal.hide_cursor()?;
                        if added {
                            state.mark_installed(&repo);
                            state.message = Some(format!("Added {}", url));
                        } else {
                            state.message = Some(format!("Failed: {}", url));
                        }
                    }
                }
                _ => {}
            }
        }
    }

    let _ = disable_raw_mode();
    let _ = execute!(terminal.backend_mut(), LeaveAlternateScreen);
    let _ = terminal.show_cursor();
    Ok(goto_list)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::config::{Config, MapSpec, Options, Plugin};
    use crate::loader::PluginScripts;
    use tempfile::tempdir;
    use toml_edit::DocumentMut;

    // ── patch_plugin_entry_triggers regression: respect existing user fields ──

    #[test]
    fn patch_plugin_entry_triggers_does_not_overwrite_existing_on_cmd() {
        // user が `--on-cmd MyCmd` で明示指定した / 手編集で既に書いた entry は
        // scan 結果で上書きしてはいけない (PR #91 review 指摘)。
        let initial = r#"[[plugins]]
url = "owner/repo"
on_cmd = ["MyCmd"]
"#;
        let mut doc = initial.parse::<DocumentMut>().unwrap();
        let applied = AddTriggerSuggestion {
            on_cmd: vec!["ScannedFoo".into(), "ScannedBar".into()],
            on_map: vec![],
        };
        patch_plugin_entry_triggers(&mut doc, "owner/repo", &applied);
        let out = doc.to_string();
        assert!(
            out.contains(r#"on_cmd = ["MyCmd"]"#),
            "existing on_cmd must be preserved, got:\n{out}"
        );
        assert!(
            !out.contains("ScannedFoo"),
            "scan result must not be written"
        );
    }

    #[test]
    fn patch_plugin_entry_triggers_does_not_overwrite_existing_on_map() {
        let initial = r#"[[plugins]]
url = "owner/repo"
on_map = [{lhs = "<leader>x", mode = ["n", "x"], desc = "custom"}]
"#;
        let mut doc = initial.parse::<DocumentMut>().unwrap();
        let applied = AddTriggerSuggestion {
            on_cmd: vec![],
            on_map: vec![MapSpec {
                lhs: "gc".into(),
                mode: vec!["n".into()],
                desc: None,
            }],
        };
        patch_plugin_entry_triggers(&mut doc, "owner/repo", &applied);
        let out = doc.to_string();
        assert!(
            out.contains("<leader>x"),
            "existing on_map must be preserved:\n{out}"
        );
        assert!(!out.contains("gc"), "scan result must not be written");
    }

    #[test]
    fn patch_plugin_entry_triggers_writes_when_field_absent() {
        // 既存 entry に on_cmd が無ければ scan 結果を書く (通常パス)。
        let initial = r#"[[plugins]]
url = "owner/repo"
"#;
        let mut doc = initial.parse::<DocumentMut>().unwrap();
        let applied = AddTriggerSuggestion {
            on_cmd: vec!["ScannedFoo".into()],
            on_map: vec![],
        };
        patch_plugin_entry_triggers(&mut doc, "owner/repo", &applied);
        let out = doc.to_string();
        assert!(out.contains("ScannedFoo"));
    }

    // ─── replace_plugin_entry_with_ai_toml (#95) ─────────────────────────

    #[test]
    fn replace_plugin_entry_with_ai_toml_preserves_position_among_other_entries() {
        // [vars] や既存 [[plugins]] の中に stub が並んでいるとき、in-place マージが
        // entry の位置を維持することを確認 (user 報告: AI 提案が `[vars]` 直後に
        // 飛んで配置される現象の回帰 test)。
        let initial = r#"[vars]
nvim_rc = "~/.config/nvim/rc"

[[plugins]]
url = "first/plugin"
on_cmd = ["First"]

[[plugins]]
url = "second/stub"

[[plugins]]
url = "third/plugin"
"#;
        let mut doc = initial.parse::<DocumentMut>().unwrap();
        let proposal = r#"[[plugins]]
url = "second/stub"
on_event = ["BufReadPre"]
"#;
        replace_plugin_entry_with_ai_toml(&mut doc, "second/stub", proposal, &[], MergeMode::Merge)
            .unwrap();
        let out = doc.to_string();
        // first/plugin → second/stub → third/plugin の順序が維持される
        let first_pos = out.find("first/plugin").unwrap();
        let second_pos = out.find("second/stub").unwrap();
        let third_pos = out.find("third/plugin").unwrap();
        assert!(first_pos < second_pos);
        assert!(second_pos < third_pos);
        assert!(out.contains(r#"on_event = ["BufReadPre"]"#));
    }

    #[test]
    fn replace_plugin_entry_with_ai_toml_preserves_user_specified_keys() {
        // user の `--rev` / `--on-cmd` が既に stub に書かれている場合、AI 提案で
        // 上書きしないことを確認 (CodeRabbit Major #95)。
        let initial = r#"[[plugins]]
url = "owner/repo"
rev = "stable"
on_cmd = ["ManualCmd"]
"#;
        let mut doc = initial.parse::<DocumentMut>().unwrap();
        let proposal = r#"[[plugins]]
url = "owner/repo"
rev = "main"
on_cmd = ["AICmd"]
on_map = ["<leader>x"]
"#;
        // user が --rev と --on-cmd を明示したシナリオ
        let preserved = ["url", "rev", "on_cmd"];
        replace_plugin_entry_with_ai_toml(
            &mut doc,
            "owner/repo",
            proposal,
            &preserved,
            MergeMode::Merge,
        )
        .unwrap();
        let out = doc.to_string();
        // user の rev / on_cmd が残る
        assert!(
            out.contains(r#"rev = "stable""#),
            "user --rev must be preserved:\n{out}"
        );
        assert!(
            out.contains(r#"on_cmd = ["ManualCmd"]"#),
            "user --on-cmd must be preserved:\n{out}"
        );
        // AI が新規に追加した on_map は反映される
        assert!(
            out.contains("on_map"),
            "AI-only field must be added:\n{out}"
        );
        // url は stored_url で正規化される
        assert!(out.contains(r#"url = "owner/repo""#));
    }

    #[test]
    fn replace_plugin_entry_with_ai_toml_writes_ai_keys_when_no_preservation() {
        // preserved_keys が空 (user が CLI flag を出してない) なら AI 提案が全反映。
        let initial = r#"[[plugins]]
url = "owner/repo"
"#;
        let mut doc = initial.parse::<DocumentMut>().unwrap();
        let proposal = r#"[[plugins]]
url = "different/url"
on_cmd = ["AICmd"]
"#;
        replace_plugin_entry_with_ai_toml(&mut doc, "owner/repo", proposal, &[], MergeMode::Merge)
            .unwrap();
        let out = doc.to_string();
        // url は stored_url を維持 (AI が url を勘違いしても保護)
        assert!(out.contains(r#"url = "owner/repo""#));
        assert!(!out.contains("different/url"));
        assert!(out.contains(r#"on_cmd = ["AICmd"]"#));
    }

    #[test]
    fn replace_plugin_entry_with_ai_toml_rejects_proposal_with_zero_or_many_entries() {
        let initial = r#"[[plugins]]
url = "x/y"
"#;
        let mut doc = initial.parse::<DocumentMut>().unwrap();

        let zero = r#"name = "no plugins""#;
        assert!(
            replace_plugin_entry_with_ai_toml(&mut doc, "x/y", zero, &[], MergeMode::Merge)
                .is_err()
        );

        let many = r#"[[plugins]]
url = "a/b"

[[plugins]]
url = "c/d"
"#;
        assert!(
            replace_plugin_entry_with_ai_toml(&mut doc, "x/y", many, &[], MergeMode::Merge)
                .is_err()
        );
    }

    #[test]
    fn replace_plugin_entry_with_ai_toml_replace_mode_drops_stale_keys() {
        // CodeRabbit PR #100 指摘: tune で AI が omit した既存 field が
        // (`on_cmd`, `rev` 等) 残ってしまう問題の回帰 test。
        let initial = r#"[[plugins]]
url = "owner/tuneme"
on_cmd = ["StaleCmd"]
rev = "abc123"
on_ft = "rust"
"#;
        let mut doc = initial.parse::<DocumentMut>().unwrap();
        // AI 提案は on_cmd のみ — rev / on_ft は意図的に omit して "削除して" の意図。
        let proposal = r#"[[plugins]]
url = "owner/tuneme"
on_cmd = ["NewCmd"]
"#;
        replace_plugin_entry_with_ai_toml(
            &mut doc,
            "owner/tuneme",
            proposal,
            &[],
            MergeMode::Replace,
        )
        .unwrap();
        let out = doc.to_string();
        assert!(out.contains(r#"on_cmd = ["NewCmd"]"#));
        // 提案に含まれない既存 field は削除される
        assert!(
            !out.contains(r#"rev = "abc123""#),
            "Replace mode must drop stale rev:\n{out}"
        );
        assert!(
            !out.contains(r#"on_ft = "rust""#),
            "Replace mode must drop stale on_ft:\n{out}"
        );
        // url は強制保持
        assert!(out.contains(r#"url = "owner/tuneme""#));
    }

    #[test]
    fn replace_plugin_entry_with_ai_toml_replace_mode_keeps_preserved_keys() {
        // Replace mode でも `preserved_keys` に挙げた field は残す
        // (将来 `tune --keep rev` のような flag を足したときの保証)。
        let initial = r#"[[plugins]]
url = "owner/repo"
rev = "stable"
on_cmd = ["KeepMe"]
"#;
        let mut doc = initial.parse::<DocumentMut>().unwrap();
        let proposal = r#"[[plugins]]
url = "owner/repo"
on_event = ["BufRead"]
"#;
        replace_plugin_entry_with_ai_toml(
            &mut doc,
            "owner/repo",
            proposal,
            &["rev"],
            MergeMode::Replace,
        )
        .unwrap();
        let out = doc.to_string();
        // preserved な rev は残る
        assert!(out.contains(r#"rev = "stable""#));
        // preserved でない on_cmd は AI 提案に無いので消える
        assert!(!out.contains("KeepMe"));
        // AI 提案の on_event は新規追加
        assert!(out.contains(r#"on_event = ["BufRead"]"#));
    }

    // ─── extract_plugin_entry_toml (rvpm tune) ──────────────────────────

    #[test]
    fn extract_plugin_entry_toml_returns_full_block_with_header() {
        let toml = r#"[options]
ai = "claude"

[[plugins]]
url = "owner/first"
on_cmd = ["First"]

[[plugins]]
url = "owner/target"
on_cmd = ["Target"]
on_ft = "rust"

[[plugins]]
url = "owner/last"
"#;
        let doc = toml.parse::<DocumentMut>().unwrap();
        let entry = extract_plugin_entry_toml(&doc, "owner/target").unwrap();
        // header が付く
        assert!(entry.starts_with("[[plugins]]"));
        // 中身を含む
        assert!(entry.contains(r#"url = "owner/target""#));
        assert!(entry.contains(r#"on_cmd = ["Target"]"#));
        assert!(entry.contains(r#"on_ft = "rust""#));
        // 他 entry は含まれない
        assert!(!entry.contains("owner/first"));
        assert!(!entry.contains("owner/last"));
    }

    #[test]
    fn extract_plugin_entry_toml_returns_none_for_missing_url() {
        let toml = r#"[[plugins]]
url = "only/one"
"#;
        let doc = toml.parse::<DocumentMut>().unwrap();
        assert!(extract_plugin_entry_toml(&doc, "missing/repo").is_none());
    }

    #[test]
    fn extract_plugin_entry_toml_returns_none_when_plugins_missing() {
        let toml = "[options]\nai = \"claude\"\n";
        let doc = toml.parse::<DocumentMut>().unwrap();
        assert!(extract_plugin_entry_toml(&doc, "any/url").is_none());
    }

    // ─── select_plugin_url (rvpm tune / set / remove 共通) ───────────────

    #[test]
    fn select_plugin_url_query_exact_match_returns_url() {
        use crate::config::Plugin;
        let plugins = vec![
            Plugin {
                url: "owner/first".to_string(),
                ..Default::default()
            },
            Plugin {
                url: "owner/second".to_string(),
                ..Default::default()
            },
        ];
        let got = select_plugin_url(&plugins, Some("owner/second"), "select").unwrap();
        assert_eq!(got, Some("owner/second".to_string()));
    }

    #[test]
    fn select_plugin_url_query_substring_match_returns_first_match() {
        use crate::config::Plugin;
        let plugins = vec![
            Plugin {
                url: "alpha/foo".to_string(),
                ..Default::default()
            },
            Plugin {
                url: "beta/bar".to_string(),
                ..Default::default()
            },
        ];
        let got = select_plugin_url(&plugins, Some("beta"), "select").unwrap();
        assert_eq!(got, Some("beta/bar".to_string()));
    }

    #[test]
    fn select_plugin_url_query_no_match_errors() {
        use crate::config::Plugin;
        let plugins = vec![Plugin {
            url: "owner/repo".to_string(),
            ..Default::default()
        }];
        let err = select_plugin_url(&plugins, Some("nonexistent"), "select").unwrap_err();
        assert!(err.to_string().contains("Plugin not found"));
    }

    #[test]
    fn select_plugin_url_exact_match_wins_over_partial() {
        // user typed `cmp` and a plugin called exactly `cmp` exists alongside
        // longer `cmp-buffer` etc → exact match takes precedence (no ambiguity).
        use crate::config::Plugin;
        let plugins = vec![
            Plugin {
                url: "hrsh7th/cmp-buffer".to_string(),
                ..Default::default()
            },
            Plugin {
                url: "cmp".to_string(),
                ..Default::default()
            },
            Plugin {
                url: "hrsh7th/cmp-cmdline".to_string(),
                ..Default::default()
            },
        ];
        let got = select_plugin_url(&plugins, Some("cmp"), "select").unwrap();
        assert_eq!(got, Some("cmp".to_string()));
    }

    #[test]
    fn select_plugin_url_ambiguous_partial_errors_with_listing() {
        // CodeRabbit PR #100 指摘: 複数の partial match を黙って先頭採用すると
        // mutating コマンドが意図しない plugin を変更してしまう。複数 match は
        // error にし、候補一覧を見せる。
        use crate::config::Plugin;
        let plugins = vec![
            Plugin {
                url: "hrsh7th/cmp-buffer".to_string(),
                ..Default::default()
            },
            Plugin {
                url: "hrsh7th/cmp-cmdline".to_string(),
                ..Default::default()
            },
            Plugin {
                url: "hrsh7th/cmp-path".to_string(),
                ..Default::default()
            },
            Plugin {
                url: "folke/snacks.nvim".to_string(),
                ..Default::default()
            },
        ];
        let err = select_plugin_url(&plugins, Some("cmp"), "select").unwrap_err();
        let msg = err.to_string();
        assert!(msg.contains("matches multiple"));
        // 全候補が表示される
        assert!(msg.contains("hrsh7th/cmp-buffer"));
        assert!(msg.contains("hrsh7th/cmp-cmdline"));
        assert!(msg.contains("hrsh7th/cmp-path"));
        // match しないものは含まれない
        assert!(!msg.contains("snacks"));
    }

    fn write_file(path: &Path, content: &str) {
        if let Some(parent) = path.parent() {
            std::fs::create_dir_all(parent).unwrap();
        }
        std::fs::write(path, content).unwrap();
    }

    // ─── classify_held_back ──────────────────────────────────────────────
    // Pure classification of "this plugin is being held back by a lockfile
    // pin". The integration path is covered by the git::remote_head test
    // on the git.rs side; these tests nail down the decision table only.

    #[test]
    fn test_classify_held_back_reports_pin_behind_remote() {
        // No rev set, lockfile contributed a commit, and HEAD (= pin)
        // lags behind remote → this is the case users hit.
        let got = classify_held_back(
            None,
            Some("lockcommit"),
            Some("lockcommit"),
            Some("newcommit"),
        );
        assert_eq!(
            got,
            Some(("lockcommit".to_string(), "newcommit".to_string()))
        );
    }

    #[test]
    fn test_classify_held_back_silent_when_explicit_rev_set() {
        // If the user pinned via `rev`, the lag is intentional — not our
        // call to flag.
        let got = classify_held_back(Some("v1.0.0"), Some("v1.0.0"), Some("abc"), Some("def"));
        assert_eq!(got, None);
    }

    #[test]
    fn test_classify_held_back_silent_without_lockfile_contribution() {
        // No lockfile entry → sync already reset to remote; there's no pin
        // holding us back.
        let got = classify_held_back(None, None, Some("abc"), Some("abc"));
        assert_eq!(got, None);
    }

    #[test]
    fn test_classify_held_back_silent_when_pin_matches_remote() {
        // Lockfile pin and remote happen to line up — everyone's up to
        // date, no reason to nag.
        let got = classify_held_back(None, Some("abc"), Some("abc"), Some("abc"));
        assert_eq!(got, None);
    }

    #[test]
    fn test_classify_held_back_silent_when_remote_head_unknown() {
        // Can't prove the pin is behind if we can't resolve the remote tip.
        // Prefer silence over a false positive (resilience).
        let got = classify_held_back(None, Some("abc"), Some("abc"), None);
        assert_eq!(got, None);
    }

    #[test]
    fn test_classify_held_back_silent_when_head_unknown() {
        // Same resilience argument in the other direction.
        let got = classify_held_back(None, Some("abc"), None, Some("def"));
        assert_eq!(got, None);
    }

    #[test]
    fn test_collect_denops_plugins_finds_main_ts() {
        let root = tempdir().unwrap();
        let plugin = root.path().join("plugin-repo");
        write_file(
            &plugin.join("denops/foo/main.ts"),
            "export async function main() {}",
        );
        write_file(&plugin.join("denops/foo/util.ts"), "export const x = 1;");

        let got = collect_denops_plugins(&plugin);
        assert_eq!(got.len(), 1);
        assert_eq!(got[0].name, "foo");
        assert!(
            got[0].main_script.ends_with("denops/foo/main.ts"),
            "main_script should be absolute path ending with denops/foo/main.ts, got: {}",
            got[0].main_script
        );
        // forward slash に正規化されている
        assert!(
            !got[0].main_script.contains('\\'),
            "main_script must use forward slashes"
        );
    }

    #[test]
    fn test_collect_denops_plugins_returns_empty_without_denops_dir() {
        let root = tempdir().unwrap();
        let plugin = root.path().join("plugin-repo");
        write_file(&plugin.join("plugin/foo.vim"), "echo 'foo'");
        // denops/ が無い
        let got = collect_denops_plugins(&plugin);
        assert!(got.is_empty());
    }

    #[test]
    fn test_collect_denops_plugins_falls_back_to_main_js() {
        let root = tempdir().unwrap();
        let plugin = root.path().join("plugin-repo");
        // main.ts なし、main.js のみ
        write_file(
            &plugin.join("denops/bar/main.js"),
            "export async function main() {}",
        );

        let got = collect_denops_plugins(&plugin);
        assert_eq!(got.len(), 1);
        assert_eq!(got[0].name, "bar");
        assert!(got[0].main_script.ends_with("denops/bar/main.js"));
    }

    #[test]
    fn test_collect_denops_plugins_prefers_main_ts_over_main_js() {
        let root = tempdir().unwrap();
        let plugin = root.path().join("plugin-repo");
        write_file(&plugin.join("denops/dual/main.ts"), "ts");
        write_file(&plugin.join("denops/dual/main.js"), "js");
        let got = collect_denops_plugins(&plugin);
        assert_eq!(got.len(), 1);
        assert!(got[0].main_script.ends_with("main.ts"));
    }

    #[test]
    fn test_collect_denops_plugins_skips_dirs_without_main() {
        let root = tempdir().unwrap();
        let plugin = root.path().join("plugin-repo");
        // main.ts も main.js も無いディレクトリは無視
        write_file(&plugin.join("denops/incomplete/other.ts"), "");
        write_file(&plugin.join("denops/ok/main.ts"), "");
        let got = collect_denops_plugins(&plugin);
        assert_eq!(got.len(), 1);
        assert_eq!(got[0].name, "ok");
    }

    #[cfg(unix)]
    #[test]
    fn test_collect_denops_plugins_follows_symlinked_subdir() {
        // dev plugin や mono-repo の構成で、denops/<name>/ が別ディレクトリへの
        // symlink になっているケースを follow して検出する。
        // Windows は symlink 作成に管理者権限が要るので Unix のみで実行。
        let root = tempdir().unwrap();
        let plugin = root.path().join("plugin-repo");
        let real = root.path().join("external").join("real-denops");
        write_file(&real.join("main.ts"), "export async function main() {}");
        std::fs::create_dir_all(plugin.join("denops")).unwrap();
        std::os::unix::fs::symlink(&real, plugin.join("denops/sym-linked")).unwrap();

        let got = collect_denops_plugins(&plugin);
        assert_eq!(got.len(), 1);
        assert_eq!(got[0].name, "sym-linked");
        assert!(got[0].main_script.ends_with("main.ts"));
    }

    #[test]
    fn test_collect_denops_plugins_multiple_sorted_by_name() {
        // 同一プラグイン repo が複数の denops サブモジュールを持つケース
        // (例: 一部の mono-repo) も決定論的な順序を保証
        let root = tempdir().unwrap();
        let plugin = root.path().join("plugin-repo");
        write_file(&plugin.join("denops/zeta/main.ts"), "");
        write_file(&plugin.join("denops/alpha/main.ts"), "");
        write_file(&plugin.join("denops/mid/main.ts"), "");
        let got = collect_denops_plugins(&plugin);
        let names: Vec<&str> = got.iter().map(|d| d.name.as_str()).collect();
        assert_eq!(names, vec!["alpha", "mid", "zeta"]);
    }

    #[test]
    fn test_resolve_plugin_dst_expands_tilde_in_custom_dst() {
        // `dst = "~/src/foo"` のような tilde 付きパスは home dir に展開される。
        // 展開されないと dev プラグインの exists() チェックが常に false になる。
        let home = dirs::home_dir().expect("home dir");
        let cache_root = PathBuf::from("/tmp/rvpm-cache");
        let plugin = Plugin {
            url: "yukimemi/snacks-source-chronicle".to_string(),
            dst: Some("~/src/github.com/yukimemi/snacks-source-chronicle".to_string()),
            dev: true,
            ..Default::default()
        };
        let got = resolve_plugin_dst(&plugin, &cache_root);
        assert_eq!(
            got,
            home.join("src/github.com/yukimemi/snacks-source-chronicle")
        );
    }

    #[test]
    fn test_resolve_plugin_dst_uses_cache_root_when_dst_unset() {
        let cache_root = PathBuf::from("/tmp/rvpm-cache");
        let plugin = Plugin {
            url: "folke/snacks.nvim".to_string(),
            ..Default::default()
        };
        let got = resolve_plugin_dst(&plugin, &cache_root);
        // repos_dir は `{cache_root}/plugins/repos`
        assert!(got.starts_with(cache_root.join("plugins/repos")));
    }

    // ─── matches_rebuild_filter ─────────────────────────────────────────
    // `--rebuild [QUERY]` のスコープ判定を 3 分岐で押さえる:
    //   None        → 常に false (フラグ未指定、従来デフォルト)
    //   Some("")    → 常に true (フラグだけ、従来の `--rebuild` 挙動)
    //   Some("q")   → url / name に q を含めば true

    fn mk_plugin(url: &str, name: Option<&str>) -> Plugin {
        Plugin {
            url: url.to_string(),
            name: name.map(|s| s.to_string()),
            ..Default::default()
        }
    }

    #[test]
    fn test_rebuild_filter_none_never_matches() {
        let p = mk_plugin("nvim-treesitter/nvim-treesitter", None);
        assert!(!matches_rebuild_filter(&p, None));
    }

    #[test]
    fn test_rebuild_filter_empty_always_matches() {
        let p = mk_plugin("folke/snacks.nvim", None);
        assert!(matches_rebuild_filter(&p, Some("")));
    }

    #[test]
    fn test_rebuild_filter_substring_matches_url() {
        // 呼び出し側で lowercase 済みの query を渡す契約。
        let p = mk_plugin("nvim-treesitter/nvim-treesitter", None);
        assert!(matches_rebuild_filter(&p, Some("treesitter")));
        assert!(!matches_rebuild_filter(&p, Some("telescope")));
    }

    #[test]
    fn test_rebuild_filter_requires_caller_to_lowercase_query() {
        // 契約: caller が lowercase してから渡す。大文字混じりは一致しない
        // (run_sync 側で事前正規化する理由)。
        let p = mk_plugin("nvim-treesitter/nvim-treesitter", None);
        assert!(
            !matches_rebuild_filter(&p, Some("TREESITTER")),
            "case-insensitivity is the caller's responsibility"
        );
    }

    #[test]
    fn test_rebuild_filter_matches_explicit_name() {
        // URL と name が独立: name 側で hit させたいケース
        let p = mk_plugin("owner/repo", Some("my-alias"));
        assert!(matches_rebuild_filter(&p, Some("alias")));
    }

    #[test]
    fn test_rebuild_filter_no_false_match_without_name() {
        // name = None のとき、url にだけマッチングが走る (name 側で空文字との
        // 意図せぬ contains が起きないこと)
        let p = mk_plugin("foo/bar", None);
        assert!(!matches_rebuild_filter(&p, Some("baz")));
    }

    #[test]
    fn test_installed_full_name_owner_repo() {
        assert_eq!(
            installed_full_name("folke/snacks.nvim"),
            Some("folke/snacks.nvim".to_string())
        );
    }

    #[test]
    fn test_installed_full_name_https_url_with_git_suffix() {
        assert_eq!(
            installed_full_name("https://github.com/Owner/Repo.git"),
            Some("owner/repo".to_string())
        );
    }

    #[test]
    fn test_installed_full_name_https_url_without_git_suffix() {
        assert_eq!(
            installed_full_name("https://github.com/nvim-lua/plenary.nvim"),
            Some("nvim-lua/plenary.nvim".to_string())
        );
    }

    #[test]
    fn test_installed_full_name_ssh_url() {
        assert_eq!(
            installed_full_name("git@github.com:Owner/Repo.git"),
            Some("owner/repo".to_string())
        );
    }

    #[test]
    fn test_installed_full_name_non_github_returns_none() {
        assert_eq!(installed_full_name("https://gitlab.com/owner/repo"), None);
    }

    #[test]
    fn test_installed_full_name_case_normalized() {
        assert_eq!(
            installed_full_name("Folke/Snacks.NVIM"),
            Some("folke/snacks.nvim".to_string())
        );
    }

    // -------- urls_match --------

    #[test]
    fn test_urls_match_treats_github_short_and_full_as_equal() {
        // `owner/repo` 形式と `https://github.com/owner/repo` 形式は同一リポジトリ扱い。
        assert!(urls_match(
            "folke/snacks.nvim",
            "https://github.com/folke/snacks.nvim",
        ));
        assert!(urls_match(
            "https://github.com/Folke/Snacks.NVIM.git",
            "folke/snacks.nvim",
        ));
        assert!(urls_match(
            "git@github.com:folke/snacks.nvim.git",
            "folke/snacks.nvim",
        ));
    }

    #[test]
    fn test_urls_match_rejects_different_owner_or_repo() {
        // 同じ repo 名でも owner が違うのは別プラグイン。
        assert!(!urls_match("foo/snacks.nvim", "bar/snacks.nvim"));
        assert!(!urls_match("folke/snacks.nvim", "folke/other.nvim"));
    }

    #[test]
    fn test_urls_match_non_github_falls_back_to_string_eq() {
        // GitHub と認識できない URL は生文字列比較。
        assert!(urls_match(
            "https://gitlab.com/x/y",
            "https://gitlab.com/x/y",
        ));
        assert!(!urls_match(
            "https://gitlab.com/x/y",
            "https://gitlab.com/x/z",
        ));
    }

    #[test]
    fn test_installed_full_name_trailing_slash() {
        // `owner/repo/`, `.../repo.git/`, `.../repo/` をすべて許容する
        assert_eq!(
            installed_full_name("folke/snacks.nvim/"),
            Some("folke/snacks.nvim".to_string())
        );
        assert_eq!(
            installed_full_name("https://github.com/Owner/Repo/"),
            Some("owner/repo".to_string())
        );
        assert_eq!(
            installed_full_name("https://github.com/Owner/Repo.git/"),
            Some("owner/repo".to_string())
        );
    }

    #[test]
    fn test_github_owner_repo_preserves_case() {
        // installed_full_name と違い case は保持する (config.toml 書き込み用途)
        assert_eq!(
            github_owner_repo("Folke/Snacks.NVIM"),
            Some("Folke/Snacks.NVIM".to_string())
        );
        assert_eq!(
            github_owner_repo("https://github.com/Owner/Repo.git"),
            Some("Owner/Repo".to_string())
        );
        assert_eq!(
            github_owner_repo("git@github.com:Owner/Repo.git"),
            Some("Owner/Repo".to_string())
        );
    }

    #[test]
    fn test_github_owner_repo_non_github_returns_none() {
        assert_eq!(github_owner_repo("https://gitlab.com/a/b"), None);
        assert_eq!(github_owner_repo("https://git.example/a/b"), None);
    }

    #[test]
    fn test_format_plugin_url_short_form() {
        use crate::config::UrlStyle;
        // 入力がどの形式でも owner/repo に統一
        assert_eq!(
            format_plugin_url("folke/snacks.nvim", UrlStyle::Short),
            "folke/snacks.nvim"
        );
        assert_eq!(
            format_plugin_url("https://github.com/folke/snacks.nvim", UrlStyle::Short),
            "folke/snacks.nvim"
        );
        assert_eq!(
            format_plugin_url("https://github.com/folke/snacks.nvim.git", UrlStyle::Short),
            "folke/snacks.nvim"
        );
    }

    #[test]
    fn test_format_plugin_url_full_form() {
        use crate::config::UrlStyle;
        // 入力がどの形式でも https://github.com/owner/repo に統一
        assert_eq!(
            format_plugin_url("folke/snacks.nvim", UrlStyle::Full),
            "https://github.com/folke/snacks.nvim"
        );
        assert_eq!(
            format_plugin_url("https://github.com/folke/snacks.nvim.git", UrlStyle::Full),
            "https://github.com/folke/snacks.nvim"
        );
        assert_eq!(
            format_plugin_url("git@github.com:folke/snacks.nvim.git", UrlStyle::Full),
            "https://github.com/folke/snacks.nvim"
        );
    }

    #[test]
    fn test_format_plugin_url_non_github_passthrough() {
        use crate::config::UrlStyle;
        // GitHub 以外は入力そのまま保存 (style 無視)
        assert_eq!(
            format_plugin_url("https://gitlab.com/g/h", UrlStyle::Short),
            "https://gitlab.com/g/h"
        );
        assert_eq!(
            format_plugin_url("https://gitlab.com/g/h", UrlStyle::Full),
            "https://gitlab.com/g/h"
        );
    }

    #[test]
    fn test_github_owner_repo_rejects_local_paths() {
        // `./foo`, `../foo`, `~/foo`, `/foo`, `\foo`, `C:/foo` を GitHub shorthand
        // と誤認しないこと (CodeRabbit major fix)。
        assert_eq!(github_owner_repo("./foo"), None);
        assert_eq!(github_owner_repo("../foo"), None);
        assert_eq!(github_owner_repo("~/foo"), None);
        assert_eq!(github_owner_repo("/tmp/foo"), None);
        assert_eq!(github_owner_repo("\\foo\\bar"), None);
        assert_eq!(github_owner_repo("C:/foo"), None);
        assert_eq!(github_owner_repo("d:/bar/baz"), None);
    }

    #[test]
    fn test_github_owner_repo_rejects_invalid_chars() {
        // owner / repo の文字集合を超えるものは GitHub shorthand と認めない。
        // owner は alphanumeric + `-`、repo は + `. _` まで許容。
        assert_eq!(github_owner_repo("foo bar/baz"), None);
        assert_eq!(github_owner_repo("-foo/bar"), None); // owner 先頭 `-`
        assert_eq!(github_owner_repo("foo!/bar"), None); // owner に `!`
    }

    #[test]
    fn test_github_owner_repo_accepts_normal_shorthand() {
        // 正常な owner/repo は従来どおり受理される
        assert_eq!(
            github_owner_repo("folke/snacks.nvim"),
            Some("folke/snacks.nvim".to_string())
        );
        assert_eq!(
            github_owner_repo("nvim-lua/plenary.nvim"),
            Some("nvim-lua/plenary.nvim".to_string())
        );
    }

    #[test]
    fn test_parse_config_url_style_defaults_to_short() {
        let toml = r#"
[options]

[[plugins]]
url = "owner/repo"
"#;
        let config = crate::config::parse_config(toml).unwrap();
        assert_eq!(config.options.url_style, crate::config::UrlStyle::Short);
    }

    #[test]
    fn test_parse_config_accepts_url_style_full() {
        let toml = r#"
[options]
url_style = "full"

[[plugins]]
url = "owner/repo"
"#;
        let config = crate::config::parse_config(toml).unwrap();
        assert_eq!(config.options.url_style, crate::config::UrlStyle::Full);
    }

    #[test]
    fn test_update_filters_by_query() {
        let plugins = [
            Plugin {
                url: "owner/telescope.nvim".to_string(),
                ..Default::default()
            },
            Plugin {
                url: "owner/plenary.nvim".to_string(),
                ..Default::default()
            },
            Plugin {
                url: "owner/nvim-cmp".to_string(),
                ..Default::default()
            },
        ];
        let query = Some("telescope".to_string());
        let filtered: Vec<_> = plugins
            .iter()
            .filter(|p| {
                if let Some(q) = &query {
                    p.url.contains(q.as_str())
                } else {
                    true
                }
            })
            .collect();
        assert_eq!(filtered.len(), 1);
        assert_eq!(filtered[0].url, "owner/telescope.nvim");
    }

    #[test]
    fn test_update_no_query_matches_all() {
        let plugins = [
            Plugin {
                url: "owner/telescope.nvim".to_string(),
                ..Default::default()
            },
            Plugin {
                url: "owner/plenary.nvim".to_string(),
                ..Default::default()
            },
        ];
        let query: Option<String> = None;
        let filtered: Vec<_> = plugins
            .iter()
            .filter(|p| {
                if let Some(q) = &query {
                    p.url.contains(q.as_str())
                } else {
                    true
                }
            })
            .collect();
        assert_eq!(filtered.len(), 2);
    }

    #[test]
    fn test_expand_tilde_bare_tilde_returns_home() {
        let home = dirs::home_dir().unwrap();
        assert_eq!(expand_tilde("~"), home);
    }

    #[test]
    fn test_expand_tilde_with_forward_slash_subpath() {
        let home = dirs::home_dir().unwrap();
        assert_eq!(expand_tilde("~/foo/bar"), home.join("foo").join("bar"));
    }

    #[test]
    fn test_expand_tilde_with_backslash_subpath() {
        let home = dirs::home_dir().unwrap();
        // Windows 入力形式にも対応
        let got = expand_tilde("~\\foo\\bar");
        // 実際のパス区切りは OS 依存だが、home 配下に foo と bar を含むかで判定
        let s = got.to_string_lossy().replace('\\', "/");
        let expected = home
            .join("foo")
            .join("bar")
            .to_string_lossy()
            .replace('\\', "/");
        assert_eq!(s, expected);
    }

    #[test]
    fn test_expand_tilde_absolute_path_untouched() {
        assert_eq!(
            expand_tilde("/absolute/path"),
            PathBuf::from("/absolute/path")
        );
    }

    #[test]
    fn test_expand_tilde_relative_path_untouched() {
        assert_eq!(
            expand_tilde("relative/path"),
            PathBuf::from("relative/path")
        );
    }

    #[test]
    fn test_is_valid_appname_rejects_unsafe_values() {
        assert!(is_valid_appname("nvim"));
        assert!(is_valid_appname("nvim-test"));
        assert!(!is_valid_appname(""));
        assert!(!is_valid_appname("."));
        assert!(!is_valid_appname(".."));
        assert!(!is_valid_appname("foo/bar"));
        assert!(!is_valid_appname("foo\\bar"));
        assert!(!is_valid_appname("foo\0bar"));
    }

    #[test]
    fn test_resolve_cache_root_uses_appname_default() {
        let home = dirs::home_dir().unwrap();
        let result = resolve_cache_root(None);
        // appname は env に依存するので親ディレクトリだけ確認
        assert!(result.starts_with(home.join(".cache").join("rvpm")));
    }

    #[test]
    fn test_resolve_cache_root_expands_tilde() {
        let home = dirs::home_dir().unwrap();
        assert_eq!(
            resolve_cache_root(Some("~/dotfiles/rvpm")),
            home.join("dotfiles").join("rvpm")
        );
    }

    #[test]
    fn test_resolve_cache_root_accepts_absolute_path() {
        assert_eq!(
            resolve_cache_root(Some("/opt/rvpm")),
            PathBuf::from("/opt/rvpm")
        );
    }

    #[test]
    fn test_resolve_config_root_uses_default_when_none() {
        let home = dirs::home_dir().unwrap();
        let result = resolve_config_root(None);
        // デフォルトは `~/.config/rvpm/<appname>` (plugins は含まない)
        assert!(result.starts_with(home.join(".config").join("rvpm")));
        assert!(!result.ends_with("plugins"));
        // 末尾は appname (env 依存)
        assert_eq!(
            result.parent(),
            Some(home.join(".config").join("rvpm").as_path())
        );
    }

    #[test]
    fn test_resolve_config_root_expands_tilde() {
        let home = dirs::home_dir().unwrap();
        assert_eq!(
            resolve_config_root(Some("~/dotfiles/nvim")),
            home.join("dotfiles").join("nvim")
        );
    }

    #[test]
    fn test_resolve_config_root_accepts_absolute_path() {
        assert_eq!(
            resolve_config_root(Some("/etc/rvpm")),
            PathBuf::from("/etc/rvpm")
        );
    }

    #[test]
    fn test_resolve_plugin_config_dir_joins_plugins_subdir() {
        let plugin = config::Plugin {
            url: "folke/snacks.nvim".to_string(),
            ..Default::default()
        };
        let root = PathBuf::from("/tmp/rvpm");
        let got = resolve_plugin_config_dir(&root, &plugin);
        assert_eq!(
            got,
            PathBuf::from("/tmp/rvpm")
                .join("plugins")
                .join(plugin.canonical_path())
        );
    }

    // -----------------------------------------------------------------
    // rvpm init ヘルパーのテスト
    // -----------------------------------------------------------------

    #[test]
    fn test_nvim_init_lua_path_for_appname_defaults_to_nvim() {
        let home = dirs::home_dir().unwrap();
        assert_eq!(
            nvim_init_lua_path_for_appname(None),
            home.join(".config").join("nvim").join("init.lua")
        );
    }

    #[test]
    fn test_nvim_init_lua_path_for_appname_respects_nvim_appname() {
        let home = dirs::home_dir().unwrap();
        assert_eq!(
            nvim_init_lua_path_for_appname(Some("mynvim")),
            home.join(".config").join("mynvim").join("init.lua")
        );
    }

    #[test]
    fn test_loader_init_snippet_uses_default_when_no_options() {
        let cfg = config::Config {
            vars: None,
            options: config::Options::default(),
            plugins: vec![],
        };
        let snippet = loader_init_snippet(&cfg);
        // appname は env 依存なので partial match
        assert!(snippet.starts_with("dofile(vim.fn.expand(\"~/.cache/rvpm/"));
        assert!(snippet.ends_with("/plugins/loader.lua\"))"));
    }

    #[test]
    fn test_loader_init_snippet_uses_cache_root_when_set() {
        let cfg = config::Config {
            vars: None,
            options: config::Options {
                cache_root: Some("~/dotfiles/rvpm".to_string()),
                ..Default::default()
            },
            plugins: vec![],
        };
        assert_eq!(
            loader_init_snippet(&cfg),
            "dofile(vim.fn.expand(\"~/dotfiles/rvpm/plugins/loader.lua\"))"
        );
    }

    #[test]
    fn test_loader_init_snippet_normalizes_windows_path_separators() {
        let cfg = config::Config {
            vars: None,
            options: config::Options {
                cache_root: Some(r"C:\Users\test\.cache\rvpm\nvim".to_string()),
                ..Default::default()
            },
            plugins: vec![],
        };
        let snippet = loader_init_snippet(&cfg);
        assert!(
            !snippet.contains('\\'),
            "snippet contains backslash: {snippet}"
        );
        assert_eq!(
            snippet,
            "dofile(vim.fn.expand(\"C:/Users/test/.cache/rvpm/nvim/plugins/loader.lua\"))"
        );
    }

    #[test]
    fn test_loader_init_snippet_trims_trailing_backslash() {
        let cfg = config::Config {
            vars: None,
            options: config::Options {
                cache_root: Some(r"C:\cache\rvpm\".to_string()),
                ..Default::default()
            },
            plugins: vec![],
        };
        assert_eq!(
            loader_init_snippet(&cfg),
            "dofile(vim.fn.expand(\"C:/cache/rvpm/plugins/loader.lua\"))"
        );
    }

    #[test]
    fn test_init_lua_references_rvpm_loader_detects_line() {
        let root = tempdir().unwrap();
        let path = root.path().join("init.lua");
        std::fs::write(
            &path,
            "-- some\ndofile(vim.fn.expand(\"~/.cache/rvpm/loader.lua\"))\n",
        )
        .unwrap();
        assert!(init_lua_references_rvpm_loader(&path));
    }

    #[test]
    fn test_init_lua_references_rvpm_loader_false_when_absent() {
        let root = tempdir().unwrap();
        let path = root.path().join("init.lua");
        std::fs::write(&path, "-- empty\nvim.g.mapleader = ' '\n").unwrap();
        assert!(!init_lua_references_rvpm_loader(&path));
    }

    #[test]
    fn test_init_lua_references_rvpm_loader_false_when_file_missing() {
        let root = tempdir().unwrap();
        let path = root.path().join("missing.lua");
        assert!(!init_lua_references_rvpm_loader(&path));
    }

    #[test]
    fn test_init_lua_references_rvpm_loader_requires_both_keywords() {
        let root = tempdir().unwrap();
        let path = root.path().join("init.lua");
        // "loader.lua" だけでは rvpm の loader 参照と判定しない
        std::fs::write(&path, "dofile(\"~/other/loader.lua\")\n").unwrap();
        assert!(!init_lua_references_rvpm_loader(&path));
    }

    #[test]
    fn test_write_init_lua_snippet_creates_when_missing() {
        let root = tempdir().unwrap();
        let init_path = root.path().join("nvim").join("init.lua");
        let snippet = "dofile(vim.fn.expand(\"~/.cache/rvpm/loader.lua\"))";
        let result = write_init_lua_snippet(&init_path, snippet).unwrap();
        assert!(matches!(result, WriteInitResult::Created));
        assert!(init_path.exists());
        let content = std::fs::read_to_string(&init_path).unwrap();
        assert!(content.contains(snippet));
        assert!(content.contains("rvpm"));
    }

    #[test]
    fn test_write_init_lua_snippet_appends_when_exists_without_loader() {
        let root = tempdir().unwrap();
        let init_path = root.path().join("init.lua");
        std::fs::write(&init_path, "-- existing\nvim.g.mapleader = ' '\n").unwrap();
        let snippet = "dofile(vim.fn.expand(\"~/.cache/rvpm/loader.lua\"))";
        let result = write_init_lua_snippet(&init_path, snippet).unwrap();
        assert!(matches!(result, WriteInitResult::Appended));
        let content = std::fs::read_to_string(&init_path).unwrap();
        assert!(content.contains("mapleader"));
        assert!(content.contains(snippet));
    }

    #[test]
    fn test_write_init_lua_snippet_noop_when_already_configured() {
        let root = tempdir().unwrap();
        let init_path = root.path().join("init.lua");
        std::fs::write(
            &init_path,
            "dofile(vim.fn.expand(\"~/.cache/rvpm/loader.lua\"))\n",
        )
        .unwrap();
        let result = write_init_lua_snippet(
            &init_path,
            "dofile(vim.fn.expand(\"~/.cache/rvpm/loader.lua\"))",
        )
        .unwrap();
        assert!(matches!(result, WriteInitResult::AlreadyConfigured));
        let content = std::fs::read_to_string(&init_path).unwrap();
        // 行数が増えていないこと
        assert_eq!(content.lines().count(), 1);
    }

    #[test]
    fn test_resolve_loader_path_is_under_plugins() {
        let base = PathBuf::from("/cache/rvpm/nvim");
        let result = resolve_loader_path(&base);
        assert_eq!(result, PathBuf::from("/cache/rvpm/nvim/plugins/loader.lua"));
    }

    #[test]
    fn test_write_loader_to_path_creates_file() {
        let root = tempdir().unwrap();
        let merged = root.path().join("merged");
        std::fs::create_dir_all(&merged).unwrap();
        let loader_path = root.path().join("custom").join("loader.lua");
        let scripts: Vec<PluginScripts> = vec![];
        write_loader_to_path(
            &merged,
            &scripts,
            &loader_path,
            &crate::loader::LoaderOptions::default(),
        )
        .unwrap();
        assert!(loader_path.exists());
        let content = std::fs::read_to_string(&loader_path).unwrap();
        assert!(content.contains("-- rvpm generated loader.lua"));
    }

    #[test]
    fn test_resolve_concurrency_defaults_to_13() {
        let result = resolve_concurrency(None);
        assert_eq!(result, DEFAULT_CONCURRENCY);
        assert_eq!(result, 13);
    }

    #[test]
    fn test_resolve_concurrency_uses_config_value() {
        let result = resolve_concurrency(Some(5));
        assert_eq!(result, 5);
    }

    #[test]
    fn test_remove_from_toml() {
        let toml = "[[plugins]]\nurl = \"owner/a\"\n\n[[plugins]]\nurl = \"owner/b\"\n";
        let mut doc = toml.parse::<DocumentMut>().unwrap();
        remove_plugin_from_toml(&mut doc, "owner/a").unwrap();
        let result = doc.to_string();
        assert!(!result.contains("owner/a"));
        assert!(result.contains("owner/b"));
    }

    #[test]
    fn test_find_plugin_line_in_toml_basic() {
        let toml = "[options]\n\n[[plugins]]\nurl = \"owner/a\"\nlazy = true\n\n[[plugins]]\nurl = \"owner/b\"\n";
        //            1         2  3             4             5           6  7             8
        assert_eq!(find_plugin_line_in_toml(toml, "owner/a"), 4);
        assert_eq!(find_plugin_line_in_toml(toml, "owner/b"), 8);
    }

    #[test]
    fn test_find_plugin_line_in_toml_handles_whitespace_variants() {
        let toml = "[[plugins]]\nurl=\"owner/a\"\n\n[[plugins]]\nurl  =   \"owner/b\"\n";
        assert_eq!(find_plugin_line_in_toml(toml, "owner/a"), 2);
        assert_eq!(find_plugin_line_in_toml(toml, "owner/b"), 5);
    }

    #[test]
    fn test_find_plugin_line_in_toml_missing_falls_back_to_one() {
        let toml = "[[plugins]]\nurl = \"owner/a\"\n";
        assert_eq!(find_plugin_line_in_toml(toml, "owner/nonexistent"), 1);
    }

    #[test]
    fn test_find_plugin_line_in_toml_ignores_substring_matches() {
        // "owner/ab" should not be matched when searching for "owner/a"
        let toml = "[[plugins]]\nurl = \"owner/ab\"\n\n[[plugins]]\nurl = \"owner/a\"\n";
        assert_eq!(find_plugin_line_in_toml(toml, "owner/a"), 5);
    }

    #[test]
    fn test_editor_supports_line_jump() {
        assert!(editor_supports_line_jump("nvim"));
        assert!(editor_supports_line_jump("vim"));
        assert!(editor_supports_line_jump("vi"));
        assert!(editor_supports_line_jump("nano"));
        assert!(editor_supports_line_jump("emacs"));
        assert!(editor_supports_line_jump("/usr/local/bin/nvim"));
        assert!(editor_supports_line_jump(
            "C:\\Program Files\\Neovim\\bin\\nvim.exe"
        ));
        assert!(!editor_supports_line_jump("code"));
        assert!(!editor_supports_line_jump("hx"));
    }

    #[test]
    fn test_remove_from_toml_not_found_returns_error() {
        let toml = "[[plugins]]\nurl = \"owner/a\"\n";
        let mut doc = toml.parse::<DocumentMut>().unwrap();
        assert!(remove_plugin_from_toml(&mut doc, "owner/nonexistent").is_err());
    }

    #[test]
    fn test_set_plugin_list_field_single_writes_as_string() {
        let toml = "[[plugins]]\nurl = \"owner/a\"\n";
        let mut doc = toml.parse::<DocumentMut>().unwrap();
        set_plugin_list_field(&mut doc, "owner/a", "on_cmd", vec!["Telescope".to_string()])
            .unwrap();
        let result = doc.to_string();
        assert!(
            result.contains("on_cmd = \"Telescope\""),
            "1要素は文字列として書かれるべき: {}",
            result
        );
        assert!(
            !result.contains("on_cmd = ["),
            "1要素は配列にしないべき: {}",
            result
        );
    }

    #[test]
    fn test_set_plugin_list_field_errors_on_url_mismatch() {
        // `options.url_style = "full"` のケースで run_add 旧コードが踏んでいた
        // regression の回帰テスト。
        //
        // `format_plugin_url("owner/repo", Full)` は `https://github.com/owner/repo`
        // を返し、config.toml にはその canonical URL が書き込まれる。続けて
        // 旧 run_add は入力文字列 (`owner/repo`) をキーに `set_plugin_list_field`
        // を呼んでいたため、entry が見つからず Err を返し、on_cmd の書き込みと
        // 後続の clone が両方失敗していた。現在は `stored_url` をキーに使う。
        //
        // format_plugin_url → set_plugin_list_field の end-to-end contract を
        // 壊さないため、canonical URL をハードコードせず format_plugin_url の
        // 戻り値をそのまま流して両者の一貫性も担保する。
        use crate::config::UrlStyle;
        let input = "owner/repo";
        let stored_url = format_plugin_url(input, UrlStyle::Full);
        assert_eq!(stored_url, "https://github.com/owner/repo");

        let toml = format!("[[plugins]]\nurl = \"{}\"\n", stored_url);

        // 旧バグ: 入力文字列をそのままキーに渡すと entry が見つからない
        let mut doc = toml.parse::<DocumentMut>().unwrap();
        let err = set_plugin_list_field(&mut doc, input, "on_cmd", vec!["Telescope".to_string()]);
        assert!(
            err.is_err(),
            "入力 URL (owner/repo) と entry URL (https://...) が違えば見つからない"
        );

        // 現行: format_plugin_url の戻り値 (stored_url) をそのまま使えば成功
        let mut doc = toml.parse::<DocumentMut>().unwrap();
        set_plugin_list_field(
            &mut doc,
            &stored_url,
            "on_cmd",
            vec!["Telescope".to_string()],
        )
        .unwrap();
        assert!(doc.to_string().contains("on_cmd = \"Telescope\""));
    }

    // -----------------------------------------------------------------
    // --on-* CLI パーサ (Vec<String> 用) のテスト
    // -----------------------------------------------------------------

    #[test]
    fn test_parse_cli_string_list_single_value() {
        let items = parse_cli_string_list("BufReadPre").unwrap();
        assert_eq!(items, vec!["BufReadPre".to_string()]);
    }

    #[test]
    fn test_parse_cli_string_list_comma_separated() {
        let items = parse_cli_string_list("BufReadPre, BufNewFile ,InsertEnter").unwrap();
        assert_eq!(
            items,
            vec![
                "BufReadPre".to_string(),
                "BufNewFile".to_string(),
                "InsertEnter".to_string(),
            ]
        );
    }

    #[test]
    fn test_parse_cli_string_list_json_array() {
        let items = parse_cli_string_list(r#"["BufReadPre", "BufNewFile"]"#).unwrap();
        assert_eq!(
            items,
            vec!["BufReadPre".to_string(), "BufNewFile".to_string()]
        );
    }

    #[test]
    fn test_parse_cli_string_list_json_array_with_user_event() {
        let items = parse_cli_string_list(r#"["BufReadPre", "User LazyVimStarted"]"#).unwrap();
        assert_eq!(items[1], "User LazyVimStarted");
    }

    #[test]
    fn test_parse_cli_string_list_malformed_json_errors() {
        // "[" で始まっていると JSON として扱うので、壊れた JSON はエラー
        let err = parse_cli_string_list(r#"[BufReadPre, BufNewFile]"#).unwrap_err();
        assert!(err.to_string().contains("JSON"));
    }

    #[test]
    fn test_parse_cli_string_list_trims_and_ignores_empty() {
        let items = parse_cli_string_list("  a  ,  ,b,").unwrap();
        assert_eq!(items, vec!["a".to_string(), "b".to_string()]);
    }

    // -----------------------------------------------------------------
    // --on-map CLI パーサ / writer のテスト
    // -----------------------------------------------------------------

    #[test]
    fn test_parse_on_map_cli_simple_single_string() {
        let specs = parse_on_map_cli("<leader>f").unwrap();
        assert_eq!(specs.len(), 1);
        assert_eq!(specs[0].lhs, "<leader>f");
        assert!(specs[0].mode.is_empty());
        assert_eq!(specs[0].desc, None);
    }

    #[test]
    fn test_parse_on_map_cli_comma_separated() {
        let specs = parse_on_map_cli("<leader>f, <leader>g ,<leader>h").unwrap();
        assert_eq!(specs.len(), 3);
        assert_eq!(specs[0].lhs, "<leader>f");
        assert_eq!(specs[1].lhs, "<leader>g");
        assert_eq!(specs[2].lhs, "<leader>h");
    }

    #[test]
    fn test_parse_on_map_cli_json_array_of_strings() {
        let specs = parse_on_map_cli(r#"["<leader>f", "<leader>g"]"#).unwrap();
        assert_eq!(specs.len(), 2);
        assert_eq!(specs[0].lhs, "<leader>f");
        assert_eq!(specs[1].lhs, "<leader>g");
    }

    #[test]
    fn test_parse_on_map_cli_json_single_object() {
        let specs =
            parse_on_map_cli(r#"{ "lhs": "<space>d", "mode": ["n", "x"], "desc": "Delete" }"#)
                .unwrap();
        assert_eq!(specs.len(), 1);
        assert_eq!(specs[0].lhs, "<space>d");
        assert_eq!(specs[0].mode, vec!["n".to_string(), "x".to_string()]);
        assert_eq!(specs[0].desc.as_deref(), Some("Delete"));
    }

    #[test]
    fn test_parse_on_map_cli_json_object_mode_as_string() {
        let specs = parse_on_map_cli(r#"{ "lhs": "<leader>v", "mode": "v" }"#).unwrap();
        assert_eq!(specs.len(), 1);
        assert_eq!(specs[0].lhs, "<leader>v");
        assert_eq!(specs[0].mode, vec!["v".to_string()]);
    }

    #[test]
    fn test_parse_on_map_cli_json_array_mixed() {
        let specs = parse_on_map_cli(
            r#"[
                "<leader>a",
                { "lhs": "<leader>b", "mode": "x" },
                { "lhs": "<leader>c", "mode": ["n", "v"], "desc": "C" }
            ]"#,
        )
        .unwrap();
        assert_eq!(specs.len(), 3);
        assert_eq!(specs[0].lhs, "<leader>a");
        assert!(specs[0].mode.is_empty());
        assert_eq!(specs[1].lhs, "<leader>b");
        assert_eq!(specs[1].mode, vec!["x".to_string()]);
        assert_eq!(specs[2].lhs, "<leader>c");
        assert_eq!(specs[2].mode, vec!["n".to_string(), "v".to_string()]);
        assert_eq!(specs[2].desc.as_deref(), Some("C"));
    }

    #[test]
    fn test_parse_on_map_cli_json_object_missing_lhs_errors() {
        let err = parse_on_map_cli(r#"{ "mode": ["n"] }"#).unwrap_err();
        assert!(err.to_string().to_lowercase().contains("lhs"));
    }

    #[test]
    fn test_set_plugin_map_field_single_simple_writes_string() {
        let toml = "[[plugins]]\nurl = \"owner/a\"\n";
        let mut doc = toml.parse::<DocumentMut>().unwrap();
        let specs = vec![MapSpec {
            lhs: "<leader>f".to_string(),
            mode: Vec::new(),
            desc: None,
        }];
        set_plugin_map_field(&mut doc, "owner/a", specs).unwrap();
        let result = doc.to_string();
        assert!(
            result.contains("on_map = \"<leader>f\""),
            "simple single spec should write as plain string: {}",
            result
        );
    }

    #[test]
    fn test_set_plugin_map_field_with_mode_writes_inline_table() {
        let toml = "[[plugins]]\nurl = \"owner/a\"\n";
        let mut doc = toml.parse::<DocumentMut>().unwrap();
        let specs = vec![MapSpec {
            lhs: "<space>d".to_string(),
            mode: vec!["n".to_string(), "x".to_string()],
            desc: Some("Delete".to_string()),
        }];
        set_plugin_map_field(&mut doc, "owner/a", specs).unwrap();
        let result = doc.to_string();
        assert!(
            result.contains("lhs = \"<space>d\""),
            "should include lhs field: {}",
            result
        );
        assert!(
            result.contains("mode = [\"n\", \"x\"]") || result.contains("mode = [ \"n\", \"x\" ]"),
            "should include mode array: {}",
            result
        );
        assert!(
            result.contains("desc = \"Delete\""),
            "should include desc: {}",
            result
        );
    }

    #[test]
    fn test_set_plugin_map_field_mixed_writes_array_of_mixed() {
        let toml = "[[plugins]]\nurl = \"owner/a\"\n";
        let mut doc = toml.parse::<DocumentMut>().unwrap();
        let specs = vec![
            MapSpec {
                lhs: "<leader>a".to_string(),
                mode: Vec::new(),
                desc: None,
            },
            MapSpec {
                lhs: "<leader>b".to_string(),
                mode: vec!["n".to_string(), "x".to_string()],
                desc: Some("B".to_string()),
            },
        ];
        set_plugin_map_field(&mut doc, "owner/a", specs).unwrap();
        let result = doc.to_string();
        // 配列 literal 内に単純文字列とインラインテーブルが混在
        assert!(
            result.contains("\"<leader>a\""),
            "simple item as string: {}",
            result
        );
        assert!(
            result.contains("lhs = \"<leader>b\""),
            "full item as inline table: {}",
            result
        );
        assert!(result.contains("desc = \"B\""));
    }

    #[test]
    fn test_set_plugin_list_field_multiple_writes_as_array() {
        let toml = "[[plugins]]\nurl = \"owner/a\"\n";
        let mut doc = toml.parse::<DocumentMut>().unwrap();
        set_plugin_list_field(
            &mut doc,
            "owner/a",
            "on_event",
            vec!["BufRead".to_string(), "BufNewFile".to_string()],
        )
        .unwrap();
        let result = doc.to_string();
        assert!(
            result.contains("on_event = ["),
            "複数要素は配列として書かれるべき: {}",
            result
        );
        assert!(result.contains("\"BufRead\""));
        assert!(result.contains("\"BufNewFile\""));
    }

    #[test]
    fn test_update_plugin_config() {
        let toml = r#"[[plugins]]
url = "test/plugin"
lazy = false"#;
        let mut doc = toml.parse::<DocumentMut>().unwrap();
        update_plugin_config(
            &mut doc,
            "test/plugin",
            Some(true),
            Some(true),
            None,
            None,
            Some("v1.0".to_string()),
        )
        .unwrap();
        let result = doc.to_string();
        assert!(result.contains("lazy = true"));
        assert!(result.contains("merge = true"));
        assert!(result.contains("rev = \"v1.0\""));
    }

    // -----------------------------------------------------------------
    // build コマンドのテスト
    // -----------------------------------------------------------------

    #[test]
    fn test_parse_build_command_shell() {
        let dirs = vec![PathBuf::from("/path/to/plugin")];
        let (cmd, args) = parse_build_command("cargo build --release", &dirs);
        if cfg!(windows) {
            assert_eq!(cmd, "cmd");
            assert_eq!(args, vec!["/C", "cargo build --release"]);
        } else {
            assert_eq!(cmd, "sh");
            assert_eq!(args, vec!["-c", "cargo build --release"]);
        }
    }

    #[test]
    fn test_parse_build_command_vim_prefix() {
        let dirs = vec![PathBuf::from("/path/to/plugin")];
        let (cmd, args) = parse_build_command(":call mkdp#util#install()", &dirs);
        assert_eq!(cmd, "nvim");
        assert!(args.iter().any(|a| a == "--headless"));
        assert!(args.iter().any(|a| a.contains("mkdp#util#install()")));
    }

    #[test]
    fn test_parse_build_command_vim_simple() {
        let dirs = vec![PathBuf::from("/path/to/plugin")];
        let (cmd, args) = parse_build_command(":TSUpdate", &dirs);
        assert_eq!(cmd, "nvim");
        assert!(args.iter().any(|a| a == "--headless"));
        assert!(args.iter().any(|a| a.contains("TSUpdate")));
    }

    #[test]
    fn test_parse_build_command_vim_adds_rtp() {
        let dirs = vec![PathBuf::from("/path/to/my-plugin")];
        let (cmd, args) = parse_build_command(":MyBuild", &dirs);
        assert_eq!(cmd, "nvim");
        assert!(args.iter().any(|a| a == "--cmd"));
        assert!(
            args.iter()
                .any(|a| a.contains("set rtp+=/path/to/my-plugin")),
            "should add plugin dir to rtp: {:?}",
            args
        );
    }

    #[test]
    fn test_parse_build_command_vim_includes_deps_rtp() {
        let dirs = vec![
            PathBuf::from("/path/to/plugin"),
            PathBuf::from("/path/to/dep1"),
            PathBuf::from("/path/to/dep2"),
        ];
        let (cmd, args) = parse_build_command(":Build", &dirs);
        assert_eq!(cmd, "nvim");
        let rtp_arg = args
            .iter()
            .find(|a| a.contains("set rtp+="))
            .expect("should have rtp cmd");
        assert!(rtp_arg.contains("/path/to/plugin"), "self: {}", rtp_arg);
        assert!(rtp_arg.contains("/path/to/dep1"), "dep1: {}", rtp_arg);
        assert!(rtp_arg.contains("/path/to/dep2"), "dep2: {}", rtp_arg);
    }

    // ─── build_lua / collect_build_rtp (#97) ────────────────────────────

    #[test]
    fn collect_build_rtp_includes_self_first_then_transitive_deps() {
        // A depends [B], B depends [C]. rtp should be [A, B, C]. (#97)
        let plugin_a = Plugin {
            url: "owner/a".to_string(),
            depends: Some(vec!["b".to_string()]),
            ..Default::default()
        };
        let plugin_b = Plugin {
            name: Some("b".to_string()),
            url: "owner/b".to_string(),
            depends: Some(vec!["c".to_string()]),
            ..Default::default()
        };
        let plugin_c = Plugin {
            name: Some("c".to_string()),
            url: "owner/c".to_string(),
            ..Default::default()
        };
        let config = Config {
            vars: None,
            options: Options::default(),
            plugins: vec![plugin_a.clone(), plugin_b, plugin_c],
        };
        let cache_root = PathBuf::from("/cache");
        let rtp = collect_build_rtp(
            &plugin_a,
            &PathBuf::from("/cache/plugins/repos/owner/a"),
            &config,
            &cache_root,
        );
        // self が先頭
        assert_eq!(rtp[0], PathBuf::from("/cache/plugins/repos/owner/a"));
        // transitive deps が含まれる (順序は DFS なので厳密には保証しないが、
        // 全部存在することを確認)
        let rtp_strings: Vec<String> = rtp.iter().map(|p| p.display().to_string()).collect();
        assert!(
            rtp_strings.iter().any(|s| s.contains("owner/b")),
            "B in rtp: {rtp_strings:?}"
        );
        assert!(
            rtp_strings.iter().any(|s| s.contains("owner/c")),
            "C in rtp: {rtp_strings:?}"
        );
    }

    #[test]
    fn unwrap_anonymous_lua_function_strips_lazy_nvim_style_wrapper() {
        // lazy.nvim style — user が `build = function() ... end` から copy
        let r = unwrap_anonymous_lua_function(
            "function() require('blink.cmp').build():wait(60000) end",
        );
        assert_eq!(
            r.as_deref(),
            Some("require('blink.cmp').build():wait(60000)"),
            "function() body end → body extraction"
        );
    }

    #[test]
    fn unwrap_anonymous_lua_function_handles_space_after_function_keyword() {
        let r = unwrap_anonymous_lua_function("function ()  vim.cmd('TSUpdate')  end");
        assert_eq!(r.as_deref(), Some("vim.cmd('TSUpdate')"));
    }

    #[test]
    fn unwrap_anonymous_lua_function_returns_none_for_plain_statement() {
        // 既に statement なら触らない (None で「unwrap 不要」と通知)。
        let r = unwrap_anonymous_lua_function("require('x').build():wait(60000)");
        assert!(r.is_none());
    }

    #[test]
    fn unwrap_anonymous_lua_function_returns_none_for_named_function_decl() {
        // `function name() ... end` は valid な statement (function 宣言) なので
        // 触る理由が無い。
        let r = unwrap_anonymous_lua_function("function build_step() require('x') end");
        assert!(r.is_none());
    }

    #[test]
    fn unwrap_anonymous_lua_function_does_not_split_identifier_ending_in_end() {
        // `local end_var = 1 end` のように identifier が "end" で終わるケースで
        // strip_suffix が誤動作しないこと。strip_suffix は exact suffix match
        // なので、"end_var" の "end" 部分は拾わない (前後の文字を見るので)。
        let r = unwrap_anonymous_lua_function("function() local end_var = 1 end");
        assert_eq!(r.as_deref(), Some("local end_var = 1"));
    }

    #[test]
    fn unwrap_anonymous_lua_function_preserves_inner_function_blocks() {
        // 入れ子の `function() ... end` は inner 側を保持する (lazy `.*?` + 末尾
        // anchor で「最後の一番外の end」だけを落とす)。
        let r =
            unwrap_anonymous_lua_function("function() local f = function() return 1 end; f() end");
        assert_eq!(r.as_deref(), Some("local f = function() return 1 end; f()"));
    }

    #[test]
    fn unwrap_anonymous_lua_function_handles_multiline_body() {
        // 改行を含む typical な複数行 build_lua。
        let code = "function()\n  local cmp = require('blink.cmp')\n  cmp.build():wait(60000)\n  vim.print('done')\nend";
        let r = unwrap_anonymous_lua_function(code);
        let body = r.expect("multiline function() should unwrap");
        assert!(body.contains("local cmp = require('blink.cmp')"));
        assert!(body.contains("cmp.build():wait(60000)"));
        assert!(body.contains("vim.print('done')"));
        assert!(!body.contains("function()"));
        // 末尾に余分な end が残ってないこと
        assert!(!body.ends_with("end"));
    }

    #[test]
    fn unwrap_anonymous_lua_function_handles_complex_multiline_with_nested_function() {
        // 複雑な複数行: ローカル関数定義入り。inner の function() ... end は保持。
        let code = "function()\n  local helper = function(x)\n    return x * 2\n  end\n  print(helper(21))\nend";
        let r = unwrap_anonymous_lua_function(code);
        let body = r.expect("complex multiline should unwrap outer only");
        assert!(body.contains("local helper = function(x)"));
        assert!(body.contains("return x * 2"));
        assert!(body.contains("print(helper(21))"));
        // inner の helper 定義 end が残ってる
        assert!(
            body.matches("end").count() >= 1,
            "inner helper end should remain, body: {body}"
        );
    }

    #[test]
    fn unwrap_anonymous_lua_function_tolerates_trailing_line_comment() {
        // `end` の後に行コメントが付くケース (regex で末尾 `(?:--[^\n]*)?` 許容)。
        let r = unwrap_anonymous_lua_function("function() vim.print('hi') end -- post-build hook");
        assert_eq!(r.as_deref(), Some("vim.print('hi')"));
    }

    #[test]
    fn unwrap_anonymous_lua_function_tolerates_whitespace_inside_parens() {
        // `function ( )` のような余分な空白も許容。
        let r = unwrap_anonymous_lua_function("function(  )  print('x')  end");
        assert_eq!(r.as_deref(), Some("print('x')"));
    }

    #[test]
    fn unwrap_anonymous_lua_function_returns_none_when_function_takes_args() {
        // `function(a, b)` のように引数があるものは触らない (匿名 zero-arity 専用)。
        let r = unwrap_anonymous_lua_function("function(a, b) return a + b end");
        assert!(r.is_none(), "function with args should not be unwrapped");
    }

    #[tokio::test]
    async fn run_build_lua_returns_none_when_field_unset() {
        let plugin = Plugin {
            url: "x/y".to_string(),
            ..Default::default()
        };
        let result = run_build_lua(&plugin, &PathBuf::from("/tmp"), &[]).await;
        assert!(
            result.is_none(),
            "no build_lua field → no-op, got: {result:?}"
        );
    }

    #[tokio::test]
    async fn run_build_lua_reports_error_when_nvim_missing() {
        // nvim が PATH に無くても rvpm はクラッシュさせず、明示的なエラー文字列を返す。
        // この test は nvim がインストールされてる環境ではスキップされる
        // (nvim コマンドが本当に成功してしまうため)。CI / 開発機の typical 構成では
        // nvim あり、ローカル run_build_lua の no-build-lua path を検証する別 test
        // (run_build_lua_returns_none_when_field_unset) で十分。
        if which::which("nvim").is_ok() {
            return;
        }
        let tmp = tempdir().unwrap();
        let plugin = Plugin {
            url: "x/y".to_string(),
            build_lua: Some("vim.print('hi')".to_string()),
            ..Default::default()
        };
        let result = run_build_lua(&plugin, tmp.path(), &[tmp.path().to_path_buf()]).await;
        let err = result.expect("missing nvim should yield an error");
        assert!(
            err.contains("failed to spawn") || err.contains("nvim"),
            "error should mention nvim: {err}"
        );
    }

    #[test]
    fn test_find_unused_repos() {
        let root = tempdir().unwrap();
        // `cache_root` を root にして、標準の {cache_root}/plugins/repos/ 下に配置する
        let repos_dir = root.path().join("plugins/repos");
        std::fs::create_dir_all(&repos_dir).unwrap();
        let used_dir = repos_dir.join("github.com/used/plugin");
        let unused_dir = repos_dir.join("github.com/unused/plugin");
        std::fs::create_dir_all(used_dir.join(".git")).unwrap();
        std::fs::create_dir_all(unused_dir.join(".git")).unwrap();
        let config = Config {
            vars: None,
            options: Options::default(),
            plugins: vec![Plugin {
                url: "used/plugin".to_string(),
                ..Default::default()
            }],
        };
        let unused = find_unused_repos(&config, root.path(), &repos_dir).unwrap();
        assert_eq!(unused.len(), 1);
        assert!(unused[0].to_string_lossy().contains("unused"));
    }

    #[test]
    fn test_find_unused_repos_respects_custom_dst_inside_repos_dir() {
        // `plugin.dst` で canonical_path と違う場所に clone してる場合でも、
        // その場所は "used" として保護されること。
        let root = tempdir().unwrap();
        let repos_dir = root.path().join("plugins/repos");
        std::fs::create_dir_all(&repos_dir).unwrap();
        let custom = repos_dir.join("custom-slot/my-plugin");
        std::fs::create_dir_all(custom.join(".git")).unwrap();
        let config = Config {
            vars: None,
            options: Options::default(),
            plugins: vec![Plugin {
                url: "owner/my-plugin".to_string(),
                dst: Some(custom.to_string_lossy().to_string()),
                ..Default::default()
            }],
        };
        let unused = find_unused_repos(&config, root.path(), &repos_dir).unwrap();
        assert!(
            unused.is_empty(),
            "custom dst must be protected, got {:?}",
            unused
        );
    }

    #[test]
    fn test_find_unused_repos_preserves_nested_git_inside_used_plugin() {
        // 設定済みプラグインのクローン配下にある submodule 等の `.git` は
        // 削除候補にしないこと (repo_root が used プラグインの子孫なら保護)。
        let root = tempdir().unwrap();
        let repos_dir = root.path().join("plugins/repos");
        std::fs::create_dir_all(&repos_dir).unwrap();
        let plugin_dir = repos_dir.join("github.com/used/plugin");
        std::fs::create_dir_all(plugin_dir.join(".git")).unwrap();
        // submodule
        let submodule = plugin_dir.join("deps/sub");
        std::fs::create_dir_all(submodule.join(".git")).unwrap();
        let config = Config {
            vars: None,
            options: Options::default(),
            plugins: vec![Plugin {
                url: "used/plugin".to_string(),
                ..Default::default()
            }],
        };
        let unused = find_unused_repos(&config, root.path(), &repos_dir).unwrap();
        assert!(
            unused.is_empty(),
            "submodule .git must not be considered unused, got {:?}",
            unused
        );
    }

    #[test]
    fn test_prune_unused_repos_removes_listed_dirs() {
        let root = tempdir().unwrap();
        let a = root.path().join("a/.git");
        let b = root.path().join("b/.git");
        std::fs::create_dir_all(&a).unwrap();
        std::fs::create_dir_all(&b).unwrap();
        let targets = vec![
            a.parent().unwrap().to_path_buf(),
            b.parent().unwrap().to_path_buf(),
        ];
        prune_unused_repos(&targets);
        assert!(!targets[0].exists());
        assert!(!targets[1].exists());
    }

    #[test]
    fn test_prune_unused_repos_empty_slice_noop() {
        // 空でもクラッシュしないこと
        prune_unused_repos(&[]);
    }

    #[test]
    fn test_plural_helper() {
        assert_eq!(plural("dir", "dirs", 0), "dirs");
        assert_eq!(plural("dir", "dirs", 1), "dir");
        assert_eq!(plural("dir", "dirs", 2), "dirs");
    }

    #[test]
    fn test_parse_config_auto_clean_defaults_to_false() {
        let toml = r#"
[options]

[[plugins]]
url = "owner/repo"
"#;
        let config = crate::config::parse_config(toml).unwrap();
        assert!(!config.options.auto_clean);
    }

    #[test]
    fn test_parse_config_accepts_auto_clean_true() {
        let toml = r#"
[options]
auto_clean = true

[[plugins]]
url = "owner/repo"
"#;
        let config = crate::config::parse_config(toml).unwrap();
        assert!(config.options.auto_clean);
    }

    // ================================================================
    // update_log wiring
    // ================================================================

    #[test]
    fn test_resolve_update_log_path_is_cache_root_sibling() {
        let cache = std::path::PathBuf::from("/tmp/cache_root");
        let p = resolve_update_log_path(&cache);
        assert_eq!(
            p,
            std::path::PathBuf::from("/tmp/cache_root/update_log.json")
        );
        // loader.lua の親 (= /tmp/cache_root/plugins/) には居ない
        let loader = resolve_loader_path(&cache);
        assert_ne!(p.parent(), loader.parent());
    }

    #[test]
    fn test_change_record_from_preserves_fields() {
        let plugin = Plugin {
            url: "folke/snacks.nvim".to_string(),
            name: Some("snacks".to_string()),
            ..Default::default()
        };
        let change = crate::git::GitChange {
            from: Some("aaaaaaa".to_string()),
            to: "bbbbbbb".to_string(),
            subjects: vec!["fix: x".to_string()],
            breaking_subjects: vec![],
            doc_files_changed: vec!["README.md".to_string()],
        };
        let rec = change_record_from(&plugin, change);
        assert_eq!(rec.name, "snacks");
        assert_eq!(rec.url, "folke/snacks.nvim");
        assert_eq!(rec.from.as_deref(), Some("aaaaaaa"));
        assert_eq!(rec.to, "bbbbbbb");
        assert_eq!(rec.subjects, vec!["fix: x".to_string()]);
        assert_eq!(rec.doc_files_changed, vec!["README.md".to_string()]);
    }

    #[test]
    fn test_change_record_from_uses_default_name_when_no_name() {
        let plugin = Plugin {
            url: "https://github.com/owner/Repo.git".to_string(),
            ..Default::default()
        };
        let change = crate::git::GitChange {
            from: None,
            to: "deadbee".to_string(),
            subjects: vec![],
            breaking_subjects: vec![],
            doc_files_changed: vec![],
        };
        let rec = change_record_from(&plugin, change);
        // default_name は `.git` を剥がして最終セグメントを返す
        assert_eq!(rec.name, "Repo");
        assert!(rec.from.is_none());
    }

    #[test]
    fn test_record_changes_or_warn_writes_file() {
        // 空 changes は file 自体を作らない方針 (有用履歴を押し出さないため)。
        // 非空 changes 1 件渡したらきちんと書かれることを確認。
        let dir = tempdir().unwrap();
        let cache_root = dir.path().to_path_buf();
        record_changes_or_warn(
            &cache_root,
            "sync",
            vec![crate::update_log::ChangeRecord {
                name: "x".into(),
                url: "owner/x".into(),
                from: Some("a".into()),
                to: "b".into(),
                subjects: vec!["fix: x".into()],
                breaking_subjects: vec![],
                doc_files_changed: vec![],
            }],
        );
        let path = resolve_update_log_path(&cache_root);
        assert!(path.exists(), "expected log file for non-empty changes");
        let log = crate::update_log::load_log(&path);
        assert_eq!(log.runs.len(), 1);
        assert_eq!(log.runs[0].command, "sync");
    }

    #[test]
    fn test_record_changes_or_warn_skips_empty() {
        let dir = tempdir().unwrap();
        let cache_root = dir.path().to_path_buf();
        record_changes_or_warn(&cache_root, "sync", vec![]);
        let path = resolve_update_log_path(&cache_root);
        assert!(
            !path.exists(),
            "empty changes should not create the log file"
        );
    }
}
