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