Skip to main content

systemprompt_content/models/
content.rs

1//! Core content data types.
2//!
3//! Defines the stored [`Content`] row and its lighter [`ContentSummary`], the
4//! frontmatter-parsed [`ContentMetadata`] and [`ContentLinkMetadata`], the
5//! [`ContentKind`] classifier, and the ingestion-side [`IngestionReport`],
6//! [`IngestionOptions`], and [`IngestionSource`] types.
7//!
8//! Copyright (c) systemprompt.io — Business Source License 1.1.
9//! See <https://systemprompt.io> for licensing details.
10
11use chrono::{DateTime, Utc};
12use serde::{Deserialize, Serialize};
13use serde_json::Value as JsonValue;
14use sqlx::FromRow;
15use systemprompt_identifiers::{CategoryId, ContentId, LocaleCode, SourceId, TagId};
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
18#[serde(rename_all = "lowercase")]
19pub enum ContentKind {
20    #[default]
21    Article,
22    Guide,
23    Tutorial,
24}
25
26impl ContentKind {
27    pub const fn as_str(&self) -> &'static str {
28        match self {
29            Self::Article => "article",
30            Self::Guide => "guide",
31            Self::Tutorial => "tutorial",
32        }
33    }
34}
35
36impl std::fmt::Display for ContentKind {
37    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
38        write!(f, "{}", self.as_str())
39    }
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
43pub struct Content {
44    pub id: ContentId,
45    pub slug: String,
46    pub locale: LocaleCode,
47    pub title: String,
48    pub description: String,
49    pub body: String,
50    pub author: String,
51    pub published_at: DateTime<Utc>,
52    pub keywords: String,
53    pub kind: String,
54    pub image: Option<String>,
55    pub category_id: Option<CategoryId>,
56    pub source_id: SourceId,
57    pub version_hash: String,
58    pub public: bool,
59    #[serde(default)]
60    pub links: JsonValue,
61    pub updated_at: DateTime<Utc>,
62}
63
64impl Content {
65    pub fn links_metadata(&self) -> Result<Vec<ContentLinkMetadata>, serde_json::Error> {
66        serde_json::from_value(self.links.clone())
67    }
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct ContentSummary {
72    pub id: ContentId,
73    pub slug: String,
74    pub title: String,
75    pub description: String,
76    pub published_at: DateTime<Utc>,
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct ContentMetadata {
81    pub title: String,
82    #[serde(default)]
83    pub description: String,
84    #[serde(default)]
85    pub author: String,
86    pub published_at: String,
87    pub slug: String,
88    #[serde(default)]
89    pub locale: Option<LocaleCode>,
90    #[serde(default)]
91    pub keywords: String,
92    pub kind: String,
93    #[serde(default)]
94    pub image: Option<String>,
95    #[serde(default)]
96    pub category: Option<String>,
97    #[serde(default)]
98    pub tags: Vec<String>,
99    #[serde(default)]
100    pub links: Vec<ContentLinkMetadata>,
101    #[serde(default)]
102    pub public: Option<bool>,
103}
104
105#[derive(Debug, Clone, Serialize, Deserialize)]
106pub struct ContentLinkMetadata {
107    pub title: String,
108    pub url: String,
109}
110
111#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
112pub struct Tag {
113    pub id: TagId,
114    pub name: String,
115    pub slug: String,
116    pub created_at: Option<DateTime<Utc>>,
117    pub updated_at: Option<DateTime<Utc>>,
118}
119
120#[derive(Debug, Clone, Serialize, Deserialize)]
121pub struct IngestionReport {
122    pub files_found: usize,
123    pub files_processed: usize,
124    pub errors: Vec<String>,
125    #[serde(default)]
126    pub warnings: Vec<String>,
127    #[serde(default, skip_serializing_if = "Vec::is_empty")]
128    pub would_create: Vec<String>,
129    #[serde(default, skip_serializing_if = "Vec::is_empty")]
130    pub would_update: Vec<String>,
131    #[serde(default)]
132    pub unchanged_count: usize,
133    #[serde(default)]
134    pub skipped_count: usize,
135}
136
137impl IngestionReport {
138    pub const fn new() -> Self {
139        Self {
140            files_found: 0,
141            files_processed: 0,
142            errors: Vec::new(),
143            warnings: Vec::new(),
144            would_create: Vec::new(),
145            would_update: Vec::new(),
146            unchanged_count: 0,
147            skipped_count: 0,
148        }
149    }
150
151    pub const fn is_success(&self) -> bool {
152        self.errors.is_empty()
153    }
154}
155
156impl Default for IngestionReport {
157    fn default() -> Self {
158        Self::new()
159    }
160}
161
162#[derive(Debug, Clone, Copy, Default)]
163pub struct IngestionOptions {
164    pub override_existing: bool,
165    pub recursive: bool,
166    pub dry_run: bool,
167}
168
169impl IngestionOptions {
170    pub const fn with_override(mut self, override_existing: bool) -> Self {
171        self.override_existing = override_existing;
172        self
173    }
174
175    pub const fn with_recursive(mut self, recursive: bool) -> Self {
176        self.recursive = recursive;
177        self
178    }
179
180    pub const fn with_dry_run(mut self, dry_run: bool) -> Self {
181        self.dry_run = dry_run;
182        self
183    }
184}
185
186#[derive(Debug, Clone)]
187pub struct IngestionSource<'a> {
188    pub source_id: &'a SourceId,
189    pub source_name: &'a str,
190    pub category_id: &'a CategoryId,
191}
192
193impl<'a> IngestionSource<'a> {
194    pub const fn new(
195        source_id: &'a SourceId,
196        source_name: &'a str,
197        category_id: &'a CategoryId,
198    ) -> Self {
199        Self {
200            source_id,
201            source_name,
202            category_id,
203        }
204    }
205}