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