tpnote_lib/content.rs
1//! Self referencing data structures to store the note's
2//! content as a raw string.
3use self_cell::self_cell;
4use std::fmt;
5use std::fmt::Debug;
6use std::fs::create_dir_all;
7use std::fs::read_to_string;
8use std::fs::OpenOptions;
9use std::io::Write;
10use std::path::Path;
11use substring::Substring;
12
13use crate::config::TMPL_VAR_DOC;
14use crate::error::InputStreamError;
15
16/// As all text before the header marker `"---"` is ignored, this
17/// constant limits the maximum number of characters that are skipped
18/// before the header starts. In other words: the header
19/// must start within the first `BEFORE_HEADER_MAX_IGNORED_CHARS`.
20const BEFORE_HEADER_MAX_IGNORED_CHARS: usize = 1024;
21
22/// This trait represents Tp-Note content.
23/// The content is devided into header and body.
24/// The header is the YAML meta data describing the body.
25/// In some cases the header might be empty, e.g. when the data comes from
26/// the clipboard (the `txt_clipboard` data might come with a header).
27/// The body is flat UTF-8 markup formatted text, e.g. in
28/// Markdown or in ReStructuredText.
29/// A special case is HTML data in the body, originating from the HTML
30/// clipboard. Here, the body always starts with an HTML start tag
31/// (for details see the `html::HtmlStream` trait) and the header is always
32/// empty.
33///
34/// The trait provides cheap access to the header with `header()`, the body
35/// with `body()`, and the whole raw text with `as_str()`.
36/// Implementers should cache the `header()` and `body()` function results in
37/// order to keep these as cheap as possible.
38///
39/// ```rust
40/// use tpnote_lib::content::Content;
41/// use tpnote_lib::content::ContentString;
42/// let input = "---\ntitle: My note\n---\nMy body";
43/// let c = ContentString::from_string(
44/// String::from(input), "doc".to_string());
45///
46/// assert_eq!(c.header(), "title: My note");
47/// assert_eq!(c.body(), "My body");
48/// assert_eq!(c.name(), "doc");
49/// assert_eq!(c.as_str(), input);
50///
51/// // A test without front matter leads to an empty header:
52/// let c = ContentString::from_string(
53/// String::from("No header"), "doc".to_string());
54///
55/// assert_eq!(c.header(), "");
56/// assert_eq!(c.body(), "No header");
57/// assert_eq!(c.name(), "doc");
58/// assert_eq!(c.as_str(), "No header");
59/// ```
60///
61/// The `Content` trait allows to plug in your own storage back end if
62/// `ContentString` does not suit you. In addition to the example shown below,
63/// you can overwrite `Content::open()` and `Content::save_as()` as well.
64///
65/// ```rust
66/// use tpnote_lib::content::Content;
67/// use std::string::String;
68///
69/// #[derive(Debug, Eq, PartialEq, Default)]
70/// struct MyString(String, String);
71/// impl Content for MyString {
72/// /// Constructor
73/// fn from_string(input: String,
74/// name: String) -> Self {
75/// MyString(input, name)
76/// }
77///
78/// /// This sample implementation may be too expensive.
79/// /// Better precalculate this in `Self::from()`.
80/// fn header(&self) -> &str {
81/// Self::split(&self.as_str()).0
82/// }
83/// fn body(&self) -> &str {
84/// Self::split(&self.as_str()).1
85/// }
86/// fn name(&self) -> &str {
87/// &self.1
88/// }
89/// }
90///
91/// impl AsRef<str> for MyString {
92/// fn as_ref(&self) -> &str {
93/// &self.0
94/// }
95/// }
96///
97/// let input = "---\ntitle: My note\n---\nMy body";
98/// let s = MyString::from_string(
99/// input.to_string(), "doc".to_string());
100///
101/// assert_eq!(s.header(), "title: My note");
102/// assert_eq!(s.body(), "My body");
103/// assert_eq!(s.name(), "doc");
104/// assert_eq!(s.as_str(), input);
105/// ```
106pub trait Content: AsRef<str> + Debug + Eq + PartialEq + Default {
107 /// Reads the file at `path` and stores the content
108 /// `Content`. Possible `\r\n` are replaced by `\n`.
109 /// This trait has a default implementation, the empty content.
110 ///
111 /// ```rust
112 /// use tpnote_lib::content::Content;
113 /// use tpnote_lib::content::ContentString;
114 /// use std::env::temp_dir;
115 ///
116 /// // Prepare test.
117 /// let raw = "---\ntitle: My note\n---\nMy body";
118 /// let notefile = temp_dir().join("20221030-hello -- world.md");
119 /// let _ = std::fs::write(¬efile, raw.as_bytes());
120 ///
121 /// // Start test.
122 /// let c = ContentString::open(¬efile).unwrap();
123 ///
124 /// assert_eq!(c.header(), "title: My note");
125 /// assert_eq!(c.body(), "My body");
126 /// assert_eq!(c.name(), "doc");
127 /// ```
128 fn open(path: &Path) -> Result<Self, std::io::Error>
129 where
130 Self: Sized,
131 {
132 Ok(Self::from_string_with_cr(
133 read_to_string(path)?,
134 TMPL_VAR_DOC.to_string(),
135 ))
136 }
137
138 /// Constructor that parses a Tp-Note document.
139 /// A valid document is UTF-8 encoded and starts with an optional
140 /// BOM (byte order mark) followed by `---`. When the start marker
141 /// `---` does not follow directly the BOM, it must be prepended
142 /// by an empty line. In this case all text before is ignored:
143 /// BOM + ignored text + empty line + `---`.
144 /// Contract: the input string does not contain `\r\n`. If
145 /// it may, use `Content::from_string_with_cr()` instead.
146 ///
147 /// ```rust
148 /// use tpnote_lib::content::Content;
149 /// use tpnote_lib::content::ContentString;
150 /// let input = "---\ntitle: My note\n---\nMy body";
151 /// let c = ContentString::from_string(
152 /// input.to_string(), "doc".to_string());
153 ///
154 /// assert_eq!(c.header(), "title: My note");
155 /// assert_eq!(c.body(), "My body");
156 /// assert_eq!(c.name(), "doc");
157 ///
158 /// // A test without front matter leads to an empty header:
159 /// let c = ContentString::from_string("No header".to_string(),
160 /// "doc".to_string());
161 ///
162 /// assert_eq!(c.header(), "");
163 /// assert_eq!(c.body(), "No header");
164 /// assert_eq!(c.name(), "doc");
165 /// ```
166 /// Self referential. The constructor splits the content
167 /// in header and body and associates names to both. These names are
168 /// referenced in various templates.
169 fn from_string(input: String, name: String) -> Self;
170
171 /// Constructor that reads a structured document with a YAML header
172 /// and body. All `\r\n` are converted to `\n` if there are any.
173 /// If not no memory allocation occurs and the buffer remains untouched.
174 ///
175 /// ```rust
176 /// use tpnote_lib::content::Content;
177 /// use tpnote_lib::content::ContentString;
178 /// let c = ContentString::from_string_with_cr(String::from(
179 /// "---\r\ntitle: My note\r\n---\r\nMy\nbody\r\n"),
180 /// "doc".to_string(),
181 /// );
182 ///
183 /// assert_eq!(c.header(), "title: My note");
184 /// assert_eq!(c.body(), "My\nbody\n");
185 /// assert_eq!(c.borrow_dependent().name, "doc");
186 ///
187 /// // A test without front matter leads to an empty header:
188 /// let c = ContentString::from_string(
189 /// "No header".to_string(), "doc".to_string());
190 ///
191 /// assert_eq!(c.borrow_dependent().header, "");
192 /// assert_eq!(c.borrow_dependent().body, "No header");
193 /// assert_eq!(c.borrow_dependent().name, "doc");
194 /// ```
195 fn from_string_with_cr(input: String, name: String) -> Self {
196 // Avoid allocating when there is nothing to do.
197 let input = if input.find('\r').is_none() {
198 // Forward without allocating.
199 input
200 } else {
201 // We allocate here and do a lot of copying.
202 input.replace("\r\n", "\n")
203 };
204 Self::from_string(input, name)
205 }
206
207 /// Returns a reference to the inner part in between `---`.
208 fn header(&self) -> &str;
209
210 /// Returns the body below the second `---`.
211 fn body(&self) -> &str;
212
213 /// Returns the associated name exactly as it was given to the constructor.
214 fn name(&self) -> &str;
215
216 /// Constructor that accepts and store HTML input in the body.
217 /// If the HTML input does not start with `<!DOCTYPE html...>` it is
218 /// automatically prepended.
219 /// If the input starts with another DOCTYPE than HTMl, return
220 /// `InputStreamError::NonHtmlDoctype`.
221 ///
222 /// ```rust
223 /// use tpnote_lib::content::Content;
224 /// use tpnote_lib::content::ContentString;
225 ///
226 /// let c = ContentString::from_html(
227 /// "Some HTML content".to_string(),
228 /// "html_clipboard".to_string()).unwrap();
229 /// assert_eq!(c.header(), "");
230 /// assert_eq!(c.body(), "<!DOCTYPE html>Some HTML content");
231 /// assert_eq!(c.name(), "html_clipboard");
232 ///
233 /// let c = ContentString::from_html(String::from(
234 /// "<!DOCTYPE html>Some HTML content"),
235 /// "html_clipboard".to_string()).unwrap();
236 /// assert_eq!(c.header(), "");
237 /// assert_eq!(c.body(), "<!DOCTYPE html>Some HTML content");
238 /// assert_eq!(c.name(), "html_clipboard");
239 ///
240 /// let c = ContentString::from_html(String::from(
241 /// "<!DOCTYPE xml>Some HTML content"), "".to_string());
242 /// assert!(c.is_err());
243 /// ```
244 fn from_html(input: String, name: String) -> Result<Self, InputStreamError> {
245 use crate::html::HtmlString;
246 let input = input.prepend_html_start_tag()?;
247 Ok(Self::from_string(input, name))
248 }
249
250 /// Writes the note to disk with `new_file_path` as filename.
251 /// If `new_file_path` contains missing directories, they will be
252 /// created on the fly.
253 ///
254 /// ```rust
255 /// use std::path::Path;
256 /// use std::env::temp_dir;
257 /// use std::fs;
258 /// use tpnote_lib::content::Content;
259 /// use tpnote_lib::content::ContentString;
260 /// let c = ContentString::from_string(
261 /// String::from("prelude\n\n---\ntitle: My note\n---\nMy body"),
262 /// "doc".to_string()
263 /// );
264 /// let outfile = temp_dir().join("mynote.md");
265 /// #[cfg(not(target_family = "windows"))]
266 /// let expected = "\u{feff}prelude\n\n---\ntitle: My note\n---\nMy body\n";
267 /// #[cfg(target_family = "windows")]
268 /// let expected = "\u{feff}prelude\r\n\r\n---\r\ntitle: My note\r\n---\r\nMy body\r\n";
269 ///
270 /// c.save_as(&outfile).unwrap();
271 /// let result = fs::read_to_string(&outfile).unwrap();
272 ///
273 /// assert_eq!(result, expected);
274 /// fs::remove_file(&outfile);
275 /// ```
276 fn save_as(&self, new_file_path: &Path) -> Result<(), std::io::Error> {
277 // Create missing directories, if there are any.
278 create_dir_all(new_file_path.parent().unwrap_or_else(|| Path::new("")))?;
279
280 let mut outfile = OpenOptions::new()
281 .write(true)
282 .create(true)
283 .truncate(true)
284 .open(new_file_path)?;
285
286 log::trace!("Creating file: {:?}", new_file_path);
287 write!(outfile, "\u{feff}")?;
288
289 for l in self.as_str().lines() {
290 write!(outfile, "{}", l)?;
291 #[cfg(target_family = "windows")]
292 write!(outfile, "\r")?;
293 writeln!(outfile)?;
294 }
295
296 Ok(())
297 }
298
299 /// Accesses the whole content with all `---`.
300 /// Contract: The content does not contain any `\r\n`.
301 /// If your content contains `\r\n` use the
302 /// `from_string_with_cr()` constructor.
303 /// Possible BOM at the first position is not returned.
304 fn as_str(&self) -> &str {
305 self.as_ref().trim_start_matches('\u{feff}')
306 }
307
308 /// True if the header and body is empty.
309 ///
310 /// ```rust
311 /// use tpnote_lib::content::Content;
312 /// use tpnote_lib::content::ContentString;
313 ///
314 /// let c = ContentString::default();
315 /// assert_eq!(c.header(), "");
316 /// assert_eq!(c.body(), "");
317 /// assert!(c.is_empty());
318 ///
319 /// let c = ContentString::from_string(
320 /// "".to_string(),
321 /// "doc".to_string(),
322 /// );
323 /// assert_eq!(c.header(), "");
324 /// assert_eq!(c.body(), "");
325 /// assert!(c.is_empty());
326 ///
327 /// let c = ContentString::from_string(
328 /// "Some content".to_string(),
329 /// "doc".to_string(),
330 /// );
331 /// assert_eq!(c.header(), "");
332 /// assert_eq!(c.body(), "Some content");
333 /// assert!(!c.is_empty());
334 ///
335 /// let c = ContentString::from_html(
336 /// "".to_string(),
337 /// "doc".to_string(),
338 /// ).unwrap();
339 /// assert_eq!(c.header(), "");
340 /// assert_eq!(c.body(), "<!DOCTYPE html>");
341 /// assert!(c.is_empty());
342 ///
343 /// let c = ContentString::from_html(
344 /// "Some HTML content".to_string(),
345 /// "doc".to_string(),
346 /// ).unwrap();
347 /// assert_eq!(c.header(), "");
348 /// assert_eq!(c.body(), "<!DOCTYPE html>Some HTML content");
349 /// assert!(!c.is_empty());
350 ///
351 ///
352 /// let c = ContentString::from_string(
353 /// String::from("---\ntitle: My note\n---\n"),
354 /// "doc".to_string(),
355 /// );
356 /// assert_eq!(c.header(), "title: My note");
357 /// assert_eq!(c.body(), "");
358 /// assert!(!c.is_empty());
359 /// ```
360 fn is_empty(&self) -> bool {
361 // Bring methods into scope. Overwrites `is_empty()`.
362 use crate::html::HtmlStr;
363 // `.is_empty_html()` is true for `""` or only `<!DOCTYPE html...>`.
364 self.header().is_empty() && (self.body().is_empty_html())
365 }
366
367 /// Helper function that splits the content into header and body.
368 /// The header, if present, is trimmed (`trim()`), the body
369 /// is kept as it is.
370 /// Any BOM (byte order mark) at the beginning is ignored.
371 ///
372 /// 1. Ignore `\u{feff}` if present
373 /// 2. Ignore `---\n` or ignore all bytes until`\n\n---\n`,
374 /// 3. followed by header bytes,
375 /// 4. optionally followed by `\n`,
376 /// 5. followed by `\n---\n` or `\n...\n`,
377 /// 6. optionally followed by some `\t` and/or some ` `,
378 /// 7. optionally followed by `\n`.
379 ///
380 /// The remaining bytes are the "body".
381 ///
382 /// Alternatively, a YAML metadata block may occur anywhere in the document, but if it is not
383 /// at the beginning, it must be preceded by a blank line:
384 /// 1. skip all text (BEFORE_HEADER_MAX_IGNORED_CHARS) until you find `"\n\n---"`
385 /// 2. followed by header bytes,
386 /// 3. same as above ...
387 fn split(content: &str) -> (&str, &str) {
388 // Bring in scope `HtmlString`.
389 use crate::html::HtmlStr;
390 // Remove BOM
391 let content = content.trim_start_matches('\u{feff}');
392
393 if content.is_empty() {
394 return ("", "");
395 };
396
397 // If this is HTML content, leave the header empty.
398 // TODO: In the future the header might be constructed from
399 // the "meta" HTML fields. Though I am not sure if something meaningful
400 // can be found in HTML clipboard meta data.
401 if content.has_html_start_tag() {
402 return ("", content);
403 }
404
405 const HEADER_START_TAG: &str = "---";
406 let fm_start = if content.starts_with(HEADER_START_TAG) {
407 // Found at first byte.
408 HEADER_START_TAG.len()
409 } else {
410 const HEADER_START_TAG: &str = "\n\n---";
411 if let Some(start) = content
412 .substring(0, BEFORE_HEADER_MAX_IGNORED_CHARS)
413 .find(HEADER_START_TAG)
414 .map(|x| x + HEADER_START_TAG.len())
415 {
416 // Found just before `start`!
417 start
418 } else {
419 // Not found.
420 return ("", content);
421 }
422 };
423
424 // The first character after the document start marker
425 // must be a whitespace.
426 if !content[fm_start..]
427 .chars()
428 .next()
429 // If none, make test fail.
430 .unwrap_or('x')
431 .is_whitespace()
432 {
433 return ("", content);
434 };
435
436 // No need to search for an additional `\n` here, as we trim the
437 // header anyway.
438
439 const HEADER_END_TAG1: &str = "\n---";
440 // Contract: next pattern must have the same length!
441 const HEADER_END_TAG2: &str = "\n...";
442 debug_assert_eq!(HEADER_END_TAG1.len(), HEADER_END_TAG2.len());
443 const TAG_LEN: usize = HEADER_END_TAG1.len();
444
445 let fm_end = content[fm_start..]
446 .find(HEADER_END_TAG1)
447 .or_else(|| content[fm_start..].find(HEADER_END_TAG2))
448 .map(|x| x + fm_start);
449
450 let fm_end = if let Some(n) = fm_end {
451 n
452 } else {
453 return ("", content);
454 };
455
456 // We advance 4 because `"\n---"` has 4 bytes.
457 let mut body_start = fm_end + TAG_LEN;
458
459 // Skip spaces and tabs followed by one optional newline.
460 while let Some(c) = content[body_start..].chars().next() {
461 if c == ' ' || c == '\t' {
462 body_start += 1;
463 } else {
464 // Skip exactly one newline, if there is at least one.
465 if c == '\n' {
466 body_start += 1;
467 }
468 // Exit loop.
469 break;
470 };
471 }
472
473 (content[fm_start..fm_end].trim(), &content[body_start..])
474 }
475}
476
477#[derive(Debug, Eq, PartialEq)]
478/// Pointers belonging to the self referential struct `Content`.
479pub struct ContentRef<'a> {
480 /// Skip optional BOM and `"---" `in `s` until next `"---"`.
481 /// When no `---` is found, this is empty.
482 /// `header` is always trimmed.
483 pub header: &'a str,
484 /// A name associated with this header. Used in templates.
485 pub body: &'a str,
486 /// A name associated with this content. Used in templates.
487 pub name: String,
488}
489
490self_cell!(
491/// Holds the notes content in a string and two string slices
492/// `header` and `body`.
493/// This struct is self referential.
494/// It deals with operating system specific handling of newlines.
495/// The content of a note is stored as UTF-8 string with
496/// one `\n` character as newline. If present, a Byte Order Mark
497/// BOM is removed while reading with `new()`.
498 pub struct ContentString {
499 owner: String,
500
501 #[covariant]
502 dependent: ContentRef,
503 }
504
505 impl {Debug, Eq, PartialEq}
506);
507
508/// Add `header()` and `body()` implementation.
509impl Content for ContentString {
510 fn from_string(input: String, name: String) -> Self {
511 ContentString::new(input, |owner: &String| {
512 let (header, body) = ContentString::split(owner);
513 ContentRef { header, body, name }
514 })
515 }
516
517 /// Cheap access to the note's header.
518 fn header(&self) -> &str {
519 self.borrow_dependent().header
520 }
521
522 /// Cheap access to the note's body.
523 fn body(&self) -> &str {
524 self.borrow_dependent().body
525 }
526
527 /// Returns the name as given at construction.
528 fn name(&self) -> &str {
529 &self.borrow_dependent().name
530 }
531}
532
533/// Default is the empty string.
534impl Default for ContentString {
535 fn default() -> Self {
536 Self::from_string(String::new(), String::new())
537 }
538}
539
540/// Returns the whole raw content with header and body.
541/// Possible `\r\n` in the input are replaced by `\n`.
542impl AsRef<str> for ContentString {
543 fn as_ref(&self) -> &str {
544 self.borrow_owner()
545 }
546}
547
548/// Concatenates the header and the body and prints the content.
549/// This function is expensive as it involves copying the
550/// whole content.
551impl fmt::Display for ContentRef<'_> {
552 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
553 let s = if self.header.is_empty() {
554 self.body.to_string()
555 } else {
556 format!("\u{feff}---\n{}\n---\n{}", &self.header, &self.body)
557 };
558 write!(f, "{}", s)
559 }
560}
561
562/// Delegates the printing to `Display for ContentRef`.
563impl fmt::Display for ContentString {
564 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
565 std::fmt::Display::fmt(&self.borrow_dependent(), f)
566 }
567}
568
569#[cfg(test)]
570mod tests {
571 use super::*;
572
573 #[test]
574 fn test_from_string_with_cr() {
575 // Test windows string.
576 let content = ContentString::from_string_with_cr(
577 "first\r\nsecond\r\nthird".to_string(),
578 "doc".to_string(),
579 );
580 assert_eq!(content.borrow_dependent().body, "first\nsecond\nthird");
581
582 // Test Unix string.
583 let content = ContentString::from_string_with_cr(
584 "first\nsecond\nthird".to_string(),
585 "doc".to_string(),
586 );
587 assert_eq!(content.borrow_dependent().body, "first\nsecond\nthird");
588
589 // Test BOM removal.
590 let content = ContentString::from_string_with_cr(
591 "\u{feff}first\nsecond\nthird".to_string(),
592 "doc".to_string(),
593 );
594 assert_eq!(content.borrow_dependent().body, "first\nsecond\nthird");
595 }
596
597 #[test]
598 fn test_from_string() {
599 // Test Unix string.
600 let content =
601 ContentString::from_string("first\nsecond\nthird".to_string(), "doc".to_string());
602 assert_eq!(content.borrow_dependent().body, "first\nsecond\nthird");
603 assert_eq!(content.borrow_dependent().name, "doc");
604
605 // Test BOM removal.
606 let content = ContentString::from_string(
607 "\u{feff}first\nsecond\nthird".to_string(),
608 "doc".to_string(),
609 );
610 assert_eq!(content.borrow_dependent().body, "first\nsecond\nthird");
611 assert_eq!(content.borrow_dependent().name, "doc");
612
613 // Test header extraction.
614 let content = ContentString::from_string(
615 "\u{feff}---\nfirst\n---\nsecond\nthird".to_string(),
616 "doc".to_string(),
617 );
618 assert_eq!(content.borrow_dependent().header, "first");
619 assert_eq!(content.borrow_dependent().body, "second\nthird");
620 assert_eq!(content.borrow_dependent().name, "doc");
621
622 // Test header extraction without `\n` at the end.
623 let content =
624 ContentString::from_string("\u{feff}---\nfirst\n---".to_string(), "doc".to_string());
625 assert_eq!(content.borrow_dependent().header, "first");
626 assert_eq!(content.borrow_dependent().body, "");
627
628 // Some skipped bytes.
629 let content = ContentString::from_string(
630 "\u{feff}ignored\n\n---\nfirst\n---".to_string(),
631 "doc".to_string(),
632 );
633 assert_eq!(content.borrow_dependent().header, "first");
634 assert_eq!(content.borrow_dependent().body, "");
635
636 // This fails to find the header because the `---` comes to late.
637 let mut s = "\u{feff}".to_string();
638 s.push_str(&String::from_utf8(vec![b'X'; BEFORE_HEADER_MAX_IGNORED_CHARS]).unwrap());
639 s.push_str("\n\n---\nfirst\n---\nsecond");
640 let s_ = s.clone();
641 let content = ContentString::from_string(s, "doc".to_string());
642 assert_eq!(content.borrow_dependent().header, "");
643 assert_eq!(content.borrow_dependent().body, &s_[3..]);
644
645 // This finds the header.
646 let mut s = "\u{feff}".to_string();
647 s.push_str(
648 &String::from_utf8(vec![
649 b'X';
650 BEFORE_HEADER_MAX_IGNORED_CHARS - "\n\n---".len()
651 ])
652 .unwrap(),
653 );
654 s.push_str("\n\n---\nfirst\n---\nsecond");
655 let content = ContentString::from_string(s, "doc".to_string());
656 assert_eq!(content.borrow_dependent().header, "first");
657 assert_eq!(content.borrow_dependent().body, "second");
658 }
659
660 #[test]
661 fn test_split() {
662 // Document start marker is not followed by whitespace.
663 let input_stream = String::from("---first\n---\nsecond\nthird");
664 let expected = ("", "---first\n---\nsecond\nthird");
665 let result = ContentString::split(&input_stream);
666 assert_eq!(result, expected);
667
668 // Document start marker is followed by whitespace.
669 let input_stream = String::from("---\nfirst\n---\nsecond\nthird");
670 let expected = ("first", "second\nthird");
671 let result = ContentString::split(&input_stream);
672 assert_eq!(result, expected);
673
674 // Document start marker is followed by whitespace.
675 let input_stream = String::from("---\tfirst\n---\nsecond\nthird");
676 let expected = ("first", "second\nthird");
677 let result = ContentString::split(&input_stream);
678 assert_eq!(result, expected);
679
680 // Document start marker is followed by whitespace.
681 let input_stream = String::from("--- first\n---\nsecond\nthird");
682 let expected = ("first", "second\nthird");
683 let result = ContentString::split(&input_stream);
684 assert_eq!(result, expected);
685
686 // Header is trimmed.
687 let input_stream = String::from("---\n\nfirst\n\n---\nsecond\nthird");
688 let expected = ("first", "second\nthird");
689 let result = ContentString::split(&input_stream);
690 assert_eq!(result, expected);
691
692 // Body is kept as it is (not trimmed).
693 let input_stream = String::from("---\nfirst\n---\n\nsecond\nthird\n");
694 let expected = ("first", "\nsecond\nthird\n");
695 let result = ContentString::split(&input_stream);
696 assert_eq!(result, expected);
697
698 // Header end marker line is trimmed right.
699 let input_stream = String::from("---\nfirst\n--- \t \n\nsecond\nthird\n");
700 let expected = ("first", "\nsecond\nthird\n");
701 let result = ContentString::split(&input_stream);
702 assert_eq!(result, expected);
703
704 let input_stream = String::from("\nsecond\nthird");
705 let expected = ("", "\nsecond\nthird");
706 let result = ContentString::split(&input_stream);
707 assert_eq!(result, expected);
708
709 let input_stream = String::from("");
710 let expected = ("", "");
711 let result = ContentString::split(&input_stream);
712 assert_eq!(result, expected);
713
714 let input_stream = String::from("\u{feff}\nsecond\nthird");
715 let expected = ("", "\nsecond\nthird");
716 let result = ContentString::split(&input_stream);
717 assert_eq!(result, expected);
718
719 let input_stream = String::from("\u{feff}");
720 let expected = ("", "");
721 let result = ContentString::split(&input_stream);
722 assert_eq!(result, expected);
723
724 let input_stream = String::from("[📽 2 videos]");
725 let expected = ("", "[📽 2 videos]");
726 let result = ContentString::split(&input_stream);
727 assert_eq!(result, expected);
728
729 let input_stream = "my prelude\n\n---\nmy header\n--- \nmy body\n";
730 let expected = ("my header", "my body\n");
731 let result = ContentString::split(input_stream);
732 assert_eq!(result, expected);
733 }
734
735 #[test]
736 fn test_display_for_content() {
737 let expected = "\u{feff}---\nfirst\n---\n\nsecond\nthird\n".to_string();
738 let input = ContentString::from_string(expected.clone(), "does not matter".to_string());
739 assert_eq!(input.to_string(), expected);
740
741 let expected = "\nsecond\nthird\n".to_string();
742 let input = ContentString::from_string(expected.clone(), "does not matter".to_string());
743 assert_eq!(input.to_string(), expected);
744
745 let expected = "".to_string();
746 let input = ContentString::from_string(expected.clone(), "does not matter".to_string());
747 assert_eq!(input.to_string(), expected);
748
749 let expected = "\u{feff}---\nfirst\n---\n\nsecond\nthird\n".to_string();
750 let input = ContentString::from_string(
751 "\u{feff}ignored\n\n---\nfirst\n---\n\nsecond\nthird\n".to_string(),
752 "does not matter".to_string(),
753 );
754 assert_eq!(input.to_string(), expected);
755 }
756}