use anyhow::{Context, Result, anyhow};
use std::path::{Path, PathBuf};
mod chat;
mod prompt;
pub use chat::{ChatOutcome, run_ai_add, run_ai_tune};
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Backend {
Claude,
Gemini,
Codex,
}
impl TryFrom<crate::config::AiBackend> for Backend {
type Error = ();
fn try_from(value: crate::config::AiBackend) -> std::result::Result<Self, ()> {
match value {
crate::config::AiBackend::Claude => Ok(Backend::Claude),
crate::config::AiBackend::Gemini => Ok(Backend::Gemini),
crate::config::AiBackend::Codex => Ok(Backend::Codex),
crate::config::AiBackend::Off => Err(()),
}
}
}
impl Backend {
pub fn cli_name(self) -> &'static str {
match self {
Backend::Claude => "claude",
Backend::Gemini => "gemini",
Backend::Codex => "codex",
}
}
pub fn is_available(self) -> bool {
resolve_cli(self.cli_name()).is_some()
}
pub fn label(self) -> &'static str {
match self {
Backend::Claude => "Claude",
Backend::Gemini => "Gemini",
Backend::Codex => "Codex",
}
}
}
#[derive(Debug, Clone)]
pub struct ResolvedCli {
pub program: PathBuf,
pub prefix_args: Vec<String>,
}
pub fn resolve_cli(name: &str) -> Option<ResolvedCli> {
if let Ok(p) = which::which(name) {
return Some(wrap_if_powershell(p));
}
#[cfg(windows)]
{
for ext in ["ps1", "cmd", "bat", "exe"] {
if let Ok(p) = which::which(format!("{name}.{ext}")) {
return Some(wrap_if_powershell(p));
}
}
}
None
}
fn wrap_if_powershell(path: PathBuf) -> ResolvedCli {
let is_ps1 = path
.extension()
.and_then(|e| e.to_str())
.map(|e| e.eq_ignore_ascii_case("ps1"))
.unwrap_or(false);
if is_ps1 {
let ps_exe = if which::which("pwsh").is_ok() {
"pwsh.exe"
} else {
"powershell.exe"
};
ResolvedCli {
program: PathBuf::from(ps_exe),
prefix_args: vec![
"-NoProfile".to_string(),
"-ExecutionPolicy".to_string(),
"Bypass".to_string(),
"-File".to_string(),
path.to_string_lossy().into_owned(),
],
}
} else {
ResolvedCli {
program: path,
prefix_args: Vec::new(),
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ProposalSection {
pub fresh: Option<String>,
pub merged: Option<String>,
}
impl ProposalSection {
pub fn is_empty(&self) -> bool {
self.fresh.is_none() && self.merged.is_none()
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Proposal {
pub plugin_entry: ProposalSection,
pub init_lua: ProposalSection,
pub before_lua: ProposalSection,
pub after_lua: ProposalSection,
pub explanation: String,
}
pub fn ensure_cli_installed(backend: Backend) -> Result<()> {
if backend.is_available() {
return Ok(());
}
let cli = backend.cli_name();
let hint = match backend {
Backend::Claude => "https://docs.claude.com/claude-code",
Backend::Gemini => "https://ai.google.dev/gemini-api/docs/cli",
Backend::Codex => "https://github.com/openai/codex",
};
Err(anyhow!(
"AI backend `{cli}` is not on PATH. Install it first ({hint}) or pass a different `--ai` flag."
))
}
pub async fn invoke_oneshot(backend: Backend, prompt_text: &str) -> Result<String> {
use tokio::io::AsyncWriteExt;
use tokio::process::Command;
use tokio::time::{Duration, timeout};
ensure_cli_installed(backend)?;
let resolved = resolve_cli(backend.cli_name())
.ok_or_else(|| anyhow!("AI CLI `{}` is not on PATH", backend.cli_name()))?;
eprintln!(
" (prompt size: {} bytes / {} lines)",
prompt_text.len(),
prompt_text.lines().count()
);
let mut cmd = Command::new(&resolved.program);
cmd.args(&resolved.prefix_args);
match backend {
Backend::Claude | Backend::Gemini => {
cmd.arg("-p").arg("-");
}
Backend::Codex => {
cmd.arg("exec").arg("-");
}
}
cmd.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.kill_on_drop(true);
let mut child = cmd.spawn().with_context(|| {
format!(
"failed to spawn AI CLI `{}` (is it installed and on PATH?)",
backend.cli_name()
)
})?;
if let Some(mut stdin) = child.stdin.take() {
stdin
.write_all(prompt_text.as_bytes())
.await
.context("failed to write prompt to AI CLI stdin")?;
}
let timeout_secs = std::env::var("RVPM_AI_TIMEOUT_SECS")
.ok()
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(300);
let output = timeout(Duration::from_secs(timeout_secs), child.wait_with_output())
.await
.map_err(|_| {
anyhow!(
"AI CLI `{}` timed out after {timeout_secs}s. \
The chat follow-up prompt grows with conversation history; \
set RVPM_AI_TIMEOUT_SECS=600 or longer if your network is slow.",
backend.cli_name()
)
})?
.with_context(|| format!("AI CLI `{}` failed to produce output", backend.cli_name()))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(anyhow!(
"AI CLI `{}` exited with status {}: {}",
backend.cli_name(),
output.status,
stderr.trim()
));
}
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
}
pub fn parse_proposal(response: &str) -> Result<Proposal> {
let plugin_entry = extract_section(response, "plugin_entry");
if plugin_entry.is_empty() {
return Err(anyhow!(
"AI response missing required <rvpm:plugin_entry> (or <rvpm:plugin_entry_merged>) tag"
));
}
let init_lua = extract_section(response, "init_lua");
let before_lua = extract_section(response, "before_lua");
let after_lua = extract_section(response, "after_lua");
let explanation =
extract_tag(response, "explanation").unwrap_or_else(|| "(no explanation given)".into());
Ok(Proposal {
plugin_entry,
init_lua,
before_lua,
after_lua,
explanation: explanation.trim().to_string(),
})
}
fn extract_section(response: &str, name: &str) -> ProposalSection {
let fresh = extract_optional_section(response, name);
let merged_tag = format!("{name}_merged");
let merged = extract_optional_section(response, &merged_tag);
ProposalSection { fresh, merged }
}
fn extract_optional_section(text: &str, name: &str) -> Option<String> {
let body = extract_tag(text, name)?;
let trimmed = body.trim();
if trimmed.eq_ignore_ascii_case("(none)") || trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}
fn extract_tag(text: &str, name: &str) -> Option<String> {
let open = format!("<rvpm:{name}>");
let close = format!("</rvpm:{name}>");
let start_off = text.rfind(&open)? + open.len();
let close_off = text[start_off..].find(&close)? + start_off;
Some(text[start_off..close_off].to_string())
}
pub fn validate_proposal_toml(toml_src: &str) -> Result<()> {
let value: toml::Value =
toml::from_str(toml_src).context("AI-proposed TOML failed to parse")?;
let plugins = value
.get("plugins")
.and_then(|v| v.as_array())
.ok_or_else(|| anyhow!("AI proposal missing `[[plugins]]` array"))?;
if plugins.is_empty() {
return Err(anyhow!("AI proposal contains 0 plugin entries"));
}
if plugins.len() > 1 {
return Err(anyhow!(
"AI proposed {} plugin entries; expected exactly 1 for `rvpm add`",
plugins.len()
));
}
Ok(())
}
pub fn should_emit_merged(backend: Backend) -> bool {
should_emit_merged_with(backend, |k| std::env::var(k).ok())
}
pub fn should_emit_merged_with(backend: Backend, get_env: impl Fn(&str) -> Option<String>) -> bool {
let flag = |name: &str| {
get_env(name)
.map(|v| !v.is_empty() && v != "0")
.unwrap_or(false)
};
if flag("RVPM_AI_FORCE_MERGED") {
return true;
}
if flag("RVPM_AI_NO_MERGED") {
return false;
}
match backend {
Backend::Gemini => false,
Backend::Claude | Backend::Codex => true,
}
}
#[derive(Debug, Clone, Copy)]
enum FirstMessageStrategy {
Positional,
InteractiveFlag,
#[allow(dead_code)]
Manual,
}
fn first_message_strategy(backend: Backend) -> FirstMessageStrategy {
match backend {
Backend::Claude | Backend::Codex => FirstMessageStrategy::Positional,
Backend::Gemini => FirstMessageStrategy::InteractiveFlag,
}
}
pub async fn run_handoff(backend: Backend, prompt_text: &str) -> Result<()> {
use console::style;
ensure_cli_installed(backend)?;
let resolved = resolve_cli(backend.cli_name())
.ok_or_else(|| anyhow!("AI CLI `{}` is not on PATH", backend.cli_name()))?;
let mut tmp_path = std::env::temp_dir();
let stamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
tmp_path.push(format!("rvpm-ai-prompt-{stamp}.md"));
std::fs::write(&tmp_path, prompt_text)
.with_context(|| format!("failed to write prompt to {}", tmp_path.display()))?;
let path_str = tmp_path.to_string_lossy().into_owned();
let first_message = format!(
"Read the file at {path_str} for our shared context. \
Summarize what it contains in 1-2 sentences. \
Do NOT apply, edit, or write any files yet. \
Wait for my next instruction before running Edit or Write tools."
);
eprintln!();
eprintln!(
"\u{1f4dd} Hand-off prompt saved to:\n {}",
style(&path_str).cyan()
);
eprintln!();
let strategy = first_message_strategy(backend);
if matches!(strategy, FirstMessageStrategy::Manual) {
eprintln!(
"Starting `{}` interactively. Once the prompt opens, paste this as \
your first message:\n",
backend.cli_name()
);
eprintln!(" {}", style(&first_message).bold());
eprintln!();
} else {
eprintln!(
"Starting `{}` interactively. The first message asking it to read \
the file above will be sent automatically.\n",
backend.cli_name()
);
}
let label = backend.cli_name().to_string();
let program = resolved.program.clone();
let prefix_args = resolved.prefix_args.clone();
tokio::task::spawn_blocking(move || -> Result<()> {
let mut cmd = std::process::Command::new(&program);
cmd.args(&prefix_args);
match strategy {
FirstMessageStrategy::Positional => {
cmd.arg(&first_message);
}
FirstMessageStrategy::InteractiveFlag => {
cmd.arg("-i").arg(&first_message);
}
FirstMessageStrategy::Manual => {
}
}
let status = cmd
.stdin(std::process::Stdio::inherit())
.stdout(std::process::Stdio::inherit())
.stderr(std::process::Stdio::inherit())
.status()
.with_context(|| format!("failed to spawn AI CLI `{label}`"))?;
let _ = status; Ok(())
})
.await
.context("failed to join blocking handoff task")??;
Ok(())
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub enum HookChoice {
#[default]
Keep,
Write(String),
Remove,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct HookWriteDecisions {
pub init_lua: HookChoice,
pub before_lua: HookChoice,
pub after_lua: HookChoice,
}
pub async fn write_hook_files(
plugin_dir: &Path,
decisions: &HookWriteDecisions,
chezmoi_enabled: bool,
) -> Result<HookWriteResult> {
let all_keep = [
&decisions.init_lua,
&decisions.before_lua,
&decisions.after_lua,
]
.iter()
.all(|c| matches!(c, HookChoice::Keep));
if all_keep {
return Ok(HookWriteResult::default());
}
let any_write = [
&decisions.init_lua,
&decisions.before_lua,
&decisions.after_lua,
]
.iter()
.any(|c| matches!(c, HookChoice::Write(_)));
if any_write {
std::fs::create_dir_all(plugin_dir).with_context(|| {
format!(
"failed to create plugin config dir {}",
plugin_dir.display()
)
})?;
}
let mut result = HookWriteResult::default();
for (name, choice) in [
("init.lua", &decisions.init_lua),
("before.lua", &decisions.before_lua),
("after.lua", &decisions.after_lua),
] {
let target = plugin_dir.join(name);
match choice {
HookChoice::Keep => {}
HookChoice::Write(body) => {
crate::chezmoi::write_routed(
chezmoi_enabled,
&target,
format!("{}\n", body.trim_end()),
)
.await
.with_context(|| format!("failed to write {}", target.display()))?;
result.written.push(target);
}
HookChoice::Remove => {
crate::chezmoi::delete_routed(chezmoi_enabled, &target)
.await
.with_context(|| format!("failed to remove {}", target.display()))?;
result.removed.push(target);
}
}
}
Ok(result)
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct HookWriteResult {
pub written: Vec<PathBuf>,
pub removed: Vec<PathBuf>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn backend_try_from_aibackend_maps_runtime_variants() {
use crate::config::AiBackend as Cfg;
assert_eq!(Backend::try_from(Cfg::Claude), Ok(Backend::Claude));
assert_eq!(Backend::try_from(Cfg::Gemini), Ok(Backend::Gemini));
assert_eq!(Backend::try_from(Cfg::Codex), Ok(Backend::Codex));
assert_eq!(Backend::try_from(Cfg::Off), Err(()));
}
#[test]
fn should_emit_merged_default_per_backend() {
let no_env = |_: &str| None;
assert!(should_emit_merged_with(Backend::Claude, no_env));
assert!(should_emit_merged_with(Backend::Codex, no_env));
assert!(!should_emit_merged_with(Backend::Gemini, no_env));
}
#[test]
fn should_emit_merged_force_overrides_backend_default() {
let force_on = |k: &str| {
if k == "RVPM_AI_FORCE_MERGED" {
Some("1".to_string())
} else {
None
}
};
assert!(should_emit_merged_with(Backend::Gemini, force_on));
assert!(should_emit_merged_with(Backend::Claude, force_on));
}
#[test]
fn should_emit_merged_no_merged_overrides_backend_default() {
let no_merged = |k: &str| {
if k == "RVPM_AI_NO_MERGED" {
Some("1".to_string())
} else {
None
}
};
assert!(!should_emit_merged_with(Backend::Claude, no_merged));
assert!(!should_emit_merged_with(Backend::Codex, no_merged));
assert!(!should_emit_merged_with(Backend::Gemini, no_merged));
}
#[test]
fn should_emit_merged_force_wins_over_no_merged() {
let both = |k: &str| match k {
"RVPM_AI_FORCE_MERGED" | "RVPM_AI_NO_MERGED" => Some("1".to_string()),
_ => None,
};
assert!(should_emit_merged_with(Backend::Gemini, both));
}
#[test]
fn should_emit_merged_treats_zero_and_empty_as_unset() {
let zero = |k: &str| {
if k == "RVPM_AI_NO_MERGED" {
Some("0".to_string())
} else {
None
}
};
assert!(should_emit_merged_with(Backend::Claude, zero));
let empty = |k: &str| {
if k == "RVPM_AI_NO_MERGED" {
Some(String::new())
} else {
None
}
};
assert!(should_emit_merged_with(Backend::Claude, empty));
}
#[test]
fn parse_proposal_extracts_required_tags() {
let response = r#"
some preamble that should be ignored
<rvpm:plugin_entry>
[[plugins]]
url = "owner/repo"
on_cmd = ["Foo"]
</rvpm:plugin_entry>
<rvpm:init_lua>
vim.g.foo = 1
</rvpm:init_lua>
<rvpm:before_lua>(none)</rvpm:before_lua>
<rvpm:after_lua>
require('foo').setup({})
</rvpm:after_lua>
<rvpm:explanation>
README shows :Foo as the entry command.
</rvpm:explanation>
"#;
let p = parse_proposal(response).unwrap();
let entry_fresh = p.plugin_entry.fresh.as_deref().unwrap();
assert!(entry_fresh.contains("[[plugins]]"));
assert!(entry_fresh.contains(r#"url = "owner/repo""#));
assert!(p.plugin_entry.merged.is_none(), "no _merged tag was sent");
assert_eq!(p.init_lua.fresh.as_deref(), Some("vim.g.foo = 1"));
assert!(p.before_lua.fresh.is_none(), "(none) must collapse to None");
assert_eq!(
p.after_lua.fresh.as_deref(),
Some("require('foo').setup({})")
);
assert!(p.explanation.contains("README shows"));
}
#[test]
fn parse_proposal_extracts_merged_variants_when_present() {
let response = r#"
<rvpm:plugin_entry>
[[plugins]]
url = "owner/repo"
on_cmd = ["Foo"]
</rvpm:plugin_entry>
<rvpm:plugin_entry_merged>
[[plugins]]
url = "owner/repo"
on_cmd = ["Foo", "FooBar"]
rev = "v1.0"
</rvpm:plugin_entry_merged>
<rvpm:init_lua>(none)</rvpm:init_lua>
<rvpm:init_lua_merged>(none)</rvpm:init_lua_merged>
<rvpm:before_lua>vim.g.foo_new = 1</rvpm:before_lua>
<rvpm:before_lua_merged>
vim.g.foo_old = "user"
vim.g.foo_new = 1
</rvpm:before_lua_merged>
<rvpm:after_lua>require('foo').setup({})</rvpm:after_lua>
<rvpm:after_lua_merged>
require('foo').setup({})
vim.keymap.set("n", "<leader>f", ":Foo<CR>")
</rvpm:after_lua_merged>
<rvpm:explanation>tune proposal.</rvpm:explanation>
"#;
let p = parse_proposal(response).unwrap();
assert!(
p.plugin_entry
.fresh
.as_deref()
.unwrap()
.contains("[[plugins]]")
);
let merged_entry = p.plugin_entry.merged.as_deref().unwrap();
assert!(merged_entry.contains(r#"rev = "v1.0""#));
assert!(merged_entry.contains("FooBar"));
assert!(p.init_lua.fresh.is_none());
assert!(p.init_lua.merged.is_none());
assert_eq!(p.before_lua.fresh.as_deref(), Some("vim.g.foo_new = 1"));
assert!(
p.before_lua
.merged
.as_deref()
.unwrap()
.contains("vim.g.foo_old")
);
assert!(p.after_lua.fresh.as_deref().unwrap().contains("setup({})"));
assert!(
p.after_lua
.merged
.as_deref()
.unwrap()
.contains("vim.keymap.set")
);
}
#[test]
fn parse_proposal_accepts_only_merged_when_fresh_missing() {
let response = r#"
<rvpm:plugin_entry_merged>
[[plugins]]
url = "owner/repo"
</rvpm:plugin_entry_merged>
<rvpm:explanation>only merged available.</rvpm:explanation>
"#;
let p = parse_proposal(response).unwrap();
assert!(p.plugin_entry.fresh.is_none());
assert!(p.plugin_entry.merged.is_some());
}
#[test]
fn parse_proposal_missing_plugin_entry_errors() {
let response = "<rvpm:explanation>nothing else</rvpm:explanation>";
assert!(parse_proposal(response).is_err());
}
#[test]
fn parse_proposal_ignores_tag_name_in_preamble() {
let response = r#"
I will populate the <rvpm:plugin_entry> tag below with the proposal.
<rvpm:plugin_entry>
[[plugins]]
url = "real/entry"
</rvpm:plugin_entry>
<rvpm:init_lua>(none)</rvpm:init_lua>
<rvpm:before_lua>(none)</rvpm:before_lua>
<rvpm:after_lua>(none)</rvpm:after_lua>
<rvpm:explanation>ok</rvpm:explanation>
"#;
let p = parse_proposal(response).unwrap();
let entry = p.plugin_entry.fresh.as_deref().unwrap();
assert!(entry.contains("real/entry"));
assert!(!entry.contains("populate"));
}
#[test]
fn parse_proposal_extracts_when_wrapped_in_markdown_fences() {
let response = r#"
```
<rvpm:plugin_entry>
[[plugins]]
url = "x/y"
</rvpm:plugin_entry>
<rvpm:init_lua>(none)</rvpm:init_lua>
<rvpm:before_lua>(none)</rvpm:before_lua>
<rvpm:after_lua>(none)</rvpm:after_lua>
<rvpm:explanation>ok</rvpm:explanation>
```
"#;
let p = parse_proposal(response).unwrap();
assert!(
p.plugin_entry
.fresh
.as_deref()
.unwrap()
.contains(r#"url = "x/y""#)
);
assert!(p.init_lua.fresh.is_none());
}
#[test]
fn validate_proposal_toml_accepts_single_plugin_entry() {
let toml_src = r#"
[[plugins]]
url = "owner/repo"
on_cmd = ["Foo"]
"#;
validate_proposal_toml(toml_src).unwrap();
}
#[test]
fn validate_proposal_toml_rejects_multiple_plugin_entries() {
let toml_src = r#"
[[plugins]]
url = "a/b"
[[plugins]]
url = "c/d"
"#;
assert!(validate_proposal_toml(toml_src).is_err());
}
#[test]
fn validate_proposal_toml_rejects_invalid_syntax() {
let toml_src = "[[plugins]\nurl = ";
assert!(validate_proposal_toml(toml_src).is_err());
}
#[test]
fn validate_proposal_toml_rejects_no_plugins_array() {
let toml_src = r#"name = "ignored""#;
assert!(validate_proposal_toml(toml_src).is_err());
}
#[test]
fn wrap_if_powershell_wraps_ps1_path() {
let p = std::path::PathBuf::from("C:/foo/gemini.ps1");
let r = wrap_if_powershell(p);
let prog = r.program.to_string_lossy().to_ascii_lowercase();
assert!(
prog == "pwsh.exe" || prog == "powershell.exe",
"expected pwsh.exe or powershell.exe, got {prog}"
);
assert!(r.prefix_args.iter().any(|a| a == "-File"));
assert!(r.prefix_args.iter().any(|a| a.contains("gemini.ps1")));
assert!(r.prefix_args.iter().any(|a| a == "Bypass"));
assert!(r.prefix_args.iter().any(|a| a == "-NoProfile"));
}
#[test]
fn wrap_if_powershell_passes_exe_through() {
let p = std::path::PathBuf::from("C:/foo/claude.exe");
let r = wrap_if_powershell(p.clone());
assert_eq!(r.program, p);
assert!(r.prefix_args.is_empty());
}
#[test]
fn wrap_if_powershell_passes_unix_path_through() {
let p = std::path::PathBuf::from("/usr/local/bin/codex");
let r = wrap_if_powershell(p.clone());
assert_eq!(r.program, p);
assert!(r.prefix_args.is_empty());
}
#[tokio::test]
async fn write_hook_files_writes_only_files_with_write_decision() {
let tmp = tempfile::tempdir().unwrap();
let plugin_dir = tmp
.path()
.join("plugins")
.join("github.com")
.join("o")
.join("r");
let decisions = HookWriteDecisions {
init_lua: HookChoice::Write("vim.g.x = 1".to_string()),
before_lua: HookChoice::Keep,
after_lua: HookChoice::Write("require('o').setup({})".to_string()),
};
let result = write_hook_files(&plugin_dir, &decisions, false)
.await
.unwrap();
assert_eq!(result.written.len(), 2);
assert!(result.removed.is_empty());
assert!(plugin_dir.join("init.lua").exists());
assert!(!plugin_dir.join("before.lua").exists());
assert!(plugin_dir.join("after.lua").exists());
}
#[tokio::test]
async fn write_hook_files_overwrites_existing_when_user_chose_write() {
let tmp = tempfile::tempdir().unwrap();
let plugin_dir = tmp.path().join("p");
std::fs::create_dir_all(&plugin_dir).unwrap();
std::fs::write(plugin_dir.join("after.lua"), "OLD CONTENT\n").unwrap();
let decisions = HookWriteDecisions {
after_lua: HookChoice::Write("NEW CONTENT".to_string()),
..Default::default()
};
write_hook_files(&plugin_dir, &decisions, false)
.await
.unwrap();
let body = std::fs::read_to_string(plugin_dir.join("after.lua")).unwrap();
assert_eq!(body, "NEW CONTENT\n");
}
#[tokio::test]
async fn write_hook_files_keep_does_not_touch_existing_file() {
let tmp = tempfile::tempdir().unwrap();
let plugin_dir = tmp.path().join("p");
std::fs::create_dir_all(&plugin_dir).unwrap();
std::fs::write(plugin_dir.join("after.lua"), "USER\n").unwrap();
let decisions = HookWriteDecisions::default(); let result = write_hook_files(&plugin_dir, &decisions, false)
.await
.unwrap();
assert!(result.written.is_empty());
assert!(result.removed.is_empty());
let body = std::fs::read_to_string(plugin_dir.join("after.lua")).unwrap();
assert_eq!(body, "USER\n");
}
#[tokio::test]
async fn write_hook_files_remove_deletes_existing_file() {
let tmp = tempfile::tempdir().unwrap();
let plugin_dir = tmp.path().join("p");
std::fs::create_dir_all(&plugin_dir).unwrap();
std::fs::write(plugin_dir.join("after.lua"), "STALE\n").unwrap();
let decisions = HookWriteDecisions {
after_lua: HookChoice::Remove,
..Default::default()
};
let result = write_hook_files(&plugin_dir, &decisions, false)
.await
.unwrap();
assert!(result.written.is_empty());
assert_eq!(result.removed.len(), 1);
assert!(!plugin_dir.join("after.lua").exists());
}
#[tokio::test]
async fn write_hook_files_remove_idempotent_when_file_absent() {
let tmp = tempfile::tempdir().unwrap();
let plugin_dir = tmp.path().join("p");
std::fs::create_dir_all(&plugin_dir).unwrap();
let decisions = HookWriteDecisions {
after_lua: HookChoice::Remove,
..Default::default()
};
let result = write_hook_files(&plugin_dir, &decisions, false)
.await
.unwrap();
assert!(result.written.is_empty());
assert_eq!(result.removed.len(), 1);
}
#[tokio::test]
async fn write_hook_files_remove_alone_does_not_create_plugin_dir() {
let tmp = tempfile::tempdir().unwrap();
let plugin_dir = tmp.path().join("p"); let decisions = HookWriteDecisions {
after_lua: HookChoice::Remove,
..Default::default()
};
write_hook_files(&plugin_dir, &decisions, false)
.await
.unwrap();
assert!(!plugin_dir.exists());
}
#[test]
fn extract_optional_section_collapses_none_marker() {
let resp = "<rvpm:init_lua> (none) </rvpm:init_lua>";
assert_eq!(extract_optional_section(resp, "init_lua"), None);
}
#[test]
fn extract_optional_section_keeps_real_content() {
let resp = "<rvpm:init_lua>vim.g.x = 1</rvpm:init_lua>";
assert_eq!(
extract_optional_section(resp, "init_lua").as_deref(),
Some("vim.g.x = 1")
);
}
}