Skip to main content

ghost_io_api/models/
settings.rs

1//! Settings model for Ghost API.
2//!
3//! The Ghost Content API exposes a read-only subset of site settings via the
4//! `/ghost/api/content/settings/` endpoint.
5//!
6//! # Example
7//!
8//! ```
9//! use ghost_io_api::models::settings::Settings;
10//!
11//! let settings = Settings {
12//!     title: Some("My Blog".to_string()),
13//!     description: Some("A blog about things".to_string()),
14//!     ..Default::default()
15//! };
16//!
17//! assert_eq!(settings.title.as_deref(), Some("My Blog"));
18//! ```
19
20use serde::{Deserialize, Serialize};
21
22/// A navigation item in the site menu.
23///
24/// # Example
25///
26/// ```
27/// use ghost_io_api::models::settings::NavItem;
28/// use serde_json::json;
29///
30/// let json = json!({ "label": "Home", "url": "/" });
31/// let item: NavItem = serde_json::from_value(json).unwrap();
32/// assert_eq!(item.label, "Home");
33/// ```
34#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
35pub struct NavItem {
36    /// Display label.
37    pub label: String,
38    /// Link URL.
39    pub url: String,
40}
41
42/// Site-wide settings returned by the Ghost Content API.
43///
44/// All fields are optional because the API may omit fields that have not been
45/// configured by the site owner.
46///
47/// # Example
48///
49/// ```
50/// use ghost_io_api::models::settings::Settings;
51/// use serde_json::json;
52///
53/// let json = json!({
54///     "title": "Ghost",
55///     "description": "The professional publishing platform",
56///     "logo": "https://example.com/logo.png",
57///     "lang": "en",
58///     "timezone": "Etc/UTC",
59///     "navigation": [
60///         { "label": "Home", "url": "/" },
61///         { "label": "About", "url": "/about/" }
62///     ]
63/// });
64///
65/// let settings: Settings = serde_json::from_value(json).unwrap();
66/// assert_eq!(settings.title.as_deref(), Some("Ghost"));
67/// assert_eq!(settings.navigation.len(), 2);
68/// ```
69#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
70pub struct Settings {
71    /// Site title.
72    #[serde(skip_serializing_if = "Option::is_none")]
73    pub title: Option<String>,
74    /// Site description / tagline.
75    #[serde(skip_serializing_if = "Option::is_none")]
76    pub description: Option<String>,
77    /// Logo image URL.
78    #[serde(skip_serializing_if = "Option::is_none")]
79    pub logo: Option<String>,
80    /// Site icon URL (favicon).
81    #[serde(skip_serializing_if = "Option::is_none")]
82    pub icon: Option<String>,
83    /// Accent colour hex code (e.g. `"#ff0095"`).
84    #[serde(skip_serializing_if = "Option::is_none")]
85    pub accent_color: Option<String>,
86    /// Cover image URL.
87    #[serde(skip_serializing_if = "Option::is_none")]
88    pub cover_image: Option<String>,
89    /// Facebook page URL or username.
90    #[serde(skip_serializing_if = "Option::is_none")]
91    pub facebook: Option<String>,
92    /// Twitter handle (without `@`).
93    #[serde(skip_serializing_if = "Option::is_none")]
94    pub twitter: Option<String>,
95    /// Site locale/language code, e.g. `"en"`.
96    #[serde(skip_serializing_if = "Option::is_none")]
97    pub lang: Option<String>,
98    /// Site timezone, e.g. `"Etc/UTC"`.
99    #[serde(skip_serializing_if = "Option::is_none")]
100    pub timezone: Option<String>,
101    /// Code injected into `<head>` site-wide.
102    #[serde(skip_serializing_if = "Option::is_none")]
103    pub codeinjection_head: Option<String>,
104    /// Code injected at the end of `<body>` site-wide.
105    #[serde(skip_serializing_if = "Option::is_none")]
106    pub codeinjection_foot: Option<String>,
107    /// Primary navigation items.
108    #[serde(default)]
109    pub navigation: Vec<NavItem>,
110    /// Secondary navigation items.
111    #[serde(default)]
112    pub secondary_navigation: Vec<NavItem>,
113    /// OG image URL.
114    #[serde(skip_serializing_if = "Option::is_none")]
115    pub og_image: Option<String>,
116    /// OG title.
117    #[serde(skip_serializing_if = "Option::is_none")]
118    pub og_title: Option<String>,
119    /// OG description.
120    #[serde(skip_serializing_if = "Option::is_none")]
121    pub og_description: Option<String>,
122    /// Twitter card image URL.
123    #[serde(skip_serializing_if = "Option::is_none")]
124    pub twitter_image: Option<String>,
125    /// Twitter card title.
126    #[serde(skip_serializing_if = "Option::is_none")]
127    pub twitter_title: Option<String>,
128    /// Twitter card description.
129    #[serde(skip_serializing_if = "Option::is_none")]
130    pub twitter_description: Option<String>,
131    /// Meta title override.
132    #[serde(skip_serializing_if = "Option::is_none")]
133    pub meta_title: Option<String>,
134    /// Meta description override.
135    #[serde(skip_serializing_if = "Option::is_none")]
136    pub meta_description: Option<String>,
137    /// Members support email address.
138    #[serde(skip_serializing_if = "Option::is_none")]
139    pub members_support_address: Option<String>,
140    /// Canonical site URL.
141    #[serde(skip_serializing_if = "Option::is_none")]
142    pub url: Option<String>,
143}
144
145impl Settings {
146    /// Returns `true` if the site has a logo configured.
147    ///
148    /// # Example
149    ///
150    /// ```
151    /// use ghost_io_api::models::settings::Settings;
152    ///
153    /// let s = Settings { logo: Some("https://example.com/logo.png".to_string()), ..Default::default() };
154    /// assert!(s.has_logo());
155    ///
156    /// let empty = Settings::default();
157    /// assert!(!empty.has_logo());
158    /// ```
159    pub fn has_logo(&self) -> bool {
160        self.logo.is_some()
161    }
162
163    /// Returns the number of primary navigation items.
164    ///
165    /// # Example
166    ///
167    /// ```
168    /// use ghost_io_api::models::settings::{Settings, NavItem};
169    ///
170    /// let s = Settings {
171    ///     navigation: vec![
172    ///         NavItem { label: "Home".to_string(), url: "/".to_string() },
173    ///     ],
174    ///     ..Default::default()
175    /// };
176    /// assert_eq!(s.nav_count(), 1);
177    /// ```
178    pub fn nav_count(&self) -> usize {
179        self.navigation.len()
180    }
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186    use serde_json::json;
187
188    #[test]
189    fn test_nav_item_deserialization() {
190        let json = json!({ "label": "Home", "url": "/" });
191        let item: NavItem = serde_json::from_value(json).unwrap();
192        assert_eq!(item.label, "Home");
193        assert_eq!(item.url, "/");
194    }
195
196    #[test]
197    fn test_settings_minimal_deserialization() {
198        let json = json!({ "title": "My Blog" });
199        let settings: Settings = serde_json::from_value(json).unwrap();
200        assert_eq!(settings.title, Some("My Blog".to_string()));
201        assert!(settings.navigation.is_empty());
202    }
203
204    #[test]
205    fn test_settings_full_deserialization() {
206        let json = json!({
207            "title": "Ghost",
208            "description": "Platform",
209            "logo": "https://example.com/logo.png",
210            "icon": "https://example.com/icon.png",
211            "accent_color": "#ff0095",
212            "cover_image": "https://example.com/cover.jpg",
213            "facebook": "ghostorg",
214            "twitter": "@Ghost",
215            "lang": "en",
216            "timezone": "Etc/UTC",
217            "navigation": [
218                { "label": "Home", "url": "/" },
219                { "label": "About", "url": "/about/" }
220            ],
221            "secondary_navigation": [],
222            "url": "https://example.com/"
223        });
224        let settings: Settings = serde_json::from_value(json).unwrap();
225        assert_eq!(settings.title.as_deref(), Some("Ghost"));
226        assert_eq!(settings.lang.as_deref(), Some("en"));
227        assert_eq!(settings.navigation.len(), 2);
228        assert_eq!(settings.navigation[0].label, "Home");
229        assert!(settings.has_logo());
230        assert_eq!(settings.nav_count(), 2);
231    }
232
233    #[test]
234    fn test_settings_empty_navigation_default() {
235        let settings = Settings::default();
236        assert!(settings.navigation.is_empty());
237        assert_eq!(settings.nav_count(), 0);
238    }
239
240    #[test]
241    fn test_has_logo_true() {
242        let s = Settings {
243            logo: Some("https://example.com/logo.png".to_string()),
244            ..Default::default()
245        };
246        assert!(s.has_logo());
247    }
248
249    #[test]
250    fn test_has_logo_false() {
251        assert!(!Settings::default().has_logo());
252    }
253
254    #[test]
255    fn test_settings_serialization_skips_none() {
256        let s = Settings {
257            title: Some("T".to_string()),
258            ..Default::default()
259        };
260        let json = serde_json::to_value(&s).unwrap();
261        let obj = json.as_object().unwrap();
262        assert!(!obj.contains_key("logo"));
263        assert!(!obj.contains_key("twitter"));
264    }
265
266    #[test]
267    fn test_settings_clone_eq() {
268        let s = Settings {
269            title: Some("Blog".to_string()),
270            lang: Some("en".to_string()),
271            ..Default::default()
272        };
273        assert_eq!(s, s.clone());
274    }
275
276    #[test]
277    fn test_nav_item_clone_eq() {
278        let item = NavItem {
279            label: "Home".to_string(),
280            url: "/".to_string(),
281        };
282        assert_eq!(item, item.clone());
283    }
284
285    #[test]
286    fn test_nav_item_default() {
287        let item = NavItem::default();
288        assert!(item.label.is_empty());
289        assert!(item.url.is_empty());
290    }
291}