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, SkipListener};
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] = &["items", "file", "markdown"];
198
199impl PlanUpdateContent {
200 #[must_use]
202 pub fn items(plan_id: impl Into<PlanId>, entries: Vec<PlanEntry>) -> Self {
203 Self::Items(PlanItems::new(plan_id, entries))
204 }
205
206 #[cfg(feature = "unstable_plan_operations")]
208 #[must_use]
209 pub fn file(plan_id: impl Into<PlanId>, uri: impl Into<String>) -> Self {
210 Self::File(PlanFile::new(plan_id, uri))
211 }
212
213 #[cfg(feature = "unstable_plan_operations")]
215 #[must_use]
216 pub fn markdown(plan_id: impl Into<PlanId>, content: impl Into<String>) -> Self {
217 Self::Markdown(PlanMarkdown::new(plan_id, content))
218 }
219}
220
221#[serde_as]
223#[skip_serializing_none]
224#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
225#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
226#[serde(rename_all = "camelCase")]
227#[non_exhaustive]
228pub struct PlanItems {
229 pub plan_id: PlanId,
231 #[serde_as(deserialize_as = "DefaultOnError<VecSkipError<_, SkipListener>>")]
236 #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true, "x-deserialize-skip-invalid-items" = true)))]
237 pub entries: Vec<PlanEntry>,
238 #[serde_as(deserialize_as = "DefaultOnError")]
244 #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))]
245 #[serde(default)]
246 #[serde(rename = "_meta")]
247 pub meta: Option<Meta>,
248}
249
250impl PlanItems {
251 #[must_use]
253 pub fn new(plan_id: impl Into<PlanId>, entries: Vec<PlanEntry>) -> Self {
254 Self {
255 plan_id: plan_id.into(),
256 entries,
257 meta: None,
258 }
259 }
260
261 #[must_use]
267 pub fn meta(mut self, meta: impl IntoOption<Meta>) -> Self {
268 self.meta = meta.into_option();
269 self
270 }
271}
272
273#[cfg(feature = "unstable_plan_operations")]
279#[serde_as]
280#[skip_serializing_none]
281#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
282#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
283#[serde(rename_all = "camelCase")]
284#[non_exhaustive]
285pub struct PlanFile {
286 pub plan_id: PlanId,
288 #[cfg_attr(feature = "schemars", schemars(url))]
290 pub uri: String,
291 #[serde_as(deserialize_as = "DefaultOnError")]
297 #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))]
298 #[serde(default)]
299 #[serde(rename = "_meta")]
300 pub meta: Option<Meta>,
301}
302
303#[cfg(feature = "unstable_plan_operations")]
304impl PlanFile {
305 #[must_use]
307 pub fn new(plan_id: impl Into<PlanId>, uri: impl Into<String>) -> Self {
308 Self {
309 plan_id: plan_id.into(),
310 uri: uri.into(),
311 meta: None,
312 }
313 }
314
315 #[must_use]
321 pub fn meta(mut self, meta: impl IntoOption<Meta>) -> Self {
322 self.meta = meta.into_option();
323 self
324 }
325}
326
327#[cfg(feature = "unstable_plan_operations")]
333#[serde_as]
334#[skip_serializing_none]
335#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
336#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
337#[serde(rename_all = "camelCase")]
338#[non_exhaustive]
339pub struct PlanMarkdown {
340 pub plan_id: PlanId,
342 pub content: String,
344 #[serde_as(deserialize_as = "DefaultOnError")]
350 #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))]
351 #[serde(default)]
352 #[serde(rename = "_meta")]
353 pub meta: Option<Meta>,
354}
355
356#[cfg(feature = "unstable_plan_operations")]
357impl PlanMarkdown {
358 #[must_use]
360 pub fn new(plan_id: impl Into<PlanId>, content: impl Into<String>) -> Self {
361 Self {
362 plan_id: plan_id.into(),
363 content: content.into(),
364 meta: None,
365 }
366 }
367
368 #[must_use]
374 pub fn meta(mut self, meta: impl IntoOption<Meta>) -> Self {
375 self.meta = meta.into_option();
376 self
377 }
378}
379
380#[cfg(feature = "unstable_plan_operations")]
386#[serde_as]
387#[skip_serializing_none]
388#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
389#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
390#[serde(rename_all = "camelCase")]
391#[non_exhaustive]
392pub struct PlanRemoved {
393 pub plan_id: PlanId,
395 #[serde_as(deserialize_as = "DefaultOnError")]
401 #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))]
402 #[serde(default)]
403 #[serde(rename = "_meta")]
404 pub meta: Option<Meta>,
405}
406
407#[cfg(feature = "unstable_plan_operations")]
408impl PlanRemoved {
409 #[must_use]
411 pub fn new(plan_id: impl Into<PlanId>) -> Self {
412 Self {
413 plan_id: plan_id.into(),
414 meta: None,
415 }
416 }
417
418 #[must_use]
424 pub fn meta(mut self, meta: impl IntoOption<Meta>) -> Self {
425 self.meta = meta.into_option();
426 self
427 }
428}
429
430#[serde_as]
436#[skip_serializing_none]
437#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
438#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
439#[serde(rename_all = "camelCase")]
440#[non_exhaustive]
441pub struct PlanEntry {
442 pub content: String,
444 pub priority: PlanEntryPriority,
447 pub status: PlanEntryStatus,
449 #[serde_as(deserialize_as = "DefaultOnError")]
455 #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))]
456 #[serde(default)]
457 #[serde(rename = "_meta")]
458 pub meta: Option<Meta>,
459}
460
461impl PlanEntry {
462 #[must_use]
464 pub fn new(
465 content: impl Into<String>,
466 priority: PlanEntryPriority,
467 status: PlanEntryStatus,
468 ) -> Self {
469 Self {
470 content: content.into(),
471 priority,
472 status,
473 meta: None,
474 }
475 }
476
477 #[must_use]
483 pub fn meta(mut self, meta: impl IntoOption<Meta>) -> Self {
484 self.meta = meta.into_option();
485 self
486 }
487}
488
489#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
495#[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq)]
496#[serde(rename_all = "snake_case")]
497#[non_exhaustive]
498pub enum PlanEntryPriority {
499 High,
501 Medium,
503 Low,
505 #[serde(untagged)]
511 Other(String),
512}
513
514#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
519#[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq)]
520#[serde(rename_all = "snake_case")]
521#[non_exhaustive]
522pub enum PlanEntryStatus {
523 Pending,
525 InProgress,
527 Completed,
529 Cancelled,
531 #[serde(untagged)]
537 Other(String),
538}
539
540#[cfg(test)]
541mod tests {
542 use super::*;
543
544 #[test]
545 fn plan_entry_priority_preserves_unknown_variant() {
546 let priority: PlanEntryPriority = serde_json::from_str("\"urgent\"").unwrap();
547 assert_eq!(priority, PlanEntryPriority::Other("urgent".to_string()));
548 assert_eq!(serde_json::to_value(&priority).unwrap(), "urgent");
549 }
550
551 #[test]
552 fn plan_entry_status_preserves_unknown_variant() {
553 let status: PlanEntryStatus = serde_json::from_str("\"blocked\"").unwrap();
554 assert_eq!(status, PlanEntryStatus::Other("blocked".to_string()));
555 assert_eq!(serde_json::to_value(&status).unwrap(), "blocked");
556 }
557
558 #[test]
559 fn plan_entry_status_recognizes_cancelled_variant() {
560 let status: PlanEntryStatus = serde_json::from_str("\"cancelled\"").unwrap();
561 assert_eq!(status, PlanEntryStatus::Cancelled);
562 assert_eq!(serde_json::to_value(&status).unwrap(), "cancelled");
563 }
564
565 #[test]
566 fn plan_update_content_preserves_unknown_variant() {
567 let content: PlanUpdateContent = serde_json::from_value(serde_json::json!({
568 "type": "_timeline",
569 "planId": "plan-1",
570 "events": []
571 }))
572 .unwrap();
573
574 let PlanUpdateContent::Other(unknown) = content else {
575 panic!("expected unknown plan update content");
576 };
577
578 assert_eq!(unknown.type_, "_timeline");
579 assert_eq!(unknown.plan_id.to_string(), "plan-1");
580 assert!(!unknown.fields.contains_key("planId"));
581 assert_eq!(
582 serde_json::to_value(PlanUpdateContent::Other(unknown)).unwrap(),
583 serde_json::json!({
584 "type": "_timeline",
585 "planId": "plan-1",
586 "events": []
587 })
588 );
589 }
590
591 #[test]
592 fn plan_update_content_does_not_hide_malformed_known_variant() {
593 assert!(
594 serde_json::from_value::<PlanUpdateContent>(serde_json::json!({
595 "type": "items"
596 }))
597 .is_err()
598 );
599 assert!(
600 serde_json::from_value::<PlanUpdateContent>(serde_json::json!({
601 "type": "file",
602 "planId": "plan-1"
603 }))
604 .is_err()
605 );
606 assert!(
607 serde_json::from_value::<PlanUpdateContent>(serde_json::json!({
608 "type": "markdown",
609 "planId": "plan-1"
610 }))
611 .is_err()
612 );
613 }
614
615 #[test]
616 fn plan_update_content_requires_id_for_unknown_variant() {
617 assert!(
618 serde_json::from_value::<PlanUpdateContent>(serde_json::json!({
619 "type": "_timeline"
620 }))
621 .is_err()
622 );
623 }
624}