use std::path::PathBuf;
use sha2::{Digest, Sha256};
const DIGEST_CHARS: usize = 12;
pub fn digest(content: &str) -> String {
let hash = Sha256::digest(content.as_bytes());
let hex: String = hash.iter().map(|byte| format!("{:02x}", byte)).collect();
format!("sha256:{}", &hex[..DIGEST_CHARS])
}
#[derive(Debug, Clone, PartialEq)]
pub struct Provenance {
pub name: String,
pub version: Option<String>,
pub digest: String,
pub origin: PathBuf,
}
impl Provenance {
pub fn cite(&self) -> String {
match &self.version {
Some(version) => format!("{} v{} ({})", self.name, version, self.digest),
None => format!("{} ({})", self.name, self.digest),
}
}
}
#[derive(Debug, Clone, Default)]
pub struct Snapshot {
pub procyon: String,
pub skills: Vec<Provenance>,
pub mcp_servers: Vec<(String, String)>,
}
impl Snapshot {
pub fn to_toml(&self) -> String {
let mut out = format!("procyon = \"{}\"\n", self.procyon);
if self.skills.is_empty() {
out.push_str("\n# No skills were discovered.\n");
}
for skill in &self.skills {
out.push_str("\n[[skill]]\n");
out.push_str(&format!("name = \"{}\"\n", skill.name));
if let Some(version) = &skill.version {
out.push_str(&format!("version = \"{}\"\n", version));
}
out.push_str(&format!("digest = \"{}\"\n", skill.digest));
out.push_str(&format!("origin = \"{}\"\n", skill.origin.display()));
}
for (name, endpoint) in &self.mcp_servers {
out.push_str("\n[[mcp_server]]\n");
out.push_str(&format!("name = \"{}\"\n", name));
out.push_str(&format!("endpoint = \"{}\"\n", endpoint));
}
out
}
}
pub async fn snapshot(cfg: &crate::config::AppConfig) -> Snapshot {
let skills = crate::registries::skills().await;
let mut provenances: Vec<Provenance> = skills.all().iter().map(|s| s.provenance()).collect();
provenances.sort_by(|a, b| a.name.cmp(&b.name));
Snapshot {
procyon: env!("CARGO_PKG_VERSION").to_string(),
skills: provenances,
mcp_servers: cfg
.mcp_servers
.iter()
.map(|server| (server.name.clone(), server.endpoint_label()))
.collect(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_digest_names_its_algorithm_and_is_stable() {
let first = digest("# Soroban\n\nStorage has three durabilities.");
assert_eq!(
first,
digest("# Soroban\n\nStorage has three durabilities.")
);
assert!(first.starts_with("sha256:"), "got {}", first);
}
#[test]
fn different_content_digests_differently() {
assert_ne!(digest("a"), digest("b"));
assert_ne!(digest("use testnet"), digest("use mainnet"));
}
#[test]
fn a_citation_carries_the_version_when_the_author_stated_one() {
let provenance = Provenance {
name: "soroban".to_string(),
version: Some("2.1.0".to_string()),
digest: "sha256:abc123abc123".to_string(),
origin: PathBuf::from("/skills/soroban"),
};
assert_eq!(provenance.cite(), "soroban v2.1.0 (sha256:abc123abc123)");
}
#[test]
fn a_citation_omits_a_version_nobody_declared() {
let provenance = Provenance {
name: "soroban".to_string(),
version: None,
digest: "sha256:abc123abc123".to_string(),
origin: PathBuf::from("/skills/soroban"),
};
assert_eq!(provenance.cite(), "soroban (sha256:abc123abc123)");
}
#[test]
fn a_snapshot_records_what_was_loaded_and_where_from() {
let snapshot = Snapshot {
procyon: "0.1.0".to_string(),
skills: vec![Provenance {
name: "soroban".to_string(),
version: Some("2.1.0".to_string()),
digest: "sha256:abc123abc123".to_string(),
origin: PathBuf::from("/skills/soroban"),
}],
mcp_servers: vec![("raven".to_string(), "https://raven/mcp".to_string())],
};
let toml = snapshot.to_toml();
for expected in [
"procyon = \"0.1.0\"",
"name = \"soroban\"",
"version = \"2.1.0\"",
"digest = \"sha256:abc123abc123\"",
"origin = \"/skills/soroban\"",
"endpoint = \"https://raven/mcp\"",
] {
assert!(
toml.contains(expected),
"{:?} missing from\n{}",
expected,
toml
);
}
}
#[test]
fn a_snapshot_is_valid_toml() {
let snapshot = Snapshot {
procyon: "0.1.0".to_string(),
skills: vec![Provenance {
name: "soroban".to_string(),
version: None,
digest: digest("body"),
origin: PathBuf::from("/skills/soroban"),
}],
mcp_servers: vec![("raven".to_string(), "https://raven/mcp".to_string())],
};
let parsed: toml::Value = snapshot.to_toml().parse().expect("snapshot must parse");
assert_eq!(parsed["skill"][0]["name"].as_str(), Some("soroban"));
assert!(parsed["skill"][0].get("version").is_none());
assert_eq!(parsed["mcp_server"][0]["name"].as_str(), Some("raven"));
}
#[test]
fn an_empty_snapshot_still_parses_and_says_so() {
let toml = Snapshot::default().to_toml();
assert!(toml.contains("No skills were discovered"));
toml.parse::<toml::Value>().expect("must parse");
}
}