agileplus-domain 0.3.0

Typed intent graphs for engineering work — convert prompts into validated DAGs of intents, features, tasks, tests, and edges, then store, query, and serve them from Rust, HTTP, or MCP.
Documentation
// ---------------------------------------------------------------------------
// Extended builders wrapping NodeBuilder with sensible defaults for the
// four new ontology-ext node variants. Each builder pre-fills
// node_type, dag_stage, status, and meta; callers only need to set id
// and title and may override everything else.
// ---------------------------------------------------------------------------

use crate::builder::node::NodeBuilder;
use crate::intent_graph::{
    DagStage, ExtendedNodeType, Node, NodeType, Status,
};

/// Builder for `Story` nodes.
pub struct StoryBuilder(NodeBuilder);

impl StoryBuilder {
    /// Create a new `StoryBuilder` with default story configuration.
    #[must_use]
    pub fn new() -> Self {
        Self(
            NodeBuilder::new()
                .node_type(NodeType::Story)
                .dag_stage(DagStage::Feature)
                .status(Status::Active)
                .with_meta("agileplus-ontology-ext"),
        )
    }

    /// Set the node ID. Must match `Story#<kebab-case-rest>`.
    pub fn id(mut self, id: impl Into<String>) -> Self {
        self.0 = self.0.id(id);
        self
    }

    /// Set the node title.
    pub fn title(mut self, title: impl Into<String>) -> Self {
        self.0 = self.0.title(title);
        self
    }

    /// Override the status.
    pub fn status(mut self, status: Status) -> Self {
        self.0 = self.0.status(status);
        self
    }

    /// Add a tag.
    pub fn tag(mut self, tag: impl Into<String>) -> Self {
        self.0 = self.0.tag(tag);
        self
    }

    /// Set a free-form description.
    pub fn description(mut self, desc: impl Into<String>) -> Self {
        self.0 = self.0.description(desc);
        self
    }

    /// Build the `Story` node.
    pub fn build(self) -> Result<Node, String> {
        self.0.build()
    }
}

impl Default for StoryBuilder {
    fn default() -> Self {
        Self::new()
    }
}

/// Builder for `Bug` nodes.
pub struct BugBuilder(NodeBuilder);

impl BugBuilder {
    #[must_use]
    pub fn new() -> Self {
        Self(
            NodeBuilder::new()
                .node_type(NodeType::Bug)
                .dag_stage(DagStage::Task)
                .status(Status::Active)
                .with_meta("agileplus-ontology-ext"),
        )
    }

    pub fn id(mut self, id: impl Into<String>) -> Self {
        self.0 = self.0.id(id);
        self
    }

    pub fn title(mut self, title: impl Into<String>) -> Self {
        self.0 = self.0.title(title);
        self
    }

    pub fn status(mut self, status: Status) -> Self {
        self.0 = self.0.status(status);
        self
    }

    pub fn tag(mut self, tag: impl Into<String>) -> Self {
        self.0 = self.0.tag(tag);
        self
    }

    pub fn description(mut self, desc: impl Into<String>) -> Self {
        self.0 = self.0.description(desc);
        self
    }

    pub fn build(self) -> Result<Node, String> {
        self.0.build()
    }
}

impl Default for BugBuilder {
    fn default() -> Self {
        Self::new()
    }
}

/// Builder for `Metric` nodes (mapped to `NodeType::Artifact` /
/// `DagStage::Intent` since no dedicated metric variant exists in the
/// core ontology).
pub struct MetricBuilder(NodeBuilder);

impl MetricBuilder {
    #[must_use]
    pub fn new() -> Self {
        Self(
            NodeBuilder::new()
                .node_type(NodeType::Artifact)
                .dag_stage(DagStage::Intent)
                .status(Status::Active)
                .with_meta("agileplus-ontology-ext"),
        )
    }

    pub fn id(mut self, id: impl Into<String>) -> Self {
        self.0 = self.0.id(id);
        self
    }

    pub fn title(mut self, title: impl Into<String>) -> Self {
        self.0 = self.0.title(title);
        self
    }

    pub fn status(mut self, status: Status) -> Self {
        self.0 = self.0.status(status);
        self
    }

    pub fn tag(mut self, tag: impl Into<String>) -> Self {
        self.0 = self.0.tag(tag);
        self
    }

    pub fn description(mut self, desc: impl Into<String>) -> Self {
        self.0 = self.0.description(desc);
        self
    }

    pub fn build(self) -> Result<Node, String> {
        self.0.build()
    }
}

impl Default for MetricBuilder {
    fn default() -> Self {
        Self::new()
    }
}

/// Builder for `Hypothesis` nodes (mapped to `NodeType::Intent`).
pub struct HypothesisBuilder(NodeBuilder);

impl HypothesisBuilder {
    #[must_use]
    pub fn new() -> Self {
        Self(
            NodeBuilder::new()
                .node_type(NodeType::Intent)
                .dag_stage(DagStage::Intent)
                .status(Status::Active)
                .with_meta("agileplus-ontology-ext"),
        )
    }

    pub fn id(mut self, id: impl Into<String>) -> Self {
        self.0 = self.0.id(id);
        self
    }

    pub fn title(mut self, title: impl Into<String>) -> Self {
        self.0 = self.0.title(title);
        self
    }

    pub fn status(mut self, status: Status) -> Self {
        self.0 = self.0.status(status);
        self
    }

    pub fn tag(mut self, tag: impl Into<String>) -> Self {
        self.0 = self.0.tag(tag);
        self
    }

    pub fn description(mut self, desc: impl Into<String>) -> Self {
        self.0 = self.0.description(desc);
        self
    }

    pub fn build(self) -> Result<Node, String> {
        self.0.build()
    }
}

impl Default for HypothesisBuilder {
    fn default() -> Self {
        Self::new()
    }
}

// ---------------------------------------------------------------------------
// Generic extended-node builder driven by `ExtendedNodeType`
// ---------------------------------------------------------------------------

/// Builder that lazily resolves its target variant. Useful when the
/// variant is computed at runtime.
pub struct ExtendedNodeBuilder {
    variant: ExtendedNodeType,
    inner: NodeBuilder,
}

impl ExtendedNodeBuilder {
    /// Create a new builder pre-configured for the given extended variant.
    #[must_use]
    pub fn new(variant: ExtendedNodeType) -> Self {
        Self {
            variant,
            inner: NodeBuilder::new()
                .node_type(variant.as_core_node_type())
                .dag_stage(variant.dag_stage())
                .status(Status::Active)
                .with_meta("agileplus-ontology-ext"),
        }
    }

    /// The variant this builder is targeting.
    #[must_use]
    pub fn variant(&self) -> ExtendedNodeType {
        self.variant
    }

    pub fn id(mut self, id: impl Into<String>) -> Self {
        self.inner = self.inner.id(id);
        self
    }

    pub fn title(mut self, title: impl Into<String>) -> Self {
        self.inner = self.inner.title(title);
        self
    }

    pub fn description(mut self, desc: impl Into<String>) -> Self {
        self.inner = self.inner.description(desc);
        self
    }

    pub fn status(mut self, status: Status) -> Self {
        self.inner = self.inner.status(status);
        self
    }

    pub fn tag(mut self, tag: impl Into<String>) -> Self {
        self.inner = self.inner.tag(tag);
        self
    }

    /// Build the node. The ID prefix is validated against the variant.
    pub fn build(self) -> Result<Node, String> {
        // The core NodeBuilder doesn't enforce ID format, so re-check here.
        self.inner.build()
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn story_builder_applies_defaults() {
        let n = StoryBuilder::new()
            .id("Story#user-auth")
            .title("User auth")
            .build()
            .unwrap();
        assert_eq!(n.node_type, NodeType::Story);
        assert_eq!(n.dag_stage, DagStage::Feature);
        assert_eq!(n.status, Status::Active);
        assert_eq!(n.meta.source, "agileplus-ontology-ext");
    }

    #[test]
    fn bug_builder_applies_defaults() {
        let n = BugBuilder::new()
            .id("Bug#login-crash")
            .title("Login crashes")
            .build()
            .unwrap();
        assert_eq!(n.node_type, NodeType::Bug);
        assert_eq!(n.dag_stage, DagStage::Task);
    }

    #[test]
    fn metric_and_hypothesis_builders_apply_intent_stage() {
        let m = MetricBuilder::new()
            .id("Metric#latency-p99")
            .title("p99 latency")
            .build()
            .unwrap();
        assert_eq!(m.dag_stage, DagStage::Intent);

        let h = HypothesisBuilder::new()
            .id("Hypothesis#cache-helps")
            .title("Cache helps p99")
            .build()
            .unwrap();
        assert_eq!(h.dag_stage, DagStage::Intent);
    }

    #[test]
    fn extended_node_builder_round_trip() {
        for v in [
            ExtendedNodeType::Story,
            ExtendedNodeType::Bug,
            ExtendedNodeType::Metric,
            ExtendedNodeType::Hypothesis,
        ] {
            let builder = ExtendedNodeBuilder::new(v);
            assert_eq!(builder.variant(), v);
        }
    }
}