Skip to main content

rippy_cli/packages/
custom.rs

1//! Discovery and loading of user-defined custom packages from `~/.rippy/packages/`.
2//!
3//! Custom packages are ordinary `.rippy.toml` files with a `[meta]` section.
4//! The filename (minus `.toml`) is the authoritative package name — the `[meta] name`
5//! field in the file is informational and triggers a warning if it disagrees.
6//!
7//! Custom packages may `extends = "<builtin>"` to inherit from a built-in package.
8//! Cycles are prevented structurally: custom packages can only extend built-ins,
9//! never other custom packages.
10
11use std::path::{Path, PathBuf};
12use std::sync::Arc;
13
14use crate::error::RippyError;
15use crate::toml_config::TomlConfig;
16
17/// A user-defined package loaded from `~/.rippy/packages/<name>.toml`.
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct CustomPackage {
20    /// Package name (derived from the filename, not `[meta] name`).
21    pub name: String,
22    /// One-line description from `[meta] tagline`, or a default.
23    pub tagline: String,
24    /// Shield bar from `[meta] shield`, or a default.
25    pub shield: String,
26    /// Path the package was loaded from.
27    pub path: PathBuf,
28    /// Raw TOML contents, cached for directive generation.
29    pub toml_source: String,
30    /// Optional built-in package name to inherit rules from.
31    pub extends: Option<String>,
32}
33
34/// The directory where custom packages live, relative to `$HOME`.
35fn custom_packages_dir(home: &Path) -> PathBuf {
36    home.join(".rippy/packages")
37}
38
39/// Scan `~/.rippy/packages/*.toml` and return metadata-loaded custom packages.
40///
41/// Malformed files are skipped with a stderr warning so callers like
42/// `rippy profile list` stay robust in the presence of a single bad file.
43#[must_use]
44pub fn discover_custom_packages(home: &Path) -> Vec<Arc<CustomPackage>> {
45    let dir = custom_packages_dir(home);
46    let Ok(entries) = std::fs::read_dir(&dir) else {
47        return Vec::new();
48    };
49
50    let mut packages: Vec<Arc<CustomPackage>> = Vec::new();
51    for entry in entries.flatten() {
52        let path = entry.path();
53        if !is_toml_file(&path) {
54            continue;
55        }
56        let Some(name) = package_name_from_path(&path) else {
57            continue;
58        };
59        match load_custom_package_from_path(&path, &name) {
60            Ok(pkg) => packages.push(Arc::new(pkg)),
61            Err(e) => eprintln!("[rippy] skipping custom package {}: {e}", path.display()),
62        }
63    }
64    packages.sort_by(|a, b| a.name.cmp(&b.name));
65    packages
66}
67
68/// Load a single custom package by name from `~/.rippy/packages/<name>.toml`.
69///
70/// Returns `Ok(None)` if no such file exists. Returns `Err` when the file
71/// exists but cannot be read or contains malformed TOML.
72///
73/// # Errors
74///
75/// Returns `RippyError::Config` if the file is malformed or unreadable.
76pub fn load_custom_package(
77    home: &Path,
78    name: &str,
79) -> Result<Option<Arc<CustomPackage>>, RippyError> {
80    let path = custom_packages_dir(home).join(format!("{name}.toml"));
81    if !path.is_file() {
82        return Ok(None);
83    }
84    let pkg = load_custom_package_from_path(&path, name)?;
85    Ok(Some(Arc::new(pkg)))
86}
87
88fn load_custom_package_from_path(path: &Path, name: &str) -> Result<CustomPackage, RippyError> {
89    let toml_source = std::fs::read_to_string(path).map_err(|e| RippyError::Config {
90        path: path.to_path_buf(),
91        line: 0,
92        message: format!("could not read: {e}"),
93    })?;
94    let config: TomlConfig = toml::from_str(&toml_source).map_err(|e| RippyError::Config {
95        path: path.to_path_buf(),
96        line: 0,
97        message: format!("{e}"),
98    })?;
99
100    let meta = config.meta.unwrap_or(crate::toml_config::TomlMeta {
101        name: None,
102        tagline: None,
103        shield: None,
104        description: None,
105        extends: None,
106    });
107
108    if let Some(meta_name) = meta.name.as_deref()
109        && meta_name != name
110    {
111        eprintln!(
112            "[rippy] custom package {}: [meta] name=\"{meta_name}\" does not match filename \"{name}\" (filename wins)",
113            path.display(),
114        );
115    }
116
117    let tagline = meta
118        .tagline
119        .unwrap_or_else(|| format!("Custom package: {name}"));
120    let shield = meta.shield.unwrap_or_else(|| "===".to_string());
121    let extends = meta.extends;
122
123    Ok(CustomPackage {
124        name: name.to_string(),
125        tagline,
126        shield,
127        path: path.to_path_buf(),
128        toml_source,
129        extends,
130    })
131}
132
133fn is_toml_file(path: &Path) -> bool {
134    path.extension().is_some_and(|ext| ext == "toml") && path.is_file()
135}
136
137fn package_name_from_path(path: &Path) -> Option<String> {
138    path.file_stem()
139        .and_then(|s| s.to_str())
140        .map(std::string::ToString::to_string)
141}
142
143#[cfg(test)]
144#[allow(clippy::unwrap_used)]
145mod tests {
146    use super::*;
147    use tempfile::tempdir;
148
149    fn write_file(path: &Path, content: &str) {
150        if let Some(parent) = path.parent() {
151            std::fs::create_dir_all(parent).unwrap();
152        }
153        std::fs::write(path, content).unwrap();
154    }
155
156    #[test]
157    fn discover_empty_dir_returns_empty() {
158        let home = tempdir().unwrap();
159        let packages = discover_custom_packages(home.path());
160        assert!(packages.is_empty());
161    }
162
163    #[test]
164    fn discover_missing_dir_returns_empty() {
165        let home = tempdir().unwrap();
166        // Do not create .rippy/packages/
167        let packages = discover_custom_packages(home.path());
168        assert!(packages.is_empty());
169    }
170
171    #[test]
172    fn discover_finds_toml_files() {
173        let home = tempdir().unwrap();
174        let pkg_dir = home.path().join(".rippy/packages");
175        write_file(
176            &pkg_dir.join("corp.toml"),
177            r#"
178[meta]
179name = "corp"
180tagline = "Corporate standard"
181shield = "===."
182
183[[rules]]
184action = "deny"
185pattern = "rm -rf"
186"#,
187        );
188
189        let packages = discover_custom_packages(home.path());
190        assert_eq!(packages.len(), 1);
191        assert_eq!(packages[0].name, "corp");
192        assert_eq!(packages[0].tagline, "Corporate standard");
193        assert_eq!(packages[0].shield, "===.");
194        assert!(packages[0].extends.is_none());
195    }
196
197    #[test]
198    fn discover_ignores_non_toml_files() {
199        let home = tempdir().unwrap();
200        let pkg_dir = home.path().join(".rippy/packages");
201        write_file(&pkg_dir.join("notes.txt"), "some text");
202        write_file(&pkg_dir.join("README"), "read me");
203
204        let packages = discover_custom_packages(home.path());
205        assert!(packages.is_empty());
206    }
207
208    #[test]
209    fn discover_skips_malformed_returns_valid_only() {
210        let home = tempdir().unwrap();
211        let pkg_dir = home.path().join(".rippy/packages");
212        write_file(&pkg_dir.join("good.toml"), "[meta]\nname = \"good\"\n");
213        write_file(&pkg_dir.join("bad.toml"), "this is not valid toml [[");
214
215        let packages = discover_custom_packages(home.path());
216        assert_eq!(packages.len(), 1);
217        assert_eq!(packages[0].name, "good");
218    }
219
220    #[test]
221    fn discover_returns_sorted() {
222        let home = tempdir().unwrap();
223        let pkg_dir = home.path().join(".rippy/packages");
224        write_file(&pkg_dir.join("zeta.toml"), "[meta]\nname = \"zeta\"\n");
225        write_file(&pkg_dir.join("alpha.toml"), "[meta]\nname = \"alpha\"\n");
226        write_file(&pkg_dir.join("mango.toml"), "[meta]\nname = \"mango\"\n");
227
228        let packages = discover_custom_packages(home.path());
229        let names: Vec<&str> = packages.iter().map(|p| p.name.as_str()).collect();
230        assert_eq!(names, vec!["alpha", "mango", "zeta"]);
231    }
232
233    #[test]
234    fn load_by_name_happy_path() {
235        let home = tempdir().unwrap();
236        let pkg_dir = home.path().join(".rippy/packages");
237        write_file(
238            &pkg_dir.join("team.toml"),
239            r#"
240[meta]
241name = "team"
242tagline = "Team package"
243extends = "develop"
244"#,
245        );
246
247        let pkg = load_custom_package(home.path(), "team").unwrap().unwrap();
248        assert_eq!(pkg.name, "team");
249        assert_eq!(pkg.tagline, "Team package");
250        assert_eq!(pkg.extends.as_deref(), Some("develop"));
251    }
252
253    #[test]
254    fn load_by_name_not_found_returns_none() {
255        let home = tempdir().unwrap();
256        let result = load_custom_package(home.path(), "missing").unwrap();
257        assert!(result.is_none());
258    }
259
260    #[test]
261    fn load_by_name_malformed_errors_with_path() {
262        let home = tempdir().unwrap();
263        let pkg_dir = home.path().join(".rippy/packages");
264        write_file(&pkg_dir.join("broken.toml"), "not valid [[");
265
266        let err = load_custom_package(home.path(), "broken").unwrap_err();
267        let msg = format!("{err:?}");
268        assert!(
269            msg.contains("broken.toml"),
270            "error should mention path: {msg}"
271        );
272    }
273
274    #[test]
275    fn load_defaults_tagline_when_missing() {
276        let home = tempdir().unwrap();
277        let pkg_dir = home.path().join(".rippy/packages");
278        write_file(
279            &pkg_dir.join("plain.toml"),
280            "[[rules]]\naction = \"ask\"\npattern = \"foo\"\n",
281        );
282
283        let pkg = load_custom_package(home.path(), "plain").unwrap().unwrap();
284        assert!(pkg.tagline.contains("plain"));
285        assert_eq!(pkg.shield, "===");
286    }
287
288    #[test]
289    fn load_warns_when_meta_name_mismatch() {
290        let home = tempdir().unwrap();
291        let pkg_dir = home.path().join(".rippy/packages");
292        // filename is "filename.toml", but [meta] name says "metaname"
293        write_file(
294            &pkg_dir.join("filename.toml"),
295            "[meta]\nname = \"metaname\"\ntagline = \"Mismatch\"\n",
296        );
297
298        let pkg = load_custom_package(home.path(), "filename")
299            .unwrap()
300            .unwrap();
301        // Filename wins
302        assert_eq!(pkg.name, "filename");
303    }
304}