use serde::{Deserialize, Serialize};
use time::OffsetDateTime;
use crate::id::WaveId;
use crate::repository::{CanonicalRepo, CanonicalRepoError};
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct WaveLocator {
repo: CanonicalRepo,
slug: String,
}
#[derive(Debug, thiserror::Error)]
pub enum WaveLocatorError {
#[error(transparent)]
Repository(#[from] CanonicalRepoError),
#[error("invalid Wave slug {0:?}")]
InvalidSlug(String),
}
impl WaveLocator {
pub fn discover(repo: &std::path::Path, slug: &str) -> Result<Self, WaveLocatorError> {
Self::new(CanonicalRepo::discover(repo)?, slug)
}
pub fn new(repo: CanonicalRepo, slug: &str) -> Result<Self, WaveLocatorError> {
let slug = crate::ops::util::normalize_wave_name(slug)
.ok_or_else(|| WaveLocatorError::InvalidSlug(slug.to_string()))?;
if slug.contains('\\')
|| slug
.split('/')
.any(|component| component.is_empty() || matches!(component, "." | ".."))
{
return Err(WaveLocatorError::InvalidSlug(slug));
}
Ok(Self { repo, slug })
}
pub fn repo(&self) -> &CanonicalRepo {
&self.repo
}
pub fn slug(&self) -> &str {
&self.slug
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct PromotionWake {
pub(crate) parent_wave_id: WaveId,
pub(crate) parent: String,
}
impl PromotionWake {
pub(crate) fn inbox_id(&self) -> String {
format!("promotion:{}", self.parent_wave_id)
}
pub(crate) fn prompt(&self) -> String {
format!(
"Promotion from parent Wave '{}' is complete. Begin the first child-Wave pass and report what this Wave now owns.",
self.parent
)
}
}
#[derive(Debug, Clone, Serialize)]
pub struct Wave {
id: WaveId,
name: String,
repo: String,
#[serde(with = "time::serde::rfc3339::option")]
created_at: Option<OffsetDateTime>,
parent_wave_id: Option<WaveId>,
#[serde(with = "time::serde::rfc3339::option")]
promoted_at: Option<OffsetDateTime>,
}
impl Wave {
pub fn new(id: WaveId, name: String, repo: String) -> Self {
Self {
id,
name,
repo,
created_at: Some(OffsetDateTime::now_utc()),
parent_wave_id: None,
promoted_at: None,
}
}
pub fn with_parent(mut self, parent: WaveId) -> Self {
self.parent_wave_id.get_or_insert(parent);
self
}
pub(crate) fn from_stored_parts(
id: WaveId,
name: String,
repo: String,
created_at: OffsetDateTime,
parent_wave_id: Option<WaveId>,
promoted_at: Option<OffsetDateTime>,
) -> Self {
Self {
id,
name,
repo,
created_at: Some(created_at),
parent_wave_id,
promoted_at,
}
}
pub(crate) fn record_promotion(
&mut self,
parent: &WaveId,
at: OffsetDateTime,
) -> Result<(), String> {
if self
.parent_wave_id
.as_ref()
.is_some_and(|current| current != parent)
{
return Err(format!(
"Wave '{}' already belongs to another parent",
self.name
));
}
self.parent_wave_id.get_or_insert_with(|| parent.clone());
self.promoted_at.get_or_insert(at);
Ok(())
}
pub fn id(&self) -> &WaveId {
&self.id
}
pub fn parent_wave_id(&self) -> Option<&WaveId> {
self.parent_wave_id.as_ref()
}
pub fn promoted_at(&self) -> Option<OffsetDateTime> {
self.promoted_at
}
pub fn name(&self) -> &str {
&self.name
}
pub fn repo(&self) -> &str {
&self.repo
}
pub fn created_at(&self) -> Option<OffsetDateTime> {
self.created_at
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reconstructing_identity_cannot_copy_or_reparent_a_promotion() {
let parent = WaveId::new();
let mut promoted = Wave::new(WaveId::new(), "ship".to_string(), "/repo".to_string());
promoted
.record_promotion(&parent, OffsetDateTime::now_utc())
.expect("record promotion");
let rebuilt = Wave::new(
promoted.id().clone(),
promoted.name().to_string(),
promoted.repo().to_string(),
);
assert_eq!(rebuilt.parent_wave_id(), None);
assert_eq!(rebuilt.promoted_at(), None);
let other_parent = WaveId::new();
let unchanged = promoted.with_parent(other_parent);
assert_eq!(unchanged.parent_wave_id(), Some(&parent));
assert!(unchanged.promoted_at().is_some());
}
#[test]
fn locator_slugs_allow_safe_nesting_but_never_path_traversal() {
let repo = crate::repository::CanonicalRepo::discover(
&std::env::current_dir().expect("current directory"),
)
.unwrap();
assert_eq!(
WaveLocator::new(repo.clone(), "goals/release")
.unwrap()
.slug(),
"goals/release"
);
for unsafe_slug in [
"../escape",
"goals/../escape",
"goals//release",
"goals\\release",
] {
assert!(WaveLocator::new(repo.clone(), unsafe_slug).is_err());
}
}
}