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