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(&notefile, raw.as_bytes());
120    ///
121    /// // Start test.
122    /// let c = ContentString::open(&notefile).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}