use std::path::{Path, PathBuf};
use std::sync::Arc;
use crate::error::RippyError;
use crate::toml_config::TomlConfig;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CustomPackage {
pub name: String,
pub tagline: String,
pub shield: String,
pub path: PathBuf,
pub toml_source: String,
pub extends: Option<String>,
}
fn custom_packages_dir(home: &Path) -> PathBuf {
home.join(".rippy/packages")
}
#[must_use]
pub fn discover_custom_packages(home: &Path) -> Vec<Arc<CustomPackage>> {
let dir = custom_packages_dir(home);
let Ok(entries) = std::fs::read_dir(&dir) else {
return Vec::new();
};
let mut packages: Vec<Arc<CustomPackage>> = Vec::new();
for entry in entries.flatten() {
let path = entry.path();
if !is_toml_file(&path) {
continue;
}
let Some(name) = package_name_from_path(&path) else {
continue;
};
match load_custom_package_from_path(&path, &name) {
Ok(pkg) => packages.push(Arc::new(pkg)),
Err(e) => eprintln!("[rippy] skipping custom package {}: {e}", path.display()),
}
}
packages.sort_by(|a, b| a.name.cmp(&b.name));
packages
}
pub fn load_custom_package(
home: &Path,
name: &str,
) -> Result<Option<Arc<CustomPackage>>, RippyError> {
let path = custom_packages_dir(home).join(format!("{name}.toml"));
if !path.is_file() {
return Ok(None);
}
let pkg = load_custom_package_from_path(&path, name)?;
Ok(Some(Arc::new(pkg)))
}
fn load_custom_package_from_path(path: &Path, name: &str) -> Result<CustomPackage, RippyError> {
let toml_source = std::fs::read_to_string(path).map_err(|e| RippyError::Config {
path: path.to_path_buf(),
line: 0,
message: format!("could not read: {e}"),
})?;
let config: TomlConfig = toml::from_str(&toml_source).map_err(|e| RippyError::Config {
path: path.to_path_buf(),
line: 0,
message: format!("{e}"),
})?;
let meta = config.meta.unwrap_or(crate::toml_config::TomlMeta {
name: None,
tagline: None,
shield: None,
description: None,
extends: None,
});
if let Some(meta_name) = meta.name.as_deref()
&& meta_name != name
{
eprintln!(
"[rippy] custom package {}: [meta] name=\"{meta_name}\" does not match filename \"{name}\" (filename wins)",
path.display(),
);
}
let tagline = meta
.tagline
.unwrap_or_else(|| format!("Custom package: {name}"));
let shield = meta.shield.unwrap_or_else(|| "===".to_string());
let extends = meta.extends;
Ok(CustomPackage {
name: name.to_string(),
tagline,
shield,
path: path.to_path_buf(),
toml_source,
extends,
})
}
fn is_toml_file(path: &Path) -> bool {
path.extension().is_some_and(|ext| ext == "toml") && path.is_file()
}
fn package_name_from_path(path: &Path) -> Option<String> {
path.file_stem()
.and_then(|s| s.to_str())
.map(std::string::ToString::to_string)
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use tempfile::tempdir;
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();
}
#[test]
fn discover_empty_dir_returns_empty() {
let home = tempdir().unwrap();
let packages = discover_custom_packages(home.path());
assert!(packages.is_empty());
}
#[test]
fn discover_missing_dir_returns_empty() {
let home = tempdir().unwrap();
let packages = discover_custom_packages(home.path());
assert!(packages.is_empty());
}
#[test]
fn discover_finds_toml_files() {
let home = tempdir().unwrap();
let pkg_dir = home.path().join(".rippy/packages");
write_file(
&pkg_dir.join("corp.toml"),
r#"
[meta]
name = "corp"
tagline = "Corporate standard"
shield = "===."
[[rules]]
action = "deny"
pattern = "rm -rf"
"#,
);
let packages = discover_custom_packages(home.path());
assert_eq!(packages.len(), 1);
assert_eq!(packages[0].name, "corp");
assert_eq!(packages[0].tagline, "Corporate standard");
assert_eq!(packages[0].shield, "===.");
assert!(packages[0].extends.is_none());
}
#[test]
fn discover_ignores_non_toml_files() {
let home = tempdir().unwrap();
let pkg_dir = home.path().join(".rippy/packages");
write_file(&pkg_dir.join("notes.txt"), "some text");
write_file(&pkg_dir.join("README"), "read me");
let packages = discover_custom_packages(home.path());
assert!(packages.is_empty());
}
#[test]
fn discover_skips_malformed_returns_valid_only() {
let home = tempdir().unwrap();
let pkg_dir = home.path().join(".rippy/packages");
write_file(&pkg_dir.join("good.toml"), "[meta]\nname = \"good\"\n");
write_file(&pkg_dir.join("bad.toml"), "this is not valid toml [[");
let packages = discover_custom_packages(home.path());
assert_eq!(packages.len(), 1);
assert_eq!(packages[0].name, "good");
}
#[test]
fn discover_returns_sorted() {
let home = tempdir().unwrap();
let pkg_dir = home.path().join(".rippy/packages");
write_file(&pkg_dir.join("zeta.toml"), "[meta]\nname = \"zeta\"\n");
write_file(&pkg_dir.join("alpha.toml"), "[meta]\nname = \"alpha\"\n");
write_file(&pkg_dir.join("mango.toml"), "[meta]\nname = \"mango\"\n");
let packages = discover_custom_packages(home.path());
let names: Vec<&str> = packages.iter().map(|p| p.name.as_str()).collect();
assert_eq!(names, vec!["alpha", "mango", "zeta"]);
}
#[test]
fn load_by_name_happy_path() {
let home = tempdir().unwrap();
let pkg_dir = home.path().join(".rippy/packages");
write_file(
&pkg_dir.join("team.toml"),
r#"
[meta]
name = "team"
tagline = "Team package"
extends = "develop"
"#,
);
let pkg = load_custom_package(home.path(), "team").unwrap().unwrap();
assert_eq!(pkg.name, "team");
assert_eq!(pkg.tagline, "Team package");
assert_eq!(pkg.extends.as_deref(), Some("develop"));
}
#[test]
fn load_by_name_not_found_returns_none() {
let home = tempdir().unwrap();
let result = load_custom_package(home.path(), "missing").unwrap();
assert!(result.is_none());
}
#[test]
fn load_by_name_malformed_errors_with_path() {
let home = tempdir().unwrap();
let pkg_dir = home.path().join(".rippy/packages");
write_file(&pkg_dir.join("broken.toml"), "not valid [[");
let err = load_custom_package(home.path(), "broken").unwrap_err();
let msg = format!("{err:?}");
assert!(
msg.contains("broken.toml"),
"error should mention path: {msg}"
);
}
#[test]
fn load_defaults_tagline_when_missing() {
let home = tempdir().unwrap();
let pkg_dir = home.path().join(".rippy/packages");
write_file(
&pkg_dir.join("plain.toml"),
"[[rules]]\naction = \"ask\"\npattern = \"foo\"\n",
);
let pkg = load_custom_package(home.path(), "plain").unwrap().unwrap();
assert!(pkg.tagline.contains("plain"));
assert_eq!(pkg.shield, "===");
}
#[test]
fn load_warns_when_meta_name_mismatch() {
let home = tempdir().unwrap();
let pkg_dir = home.path().join(".rippy/packages");
write_file(
&pkg_dir.join("filename.toml"),
"[meta]\nname = \"metaname\"\ntagline = \"Mismatch\"\n",
);
let pkg = load_custom_package(home.path(), "filename")
.unwrap()
.unwrap();
assert_eq!(pkg.name, "filename");
}
}