use anyhow::Result;
use std::path::Path;
const SCHEMA: &str = include_str!("schema_prompt.md");
pub fn build_initial_prompt(
plugin_url: &str,
plugin_root: &Path,
user_config_toml: &str,
user_plugins_tree: &str,
ai_language: &str,
) -> Result<String> {
let plugin_readme = read_plugin_readme(plugin_root);
let plugin_doc = read_plugin_doc(plugin_root);
let mut out = String::new();
out.push_str(SCHEMA);
out.push_str("\n\n---\n\n");
if !ai_language.eq_ignore_ascii_case("en") {
out.push_str(&format!(
"## Language\n\n\
Respond in **{ai_language}** for natural-language portions: the \
`<rvpm:explanation>` body and any chat replies after this turn. \
Keep XML tag names, TOML, and Lua code in their original form (no translation).\n\n",
));
}
out.push_str("---\n\n");
out.push_str("# Plugin to add\n\n");
out.push_str(&format!("URL: `{plugin_url}`\n\n"));
if let Some(readme) = plugin_readme {
out.push_str("## README\n\n");
out.push_str(&trim_to_cap(&readme, 30_000));
out.push_str("\n\n");
}
if let Some(doc) = plugin_doc {
out.push_str("## Vim help (doc/)\n\n");
out.push_str(&trim_to_cap(&doc, 15_000));
out.push_str("\n\n");
}
out.push_str("---\n\n");
out.push_str("# User context\n\n");
out.push_str("## Current config.toml\n\n");
out.push_str("```toml\n");
out.push_str(&trim_to_cap(user_config_toml, 30_000));
out.push_str("\n```\n\n");
out.push_str("## Existing plugins/ directory tree\n\n");
out.push_str("```\n");
out.push_str(user_plugins_tree.trim_end());
out.push_str("\n```\n\n");
out.push_str("---\n\n");
out.push_str(
"Now propose the optimal `[[plugins]]` block for the plugin above, plus any \
hook files. Output exactly the XML tag structure shown earlier — no markdown \
code fences around the tags, no preamble text outside the tags.\n",
);
Ok(out)
}
pub fn build_followup_prompt(
initial_prompt: &str,
prior_response: &str,
user_followup: &str,
) -> String {
format!(
"{initial_prompt}\n\n---\n\n\
# Previous proposal (your last reply)\n\n\
{prior_response}\n\n---\n\n\
# User feedback\n\n\
{user_followup}\n\n\
Update the proposal to address this feedback. Return the same XML tag structure.\n"
)
}
fn read_plugin_readme(plugin_root: &Path) -> Option<String> {
let candidates = [
"README.md",
"README",
"README.rst",
"Readme.md",
"readme.md",
];
for name in candidates {
let path = plugin_root.join(name);
if let Ok(content) = std::fs::read_to_string(&path) {
return Some(content);
}
}
None
}
fn read_plugin_doc(plugin_root: &Path) -> Option<String> {
let doc_dir = plugin_root.join("doc");
if !doc_dir.is_dir() {
return None;
}
let mut combined = String::new();
let entries = std::fs::read_dir(&doc_dir).ok()?;
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("txt") {
continue;
}
if let Ok(content) = std::fs::read_to_string(&path) {
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
combined.push_str(&format!("\n\n=== doc/{name} ===\n\n"));
combined.push_str(&content);
}
}
if combined.is_empty() {
None
} else {
Some(combined)
}
}
fn trim_to_cap(text: &str, cap: usize) -> String {
if text.len() <= cap {
return text.to_string();
}
let mut end = cap;
while end > 0 && !text.is_char_boundary(end) {
end -= 1;
}
format!(
"{}\n\n...(truncated, {} bytes total, showing first {} bytes)",
&text[..end],
text.len(),
end
)
}
pub fn collect_plugins_tree(plugins_root: &Path) -> String {
let mut out = String::new();
let _ = walk_tree(plugins_root, plugins_root, 0, 3, &mut out);
if out.is_empty() {
"(no plugins/ directory yet)".to_string()
} else {
out
}
}
fn walk_tree(
root: &Path,
cur: &Path,
depth: usize,
max_depth: usize,
out: &mut String,
) -> std::io::Result<()> {
if depth > max_depth {
return Ok(());
}
let mut entries: Vec<_> = std::fs::read_dir(cur)?.flatten().collect();
entries.sort_by_key(|e| e.file_name());
for entry in entries {
let path = entry.path();
let rel = path
.strip_prefix(root)
.unwrap_or(&path)
.to_string_lossy()
.replace('\\', "/");
if path.is_dir() {
out.push_str(&format!("{rel}/\n"));
let _ = walk_tree(root, &path, depth + 1, max_depth, out);
} else {
out.push_str(&format!("{rel}\n"));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn build_initial_prompt_includes_schema_and_inputs() {
let tmp = tempfile::tempdir().unwrap();
let plugin_root = tmp.path().join("plugin");
std::fs::create_dir_all(&plugin_root).unwrap();
std::fs::write(
plugin_root.join("README.md"),
"# my-plugin\n\nUse :Foo to start.",
)
.unwrap();
let prompt = build_initial_prompt(
"owner/repo",
&plugin_root,
"[[plugins]]\nurl = \"existing/dep\"\n",
"github.com/existing/dep/\n",
"en",
)
.unwrap();
assert!(prompt.contains("rvpm — TOML schema brief"));
assert!(prompt.contains("owner/repo"));
assert!(prompt.contains("Use :Foo to start"));
assert!(prompt.contains("existing/dep"));
assert!(!prompt.contains("Respond in"));
}
#[test]
fn build_initial_prompt_inserts_language_hint_when_non_english() {
let tmp = tempfile::tempdir().unwrap();
let plugin_root = tmp.path().join("plugin");
std::fs::create_dir_all(&plugin_root).unwrap();
std::fs::write(plugin_root.join("README.md"), "x").unwrap();
let prompt = build_initial_prompt("owner/repo", &plugin_root, "", "(empty)", "ja").unwrap();
assert!(prompt.contains("Respond in **ja**"));
}
#[test]
fn build_followup_prompt_includes_prior_response_and_feedback() {
let p = build_followup_prompt(
"INITIAL",
"<rvpm:plugin_entry>...</rvpm:plugin_entry>",
"Add depends = telescope.nvim",
);
assert!(p.contains("INITIAL"));
assert!(p.contains("Previous proposal"));
assert!(p.contains("<rvpm:plugin_entry>...</rvpm:plugin_entry>"));
assert!(p.contains("Add depends = telescope.nvim"));
}
#[test]
fn trim_to_cap_truncates_oversized_text() {
let text = "a".repeat(100);
let trimmed = trim_to_cap(&text, 30);
assert!(trimmed.starts_with(&"a".repeat(30)));
assert!(trimmed.contains("(truncated"));
assert!(trimmed.contains("100 bytes total"));
}
#[test]
fn trim_to_cap_passes_through_when_under_cap() {
let text = "short";
assert_eq!(trim_to_cap(text, 100), "short");
}
#[test]
fn read_plugin_readme_handles_uppercase_and_md_extension() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("README.md"), "hello").unwrap();
assert_eq!(read_plugin_readme(tmp.path()).as_deref(), Some("hello"));
}
#[test]
fn read_plugin_doc_concatenates_txt_files() {
let tmp = tempfile::tempdir().unwrap();
let doc = tmp.path().join("doc");
std::fs::create_dir_all(&doc).unwrap();
std::fs::write(doc.join("a.txt"), "AAA").unwrap();
std::fs::write(doc.join("b.txt"), "BBB").unwrap();
std::fs::write(doc.join("ignored.md"), "MMM").unwrap();
let combined = read_plugin_doc(tmp.path()).unwrap();
assert!(combined.contains("AAA"));
assert!(combined.contains("BBB"));
assert!(!combined.contains("MMM"));
}
#[test]
fn collect_plugins_tree_lists_files_and_dirs() {
let tmp = tempfile::tempdir().unwrap();
let nested = tmp.path().join("github.com").join("owner").join("repo");
std::fs::create_dir_all(&nested).unwrap();
std::fs::write(nested.join("init.lua"), "").unwrap();
let tree = collect_plugins_tree(tmp.path());
assert!(tree.contains("github.com/"));
assert!(tree.contains("github.com/owner/repo/init.lua"));
}
}