rippy_cli/packages/
custom.rs1use std::path::{Path, PathBuf};
12use std::sync::Arc;
13
14use crate::error::RippyError;
15use crate::toml_config::TomlConfig;
16
17#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct CustomPackage {
20 pub name: String,
22 pub tagline: String,
24 pub shield: String,
26 pub path: PathBuf,
28 pub toml_source: String,
30 pub extends: Option<String>,
32}
33
34fn custom_packages_dir(home: &Path) -> PathBuf {
36 home.join(".rippy/packages")
37}
38
39#[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
68pub 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 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 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 assert_eq!(pkg.name, "filename");
303 }
304}