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]
28 pub fn new(id: impl Into<Arc<str>>) -> Self {
29 Self(id.into())
30 }
31}
32
33#[skip_serializing_none]
35#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
36#[serde(rename_all = "camelCase")]
37#[non_exhaustive]
38pub struct PlanUpdate {
39 pub plan: PlanUpdateContent,
41 #[serde(rename = "_meta")]
47 pub meta: Option<Meta>,
48}
49
50impl PlanUpdate {
51 #[must_use]
52 pub fn new(plan: PlanUpdateContent) -> Self {
53 Self { plan, meta: None }
54 }
55
56 #[must_use]
62 pub fn meta(mut self, meta: impl IntoOption<Meta>) -> Self {
63 self.meta = meta.into_option();
64 self
65 }
66}
67
68#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
70#[serde(tag = "type", rename_all = "snake_case")]
71#[schemars(extend("discriminator" = {"propertyName": "type"}))]
72#[non_exhaustive]
73pub enum PlanUpdateContent {
74 Items(PlanItems),
76 #[cfg(feature = "unstable_plan_operations")]
82 File(PlanFile),
83 #[cfg(feature = "unstable_plan_operations")]
89 Markdown(PlanMarkdown),
90 #[serde(untagged)]
100 Other(OtherPlanUpdateContent),
101}
102
103#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)]
105#[schemars(inline)]
106#[schemars(transform = other_plan_update_content_schema)]
107#[serde(rename_all = "camelCase")]
108#[non_exhaustive]
109pub struct OtherPlanUpdateContent {
110 #[serde(rename = "type")]
116 pub type_: String,
117 pub id: PlanId,
119 #[serde(flatten)]
121 pub fields: BTreeMap<String, serde_json::Value>,
122}
123
124impl OtherPlanUpdateContent {
125 #[must_use]
126 pub fn new(
127 type_: impl Into<String>,
128 id: impl Into<PlanId>,
129 mut fields: BTreeMap<String, serde_json::Value>,
130 ) -> Self {
131 fields.remove("type");
132 fields.remove("id");
133 Self {
134 type_: type_.into(),
135 id: id.into(),
136 fields,
137 }
138 }
139}
140
141impl<'de> Deserialize<'de> for OtherPlanUpdateContent {
142 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
143 where
144 D: serde::Deserializer<'de>,
145 {
146 let mut fields = BTreeMap::<String, serde_json::Value>::deserialize(deserializer)?;
147 let type_ = fields
148 .remove("type")
149 .ok_or_else(|| serde::de::Error::missing_field("type"))?;
150 let serde_json::Value::String(type_) = type_ else {
151 return Err(serde::de::Error::custom("`type` must be a string"));
152 };
153 let id = fields
154 .remove("id")
155 .ok_or_else(|| serde::de::Error::missing_field("id"))?;
156 let serde_json::Value::String(id) = id else {
157 return Err(serde::de::Error::custom("`id` must be a string"));
158 };
159
160 if is_known_plan_update_content_type(&type_) {
161 return Err(serde::de::Error::custom(format!(
162 "known plan update content `{type_}` did not match its schema"
163 )));
164 }
165
166 Ok(Self {
167 type_,
168 id: PlanId::new(id),
169 fields,
170 })
171 }
172}
173
174fn is_known_plan_update_content_type(type_: &str) -> bool {
175 KNOWN_PLAN_UPDATE_CONTENT_TYPES.contains(&type_)
176}
177
178fn other_plan_update_content_schema(schema: &mut Schema) {
179 super::schema_util::reject_known_string_discriminators(
180 schema,
181 "type",
182 KNOWN_PLAN_UPDATE_CONTENT_TYPES,
183 );
184}
185
186const KNOWN_PLAN_UPDATE_CONTENT_TYPES: &[&str] = &["items", "file", "markdown"];
187
188impl PlanUpdateContent {
189 #[must_use]
190 pub fn items(id: impl Into<PlanId>, entries: Vec<PlanEntry>) -> Self {
191 Self::Items(PlanItems::new(id, entries))
192 }
193
194 #[cfg(feature = "unstable_plan_operations")]
195 #[must_use]
196 pub fn file(id: impl Into<PlanId>, uri: impl Into<String>) -> Self {
197 Self::File(PlanFile::new(id, uri))
198 }
199
200 #[cfg(feature = "unstable_plan_operations")]
201 #[must_use]
202 pub fn markdown(id: impl Into<PlanId>, content: impl Into<String>) -> Self {
203 Self::Markdown(PlanMarkdown::new(id, content))
204 }
205}
206
207#[serde_as]
209#[skip_serializing_none]
210#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
211#[serde(rename_all = "camelCase")]
212#[non_exhaustive]
213pub struct PlanItems {
214 pub id: PlanId,
216 #[serde_as(deserialize_as = "DefaultOnError<VecSkipError<_, SkipListener>>")]
221 #[schemars(extend("x-deserialize-default-on-error" = true, "x-deserialize-skip-invalid-items" = true))]
222 pub entries: Vec<PlanEntry>,
223 #[serde(rename = "_meta")]
229 pub meta: Option<Meta>,
230}
231
232impl PlanItems {
233 #[must_use]
234 pub fn new(id: impl Into<PlanId>, entries: Vec<PlanEntry>) -> Self {
235 Self {
236 id: id.into(),
237 entries,
238 meta: None,
239 }
240 }
241
242 #[must_use]
248 pub fn meta(mut self, meta: impl IntoOption<Meta>) -> Self {
249 self.meta = meta.into_option();
250 self
251 }
252}
253
254#[cfg(feature = "unstable_plan_operations")]
260#[skip_serializing_none]
261#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
262#[serde(rename_all = "camelCase")]
263#[non_exhaustive]
264pub struct PlanFile {
265 pub id: PlanId,
267 pub uri: String,
269 #[serde(rename = "_meta")]
275 pub meta: Option<Meta>,
276}
277
278#[cfg(feature = "unstable_plan_operations")]
279impl PlanFile {
280 #[must_use]
281 pub fn new(id: impl Into<PlanId>, uri: impl Into<String>) -> Self {
282 Self {
283 id: id.into(),
284 uri: uri.into(),
285 meta: None,
286 }
287 }
288
289 #[must_use]
295 pub fn meta(mut self, meta: impl IntoOption<Meta>) -> Self {
296 self.meta = meta.into_option();
297 self
298 }
299}
300
301#[cfg(feature = "unstable_plan_operations")]
307#[skip_serializing_none]
308#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
309#[serde(rename_all = "camelCase")]
310#[non_exhaustive]
311pub struct PlanMarkdown {
312 pub id: PlanId,
314 pub content: String,
316 #[serde(rename = "_meta")]
322 pub meta: Option<Meta>,
323}
324
325#[cfg(feature = "unstable_plan_operations")]
326impl PlanMarkdown {
327 #[must_use]
328 pub fn new(id: impl Into<PlanId>, content: impl Into<String>) -> Self {
329 Self {
330 id: id.into(),
331 content: content.into(),
332 meta: None,
333 }
334 }
335
336 #[must_use]
342 pub fn meta(mut self, meta: impl IntoOption<Meta>) -> Self {
343 self.meta = meta.into_option();
344 self
345 }
346}
347
348#[cfg(feature = "unstable_plan_operations")]
354#[skip_serializing_none]
355#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
356#[serde(rename_all = "camelCase")]
357#[non_exhaustive]
358pub struct PlanRemoved {
359 pub id: PlanId,
361 #[serde(rename = "_meta")]
367 pub meta: Option<Meta>,
368}
369
370#[cfg(feature = "unstable_plan_operations")]
371impl PlanRemoved {
372 #[must_use]
373 pub fn new(id: impl Into<PlanId>) -> Self {
374 Self {
375 id: id.into(),
376 meta: None,
377 }
378 }
379
380 #[must_use]
386 pub fn meta(mut self, meta: impl IntoOption<Meta>) -> Self {
387 self.meta = meta.into_option();
388 self
389 }
390}
391
392#[skip_serializing_none]
398#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
399#[serde(rename_all = "camelCase")]
400#[non_exhaustive]
401pub struct PlanEntry {
402 pub content: String,
404 pub priority: PlanEntryPriority,
407 pub status: PlanEntryStatus,
409 #[serde(rename = "_meta")]
415 pub meta: Option<Meta>,
416}
417
418impl PlanEntry {
419 #[must_use]
420 pub fn new(
421 content: impl Into<String>,
422 priority: PlanEntryPriority,
423 status: PlanEntryStatus,
424 ) -> Self {
425 Self {
426 content: content.into(),
427 priority,
428 status,
429 meta: None,
430 }
431 }
432
433 #[must_use]
439 pub fn meta(mut self, meta: impl IntoOption<Meta>) -> Self {
440 self.meta = meta.into_option();
441 self
442 }
443}
444
445#[derive(Deserialize, Serialize, JsonSchema, Debug, Clone, PartialEq, Eq)]
451#[serde(rename_all = "snake_case")]
452#[non_exhaustive]
453pub enum PlanEntryPriority {
454 High,
456 Medium,
458 Low,
460 #[serde(untagged)]
466 Other(String),
467}
468
469#[derive(Deserialize, Serialize, JsonSchema, Debug, Clone, PartialEq, Eq)]
474#[serde(rename_all = "snake_case")]
475#[non_exhaustive]
476pub enum PlanEntryStatus {
477 Pending,
479 InProgress,
481 Completed,
483 #[serde(untagged)]
489 Other(String),
490}
491
492#[cfg(test)]
493mod tests {
494 use super::*;
495
496 #[test]
497 fn plan_entry_priority_preserves_unknown_variant() {
498 let priority: PlanEntryPriority = serde_json::from_str("\"urgent\"").unwrap();
499 assert_eq!(priority, PlanEntryPriority::Other("urgent".to_string()));
500 assert_eq!(serde_json::to_value(&priority).unwrap(), "urgent");
501 }
502
503 #[test]
504 fn plan_entry_status_preserves_unknown_variant() {
505 let status: PlanEntryStatus = serde_json::from_str("\"blocked\"").unwrap();
506 assert_eq!(status, PlanEntryStatus::Other("blocked".to_string()));
507 assert_eq!(serde_json::to_value(&status).unwrap(), "blocked");
508 }
509
510 #[test]
511 fn plan_update_content_preserves_unknown_variant() {
512 let content: PlanUpdateContent = serde_json::from_value(serde_json::json!({
513 "type": "_timeline",
514 "id": "plan-1",
515 "events": []
516 }))
517 .unwrap();
518
519 let PlanUpdateContent::Other(unknown) = content else {
520 panic!("expected unknown plan update content");
521 };
522
523 assert_eq!(unknown.type_, "_timeline");
524 assert_eq!(unknown.id.to_string(), "plan-1");
525 assert!(!unknown.fields.contains_key("id"));
526 assert_eq!(
527 serde_json::to_value(PlanUpdateContent::Other(unknown)).unwrap(),
528 serde_json::json!({
529 "type": "_timeline",
530 "id": "plan-1",
531 "events": []
532 })
533 );
534 }
535
536 #[test]
537 fn plan_update_content_does_not_hide_malformed_known_variant() {
538 assert!(
539 serde_json::from_value::<PlanUpdateContent>(serde_json::json!({
540 "type": "items"
541 }))
542 .is_err()
543 );
544 assert!(
545 serde_json::from_value::<PlanUpdateContent>(serde_json::json!({
546 "type": "file",
547 "id": "plan-1"
548 }))
549 .is_err()
550 );
551 assert!(
552 serde_json::from_value::<PlanUpdateContent>(serde_json::json!({
553 "type": "markdown",
554 "id": "plan-1"
555 }))
556 .is_err()
557 );
558 }
559
560 #[test]
561 fn plan_update_content_requires_id_for_unknown_variant() {
562 assert!(
563 serde_json::from_value::<PlanUpdateContent>(serde_json::json!({
564 "type": "_timeline"
565 }))
566 .is_err()
567 );
568 }
569}