use std::collections::HashSet;
use serde::{Deserialize, Serialize};
use crate::error::{Error, Result};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase", deny_unknown_fields)]
pub enum Pillar {
Practical,
Foundational,
Investigative,
}
impl Pillar {
pub const ALL_IN_ORDER: [Pillar; 3] = [
Pillar::Practical,
Pillar::Foundational,
Pillar::Investigative,
];
pub fn header(&self) -> &'static str {
match self {
Pillar::Practical => "PRACTICAL",
Pillar::Foundational => "FOUNDATIONAL",
Pillar::Investigative => "INVESTIGATIVE",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IndexEntry {
pub name: String,
#[serde(default)]
pub owner: Option<String>,
pub pillar: Pillar,
pub summary: String,
pub repo_url: String,
pub repo_path: String,
pub current_version: String,
#[serde(default)]
pub sources: Vec<String>,
#[serde(default)]
pub schedule_guidance: Option<String>,
}
impl IndexEntry {
pub fn display_name(&self) -> String {
match &self.owner {
Some(owner) => format!("{}/{}", owner, self.name),
None => self.name.clone(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RegistryIndex {
pub version: u32,
#[serde(default)]
pub entries: Vec<IndexEntry>,
}
const SUPPORTED_INDEX_VERSION: u32 = 1;
impl RegistryIndex {
pub fn parse(yaml: &str) -> Result<Self> {
let raw: RegistryIndex =
serde_yaml::from_str(yaml).map_err(|e| Error::RegistryIndexParse {
detail: e.to_string(),
})?;
raw.validate()?;
Ok(raw)
}
fn validate(&self) -> Result<()> {
if self.version != SUPPORTED_INDEX_VERSION {
return Err(Error::RegistryIndexParse {
detail: format!(
"unsupported index version {} (expected {})",
self.version, SUPPORTED_INDEX_VERSION
),
});
}
let mut seen: HashSet<(Option<&str>, &str)> = HashSet::new();
for e in &self.entries {
let key = (e.owner.as_deref(), e.name.as_str());
if !seen.insert(key) {
return Err(Error::RegistryIndexParse {
detail: format!("duplicate entry '{}'", e.display_name()),
});
}
}
let canonical_names: HashSet<&str> = self
.entries
.iter()
.filter(|e| e.owner.is_none())
.map(|e| e.name.as_str())
.collect();
for e in &self.entries {
if e.owner.is_some() && canonical_names.contains(e.name.as_str()) {
return Err(Error::RegistryIndexParse {
detail: format!(
"contributor entry '{}' shadows canonical name '{}'",
e.display_name(),
e.name
),
});
}
}
Ok(())
}
pub fn find_canonical(&self, name: &str) -> Option<&IndexEntry> {
self.entries
.iter()
.find(|e| e.owner.is_none() && e.name == name)
}
pub fn find_contributor(&self, owner: &str, name: &str) -> Option<&IndexEntry> {
self.entries
.iter()
.find(|e| e.owner.as_deref() == Some(owner) && e.name == name)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn fixture_index() -> &'static str {
r#"
version: 1
entries:
- name: brewtrend
pillar: practical
summary: Homebrew analytics & trending packages
repo_url: https://github.com/meridian-online/registry
repo_path: practical/brewtrend
current_version: v1.0
sources:
- https://formulae.brew.sh/api/analytics/install/30d.json
schedule_guidance: daily
- name: gnaf
pillar: foundational
summary: Australian address gazetteer
repo_url: https://github.com/meridian-online/registry
repo_path: foundational/gnaf
current_version: v0.4
sources: []
- name: myproject
owner: someone
pillar: practical
summary: Personal example
repo_url: https://github.com/someone/myproject
repo_path: ""
current_version: v0.3
sources: []
"#
}
#[test]
fn test_parse_valid_index() {
let idx = RegistryIndex::parse(fixture_index()).expect("parse should succeed");
assert_eq!(idx.version, 1);
assert_eq!(idx.entries.len(), 3);
let brew = &idx.entries[0];
assert_eq!(brew.name, "brewtrend");
assert_eq!(brew.owner, None);
assert_eq!(brew.pillar, Pillar::Practical);
assert_eq!(brew.current_version, "v1.0");
assert_eq!(brew.display_name(), "brewtrend");
let mp = &idx.entries[2];
assert_eq!(mp.owner.as_deref(), Some("someone"));
assert_eq!(mp.display_name(), "someone/myproject");
}
#[test]
fn test_parses_all_pillars() {
let idx = RegistryIndex::parse(fixture_index()).unwrap();
let pillars: Vec<Pillar> = idx.entries.iter().map(|e| e.pillar).collect();
assert!(pillars.contains(&Pillar::Practical));
assert!(pillars.contains(&Pillar::Foundational));
}
#[test]
fn test_unsupported_version_rejects() {
let yaml = "version: 2\nentries: []\n";
let err = RegistryIndex::parse(yaml).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("version"), "should mention version: {msg}");
}
#[test]
fn test_duplicate_rejects() {
let yaml = r#"
version: 1
entries:
- name: brewtrend
pillar: practical
summary: a
repo_url: x
repo_path: x
current_version: v1
sources: []
- name: brewtrend
pillar: practical
summary: b
repo_url: x
repo_path: x
current_version: v2
sources: []
"#;
let err = RegistryIndex::parse(yaml).unwrap_err();
assert!(err.to_string().contains("duplicate"), "{err}");
}
#[test]
fn test_contributor_shadows_canonical_rejects() {
let yaml = r#"
version: 1
entries:
- name: brewtrend
pillar: practical
summary: canonical
repo_url: x
repo_path: x
current_version: v1
sources: []
- name: brewtrend
owner: someone
pillar: practical
summary: shadow
repo_url: x
repo_path: x
current_version: v1
sources: []
"#;
let err = RegistryIndex::parse(yaml).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("shadows"), "{msg}");
assert!(msg.contains("brewtrend"), "{msg}");
}
#[test]
fn test_unknown_pillar_rejects() {
let yaml = r#"
version: 1
entries:
- name: weird
pillar: educational
summary: x
repo_url: x
repo_path: x
current_version: v1
sources: []
"#;
let err = RegistryIndex::parse(yaml).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("educational") || msg.contains("pillar") || msg.contains("variant"),
"expected pillar/variant rejection: {msg}"
);
}
#[test]
fn test_pillar_headers() {
assert_eq!(Pillar::Practical.header(), "PRACTICAL");
assert_eq!(Pillar::Foundational.header(), "FOUNDATIONAL");
assert_eq!(Pillar::Investigative.header(), "INVESTIGATIVE");
}
}