1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::{fmt, str::FromStr};
5use std::error::Error;
6
7pub mod prelude {
8 pub use crate::{
9 PlanDependencyKind, PlanExecutionMode, PlanId, PlanName, PlanPriority, PlanReviewStatus,
10 PlanRiskKind, PlanStatus, PlanStepId, PlanStepKind, PlanningError,
11 };
12}
13
14macro_rules! planning_text_newtype {
15 ($name:ident) => {
16 #[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
17 pub struct $name(String);
18
19 impl $name {
20 pub fn new(value: impl AsRef<str>) -> Result<Self, PlanningError> {
21 non_empty_text(value).map(Self)
22 }
23
24 pub fn as_str(&self) -> &str {
25 &self.0
26 }
27
28 pub fn value(&self) -> &str {
29 self.as_str()
30 }
31
32 pub fn into_string(self) -> String {
33 self.0
34 }
35 }
36
37 impl AsRef<str> for $name {
38 fn as_ref(&self) -> &str {
39 self.as_str()
40 }
41 }
42
43 impl fmt::Display for $name {
44 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
45 formatter.write_str(self.as_str())
46 }
47 }
48
49 impl FromStr for $name {
50 type Err = PlanningError;
51
52 fn from_str(value: &str) -> Result<Self, Self::Err> {
53 Self::new(value)
54 }
55 }
56
57 impl TryFrom<&str> for $name {
58 type Error = PlanningError;
59
60 fn try_from(value: &str) -> Result<Self, Self::Error> {
61 Self::new(value)
62 }
63 }
64 };
65}
66
67macro_rules! planning_enum {
68 ($name:ident { $($variant:ident => $label:literal),+ $(,)? }) => {
69 #[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
70 pub enum $name {
71 $($variant),+
72 }
73
74 impl $name {
75 pub const ALL: &'static [Self] = &[$(Self::$variant),+];
76
77 pub const fn as_str(self) -> &'static str {
78 match self {
79 $(Self::$variant => $label),+
80 }
81 }
82 }
83
84 impl fmt::Display for $name {
85 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
86 formatter.write_str(self.as_str())
87 }
88 }
89
90 impl FromStr for $name {
91 type Err = PlanningError;
92
93 fn from_str(value: &str) -> Result<Self, Self::Err> {
94 match normalized_label(value)?.as_str() {
95 $($label => Ok(Self::$variant),)+
96 _ => Err(PlanningError::UnknownLabel),
97 }
98 }
99 }
100 };
101}
102
103planning_text_newtype!(PlanName);
104planning_text_newtype!(PlanId);
105planning_text_newtype!(PlanStepId);
106
107planning_enum!(PlanStepKind {
108 Think => "think",
109 Retrieve => "retrieve",
110 Transform => "transform",
111 Generate => "generate",
112 Validate => "validate",
113 CallTool => "call-tool",
114 AskUser => "ask-user",
115 Review => "review",
116 Deliver => "deliver",
117 Stop => "stop",
118 Custom => "custom",
119});
120
121planning_enum!(PlanStatus {
122 Draft => "draft",
123 Ready => "ready",
124 Running => "running",
125 Blocked => "blocked",
126 Succeeded => "succeeded",
127 Failed => "failed",
128 Cancelled => "cancelled",
129 Superseded => "superseded",
130});
131
132planning_enum!(PlanPriority {
133 Low => "low",
134 Normal => "normal",
135 High => "high",
136 Urgent => "urgent",
137});
138
139planning_enum!(PlanDependencyKind {
140 Sequential => "sequential",
141 Parallel => "parallel",
142 Conditional => "conditional",
143 Blocking => "blocking",
144 Optional => "optional",
145});
146
147planning_enum!(PlanExecutionMode {
148 Manual => "manual",
149 Assisted => "assisted",
150 Automated => "automated",
151 Supervised => "supervised",
152});
153
154planning_enum!(PlanReviewStatus {
155 NotReviewed => "not-reviewed",
156 NeedsReview => "needs-review",
157 Approved => "approved",
158 Rejected => "rejected",
159 Revised => "revised",
160});
161
162planning_enum!(PlanRiskKind {
163 AmbiguousInput => "ambiguous-input",
164 MissingContext => "missing-context",
165 UnsafeAction => "unsafe-action",
166 ToolFailure => "tool-failure",
167 TimeLimit => "time-limit",
168 CostLimit => "cost-limit",
169 Unknown => "unknown",
170});
171
172#[derive(Clone, Copy, Debug, Eq, PartialEq)]
173pub enum PlanningError {
174 Empty,
175 UnknownLabel,
176}
177
178impl fmt::Display for PlanningError {
179 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
180 match self {
181 Self::Empty => formatter.write_str("planning metadata text cannot be empty"),
182 Self::UnknownLabel => formatter.write_str("unknown planning metadata label"),
183 }
184 }
185}
186
187impl Error for PlanningError {}
188
189fn non_empty_text(value: impl AsRef<str>) -> Result<String, PlanningError> {
190 let trimmed = value.as_ref().trim();
191 if trimmed.is_empty() {
192 Err(PlanningError::Empty)
193 } else {
194 Ok(trimmed.to_string())
195 }
196}
197
198fn normalized_label(value: &str) -> Result<String, PlanningError> {
199 let trimmed = value.trim();
200 if trimmed.is_empty() {
201 Err(PlanningError::Empty)
202 } else {
203 Ok(trimmed.to_ascii_lowercase().replace(['_', ' '], "-"))
204 }
205}
206
207#[cfg(test)]
208mod tests {
209 use super::{
210 PlanDependencyKind, PlanExecutionMode, PlanId, PlanName, PlanPriority, PlanReviewStatus,
211 PlanRiskKind, PlanStatus, PlanStepId, PlanStepKind, PlanningError,
212 };
213 use core::{fmt, str::FromStr};
214
215 macro_rules! assert_text_newtype {
216 ($type:ty, $value:literal) => {{
217 let value = <$type>::new(concat!(" ", $value, " "))?;
218 assert_eq!(value.as_str(), $value);
219 assert_eq!(value.value(), $value);
220 assert_eq!(value.as_ref(), $value);
221 assert_eq!(value.to_string(), $value);
222 assert_eq!(<$type as TryFrom<&str>>::try_from($value)?, value);
223 assert_eq!(value.into_string(), $value.to_string());
224 }};
225 }
226
227 fn assert_enum_family<T>(variants: &[T]) -> Result<(), PlanningError>
228 where
229 T: Copy + Eq + fmt::Debug + fmt::Display + FromStr<Err = PlanningError>,
230 {
231 for variant in variants {
232 let label = variant.to_string();
233 assert_eq!(label.parse::<T>()?, *variant);
234 assert_eq!(label.replace('-', "_").parse::<T>()?, *variant);
235 assert_eq!(label.replace('-', " ").parse::<T>()?, *variant);
236 }
237 Ok(())
238 }
239
240 #[test]
241 fn validates_planning_text_newtypes() -> Result<(), PlanningError> {
242 assert_text_newtype!(PlanName, "resolve-ticket");
243 assert_text_newtype!(PlanId, "plan-001");
244 assert_text_newtype!(PlanStepId, "step-001");
245 assert_eq!(PlanName::new(" "), Err(PlanningError::Empty));
246 Ok(())
247 }
248
249 #[test]
250 fn displays_and_parses_planning_enums() -> Result<(), PlanningError> {
251 assert_enum_family(PlanStepKind::ALL)?;
252 assert_enum_family(PlanStatus::ALL)?;
253 assert_enum_family(PlanPriority::ALL)?;
254 assert_enum_family(PlanDependencyKind::ALL)?;
255 assert_enum_family(PlanExecutionMode::ALL)?;
256 assert_enum_family(PlanReviewStatus::ALL)?;
257 assert_enum_family(PlanRiskKind::ALL)?;
258 assert_eq!("call tool".parse::<PlanStepKind>()?, PlanStepKind::CallTool);
259 Ok(())
260 }
261}