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