use std::collections::{BTreeMap, BTreeSet};
use wyrd_spec::{
envelope::Card,
error::WyrdError,
reference::CardRef,
registry::{ArtifactInventoryResponse, GetCardResponse},
};
use super::CardGraphPuller;
use crate::cards::{CardSelector, engine::RegistryEngine, reads};
#[derive(Debug)]
pub(super) struct ResolvedCard {
pub(super) card_ref: CardRef,
pub(super) card: Card,
pub(super) inventory: ArtifactInventoryResponse,
pub(super) aliases: BTreeSet<String>,
}
#[derive(Debug)]
pub(super) struct ResolvedGraph {
pub(super) root: CardRef,
pub(super) cards: Vec<ResolvedCard>,
}
enum GraphVisit {
Enter(GraphVisitEntry),
Exit(String),
}
struct GraphVisitEntry {
card_ref: CardRef,
alias: String,
}
#[derive(Clone, Copy, Eq, PartialEq)]
enum GraphVisitState {
Visiting,
Resolved,
}
struct LoadedCard {
card_ref: CardRef,
card: Card,
inventory: ArtifactInventoryResponse,
alias: String,
}
struct GraphTraversal<'a> {
engine: &'a RegistryEngine,
pending: Vec<GraphVisit>,
states: BTreeMap<String, GraphVisitState>,
aliases: BTreeMap<String, String>,
nodes: BTreeMap<String, ResolvedCard>,
preloaded: BTreeMap<String, GetCardResponse>,
}
impl<'a> GraphTraversal<'a> {
fn new(engine: &'a RegistryEngine) -> Self {
Self {
engine,
pending: Vec::new(),
states: BTreeMap::new(),
aliases: BTreeMap::new(),
nodes: BTreeMap::new(),
preloaded: BTreeMap::new(),
}
}
fn enter_loaded(&mut self, card_ref: CardRef, alias: String, response: GetCardResponse) {
self.preloaded.insert(card_ref.to_string(), response);
self.pending
.push(GraphVisit::Enter(GraphVisitEntry { card_ref, alias }));
}
async fn run(&mut self) -> Result<(), WyrdError> {
while let Some(visit) = self.next() {
self.process(visit).await?;
}
Ok(())
}
fn next(&mut self) -> Option<GraphVisit> {
self.pending.pop()
}
async fn process(&mut self, visit: GraphVisit) -> Result<(), WyrdError> {
match visit {
GraphVisit::Exit(key) => {
self.states.insert(key, GraphVisitState::Resolved);
Ok(())
}
GraphVisit::Enter(entry) => self.process_entry(entry).await,
}
}
async fn process_entry(&mut self, entry: GraphVisitEntry) -> Result<(), WyrdError> {
let key = entry.card_ref.to_string();
register_alias(&mut self.aliases, &entry.alias, &key)?;
match self.states.get(&key) {
Some(GraphVisitState::Visiting) => {
return Err(graph_error(
"card relationship graph contains a cycle",
&entry.card_ref,
));
}
Some(GraphVisitState::Resolved) => {
if let Some(node) = self.nodes.get_mut(&key) {
node.aliases.insert(entry.alias);
}
return Ok(());
}
None => {
self.states.insert(key.clone(), GraphVisitState::Visiting);
}
}
let loaded = self.load_card(entry).await?;
validate_relationships(&loaded.card, &loaded.card_ref)?;
self.pending.push(GraphVisit::Exit(key.clone()));
self.schedule_relationships(&loaded.card)?;
self.insert_node(key, loaded);
Ok(())
}
async fn load_card(&mut self, entry: GraphVisitEntry) -> Result<LoadedCard, WyrdError> {
let response = match self.preloaded.remove(&entry.card_ref.to_string()) {
Some(response) => response,
None => reads::get_response(
&self.engine.client,
&CardSelector::exact(entry.card_ref.clone()),
)
.await
.map_err(WyrdError::from)?,
};
let resolved = reads::card_ref_from_card(&response.card).map_err(WyrdError::from)?;
if resolved != entry.card_ref {
return Err(graph_error(
"related Card response did not match its exact reference",
&entry.card_ref,
));
}
let uid = entry.card_ref.uid.as_ref().ok_or_else(|| {
graph_error(
"pull requires UID-bearing relationship references",
&entry.card_ref,
)
})?;
let inventory = reads::list_artifacts(&self.engine.client, uid)
.await
.map_err(WyrdError::from)?;
Ok(LoadedCard {
card_ref: entry.card_ref,
card: response.card,
inventory,
alias: entry.alias,
})
}
fn schedule_relationships(&mut self, card: &Card) -> Result<(), WyrdError> {
for relationship in card.relationships.outbound_refs.iter().rev() {
let child = relationship.card_ref.clone();
let child_alias = relationship
.alias
.clone()
.unwrap_or_else(|| default_alias(&child));
validate_alias(&child_alias)?;
let child_key = child.to_string();
register_alias(&mut self.aliases, &child_alias, &child_key)?;
match self.states.get(&child_key) {
Some(GraphVisitState::Visiting) => {
return Err(graph_error(
"card relationship graph contains a cycle",
&child,
));
}
Some(GraphVisitState::Resolved) => {
if let Some(existing) = self.nodes.get_mut(&child_key) {
existing.aliases.insert(child_alias);
}
}
None => self.pending.push(GraphVisit::Enter(GraphVisitEntry {
card_ref: child,
alias: child_alias,
})),
}
}
Ok(())
}
fn insert_node(&mut self, key: String, loaded: LoadedCard) {
self.nodes.insert(
key,
ResolvedCard {
card_ref: loaded.card_ref,
card: loaded.card,
inventory: loaded.inventory,
aliases: BTreeSet::from([loaded.alias]),
},
);
}
fn finish(self) -> Vec<ResolvedCard> {
self.nodes.into_values().collect()
}
}
impl CardGraphPuller {
pub(super) async fn resolve_graph(
&self,
selector: &CardSelector,
) -> Result<ResolvedGraph, WyrdError> {
let (root, root_response) = self.load_root(selector).await?;
let mut traversal = GraphTraversal::new(&self.context.engine);
traversal.enter_loaded(root.clone(), String::from("root"), root_response);
traversal.run().await?;
Ok(ResolvedGraph {
root,
cards: traversal.finish(),
})
}
async fn load_root(
&self,
selector: &CardSelector,
) -> Result<(CardRef, GetCardResponse), WyrdError> {
let exact_selector = self.resolve_root_selector(selector).await?;
let response = reads::get_response(&self.context.engine.client, &exact_selector)
.await
.map_err(WyrdError::from)?;
let card_ref = reads::card_ref_from_card(&response.card).map_err(WyrdError::from)?;
Ok((card_ref, response))
}
async fn resolve_root_selector(
&self,
selector: &CardSelector,
) -> Result<CardSelector, WyrdError> {
match selector {
CardSelector::Named { version: None, .. } => {
let response = reads::get_response(&self.context.engine.client, selector)
.await
.map_err(WyrdError::from)?;
reads::card_ref_from_card(&response.card)
.map(CardSelector::exact)
.map_err(WyrdError::from)
}
_ => Ok(selector.clone()),
}
}
}
fn validate_relationships(card: &Card, card_ref: &CardRef) -> Result<(), WyrdError> {
if !card.relationships.outbound.is_empty() && card.relationships.outbound_refs.is_empty() {
return Err(graph_error(
"server returned untyped outbound relationships; graph closure is unsafe",
card_ref,
));
}
Ok(())
}
fn register_alias(
aliases: &mut BTreeMap<String, String>,
alias: &str,
card_key: &str,
) -> Result<(), WyrdError> {
validate_alias(alias)?;
if let Some(existing) = aliases.get(alias) {
if existing != card_key {
return Err(WyrdError::RegistryInvalidCardSpec {
message: "relationship aliases identify conflicting Cards".to_owned(),
details: serde_json::json!({ "alias": alias }),
});
}
} else {
aliases.insert(alias.to_owned(), card_key.to_owned());
}
Ok(())
}
pub(super) fn validate_alias(alias: &str) -> Result<(), WyrdError> {
if alias.is_empty()
|| alias == "."
|| alias == ".."
|| alias.contains('/')
|| alias.contains('\\')
|| alias.bytes().any(
|byte| !matches!(byte, b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'.' | b'_' | b'-'),
)
{
return Err(WyrdError::RegistryInvalidCardSpec {
message: "relationship alias is not a safe bundle path".to_owned(),
details: serde_json::json!({ "alias": alias }),
});
}
Ok(())
}
fn default_alias(card_ref: &CardRef) -> String {
format!(
"{}-{}-{}-{}",
card_ref
.space
.as_ref()
.map_or("default", |space| space.as_str()),
card_ref.kind.wire_name(),
card_ref.name,
card_ref.version
)
}
pub(super) fn graph_error(message: &str, card_ref: &CardRef) -> WyrdError {
WyrdError::RegistryInvalidCardSpec {
message: message.to_owned(),
details: serde_json::json!({ "card_ref": card_ref }),
}
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use wyrd_semver::VersionBlock;
use wyrd_spec::{
envelope::CardKind,
ids::{CardName, SpaceName},
reference::CardRef,
};
use super::{default_alias, register_alias, validate_alias};
fn test_card_ref() -> CardRef {
CardRef {
kind: CardKind::Prompt,
name: CardName::new("welcome").expect("test name is valid"),
version: VersionBlock::parse("1.0.0").expect("test version is valid"),
space: Some(SpaceName::new("default").expect("test space is valid")),
uid: None,
}
}
#[test]
fn aliases_are_path_safe() {
assert!(validate_alias("agent_one").is_ok());
assert!(validate_alias("../escape").is_err());
assert!(validate_alias("nested/name").is_err());
}
#[test]
fn default_alias_contains_exact_identity_without_uid() {
assert_eq!(
default_alias(&test_card_ref()),
"default-Prompt-welcome-1.0.0"
);
}
#[test]
fn aliases_cannot_point_at_two_exact_cards() {
let mut aliases = BTreeMap::new();
register_alias(&mut aliases, "shared", "first").expect("first alias registers");
assert!(register_alias(&mut aliases, "shared", "second").is_err());
register_alias(&mut aliases, "shared", "first").expect("same alias remains valid");
}
}