1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum Package {
25 Review,
27 Develop,
29 Autopilot,
31}
32
33const ALL_PACKAGES: &[Package] = &[Package::Review, Package::Develop, Package::Autopilot];
35
36impl Package {
37 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 #[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 #[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 #[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 #[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
98pub 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#[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 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}