use anyhow::Result;
use serde::Deserialize;
use tera::{Context, Tera};
#[derive(Debug, Deserialize, PartialEq, Eq)]
pub struct Config {
pub vars: Option<serde_json::Value>,
pub options: Options,
#[serde(default)]
pub plugins: Vec<Plugin>,
}
#[derive(Debug, Deserialize, PartialEq, Eq, Default, Clone)]
pub struct Options {
pub config_root: Option<String>,
pub concurrency: Option<usize>,
pub loader_path: Option<String>,
pub base_dir: Option<String>,
}
#[derive(Debug, PartialEq, Eq, Clone, Default)]
pub struct MapSpec {
pub lhs: String,
pub mode: Vec<String>,
pub desc: Option<String>,
}
impl MapSpec {
pub fn modes_or_default(&self) -> Vec<String> {
if self.mode.is_empty() {
vec!["n".to_string()]
} else {
self.mode.clone()
}
}
}
#[derive(Debug, Deserialize, PartialEq, Eq, Default, Clone)]
pub struct Plugin {
pub name: Option<String>,
pub url: String,
pub dst: Option<String>,
#[serde(default, rename = "lazy")]
pub lazy_raw: Option<bool>,
#[serde(skip)]
pub lazy: bool,
#[serde(default = "default_merge")]
pub merge: bool,
#[serde(default, deserialize_with = "deserialize_string_or_vec")]
pub on_cmd: Option<Vec<String>>,
#[serde(default, deserialize_with = "deserialize_string_or_vec")]
pub on_ft: Option<Vec<String>>,
#[serde(default, deserialize_with = "deserialize_map_specs")]
pub on_map: Option<Vec<MapSpec>>,
#[serde(default, deserialize_with = "deserialize_string_or_vec")]
pub on_event: Option<Vec<String>>,
#[serde(default, deserialize_with = "deserialize_string_or_vec")]
pub on_path: Option<Vec<String>>,
#[serde(default, deserialize_with = "deserialize_string_or_vec")]
pub on_source: Option<Vec<String>>,
pub depends: Option<Vec<String>>,
pub build: Option<String>,
pub rev: Option<String>,
pub cond: Option<String>,
}
fn deserialize_string_or_vec<'de, D>(
deserializer: D,
) -> std::result::Result<Option<Vec<String>>, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum StringOrVec {
String(String),
Vec(Vec<String>),
}
let opt = Option::<StringOrVec>::deserialize(deserializer)?;
Ok(opt.map(|v| match v {
StringOrVec::String(s) => vec![s],
StringOrVec::Vec(v) => v,
}))
}
fn deserialize_string_or_vec_vec<'de, D>(
deserializer: D,
) -> std::result::Result<Vec<String>, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum SV {
S(String),
V(Vec<String>),
}
let sv: SV = Deserialize::deserialize(deserializer)?;
Ok(match sv {
SV::S(s) => vec![s],
SV::V(v) => v,
})
}
fn deserialize_map_specs<'de, D>(
deserializer: D,
) -> std::result::Result<Option<Vec<MapSpec>>, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
struct MapSpecFull {
lhs: String,
#[serde(default, deserialize_with = "deserialize_string_or_vec_vec")]
mode: Vec<String>,
desc: Option<String>,
}
#[derive(Deserialize)]
#[serde(untagged)]
enum MapSpecRaw {
Simple(String),
Full(MapSpecFull),
}
let opt = Option::<Vec<MapSpecRaw>>::deserialize(deserializer)?;
Ok(opt.map(|v| {
v.into_iter()
.map(|raw| match raw {
MapSpecRaw::Simple(lhs) => MapSpec {
lhs,
mode: Vec::new(),
desc: None,
},
MapSpecRaw::Full(full) => MapSpec {
lhs: full.lhs,
mode: full.mode,
desc: full.desc,
},
})
.collect()
}))
}
fn default_merge() -> bool {
true
}
impl Plugin {
pub fn canonical_path(&self) -> String {
let url = self.url.trim_end_matches(".git");
if url.contains("://") {
let parts: Vec<&str> = url.split("://").collect();
let path = parts[1];
path.to_string()
} else if url.contains("@") {
let parts: Vec<&str> = url.split("@").collect();
let path = parts[1].replace(":", "/");
path.to_string()
} else {
if url.contains("/") {
format!("github.com/{}", url)
} else {
url.to_string()
}
}
}
pub fn default_name(&self) -> String {
let url = self.url.trim_end_matches(".git");
let normalized = url.replace(':', "/");
normalized.rsplit('/').next().unwrap_or(url).to_string()
}
pub fn display_name(&self) -> String {
self.name.clone().unwrap_or_else(|| self.default_name())
}
}
pub fn parse_config(toml_str: &str) -> Result<Config> {
#[derive(Deserialize)]
struct Raw {
vars: Option<serde_json::Value>,
}
let raw: Raw = toml::from_str(toml_str)?;
let mut context = Context::new();
if let Some(v) = raw.vars.as_ref() {
context.insert("vars", v);
}
context.insert("is_windows", &cfg!(windows));
let mut env_map = std::collections::HashMap::new();
for (key, value) in std::env::vars() {
env_map.insert(key, value);
}
context.insert("env", &env_map);
let rendered = Tera::one_off(toml_str, &context, false)?;
let mut config: Config = toml::from_str(&rendered)?;
for plugin in config.plugins.iter_mut() {
let has_trigger = plugin.on_cmd.is_some()
|| plugin.on_ft.is_some()
|| plugin.on_map.is_some()
|| plugin.on_event.is_some()
|| plugin.on_path.is_some()
|| plugin.on_source.is_some();
plugin.lazy = match plugin.lazy_raw {
Some(v) => v, None if has_trigger => true, None => false, };
}
Ok(config)
}
pub fn sort_plugins(plugins: &mut Vec<Plugin>) -> Result<()> {
let mut sorted = Vec::new();
let mut visited = std::collections::HashSet::new();
let mut visiting = std::collections::HashSet::new();
let mut plugin_map: std::collections::HashMap<String, &Plugin> =
std::collections::HashMap::new();
for p in plugins.iter() {
plugin_map.insert(p.url.clone(), p);
plugin_map.insert(p.display_name(), p);
}
fn visit(
key: &str,
plugin_map: &std::collections::HashMap<String, &Plugin>,
visited: &mut std::collections::HashSet<String>,
visiting: &mut std::collections::HashSet<String>,
sorted: &mut Vec<Plugin>,
) -> Result<()> {
if visited.contains(key) {
return Ok(());
}
if visiting.contains(key) {
eprintln!("Warning: Cyclic dependency detected: {}", key);
return Ok(());
}
visiting.insert(key.to_string());
if let Some(plugin) = plugin_map.get(key) {
if visited.contains(&plugin.url) {
visiting.remove(key);
return Ok(());
}
if let Some(deps) = &plugin.depends {
for dep in deps {
visit(dep, plugin_map, visited, visiting, sorted)?;
}
}
visited.insert(plugin.url.clone());
visited.insert(plugin.display_name());
visiting.remove(key);
sorted.push((*plugin).clone());
} else {
eprintln!("Warning: Dependency not found in config: {}", key);
visited.insert(key.to_string());
visiting.remove(key);
}
Ok(())
}
for plugin in plugins.iter() {
visit(
&plugin.url,
&plugin_map,
&mut visited,
&mut visiting,
&mut sorted,
)?;
}
*plugins = sorted;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_config_accepts_on_cmd_as_string() {
let toml = r#"
[options]
[[plugins]]
url = "owner/repo"
on_cmd = "Telescope"
"#;
let config = parse_config(toml).unwrap();
assert_eq!(
config.plugins[0].on_cmd,
Some(vec!["Telescope".to_string()])
);
}
#[test]
fn test_parse_config_accepts_on_cmd_as_array() {
let toml = r#"
[options]
[[plugins]]
url = "owner/repo"
on_cmd = ["Telescope", "Grep"]
"#;
let config = parse_config(toml).unwrap();
assert_eq!(
config.plugins[0].on_cmd,
Some(vec!["Telescope".to_string(), "Grep".to_string()])
);
}
#[test]
fn test_parse_config_accepts_base_dir_option() {
let toml = r#"
[options]
base_dir = "~/dotfiles/nvim/rvpm"
[[plugins]]
url = "owner/repo"
"#;
let config = parse_config(toml).unwrap();
assert_eq!(
config.options.base_dir.as_deref(),
Some("~/dotfiles/nvim/rvpm")
);
}
#[test]
fn test_parse_config_base_dir_defaults_to_none() {
let toml = r#"
[options]
[[plugins]]
url = "owner/repo"
"#;
let config = parse_config(toml).unwrap();
assert_eq!(config.options.base_dir, None);
}
#[test]
fn test_parse_config_accepts_on_map_simple_string() {
let toml = r#"
[options]
[[plugins]]
url = "owner/repo"
on_map = ["<leader>f"]
"#;
let config = parse_config(toml).unwrap();
let maps = config.plugins[0].on_map.as_ref().unwrap();
assert_eq!(maps.len(), 1);
assert_eq!(maps[0].lhs, "<leader>f");
assert!(
maps[0].mode.is_empty(),
"simple form leaves mode empty (defaults to 'n' at generate)"
);
assert_eq!(maps[0].desc, None);
}
#[test]
fn test_parse_config_accepts_on_map_table_form() {
let toml = r#"
[options]
[[plugins]]
url = "owner/repo"
on_map = [
{ lhs = "<leader>v", mode = ["n", "x"], desc = "visual thing" },
]
"#;
let config = parse_config(toml).unwrap();
let maps = config.plugins[0].on_map.as_ref().unwrap();
assert_eq!(maps.len(), 1);
assert_eq!(maps[0].lhs, "<leader>v");
assert_eq!(maps[0].mode, vec!["n".to_string(), "x".to_string()]);
assert_eq!(maps[0].desc.as_deref(), Some("visual thing"));
}
#[test]
fn test_parse_config_accepts_on_map_mixed_forms() {
let toml = r#"
[options]
[[plugins]]
url = "owner/repo"
on_map = [
"<leader>a",
{ lhs = "<leader>b", mode = "x" },
{ lhs = "<leader>c", mode = ["n", "v"], desc = "C" },
]
"#;
let config = parse_config(toml).unwrap();
let maps = config.plugins[0].on_map.as_ref().unwrap();
assert_eq!(maps.len(), 3);
assert_eq!(maps[0].lhs, "<leader>a");
assert!(maps[0].mode.is_empty());
assert_eq!(maps[1].lhs, "<leader>b");
assert_eq!(maps[1].mode, vec!["x".to_string()]);
assert_eq!(maps[2].lhs, "<leader>c");
assert_eq!(maps[2].mode, vec!["n".to_string(), "v".to_string()]);
assert_eq!(maps[2].desc.as_deref(), Some("C"));
}
#[test]
fn test_lazy_auto_true_when_on_cmd_set() {
let toml = r#"
[options]
[[plugins]]
url = "owner/repo"
on_cmd = "Foo"
"#;
let config = parse_config(toml).unwrap();
assert!(
config.plugins[0].lazy,
"on_cmd が設定されていれば lazy = true になるべき"
);
}
#[test]
fn test_lazy_auto_true_when_on_event_set() {
let toml = r#"
[options]
[[plugins]]
url = "owner/repo"
on_event = "BufReadPre"
"#;
let config = parse_config(toml).unwrap();
assert!(
config.plugins[0].lazy,
"on_event が設定されていれば lazy = true になるべき"
);
}
#[test]
fn test_lazy_auto_true_when_on_ft_set() {
let toml = r#"
[options]
[[plugins]]
url = "owner/repo"
on_ft = ["rust"]
"#;
let config = parse_config(toml).unwrap();
assert!(config.plugins[0].lazy);
}
#[test]
fn test_lazy_auto_true_when_on_map_set() {
let toml = r#"
[options]
[[plugins]]
url = "owner/repo"
on_map = ["<leader>f"]
"#;
let config = parse_config(toml).unwrap();
assert!(config.plugins[0].lazy);
}
#[test]
fn test_lazy_auto_true_when_on_source_set() {
let toml = r#"
[options]
[[plugins]]
url = "owner/repo"
on_source = "telescope.nvim"
"#;
let config = parse_config(toml).unwrap();
assert!(config.plugins[0].lazy);
}
#[test]
fn test_lazy_auto_true_when_on_path_set() {
let toml = r#"
[options]
[[plugins]]
url = "owner/repo"
on_path = "*.rs"
"#;
let config = parse_config(toml).unwrap();
assert!(config.plugins[0].lazy);
}
#[test]
fn test_lazy_explicit_false_overrides_auto() {
let toml = r#"
[options]
[[plugins]]
url = "owner/repo"
lazy = false
on_cmd = "Foo"
"#;
let config = parse_config(toml).unwrap();
assert!(
!config.plugins[0].lazy,
"lazy = false が明示されていればそちらを尊重"
);
}
#[test]
fn test_no_trigger_stays_eager() {
let toml = r#"
[options]
[[plugins]]
url = "owner/repo"
"#;
let config = parse_config(toml).unwrap();
assert!(!config.plugins[0].lazy, "トリガーなしは eager のまま");
}
#[test]
fn test_parse_config_accepts_on_event_as_string() {
let toml = r#"
[options]
[[plugins]]
url = "owner/repo"
on_event = "BufReadPre"
"#;
let config = parse_config(toml).unwrap();
assert_eq!(
config.plugins[0].on_event,
Some(vec!["BufReadPre".to_string()])
);
}
#[test]
fn test_sort_plugins_dependencies() {
let mut plugins = vec![
Plugin {
url: "A".to_string(),
depends: Some(vec!["B".to_string()]),
..Default::default()
},
Plugin {
url: "B".to_string(),
..Default::default()
},
];
sort_plugins(&mut plugins).unwrap();
assert_eq!(plugins[0].url, "B");
assert_eq!(plugins[1].url, "A");
}
#[test]
fn test_sort_plugins_depends_by_display_name() {
let mut plugins = vec![
Plugin {
url: "nvim-telescope/telescope.nvim".to_string(),
depends: Some(vec!["plenary.nvim".to_string()]),
..Default::default()
},
Plugin {
url: "nvim-lua/plenary.nvim".to_string(),
..Default::default()
},
];
sort_plugins(&mut plugins).unwrap();
assert_eq!(plugins[0].url, "nvim-lua/plenary.nvim");
assert_eq!(plugins[1].url, "nvim-telescope/telescope.nvim");
}
#[test]
fn test_sort_plugins_depends_by_url_still_works() {
let mut plugins = vec![
Plugin {
url: "nvim-telescope/telescope.nvim".to_string(),
depends: Some(vec!["nvim-lua/plenary.nvim".to_string()]),
..Default::default()
},
Plugin {
url: "nvim-lua/plenary.nvim".to_string(),
..Default::default()
},
];
sort_plugins(&mut plugins).unwrap();
assert_eq!(plugins[0].url, "nvim-lua/plenary.nvim");
assert_eq!(plugins[1].url, "nvim-telescope/telescope.nvim");
}
#[test]
fn test_sort_plugins_cycle_resilience() {
let mut plugins = vec![
Plugin {
url: "A".to_string(),
depends: Some(vec!["B".to_string()]),
..Default::default()
},
Plugin {
url: "B".to_string(),
depends: Some(vec!["A".to_string()]),
..Default::default()
},
Plugin {
url: "C".to_string(),
..Default::default()
},
];
let result = sort_plugins(&mut plugins);
assert!(result.is_ok());
assert!(plugins.iter().any(|p| p.url == "C"));
}
#[test]
fn test_sort_plugins_missing_dependency_resilience() {
let mut plugins = vec![Plugin {
url: "A".to_string(),
depends: Some(vec!["NOT_FOUND".to_string()]),
..Default::default()
}];
let result = sort_plugins(&mut plugins);
assert!(result.is_ok());
assert_eq!(plugins.len(), 1);
assert_eq!(plugins[0].url, "A");
}
#[test]
fn test_parse_config_with_tera() {
let toml_content = r#"
[vars]
base = "/tmp/rvpm"
[options]
[[plugins]]
name = "plenary"
url = "nvim-lua/plenary.nvim"
dst = "{{ vars.base }}/plenary"
"#;
let config = parse_config(toml_content).unwrap();
assert_eq!(config.plugins[0].dst, Some("/tmp/rvpm/plenary".to_string()));
}
#[test]
fn test_parse_config_with_env_and_os() {
unsafe {
std::env::set_var("RVPM_TEST_ENV", "hello");
}
let toml_content = r#"
[options]
[[plugins]]
name = "test"
url = "repo"
dst = "{{ env.RVPM_TEST_ENV }}_{{ is_windows }}"
"#;
let config = parse_config(toml_content).unwrap();
let expected_dst = format!("hello_{}", cfg!(windows));
assert_eq!(config.plugins[0].dst, Some(expected_dst));
}
#[test]
fn test_parse_complex_config() {
let toml_content = r#"
[options]
[[plugins]]
url = "nvim-telescope/telescope.nvim"
lazy = true
on_cmd = ["Telescope"]
on_path = ["*.rs"]
on_source = ["plenary.nvim"]
depends = ["plenary"]
merge = false
"#;
let config = parse_config(toml_content).unwrap();
let p = &config.plugins[0];
assert_eq!(p.url, "nvim-telescope/telescope.nvim");
assert!(p.lazy);
assert_eq!(p.on_cmd, Some(vec!["Telescope".to_string()]));
assert_eq!(p.on_path, Some(vec!["*.rs".to_string()]));
assert_eq!(p.on_source, Some(vec!["plenary.nvim".to_string()]));
assert_eq!(p.depends, Some(vec!["plenary".to_string()]));
assert!(!p.merge);
}
#[test]
fn test_plugin_canonical_path() {
let p1 = Plugin {
url: "https://github.com/owner/repo".to_string(),
..Default::default()
};
assert_eq!(p1.canonical_path(), "github.com/owner/repo");
let p2 = Plugin {
url: "owner/repo".to_string(),
..Default::default()
};
assert_eq!(p2.canonical_path(), "github.com/owner/repo");
let p3 = Plugin {
url: "git@github.com:owner/repo.git".to_string(),
..Default::default()
};
assert_eq!(p3.canonical_path(), "github.com/owner/repo");
}
#[test]
fn test_plugin_default_name() {
let p1 = Plugin {
url: "nvim-lua/plenary.nvim".to_string(),
..Default::default()
};
assert_eq!(p1.default_name(), "plenary.nvim");
let p2 = Plugin {
url: "https://github.com/yukimemi/chronicle.vim".to_string(),
..Default::default()
};
assert_eq!(p2.default_name(), "chronicle.vim");
let p3 = Plugin {
url: "https://github.com/owner/repo.git".to_string(),
..Default::default()
};
assert_eq!(p3.default_name(), "repo");
let p4 = Plugin {
url: "git@github.com:owner/telescope.nvim.git".to_string(),
..Default::default()
};
assert_eq!(p4.default_name(), "telescope.nvim");
let p5 = Plugin {
url: "https://github.com/owner/long-ugly-name".to_string(),
name: Some("short".to_string()),
..Default::default()
};
assert_eq!(p5.display_name(), "short");
assert_eq!(p1.display_name(), "plenary.nvim");
}
}