1use std::fmt::Display;
2
3use serde::ser::{Serialize, SerializeMap, Serializer};
4
5use crate::{Block, BlockMetadata, InlineNode, Location, model::inlines::converter};
6
7use super::title::Title;
8
9pub type SectionLevel = u8;
11
12#[derive(Clone, Debug, PartialEq)]
14#[non_exhaustive]
15pub struct Section {
16 pub metadata: BlockMetadata,
17 pub title: Title,
18 pub level: SectionLevel,
19 pub content: Vec<Block>,
20 pub location: Location,
21}
22
23impl Section {
24 #[must_use]
26 pub fn new(title: Title, level: SectionLevel, content: Vec<Block>, location: Location) -> Self {
27 Self {
28 metadata: BlockMetadata::default(),
29 title,
30 level,
31 content,
32 location,
33 }
34 }
35
36 #[must_use]
38 pub fn with_metadata(mut self, metadata: BlockMetadata) -> Self {
39 self.metadata = metadata;
40 self
41 }
42}
43
44#[derive(Clone, Debug, PartialEq)]
46#[non_exhaustive]
47pub enum SafeId {
48 Generated(String),
49 Explicit(String),
50}
51
52impl Display for SafeId {
53 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
54 match self {
55 SafeId::Generated(id) => write!(f, "_{id}"),
56 SafeId::Explicit(id) => write!(f, "{id}"),
57 }
58 }
59}
60
61impl Section {
62 fn id_from_title(title: &[InlineNode]) -> String {
63 let title_text = converter::inlines_to_string(title);
65 let mut id = title_text
66 .to_lowercase()
67 .chars()
68 .filter_map(|c| {
69 if c.is_alphanumeric() {
70 Some(c)
71 } else if c.is_whitespace() || c == '-' || c == '.' || c == '_' {
72 Some('_')
73 } else {
74 None
75 }
76 })
77 .collect::<String>();
78
79 id = id.trim_end_matches('_').to_string();
81
82 let (collapsed, _) = id.chars().fold(
87 (String::with_capacity(id.len()), false), |(mut acc_string, last_was_underscore), current_char| {
89 if current_char == '_' {
90 if !last_was_underscore {
91 acc_string.push('_'); }
93 (acc_string, true) } else {
95 acc_string.push(current_char);
96 (acc_string, false) }
98 },
99 );
100 collapsed
101 }
102
103 #[must_use]
113 pub fn generate_id(metadata: &BlockMetadata, title: &[InlineNode]) -> SafeId {
114 if let Some(anchor) = &metadata.id {
116 return SafeId::Explicit(anchor.id.clone());
117 }
118 if let Some(anchor) = metadata.anchors.last() {
121 return SafeId::Explicit(anchor.id.clone());
122 }
123 let id = Self::id_from_title(title);
125 SafeId::Generated(id)
126 }
127}
128
129impl Serialize for Section {
130 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
131 where
132 S: Serializer,
133 {
134 let mut state = serializer.serialize_map(None)?;
135 state.serialize_entry("name", "section")?;
136 state.serialize_entry("type", "block")?;
137 state.serialize_entry("title", &self.title)?;
138 state.serialize_entry("level", &self.level)?;
139 if !self.metadata.is_default() {
140 state.serialize_entry("metadata", &self.metadata)?;
141 }
142 if !self.content.is_empty() {
143 state.serialize_entry("blocks", &self.content)?;
144 }
145 state.serialize_entry("location", &self.location)?;
146 state.end()
147 }
148}
149
150#[cfg(test)]
151mod tests {
152 use crate::{Anchor, Plain};
153
154 use super::*;
155
156 #[test]
157 fn test_id_from_title() {
158 let inlines: &[InlineNode] = &[InlineNode::PlainText(Plain {
159 content: "This is a title.".to_string(),
160 location: Location::default(),
161 escaped: false,
162 })];
163 assert_eq!(
164 Section::id_from_title(inlines),
165 "this_is_a_title".to_string()
166 );
167 let inlines: &[InlineNode] = &[InlineNode::PlainText(Plain {
168 content: "This is a----title.".to_string(),
169 location: Location::default(),
170 escaped: false,
171 })];
172 assert_eq!(
173 Section::id_from_title(inlines),
174 "this_is_a_title".to_string()
175 );
176 }
177
178 #[test]
179 fn test_id_from_title_preserves_underscores() {
180 let inlines: &[InlineNode] = &[InlineNode::PlainText(Plain {
182 content: "CHART_BOT".to_string(),
183 location: Location::default(),
184 escaped: false,
185 })];
186 assert_eq!(Section::id_from_title(inlines), "chart_bot".to_string());
187 let inlines: &[InlineNode] = &[InlineNode::PlainText(Plain {
188 content: "haiku_robot".to_string(),
189 location: Location::default(),
190 escaped: false,
191 })];
192 assert_eq!(Section::id_from_title(inlines), "haiku_robot".to_string());
193 let inlines: &[InlineNode] = &[InlineNode::PlainText(Plain {
194 content: "meme_transcriber".to_string(),
195 location: Location::default(),
196 escaped: false,
197 })];
198 assert_eq!(
199 Section::id_from_title(inlines),
200 "meme_transcriber".to_string()
201 );
202 }
203
204 #[test]
205 fn test_section_generate_id() {
206 let inlines: &[InlineNode] = &[InlineNode::PlainText(Plain {
207 content: "This is a b__i__g title.".to_string(),
208 location: Location::default(),
209 escaped: false,
210 })];
211 let metadata = BlockMetadata::default();
213 assert_eq!(
214 Section::generate_id(&metadata, inlines),
215 SafeId::Generated("this_is_a_b_i_g_title".to_string())
216 );
217
218 let metadata = BlockMetadata {
220 id: Some(Anchor {
221 id: "custom_id".to_string(),
222 xreflabel: None,
223 location: Location::default(),
224 }),
225 ..Default::default()
226 };
227 assert_eq!(
228 Section::generate_id(&metadata, inlines),
229 SafeId::Explicit("custom_id".to_string())
230 );
231
232 let metadata = BlockMetadata {
234 anchors: vec![Anchor {
235 id: "anchor_id".to_string(),
236 xreflabel: None,
237 location: Location::default(),
238 }],
239 ..Default::default()
240 };
241 assert_eq!(
242 Section::generate_id(&metadata, inlines),
243 SafeId::Explicit("anchor_id".to_string())
244 );
245
246 let metadata = BlockMetadata {
248 anchors: vec![
249 Anchor {
250 id: "first_anchor".to_string(),
251 xreflabel: None,
252 location: Location::default(),
253 },
254 Anchor {
255 id: "last_anchor".to_string(),
256 xreflabel: None,
257 location: Location::default(),
258 },
259 ],
260 ..Default::default()
261 };
262 assert_eq!(
263 Section::generate_id(&metadata, inlines),
264 SafeId::Explicit("last_anchor".to_string())
265 );
266
267 let metadata = BlockMetadata {
269 id: Some(Anchor {
270 id: "from_id".to_string(),
271 xreflabel: None,
272 location: Location::default(),
273 }),
274 anchors: vec![Anchor {
275 id: "from_anchors".to_string(),
276 xreflabel: None,
277 location: Location::default(),
278 }],
279 ..Default::default()
280 };
281 assert_eq!(
282 Section::generate_id(&metadata, inlines),
283 SafeId::Explicit("from_id".to_string())
284 );
285 }
286}