Skip to main content

ghost_io_api/models/
tag.rs

1//! Tag model for Ghost API.
2//!
3//! Tags are used to categorise posts and pages. Each tag has a name, slug,
4//! and optional metadata fields.
5//!
6//! # Example
7//!
8//! ```
9//! use ghost_io_api::models::tag::Tag;
10//!
11//! let tag = Tag {
12//!     id: "5e6f7a8b9c0d1e2f3a4b5c6e".to_string(),
13//!     name: "Getting Started".to_string(),
14//!     slug: "getting-started".to_string(),
15//!     ..Default::default()
16//! };
17//!
18//! assert_eq!(tag.name, "Getting Started");
19//! assert!(tag.is_public());
20//! ```
21
22use serde::{Deserialize, Serialize};
23
24/// Count of posts associated with a tag or author.
25///
26/// Populated when `include=count.posts` is added to the request.
27#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
28pub struct PostCount {
29    /// Number of published posts with this tag / by this author.
30    pub posts: u32,
31}
32
33/// A Ghost tag resource.
34///
35/// # Example
36///
37/// ```
38/// use ghost_io_api::models::tag::Tag;
39/// use serde_json::json;
40///
41/// let json = json!({
42///     "id": "abc",
43///     "name": "Rust",
44///     "slug": "rust",
45///     "visibility": "public"
46/// });
47/// let tag: Tag = serde_json::from_value(json).unwrap();
48/// assert!(tag.is_public());
49/// ```
50#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
51pub struct Tag {
52    /// Ghost object ID.
53    pub id: String,
54    /// Display name.
55    pub name: String,
56    /// URL slug.
57    pub slug: String,
58    /// Optional description.
59    #[serde(skip_serializing_if = "Option::is_none")]
60    pub description: Option<String>,
61    /// Feature image URL.
62    #[serde(skip_serializing_if = "Option::is_none")]
63    pub feature_image: Option<String>,
64    /// Visibility: `"public"` or `"internal"`.
65    #[serde(skip_serializing_if = "Option::is_none")]
66    pub visibility: Option<String>,
67    /// OG image URL.
68    #[serde(skip_serializing_if = "Option::is_none")]
69    pub og_image: Option<String>,
70    /// OG title.
71    #[serde(skip_serializing_if = "Option::is_none")]
72    pub og_title: Option<String>,
73    /// OG description.
74    #[serde(skip_serializing_if = "Option::is_none")]
75    pub og_description: Option<String>,
76    /// Twitter card image URL.
77    #[serde(skip_serializing_if = "Option::is_none")]
78    pub twitter_image: Option<String>,
79    /// Twitter card title.
80    #[serde(skip_serializing_if = "Option::is_none")]
81    pub twitter_title: Option<String>,
82    /// Twitter card description.
83    #[serde(skip_serializing_if = "Option::is_none")]
84    pub twitter_description: Option<String>,
85    /// Meta title override.
86    #[serde(skip_serializing_if = "Option::is_none")]
87    pub meta_title: Option<String>,
88    /// Meta description override.
89    #[serde(skip_serializing_if = "Option::is_none")]
90    pub meta_description: Option<String>,
91    /// Code injected into `<head>` on tag pages.
92    #[serde(skip_serializing_if = "Option::is_none")]
93    pub codeinjection_head: Option<String>,
94    /// Code injected at the end of `<body>` on tag pages.
95    #[serde(skip_serializing_if = "Option::is_none")]
96    pub codeinjection_foot: Option<String>,
97    /// Canonical URL override.
98    #[serde(skip_serializing_if = "Option::is_none")]
99    pub canonical_url: Option<String>,
100    /// Public URL of the tag archive page.
101    #[serde(skip_serializing_if = "Option::is_none")]
102    pub url: Option<String>,
103    /// ISO 8601 creation timestamp.
104    #[serde(skip_serializing_if = "Option::is_none")]
105    pub created_at: Option<String>,
106    /// ISO 8601 last-updated timestamp.
107    #[serde(skip_serializing_if = "Option::is_none")]
108    pub updated_at: Option<String>,
109    /// Post count (populated when `include=count.posts`).
110    #[serde(skip_serializing_if = "Option::is_none")]
111    pub count: Option<PostCount>,
112}
113
114impl Tag {
115    /// Returns `true` if the tag's visibility is `"public"` or unset.
116    ///
117    /// # Example
118    ///
119    /// ```
120    /// use ghost_io_api::models::tag::Tag;
121    ///
122    /// let public_tag = Tag { visibility: Some("public".to_string()), ..Default::default() };
123    /// assert!(public_tag.is_public());
124    ///
125    /// let internal_tag = Tag { visibility: Some("internal".to_string()), ..Default::default() };
126    /// assert!(!internal_tag.is_public());
127    /// ```
128    pub fn is_public(&self) -> bool {
129        matches!(self.visibility.as_deref(), None | Some("public"))
130    }
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136    use serde_json::json;
137
138    #[test]
139    fn test_tag_minimal_deserialization() {
140        let json = json!({ "id": "1", "name": "Rust", "slug": "rust" });
141        let tag: Tag = serde_json::from_value(json).unwrap();
142        assert_eq!(tag.id, "1");
143        assert_eq!(tag.name, "Rust");
144        assert_eq!(tag.slug, "rust");
145    }
146
147    #[test]
148    fn test_tag_full_deserialization() {
149        let json = json!({
150            "id": "1",
151            "name": "Rust",
152            "slug": "rust",
153            "description": "Rust programming",
154            "feature_image": "https://example.com/img.jpg",
155            "visibility": "public",
156            "og_image": null,
157            "meta_title": "Rust Tag",
158            "url": "https://example.com/tag/rust/",
159            "created_at": "2021-01-01T00:00:00.000Z",
160            "updated_at": "2021-01-01T00:00:00.000Z",
161            "count": { "posts": 42 }
162        });
163        let tag: Tag = serde_json::from_value(json).unwrap();
164        assert_eq!(tag.description, Some("Rust programming".to_string()));
165        assert_eq!(tag.count, Some(PostCount { posts: 42 }));
166        assert!(tag.is_public());
167    }
168
169    #[test]
170    fn test_tag_is_public_with_public_visibility() {
171        let tag = Tag {
172            visibility: Some("public".to_string()),
173            ..Default::default()
174        };
175        assert!(tag.is_public());
176    }
177
178    #[test]
179    fn test_tag_is_public_with_no_visibility() {
180        let tag = Tag::default();
181        assert!(tag.is_public());
182    }
183
184    #[test]
185    fn test_tag_is_not_public_when_internal() {
186        let tag = Tag {
187            visibility: Some("internal".to_string()),
188            ..Default::default()
189        };
190        assert!(!tag.is_public());
191    }
192
193    #[test]
194    fn test_tag_serialization_skips_none() {
195        let tag = Tag {
196            id: "1".to_string(),
197            name: "T".to_string(),
198            slug: "t".to_string(),
199            ..Default::default()
200        };
201        let json = serde_json::to_value(&tag).unwrap();
202        let obj = json.as_object().unwrap();
203        assert!(!obj.contains_key("description"));
204        assert!(!obj.contains_key("feature_image"));
205        assert!(!obj.contains_key("count"));
206    }
207
208    #[test]
209    fn test_post_count_default() {
210        let count = PostCount::default();
211        assert_eq!(count.posts, 0);
212    }
213
214    #[test]
215    fn test_post_count_serialization() {
216        let count = PostCount { posts: 10 };
217        let json = serde_json::to_value(&count).unwrap();
218        assert_eq!(json["posts"], 10);
219    }
220
221    #[test]
222    fn test_tag_clone_eq() {
223        let tag = Tag {
224            id: "1".to_string(),
225            name: "T".to_string(),
226            slug: "t".to_string(),
227            ..Default::default()
228        };
229        assert_eq!(tag, tag.clone());
230    }
231
232    #[test]
233    fn test_tag_default() {
234        let tag = Tag::default();
235        assert!(tag.id.is_empty());
236        assert!(tag.name.is_empty());
237        assert!(tag.count.is_none());
238    }
239}