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;
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/// A preconfigured safety profile.
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub enum Package {
34    /// Full supervision. Every command asks.
35    Review,
36    /// Auto-approves builds, tests, VCS. Asks for destructive ops.
37    Develop,
38    /// Maximum AI autonomy. Only catastrophic ops blocked.
39    Autopilot,
40    /// User-defined custom package loaded from `~/.rippy/packages/<name>.toml`.
41    Custom(Arc<CustomPackage>),
42}
43
44/// All built-in packages in display order (most restrictive first).
45const ALL_BUILTIN: &[Package] = &[Package::Review, Package::Develop, Package::Autopilot];
46
47impl Package {
48    /// Parse a built-in package name from a string.
49    ///
50    /// For custom packages use [`Package::resolve`], which checks built-ins
51    /// first and then `~/.rippy/packages/<name>.toml`.
52    ///
53    /// # Errors
54    ///
55    /// Returns an error if the name does not match a built-in package.
56    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    /// Resolve a package name to a built-in or custom package.
68    ///
69    /// Built-in names (`review`, `develop`, `autopilot`) always take priority.
70    /// If a custom file in `~/.rippy/packages/` has the same name as a built-in,
71    /// a warning is printed to stderr and the built-in is used.
72    ///
73    /// Pass `None` for `home` to skip custom package resolution.
74    ///
75    /// # Errors
76    ///
77    /// Returns `RippyError::Config` if a custom package file exists but is
78    /// malformed. Returns `RippyError::Setup` if the name is unknown.
79    pub fn resolve(name: &str, home: Option<&Path>) -> Result<Self, RippyError> {
80        // Built-ins always take priority.
81        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        // Try custom packages.
96        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    /// The short name used in config files.
110    #[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    /// One-line description shown in `rippy profile list`.
121    #[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    /// Shield bar for terminal display (e.g., `===`, `==.`, `=..`).
132    #[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    /// All built-in packages in display order (most restrictive first).
143    #[must_use]
144    pub const fn all() -> &'static [Self] {
145        ALL_BUILTIN
146    }
147
148    /// All built-in packages as an owned array.
149    #[must_use]
150    pub const fn all_builtin() -> [Self; 3] {
151        [Self::Review, Self::Develop, Self::Autopilot]
152    }
153
154    /// All packages available: built-ins followed by discovered custom packages.
155    ///
156    /// Pass `None` for `home` to return only built-ins.
157    #[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                // Skip custom packages that shadow built-ins — they're unreachable.
163                if Self::parse(&custom.name).is_ok() {
164                    continue;
165                }
166                packages.push(Self::Custom(custom));
167            }
168        }
169        packages
170    }
171
172    /// Whether this package is user-defined (loaded from `~/.rippy/packages/`).
173    #[must_use]
174    pub const fn is_custom(&self) -> bool {
175        matches!(self, Self::Custom(_))
176    }
177
178    /// Raw TOML source for the package's rules.
179    #[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
202/// Parse a package's TOML into config directives.
203///
204/// For built-ins, parses the embedded TOML. For custom packages with
205/// `extends = "<builtin>"`, first generates the base package's directives,
206/// then appends the custom package's directives (last-match-wins lets custom
207/// rules override base rules).
208///
209/// # Errors
210///
211/// Returns `RippyError::Config` if the TOML is malformed, or
212/// `RippyError::Setup` if `extends` references an unknown or non-built-in
213/// package.
214pub 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/// Get the raw TOML source for a package.
250#[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        // Review should only have git style rules (from cautious), no explicit allow commands
349        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    // --- Custom package and resolution tests ---
408
409    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        // Built-in wins
429        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        // Custom file with a built-in name should not appear in the list.
492        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        // Inherited from develop:
531        let v = config.match_command("cargo test", None);
532        assert!(v.is_some());
533        assert_eq!(v.unwrap().decision, Decision::Allow);
534
535        // Added by custom team package:
536        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        // extends = "team" is not a built-in, so it's rejected even if a custom
561        // package named "team" exists.
562        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        // The solo package does NOT inherit from develop, so `cargo test`
598        // has no matching rule.
599        let v = config.match_command("cargo test", None);
600        assert!(v.is_none(), "solo should not inherit develop's rules");
601
602        // Own rule still applies.
603        let v = config.match_command("rm -rf /", None);
604        assert!(v.is_some());
605        assert_eq!(v.unwrap().decision, Decision::Deny);
606    }
607}