Skip to main content

typub_storage/status/
lifecycle.rs

1//! Lifecycle management for publish status.
2//!
3//! Per [[RFC-0005:C-LIFECYCLE-TRANSITIONS]].
4
5use anyhow::Result;
6use typub_core::DraftSupport;
7
8/// Validate remote_status for API-based platforms.
9/// Per [[RFC-0005:C-LIFECYCLE-TRANSITIONS]] data integrity guard.
10///
11/// If `platform_id` is present (remote object exists), `remote_status` MUST be
12/// one of "draft" or "published". This ensures corrupted or incomplete status
13/// data causes immediate, diagnosable failures rather than undefined behavior.
14pub fn validate_remote_status(
15    slug: &str,
16    platform: &str,
17    platform_id: Option<&str>,
18    remote_status: Option<&str>,
19) -> Result<()> {
20    if platform_id.is_some() {
21        match remote_status {
22            Some("draft") | Some("published") => Ok(()),
23            Some(invalid) => anyhow::bail!(
24                "Invalid remote_status '{}' for {}/{}: expected 'draft' or 'published'",
25                invalid,
26                slug,
27                platform
28            ),
29            None => anyhow::bail!(
30                "Missing remote_status for {}/{} with existing platform_id",
31                slug,
32                platform
33            ),
34        }
35    } else {
36        Ok(())
37    }
38}
39
40/// Lifecycle action to take per [[RFC-0005:C-LIFECYCLE-TRANSITIONS]].
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum LifecycleAction {
43    /// Create new published content
44    CreatePublished,
45    /// Create new draft content
46    CreateDraft,
47    /// Update existing published content
48    UpdatePublished,
49    /// Update existing draft content
50    UpdateDraft,
51    /// Transition from draft to published (draft-to-publish)
52    TransitionDraftToPublished,
53    /// Transition from published to draft (publish-to-draft, if reversible)
54    TransitionPublishedToDraft,
55    /// Cannot unpublish - warn and update content only
56    WarnCannotUnpublish,
57}
58
59/// Determine lifecycle action per [[RFC-0005:C-LIFECYCLE-TRANSITIONS]].
60///
61/// This implements the decision table for API-based platforms.
62/// Local output platforms should not use this function.
63///
64/// # Arguments
65/// * `has_remote_object` - Whether `platform_id` is present in the status row
66/// * `remote_status` - The stored lifecycle state ("draft" or "published")
67/// * `desired_published` - The resolved `published` configuration value
68/// * `draft_support` - The platform's declared `DraftSupport` capability
69pub fn determine_lifecycle_action(
70    has_remote_object: bool,
71    remote_status: Option<&str>,
72    desired_published: bool,
73    draft_support: DraftSupport,
74) -> LifecycleAction {
75    match (
76        has_remote_object,
77        remote_status,
78        desired_published,
79        draft_support,
80    ) {
81        // No remote object exists - create new
82        (false, _, true, _) => LifecycleAction::CreatePublished,
83        (false, _, false, DraftSupport::StatusField { .. } | DraftSupport::SeparateObjects) => {
84            LifecycleAction::CreateDraft
85        }
86        (false, _, false, DraftSupport::None) => LifecycleAction::CreatePublished, // Ignore config
87
88        // Remote object exists as published
89        (true, Some("published"), true, _) => LifecycleAction::UpdatePublished,
90        (true, Some("published"), false, DraftSupport::StatusField { reversible: true }) => {
91            LifecycleAction::TransitionPublishedToDraft
92        }
93        (true, Some("published"), false, DraftSupport::StatusField { reversible: false }) => {
94            LifecycleAction::WarnCannotUnpublish
95        }
96        (true, Some("published"), false, DraftSupport::SeparateObjects) => {
97            LifecycleAction::WarnCannotUnpublish
98        }
99        (true, Some("published"), false, DraftSupport::None) => LifecycleAction::UpdatePublished, // Ignore config
100
101        // Remote object exists as draft
102        (true, Some("draft"), true, DraftSupport::StatusField { .. }) => {
103            LifecycleAction::TransitionDraftToPublished
104        }
105        (true, Some("draft"), true, DraftSupport::SeparateObjects) => {
106            LifecycleAction::TransitionDraftToPublished
107        }
108        (true, Some("draft"), true, DraftSupport::None) => {
109            // N/A - DraftSupport::None never creates draft
110            // Treat as CreatePublished since we somehow have a draft
111            LifecycleAction::CreatePublished
112        }
113        (
114            true,
115            Some("draft"),
116            false,
117            DraftSupport::StatusField { .. } | DraftSupport::SeparateObjects,
118        ) => LifecycleAction::UpdateDraft,
119        (true, Some("draft"), false, DraftSupport::None) => {
120            // N/A - DraftSupport::None never creates draft
121            LifecycleAction::CreatePublished
122        }
123
124        // Invalid/unknown remote_status - should have been caught by validate_remote_status
125        // Default to CreatePublished as a safe fallback
126        _ => LifecycleAction::CreatePublished,
127    }
128}
129
130#[cfg(test)]
131mod tests {
132    #![allow(clippy::expect_used)]
133    use super::*;
134
135    #[test]
136    fn test_validate_remote_status_ok() {
137        assert!(validate_remote_status("slug", "platform", Some("id"), Some("draft")).is_ok());
138        assert!(validate_remote_status("slug", "platform", Some("id"), Some("published")).is_ok());
139        assert!(validate_remote_status("slug", "platform", None, None).is_ok());
140        assert!(validate_remote_status("slug", "platform", None, Some("garbage")).is_ok());
141    }
142
143    #[test]
144    fn test_validate_remote_status_invalid() {
145        let result = validate_remote_status("slug", "platform", Some("id"), Some("garbage"));
146        assert!(result.is_err());
147        assert!(
148            result
149                .expect_err("already verified is_err()")
150                .to_string()
151                .contains("Invalid remote_status")
152        );
153    }
154
155    #[test]
156    fn test_validate_remote_status_missing() {
157        let result = validate_remote_status("slug", "platform", Some("id"), None);
158        assert!(result.is_err());
159        assert!(
160            result
161                .expect_err("already verified is_err()")
162                .to_string()
163                .contains("Missing remote_status")
164        );
165    }
166
167    #[test]
168    fn test_lifecycle_create_published() {
169        assert_eq!(
170            determine_lifecycle_action(false, None, true, DraftSupport::None),
171            LifecycleAction::CreatePublished
172        );
173        assert_eq!(
174            determine_lifecycle_action(
175                false,
176                None,
177                true,
178                DraftSupport::StatusField { reversible: true }
179            ),
180            LifecycleAction::CreatePublished
181        );
182    }
183
184    #[test]
185    fn test_lifecycle_create_draft() {
186        assert_eq!(
187            determine_lifecycle_action(
188                false,
189                None,
190                false,
191                DraftSupport::StatusField { reversible: true }
192            ),
193            LifecycleAction::CreateDraft
194        );
195        assert_eq!(
196            determine_lifecycle_action(false, None, false, DraftSupport::SeparateObjects),
197            LifecycleAction::CreateDraft
198        );
199    }
200
201    #[test]
202    fn test_lifecycle_create_published_when_no_draft_support() {
203        // Even if desired_published=false, platforms with DraftSupport::None
204        // should create published content
205        assert_eq!(
206            determine_lifecycle_action(false, None, false, DraftSupport::None),
207            LifecycleAction::CreatePublished
208        );
209    }
210
211    #[test]
212    fn test_lifecycle_update_published() {
213        assert_eq!(
214            determine_lifecycle_action(true, Some("published"), true, DraftSupport::None),
215            LifecycleAction::UpdatePublished
216        );
217        assert_eq!(
218            determine_lifecycle_action(
219                true,
220                Some("published"),
221                true,
222                DraftSupport::StatusField { reversible: true }
223            ),
224            LifecycleAction::UpdatePublished
225        );
226    }
227
228    #[test]
229    fn test_lifecycle_transition_draft_to_published() {
230        assert_eq!(
231            determine_lifecycle_action(
232                true,
233                Some("draft"),
234                true,
235                DraftSupport::StatusField { reversible: true }
236            ),
237            LifecycleAction::TransitionDraftToPublished
238        );
239        assert_eq!(
240            determine_lifecycle_action(true, Some("draft"), true, DraftSupport::SeparateObjects),
241            LifecycleAction::TransitionDraftToPublished
242        );
243    }
244
245    #[test]
246    fn test_lifecycle_transition_published_to_draft() {
247        assert_eq!(
248            determine_lifecycle_action(
249                true,
250                Some("published"),
251                false,
252                DraftSupport::StatusField { reversible: true }
253            ),
254            LifecycleAction::TransitionPublishedToDraft
255        );
256    }
257
258    #[test]
259    fn test_lifecycle_warn_cannot_unpublish() {
260        assert_eq!(
261            determine_lifecycle_action(
262                true,
263                Some("published"),
264                false,
265                DraftSupport::StatusField { reversible: false }
266            ),
267            LifecycleAction::WarnCannotUnpublish
268        );
269        assert_eq!(
270            determine_lifecycle_action(
271                true,
272                Some("published"),
273                false,
274                DraftSupport::SeparateObjects
275            ),
276            LifecycleAction::WarnCannotUnpublish
277        );
278    }
279
280    #[test]
281    fn test_lifecycle_update_draft() {
282        assert_eq!(
283            determine_lifecycle_action(
284                true,
285                Some("draft"),
286                false,
287                DraftSupport::StatusField { reversible: true }
288            ),
289            LifecycleAction::UpdateDraft
290        );
291        assert_eq!(
292            determine_lifecycle_action(true, Some("draft"), false, DraftSupport::SeparateObjects),
293            LifecycleAction::UpdateDraft
294        );
295    }
296}