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
use crate::intent_graph::*;
use std::collections::{HashMap, HashSet, VecDeque};

/// Builder-pattern query struct for filtering nodes and edges in an IntentGraph.
#[derive(Debug, Default, Clone)]
pub struct GraphQuery {
    node_type: Option<NodeType>,
    status: Option<Status>,
    dag_stage: Option<DagStage>,
    tags: Vec<String>,
    title_contains: Option<String>,
    relationship_type: Option<RelationshipType>,
    source_type: Option<NodeType>,
    target_type: Option<NodeType>,
}

impl GraphQuery {
    pub fn new() -> Self {
        Self::default()
    }

    pub fn node_type(mut self, nt: NodeType) -> Self {
        self.node_type = Some(nt);
        self
    }

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

    pub fn dag_stage(mut self, ds: DagStage) -> Self {
        self.dag_stage = Some(ds);
        self
    }

    pub fn tag(mut self, t: impl Into<String>) -> Self {
        self.tags.push(t.into());
        self
    }

    pub fn tags(mut self, tags: Vec<String>) -> Self {
        self.tags = tags;
        self
    }

    pub fn title_contains(mut self, t: impl Into<String>) -> Self {
        self.title_contains = Some(t.into());
        self
    }

    pub fn relationship_type(mut self, rt: RelationshipType) -> Self {
        self.relationship_type = Some(rt);
        self
    }

    pub fn source_type(mut self, nt: NodeType) -> Self {
        self.source_type = Some(nt);
        self
    }

    pub fn target_type(mut self, nt: NodeType) -> Self {
        self.target_type = Some(nt);
        self
    }

    /// Execute the query against a graph and return matching nodes.
    pub fn execute<'a>(&self, graph: &'a IntentGraph) -> Vec<&'a Node> {
        graph
            .nodes
            .iter()
            .filter(|n| {
                self.node_type.is_none_or(|t| n.node_type == t)
                    && self.status.is_none_or(|s| n.status == s)
                    && self.dag_stage.is_none_or(|ds| n.dag_stage == ds)
                    && self.tags.iter().all(|t| n.tags.contains(t))
                    && self
                        .title_contains
                        .as_ref()
                        .is_none_or(|pat| n.title.contains(pat))
            })
            .collect()
    }

    /// Execute the query against a graph and return matching edges.
    pub fn execute_edges<'a>(&self, graph: &'a IntentGraph) -> Vec<&'a Edge> {
        graph
            .edges
            .iter()
            .filter(|e| {
                let source_type_ok = self.source_type.is_none_or(|nt| {
                    graph
                        .node_by_id(&e.source)
                        .is_some_and(|n| n.node_type == nt)
                });
                let target_type_ok = self.target_type.is_none_or(|nt| {
                    graph
                        .node_by_id(&e.target)
                        .is_some_and(|n| n.node_type == nt)
                });
                self.relationship_type
                    .is_none_or(|rt| e.relationship_type == rt)
                    && source_type_ok
                    && target_type_ok
            })
            .collect()
    }
}

/// Return nodes that have no incoming edges.
pub fn find_roots(graph: &IntentGraph) -> Vec<&Node> {
    let targets: HashSet<NodeId> = graph.edges.iter().map(|e| e.target.clone()).collect();
    graph
        .nodes
        .iter()
        .filter(|n| !targets.contains(&n.id))
        .collect()
}

/// Return nodes that have no outgoing edges.
pub fn find_leaves(graph: &IntentGraph) -> Vec<&Node> {
    let sources: HashSet<NodeId> = graph.edges.iter().map(|e| e.source.clone()).collect();
    graph
        .nodes
        .iter()
        .filter(|n| !sources.contains(&n.id))
        .collect()
}

/// BFS path finding between two nodes.
pub fn find_path<'a>(
    graph: &'a IntentGraph,
    from: &NodeId,
    to: &NodeId,
) -> Option<Vec<&'a Node>> {
    if from == to {
        return graph.node_by_id(from).map(|n| vec![n]);
    }

    let mut queue: VecDeque<(NodeId, Vec<NodeId>)> = VecDeque::new();
    queue.push_back((from.clone(), vec![from.clone()]));
    let mut visited: HashSet<NodeId> = HashSet::new();
    visited.insert(from.clone());

    while let Some((current_id, path_ids)) = queue.pop_front() {
        for edge in graph.edges_from(&current_id) {
            let next_id = edge.target.clone();
            if visited.contains(&next_id) {
                continue;
            }
            let mut next_path = path_ids.clone();
            next_path.push(next_id.clone());
            if next_id == *to {
                let nodes: Vec<&Node> = next_path
                    .iter()
                    .map(|id| graph.node_by_id(id).unwrap())
                    .collect();
                return Some(nodes);
            }
            visited.insert(next_id.clone());
            queue.push_back((next_id, next_path));
        }
    }
    None
}

/// Kahn's algorithm for topological sorting.
pub fn topological_sort(graph: &IntentGraph) -> Result<Vec<&Node>, String> {
    let mut in_degree: HashMap<NodeId, usize> =
        graph.nodes.iter().map(|n| (n.id.clone(), 0)).collect();
    for edge in &graph.edges {
        *in_degree.entry(edge.target.clone()).or_insert(0) += 1;
    }

    let mut queue: VecDeque<NodeId> = VecDeque::new();
    for node in &graph.nodes {
        if in_degree.get(&node.id).copied().unwrap_or(0) == 0 {
            queue.push_back(node.id.clone());
        }
    }

    let mut sorted: Vec<&Node> = Vec::new();
    let mut processed = 0;

    while let Some(node_id) = queue.pop_front() {
        let node = graph
            .node_by_id(&node_id)
            .ok_or_else(|| format!("Node {} not found", node_id))?;
        sorted.push(node);
        processed += 1;

        for edge in graph.edges_from(&node_id) {
            let target = &edge.target;
            let degree = in_degree
                .get_mut(target)
                .ok_or_else(|| format!("Node {} not found", target))?;
            *degree -= 1;
            if *degree == 0 {
                queue.push_back(target.clone());
            }
        }
    }

    if processed != graph.nodes.len() {
        return Err("Graph contains cycles and cannot be topologically sorted".to_string());
    }
    Ok(sorted)
}