1mod custom;
18mod meta;
19
20pub use custom::{CustomPackage, discover_custom_packages, load_custom_package};
21
22use std::path::Path;
23use std::sync::Arc;
24
25use crate::config::ConfigDirective;
26use crate::error::RippyError;
27use meta::builtin_meta;
28
29const REVIEW_TOML: &str = include_str!("packages/review.toml");
30const DEVELOP_TOML: &str = include_str!("packages/develop.toml");
31const AUTOPILOT_TOML: &str = include_str!("packages/autopilot.toml");
32
33#[derive(Debug, Clone, PartialEq, Eq)]
35pub enum Package {
36 Review,
38 Develop,
40 Autopilot,
42 Custom(Arc<CustomPackage>),
44}
45
46const ALL_BUILTIN: &[Package] = &[Package::Review, Package::Develop, Package::Autopilot];
48
49impl Package {
50 pub fn parse(s: &str) -> Result<Self, String> {
59 match s {
60 "review" => Ok(Self::Review),
61 "develop" => Ok(Self::Develop),
62 "autopilot" => Ok(Self::Autopilot),
63 other => Err(format!(
64 "unknown package: {other} (expected review, develop, or autopilot)"
65 )),
66 }
67 }
68
69 pub fn resolve(name: &str, home: Option<&Path>) -> Result<Self, RippyError> {
82 if let Ok(builtin) = Self::parse(name) {
84 if let Some(home) = home
85 && home
86 .join(".rippy/packages")
87 .join(format!("{name}.toml"))
88 .is_file()
89 {
90 eprintln!(
91 "[rippy] custom package \"{name}\" is shadowed by the built-in package with the same name"
92 );
93 }
94 return Ok(builtin);
95 }
96
97 if let Some(home) = home
99 && let Some(pkg) = load_custom_package(home, name)?
100 {
101 return Ok(Self::Custom(pkg));
102 }
103
104 let known = known_package_names(home);
105 Err(RippyError::Setup(format!(
106 "unknown package: {name} (known: {})",
107 known.join(", ")
108 )))
109 }
110
111 #[must_use]
113 pub fn name(&self) -> &str {
114 match self {
115 Self::Review => "review",
116 Self::Develop => "develop",
117 Self::Autopilot => "autopilot",
118 Self::Custom(c) => &c.name,
119 }
120 }
121
122 #[must_use]
128 pub fn tagline(&self) -> &str {
129 match self {
130 Self::Custom(c) => &c.tagline,
131 _ => &builtin_meta(self).tagline,
132 }
133 }
134
135 #[must_use]
139 pub fn shield(&self) -> &str {
140 match self {
141 Self::Custom(c) => &c.shield,
142 _ => &builtin_meta(self).shield,
143 }
144 }
145
146 #[must_use]
148 pub const fn all() -> &'static [Self] {
149 ALL_BUILTIN
150 }
151
152 #[must_use]
154 pub const fn all_builtin() -> [Self; 3] {
155 [Self::Review, Self::Develop, Self::Autopilot]
156 }
157
158 #[must_use]
162 pub fn all_available(home: Option<&Path>) -> Vec<Self> {
163 let mut packages: Vec<Self> = ALL_BUILTIN.to_vec();
164 if let Some(home) = home {
165 for custom in discover_custom_packages(home) {
166 if Self::parse(&custom.name).is_ok() {
168 continue;
169 }
170 packages.push(Self::Custom(custom));
171 }
172 }
173 packages
174 }
175
176 #[must_use]
178 pub const fn is_custom(&self) -> bool {
179 matches!(self, Self::Custom(_))
180 }
181
182 #[must_use]
184 pub fn toml_source(&self) -> &str {
185 match self {
186 Self::Review => REVIEW_TOML,
187 Self::Develop => DEVELOP_TOML,
188 Self::Autopilot => AUTOPILOT_TOML,
189 Self::Custom(c) => &c.toml_source,
190 }
191 }
192}
193
194fn known_package_names(home: Option<&Path>) -> Vec<String> {
195 let mut names: Vec<String> = ALL_BUILTIN.iter().map(|p| p.name().to_string()).collect();
196 if let Some(home) = home {
197 for custom in discover_custom_packages(home) {
198 if !names.contains(&custom.name) {
199 names.push(custom.name.clone());
200 }
201 }
202 }
203 names
204}
205
206pub fn package_directives(package: &Package) -> Result<Vec<ConfigDirective>, RippyError> {
219 if let Package::Custom(c) = package {
220 return custom_package_directives(c);
221 }
222 let source = package.toml_source();
223 let label = format!("(package:{})", package.name());
224 crate::toml_config::parse_toml_config(source, Path::new(&label))
225}
226
227fn custom_package_directives(pkg: &CustomPackage) -> Result<Vec<ConfigDirective>, RippyError> {
228 let mut directives = Vec::new();
229
230 if let Some(base_name) = &pkg.extends {
231 let base = Package::parse(base_name).map_err(|_| {
232 RippyError::Setup(format!(
233 "custom package \"{}\" extends unknown package \"{base_name}\" \
234 (only built-ins review, develop, autopilot may be extended)",
235 pkg.name
236 ))
237 })?;
238 let base_source = base.toml_source();
239 let base_label = format!("(package:{})", base.name());
240 directives.extend(crate::toml_config::parse_toml_config(
241 base_source,
242 Path::new(&base_label),
243 )?);
244 }
245
246 directives.extend(crate::toml_config::parse_toml_config(
247 &pkg.toml_source,
248 &pkg.path,
249 )?);
250 Ok(directives)
251}
252
253#[must_use]
255pub fn package_toml(package: &Package) -> &str {
256 package.toml_source()
257}
258
259impl std::fmt::Display for Package {
260 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
261 write!(f, "{}", self.name())
262 }
263}
264
265#[cfg(test)]
266#[allow(clippy::unwrap_used, clippy::panic)]
267mod tests {
268 use super::*;
269 use crate::config::Config;
270 use crate::verdict::Decision;
271
272 #[test]
273 fn review_toml_parses() {
274 let directives = package_directives(&Package::Review).unwrap();
275 assert!(
276 !directives.is_empty(),
277 "review package should produce directives"
278 );
279 }
280
281 #[test]
282 fn develop_toml_parses() {
283 let directives = package_directives(&Package::Develop).unwrap();
284 assert!(
285 !directives.is_empty(),
286 "develop package should produce directives"
287 );
288 }
289
290 #[test]
291 fn autopilot_toml_parses() {
292 let directives = package_directives(&Package::Autopilot).unwrap();
293 assert!(
294 !directives.is_empty(),
295 "autopilot package should produce directives"
296 );
297 }
298
299 #[test]
300 fn parse_valid_names() {
301 assert_eq!(Package::parse("review").unwrap(), Package::Review);
302 assert_eq!(Package::parse("develop").unwrap(), Package::Develop);
303 assert_eq!(Package::parse("autopilot").unwrap(), Package::Autopilot);
304 }
305
306 #[test]
307 fn parse_invalid_name_errors() {
308 let err = Package::parse("yolo").unwrap_err();
309 assert!(err.contains("unknown package"));
310 assert!(err.contains("yolo"));
311 }
312
313 #[test]
314 fn all_returns_three_packages() {
315 assert_eq!(Package::all().len(), 3);
316 }
317
318 #[test]
319 fn develop_allows_cargo_test() {
320 let config = Config::from_directives(package_directives(&Package::Develop).unwrap());
321 let v = config.match_command("cargo test", None);
322 assert!(v.is_some(), "develop package should match cargo test");
323 assert_eq!(v.unwrap().decision, Decision::Allow);
324 }
325
326 #[test]
327 fn develop_allows_file_ops() {
328 let config = Config::from_directives(package_directives(&Package::Develop).unwrap());
329 for cmd in &["rm foo.txt", "mv a b", "cp a b", "touch new.txt"] {
330 let v = config.match_command(cmd, None);
331 assert!(v.is_some(), "develop should match {cmd}");
332 assert_eq!(
333 v.unwrap().decision,
334 Decision::Allow,
335 "develop should allow {cmd}"
336 );
337 }
338 }
339
340 #[test]
341 fn autopilot_has_allow_default() {
342 let directives = package_directives(&Package::Autopilot).unwrap();
343 let has_default_allow = directives
344 .iter()
345 .any(|d| matches!(d, ConfigDirective::Set { key, value } if key == "default" && value == "allow"));
346 assert!(has_default_allow, "autopilot should set default = allow");
347 }
348
349 #[test]
350 fn review_has_no_extra_allow_rules() {
351 let directives = package_directives(&Package::Review).unwrap();
352 let allow_command_rules = directives.iter().filter(|d| {
354 matches!(d, ConfigDirective::Rule(r) if r.decision == Decision::Allow
355 && !r.pattern.raw().starts_with("git"))
356 });
357 assert_eq!(
358 allow_command_rules.count(),
359 0,
360 "review should not add non-git allow rules"
361 );
362 }
363
364 #[test]
365 fn package_toml_not_empty() {
366 for pkg in Package::all() {
367 let toml = package_toml(pkg);
368 assert!(!toml.is_empty(), "{pkg} TOML should not be empty");
369 assert!(toml.contains("[meta]"), "{pkg} should have [meta] section");
370 }
371 }
372
373 #[test]
374 fn display_shows_name() {
375 assert_eq!(format!("{}", Package::Review), "review");
376 assert_eq!(format!("{}", Package::Develop), "develop");
377 assert_eq!(format!("{}", Package::Autopilot), "autopilot");
378 }
379
380 #[test]
381 fn shield_values_match_expected() {
382 assert_eq!(Package::Review.shield(), "===");
383 assert_eq!(Package::Develop.shield(), "==.");
384 assert_eq!(Package::Autopilot.shield(), "=..");
385 }
386
387 #[test]
388 fn tagline_values_not_empty() {
389 for pkg in Package::all() {
390 assert!(
391 !pkg.tagline().is_empty(),
392 "{pkg} tagline should not be empty"
393 );
394 }
395 }
396
397 #[test]
398 fn autopilot_denies_catastrophic_rm() {
399 let config = Config::from_directives(package_directives(&Package::Autopilot).unwrap());
400 for cmd in &["rm -rf /", "rm -rf ~"] {
401 let v = config.match_command(cmd, None);
402 assert!(v.is_some(), "autopilot should match {cmd}");
403 assert_eq!(
404 v.unwrap().decision,
405 Decision::Deny,
406 "autopilot should deny {cmd}"
407 );
408 }
409 }
410
411 use tempfile::tempdir;
414
415 fn write_custom(home: &Path, name: &str, body: &str) {
416 let dir = home.join(".rippy/packages");
417 std::fs::create_dir_all(&dir).unwrap();
418 std::fs::write(dir.join(format!("{name}.toml")), body).unwrap();
419 }
420
421 #[test]
422 fn resolve_builtin_without_home() {
423 let pkg = Package::resolve("develop", None).unwrap();
424 assert_eq!(pkg, Package::Develop);
425 }
426
427 #[test]
428 fn resolve_builtin_takes_priority_over_custom_with_same_name() {
429 let home = tempdir().unwrap();
430 write_custom(home.path(), "develop", "[meta]\ntagline = \"shadowed\"\n");
431 let pkg = Package::resolve("develop", Some(home.path())).unwrap();
432 assert_eq!(pkg, Package::Develop);
434 }
435
436 #[test]
437 fn resolve_custom_package_by_name() {
438 let home = tempdir().unwrap();
439 write_custom(
440 home.path(),
441 "corp",
442 "[meta]\nname = \"corp\"\ntagline = \"Corporate\"\n",
443 );
444 let pkg = Package::resolve("corp", Some(home.path())).unwrap();
445 match pkg {
446 Package::Custom(c) => {
447 assert_eq!(c.name, "corp");
448 assert_eq!(c.tagline, "Corporate");
449 }
450 _ => panic!("expected Custom variant"),
451 }
452 }
453
454 #[test]
455 fn resolve_unknown_errors_lists_known() {
456 let err = Package::resolve("bogus", None).unwrap_err();
457 let msg = err.to_string();
458 assert!(msg.contains("bogus"), "error should mention name: {msg}");
459 assert!(
460 msg.contains("develop"),
461 "error should list built-ins: {msg}"
462 );
463 }
464
465 #[test]
466 fn resolve_unknown_errors_includes_custom() {
467 let home = tempdir().unwrap();
468 write_custom(home.path(), "extra", "[meta]\nname = \"extra\"\n");
469 let err = Package::resolve("bogus", Some(home.path())).unwrap_err();
470 let msg = err.to_string();
471 assert!(
472 msg.contains("extra"),
473 "error should list custom packages: {msg}"
474 );
475 }
476
477 #[test]
478 fn all_available_includes_custom_from_home() {
479 let home = tempdir().unwrap();
480 write_custom(home.path(), "corp", "[meta]\nname = \"corp\"\n");
481 write_custom(home.path(), "team", "[meta]\nname = \"team\"\n");
482
483 let all = Package::all_available(Some(home.path()));
484 let names: Vec<&str> = all.iter().map(Package::name).collect();
485 assert!(names.contains(&"review"));
486 assert!(names.contains(&"develop"));
487 assert!(names.contains(&"autopilot"));
488 assert!(names.contains(&"corp"));
489 assert!(names.contains(&"team"));
490 }
491
492 #[test]
493 fn all_available_filters_shadowed_custom() {
494 let home = tempdir().unwrap();
495 write_custom(home.path(), "develop", "[meta]\ntagline = \"shadowed\"\n");
497 let all = Package::all_available(Some(home.path()));
498 let develop_entries: Vec<&Package> = all.iter().filter(|p| p.name() == "develop").collect();
499 assert_eq!(develop_entries.len(), 1);
500 assert_eq!(develop_entries[0], &Package::Develop);
501 }
502
503 #[test]
504 fn all_builtin_returns_three() {
505 let all = Package::all_builtin();
506 assert_eq!(all.len(), 3);
507 assert_eq!(all[0], Package::Review);
508 assert_eq!(all[1], Package::Develop);
509 assert_eq!(all[2], Package::Autopilot);
510 }
511
512 #[test]
513 fn custom_extends_develop_inherits_directives() {
514 let home = tempdir().unwrap();
515 write_custom(
516 home.path(),
517 "team",
518 r#"
519[meta]
520name = "team"
521extends = "develop"
522
523[[rules]]
524action = "deny"
525pattern = "npm publish"
526message = "team rule: no publishing"
527"#,
528 );
529 let pkg = Package::resolve("team", Some(home.path())).unwrap();
530 let directives = package_directives(&pkg).unwrap();
531
532 let config = Config::from_directives(directives);
533
534 let v = config.match_command("cargo test", None);
536 assert!(v.is_some());
537 assert_eq!(v.unwrap().decision, Decision::Allow);
538
539 let v = config.match_command("npm publish", None);
541 assert!(v.is_some());
542 assert_eq!(v.unwrap().decision, Decision::Deny);
543 }
544
545 #[test]
546 fn custom_extends_unknown_package_errors() {
547 let home = tempdir().unwrap();
548 write_custom(
549 home.path(),
550 "bad",
551 "[meta]\nname = \"bad\"\nextends = \"nope\"\n",
552 );
553 let pkg = Package::resolve("bad", Some(home.path())).unwrap();
554 let err = package_directives(&pkg).unwrap_err();
555 let msg = err.to_string();
556 assert!(
557 msg.contains("nope"),
558 "error should mention extends target: {msg}"
559 );
560 }
561
562 #[test]
563 fn custom_extends_custom_rejected() {
564 let home = tempdir().unwrap();
567 write_custom(home.path(), "team", "[meta]\nname = \"team\"\n");
568 write_custom(
569 home.path(),
570 "derived",
571 "[meta]\nname = \"derived\"\nextends = \"team\"\n",
572 );
573 let pkg = Package::resolve("derived", Some(home.path())).unwrap();
574 let err = package_directives(&pkg).unwrap_err();
575 let msg = err.to_string();
576 assert!(
577 msg.contains("team"),
578 "error should mention the rejected base: {msg}"
579 );
580 }
581
582 #[test]
583 fn custom_without_extends_has_only_own_rules() {
584 let home = tempdir().unwrap();
585 write_custom(
586 home.path(),
587 "solo",
588 r#"
589[meta]
590name = "solo"
591
592[[rules]]
593action = "deny"
594pattern = "rm -rf /"
595"#,
596 );
597 let pkg = Package::resolve("solo", Some(home.path())).unwrap();
598 let directives = package_directives(&pkg).unwrap();
599 let config = Config::from_directives(directives);
600
601 let v = config.match_command("cargo test", None);
604 assert!(v.is_none(), "solo should not inherit develop's rules");
605
606 let v = config.match_command("rm -rf /", None);
608 assert!(v.is_some());
609 assert_eq!(v.unwrap().decision, Decision::Deny);
610 }
611}