Skip to main content

autumn_web/
hooks.rs

1//! Mutation hook types for repository lifecycle callbacks.
2//!
3//! This module provides the types and trait that power before/after mutation
4//! hooks on generated repositories (create, update, delete).
5//!
6//! # Opting in
7//!
8//! By default, repositories generated by `#[repository(Model)]` perform
9//! plain CRUD with no hook overhead. To enable hooks, pass a hooks type:
10//!
11//! ```rust,ignore
12//! #[derive(Clone, Default)]
13//! struct ArticleHooks;
14//!
15//! impl MutationHooks for ArticleHooks {
16//!     type Model = Article;
17//!     type NewModel = NewArticle;
18//!     type UpdateModel = UpdateArticle;
19//!
20//!     // override only the callbacks you need …
21//! }
22//!
23//! #[repository(Article, hooks = ArticleHooks)]
24//! pub trait ArticleRepository {}
25//! ```
26//!
27//! The hooks type **must** implement [`Default`] and [`Clone`] (the
28//! generated extractor constructs it via `Default::default()` and the
29//! repository struct derives `Clone`).
30//!
31//! # Lifecycle
32//!
33//! Every mutating CRUD operation follows the same lifecycle:
34//!
35//! 1. **`before_*`** -- called before the mutation with a mutable
36//!    reference to the [`MutationContext`] and the input/model. The hook
37//!    may validate, enrich, or reject (by returning `Err`).
38//! 2. **persist** -- the actual `INSERT`, `UPDATE`, or `DELETE` runs.
39//!
40//! # Error semantics
41//!
42//! - `before_*` errors prevent the mutation entirely.
43//!
44//! # Helper types
45//!
46//! - [`Patch<T>`] -- tri-state value for partial-update (PATCH) payloads.
47//! - [`FieldDiff<T>`] -- per-field before/after diff for inspecting and
48//!   overriding changes inside hooks.
49//! - [`MutationOp`] -- discriminant for create / update / delete.
50//! - [`MutationContext`] -- carries actor identity, request ID, and
51//!   timestamp into every hook invocation.
52
53use serde::de::Deserializer;
54use serde::{Deserialize, Serialize};
55
56// ── Mutation operation & context ─────────────────────────────────────
57
58/// The kind of mutation being performed on a repository record.
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60#[non_exhaustive]
61pub enum MutationOp {
62    /// A new record is being created.
63    Create,
64    /// An existing record is being updated.
65    Update,
66    /// An existing record is being deleted.
67    Delete,
68}
69
70impl MutationOp {
71    /// Returns the operation name as a static string slice.
72    #[must_use]
73    pub const fn as_str(self) -> &'static str {
74        match self {
75            Self::Create => "create",
76            Self::Update => "update",
77            Self::Delete => "delete",
78        }
79    }
80}
81
82impl std::fmt::Display for MutationOp {
83    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
84        f.write_str(self.as_str())
85    }
86}
87
88/// Context available to mutation hooks.
89///
90/// Carries actor identity, request metadata, and timestamps so that
91/// hook implementations can perform auditing, validation, or enrichment.
92#[derive(Debug, Clone)]
93pub struct MutationContext {
94    /// The mutation operation type.
95    pub op: MutationOp,
96    /// Actor identity (user ID or service name). `None` for anonymous.
97    pub actor: Option<String>,
98    /// Correlation / request ID for tracing.
99    pub request_id: Option<String>,
100    /// Timestamp of the mutation.
101    pub now: chrono::DateTime<chrono::Utc>,
102    /// Cache keys to invalidate after the mutation.
103    pub invalidate_keys: Vec<String>,
104}
105
106impl MutationContext {
107    /// Create a new context for the given operation.
108    ///
109    /// Auto-populates `now` with `Utc::now()` and `request_id` with a
110    /// freshly generated UUID v4.
111    #[must_use]
112    pub fn new(op: MutationOp) -> Self {
113        Self {
114            op,
115            actor: None,
116            request_id: Some(uuid::Uuid::new_v4().to_string()),
117            now: chrono::Utc::now(),
118            invalidate_keys: Vec::new(),
119        }
120    }
121
122    /// Add a cache key to the invalidation list.
123    pub fn invalidate(&mut self, key: impl Into<String>) {
124        self.invalidate_keys.push(key.into());
125    }
126}
127
128// ── Mutation hooks trait ─────────────────────────────────────────────
129
130use crate::AutumnResult;
131use std::future::Future;
132
133/// Repository-scoped mutation lifecycle hooks.
134///
135/// All methods have default no-op implementations, so you only need to
136/// override the callbacks you care about. Each method receives a
137/// [`MutationContext`] (mutable for "before" hooks) and the relevant
138/// model/changeset.
139///
140/// # Associated types
141///
142/// - `Model` -- the read model returned by queries (e.g., `User`).
143/// - `NewModel` -- the insertable changeset (e.g., `NewUser`).
144/// - `UpdateModel` -- the partial-update changeset (e.g., `UpdateUser`).
145pub trait MutationHooks: Send + Sync + 'static {
146    /// The read model (e.g., the `Queryable` struct).
147    type Model: Send + Sync;
148    /// The insertable changeset for new records.
149    type NewModel: Send + Sync;
150    /// The partial-update changeset.
151    type UpdateModel: Send + Sync;
152
153    /// Called before a new record is inserted.
154    fn before_create(
155        &self,
156        _ctx: &mut MutationContext,
157        _new: &mut Self::NewModel,
158    ) -> impl Future<Output = AutumnResult<()>> + Send {
159        async { Ok(()) }
160    }
161
162    /// Called before an existing record is updated.
163    ///
164    /// The `draft` holds the merged before/after state. Use per-field
165    /// accessors (generated by `#[model]`) to inspect and rewrite:
166    ///
167    /// ```rust,ignore
168    /// if draft.status().changed_to(&Status::Approved) {
169    ///     draft.approved_at().set(Some(ctx.now));
170    /// }
171    /// ```
172    fn before_update(
173        &self,
174        _ctx: &mut MutationContext,
175        _draft: &mut UpdateDraft<Self::Model>,
176    ) -> impl Future<Output = AutumnResult<()>> + Send
177    where
178        Self::Model: Clone,
179    {
180        async { Ok(()) }
181    }
182
183    /// Called before an existing record is deleted.
184    fn before_delete(
185        &self,
186        _ctx: &mut MutationContext,
187        _record: &Self::Model,
188    ) -> impl Future<Output = AutumnResult<()>> + Send {
189        async { Ok(()) }
190    }
191
192    /// Called after a new record is inserted.
193    fn after_create(
194        &self,
195        _ctx: &mut MutationContext,
196        _record: &Self::Model,
197    ) -> impl Future<Output = AutumnResult<()>> + Send {
198        async { Ok(()) }
199    }
200
201    /// Called after an existing record is updated.
202    fn after_update(
203        &self,
204        _ctx: &mut MutationContext,
205        _record: &Self::Model,
206    ) -> impl Future<Output = AutumnResult<()>> + Send {
207        async { Ok(()) }
208    }
209}
210
211/// Stable hook-construction contract used by generated repositories.
212///
213/// This indirection keeps compile-time diagnostics anchored on Autumn-owned
214/// trait names instead of compiler-rendered `Default` wording, which is more
215/// brittle across platforms and environments.
216pub trait RepositoryHooksDefault: Sized {
217    /// Construct a hook instance for generated repository state.
218    fn autumn_default() -> Self;
219}
220
221impl<T> RepositoryHooksDefault for T
222where
223    T: Default,
224{
225    fn autumn_default() -> Self {
226        Self::default()
227    }
228}
229
230/// Stable hook-cloning contract used by generated repositories.
231///
232/// Generated repository implementations clone their hook state through this
233/// trait so compile-fail diagnostics stay deterministic across toolchains.
234pub trait RepositoryHooksClone: Sized {
235    /// Clone hook state for generated repository values.
236    #[must_use]
237    fn autumn_clone(&self) -> Self;
238}
239
240impl<T> RepositoryHooksClone for T
241where
242    T: Clone,
243{
244    fn autumn_clone(&self) -> Self {
245        self.clone()
246    }
247}
248
249// ── Default no-op hooks ──────────────────────────────────────────────
250
251/// Zero-cost no-op implementation of [`MutationHooks`].
252///
253/// Used by generated repository code when the user has not configured any
254/// hooks. All methods use the trait defaults (immediate `Ok(())`).
255pub struct NoHooks<M, N, U> {
256    _phantom: std::marker::PhantomData<(M, N, U)>,
257}
258
259impl<M, N, U> Default for NoHooks<M, N, U> {
260    fn default() -> Self {
261        Self {
262            _phantom: std::marker::PhantomData,
263        }
264    }
265}
266
267impl<M, N, U> MutationHooks for NoHooks<M, N, U>
268where
269    M: Send + Sync + Clone + 'static,
270    N: Send + Sync + 'static,
271    U: Send + Sync + 'static,
272{
273    type Model = M;
274    type NewModel = N;
275    type UpdateModel = U;
276}
277
278/// Tri-state sparse update value.
279///
280/// `Patch<T>` distinguishes between "field not mentioned" ([`Unchanged`](Patch::Unchanged)),
281/// "field explicitly set" ([`Set`](Patch::Set)), and "field explicitly cleared"
282/// ([`Clear`](Patch::Clear), mapping to SQL `NULL`).
283///
284/// This is the building block for partial-update (PATCH) payloads where
285/// omitting a field means "leave it alone" rather than "set it to its
286/// default".
287#[derive(Debug, Clone, Default, PartialEq, Eq)]
288pub enum Patch<T> {
289    /// The field was not included in the update payload.
290    #[default]
291    Unchanged,
292    /// The field was explicitly set to a new value.
293    Set(T),
294    /// The field was explicitly cleared (maps to SQL `NULL`).
295    Clear,
296}
297
298impl<T> Patch<T> {
299    /// Returns `true` if the field was not included in the update.
300    #[must_use]
301    pub const fn is_unchanged(&self) -> bool {
302        matches!(self, Self::Unchanged)
303    }
304
305    /// Returns `true` if the field was explicitly set to a new value.
306    #[must_use]
307    pub const fn is_set(&self) -> bool {
308        matches!(self, Self::Set(_))
309    }
310
311    /// Returns `true` if the field was explicitly cleared.
312    #[must_use]
313    pub const fn is_clear(&self) -> bool {
314        matches!(self, Self::Clear)
315    }
316
317    /// Returns a reference to the inner value if [`Set`](Patch::Set), or `None`.
318    #[must_use]
319    pub const fn as_set(&self) -> Option<&T> {
320        match self {
321            Self::Set(v) => Some(v),
322            _ => None,
323        }
324    }
325
326    /// Converts into a nested `Option`:
327    ///
328    /// - `Set(v)` -> `Some(Some(v))`
329    /// - `Clear` -> `Some(None)`
330    /// - `Unchanged` -> `None`
331    #[must_use]
332    pub fn into_option(self) -> Option<Option<T>> {
333        match self {
334            Self::Set(v) => Some(Some(v)),
335            Self::Clear => Some(None),
336            Self::Unchanged => None,
337        }
338    }
339}
340
341impl<T: Serialize> Serialize for Patch<T> {
342    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
343        match self {
344            Self::Unchanged | Self::Clear => serializer.serialize_none(),
345            Self::Set(v) => v.serialize(serializer),
346        }
347    }
348}
349
350impl<'de, T: Deserialize<'de>> Deserialize<'de> for Patch<T> {
351    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
352        // When this method is called, the field WAS present in the JSON.
353        // Absent fields use the Default impl (→ Unchanged) via #[serde(default)].
354        let opt: Option<T> = Option::deserialize(deserializer)?;
355        Ok(opt.map_or_else(|| Self::Clear, Self::Set))
356    }
357}
358
359/// Per-field before/after diff accessor for mutation hooks.
360///
361/// `FieldDiff<T>` holds the previous and proposed values for a single field,
362/// allowing hook authors to inspect what changed and optionally override the
363/// new value via [`set`](FieldDiff::set).
364#[derive(Debug, Clone, PartialEq, Eq)]
365pub struct FieldDiff<T> {
366    before: T,
367    after: T,
368}
369
370impl<T: PartialEq> FieldDiff<T> {
371    /// Create a new diff from before and after values.
372    #[must_use]
373    pub const fn new(before: T, after: T) -> Self {
374        Self { before, after }
375    }
376
377    /// Reference to the value before the mutation.
378    #[must_use]
379    pub const fn before(&self) -> &T {
380        &self.before
381    }
382
383    /// Reference to the (possibly overridden) value after the mutation.
384    #[must_use]
385    pub const fn after(&self) -> &T {
386        &self.after
387    }
388
389    /// Returns `true` if the field value changed.
390    #[must_use]
391    pub fn changed(&self) -> bool {
392        self.before != self.after
393    }
394
395    /// Returns `true` if the field value did not change.
396    #[must_use]
397    pub fn unchanged(&self) -> bool {
398        self.before == self.after
399    }
400
401    /// Returns `true` if the field changed **and** the new value equals `value`.
402    #[must_use]
403    pub fn changed_to(&self, value: &T) -> bool {
404        self.changed() && self.after == *value
405    }
406
407    /// Returns `true` if the field changed **and** the old value equals `value`.
408    #[must_use]
409    pub fn changed_from(&self, value: &T) -> bool {
410        self.changed() && self.before == *value
411    }
412
413    /// Override the after value. Does not affect `before`.
414    pub fn set(&mut self, value: T) {
415        self.after = value;
416    }
417}
418
419impl<T: PartialEq> FieldDiff<Option<T>> {
420    /// Returns `true` if the field went from `None` to `Some`.
421    #[must_use]
422    pub const fn was_set(&self) -> bool {
423        self.before.is_none() && self.after.is_some()
424    }
425
426    /// Returns `true` if the field went from `Some` to `None`.
427    #[must_use]
428    pub const fn was_cleared(&self) -> bool {
429        self.before.is_some() && self.after.is_none()
430    }
431}
432
433// ── UpdateDraft & DraftField ─────────────────────────────────────────
434
435/// Merged before/after snapshot of a model for `before_update` hooks.
436///
437/// `UpdateDraft<T>` holds the original (`before`) and proposed (`after`)
438/// state of a model. The `after` copy starts as a clone of `before` and
439/// can be mutated via [`after_mut`](UpdateDraft::after_mut) or through
440/// per-field [`DraftField`] accessors generated by `#[model]`.
441///
442/// # Usage
443///
444/// ```rust,ignore
445/// let mut draft = UpdateDraft::new(existing_article);
446/// // apply partial changes from the request …
447/// draft.after_mut().title = new_title;
448///
449/// // per-field inspection via DraftField:
450/// let title = DraftField::new(&draft.before().title, &mut draft.after_mut().title);
451/// if title.changed() { /* … */ }
452/// ```
453#[derive(Debug, Clone)]
454pub struct UpdateDraft<T: Clone> {
455    /// The original (pre-mutation) model state.
456    ///
457    /// Public so that `#[model]`-generated per-field `DraftField` accessors
458    /// can split-borrow `before` and `after` simultaneously.
459    pub before: T,
460    /// The proposed (post-mutation) model state.
461    ///
462    /// Public so that `#[model]`-generated per-field `DraftField` accessors
463    /// can split-borrow `before` and `after` simultaneously.
464    pub after: T,
465}
466
467impl<T: Clone> UpdateDraft<T> {
468    /// Create a new draft from the current model state.
469    ///
470    /// Clones `before` into `after` so that `after` starts as an
471    /// identical copy that can be selectively mutated.
472    #[must_use]
473    pub fn new(before: T) -> Self {
474        let after = before.clone();
475        Self { before, after }
476    }
477
478    /// Create a draft with explicit before and after values.
479    ///
480    /// Useful in tests or when changes have already been applied.
481    #[must_use]
482    pub const fn new_with_changes(before: T, after: T) -> Self {
483        Self { before, after }
484    }
485
486    /// Reference to the original (pre-mutation) model.
487    #[must_use]
488    pub const fn before(&self) -> &T {
489        &self.before
490    }
491
492    /// Reference to the proposed (post-mutation) model.
493    #[must_use]
494    pub const fn after(&self) -> &T {
495        &self.after
496    }
497
498    /// Mutable reference to the proposed (post-mutation) model.
499    ///
500    /// Use this to apply changes before the update is persisted.
501    #[must_use]
502    pub const fn after_mut(&mut self) -> &mut T {
503        &mut self.after
504    }
505
506    /// Consume the draft and return the proposed model.
507    #[must_use]
508    pub fn into_after(self) -> T {
509        self.after
510    }
511}
512
513/// Borrowing per-field accessor into an [`UpdateDraft`].
514///
515/// `DraftField` borrows `before` immutably and `after` mutably from the
516/// parent draft. Because each `DraftField` is a temporary that drops at
517/// statement end, you can inspect and mutate fields one at a time without
518/// running into overlapping borrow issues:
519///
520/// ```rust,ignore
521/// // generated by #[model]:
522/// draft.status().changed_to(&Status::Published);
523/// draft.title().set("New title".into());
524/// ```
525///
526/// # Lifetime
527///
528/// `'a` ties the field references back to the `UpdateDraft` they came
529/// from. The borrow is released when the `DraftField` is dropped.
530#[derive(Debug)]
531pub struct DraftField<'a, T> {
532    before: &'a T,
533    after: &'a mut T,
534}
535
536impl<'a, T> DraftField<'a, T> {
537    /// Create a new field accessor borrowing from a draft.
538    #[must_use]
539    pub const fn new(before: &'a T, after: &'a mut T) -> Self {
540        Self { before, after }
541    }
542
543    /// Reference to the original (pre-mutation) field value.
544    #[must_use]
545    pub const fn before(&self) -> &T {
546        self.before
547    }
548
549    /// Reference to the proposed (post-mutation) field value.
550    #[must_use]
551    pub const fn after(&self) -> &T {
552        self.after
553    }
554
555    /// Override the proposed value for this field.
556    pub fn set(&mut self, value: T) {
557        *self.after = value;
558    }
559}
560
561impl<T: PartialEq> DraftField<'_, T> {
562    /// Returns `true` if the field value changed.
563    #[must_use]
564    pub fn changed(&self) -> bool {
565        self.before != self.after
566    }
567
568    /// Returns `true` if the field value did not change.
569    #[must_use]
570    pub fn unchanged(&self) -> bool {
571        self.before == self.after
572    }
573
574    /// Returns `true` if the field changed **and** the new value equals `value`.
575    #[must_use]
576    pub fn changed_to(&self, value: &T) -> bool {
577        self.changed() && *self.after == *value
578    }
579
580    /// Returns `true` if the field changed **and** the old value equals `value`.
581    #[must_use]
582    pub fn changed_from(&self, value: &T) -> bool {
583        self.changed() && *self.before == *value
584    }
585}
586
587impl<T: PartialEq> DraftField<'_, Option<T>> {
588    /// Returns `true` if the field went from `None` to `Some`.
589    #[must_use]
590    pub const fn was_set(&self) -> bool {
591        self.before.is_none() && self.after.is_some()
592    }
593
594    /// Returns `true` if the field went from `Some` to `None`.
595    #[must_use]
596    pub const fn was_cleared(&self) -> bool {
597        self.before.is_some() && self.after.is_none()
598    }
599}
600
601#[cfg(test)]
602mod tests {
603    use super::*;
604
605    // ── Patch tests ──────────────────────────────────────────────
606
607    #[test]
608    fn patch_unchanged_is_default() {
609        let p: Patch<String> = Patch::default();
610        assert!(p.is_unchanged());
611        assert!(!p.is_set());
612        assert!(!p.is_clear());
613    }
614
615    #[test]
616    fn patch_set_holds_value() {
617        let p = Patch::Set("hello");
618        assert!(p.is_set());
619        assert!(!p.is_unchanged());
620        assert!(!p.is_clear());
621        assert_eq!(p.as_set(), Some(&"hello"));
622    }
623
624    #[test]
625    fn patch_clear_is_clear() {
626        let p: Patch<i32> = Patch::Clear;
627        assert!(p.is_clear());
628        assert!(!p.is_set());
629        assert!(!p.is_unchanged());
630    }
631
632    #[test]
633    fn patch_into_option_set() {
634        assert_eq!(Patch::Set(42).into_option(), Some(Some(42)));
635    }
636
637    #[test]
638    fn patch_into_option_clear() {
639        assert_eq!(Patch::<i32>::Clear.into_option(), Some(None));
640    }
641
642    #[test]
643    fn patch_into_option_unchanged() {
644        assert_eq!(Patch::<i32>::Unchanged.into_option(), None);
645    }
646
647    // ── FieldDiff tests ──────────────────────────────────────────
648
649    #[test]
650    fn field_diff_unchanged() {
651        let diff = FieldDiff::new(1, 1);
652        assert!(diff.unchanged());
653        assert!(!diff.changed());
654    }
655
656    #[test]
657    fn field_diff_changed() {
658        let diff = FieldDiff::new(1, 2);
659        assert!(diff.changed());
660    }
661
662    #[test]
663    fn field_diff_changed_to() {
664        let diff = FieldDiff::new(1, 2);
665        assert!(diff.changed_to(&2));
666    }
667
668    #[test]
669    fn field_diff_changed_from() {
670        let diff = FieldDiff::new(1, 2);
671        assert!(diff.changed_from(&1));
672    }
673
674    #[test]
675    fn field_diff_set_updates_after() {
676        let mut diff = FieldDiff::new(1, 1);
677        assert!(diff.unchanged());
678        diff.set(5);
679        assert!(diff.changed());
680        assert_eq!(diff.after(), &5);
681        assert_eq!(diff.before(), &1);
682    }
683
684    #[test]
685    fn field_diff_option_was_set() {
686        let diff = FieldDiff::new(None, Some(42));
687        assert!(diff.was_set());
688    }
689
690    #[test]
691    fn field_diff_option_was_cleared() {
692        let diff = FieldDiff::new(Some(42), None);
693        assert!(diff.was_cleared());
694    }
695
696    // ── MutationOp tests ────────────────────────────────────────────
697
698    #[test]
699    fn mutation_op_as_str() {
700        assert_eq!(MutationOp::Create.as_str(), "create");
701        assert_eq!(MutationOp::Update.as_str(), "update");
702        assert_eq!(MutationOp::Delete.as_str(), "delete");
703    }
704
705    #[test]
706    fn mutation_op_display() {
707        assert_eq!(format!("{}", MutationOp::Create), "create");
708    }
709
710    // ── MutationContext tests ───────────────────────────────────────
711
712    #[test]
713    fn mutation_context_auto_populates() {
714        let ctx = MutationContext::new(MutationOp::Create);
715        assert!(ctx.actor.is_none());
716        assert!(ctx.request_id.is_some());
717        // UUID v4 format: 8-4-4-4-12 = 36 chars
718        assert_eq!(ctx.request_id.as_ref().unwrap().len(), 36);
719        assert!(matches!(ctx.op, MutationOp::Create));
720        assert!(ctx.invalidate_keys.is_empty());
721    }
722
723    #[test]
724    fn mutation_context_invalidate_pushes_key() {
725        let mut ctx = MutationContext::new(MutationOp::Create);
726        assert!(ctx.invalidate_keys.is_empty());
727        ctx.invalidate("cache:key");
728        assert_eq!(ctx.invalidate_keys, vec!["cache:key".to_string()]);
729    }
730
731    #[test]
732    fn mutation_context_with_actor() {
733        let mut ctx = MutationContext::new(MutationOp::Update);
734        ctx.actor = Some("user-123".into());
735        assert_eq!(ctx.actor.as_deref(), Some("user-123"));
736    }
737
738    // ── MutationHooks / NoHooks tests ───────────────────────────────
739
740    #[tokio::test]
741    async fn no_hooks_all_methods_are_noop() {
742        let hooks: NoHooks<(), (), ()> = NoHooks::default();
743        let mut ctx = MutationContext::new(MutationOp::Create);
744        let mut new_model = ();
745        let model = ();
746        let mut draft = UpdateDraft::new(());
747
748        assert!(hooks.before_create(&mut ctx, &mut new_model).await.is_ok());
749        assert!(hooks.before_update(&mut ctx, &mut draft).await.is_ok());
750        assert!(hooks.before_delete(&mut ctx, &model).await.is_ok());
751        assert!(hooks.after_create(&mut ctx, &model).await.is_ok());
752        assert!(hooks.after_update(&mut ctx, &model).await.is_ok());
753    }
754
755    // ── Patch serde tests ──────────────────────────────────────────
756
757    #[test]
758    fn patch_serde_set_roundtrip() {
759        let p = Patch::Set(42);
760        let json = serde_json::to_string(&p).unwrap();
761        assert_eq!(json, "42");
762        let back: Patch<i32> = serde_json::from_str(&json).unwrap();
763        assert_eq!(back, Patch::Set(42));
764    }
765
766    #[test]
767    fn patch_serde_clear_serializes_as_null() {
768        let p: Patch<i32> = Patch::Clear;
769        let json = serde_json::to_string(&p).unwrap();
770        assert_eq!(json, "null");
771    }
772
773    #[test]
774    fn patch_serde_null_deserializes_as_clear() {
775        let p: Patch<i32> = serde_json::from_str("null").unwrap();
776        assert_eq!(p, Patch::Clear);
777    }
778
779    #[test]
780    fn patch_serde_absent_field_is_unchanged() {
781        #[derive(Deserialize, PartialEq, Debug)]
782        struct Payload {
783            #[serde(default)]
784            name: Patch<String>,
785            #[serde(default)]
786            age: Patch<i32>,
787        }
788        let p: Payload = serde_json::from_str(r#"{"name": "Alice"}"#).unwrap();
789        assert_eq!(p.name, Patch::Set("Alice".to_string()));
790        assert_eq!(p.age, Patch::Unchanged);
791    }
792
793    #[test]
794    fn patch_serde_explicit_null_is_clear() {
795        #[derive(Deserialize, PartialEq, Debug)]
796        struct Payload {
797            #[serde(default)]
798            name: Patch<String>,
799        }
800        let p: Payload = serde_json::from_str(r#"{"name": null}"#).unwrap();
801        assert_eq!(p.name, Patch::Clear);
802    }
803
804    // ── UpdateDraft tests ───────────────────────────────────────────
805
806    #[test]
807    fn update_draft_before_after() {
808        let draft = UpdateDraft::new_with_changes("old".to_string(), "new".to_string());
809        assert_eq!(draft.before(), "old");
810        assert_eq!(draft.after(), "new");
811    }
812
813    #[test]
814    fn update_draft_into_after() {
815        let draft = UpdateDraft::new_with_changes(1, 2);
816        assert_eq!(draft.into_after(), 2);
817    }
818
819    #[test]
820    fn update_draft_new_clones() {
821        let draft = UpdateDraft::new(42);
822        assert_eq!(draft.before(), &42);
823        assert_eq!(draft.after(), &42);
824    }
825
826    #[test]
827    fn update_draft_after_mut() {
828        let mut draft = UpdateDraft::new_with_changes(1, 2);
829        *draft.after_mut() = 3;
830        assert_eq!(draft.after(), &3);
831    }
832
833    // ── DraftField tests ────────────────────────────────────────────
834
835    #[test]
836    fn draft_field_before_after() {
837        let before = 1;
838        let mut after = 2;
839        let field = DraftField::new(&before, &mut after);
840        assert_eq!(field.before(), &1);
841        assert_eq!(field.after(), &2);
842    }
843
844    #[test]
845    fn draft_field_changed() {
846        let before = 1;
847        let mut after = 2;
848        let field = DraftField::new(&before, &mut after);
849        assert!(field.changed());
850        assert!(!field.unchanged());
851    }
852
853    #[test]
854    fn draft_field_unchanged() {
855        let before = 1;
856        let mut after = 1;
857        let field = DraftField::new(&before, &mut after);
858        assert!(field.unchanged());
859        assert!(!field.changed());
860    }
861
862    #[test]
863    fn draft_field_changed_to() {
864        let before = "draft".to_string();
865        let mut after = "published".to_string();
866        let field = DraftField::new(&before, &mut after);
867        assert!(field.changed_to(&"published".to_string()));
868        assert!(!field.changed_to(&"draft".to_string()));
869    }
870
871    #[test]
872    fn draft_field_changed_from() {
873        let before = "draft".to_string();
874        let mut after = "published".to_string();
875        let field = DraftField::new(&before, &mut after);
876        assert!(field.changed_from(&"draft".to_string()));
877        assert!(!field.changed_from(&"published".to_string()));
878    }
879
880    #[test]
881    fn draft_field_set_mutates_after() {
882        let before = 10;
883        let mut after = 10;
884        {
885            let mut field = DraftField::new(&before, &mut after);
886            assert!(field.unchanged());
887            field.set(20);
888            assert!(field.changed());
889            assert_eq!(field.after(), &20);
890        }
891        // Verify mutation propagated to the original variable
892        assert_eq!(after, 20);
893    }
894
895    #[test]
896    fn draft_field_option_was_set() {
897        let before: Option<i32> = None;
898        let mut after: Option<i32> = Some(42);
899        let field = DraftField::new(&before, &mut after);
900        assert!(field.was_set());
901        assert!(!field.was_cleared());
902    }
903
904    #[test]
905    fn draft_field_option_was_cleared() {
906        let before: Option<i32> = Some(42);
907        let mut after: Option<i32> = None;
908        let field = DraftField::new(&before, &mut after);
909        assert!(field.was_cleared());
910        assert!(!field.was_set());
911    }
912}