const PROVENANCE_EMAIL_DOMAIN: &str = "memstead.io";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Actor {
Agent,
Cli,
App,
External,
Unknown,
}
impl Actor {
pub fn as_trailer(&self) -> &'static str {
match self {
Actor::Agent => "agent",
Actor::Cli => "cli",
Actor::App => "app",
Actor::External => "external",
Actor::Unknown => "unknown",
}
}
pub fn from_trailer(s: &str) -> Option<Self> {
match s {
"agent" => Some(Actor::Agent),
"cli" => Some(Actor::Cli),
"app" => Some(Actor::App),
"external" => Some(Actor::External),
"unknown" => Some(Actor::Unknown),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ClientId {
pub name: String,
pub version: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Role {
Author,
Checker,
Verifier,
#[default]
Unspecified,
}
impl Role {
pub const DECLARABLE: &'static [&'static str] = &["author", "checker", "verifier"];
pub fn as_trailer(&self) -> Option<&'static str> {
match self {
Role::Author => Some("author"),
Role::Checker => Some("checker"),
Role::Verifier => Some("verifier"),
Role::Unspecified => None,
}
}
pub fn from_wire(s: &str) -> Option<Self> {
match s {
"author" => Some(Role::Author),
"checker" => Some(Role::Checker),
"verifier" => Some(Role::Verifier),
_ => None,
}
}
}
#[derive(Debug, Clone)]
pub struct CommitContext<'a> {
pub actor: Actor,
pub client: Option<ClientId>,
pub tool: Option<&'a str>,
pub note: Option<String>,
pub role: Role,
pub logical_operation_id: Option<&'a str>,
pub entity_ids: Option<Vec<String>>,
}
impl<'a> CommitContext<'a> {
pub fn internal() -> Self {
Self {
actor: Actor::Unknown,
client: None,
tool: None,
note: None,
role: Role::Unspecified,
logical_operation_id: None,
entity_ids: None,
}
}
}
pub fn parse_client_id(s: &str) -> Option<ClientId> {
let (name, version) = s.rsplit_once('@')?;
if name.is_empty() || version.is_empty() {
return None;
}
Some(ClientId {
name: name.to_string(),
version: version.to_string(),
})
}
pub fn sanitise_client_name(raw: &str) -> String {
let mut out = String::with_capacity(raw.len());
for ch in raw.chars() {
let lower = ch.to_ascii_lowercase();
if lower.is_ascii_alphanumeric() || matches!(lower, '.' | '_' | '-') {
out.push(lower);
} else {
out.push('-');
}
}
if out.chars().all(|c| c == '-' || c.is_whitespace()) {
return "unknown".to_string();
}
out
}
pub fn author_identity(ctx: &CommitContext<'_>) -> Option<(String, String)> {
match (ctx.actor, ctx.client.as_ref()) {
(Actor::Agent | Actor::Cli | Actor::App, Some(c)) => {
let local = sanitise_client_name(&c.name);
let email = format!("{local}@{PROVENANCE_EMAIL_DOMAIN}");
Some((local, email))
}
(Actor::External, _) => Some((
"external".to_string(),
format!("external@{PROVENANCE_EMAIL_DOMAIN}"),
)),
_ => None,
}
}
pub fn format_commit_message(prose: &str, ctx: &CommitContext<'_>) -> String {
let trimmed = prose.trim_end_matches('\n');
let mut trailers: Vec<String> = Vec::with_capacity(4);
if let Some(tool) = ctx.tool {
trailers.push(format!("Tool: {tool}"));
}
trailers.push(format!("Actor: {}", ctx.actor.as_trailer()));
if let Some(c) = ctx.client.as_ref() {
trailers.push(format!("Client: {}@{}", c.name, c.version));
}
if let Some(role) = ctx.role.as_trailer() {
trailers.push(format!("Role: {role}"));
}
if let Some(id) = ctx.logical_operation_id {
trailers.push(format!("Logical-Op: {id}"));
}
if let Some(ids) = ctx.entity_ids.as_ref().filter(|v| !v.is_empty()) {
trailers.push(format!("Entities: {}", ids.join(", ")));
}
let note_body = ctx.note.as_deref().map(str::trim).filter(|n| !n.is_empty());
match note_body {
Some(note) => format!("{trimmed}\n\n{note}\n\n{}", trailers.join("\n")),
None => format!("{trimmed}\n\n{}", trailers.join("\n")),
}
}