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(Arc<str>, String, &'static str)]
23#[non_exhaustive]
24pub struct PlanId(pub Arc<str>);
25
26impl PlanId {
27 #[must_use]
29 pub fn new(id: impl Into<Arc<str>>) -> Self {
30 Self(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#[schemars(extend("discriminator" = {"propertyName": "type"}))]
78#[non_exhaustive]
79pub enum PlanUpdateContent {
80 Items(PlanItems),
82 #[cfg(feature = "unstable_plan_operations")]
88 File(PlanFile),
89 #[cfg(feature = "unstable_plan_operations")]
95 Markdown(PlanMarkdown),
96 #[serde(untagged)]
106 Other(OtherPlanUpdateContent),
107}
108
109#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)]
111#[schemars(inline)]
112#[schemars(transform = other_plan_update_content_schema)]
113#[serde(rename_all = "camelCase")]
114#[non_exhaustive]
115pub struct OtherPlanUpdateContent {
116 #[serde(rename = "type")]
122 pub type_: String,
123 pub plan_id: PlanId,
125 #[serde(flatten)]
127 pub fields: BTreeMap<String, serde_json::Value>,
128}
129
130impl OtherPlanUpdateContent {
131 #[must_use]
133 pub fn new(
134 type_: impl Into<String>,
135 plan_id: impl Into<PlanId>,
136 mut fields: BTreeMap<String, serde_json::Value>,
137 ) -> Self {
138 fields.remove("type");
139 fields.remove("planId");
140 Self {
141 type_: type_.into(),
142 plan_id: plan_id.into(),
143 fields,
144 }
145 }
146}
147
148impl<'de> Deserialize<'de> for OtherPlanUpdateContent {
149 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
150 where
151 D: serde::Deserializer<'de>,
152 {
153 let mut fields = BTreeMap::<String, serde_json::Value>::deserialize(deserializer)?;
154 let type_ = fields
155 .remove("type")
156 .ok_or_else(|| serde::de::Error::missing_field("type"))?;
157 let serde_json::Value::String(type_) = type_ else {
158 return Err(serde::de::Error::custom("`type` must be a string"));
159 };
160 let plan_id = fields
161 .remove("planId")
162 .ok_or_else(|| serde::de::Error::missing_field("planId"))?;
163 let serde_json::Value::String(plan_id) = plan_id else {
164 return Err(serde::de::Error::custom("`planId` must be a string"));
165 };
166
167 if is_known_plan_update_content_type(&type_) {
168 return Err(serde::de::Error::custom(format!(
169 "known plan update content `{type_}` did not match its schema"
170 )));
171 }
172
173 Ok(Self {
174 type_,
175 plan_id: PlanId::new(plan_id),
176 fields,
177 })
178 }
179}
180
181fn is_known_plan_update_content_type(type_: &str) -> bool {
182 KNOWN_PLAN_UPDATE_CONTENT_TYPES.contains(&type_)
183}
184
185fn other_plan_update_content_schema(schema: &mut Schema) {
186 super::schema_util::reject_known_string_discriminators(
187 schema,
188 "type",
189 KNOWN_PLAN_UPDATE_CONTENT_TYPES,
190 );
191}
192
193const KNOWN_PLAN_UPDATE_CONTENT_TYPES: &[&str] = &["items", "file", "markdown"];
194
195impl PlanUpdateContent {
196 #[must_use]
198 pub fn items(plan_id: impl Into<PlanId>, entries: Vec<PlanEntry>) -> Self {
199 Self::Items(PlanItems::new(plan_id, entries))
200 }
201
202 #[cfg(feature = "unstable_plan_operations")]
204 #[must_use]
205 pub fn file(plan_id: impl Into<PlanId>, uri: impl Into<String>) -> Self {
206 Self::File(PlanFile::new(plan_id, uri))
207 }
208
209 #[cfg(feature = "unstable_plan_operations")]
211 #[must_use]
212 pub fn markdown(plan_id: impl Into<PlanId>, content: impl Into<String>) -> Self {
213 Self::Markdown(PlanMarkdown::new(plan_id, content))
214 }
215}
216
217#[serde_as]
219#[skip_serializing_none]
220#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
221#[serde(rename_all = "camelCase")]
222#[non_exhaustive]
223pub struct PlanItems {
224 pub plan_id: PlanId,
226 #[serde_as(deserialize_as = "DefaultOnError<VecSkipError<_, SkipListener>>")]
231 #[schemars(extend("x-deserialize-default-on-error" = true, "x-deserialize-skip-invalid-items" = true))]
232 pub entries: Vec<PlanEntry>,
233 #[serde_as(deserialize_as = "DefaultOnError")]
239 #[schemars(extend("x-deserialize-default-on-error" = true))]
240 #[serde(default)]
241 #[serde(rename = "_meta")]
242 pub meta: Option<Meta>,
243}
244
245impl PlanItems {
246 #[must_use]
248 pub fn new(plan_id: impl Into<PlanId>, entries: Vec<PlanEntry>) -> Self {
249 Self {
250 plan_id: plan_id.into(),
251 entries,
252 meta: None,
253 }
254 }
255
256 #[must_use]
262 pub fn meta(mut self, meta: impl IntoOption<Meta>) -> Self {
263 self.meta = meta.into_option();
264 self
265 }
266}
267
268#[cfg(feature = "unstable_plan_operations")]
274#[serde_as]
275#[skip_serializing_none]
276#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
277#[serde(rename_all = "camelCase")]
278#[non_exhaustive]
279pub struct PlanFile {
280 pub plan_id: PlanId,
282 #[schemars(url)]
284 pub uri: String,
285 #[serde_as(deserialize_as = "DefaultOnError")]
291 #[schemars(extend("x-deserialize-default-on-error" = true))]
292 #[serde(default)]
293 #[serde(rename = "_meta")]
294 pub meta: Option<Meta>,
295}
296
297#[cfg(feature = "unstable_plan_operations")]
298impl PlanFile {
299 #[must_use]
301 pub fn new(plan_id: impl Into<PlanId>, uri: impl Into<String>) -> Self {
302 Self {
303 plan_id: plan_id.into(),
304 uri: uri.into(),
305 meta: None,
306 }
307 }
308
309 #[must_use]
315 pub fn meta(mut self, meta: impl IntoOption<Meta>) -> Self {
316 self.meta = meta.into_option();
317 self
318 }
319}
320
321#[cfg(feature = "unstable_plan_operations")]
327#[serde_as]
328#[skip_serializing_none]
329#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
330#[serde(rename_all = "camelCase")]
331#[non_exhaustive]
332pub struct PlanMarkdown {
333 pub plan_id: PlanId,
335 pub content: String,
337 #[serde_as(deserialize_as = "DefaultOnError")]
343 #[schemars(extend("x-deserialize-default-on-error" = true))]
344 #[serde(default)]
345 #[serde(rename = "_meta")]
346 pub meta: Option<Meta>,
347}
348
349#[cfg(feature = "unstable_plan_operations")]
350impl PlanMarkdown {
351 #[must_use]
353 pub fn new(plan_id: impl Into<PlanId>, content: impl Into<String>) -> Self {
354 Self {
355 plan_id: plan_id.into(),
356 content: content.into(),
357 meta: None,
358 }
359 }
360
361 #[must_use]
367 pub fn meta(mut self, meta: impl IntoOption<Meta>) -> Self {
368 self.meta = meta.into_option();
369 self
370 }
371}
372
373#[cfg(feature = "unstable_plan_operations")]
379#[serde_as]
380#[skip_serializing_none]
381#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
382#[serde(rename_all = "camelCase")]
383#[non_exhaustive]
384pub struct PlanRemoved {
385 pub plan_id: PlanId,
387 #[serde_as(deserialize_as = "DefaultOnError")]
393 #[schemars(extend("x-deserialize-default-on-error" = true))]
394 #[serde(default)]
395 #[serde(rename = "_meta")]
396 pub meta: Option<Meta>,
397}
398
399#[cfg(feature = "unstable_plan_operations")]
400impl PlanRemoved {
401 #[must_use]
403 pub fn new(plan_id: impl Into<PlanId>) -> Self {
404 Self {
405 plan_id: plan_id.into(),
406 meta: None,
407 }
408 }
409
410 #[must_use]
416 pub fn meta(mut self, meta: impl IntoOption<Meta>) -> Self {
417 self.meta = meta.into_option();
418 self
419 }
420}
421
422#[serde_as]
428#[skip_serializing_none]
429#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
430#[serde(rename_all = "camelCase")]
431#[non_exhaustive]
432pub struct PlanEntry {
433 pub content: String,
435 pub priority: PlanEntryPriority,
438 pub status: PlanEntryStatus,
440 #[serde_as(deserialize_as = "DefaultOnError")]
446 #[schemars(extend("x-deserialize-default-on-error" = true))]
447 #[serde(default)]
448 #[serde(rename = "_meta")]
449 pub meta: Option<Meta>,
450}
451
452impl PlanEntry {
453 #[must_use]
455 pub fn new(
456 content: impl Into<String>,
457 priority: PlanEntryPriority,
458 status: PlanEntryStatus,
459 ) -> Self {
460 Self {
461 content: content.into(),
462 priority,
463 status,
464 meta: None,
465 }
466 }
467
468 #[must_use]
474 pub fn meta(mut self, meta: impl IntoOption<Meta>) -> Self {
475 self.meta = meta.into_option();
476 self
477 }
478}
479
480#[derive(Deserialize, Serialize, JsonSchema, Debug, Clone, PartialEq, Eq)]
486#[serde(rename_all = "snake_case")]
487#[non_exhaustive]
488pub enum PlanEntryPriority {
489 High,
491 Medium,
493 Low,
495 #[serde(untagged)]
501 Other(String),
502}
503
504#[derive(Deserialize, Serialize, JsonSchema, Debug, Clone, PartialEq, Eq)]
509#[serde(rename_all = "snake_case")]
510#[non_exhaustive]
511pub enum PlanEntryStatus {
512 Pending,
514 InProgress,
516 Completed,
518 #[serde(untagged)]
524 Other(String),
525}
526
527#[cfg(test)]
528mod tests {
529 use super::*;
530
531 #[test]
532 fn plan_entry_priority_preserves_unknown_variant() {
533 let priority: PlanEntryPriority = serde_json::from_str("\"urgent\"").unwrap();
534 assert_eq!(priority, PlanEntryPriority::Other("urgent".to_string()));
535 assert_eq!(serde_json::to_value(&priority).unwrap(), "urgent");
536 }
537
538 #[test]
539 fn plan_entry_status_preserves_unknown_variant() {
540 let status: PlanEntryStatus = serde_json::from_str("\"blocked\"").unwrap();
541 assert_eq!(status, PlanEntryStatus::Other("blocked".to_string()));
542 assert_eq!(serde_json::to_value(&status).unwrap(), "blocked");
543 }
544
545 #[test]
546 fn plan_update_content_preserves_unknown_variant() {
547 let content: PlanUpdateContent = serde_json::from_value(serde_json::json!({
548 "type": "_timeline",
549 "planId": "plan-1",
550 "events": []
551 }))
552 .unwrap();
553
554 let PlanUpdateContent::Other(unknown) = content else {
555 panic!("expected unknown plan update content");
556 };
557
558 assert_eq!(unknown.type_, "_timeline");
559 assert_eq!(unknown.plan_id.to_string(), "plan-1");
560 assert!(!unknown.fields.contains_key("planId"));
561 assert_eq!(
562 serde_json::to_value(PlanUpdateContent::Other(unknown)).unwrap(),
563 serde_json::json!({
564 "type": "_timeline",
565 "planId": "plan-1",
566 "events": []
567 })
568 );
569 }
570
571 #[test]
572 fn plan_update_content_does_not_hide_malformed_known_variant() {
573 assert!(
574 serde_json::from_value::<PlanUpdateContent>(serde_json::json!({
575 "type": "items"
576 }))
577 .is_err()
578 );
579 assert!(
580 serde_json::from_value::<PlanUpdateContent>(serde_json::json!({
581 "type": "file",
582 "planId": "plan-1"
583 }))
584 .is_err()
585 );
586 assert!(
587 serde_json::from_value::<PlanUpdateContent>(serde_json::json!({
588 "type": "markdown",
589 "planId": "plan-1"
590 }))
591 .is_err()
592 );
593 }
594
595 #[test]
596 fn plan_update_content_requires_id_for_unknown_variant() {
597 assert!(
598 serde_json::from_value::<PlanUpdateContent>(serde_json::json!({
599 "type": "_timeline"
600 }))
601 .is_err()
602 );
603 }
604}