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 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 id: impl Into<PlanId>,
136 mut fields: BTreeMap<String, serde_json::Value>,
137 ) -> Self {
138 fields.remove("type");
139 fields.remove("id");
140 Self {
141 type_: type_.into(),
142 id: 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 id = fields
161 .remove("id")
162 .ok_or_else(|| serde::de::Error::missing_field("id"))?;
163 let serde_json::Value::String(id) = id else {
164 return Err(serde::de::Error::custom("`id` 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 id: PlanId::new(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(id: impl Into<PlanId>, entries: Vec<PlanEntry>) -> Self {
199 Self::Items(PlanItems::new(id, entries))
200 }
201
202 #[cfg(feature = "unstable_plan_operations")]
204 #[must_use]
205 pub fn file(id: impl Into<PlanId>, uri: impl Into<String>) -> Self {
206 Self::File(PlanFile::new(id, uri))
207 }
208
209 #[cfg(feature = "unstable_plan_operations")]
211 #[must_use]
212 pub fn markdown(id: impl Into<PlanId>, content: impl Into<String>) -> Self {
213 Self::Markdown(PlanMarkdown::new(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 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(id: impl Into<PlanId>, entries: Vec<PlanEntry>) -> Self {
249 Self {
250 id: 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 id: PlanId,
282 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(id: impl Into<PlanId>, uri: impl Into<String>) -> Self {
301 Self {
302 id: 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 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(id: impl Into<PlanId>, content: impl Into<String>) -> Self {
353 Self {
354 id: 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 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(id: impl Into<PlanId>) -> Self {
403 Self {
404 id: 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 #[serde(untagged)]
523 Other(String),
524}
525
526#[cfg(test)]
527mod tests {
528 use super::*;
529
530 #[test]
531 fn plan_entry_priority_preserves_unknown_variant() {
532 let priority: PlanEntryPriority = serde_json::from_str("\"urgent\"").unwrap();
533 assert_eq!(priority, PlanEntryPriority::Other("urgent".to_string()));
534 assert_eq!(serde_json::to_value(&priority).unwrap(), "urgent");
535 }
536
537 #[test]
538 fn plan_entry_status_preserves_unknown_variant() {
539 let status: PlanEntryStatus = serde_json::from_str("\"blocked\"").unwrap();
540 assert_eq!(status, PlanEntryStatus::Other("blocked".to_string()));
541 assert_eq!(serde_json::to_value(&status).unwrap(), "blocked");
542 }
543
544 #[test]
545 fn plan_update_content_preserves_unknown_variant() {
546 let content: PlanUpdateContent = serde_json::from_value(serde_json::json!({
547 "type": "_timeline",
548 "id": "plan-1",
549 "events": []
550 }))
551 .unwrap();
552
553 let PlanUpdateContent::Other(unknown) = content else {
554 panic!("expected unknown plan update content");
555 };
556
557 assert_eq!(unknown.type_, "_timeline");
558 assert_eq!(unknown.id.to_string(), "plan-1");
559 assert!(!unknown.fields.contains_key("id"));
560 assert_eq!(
561 serde_json::to_value(PlanUpdateContent::Other(unknown)).unwrap(),
562 serde_json::json!({
563 "type": "_timeline",
564 "id": "plan-1",
565 "events": []
566 })
567 );
568 }
569
570 #[test]
571 fn plan_update_content_does_not_hide_malformed_known_variant() {
572 assert!(
573 serde_json::from_value::<PlanUpdateContent>(serde_json::json!({
574 "type": "items"
575 }))
576 .is_err()
577 );
578 assert!(
579 serde_json::from_value::<PlanUpdateContent>(serde_json::json!({
580 "type": "file",
581 "id": "plan-1"
582 }))
583 .is_err()
584 );
585 assert!(
586 serde_json::from_value::<PlanUpdateContent>(serde_json::json!({
587 "type": "markdown",
588 "id": "plan-1"
589 }))
590 .is_err()
591 );
592 }
593
594 #[test]
595 fn plan_update_content_requires_id_for_unknown_variant() {
596 assert!(
597 serde_json::from_value::<PlanUpdateContent>(serde_json::json!({
598 "type": "_timeline"
599 }))
600 .is_err()
601 );
602 }
603}