1use std::{collections::BTreeMap, sync::Arc};
9
10use derive_more::{Display, From};
11use schemars::JsonSchema;
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#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Hash, Display, From)]
21#[serde(transparent)]
22#[from(forward)]
23#[non_exhaustive]
24pub struct PlanId(pub Arc<str>);
25
26impl PlanId {
27 #[must_use]
29 pub fn new(id: impl Into<Self>) -> Self {
30 id.into()
31 }
32}
33
34#[serde_as]
36#[skip_serializing_none]
37#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
38#[serde(rename_all = "camelCase")]
39#[non_exhaustive]
40pub struct PlanUpdate {
41 pub plan: PlanUpdateContent,
43 #[serde_as(deserialize_as = "DefaultOnError")]
49 #[schemars(extend("x-deserialize-default-on-error" = true))]
50 #[serde(default)]
51 #[serde(rename = "_meta")]
52 pub meta: Option<Meta>,
53}
54
55impl PlanUpdate {
56 #[must_use]
58 pub fn new(plan: PlanUpdateContent) -> Self {
59 Self { plan, meta: None }
60 }
61
62 #[must_use]
68 pub fn meta(mut self, meta: impl IntoOption<Meta>) -> Self {
69 self.meta = meta.into_option();
70 self
71 }
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
76#[serde(tag = "type", rename_all = "snake_case")]
77#[non_exhaustive]
78pub enum PlanUpdateContent {
79 Items(PlanItems),
81 #[cfg(feature = "unstable_plan_operations")]
87 File(PlanFile),
88 #[cfg(feature = "unstable_plan_operations")]
94 Markdown(PlanMarkdown),
95 #[serde(untagged)]
105 Other(OtherPlanUpdateContent),
106}
107
108#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)]
110#[schemars(inline)]
111#[schemars(transform = other_plan_update_content_schema)]
112#[serde(rename_all = "camelCase")]
113#[non_exhaustive]
114pub struct OtherPlanUpdateContent {
115 #[serde(rename = "type")]
121 pub type_: String,
122 pub plan_id: PlanId,
124 #[serde(flatten)]
126 pub fields: BTreeMap<String, serde_json::Value>,
127}
128
129impl OtherPlanUpdateContent {
130 #[must_use]
132 pub fn new(
133 type_: impl Into<String>,
134 plan_id: impl Into<PlanId>,
135 mut fields: BTreeMap<String, serde_json::Value>,
136 ) -> Self {
137 fields.remove("type");
138 fields.remove("planId");
139 Self {
140 type_: type_.into(),
141 plan_id: plan_id.into(),
142 fields,
143 }
144 }
145}
146
147impl<'de> Deserialize<'de> for OtherPlanUpdateContent {
148 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
149 where
150 D: serde::Deserializer<'de>,
151 {
152 let mut fields = BTreeMap::<String, serde_json::Value>::deserialize(deserializer)?;
153 let type_ = fields
154 .remove("type")
155 .ok_or_else(|| serde::de::Error::missing_field("type"))?;
156 let serde_json::Value::String(type_) = type_ else {
157 return Err(serde::de::Error::custom("`type` must be a string"));
158 };
159 let plan_id = fields
160 .remove("planId")
161 .ok_or_else(|| serde::de::Error::missing_field("planId"))?;
162 let serde_json::Value::String(plan_id) = plan_id else {
163 return Err(serde::de::Error::custom("`planId` must be a string"));
164 };
165
166 if is_known_plan_update_content_type(&type_) {
167 return Err(serde::de::Error::custom(format!(
168 "known plan update content `{type_}` did not match its schema"
169 )));
170 }
171
172 Ok(Self {
173 type_,
174 plan_id: PlanId::new(plan_id),
175 fields,
176 })
177 }
178}
179
180fn is_known_plan_update_content_type(type_: &str) -> bool {
181 KNOWN_PLAN_UPDATE_CONTENT_TYPES.contains(&type_)
182}
183
184fn other_plan_update_content_schema(schema: &mut Schema) {
185 super::schema_util::reject_known_string_discriminators(
186 schema,
187 "type",
188 KNOWN_PLAN_UPDATE_CONTENT_TYPES,
189 );
190}
191
192const KNOWN_PLAN_UPDATE_CONTENT_TYPES: &[&str] = &["items", "file", "markdown"];
193
194impl PlanUpdateContent {
195 #[must_use]
197 pub fn items(plan_id: impl Into<PlanId>, entries: Vec<PlanEntry>) -> Self {
198 Self::Items(PlanItems::new(plan_id, entries))
199 }
200
201 #[cfg(feature = "unstable_plan_operations")]
203 #[must_use]
204 pub fn file(plan_id: impl Into<PlanId>, uri: impl Into<String>) -> Self {
205 Self::File(PlanFile::new(plan_id, uri))
206 }
207
208 #[cfg(feature = "unstable_plan_operations")]
210 #[must_use]
211 pub fn markdown(plan_id: impl Into<PlanId>, content: impl Into<String>) -> Self {
212 Self::Markdown(PlanMarkdown::new(plan_id, content))
213 }
214}
215
216#[serde_as]
218#[skip_serializing_none]
219#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
220#[serde(rename_all = "camelCase")]
221#[non_exhaustive]
222pub struct PlanItems {
223 pub plan_id: PlanId,
225 #[serde_as(deserialize_as = "DefaultOnError<VecSkipError<_, SkipListener>>")]
230 #[schemars(extend("x-deserialize-default-on-error" = true, "x-deserialize-skip-invalid-items" = true))]
231 pub entries: Vec<PlanEntry>,
232 #[serde_as(deserialize_as = "DefaultOnError")]
238 #[schemars(extend("x-deserialize-default-on-error" = true))]
239 #[serde(default)]
240 #[serde(rename = "_meta")]
241 pub meta: Option<Meta>,
242}
243
244impl PlanItems {
245 #[must_use]
247 pub fn new(plan_id: impl Into<PlanId>, entries: Vec<PlanEntry>) -> Self {
248 Self {
249 plan_id: plan_id.into(),
250 entries,
251 meta: None,
252 }
253 }
254
255 #[must_use]
261 pub fn meta(mut self, meta: impl IntoOption<Meta>) -> Self {
262 self.meta = meta.into_option();
263 self
264 }
265}
266
267#[cfg(feature = "unstable_plan_operations")]
273#[serde_as]
274#[skip_serializing_none]
275#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
276#[serde(rename_all = "camelCase")]
277#[non_exhaustive]
278pub struct PlanFile {
279 pub plan_id: PlanId,
281 #[schemars(url)]
283 pub uri: String,
284 #[serde_as(deserialize_as = "DefaultOnError")]
290 #[schemars(extend("x-deserialize-default-on-error" = true))]
291 #[serde(default)]
292 #[serde(rename = "_meta")]
293 pub meta: Option<Meta>,
294}
295
296#[cfg(feature = "unstable_plan_operations")]
297impl PlanFile {
298 #[must_use]
300 pub fn new(plan_id: impl Into<PlanId>, uri: impl Into<String>) -> Self {
301 Self {
302 plan_id: plan_id.into(),
303 uri: uri.into(),
304 meta: None,
305 }
306 }
307
308 #[must_use]
314 pub fn meta(mut self, meta: impl IntoOption<Meta>) -> Self {
315 self.meta = meta.into_option();
316 self
317 }
318}
319
320#[cfg(feature = "unstable_plan_operations")]
326#[serde_as]
327#[skip_serializing_none]
328#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
329#[serde(rename_all = "camelCase")]
330#[non_exhaustive]
331pub struct PlanMarkdown {
332 pub plan_id: PlanId,
334 pub content: String,
336 #[serde_as(deserialize_as = "DefaultOnError")]
342 #[schemars(extend("x-deserialize-default-on-error" = true))]
343 #[serde(default)]
344 #[serde(rename = "_meta")]
345 pub meta: Option<Meta>,
346}
347
348#[cfg(feature = "unstable_plan_operations")]
349impl PlanMarkdown {
350 #[must_use]
352 pub fn new(plan_id: impl Into<PlanId>, content: impl Into<String>) -> Self {
353 Self {
354 plan_id: plan_id.into(),
355 content: content.into(),
356 meta: None,
357 }
358 }
359
360 #[must_use]
366 pub fn meta(mut self, meta: impl IntoOption<Meta>) -> Self {
367 self.meta = meta.into_option();
368 self
369 }
370}
371
372#[cfg(feature = "unstable_plan_operations")]
378#[serde_as]
379#[skip_serializing_none]
380#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
381#[serde(rename_all = "camelCase")]
382#[non_exhaustive]
383pub struct PlanRemoved {
384 pub plan_id: PlanId,
386 #[serde_as(deserialize_as = "DefaultOnError")]
392 #[schemars(extend("x-deserialize-default-on-error" = true))]
393 #[serde(default)]
394 #[serde(rename = "_meta")]
395 pub meta: Option<Meta>,
396}
397
398#[cfg(feature = "unstable_plan_operations")]
399impl PlanRemoved {
400 #[must_use]
402 pub fn new(plan_id: impl Into<PlanId>) -> Self {
403 Self {
404 plan_id: plan_id.into(),
405 meta: None,
406 }
407 }
408
409 #[must_use]
415 pub fn meta(mut self, meta: impl IntoOption<Meta>) -> Self {
416 self.meta = meta.into_option();
417 self
418 }
419}
420
421#[serde_as]
427#[skip_serializing_none]
428#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
429#[serde(rename_all = "camelCase")]
430#[non_exhaustive]
431pub struct PlanEntry {
432 pub content: String,
434 pub priority: PlanEntryPriority,
437 pub status: PlanEntryStatus,
439 #[serde_as(deserialize_as = "DefaultOnError")]
445 #[schemars(extend("x-deserialize-default-on-error" = true))]
446 #[serde(default)]
447 #[serde(rename = "_meta")]
448 pub meta: Option<Meta>,
449}
450
451impl PlanEntry {
452 #[must_use]
454 pub fn new(
455 content: impl Into<String>,
456 priority: PlanEntryPriority,
457 status: PlanEntryStatus,
458 ) -> Self {
459 Self {
460 content: content.into(),
461 priority,
462 status,
463 meta: None,
464 }
465 }
466
467 #[must_use]
473 pub fn meta(mut self, meta: impl IntoOption<Meta>) -> Self {
474 self.meta = meta.into_option();
475 self
476 }
477}
478
479#[derive(Deserialize, Serialize, JsonSchema, Debug, Clone, PartialEq, Eq)]
485#[serde(rename_all = "snake_case")]
486#[non_exhaustive]
487pub enum PlanEntryPriority {
488 High,
490 Medium,
492 Low,
494 #[serde(untagged)]
500 Other(String),
501}
502
503#[derive(Deserialize, Serialize, JsonSchema, Debug, Clone, PartialEq, Eq)]
508#[serde(rename_all = "snake_case")]
509#[non_exhaustive]
510pub enum PlanEntryStatus {
511 Pending,
513 InProgress,
515 Completed,
517 Cancelled,
519 #[serde(untagged)]
525 Other(String),
526}
527
528#[cfg(test)]
529mod tests {
530 use super::*;
531
532 #[test]
533 fn plan_entry_priority_preserves_unknown_variant() {
534 let priority: PlanEntryPriority = serde_json::from_str("\"urgent\"").unwrap();
535 assert_eq!(priority, PlanEntryPriority::Other("urgent".to_string()));
536 assert_eq!(serde_json::to_value(&priority).unwrap(), "urgent");
537 }
538
539 #[test]
540 fn plan_entry_status_preserves_unknown_variant() {
541 let status: PlanEntryStatus = serde_json::from_str("\"blocked\"").unwrap();
542 assert_eq!(status, PlanEntryStatus::Other("blocked".to_string()));
543 assert_eq!(serde_json::to_value(&status).unwrap(), "blocked");
544 }
545
546 #[test]
547 fn plan_entry_status_recognizes_cancelled_variant() {
548 let status: PlanEntryStatus = serde_json::from_str("\"cancelled\"").unwrap();
549 assert_eq!(status, PlanEntryStatus::Cancelled);
550 assert_eq!(serde_json::to_value(&status).unwrap(), "cancelled");
551 }
552
553 #[test]
554 fn plan_update_content_preserves_unknown_variant() {
555 let content: PlanUpdateContent = serde_json::from_value(serde_json::json!({
556 "type": "_timeline",
557 "planId": "plan-1",
558 "events": []
559 }))
560 .unwrap();
561
562 let PlanUpdateContent::Other(unknown) = content else {
563 panic!("expected unknown plan update content");
564 };
565
566 assert_eq!(unknown.type_, "_timeline");
567 assert_eq!(unknown.plan_id.to_string(), "plan-1");
568 assert!(!unknown.fields.contains_key("planId"));
569 assert_eq!(
570 serde_json::to_value(PlanUpdateContent::Other(unknown)).unwrap(),
571 serde_json::json!({
572 "type": "_timeline",
573 "planId": "plan-1",
574 "events": []
575 })
576 );
577 }
578
579 #[test]
580 fn plan_update_content_does_not_hide_malformed_known_variant() {
581 assert!(
582 serde_json::from_value::<PlanUpdateContent>(serde_json::json!({
583 "type": "items"
584 }))
585 .is_err()
586 );
587 assert!(
588 serde_json::from_value::<PlanUpdateContent>(serde_json::json!({
589 "type": "file",
590 "planId": "plan-1"
591 }))
592 .is_err()
593 );
594 assert!(
595 serde_json::from_value::<PlanUpdateContent>(serde_json::json!({
596 "type": "markdown",
597 "planId": "plan-1"
598 }))
599 .is_err()
600 );
601 }
602
603 #[test]
604 fn plan_update_content_requires_id_for_unknown_variant() {
605 assert!(
606 serde_json::from_value::<PlanUpdateContent>(serde_json::json!({
607 "type": "_timeline"
608 }))
609 .is_err()
610 );
611 }
612}