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 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
13use std::path::Path;
14
15use crate::config::ConfigDirective;
16use crate::error::RippyError;
17
18const REVIEW_TOML: &str = include_str!("packages/review.toml");
19const DEVELOP_TOML: &str = include_str!("packages/develop.toml");
20const AUTOPILOT_TOML: &str = include_str!("packages/autopilot.toml");
21
22/// A preconfigured safety profile.
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum Package {
25    /// Full supervision. Every command asks.
26    Review,
27    /// Auto-approves builds, tests, VCS. Asks for destructive ops.
28    Develop,
29    /// Maximum AI autonomy. Only catastrophic ops blocked.
30    Autopilot,
31}
32
33/// All available packages in display order (most restrictive first).
34const ALL_PACKAGES: &[Package] = &[Package::Review, Package::Develop, Package::Autopilot];
35
36impl Package {
37    /// Parse a package name from a string.
38    ///
39    /// # Errors
40    ///
41    /// Returns an error if the name is not recognized.
42    pub fn parse(s: &str) -> Result<Self, String> {
43        match s {
44            "review" => Ok(Self::Review),
45            "develop" => Ok(Self::Develop),
46            "autopilot" => Ok(Self::Autopilot),
47            other => Err(format!(
48                "unknown package: {other} (expected review, develop, or autopilot)"
49            )),
50        }
51    }
52
53    /// The short name used in config files.
54    #[must_use]
55    pub const fn name(self) -> &'static str {
56        match self {
57            Self::Review => "review",
58            Self::Develop => "develop",
59            Self::Autopilot => "autopilot",
60        }
61    }
62
63    /// One-line description shown in `rippy profile list`.
64    #[must_use]
65    pub const fn tagline(self) -> &'static str {
66        match self {
67            Self::Review => "Full supervision. Every command asks.",
68            Self::Develop => "Let me code. Ask when it matters.",
69            Self::Autopilot => "Maximum AI autonomy. Only catastrophic ops are blocked.",
70        }
71    }
72
73    /// Shield bar for terminal display (e.g., `===`, `==.`, `=..`).
74    #[must_use]
75    pub const fn shield(self) -> &'static str {
76        match self {
77            Self::Review => "===",
78            Self::Develop => "==.",
79            Self::Autopilot => "=..",
80        }
81    }
82
83    /// All available packages in display order (most restrictive first).
84    #[must_use]
85    pub const fn all() -> &'static [Self] {
86        ALL_PACKAGES
87    }
88
89    const fn toml_source(self) -> &'static str {
90        match self {
91            Self::Review => REVIEW_TOML,
92            Self::Develop => DEVELOP_TOML,
93            Self::Autopilot => AUTOPILOT_TOML,
94        }
95    }
96}
97
98/// Parse a package's embedded TOML into config directives.
99///
100/// # Errors
101///
102/// Returns `RippyError::Config` if the embedded TOML is malformed (a build bug).
103pub fn package_directives(package: Package) -> Result<Vec<ConfigDirective>, RippyError> {
104    let source = package.toml_source();
105    let label = format!("(package:{})", package.name());
106    crate::toml_config::parse_toml_config(source, Path::new(&label))
107}
108
109/// Get the raw TOML source for a package.
110#[must_use]
111pub const fn package_toml(package: Package) -> &'static str {
112    package.toml_source()
113}
114
115impl std::fmt::Display for Package {
116    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
117        write!(f, "{}", self.name())
118    }
119}
120
121#[cfg(test)]
122#[allow(clippy::unwrap_used)]
123mod tests {
124    use super::*;
125    use crate::config::Config;
126    use crate::verdict::Decision;
127
128    #[test]
129    fn review_toml_parses() {
130        let directives = package_directives(Package::Review).unwrap();
131        assert!(
132            !directives.is_empty(),
133            "review package should produce directives"
134        );
135    }
136
137    #[test]
138    fn develop_toml_parses() {
139        let directives = package_directives(Package::Develop).unwrap();
140        assert!(
141            !directives.is_empty(),
142            "develop package should produce directives"
143        );
144    }
145
146    #[test]
147    fn autopilot_toml_parses() {
148        let directives = package_directives(Package::Autopilot).unwrap();
149        assert!(
150            !directives.is_empty(),
151            "autopilot package should produce directives"
152        );
153    }
154
155    #[test]
156    fn parse_valid_names() {
157        assert_eq!(Package::parse("review").unwrap(), Package::Review);
158        assert_eq!(Package::parse("develop").unwrap(), Package::Develop);
159        assert_eq!(Package::parse("autopilot").unwrap(), Package::Autopilot);
160    }
161
162    #[test]
163    fn parse_invalid_name_errors() {
164        let err = Package::parse("yolo").unwrap_err();
165        assert!(err.contains("unknown package"));
166        assert!(err.contains("yolo"));
167    }
168
169    #[test]
170    fn all_returns_three_packages() {
171        assert_eq!(Package::all().len(), 3);
172    }
173
174    #[test]
175    fn develop_allows_cargo_test() {
176        let config = Config::from_directives(package_directives(Package::Develop).unwrap());
177        let v = config.match_command("cargo test", None);
178        assert!(v.is_some(), "develop package should match cargo test");
179        assert_eq!(v.unwrap().decision, Decision::Allow);
180    }
181
182    #[test]
183    fn develop_allows_file_ops() {
184        let config = Config::from_directives(package_directives(Package::Develop).unwrap());
185        for cmd in &["rm foo.txt", "mv a b", "cp a b", "touch new.txt"] {
186            let v = config.match_command(cmd, None);
187            assert!(v.is_some(), "develop should match {cmd}");
188            assert_eq!(
189                v.unwrap().decision,
190                Decision::Allow,
191                "develop should allow {cmd}"
192            );
193        }
194    }
195
196    #[test]
197    fn autopilot_has_allow_default() {
198        let directives = package_directives(Package::Autopilot).unwrap();
199        let has_default_allow = directives
200            .iter()
201            .any(|d| matches!(d, ConfigDirective::Set { key, value } if key == "default" && value == "allow"));
202        assert!(has_default_allow, "autopilot should set default = allow");
203    }
204
205    #[test]
206    fn review_has_no_extra_allow_rules() {
207        let directives = package_directives(Package::Review).unwrap();
208        // Review should only have git style rules (from cautious), no explicit allow commands
209        let allow_command_rules = directives.iter().filter(|d| {
210            matches!(d, ConfigDirective::Rule(r) if r.decision == Decision::Allow
211                && !r.pattern.raw().starts_with("git"))
212        });
213        assert_eq!(
214            allow_command_rules.count(),
215            0,
216            "review should not add non-git allow rules"
217        );
218    }
219
220    #[test]
221    fn package_toml_not_empty() {
222        for pkg in Package::all() {
223            let toml = package_toml(*pkg);
224            assert!(!toml.is_empty(), "{pkg} TOML should not be empty");
225            assert!(toml.contains("[meta]"), "{pkg} should have [meta] section");
226        }
227    }
228
229    #[test]
230    fn display_shows_name() {
231        assert_eq!(format!("{}", Package::Review), "review");
232        assert_eq!(format!("{}", Package::Develop), "develop");
233        assert_eq!(format!("{}", Package::Autopilot), "autopilot");
234    }
235
236    #[test]
237    fn shield_values_match_expected() {
238        assert_eq!(Package::Review.shield(), "===");
239        assert_eq!(Package::Develop.shield(), "==.");
240        assert_eq!(Package::Autopilot.shield(), "=..");
241    }
242
243    #[test]
244    fn tagline_values_not_empty() {
245        for pkg in Package::all() {
246            assert!(
247                !pkg.tagline().is_empty(),
248                "{pkg} tagline should not be empty"
249            );
250        }
251    }
252
253    #[test]
254    fn autopilot_denies_catastrophic_rm() {
255        let config = Config::from_directives(package_directives(Package::Autopilot).unwrap());
256        for cmd in &["rm -rf /", "rm -rf ~"] {
257            let v = config.match_command(cmd, None);
258            assert!(v.is_some(), "autopilot should match {cmd}");
259            assert_eq!(
260                v.unwrap().decision,
261                Decision::Deny,
262                "autopilot should deny {cmd}"
263            );
264        }
265    }
266}