mod custom;
pub use custom::{CustomPackage, discover_custom_packages, load_custom_package};
use std::path::Path;
use std::sync::Arc;
use crate::config::ConfigDirective;
use crate::error::RippyError;
const REVIEW_TOML: &str = include_str!("packages/review.toml");
const DEVELOP_TOML: &str = include_str!("packages/develop.toml");
const AUTOPILOT_TOML: &str = include_str!("packages/autopilot.toml");
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Package {
Review,
Develop,
Autopilot,
Custom(Arc<CustomPackage>),
}
const ALL_BUILTIN: &[Package] = &[Package::Review, Package::Develop, Package::Autopilot];
impl Package {
pub fn parse(s: &str) -> Result<Self, String> {
match s {
"review" => Ok(Self::Review),
"develop" => Ok(Self::Develop),
"autopilot" => Ok(Self::Autopilot),
other => Err(format!(
"unknown package: {other} (expected review, develop, or autopilot)"
)),
}
}
pub fn resolve(name: &str, home: Option<&Path>) -> Result<Self, RippyError> {
if let Ok(builtin) = Self::parse(name) {
if let Some(home) = home
&& home
.join(".rippy/packages")
.join(format!("{name}.toml"))
.is_file()
{
eprintln!(
"[rippy] custom package \"{name}\" is shadowed by the built-in package with the same name"
);
}
return Ok(builtin);
}
if let Some(home) = home
&& let Some(pkg) = load_custom_package(home, name)?
{
return Ok(Self::Custom(pkg));
}
let known = known_package_names(home);
Err(RippyError::Setup(format!(
"unknown package: {name} (known: {})",
known.join(", ")
)))
}
#[must_use]
pub fn name(&self) -> &str {
match self {
Self::Review => "review",
Self::Develop => "develop",
Self::Autopilot => "autopilot",
Self::Custom(c) => &c.name,
}
}
#[must_use]
pub fn tagline(&self) -> &str {
match self {
Self::Review => "Full supervision. Every command asks.",
Self::Develop => "Let me code. Ask when it matters.",
Self::Autopilot => "Maximum AI autonomy. Only catastrophic ops are blocked.",
Self::Custom(c) => &c.tagline,
}
}
#[must_use]
pub fn shield(&self) -> &str {
match self {
Self::Review => "===",
Self::Develop => "==.",
Self::Autopilot => "=..",
Self::Custom(c) => &c.shield,
}
}
#[must_use]
pub const fn all() -> &'static [Self] {
ALL_BUILTIN
}
#[must_use]
pub const fn all_builtin() -> [Self; 3] {
[Self::Review, Self::Develop, Self::Autopilot]
}
#[must_use]
pub fn all_available(home: Option<&Path>) -> Vec<Self> {
let mut packages: Vec<Self> = ALL_BUILTIN.to_vec();
if let Some(home) = home {
for custom in discover_custom_packages(home) {
if Self::parse(&custom.name).is_ok() {
continue;
}
packages.push(Self::Custom(custom));
}
}
packages
}
#[must_use]
pub const fn is_custom(&self) -> bool {
matches!(self, Self::Custom(_))
}
#[must_use]
pub fn toml_source(&self) -> &str {
match self {
Self::Review => REVIEW_TOML,
Self::Develop => DEVELOP_TOML,
Self::Autopilot => AUTOPILOT_TOML,
Self::Custom(c) => &c.toml_source,
}
}
}
fn known_package_names(home: Option<&Path>) -> Vec<String> {
let mut names: Vec<String> = ALL_BUILTIN.iter().map(|p| p.name().to_string()).collect();
if let Some(home) = home {
for custom in discover_custom_packages(home) {
if !names.contains(&custom.name) {
names.push(custom.name.clone());
}
}
}
names
}
pub fn package_directives(package: &Package) -> Result<Vec<ConfigDirective>, RippyError> {
if let Package::Custom(c) = package {
return custom_package_directives(c);
}
let source = package.toml_source();
let label = format!("(package:{})", package.name());
crate::toml_config::parse_toml_config(source, Path::new(&label))
}
fn custom_package_directives(pkg: &CustomPackage) -> Result<Vec<ConfigDirective>, RippyError> {
let mut directives = Vec::new();
if let Some(base_name) = &pkg.extends {
let base = Package::parse(base_name).map_err(|_| {
RippyError::Setup(format!(
"custom package \"{}\" extends unknown package \"{base_name}\" \
(only built-ins review, develop, autopilot may be extended)",
pkg.name
))
})?;
let base_source = base.toml_source();
let base_label = format!("(package:{})", base.name());
directives.extend(crate::toml_config::parse_toml_config(
base_source,
Path::new(&base_label),
)?);
}
directives.extend(crate::toml_config::parse_toml_config(
&pkg.toml_source,
&pkg.path,
)?);
Ok(directives)
}
#[must_use]
pub fn package_toml(package: &Package) -> &str {
package.toml_source()
}
impl std::fmt::Display for Package {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.name())
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::panic)]
mod tests {
use super::*;
use crate::config::Config;
use crate::verdict::Decision;
#[test]
fn review_toml_parses() {
let directives = package_directives(&Package::Review).unwrap();
assert!(
!directives.is_empty(),
"review package should produce directives"
);
}
#[test]
fn develop_toml_parses() {
let directives = package_directives(&Package::Develop).unwrap();
assert!(
!directives.is_empty(),
"develop package should produce directives"
);
}
#[test]
fn autopilot_toml_parses() {
let directives = package_directives(&Package::Autopilot).unwrap();
assert!(
!directives.is_empty(),
"autopilot package should produce directives"
);
}
#[test]
fn parse_valid_names() {
assert_eq!(Package::parse("review").unwrap(), Package::Review);
assert_eq!(Package::parse("develop").unwrap(), Package::Develop);
assert_eq!(Package::parse("autopilot").unwrap(), Package::Autopilot);
}
#[test]
fn parse_invalid_name_errors() {
let err = Package::parse("yolo").unwrap_err();
assert!(err.contains("unknown package"));
assert!(err.contains("yolo"));
}
#[test]
fn all_returns_three_packages() {
assert_eq!(Package::all().len(), 3);
}
#[test]
fn develop_allows_cargo_test() {
let config = Config::from_directives(package_directives(&Package::Develop).unwrap());
let v = config.match_command("cargo test", None);
assert!(v.is_some(), "develop package should match cargo test");
assert_eq!(v.unwrap().decision, Decision::Allow);
}
#[test]
fn develop_allows_file_ops() {
let config = Config::from_directives(package_directives(&Package::Develop).unwrap());
for cmd in &["rm foo.txt", "mv a b", "cp a b", "touch new.txt"] {
let v = config.match_command(cmd, None);
assert!(v.is_some(), "develop should match {cmd}");
assert_eq!(
v.unwrap().decision,
Decision::Allow,
"develop should allow {cmd}"
);
}
}
#[test]
fn autopilot_has_allow_default() {
let directives = package_directives(&Package::Autopilot).unwrap();
let has_default_allow = directives
.iter()
.any(|d| matches!(d, ConfigDirective::Set { key, value } if key == "default" && value == "allow"));
assert!(has_default_allow, "autopilot should set default = allow");
}
#[test]
fn review_has_no_extra_allow_rules() {
let directives = package_directives(&Package::Review).unwrap();
let allow_command_rules = directives.iter().filter(|d| {
matches!(d, ConfigDirective::Rule(r) if r.decision == Decision::Allow
&& !r.pattern.raw().starts_with("git"))
});
assert_eq!(
allow_command_rules.count(),
0,
"review should not add non-git allow rules"
);
}
#[test]
fn package_toml_not_empty() {
for pkg in Package::all() {
let toml = package_toml(pkg);
assert!(!toml.is_empty(), "{pkg} TOML should not be empty");
assert!(toml.contains("[meta]"), "{pkg} should have [meta] section");
}
}
#[test]
fn display_shows_name() {
assert_eq!(format!("{}", Package::Review), "review");
assert_eq!(format!("{}", Package::Develop), "develop");
assert_eq!(format!("{}", Package::Autopilot), "autopilot");
}
#[test]
fn shield_values_match_expected() {
assert_eq!(Package::Review.shield(), "===");
assert_eq!(Package::Develop.shield(), "==.");
assert_eq!(Package::Autopilot.shield(), "=..");
}
#[test]
fn tagline_values_not_empty() {
for pkg in Package::all() {
assert!(
!pkg.tagline().is_empty(),
"{pkg} tagline should not be empty"
);
}
}
#[test]
fn autopilot_denies_catastrophic_rm() {
let config = Config::from_directives(package_directives(&Package::Autopilot).unwrap());
for cmd in &["rm -rf /", "rm -rf ~"] {
let v = config.match_command(cmd, None);
assert!(v.is_some(), "autopilot should match {cmd}");
assert_eq!(
v.unwrap().decision,
Decision::Deny,
"autopilot should deny {cmd}"
);
}
}
use tempfile::tempdir;
fn write_custom(home: &Path, name: &str, body: &str) {
let dir = home.join(".rippy/packages");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join(format!("{name}.toml")), body).unwrap();
}
#[test]
fn resolve_builtin_without_home() {
let pkg = Package::resolve("develop", None).unwrap();
assert_eq!(pkg, Package::Develop);
}
#[test]
fn resolve_builtin_takes_priority_over_custom_with_same_name() {
let home = tempdir().unwrap();
write_custom(home.path(), "develop", "[meta]\ntagline = \"shadowed\"\n");
let pkg = Package::resolve("develop", Some(home.path())).unwrap();
assert_eq!(pkg, Package::Develop);
}
#[test]
fn resolve_custom_package_by_name() {
let home = tempdir().unwrap();
write_custom(
home.path(),
"corp",
"[meta]\nname = \"corp\"\ntagline = \"Corporate\"\n",
);
let pkg = Package::resolve("corp", Some(home.path())).unwrap();
match pkg {
Package::Custom(c) => {
assert_eq!(c.name, "corp");
assert_eq!(c.tagline, "Corporate");
}
_ => panic!("expected Custom variant"),
}
}
#[test]
fn resolve_unknown_errors_lists_known() {
let err = Package::resolve("bogus", None).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("bogus"), "error should mention name: {msg}");
assert!(
msg.contains("develop"),
"error should list built-ins: {msg}"
);
}
#[test]
fn resolve_unknown_errors_includes_custom() {
let home = tempdir().unwrap();
write_custom(home.path(), "extra", "[meta]\nname = \"extra\"\n");
let err = Package::resolve("bogus", Some(home.path())).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("extra"),
"error should list custom packages: {msg}"
);
}
#[test]
fn all_available_includes_custom_from_home() {
let home = tempdir().unwrap();
write_custom(home.path(), "corp", "[meta]\nname = \"corp\"\n");
write_custom(home.path(), "team", "[meta]\nname = \"team\"\n");
let all = Package::all_available(Some(home.path()));
let names: Vec<&str> = all.iter().map(Package::name).collect();
assert!(names.contains(&"review"));
assert!(names.contains(&"develop"));
assert!(names.contains(&"autopilot"));
assert!(names.contains(&"corp"));
assert!(names.contains(&"team"));
}
#[test]
fn all_available_filters_shadowed_custom() {
let home = tempdir().unwrap();
write_custom(home.path(), "develop", "[meta]\ntagline = \"shadowed\"\n");
let all = Package::all_available(Some(home.path()));
let develop_entries: Vec<&Package> = all.iter().filter(|p| p.name() == "develop").collect();
assert_eq!(develop_entries.len(), 1);
assert_eq!(develop_entries[0], &Package::Develop);
}
#[test]
fn all_builtin_returns_three() {
let all = Package::all_builtin();
assert_eq!(all.len(), 3);
assert_eq!(all[0], Package::Review);
assert_eq!(all[1], Package::Develop);
assert_eq!(all[2], Package::Autopilot);
}
#[test]
fn custom_extends_develop_inherits_directives() {
let home = tempdir().unwrap();
write_custom(
home.path(),
"team",
r#"
[meta]
name = "team"
extends = "develop"
[[rules]]
action = "deny"
pattern = "npm publish"
message = "team rule: no publishing"
"#,
);
let pkg = Package::resolve("team", Some(home.path())).unwrap();
let directives = package_directives(&pkg).unwrap();
let config = Config::from_directives(directives);
let v = config.match_command("cargo test", None);
assert!(v.is_some());
assert_eq!(v.unwrap().decision, Decision::Allow);
let v = config.match_command("npm publish", None);
assert!(v.is_some());
assert_eq!(v.unwrap().decision, Decision::Deny);
}
#[test]
fn custom_extends_unknown_package_errors() {
let home = tempdir().unwrap();
write_custom(
home.path(),
"bad",
"[meta]\nname = \"bad\"\nextends = \"nope\"\n",
);
let pkg = Package::resolve("bad", Some(home.path())).unwrap();
let err = package_directives(&pkg).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("nope"),
"error should mention extends target: {msg}"
);
}
#[test]
fn custom_extends_custom_rejected() {
let home = tempdir().unwrap();
write_custom(home.path(), "team", "[meta]\nname = \"team\"\n");
write_custom(
home.path(),
"derived",
"[meta]\nname = \"derived\"\nextends = \"team\"\n",
);
let pkg = Package::resolve("derived", Some(home.path())).unwrap();
let err = package_directives(&pkg).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("team"),
"error should mention the rejected base: {msg}"
);
}
#[test]
fn custom_without_extends_has_only_own_rules() {
let home = tempdir().unwrap();
write_custom(
home.path(),
"solo",
r#"
[meta]
name = "solo"
[[rules]]
action = "deny"
pattern = "rm -rf /"
"#,
);
let pkg = Package::resolve("solo", Some(home.path())).unwrap();
let directives = package_directives(&pkg).unwrap();
let config = Config::from_directives(directives);
let v = config.match_command("cargo test", None);
assert!(v.is_none(), "solo should not inherit develop's rules");
let v = config.match_command("rm -rf /", None);
assert!(v.is_some());
assert_eq!(v.unwrap().decision, Decision::Deny);
}
}