1use std::{collections::BTreeMap, sync::Arc};
9
10use derive_more::{Display, From};
11#[cfg(feature = "schemars")]
12use schemars::Schema;
13use serde::{Deserialize, Serialize};
14use serde_with::{DefaultOnError, VecSkipError, serde_as, skip_serializing_none};
15
16use super::Meta;
17use crate::IntoOption;
18
19#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
21#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Display, From)]
22#[serde(transparent)]
23#[from(forward)]
24#[non_exhaustive]
25pub struct PlanId(pub Arc<str>);
26
27impl PlanId {
28 #[must_use]
30 pub fn new(id: impl Into<Self>) -> Self {
31 id.into()
32 }
33}
34
35#[serde_as]
37#[skip_serializing_none]
38#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
39#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
40#[serde(rename_all = "camelCase")]
41#[non_exhaustive]
42pub struct PlanUpdate {
43 pub plan: PlanUpdateContent,
45 #[serde_as(deserialize_as = "DefaultOnError")]
51 #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))]
52 #[serde(default)]
53 #[serde(rename = "_meta")]
54 pub meta: Option<Meta>,
55}
56
57impl PlanUpdate {
58 #[must_use]
60 pub fn new(plan: PlanUpdateContent) -> Self {
61 Self { plan, meta: None }
62 }
63
64 #[must_use]
70 pub fn meta(mut self, meta: impl IntoOption<Meta>) -> Self {
71 self.meta = meta.into_option();
72 self
73 }
74}
75
76#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
78#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
79#[serde(tag = "type", rename_all = "snake_case")]
80#[non_exhaustive]
81pub enum PlanUpdateContent {
82 Items(PlanItems),
84 #[cfg(feature = "unstable_plan_operations")]
90 File(PlanFile),
91 #[cfg(feature = "unstable_plan_operations")]
97 Markdown(PlanMarkdown),
98 #[serde(untagged)]
108 Other(OtherPlanUpdateContent),
109}
110
111#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
113#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
114#[cfg_attr(feature = "schemars", schemars(inline))]
115#[cfg_attr(feature = "schemars", schemars(transform = other_plan_update_content_schema))]
116#[serde(rename_all = "camelCase")]
117#[non_exhaustive]
118pub struct OtherPlanUpdateContent {
119 #[serde(rename = "type")]
125 pub type_: String,
126 pub plan_id: PlanId,
128 #[serde(flatten)]
130 pub fields: BTreeMap<String, serde_json::Value>,
131}
132
133impl OtherPlanUpdateContent {
134 #[must_use]
136 pub fn new(
137 type_: impl Into<String>,
138 plan_id: impl Into<PlanId>,
139 mut fields: BTreeMap<String, serde_json::Value>,
140 ) -> Self {
141 fields.remove("type");
142 fields.remove("planId");
143 Self {
144 type_: type_.into(),
145 plan_id: plan_id.into(),
146 fields,
147 }
148 }
149}
150
151impl<'de> Deserialize<'de> for OtherPlanUpdateContent {
152 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
153 where
154 D: serde::Deserializer<'de>,
155 {
156 let mut fields = BTreeMap::<String, serde_json::Value>::deserialize(deserializer)?;
157 let type_ = fields
158 .remove("type")
159 .ok_or_else(|| serde::de::Error::missing_field("type"))?;
160 let serde_json::Value::String(type_) = type_ else {
161 return Err(serde::de::Error::custom("`type` must be a string"));
162 };
163 let plan_id = fields
164 .remove("planId")
165 .ok_or_else(|| serde::de::Error::missing_field("planId"))?;
166 let serde_json::Value::String(plan_id) = plan_id else {
167 return Err(serde::de::Error::custom("`planId` must be a string"));
168 };
169
170 if is_known_plan_update_content_type(&type_) {
171 return Err(serde::de::Error::custom(format!(
172 "known plan update content `{type_}` did not match its schema"
173 )));
174 }
175
176 Ok(Self {
177 type_,
178 plan_id: PlanId::new(plan_id),
179 fields,
180 })
181 }
182}
183
184fn is_known_plan_update_content_type(type_: &str) -> bool {
185 KNOWN_PLAN_UPDATE_CONTENT_TYPES.contains(&type_)
186}
187
188#[cfg(feature = "schemars")]
189fn other_plan_update_content_schema(schema: &mut Schema) {
190 super::schema_util::reject_known_string_discriminators(
191 schema,
192 "type",
193 KNOWN_PLAN_UPDATE_CONTENT_TYPES,
194 );
195}
196
197const KNOWN_PLAN_UPDATE_CONTENT_TYPES: &[&str] = &[
198 "items",
199 #[cfg(feature = "unstable_plan_operations")]
200 "file",
201 #[cfg(feature = "unstable_plan_operations")]
202 "markdown",
203];
204
205impl PlanUpdateContent {
206 #[must_use]
208 pub fn items(plan_id: impl Into<PlanId>, entries: Vec<PlanEntry>) -> Self {
209 Self::Items(PlanItems::new(plan_id, entries))
210 }
211
212 #[cfg(feature = "unstable_plan_operations")]
214 #[must_use]
215 pub fn file(plan_id: impl Into<PlanId>, uri: impl Into<String>) -> Self {
216 Self::File(PlanFile::new(plan_id, uri))
217 }
218
219 #[cfg(feature = "unstable_plan_operations")]
221 #[must_use]
222 pub fn markdown(plan_id: impl Into<PlanId>, content: impl Into<String>) -> Self {
223 Self::Markdown(PlanMarkdown::new(plan_id, content))
224 }
225}
226
227#[serde_as]
229#[skip_serializing_none]
230#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
231#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
232#[serde(rename_all = "camelCase")]
233#[non_exhaustive]
234pub struct PlanItems {
235 pub plan_id: PlanId,
237 #[serde_as(deserialize_as = "DefaultOnError<VecSkipError<_>>")]
242 #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true, "x-deserialize-skip-invalid-items" = true)))]
243 pub entries: Vec<PlanEntry>,
244 #[serde_as(deserialize_as = "DefaultOnError")]
250 #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))]
251 #[serde(default)]
252 #[serde(rename = "_meta")]
253 pub meta: Option<Meta>,
254}
255
256impl PlanItems {
257 #[must_use]
259 pub fn new(plan_id: impl Into<PlanId>, entries: Vec<PlanEntry>) -> Self {
260 Self {
261 plan_id: plan_id.into(),
262 entries,
263 meta: None,
264 }
265 }
266
267 #[must_use]
273 pub fn meta(mut self, meta: impl IntoOption<Meta>) -> Self {
274 self.meta = meta.into_option();
275 self
276 }
277}
278
279#[cfg(feature = "unstable_plan_operations")]
285#[serde_as]
286#[skip_serializing_none]
287#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
288#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
289#[serde(rename_all = "camelCase")]
290#[non_exhaustive]
291pub struct PlanFile {
292 pub plan_id: PlanId,
294 #[cfg_attr(feature = "schemars", schemars(url))]
296 pub uri: String,
297 #[serde_as(deserialize_as = "DefaultOnError")]
303 #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))]
304 #[serde(default)]
305 #[serde(rename = "_meta")]
306 pub meta: Option<Meta>,
307}
308
309#[cfg(feature = "unstable_plan_operations")]
310impl PlanFile {
311 #[must_use]
313 pub fn new(plan_id: impl Into<PlanId>, uri: impl Into<String>) -> Self {
314 Self {
315 plan_id: plan_id.into(),
316 uri: uri.into(),
317 meta: None,
318 }
319 }
320
321 #[must_use]
327 pub fn meta(mut self, meta: impl IntoOption<Meta>) -> Self {
328 self.meta = meta.into_option();
329 self
330 }
331}
332
333#[cfg(feature = "unstable_plan_operations")]
339#[serde_as]
340#[skip_serializing_none]
341#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
342#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
343#[serde(rename_all = "camelCase")]
344#[non_exhaustive]
345pub struct PlanMarkdown {
346 pub plan_id: PlanId,
348 pub content: String,
350 #[serde_as(deserialize_as = "DefaultOnError")]
356 #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))]
357 #[serde(default)]
358 #[serde(rename = "_meta")]
359 pub meta: Option<Meta>,
360}
361
362#[cfg(feature = "unstable_plan_operations")]
363impl PlanMarkdown {
364 #[must_use]
366 pub fn new(plan_id: impl Into<PlanId>, content: impl Into<String>) -> Self {
367 Self {
368 plan_id: plan_id.into(),
369 content: content.into(),
370 meta: None,
371 }
372 }
373
374 #[must_use]
380 pub fn meta(mut self, meta: impl IntoOption<Meta>) -> Self {
381 self.meta = meta.into_option();
382 self
383 }
384}
385
386#[cfg(feature = "unstable_plan_operations")]
392#[serde_as]
393#[skip_serializing_none]
394#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
395#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
396#[serde(rename_all = "camelCase")]
397#[non_exhaustive]
398pub struct PlanRemoved {
399 pub plan_id: PlanId,
401 #[serde_as(deserialize_as = "DefaultOnError")]
407 #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))]
408 #[serde(default)]
409 #[serde(rename = "_meta")]
410 pub meta: Option<Meta>,
411}
412
413#[cfg(feature = "unstable_plan_operations")]
414impl PlanRemoved {
415 #[must_use]
417 pub fn new(plan_id: impl Into<PlanId>) -> Self {
418 Self {
419 plan_id: plan_id.into(),
420 meta: None,
421 }
422 }
423
424 #[must_use]
430 pub fn meta(mut self, meta: impl IntoOption<Meta>) -> Self {
431 self.meta = meta.into_option();
432 self
433 }
434}
435
436#[serde_as]
442#[skip_serializing_none]
443#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
444#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
445#[serde(rename_all = "camelCase")]
446#[non_exhaustive]
447pub struct PlanEntry {
448 pub content: String,
450 pub priority: PlanEntryPriority,
453 pub status: PlanEntryStatus,
455 #[serde_as(deserialize_as = "DefaultOnError")]
461 #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))]
462 #[serde(default)]
463 #[serde(rename = "_meta")]
464 pub meta: Option<Meta>,
465}
466
467impl PlanEntry {
468 #[must_use]
470 pub fn new(
471 content: impl Into<String>,
472 priority: PlanEntryPriority,
473 status: PlanEntryStatus,
474 ) -> Self {
475 Self {
476 content: content.into(),
477 priority,
478 status,
479 meta: None,
480 }
481 }
482
483 #[must_use]
489 pub fn meta(mut self, meta: impl IntoOption<Meta>) -> Self {
490 self.meta = meta.into_option();
491 self
492 }
493}
494
495#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
501#[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq)]
502#[serde(rename_all = "snake_case")]
503#[non_exhaustive]
504pub enum PlanEntryPriority {
505 High,
507 Medium,
509 Low,
511 #[serde(untagged)]
517 Other(String),
518}
519
520#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
525#[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq)]
526#[serde(rename_all = "snake_case")]
527#[non_exhaustive]
528pub enum PlanEntryStatus {
529 Pending,
531 InProgress,
533 Completed,
535 Cancelled,
537 #[serde(untagged)]
543 Other(String),
544}
545
546#[cfg(test)]
547mod tests {
548 use super::*;
549
550 #[test]
551 fn plan_entry_priority_preserves_unknown_variant() {
552 let priority: PlanEntryPriority = serde_json::from_str("\"urgent\"").unwrap();
553 assert_eq!(priority, PlanEntryPriority::Other("urgent".to_string()));
554 assert_eq!(serde_json::to_value(&priority).unwrap(), "urgent");
555 }
556
557 #[test]
558 fn plan_entry_status_preserves_unknown_variant() {
559 let status: PlanEntryStatus = serde_json::from_str("\"blocked\"").unwrap();
560 assert_eq!(status, PlanEntryStatus::Other("blocked".to_string()));
561 assert_eq!(serde_json::to_value(&status).unwrap(), "blocked");
562 }
563
564 #[test]
565 fn plan_entry_status_recognizes_cancelled_variant() {
566 let status: PlanEntryStatus = serde_json::from_str("\"cancelled\"").unwrap();
567 assert_eq!(status, PlanEntryStatus::Cancelled);
568 assert_eq!(serde_json::to_value(&status).unwrap(), "cancelled");
569 }
570
571 #[test]
572 fn plan_update_content_preserves_unknown_variant() {
573 let content: PlanUpdateContent = serde_json::from_value(serde_json::json!({
574 "type": "_timeline",
575 "planId": "plan-1",
576 "events": []
577 }))
578 .unwrap();
579
580 let PlanUpdateContent::Other(unknown) = content else {
581 panic!("expected unknown plan update content");
582 };
583
584 assert_eq!(unknown.type_, "_timeline");
585 assert_eq!(unknown.plan_id.to_string(), "plan-1");
586 assert!(!unknown.fields.contains_key("planId"));
587 assert_eq!(
588 serde_json::to_value(PlanUpdateContent::Other(unknown)).unwrap(),
589 serde_json::json!({
590 "type": "_timeline",
591 "planId": "plan-1",
592 "events": []
593 })
594 );
595 }
596
597 #[test]
598 fn plan_update_content_does_not_hide_malformed_known_variant() {
599 assert!(
600 serde_json::from_value::<PlanUpdateContent>(serde_json::json!({
601 "type": "items"
602 }))
603 .is_err()
604 );
605 }
606
607 #[cfg(feature = "unstable_plan_operations")]
608 #[test]
609 fn plan_update_content_does_not_hide_malformed_known_experimental_variant() {
610 assert!(
611 serde_json::from_value::<PlanUpdateContent>(serde_json::json!({
612 "type": "file",
613 "planId": "plan-1"
614 }))
615 .is_err()
616 );
617 assert!(
618 serde_json::from_value::<PlanUpdateContent>(serde_json::json!({
619 "type": "markdown",
620 "planId": "plan-1"
621 }))
622 .is_err()
623 );
624 }
625
626 #[test]
627 fn plan_update_content_requires_id_for_unknown_variant() {
628 assert!(
629 serde_json::from_value::<PlanUpdateContent>(serde_json::json!({
630 "type": "_timeline"
631 }))
632 .is_err()
633 );
634 }
635}