use serde::{Deserialize, Serialize};
use punch_types::PunchResult;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IndexEntry {
pub name: String,
pub version: String,
pub checksum: String,
pub signature: String,
pub public_key: String,
pub source_url: String,
pub scan_result: ScanVerdict,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IndexMeta {
pub name: String,
pub versions: Vec<IndexEntry>,
pub install_count: u64,
pub rating: f64,
pub report_count: u64,
pub yanked: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum ScanVerdict {
Clean,
Warning(Vec<ScanFinding>),
Rejected(Vec<ScanFinding>),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ScanFinding {
pub severity: String,
pub pattern: String,
pub line: usize,
pub description: String,
}
pub fn validate_skill_name(name: &str) -> PunchResult<()> {
if name.len() < 3 {
return Err(punch_types::PunchError::Config(format!(
"skill name '{}' is too short (minimum 3 characters)",
name
)));
}
if name.len() > 64 {
return Err(punch_types::PunchError::Config(format!(
"skill name '{}' is too long (maximum 64 characters)",
name
)));
}
if !name
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
{
return Err(punch_types::PunchError::Config(format!(
"skill name '{}' contains invalid characters (only lowercase alphanumeric and hyphens allowed)",
name
)));
}
if !name
.chars()
.next()
.is_some_and(|c| c.is_ascii_alphanumeric())
{
return Err(punch_types::PunchError::Config(format!(
"skill name '{}' must start with a letter or digit",
name
)));
}
if name.ends_with('-') {
return Err(punch_types::PunchError::Config(format!(
"skill name '{}' must not end with a hyphen",
name
)));
}
if name.contains("--") {
return Err(punch_types::PunchError::Config(format!(
"skill name '{}' must not contain consecutive hyphens",
name
)));
}
Ok(())
}
pub fn index_path_for_name(name: &str) -> String {
match name.len() {
0 => String::new(),
1 => format!("1/{}", name),
2 => format!("2/{}", name),
3 => format!("3/{}/{}", &name[..1], name),
_ => format!("{}/{}", &name[..2], name),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_validate_skill_name_valid() {
assert!(validate_skill_name("code-reviewer").is_ok());
assert!(validate_skill_name("abc").is_ok());
assert!(validate_skill_name("my-awesome-skill").is_ok());
assert!(validate_skill_name("a1b2c3").is_ok());
assert!(validate_skill_name("skill123").is_ok());
}
#[test]
fn test_validate_skill_name_too_short() {
assert!(validate_skill_name("ab").is_err());
assert!(validate_skill_name("a").is_err());
assert!(validate_skill_name("").is_err());
}
#[test]
fn test_validate_skill_name_too_long() {
let long_name = "a".repeat(65);
assert!(validate_skill_name(&long_name).is_err());
let max_name = "a".repeat(64);
assert!(validate_skill_name(&max_name).is_ok());
}
#[test]
fn test_validate_skill_name_invalid_chars() {
assert!(validate_skill_name("Code-Reviewer").is_err()); assert!(validate_skill_name("my_skill").is_err()); assert!(validate_skill_name("my skill").is_err()); assert!(validate_skill_name("my.skill").is_err()); }
#[test]
fn test_validate_skill_name_must_start_alphanumeric() {
assert!(validate_skill_name("-bad-start").is_err());
}
#[test]
fn test_validate_skill_name_must_not_end_hyphen() {
assert!(validate_skill_name("bad-end-").is_err());
}
#[test]
fn test_validate_skill_name_no_consecutive_hyphens() {
assert!(validate_skill_name("bad--name").is_err());
}
#[test]
fn test_index_path_for_name() {
assert_eq!(index_path_for_name("code-reviewer"), "co/code-reviewer");
assert_eq!(index_path_for_name("abc"), "3/a/abc");
assert_eq!(index_path_for_name("ab"), "2/ab");
assert_eq!(index_path_for_name("a"), "1/a");
assert_eq!(index_path_for_name("my-tool"), "my/my-tool");
}
#[test]
fn test_scan_verdict_serde() {
let clean = ScanVerdict::Clean;
let json = serde_json::to_string(&clean).unwrap();
let restored: ScanVerdict = serde_json::from_str(&json).unwrap();
assert_eq!(restored, ScanVerdict::Clean);
}
#[test]
fn test_scan_finding_serde() {
let finding = ScanFinding {
severity: "critical".to_string(),
pattern: "pipe_to_shell".to_string(),
line: 42,
description: "curl piped to bash detected".to_string(),
};
let json = serde_json::to_string(&finding).unwrap();
let restored: ScanFinding = serde_json::from_str(&json).unwrap();
assert_eq!(restored, finding);
}
#[test]
fn test_scan_verdict_warning_serde() {
let findings = vec![ScanFinding {
severity: "warning".to_string(),
pattern: "sudo_usage".to_string(),
line: 10,
description: "sudo command detected".to_string(),
}];
let verdict = ScanVerdict::Warning(findings.clone());
let json = serde_json::to_string(&verdict).unwrap();
let restored: ScanVerdict = serde_json::from_str(&json).unwrap();
assert_eq!(restored, ScanVerdict::Warning(findings));
}
#[test]
fn test_index_entry_serde() {
let entry = IndexEntry {
name: "code-reviewer".to_string(),
version: "1.0.0".to_string(),
checksum: "abcd1234".to_string(),
signature: "deadbeef".to_string(),
public_key: "cafebabe".to_string(),
source_url: "https://example.com/skill.tar.gz".to_string(),
scan_result: ScanVerdict::Clean,
};
let json = serde_json::to_string(&entry).unwrap();
let restored: IndexEntry = serde_json::from_str(&json).unwrap();
assert_eq!(restored.name, "code-reviewer");
assert_eq!(restored.version, "1.0.0");
assert_eq!(restored.scan_result, ScanVerdict::Clean);
}
#[test]
fn test_index_meta_serde() {
let meta = IndexMeta {
name: "test-skill".to_string(),
versions: vec![],
install_count: 42,
rating: 4.5,
report_count: 0,
yanked: false,
};
let json = serde_json::to_string(&meta).unwrap();
let restored: IndexMeta = serde_json::from_str(&json).unwrap();
assert_eq!(restored.name, "test-skill");
assert_eq!(restored.install_count, 42);
assert!(!restored.yanked);
}
}