Skip to main content

jellyflow_runtime/rules/
plans.rs

1use serde::{Deserialize, Serialize};
2
3use jellyflow_core::ops::GraphOp;
4
5use super::{Diagnostic, DiagnosticTarget};
6
7/// Connection decision.
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
9#[serde(rename_all = "snake_case")]
10pub enum ConnectDecision {
11    /// Accept the connection.
12    Accept,
13    /// Reject the connection.
14    Reject,
15}
16
17/// Delete decision.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
19#[serde(rename_all = "snake_case")]
20pub enum DeleteDecision {
21    /// Accept the deletion.
22    Accept,
23    /// Reject the deletion.
24    Reject,
25}
26
27/// Which endpoint of an existing edge is being reconnected.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
29#[serde(rename_all = "snake_case")]
30pub enum EdgeEndpoint {
31    /// The source endpoint (`edge.from`).
32    From,
33    /// The target endpoint (`edge.to`).
34    To,
35}
36
37/// A rules-driven plan for connecting two ports.
38#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct ConnectPlan {
40    /// Decision.
41    pub decision: ConnectDecision,
42    /// Diagnostics explaining the decision.
43    #[serde(default, skip_serializing_if = "Vec::is_empty")]
44    pub diagnostics: Vec<Diagnostic>,
45    /// Optional edits to apply if accepted (disconnect existing edges, insert conversion nodes, etc.).
46    #[serde(default, skip_serializing_if = "Vec::is_empty")]
47    pub ops: Vec<GraphOp>,
48}
49
50impl ConnectPlan {
51    pub fn is_accept(&self) -> bool {
52        self.decision == ConnectDecision::Accept
53    }
54
55    pub fn is_reject(&self) -> bool {
56        self.decision == ConnectDecision::Reject
57    }
58
59    pub fn diagnostics(&self) -> &[Diagnostic] {
60        &self.diagnostics
61    }
62
63    pub fn ops(&self) -> &[GraphOp] {
64        &self.ops
65    }
66
67    pub fn into_ops(self) -> Vec<GraphOp> {
68        self.ops
69    }
70
71    /// Creates an accepted plan with no side effects.
72    pub fn accept() -> Self {
73        Self {
74            decision: ConnectDecision::Accept,
75            diagnostics: Vec::new(),
76            ops: Vec::new(),
77        }
78    }
79
80    /// Creates an accepted plan with planned connection ops.
81    pub fn from_ops(ops: impl IntoIterator<Item = GraphOp>) -> Self {
82        Self {
83            decision: ConnectDecision::Accept,
84            diagnostics: Vec::new(),
85            ops: ops.into_iter().collect(),
86        }
87    }
88
89    /// Creates a rejected plan with a single error diagnostic.
90    pub fn reject(message: impl Into<String>) -> Self {
91        Self::reject_with_diagnostic(Diagnostic::error(
92            "connect.rejected",
93            DiagnosticTarget::Graph,
94            message,
95        ))
96    }
97
98    pub fn reject_with_diagnostic(diagnostic: Diagnostic) -> Self {
99        Self::reject_with_diagnostics(vec![diagnostic])
100    }
101
102    pub fn reject_with_diagnostics(diagnostics: Vec<Diagnostic>) -> Self {
103        Self {
104            decision: ConnectDecision::Reject,
105            diagnostics,
106            ops: Vec::new(),
107        }
108    }
109}
110
111/// A rules-driven plan for deleting graph elements.
112///
113/// Delete planning is atomic: if any explicitly requested element is missing or not deletable under
114/// the effective interaction policy, the plan is rejected and contains no ops. Edges that are
115/// removed as a consequence of deleting a node are treated as cascaded consistency edits rather
116/// than separate direct edge deletions.
117#[derive(Debug, Clone, Serialize, Deserialize)]
118pub struct DeletePlan {
119    /// Decision.
120    pub decision: DeleteDecision,
121    /// Diagnostics explaining the decision.
122    #[serde(default, skip_serializing_if = "Vec::is_empty")]
123    pub diagnostics: Vec<Diagnostic>,
124    /// Optional edits to apply if accepted.
125    #[serde(default, skip_serializing_if = "Vec::is_empty")]
126    pub ops: Vec<GraphOp>,
127}
128
129impl DeletePlan {
130    pub fn is_accept(&self) -> bool {
131        self.decision == DeleteDecision::Accept
132    }
133
134    pub fn is_reject(&self) -> bool {
135        self.decision == DeleteDecision::Reject
136    }
137
138    pub fn diagnostics(&self) -> &[Diagnostic] {
139        &self.diagnostics
140    }
141
142    pub fn ops(&self) -> &[GraphOp] {
143        &self.ops
144    }
145
146    pub fn into_ops(self) -> Vec<GraphOp> {
147        self.ops
148    }
149
150    /// Creates an accepted plan with no side effects.
151    pub fn accept() -> Self {
152        Self {
153            decision: DeleteDecision::Accept,
154            diagnostics: Vec::new(),
155            ops: Vec::new(),
156        }
157    }
158
159    /// Creates an accepted plan with planned delete ops.
160    pub fn from_ops(ops: impl IntoIterator<Item = GraphOp>) -> Self {
161        Self {
162            decision: DeleteDecision::Accept,
163            diagnostics: Vec::new(),
164            ops: ops.into_iter().collect(),
165        }
166    }
167
168    /// Creates a rejected plan with a single graph-level error diagnostic.
169    pub fn reject(message: impl Into<String>) -> Self {
170        Self::reject_with_diagnostic(Diagnostic::error(
171            "delete.rejected",
172            DiagnosticTarget::Graph,
173            message,
174        ))
175    }
176
177    pub fn reject_with_diagnostic(diagnostic: Diagnostic) -> Self {
178        Self::reject_with_diagnostics(vec![diagnostic])
179    }
180
181    pub fn reject_with_diagnostics(diagnostics: Vec<Diagnostic>) -> Self {
182        Self {
183            decision: DeleteDecision::Reject,
184            diagnostics,
185            ops: Vec::new(),
186        }
187    }
188}