use std::fmt;
use serde::{Deserialize, Serialize};
use time::OffsetDateTime;
use uuid::Uuid;
use crate::error::MetadataError;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct VarId(Uuid);
impl VarId {
#[must_use]
pub fn new_v4() -> Self {
Self(Uuid::new_v4())
}
#[must_use]
pub const fn from_uuid(id: Uuid) -> Self {
Self(id)
}
#[must_use]
pub const fn as_uuid(&self) -> &Uuid {
&self.0
}
}
impl fmt::Display for VarId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
fmt::Display::fmt(&self.0, f)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Group {
User,
System,
Project,
Custom(String),
}
impl Group {
#[must_use]
pub const fn as_str(&self) -> &str {
match self {
Self::User => "user",
Self::System => "system",
Self::Project => "project",
Self::Custom(s) => s.as_str(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum VarKind {
Secret,
Plain,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Var {
id: VarId,
name: String,
group: Group,
kind: VarKind,
tags: Vec<String>,
length: usize,
#[serde(with = "time::serde::rfc3339")]
created_at: OffsetDateTime,
#[serde(with = "time::serde::rfc3339")]
updated_at: OffsetDateTime,
}
impl Var {
pub fn new(name: impl Into<String>, group: Group, kind: VarKind) -> Self {
let now = OffsetDateTime::now_utc();
Self {
id: VarId::new_v4(),
name: name.into(),
group,
kind,
tags: Vec::new(),
length: 0,
created_at: now,
updated_at: now,
}
}
pub fn try_new(
name: impl Into<String>,
group: Group,
kind: VarKind,
) -> Result<Self, MetadataError> {
let name = name.into();
Self::validate_name(&name)?;
Ok(Self::new(name, group, kind))
}
#[must_use]
#[allow(clippy::too_many_arguments)]
pub const fn from_trusted_parts(
id: VarId,
name: String,
group: Group,
kind: VarKind,
tags: Vec<String>,
length: usize,
created_at: OffsetDateTime,
updated_at: OffsetDateTime,
) -> Self {
Self {
id,
name,
group,
kind,
tags,
length,
created_at,
updated_at,
}
}
#[allow(clippy::too_many_arguments)]
pub fn try_from_parts(
id: VarId,
name: String,
group: Group,
kind: VarKind,
tags: Vec<String>,
length: usize,
created_at: OffsetDateTime,
updated_at: OffsetDateTime,
) -> Result<Self, MetadataError> {
Self::validate_name(&name)?;
Ok(Self::from_trusted_parts(
id, name, group, kind, tags, length, created_at, updated_at,
))
}
pub fn validate_name(candidate: &str) -> Result<&str, MetadataError> {
if candidate.is_empty() {
return Err(MetadataError::Invalid("name is empty".into()));
}
if candidate.len() > 64 {
return Err(MetadataError::Invalid(
"name is longer than 64 characters".into(),
));
}
let bytes = candidate.as_bytes();
let first = bytes[0];
if !(first.is_ascii_alphabetic() || first == b'_') {
return Err(MetadataError::Invalid(
"name must start with an ASCII letter or underscore".into(),
));
}
for (offset, &b) in bytes.iter().enumerate().skip(1) {
if !(b.is_ascii_alphanumeric() || b == b'_') {
return Err(MetadataError::Invalid(format!(
"name contains an invalid character at byte offset {offset}"
)));
}
}
Ok(candidate)
}
#[must_use]
pub const fn id(&self) -> VarId {
self.id
}
#[must_use]
pub fn name(&self) -> &str {
&self.name
}
#[must_use]
pub const fn group(&self) -> &Group {
&self.group
}
#[must_use]
pub const fn kind(&self) -> VarKind {
self.kind
}
#[must_use]
pub fn tags(&self) -> &[String] {
&self.tags
}
pub fn set_tags(&mut self, tags: Vec<String>) {
self.tags = tags;
self.touch();
}
#[must_use]
pub const fn length(&self) -> usize {
self.length
}
pub fn set_length(&mut self, length: usize) {
self.length = length;
self.touch();
}
#[must_use]
pub const fn created_at(&self) -> OffsetDateTime {
self.created_at
}
#[must_use]
pub const fn updated_at(&self) -> OffsetDateTime {
self.updated_at
}
fn touch(&mut self) {
self.updated_at = OffsetDateTime::now_utc();
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct VarFilter {
pub group: Option<Group>,
pub name_contains: Option<String>,
pub kind: Option<VarKind>,
pub tags_all: Vec<String>,
}
impl VarFilter {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn matches(&self, var: &Var) -> bool {
if let Some(group) = &self.group {
if var.group() != group {
return false;
}
}
if let Some(needle) = &self.name_contains {
let hay = var.name().to_ascii_lowercase();
let needle = needle.to_ascii_lowercase();
if !hay.contains(&needle) {
return false;
}
}
if let Some(kind) = self.kind {
if var.kind() != kind {
return false;
}
}
for tag in &self.tags_all {
if !var.tags().iter().any(|t| t == tag) {
return false;
}
}
true
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn new_var_has_fresh_id_and_equal_timestamps() {
let v = Var::new("FOO", Group::User, VarKind::Plain);
assert_eq!(v.name(), "FOO");
assert_eq!(v.created_at(), v.updated_at());
}
#[test]
fn touch_bumps_updated_at() {
let mut v = Var::new("FOO", Group::User, VarKind::Plain);
let original = v.updated_at();
std::thread::sleep(std::time::Duration::from_millis(2));
v.set_length(5);
assert!(v.updated_at() > original);
}
#[test]
fn validate_name_accepts_typical_names() {
for name in ["FOO", "_FOO", "DB_URL_2", "x"] {
assert!(Var::validate_name(name).is_ok(), "expected {name} valid");
}
}
#[test]
fn validate_name_rejects_invalid_inputs() {
let bad = ["", "1FOO", "FOO-BAR", "FOO BAR", &"A".repeat(65)];
for name in bad {
assert!(
Var::validate_name(name).is_err(),
"expected {name:?} invalid"
);
}
}
#[test]
fn try_from_parts_validates_name() {
let now = time::OffsetDateTime::now_utc();
let bad = Var::try_from_parts(
VarId::new_v4(),
"1BAD".to_owned(),
Group::User,
VarKind::Plain,
Vec::new(),
0,
now,
now,
);
assert!(bad.is_err(), "try_from_parts should reject invalid names");
}
#[test]
fn try_from_parts_round_trips_a_valid_var() {
let now = time::OffsetDateTime::now_utc();
let id = VarId::new_v4();
let var = Var::try_from_parts(
id,
"DATABASE_URL".to_owned(),
Group::Project,
VarKind::Secret,
vec!["db".into()],
12,
now,
now,
)
.expect("valid var");
assert_eq!(var.id(), id);
assert_eq!(var.name(), "DATABASE_URL");
assert_eq!(var.tags(), &["db".to_owned()]);
assert_eq!(var.length(), 12);
}
#[test]
fn var_filter_default_matches_everything() {
let f = VarFilter::new();
let v = Var::new("FOO", Group::Project, VarKind::Secret);
assert!(f.matches(&v));
}
#[test]
fn var_filter_combines_criteria() {
let v = {
let mut v = Var::new("DATABASE_URL", Group::Project, VarKind::Secret);
v.set_tags(vec!["db".into(), "prod".into()]);
v
};
let f = VarFilter {
group: Some(Group::Project),
name_contains: Some("data".into()),
kind: Some(VarKind::Secret),
tags_all: vec!["db".into()],
};
assert!(f.matches(&v));
let f = VarFilter {
kind: Some(VarKind::Plain),
..VarFilter::default()
};
assert!(!f.matches(&v));
}
#[test]
fn group_as_str_uses_canonical_names() {
assert_eq!(Group::User.as_str(), "user");
assert_eq!(Group::System.as_str(), "system");
assert_eq!(Group::Project.as_str(), "project");
assert_eq!(Group::Custom("aws".into()).as_str(), "aws");
}
#[test]
fn var_id_display_matches_uuid() {
let id = VarId::new_v4();
assert_eq!(id.to_string(), id.as_uuid().to_string());
}
}