use chrono::Utc;
use regex::Regex;
use serde_json::json;
use std::sync::OnceLock;
use crate::intent_graph::{
DagStage, Edge, Meta, Node, NodeId, NodeType, RelationshipType, Status,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ExtendedNodeType {
Story,
Bug,
Metric,
Hypothesis,
}
impl ExtendedNodeType {
#[must_use]
pub fn as_token(self) -> &'static str {
match self {
Self::Story => "Story",
Self::Bug => "Bug",
Self::Metric => "Metric",
Self::Hypothesis => "Hypothesis",
}
}
#[must_use]
pub fn as_core_node_type(self) -> NodeType {
match self {
Self::Story => NodeType::Story,
Self::Bug => NodeType::Bug,
Self::Metric => NodeType::Artifact,
Self::Hypothesis => NodeType::Intent,
}
}
#[must_use]
pub fn dag_stage(self) -> DagStage {
match self {
Self::Story => DagStage::Feature,
Self::Bug => DagStage::Task,
Self::Metric | Self::Hypothesis => DagStage::Intent,
}
}
}
impl std::fmt::Display for ExtendedNodeType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_token())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ExtendedRelationshipType {
DecomposesInto,
Mitigates,
Validates,
}
impl ExtendedRelationshipType {
pub const PROP_KEY: &'static str = "relationship_type_extended";
#[must_use]
pub fn as_kebab(self) -> &'static str {
match self {
Self::DecomposesInto => "decomposes-into",
Self::Mitigates => "mitigates",
Self::Validates => "validates",
}
}
#[must_use]
pub fn as_core_relationship_type(self) -> RelationshipType {
match self {
Self::DecomposesInto => RelationshipType::TracesTo,
Self::Mitigates => RelationshipType::DependsOn,
Self::Validates => RelationshipType::Covers,
}
}
}
impl std::str::FromStr for ExtendedRelationshipType {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"decomposes-into" => Ok(Self::DecomposesInto),
"mitigates" => Ok(Self::Mitigates),
"validates" => Ok(Self::Validates),
other => Err(format!("unknown extended relationship type: {other}")),
}
}
}
fn id_regex() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r"^[A-Z][A-Za-z0-9]*#[a-z0-9]+(-[a-z0-9]+)*$").unwrap())
}
fn validate_extended_node_id(id: &str, expected_token: &str) -> Result<(), String> {
let re = id_regex();
if !re.is_match(id) {
return Err(format!(
"id `{id}` does not match `<Type>#<kebab-case-rest>`"
));
}
let prefix = id.split('#').next().unwrap_or("");
if prefix != expected_token {
return Err(format!(
"id `{id}` has prefix `{prefix}`, expected `{expected_token}#`"
));
}
Ok(())
}
fn default_meta() -> Meta {
Meta {
source: "agileplus-ontology-ext".to_string(),
timestamp: Utc::now().to_rfc3339(),
confidence: None,
agent_id: None,
}
}
#[allow(clippy::too_many_arguments)]
pub fn build_extended_node(
variant: ExtendedNodeType,
id: impl Into<String>,
title: impl Into<String>,
) -> Result<Node, String> {
let id = id.into();
let title = title.into();
validate_extended_node_id(&id, variant.as_token())?;
Ok(Node {
id: NodeId(id),
title,
node_type: variant.as_core_node_type(),
dag_stage: variant.dag_stage(),
status: Status::Active,
meta: default_meta(),
description: None,
tags: Vec::new(),
properties: None,
table_ref: None,
table_id: None,
created_at: None,
updated_at: None,
})
}
pub fn build_extended_edge(
variant: ExtendedRelationshipType,
source: impl Into<String>,
target: impl Into<String>,
) -> Result<Edge, String> {
let source = source.into();
let target = target.into();
if source.is_empty() || target.is_empty() {
return Err("source and target must be non-empty".to_string());
}
if source == target {
return Err("source and target must differ".to_string());
}
Ok(Edge {
id: format!("edge-{}", uuid::Uuid::new_v4()),
source: NodeId(source),
target: NodeId(target),
relationship_type: variant.as_core_relationship_type(),
canonical_map: None,
meta: default_meta(),
properties: Some(json!({
ExtendedRelationshipType::PROP_KEY: variant.as_kebab(),
})),
created_at: None,
})
}
pub fn story(id: impl Into<String>, title: impl Into<String>) -> Result<Node, String> {
build_extended_node(ExtendedNodeType::Story, id, title)
}
pub fn bug(id: impl Into<String>, title: impl Into<String>) -> Result<Node, String> {
build_extended_node(ExtendedNodeType::Bug, id, title)
}
pub fn metric(id: impl Into<String>, title: impl Into<String>) -> Result<Node, String> {
build_extended_node(ExtendedNodeType::Metric, id, title)
}
pub fn hypothesis(id: impl Into<String>, title: impl Into<String>) -> Result<Node, String> {
build_extended_node(ExtendedNodeType::Hypothesis, id, title)
}
pub fn decomposes_into(
source: impl Into<String>,
target: impl Into<String>,
) -> Result<Edge, String> {
build_extended_edge(ExtendedRelationshipType::DecomposesInto, source, target)
}
pub fn mitigates(source: impl Into<String>, target: impl Into<String>) -> Result<Edge, String> {
build_extended_edge(ExtendedRelationshipType::Mitigates, source, target)
}
pub fn validates(source: impl Into<String>, target: impl Into<String>) -> Result<Edge, String> {
build_extended_edge(ExtendedRelationshipType::Validates, source, target)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn story_constructor_validates_id_prefix() {
let ok = story("Story#user-auth", "User auth").unwrap();
assert_eq!(ok.id.0, "Story#user-auth");
assert_eq!(ok.node_type, NodeType::Story);
assert_eq!(ok.dag_stage, DagStage::Feature);
assert_eq!(ok.status, Status::Active);
let bad = story("Feature#user-auth", "x");
assert!(bad.is_err());
let bad2 = story("Story#User-Auth", "x");
assert!(bad2.is_err(), "uppercase kebab not allowed");
}
#[test]
fn metric_and_hypothesis_use_intent_stage() {
let m = metric("Metric#latency-p99", "p99 latency").unwrap();
assert_eq!(m.dag_stage, DagStage::Intent);
let h = hypothesis("Hypothesis#cache-helps", "Cache helps p99").unwrap();
assert_eq!(h.dag_stage, DagStage::Intent);
}
#[test]
fn edge_constructor_round_trips_extended_type() {
let e = decomposes_into("Story#a", "Task#b").unwrap();
let prop = e
.properties
.as_ref()
.and_then(|p| p.get(ExtendedRelationshipType::PROP_KEY))
.and_then(|v| v.as_str())
.unwrap();
let parsed: ExtendedRelationshipType = prop.parse().unwrap();
assert_eq!(parsed, ExtendedRelationshipType::DecomposesInto);
}
#[test]
fn edge_constructor_rejects_self_loop() {
let err = mitigates("Story#x", "Story#x").unwrap_err();
assert!(err.contains("differ"));
}
}