Skip to main content

rippy_cli/
packages.rs

1//! Preconfigured safety packages — named rule bundles for different workflows.
2//!
3//! Packages are embedded TOML files that slot between stdlib and user config.
4//! They provide sensible defaults for common development scenarios so users
5//! can get started with a single `package = "develop"` setting.
6//!
7//! Three built-in packages are available:
8//!
9//! - `review`    — full supervision, every command asks
10//! - `develop`   — auto-approves builds, tests, VCS; asks for destructive ops
11//! - `autopilot` — maximum AI autonomy, only catastrophic ops blocked
12//!
13//! Users may also define custom packages in `~/.rippy/packages/<name>.toml`.
14//! Custom packages can `extends = "<builtin>"` to inherit from a built-in
15//! package and layer extra rules on top.
16
17mod 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/// A preconfigured safety profile.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub enum Package {
36    /// Full supervision. Every command asks.
37    Review,
38    /// Auto-approves builds, tests, VCS. Asks for destructive ops.
39    Develop,
40    /// Maximum AI autonomy. Only catastrophic ops blocked.
41    Autopilot,
42    /// User-defined custom package loaded from `~/.rippy/packages/<name>.toml`.
43    Custom(Arc<CustomPackage>),
44}
45
46/// All built-in packages in display order (most restrictive first).
47const ALL_BUILTIN: &[Package] = &[Package::Review, Package::Develop, Package::Autopilot];
48
49impl Package {
50    /// Parse a built-in package name from a string.
51    ///
52    /// For custom packages use [`Package::resolve`], which checks built-ins
53    /// first and then `~/.rippy/packages/<name>.toml`.
54    ///
55    /// # Errors
56    ///
57    /// Returns an error if the name does not match a built-in package.
58    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    /// Resolve a package name to a built-in or custom package.
70    ///
71    /// Built-in names (`review`, `develop`, `autopilot`) always take priority.
72    /// If a custom file in `~/.rippy/packages/` has the same name as a built-in,
73    /// a warning is printed to stderr and the built-in is used.
74    ///
75    /// Pass `None` for `home` to skip custom package resolution.
76    ///
77    /// # Errors
78    ///
79    /// Returns `RippyError::Config` if a custom package file exists but is
80    /// malformed. Returns `RippyError::Setup` if the name is unknown.
81    pub fn resolve(name: &str, home: Option<&Path>) -> Result<Self, RippyError> {
82        // Built-ins always take priority.
83        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        // Try custom packages.
98        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    /// The short name used in config files.
112    #[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    /// One-line description shown in `rippy profile list`.
123    ///
124    /// For built-ins, sourced from the `[meta] tagline` field of the
125    /// package's embedded TOML (parsed once, cached). For custom packages,
126    /// read from the `CustomPackage` loaded at discovery time.
127    #[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    /// Shield bar for terminal display (e.g., `===`, `==.`, `=..`).
136    ///
137    /// Sourced from `[meta] shield` in the package's TOML.
138    #[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    /// All built-in packages in display order (most restrictive first).
147    #[must_use]
148    pub const fn all() -> &'static [Self] {
149        ALL_BUILTIN
150    }
151
152    /// All built-in packages as an owned array.
153    #[must_use]
154    pub const fn all_builtin() -> [Self; 3] {
155        [Self::Review, Self::Develop, Self::Autopilot]
156    }
157
158    /// All packages available: built-ins followed by discovered custom packages.
159    ///
160    /// Pass `None` for `home` to return only built-ins.
161    #[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                // Skip custom packages that shadow built-ins — they're unreachable.
167                if Self::parse(&custom.name).is_ok() {
168                    continue;
169                }
170                packages.push(Self::Custom(custom));
171            }
172        }
173        packages
174    }
175
176    /// Whether this package is user-defined (loaded from `~/.rippy/packages/`).
177    #[must_use]
178    pub const fn is_custom(&self) -> bool {
179        matches!(self, Self::Custom(_))
180    }
181
182    /// Raw TOML source for the package's rules.
183    #[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
206/// Parse a package's TOML into config directives.
207///
208/// For built-ins, parses the embedded TOML. For custom packages with
209/// `extends = "<builtin>"`, first generates the base package's directives,
210/// then appends the custom package's directives (last-match-wins lets custom
211/// rules override base rules).
212///
213/// # Errors
214///
215/// Returns `RippyError::Config` if the TOML is malformed, or
216/// `RippyError::Setup` if `extends` references an unknown or non-built-in
217/// package.
218pub 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/// Get the raw TOML source for a package.
254#[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        // Review should only have git style rules (from cautious), no explicit allow commands
353        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    // --- Custom package and resolution tests ---
412
413    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        // Built-in wins
433        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        // Custom file with a built-in name should not appear in the list.
496        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        // Inherited from develop:
535        let v = config.match_command("cargo test", None);
536        assert!(v.is_some());
537        assert_eq!(v.unwrap().decision, Decision::Allow);
538
539        // Added by custom team package:
540        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        // extends = "team" is not a built-in, so it's rejected even if a custom
565        // package named "team" exists.
566        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        // The solo package does NOT inherit from develop, so `cargo test`
602        // has no matching rule.
603        let v = config.match_command("cargo test", None);
604        assert!(v.is_none(), "solo should not inherit develop's rules");
605
606        // Own rule still applies.
607        let v = config.match_command("rm -rf /", None);
608        assert!(v.is_some());
609        assert_eq!(v.unwrap().decision, Decision::Deny);
610    }
611}