Skip to main content

tpnote_lib/
context.rs

1//! Extends the built-in Tera filters.
2use crate::config::Assertion;
3use crate::config::FILENAME_ROOT_PATH_MARKER;
4use crate::config::LIB_CFG;
5#[cfg(feature = "viewer")]
6use crate::config::TMPL_HTML_VAR_DOC_ERROR;
7#[cfg(feature = "viewer")]
8use crate::config::TMPL_HTML_VAR_DOC_TEXT;
9use crate::config::TMPL_HTML_VAR_EXPORTER_DOC_CSS;
10use crate::config::TMPL_HTML_VAR_EXPORTER_HIGHLIGHTING_CSS;
11use crate::config::TMPL_HTML_VAR_VIEWER_DOC_CSS_PATH;
12use crate::config::TMPL_HTML_VAR_VIEWER_DOC_CSS_PATH_VALUE;
13use crate::config::TMPL_HTML_VAR_VIEWER_DOC_JS;
14use crate::config::TMPL_HTML_VAR_VIEWER_HIGHLIGHTING_CSS_PATH;
15use crate::config::TMPL_HTML_VAR_VIEWER_HIGHLIGHTING_CSS_PATH_VALUE;
16use crate::config::TMPL_VAR_BODY;
17use crate::config::TMPL_VAR_CURRENT_SCHEME;
18use crate::config::TMPL_VAR_DIR_PATH;
19use crate::config::TMPL_VAR_DOC_FILE_DATE;
20use crate::config::TMPL_VAR_EXTENSION_DEFAULT;
21use crate::config::TMPL_VAR_FM_;
22use crate::config::TMPL_VAR_FM_ALL;
23use crate::config::TMPL_VAR_FM_SCHEME;
24use crate::config::TMPL_VAR_FORCE_LANG;
25use crate::config::TMPL_VAR_HEADER;
26use crate::config::TMPL_VAR_LANG;
27use crate::config::TMPL_VAR_PATH;
28use crate::config::TMPL_VAR_ROOT_PATH;
29use crate::config::TMPL_VAR_SCHEME_SYNC_DEFAULT;
30use crate::config::TMPL_VAR_USERNAME;
31use crate::content::Content;
32use crate::error::FileError;
33use crate::error::LibCfgError;
34use crate::error::NoteError;
35use crate::filename::Extension;
36use crate::filename::NotePath;
37use crate::filename::NotePathStr;
38use crate::filter::name;
39use crate::front_matter::FrontMatter;
40use crate::settings::SETTINGS;
41use std::borrow::Cow;
42use std::fs::File;
43use std::marker::PhantomData;
44use std::ops::Deref;
45use std::path::Path;
46use std::path::PathBuf;
47use std::time::SystemTime;
48
49/// At trait setting up a state machine as described below.
50/// Its implementors represent one specific state defining the amount and the
51/// type of data the `Context` type holds at that moment.
52pub trait ContextState {}
53
54#[derive(Debug, PartialEq, Clone)]
55/// See description in the `ContextState` implementor list.
56pub struct Invalid;
57
58#[derive(Debug, PartialEq, Clone)]
59/// See description in the `ContextState` implementor list.
60pub struct HasSettings;
61
62#[derive(Debug, PartialEq, Clone)]
63/// See description in the `ContextState` implementor list.
64pub(crate) struct ReadyForFilenameTemplate;
65
66#[derive(Debug, PartialEq, Clone)]
67/// See description in the `ContextState` implementor list.
68pub(crate) struct HasExistingContent;
69
70#[derive(Debug, PartialEq, Clone)]
71/// See description in the `ContextState` implementor list.
72pub(crate) struct ReadyForContentTemplate;
73
74#[derive(Debug, PartialEq, Clone)]
75/// See description in the `ContextState` implementor list.
76pub(crate) struct ReadyForHtmlTemplate;
77
78#[cfg(feature = "viewer")]
79#[derive(Debug, PartialEq, Clone)]
80/// See description in the `ContextState` implementor list.
81pub(crate) struct ReadyForHtmlErrorTemplate;
82
83/// The `Context` object is in an invalid state. Either it was not initialized
84/// or its data does not correspond any more to the `Content` it represents.
85///
86/// |  State order   |                                       |
87/// |----------------|---------------------------------------|
88/// | Previous state | none                                  |
89/// | Current state  | `Invalid`                             |
90/// | Next state     | `HasSettings`                         |
91///
92impl ContextState for Invalid {}
93
94/// The `Context` has the following initialized and valid fields: `path`,
95/// `dir_path`, `root_path` and `ct`. The context `ct` contains data from
96/// `insert_config_vars()` and `insert_settings()`.
97/// `Context<HasSettings>` has the following variables set:
98///
99/// * `TMPL_VAR_CURRENT_SCHEME`
100/// * `TMPL_VAR_DIR_PATH` in sync with `self.dir_path` and
101/// * `TMPL_VAR_DOC_FILE_DATE` in sync with `self.doc_file_date` (only if
102///   available).
103/// * `TMPL_VAR_EXTENSION_DEFAULT`
104/// * `TMPL_VAR_LANG`
105/// * `TMPL_VAR_PATH` in sync with `self.path`,
106/// * `TMPL_VAR_ROOT_PATH` in sync with `self.root_path`.
107/// * `TMPL_VAR_SCHEME_SYNC_DEFAULT`.
108/// * `TMPL_VAR_USERNAME`
109///
110/// The variables are inserted by the following methods: `self.from()`,
111/// `self.insert_config_vars()` and `self.insert_settings()`.
112/// Once this state is achieved, `Context` is constant and write protected until
113/// the next state transition.
114///
115/// |  State order   |                                       |
116/// |----------------|---------------------------------------|
117/// | Previous state | `Invalid`                             |
118/// | Current state  | `HasSettings`                         |
119/// | Next state     | `ReadyForFilenameTemplate` or `HasExistingContent` |
120///
121impl ContextState for HasSettings {}
122
123/// In addition to `HasSettings`, the `context.ct` contains template variables
124/// deserialized from some note's front matter. E.g. a field named `title:`
125/// appears in the context as `fm.fm_title` template variable.
126/// In `Note` objects the `Content` is always associated with a
127/// `Context<ReadyForFilenameTemplate>`.
128/// Once this state is achieved, `Context` is constant and write protected until
129/// the next state transition.
130///
131/// |  State order   |                                       |
132/// |----------------|---------------------------------------|
133/// | Previous state | `HasSettings`                         |
134/// | Current state  | `ReadyForFilenameTemplate `           |
135/// | Next state     | none or `ReadyForHtmlTemplate`        |
136///
137impl ContextState for ReadyForFilenameTemplate {}
138
139/// In addition to the `HasSettings` the YAML headers of all clipboard
140/// `Content` objects are registered as front matter variables `fm.fm*` in the
141/// `Context`.
142/// This stage is also used for the `TemplateKind::FromTextFile` template.
143/// In this case the last inserted `Content` comes from the text file
144/// the command line parameter `<path>` points to. This adds the following key:
145///
146/// * `TMPL_VAR_DOC`
147///
148/// This state can evolve as the
149/// `insert_front_matter_and_raw_text_from_existing_content()` function can be
150/// called several times.
151///
152/// |  State order   |                                       |
153/// |----------------|---------------------------------------|
154/// | Previous state | `HasSettings` or `HasExistingContent` |
155/// | Current state  | `HasExistingContent`                  |
156/// | Next state     | `ReadyForContentTemplate`             |
157///
158impl ContextState for HasExistingContent {}
159
160/// This marker state means that enough information have been collected
161/// in the `HasExistingContent` state to be passed to a
162/// content template renderer.
163/// Once this state is achieved, `Context` is constant and write protected until
164/// the next state transition.
165///
166/// |  State order   |                                       |
167/// |----------------|---------------------------------------|
168/// | Previous state | `HasExistingContent`                  |
169/// | Current state  | `ReadyForContentTemplate`             |
170/// | Next state     | none                                  |
171///
172impl ContextState for ReadyForContentTemplate {}
173
174/// In addition to the `ReadyForFilenameTemplate` state this state has the
175/// following variables set:
176///
177/// * `TMPL_HTML_VAR_EXPORTER_DOC_CSS`
178/// * `TMPL_HTML_VAR_EXPORTER_HIGHLIGHTING_CSS`
179/// * `TMPL_HTML_VAR_EXPORTER_HIGHLIGHTING_CSS`
180/// * `TMPL_HTML_VAR_VIEWER_DOC_CSS_PATH`
181/// * `TMPL_HTML_VAR_VIEWER_DOC_CSS_PATH_VALUE`
182/// * `TMPL_HTML_VAR_VIEWER_DOC_JS` from `viewer_doc_js`
183/// * `TMPL_HTML_VAR_VIEWER_HIGHLIGHTING_CSS_PATH`
184/// * `TMPL_HTML_VAR_VIEWER_HIGHLIGHTING_CSS_PATH_VALUE`
185/// * `TMPL_VAR_DOC`
186///
187/// Once this state is achieved, `Context` is constant and write protected until
188/// the next state transition.
189///
190/// |  State order   |                                       |
191/// |----------------|---------------------------------------|
192/// | Previous state | `ReadyForFilenameTemplate`            |
193/// | Current state  | `ReadyForHtmlTemplate`                |
194/// | Next state     | none                                  |
195///
196impl ContextState for ReadyForHtmlTemplate {}
197
198/// The `Context` has all data for the intended template.
199///
200/// * `TMPL_HTML_VAR_DOC_ERROR` from `error_message`
201/// * `TMPL_HTML_VAR_DOC_TEXT` from `note_erroneous_content`
202/// * `TMPL_HTML_VAR_VIEWER_DOC_JS` from `viewer_doc_js`
203///
204/// Once this state is achieved, `Context` is constant and write protected until
205/// the next state transition.
206///
207/// |  State order   |                                       |
208/// |----------------|---------------------------------------|
209/// | Previous state | `HasSettings`                         |
210/// | Current state  | `ReadyForHtmlErrorTemplate`           |
211/// | Next state     | none                                  |
212///
213#[cfg(feature = "viewer")]
214impl ContextState for ReadyForHtmlErrorTemplate {}
215
216/// Recursively checks that all leaf values of a `tera::Value` tree satisfy `f`.
217/// Arrays and maps are traversed; other values are passed to `f`.
218fn tera_all_leaves(val: &tera::Value, f: &dyn Fn(&tera::Value) -> bool) -> bool {
219    if let Some(a) = val.as_array() {
220        a.iter().all(|i| tera_all_leaves(i, f))
221    } else if let Some(map) = val.as_map() {
222        map.values().all(|v| tera_all_leaves(v, f))
223    } else {
224        f(val)
225    }
226}
227
228/// Tiny wrapper around "Tera context" with some additional information.
229#[derive(Clone, Debug, PartialEq)]
230pub struct Context<S: ContextState + ?Sized> {
231    /// Collection of substitution variables.
232    ct: tera::Context,
233    /// First positional command line argument.
234    path: PathBuf,
235    /// The directory (only) path corresponding to the first positional
236    /// command line argument. The is our working directory and
237    /// the directory where the note file is (will be) located.
238    dir_path: PathBuf,
239    /// `dir_path` is a subdirectory of `root_path`. `root_path` is the
240    /// first directory, that upwards from `dir_path`, contains a file named
241    /// `FILENAME_ROOT_PATH_MARKER` (or `/` if no marker file can be found).
242    /// The root directory is interpreted by Tp-Note's viewer as its base
243    /// directory: only files within this directory are served.
244    root_path: PathBuf,
245    /// If `path` points to a file, we store its creation date here.
246    doc_file_date: Option<SystemTime>,
247    /// Rust requires usage of generic parameters, here `S`.
248    _marker: PhantomData<S>,
249}
250
251/// The methods below are available in all `ContentState` states.
252impl<S: ContextState> Context<S> {
253    /// Getter for `self.path`.
254    /// See `from()` method for details.
255    pub fn get_path(&self) -> &Path {
256        self.path.as_path()
257    }
258
259    /// Getter for `self.dir_path`.
260    /// See `from()` method for details.
261    pub fn get_dir_path(&self) -> &Path {
262        self.dir_path.as_path()
263    }
264
265    /// Getter for `self.root_path`.
266    /// See `from()` method for details.
267    pub fn get_root_path(&self) -> &Path {
268        self.root_path.as_path()
269    }
270
271    /// Getter for `self.doc_file_date`.
272    /// See `from()` method for details.
273    pub fn get_doc_file_date(&self) -> Option<SystemTime> {
274        self.doc_file_date
275    }
276
277    /// Constructor. Unlike `from()` this constructor does not access
278    /// the filesystem in order to detect `dir_path`, `root_path` and
279    /// `doc_file_date`. It copies these values from the passed `context`.
280    /// Use this constructor when you are sure that the above date has
281    /// not changed since you instantiated `context`. In this case you
282    /// can avoid repeated file access.
283    pub fn from_context_path(context: &Context<S>) -> Context<HasSettings> {
284        let mut new_context = Context {
285            ct: tera::Context::new(),
286            path: context.path.clone(),
287            dir_path: context.dir_path.clone(),
288            root_path: context.root_path.clone(),
289            doc_file_date: context.doc_file_date,
290            _marker: PhantomData,
291        };
292
293        new_context.sync_paths_to_map();
294        new_context.insert_config_vars();
295        new_context.insert_settings();
296        new_context
297    }
298
299    /// Helper function that keeps the values with the `self.ct` key
300    ///
301    /// * `TMPL_VAR_PATH` in sync with `self.path`,
302    /// * `TMPL_VAR_DIR_PATH` in sync with `self.dir_path` and
303    /// * `TMPL_VAR_ROOT_PATH` in sync with `self.root_path`.
304    /// * `TMPL_VAR_DOC_FILE_DATE` in sync with `self.doc_file_date` (only if
305    ///
306    /// available).
307    /// Synchronization is performed by copying the latter to the former.
308    fn sync_paths_to_map(&mut self) {
309        self.ct.insert(TMPL_VAR_PATH, &self.path);
310        self.ct.insert(TMPL_VAR_DIR_PATH, &self.dir_path);
311        self.ct.insert(TMPL_VAR_ROOT_PATH, &self.root_path);
312        if let Some(time) = self.doc_file_date {
313            self.ct.insert(
314                TMPL_VAR_DOC_FILE_DATE,
315                &time
316                    .duration_since(SystemTime::UNIX_EPOCH)
317                    .unwrap_or_default()
318                    .as_secs(),
319            )
320        } else {
321            self.ct.remove(TMPL_VAR_DOC_FILE_DATE);
322        };
323    }
324
325    /// Insert some configuration variables into the context so that they
326    /// can be used in the templates.
327    ///
328    /// This function adds the key:
329    ///
330    /// * `TMPL_VAR_SCHEME_SYNC_DEFAULT`.
331    ///
332    /// ```
333    /// use std::path::Path;
334    /// use tpnote_lib::config::TMPL_VAR_SCHEME_SYNC_DEFAULT;
335    /// use tpnote_lib::settings::set_test_default_settings;
336    /// use tpnote_lib::context::Context;
337    /// set_test_default_settings().unwrap();
338    ///
339    /// // The constructor calls `context.insert_settings()` before returning.
340    /// let mut context = Context::from(&Path::new("/path/to/mynote.md")).unwrap();
341    ///
342    /// // When the note's YAML header does not contain a `scheme:` field,
343    /// // the `default` scheme is used.
344    /// assert_eq!(&context.get(TMPL_VAR_SCHEME_SYNC_DEFAULT).unwrap().to_string(),
345    ///     "default");
346    /// ```
347    fn insert_config_vars(&mut self) {
348        let lib_cfg = LIB_CFG.read_recursive();
349
350        // Default extension for new notes as defined in the configuration file.
351        self.ct.insert(
352            TMPL_VAR_SCHEME_SYNC_DEFAULT,
353            lib_cfg.scheme_sync_default.as_str(),
354        );
355    }
356
357    /// Captures Tp-Note's environment and stores it as variables in a
358    /// `context` collection. The variables are needed later to populate
359    /// a context template and a filename template.
360    ///
361    /// This function adds the keys:
362    ///
363    /// * `TMPL_VAR_EXTENSION_DEFAULT`
364    /// * `TMPL_VAR_USERNAME`
365    /// * `TMPL_VAR_LANG`
366    /// * `TMPL_VAR_CURRENT_SCHEME`
367    ///
368    /// ```
369    /// use std::path::Path;
370    /// use tpnote_lib::config::TMPL_VAR_EXTENSION_DEFAULT;
371    /// use tpnote_lib::config::TMPL_VAR_CURRENT_SCHEME;
372    /// use tpnote_lib::settings::set_test_default_settings;
373    /// use tpnote_lib::context::Context;
374    /// set_test_default_settings().unwrap();
375    ///
376    /// // The constructor calls `context.insert_settings()` before returning.
377    /// let mut context = Context::from(&Path::new("/path/to/mynote.md")).unwrap();
378    ///
379    /// // For most platforms `context.get("extension_default")` is `md`
380    /// assert_eq!(&context.get(TMPL_VAR_EXTENSION_DEFAULT).unwrap().to_string(),
381    ///     "md");
382    /// // `Settings.current_scheme` is by default the `default` scheme.
383    /// assert_eq!(&context.get(TMPL_VAR_CURRENT_SCHEME).unwrap().to_string(),
384    ///     "default");
385    /// ```
386    fn insert_settings(&mut self) {
387        let settings = SETTINGS.read_recursive();
388
389        // Default extension for new notes as defined in the configuration file.
390        self.ct.insert(
391            TMPL_VAR_EXTENSION_DEFAULT,
392            settings.extension_default.as_str(),
393        );
394
395        {
396            let lib_cfg = LIB_CFG.read_recursive();
397            self.ct.insert(
398                TMPL_VAR_CURRENT_SCHEME,
399                &lib_cfg.scheme[settings.current_scheme].name,
400            );
401        } // Release `lib_cfg` here.
402
403        // Search for UNIX, Windows, and MacOS user-names.
404        self.ct.insert(TMPL_VAR_USERNAME, &settings.author);
405
406        // Get the user's language tag.
407        self.ct.insert(TMPL_VAR_LANG, &settings.lang);
408
409        // Store `force_lang`.
410        self.ct.insert(TMPL_VAR_FORCE_LANG, &settings.force_lang);
411    }
412
413    /// Inserts the YAML front header variables into the context for later use
414    /// with templates.
415    ///
416    fn insert_front_matter2(&mut self, fm: &FrontMatter) {
417        let mut fm_all_map: serde_json::Map<String, serde_json::Value> = self
418            .ct
419            .remove(TMPL_VAR_FM_ALL)
420            .and_then(|v| {
421                serde_json::to_value(&v).ok().and_then(|sj| match sj {
422                    serde_json::Value::Object(m) => Some(m),
423                    _ => None,
424                })
425            })
426            .unwrap_or_default();
427
428        // Collect all localized scheme field names.
429        // Example: `["scheme", "scheme", "Schema"]`
430        let localized_scheme_names: Vec<String> = LIB_CFG
431            .read_recursive()
432            .scheme
433            .iter()
434            .map(|s| {
435                s.tmpl
436                    .fm_var
437                    .localization
438                    .iter()
439                    .find_map(|(k, v)| (k == TMPL_VAR_FM_SCHEME).then_some(v.to_owned()))
440            })
441            .collect::<Option<Vec<String>>>()
442            .unwrap_or_default();
443
444        // Search for localized scheme names in front matter.
445        // `(scheme_idx, field_value)`. Example: `(2, "Deutsch")`
446        let localized_scheme: Option<(usize, &str)> = localized_scheme_names
447            .iter()
448            .enumerate()
449            .find_map(|(i, k)| fm.0.get(k).and_then(|s| s.as_str()).map(|s| (i, s)));
450
451        let scheme = if let Some((scheme_idx, scheme_name)) = localized_scheme {
452            {
453                log::trace!(
454                    "Found `scheme: {}` with index=={} in front matter",
455                    scheme_name,
456                    scheme_idx,
457                );
458                scheme_idx
459            }
460        } else {
461            SETTINGS.read_recursive().current_scheme
462        };
463        let scheme = &LIB_CFG.read_recursive().scheme[scheme];
464
465        let vars = &scheme.tmpl.fm_var.localization;
466        for (key, value) in fm.iter() {
467            // This delocalizes the variable name and prepends `fm_` to its name.
468            // NB: We also insert `Value::Array` and `Value::Object`
469            // variants, No flattening occurs here.
470            let fm_key = vars.iter().find(|&l| &l.1 == key).map_or_else(
471                || {
472                    let mut s = TMPL_VAR_FM_.to_string();
473                    s.push_str(key);
474                    Cow::Owned(s)
475                },
476                |l| Cow::Borrowed(&l.0),
477            );
478
479            // Store a copy in `fm`.
480            fm_all_map.insert(fm_key.to_string(), value.clone());
481        }
482        // Register the collection as `Object(Map<String, Value>)`.
483        self.ct.insert(TMPL_VAR_FM_ALL, &fm_all_map);
484    }
485
486    /// Insert a key/val pair directly. Only available in tests.
487    #[cfg(test)]
488    pub(crate) fn insert(&mut self, key: &str, val: &impl serde::Serialize) {
489        self.ct.insert(key.to_owned(), val);
490    }
491
492    /// Inserts a `Content` in `Context`. The content appears as key in
493    /// `context.ct` with its name taken from `content.name()`.
494    /// Its value is a `tera::Map` with two keys `TMPL_VAR_HEADER` and
495    /// `TMPL_VAR_BODY`. The corresponding values are copied from
496    /// `conten.header()` and `content.body()`.
497    fn insert_raw_text_from_existing_content(&mut self, content: &impl Content) {
498        //
499        // Register input.
500        let mut map = serde_json::Map::new();
501        map.insert(
502            TMPL_VAR_HEADER.to_string(),
503            serde_json::Value::String(content.header().to_string()),
504        );
505        map.insert(
506            TMPL_VAR_BODY.to_string(),
507            serde_json::Value::String(content.body().to_string()),
508        );
509
510        self.ct
511            .insert(content.name().to_owned(), &serde_json::Value::Object(map));
512    }
513
514    /// See function of the same name in `impl Context<HasSettings>`.
515    fn insert_front_matter_and_raw_text_from_existing_content2(
516        &mut self,
517        clipboards: &Vec<&impl Content>,
518    ) -> Result<(), NoteError> {
519        //
520        for &clip in clipboards {
521            // Register input.
522            self.insert_raw_text_from_existing_content(clip);
523
524            // Can we find a front matter in the input stream? If yes, the
525            // unmodified input stream is our new note content.
526            if !clip.header().is_empty() {
527                let input_fm = FrontMatter::try_from(clip.header());
528                match input_fm {
529                    Ok(ref fm) => {
530                        log::trace!(
531                            "Input stream \"{}\" generates the front matter variables:\n{:#?}",
532                            clip.name(),
533                            &fm
534                        )
535                    }
536                    Err(ref e) => {
537                        log::warn!(
538                            "Invalid YAML header in \"{}\" input stream, \
539                            treating entire content as body:\n{}",
540                            clip.name(),
541                            e
542                        );
543                        // Re-register with empty header and full raw content as body.
544                        let mut map = serde_json::Map::new();
545                        map.insert(
546                            TMPL_VAR_HEADER.to_string(),
547                            serde_json::Value::String(String::new()),
548                        );
549                        map.insert(
550                            TMPL_VAR_BODY.to_string(),
551                            serde_json::Value::String(clip.as_str().to_string()),
552                        );
553                        self.ct
554                            .insert(clip.name().to_owned(), &serde_json::Value::Object(map));
555                    }
556                };
557
558                // Register front matter.
559                // The variables registered here can be overwrite the ones from the clipboard.
560                if let Ok(fm) = input_fm {
561                    self.insert_front_matter2(&fm);
562                }
563            }
564        }
565
566        // Tera v2 raises an error when a template accesses an undefined variable
567        // (e.g. `fm.fm_title`). Ensure `fm` is always present so templates using
568        // `fm.x | default(value='')` work even when no front matter was found.
569        if self.ct.get(TMPL_VAR_FM_ALL).is_none() {
570            let empty: serde_json::Map<String, serde_json::Value> = serde_json::Map::new();
571            self.ct.insert(TMPL_VAR_FM_ALL.to_owned(), &empty);
572        }
573
574        Ok(())
575    }
576}
577
578/// The start state of all `Context` objects.
579///
580impl Context<Invalid> {
581    /// Constructor: `path` is Tp-Notes first positional command line parameter
582    /// `<path>` (see man page). `path` must point to a directory or
583    /// a file.
584    ///
585    /// A copy of `path` is stored in `self.ct` as key `TMPL_VAR_PATH`. It
586    /// directory path as key `TMPL_VAR_DIR_PATH`. The root directory, where
587    /// the marker file `tpnote.toml` was found, is stored with the key
588    /// `TMPL_VAR_ROOT_PATH`. If `path` points to a file, its file creation
589    /// date is stored with the key `TMPL_VAR_DOC_FILE_DATE`.
590    ///
591    /// ```rust
592    /// use std::path::Path;
593    /// use tpnote_lib::settings::set_test_default_settings;
594    /// use tpnote_lib::config::TMPL_VAR_DIR_PATH;
595    /// use tpnote_lib::config::TMPL_VAR_PATH;
596    /// use tpnote_lib::context::Context;
597    /// set_test_default_settings().unwrap();
598    ///
599    /// let mut context = Context::from(&Path::new("/path/to/mynote.md")).unwrap();
600    ///
601    /// assert_eq!(context.get_path(), Path::new("/path/to/mynote.md"));
602    /// assert_eq!(context.get_dir_path(), Path::new("/path/to/"));
603    /// assert_eq!(&context.get(TMPL_VAR_PATH).unwrap().to_string(),
604    ///             "/path/to/mynote.md");
605    /// assert_eq!(&context.get(TMPL_VAR_DIR_PATH).unwrap().to_string(),
606    ///             "/path/to");
607    /// ```
608    pub fn from(path: &Path) -> Result<Context<HasSettings>, FileError> {
609        let path = path.to_path_buf();
610
611        // `dir_path` is a directory as fully qualified path, ending
612        // by a separator.
613        let dir_path = if path.is_dir() {
614            path.clone()
615        } else {
616            path.parent()
617                .unwrap_or_else(|| Path::new("./"))
618                .to_path_buf()
619        };
620
621        // Get the root directory.
622        let mut root_path = Path::new("");
623
624        for anc in dir_path.ancestors() {
625            root_path = anc;
626            let mut p = anc.to_owned();
627            p.push(Path::new(FILENAME_ROOT_PATH_MARKER));
628            if p.is_file() {
629                break;
630            }
631        }
632        let root_path = root_path.to_owned();
633        debug_assert!(dir_path.starts_with(&root_path));
634
635        // Get the file's creation date. Fail silently.
636        let file_creation_date = if let Ok(file) = File::open(&path) {
637            let metadata = file.metadata()?;
638            metadata.created().or_else(|_| metadata.modified()).ok()
639        } else {
640            None
641        };
642
643        // Insert environment.
644        let mut context = Context {
645            ct: tera::Context::new(),
646            path,
647            dir_path,
648            root_path,
649            doc_file_date: file_creation_date,
650            _marker: PhantomData,
651        };
652
653        context.sync_paths_to_map();
654        context.insert_config_vars();
655        context.insert_settings();
656        Ok(context)
657    }
658}
659
660impl Context<HasSettings> {
661    /// Merges `fm` into `self.ct`.
662    pub(crate) fn insert_front_matter(
663        mut self,
664        fm: &FrontMatter,
665    ) -> Context<ReadyForFilenameTemplate> {
666        Context::insert_front_matter2(&mut self, fm);
667        Context {
668            ct: self.ct,
669            path: self.path,
670            dir_path: self.dir_path,
671            root_path: self.root_path,
672            doc_file_date: self.doc_file_date,
673            _marker: PhantomData,
674        }
675    }
676
677    /// Inserts clipboard data, standard input data and/or existing note file
678    /// content into the context. The data may contain some copied text with
679    /// or without a YAML header. The latter usually carries front matter
680    /// variables. The `input` data below is registered with the key name given
681    /// by `tmpl_var_body_name`. Typical names are `"clipboard"` or `"stdin"`.
682    /// If the below `input` contains a valid YAML header, it will be registered
683    /// in the context with the key name given by `tmpl_var_header_name`. The
684    /// templates expect the key names `clipboard_header` or `std_header`. The
685    /// raw header text will be inserted with this key name.
686    ///
687    pub(crate) fn insert_front_matter_and_raw_text_from_existing_content(
688        mut self,
689        clipboards: &Vec<&impl Content>,
690    ) -> Result<Context<HasExistingContent>, NoteError> {
691        //
692        self.insert_front_matter_and_raw_text_from_existing_content2(clipboards)?;
693
694        Ok(Context {
695            ct: self.ct,
696            path: self.path,
697            dir_path: self.dir_path,
698            root_path: self.root_path,
699            doc_file_date: self.doc_file_date,
700            _marker: PhantomData,
701        })
702    }
703
704    /// This adds the following variables to `self`:
705    ///
706    /// * `TMPL_HTML_VAR_VIEWER_DOC_JS` from `viewer_doc_js`
707    /// * `TMPL_HTML_VAR_DOC_ERROR` from `error_message`
708    /// * `TMPL_HTML_VAR_DOC_TEXT` from `note_erroneous_content`
709    ///
710    #[cfg(feature = "viewer")]
711    pub(crate) fn insert_error_content(
712        mut self,
713        note_erroneous_content: &impl Content,
714        error_message: &str,
715        // Java Script live updater inject code. Will be inserted into
716        // `tmpl_html.viewer`.
717        viewer_doc_js: &str,
718    ) -> Context<ReadyForHtmlErrorTemplate> {
719        //
720        self.ct.insert(TMPL_HTML_VAR_VIEWER_DOC_JS, viewer_doc_js);
721
722        self.ct.insert(TMPL_HTML_VAR_DOC_ERROR, error_message);
723        self.ct
724            .insert(TMPL_HTML_VAR_DOC_TEXT, &note_erroneous_content.as_str());
725
726        Context {
727            ct: self.ct,
728            path: self.path,
729            dir_path: self.dir_path,
730            root_path: self.root_path,
731            doc_file_date: self.doc_file_date,
732            _marker: PhantomData,
733        }
734    }
735}
736
737impl Context<HasExistingContent> {
738    /// See same method in `Context<HasSettings>`.
739    pub(crate) fn insert_front_matter_and_raw_text_from_existing_content(
740        mut self,
741        clipboards: &Vec<&impl Content>,
742    ) -> Result<Context<HasExistingContent>, NoteError> {
743        //
744        self.insert_front_matter_and_raw_text_from_existing_content2(clipboards)?;
745
746        Ok(Context {
747            ct: self.ct,
748            path: self.path,
749            dir_path: self.dir_path,
750            root_path: self.root_path,
751            doc_file_date: self.doc_file_date,
752            _marker: PhantomData,
753        })
754    }
755
756    /// Mark this as ready for a content template.
757    pub(crate) fn set_state_ready_for_content_template(self) -> Context<ReadyForContentTemplate> {
758        Context {
759            ct: self.ct,
760            path: self.path,
761            dir_path: self.dir_path,
762            root_path: self.root_path,
763            doc_file_date: self.doc_file_date,
764            _marker: PhantomData,
765        }
766    }
767}
768
769impl Context<ReadyForFilenameTemplate> {
770    /// Checks if the front matter variables satisfy preconditions.
771    /// `self.path` is the path to the current document.
772    #[inline]
773    pub(crate) fn assert_precoditions(&self) -> Result<(), NoteError> {
774        let path = &self.path;
775        let lib_cfg = &LIB_CFG.read_recursive();
776
777        // Get front matter scheme if there is any.
778        let fm_all = self.get(TMPL_VAR_FM_ALL);
779        if fm_all.is_none() {
780            return Ok(());
781        }
782        let fm_all = fm_all.unwrap();
783        let fm_scheme = fm_all
784            .get_from_path(TMPL_VAR_FM_SCHEME)
785            .and_then(|v| v.as_str());
786        let scheme_idx = fm_scheme.and_then(|scheme_name| {
787            lib_cfg
788                .scheme
789                .iter()
790                .enumerate()
791                .find_map(|(i, s)| (s.name == scheme_name).then_some(i))
792        });
793        // If not use `current_scheme` from `SETTINGS`
794        let scheme_idx = scheme_idx.unwrap_or_else(|| SETTINGS.read_recursive().current_scheme);
795        let scheme = &lib_cfg.scheme[scheme_idx];
796
797        for (key, conditions) in scheme.tmpl.fm_var.assertions.iter() {
798            if let Some(value) = fm_all.get_from_path(key) {
799                for cond in conditions {
800                    match cond {
801                        Assertion::IsDefined => {}
802
803                        Assertion::IsString => {
804                            if !tera_all_leaves(value, &|v| v.is_string()) {
805                                return Err(NoteError::FrontMatterFieldIsNotString {
806                                    field_name: name(scheme, key).to_string(),
807                                });
808                            }
809                        }
810
811                        Assertion::IsNotEmptyString => {
812                            if !tera_all_leaves(value, &|v| v.is_string() && v.as_str() != Some(""))
813                            {
814                                return Err(NoteError::FrontMatterFieldIsEmptyString {
815                                    field_name: name(scheme, key).to_string(),
816                                });
817                            }
818                        }
819
820                        Assertion::IsNumber => {
821                            if !tera_all_leaves(value, &|v| v.is_number()) {
822                                return Err(NoteError::FrontMatterFieldIsNotNumber {
823                                    field_name: name(scheme, key).to_string(),
824                                });
825                            }
826                        }
827
828                        Assertion::IsBool => {
829                            if !tera_all_leaves(value, &|v| v.is_bool()) {
830                                return Err(NoteError::FrontMatterFieldIsNotBool {
831                                    field_name: name(scheme, key).to_string(),
832                                });
833                            }
834                        }
835
836                        Assertion::IsNotCompound => {
837                            if value.is_array() || value.is_map() {
838                                return Err(NoteError::FrontMatterFieldIsCompound {
839                                    field_name: name(scheme, key).to_string(),
840                                });
841                            }
842                        }
843
844                        Assertion::IsValidSortTag => {
845                            let fm_sort_tag = value.as_str().unwrap_or_default();
846                            if !fm_sort_tag.is_empty() {
847                                // Check for forbidden characters.
848                                let (_, rest, is_sequential) = fm_sort_tag.split_sort_tag(true);
849                                if !rest.is_empty() {
850                                    return Err(NoteError::FrontMatterFieldIsInvalidSortTag {
851                                        sort_tag: fm_sort_tag.to_owned(),
852                                        sort_tag_extra_chars: scheme
853                                            .filename
854                                            .sort_tag
855                                            .extra_chars
856                                            .escape_default()
857                                            .to_string(),
858                                        filename_sort_tag_letters_in_succession_max: scheme
859                                            .filename
860                                            .sort_tag
861                                            .letters_in_succession_max,
862                                    });
863                                }
864
865                                // Check for duplicate sequential sort-tags.
866                                if !is_sequential {
867                                    // No further checks.
868                                    return Ok(());
869                                }
870                                let docpath = path.to_str().unwrap_or_default();
871
872                                let (dirpath, filename) =
873                                    docpath.rsplit_once(['/', '\\']).unwrap_or(("", docpath));
874                                let sort_tag = filename.split_sort_tag(false).0;
875                                // No further check if filename(path) has no sort-tag
876                                // or if sort-tags are identical.
877                                if sort_tag.is_empty() || sort_tag == fm_sort_tag {
878                                    return Ok(());
879                                }
880                                let dirpath = Path::new(dirpath);
881
882                                if let Some(other_file) =
883                                    dirpath.has_file_with_sort_tag(fm_sort_tag)
884                                {
885                                    return Err(NoteError::FrontMatterFieldIsDuplicateSortTag {
886                                        sort_tag: fm_sort_tag.to_string(),
887                                        existing_file: other_file,
888                                    });
889                                }
890                            }
891                        }
892
893                        Assertion::IsTpnoteExtension => {
894                            let file_ext = value.as_str().unwrap_or_default();
895
896                            if !file_ext.is_empty() && !(*file_ext).is_tpnote_ext() {
897                                return Err(NoteError::FrontMatterFieldIsNotTpnoteExtension {
898                                    extension: file_ext.to_string(),
899                                    extensions: {
900                                        use std::fmt::Write;
901                                        let mut errstr = scheme.filename.extensions.iter().fold(
902                                            String::new(),
903                                            |mut output, (k, _v1, _v2)| {
904                                                let _ = write!(output, "{k}, ");
905                                                output
906                                            },
907                                        );
908                                        errstr.truncate(errstr.len().saturating_sub(2));
909                                        errstr
910                                    },
911                                });
912                            }
913                        }
914
915                        Assertion::IsConfiguredScheme => {
916                            let fm_scheme = value.as_str().unwrap_or_default();
917                            match lib_cfg.scheme_idx(fm_scheme) {
918                                Ok(_) => {}
919                                Err(LibCfgError::SchemeNotFound {
920                                    scheme_name,
921                                    schemes,
922                                }) => {
923                                    return Err(NoteError::SchemeNotFound {
924                                        scheme_val: scheme_name,
925                                        scheme_key: key.to_string(),
926                                        schemes,
927                                    });
928                                }
929                                Err(e) => return Err(e.into()),
930                            };
931                        }
932
933                        Assertion::NoOperation => {}
934                    } //
935                }
936                //
937            } else if conditions.contains(&Assertion::IsDefined) {
938                return Err(NoteError::FrontMatterFieldMissing {
939                    field_name: name(scheme, key).to_string(),
940                });
941            }
942        }
943        Ok(())
944    }
945
946    /// Indicates that this context contains all we need for the content
947    /// template.
948    #[cfg(test)]
949    pub(crate) fn set_state_ready_for_content_template(self) -> Context<ReadyForContentTemplate> {
950        //
951        Context {
952            ct: self.ct,
953            path: self.path,
954            dir_path: self.dir_path,
955            root_path: self.root_path,
956            doc_file_date: self.doc_file_date,
957            _marker: PhantomData,
958        }
959    }
960
961    /// Inserts the following variables into `self`:
962    ///
963    /// * `TMPL_HTML_VAR_VIEWER_DOC_JS` from `viewer_doc_js`
964    /// * `TMPL_VAR_DOC` from `content.header()` and `content.body()`
965    /// * `TMPL_HTML_VAR_EXPORTER_DOC_CSS`
966    /// * `TMPL_HTML_VAR_EXPORTER_HIGHLIGHTING_CSS`
967    /// * `TMPL_HTML_VAR_EXPORTER_HIGHLIGHTING_CSS`
968    /// * `TMPL_HTML_VAR_VIEWER_DOC_CSS_PATH`
969    /// * `TMPL_HTML_VAR_VIEWER_DOC_CSS_PATH_VALUE`
970    /// * `TMPL_HTML_VAR_VIEWER_HIGHLIGHTING_CSS_PATH`
971    /// * `TMPL_HTML_VAR_VIEWER_HIGHLIGHTING_CSS_PATH_VALUE`
972    ///
973    pub(crate) fn insert_raw_content_and_css(
974        mut self,
975        content: &impl Content,
976        viewer_doc_js: &str,
977    ) -> Context<ReadyForHtmlTemplate> {
978        //
979        self.ct.insert(TMPL_HTML_VAR_VIEWER_DOC_JS, viewer_doc_js);
980
981        self.insert_raw_text_from_existing_content(content);
982
983        {
984            let lib_cfg = &LIB_CFG.read_recursive();
985
986            // Insert the raw CSS
987            self.ct.insert(
988                TMPL_HTML_VAR_EXPORTER_DOC_CSS,
989                &(lib_cfg.tmpl_html.exporter_doc_css),
990            );
991
992            // Insert the raw CSS
993            #[cfg(feature = "renderer")]
994            self.ct.insert(
995                TMPL_HTML_VAR_EXPORTER_HIGHLIGHTING_CSS,
996                &(lib_cfg.tmpl_html.exporter_highlighting_css),
997            );
998        } // Drop `lib_cfg`.
999
1000        // Insert the raw CSS
1001        #[cfg(not(feature = "renderer"))]
1002        self.ct.insert(TMPL_HTML_VAR_EXPORTER_HIGHLIGHTING_CSS, "");
1003
1004        // Insert the web server path to get the Tp-Note's CSS loaded.
1005        self.ct.insert(
1006            TMPL_HTML_VAR_VIEWER_DOC_CSS_PATH,
1007            TMPL_HTML_VAR_VIEWER_DOC_CSS_PATH_VALUE,
1008        );
1009
1010        // Insert the web server path to get the highlighting CSS loaded.
1011        self.ct.insert(
1012            TMPL_HTML_VAR_VIEWER_HIGHLIGHTING_CSS_PATH,
1013            TMPL_HTML_VAR_VIEWER_HIGHLIGHTING_CSS_PATH_VALUE,
1014        );
1015        Context {
1016            ct: self.ct,
1017            path: self.path,
1018            dir_path: self.dir_path,
1019            root_path: self.root_path,
1020            doc_file_date: self.doc_file_date,
1021            _marker: PhantomData,
1022        }
1023    }
1024}
1025
1026/// Auto dereferences for convenient access to `tera::Context`.
1027impl<S: ContextState> Deref for Context<S> {
1028    type Target = tera::Context;
1029
1030    fn deref(&self) -> &Self::Target {
1031        &self.ct
1032    }
1033}
1034
1035#[cfg(test)]
1036mod tests {
1037
1038    use crate::{config::TMPL_VAR_FM_ALL, error::NoteError};
1039
1040    use std::path::Path;
1041
1042    #[test]
1043    fn test_insert_front_matter() {
1044        use crate::context::Context;
1045        use crate::front_matter::FrontMatter;
1046        use std::path::Path;
1047        let context = Context::from(Path::new("/path/to/mynote.md")).unwrap();
1048        let context = context
1049            .insert_front_matter(&FrontMatter::try_from("title: My Stdin.\nsome: text").unwrap());
1050
1051        assert_eq!(
1052            &context
1053                .get(TMPL_VAR_FM_ALL)
1054                .unwrap()
1055                .get_from_path("fm_title")
1056                .unwrap()
1057                .to_string(),
1058            "My Stdin."
1059        );
1060        assert_eq!(
1061            &context
1062                .get(TMPL_VAR_FM_ALL)
1063                .unwrap()
1064                .get_from_path("fm_some")
1065                .unwrap()
1066                .to_string(),
1067            "text"
1068        );
1069    }
1070
1071    #[test]
1072    fn test_insert_front_matter2() {
1073        use crate::context::Context;
1074        use crate::front_matter::FrontMatter;
1075        use std::path::Path;
1076        let context = Context::from(Path::new("/path/to/mynote.md")).unwrap();
1077        let context = context
1078            .insert_front_matter(&FrontMatter::try_from("title: My Stdin.\nsome: text").unwrap());
1079        let context = context.set_state_ready_for_content_template();
1080
1081        assert_eq!(
1082            &context
1083                .get(TMPL_VAR_FM_ALL)
1084                .unwrap()
1085                .get_from_path("fm_title")
1086                .unwrap()
1087                .to_string(),
1088            "My Stdin."
1089        );
1090        assert_eq!(
1091            &context
1092                .get(TMPL_VAR_FM_ALL)
1093                .unwrap()
1094                .get_from_path("fm_some")
1095                .unwrap()
1096                .to_string(),
1097            "text"
1098        );
1099    }
1100
1101    #[test]
1102    fn test_insert_front_matter_and_raw_text_from_existing_content() {
1103        use crate::content::Content;
1104        use crate::content::ContentString;
1105        use crate::context::Context;
1106        use crate::settings::set_test_default_settings;
1107        use std::path::Path;
1108        set_test_default_settings().unwrap();
1109        let context = Context::from(Path::new("/path/to/mynote.md")).unwrap();
1110        let c1 = ContentString::from_string(
1111            String::from("Data from clipboard."),
1112            "txt_clipboard".to_string(),
1113        );
1114        let c2 = ContentString::from_string(
1115            "---\ntitle: My Stdin.\n---\nbody".to_string(),
1116            "stdin".to_string(),
1117        );
1118        let c = vec![&c1, &c2];
1119        let context = context
1120            .insert_front_matter_and_raw_text_from_existing_content(&c)
1121            .unwrap();
1122        assert_eq!(
1123            &context
1124                .get("txt_clipboard")
1125                .unwrap()
1126                .get_from_path("body")
1127                .unwrap()
1128                .to_string(),
1129            "Data from clipboard."
1130        );
1131        assert_eq!(
1132            &context
1133                .get("stdin")
1134                .unwrap()
1135                .get_from_path("body")
1136                .unwrap()
1137                .to_string(),
1138            "body"
1139        );
1140        assert_eq!(
1141            &context
1142                .get("stdin")
1143                .unwrap()
1144                .get_from_path("header")
1145                .unwrap()
1146                .to_string(),
1147            "title: My Stdin."
1148        );
1149        // "fm_title" is dynamically generated from the header variable "title".
1150        assert_eq!(
1151            &context
1152                .get("fm")
1153                .unwrap()
1154                .get_from_path("fm_title")
1155                .unwrap()
1156                .to_string(),
1157            "My Stdin."
1158        );
1159    }
1160
1161    #[test]
1162    fn test_assert_preconditions() {
1163        // Check `tmpl.filter.assert_preconditions` in
1164        // `tpnote_lib/src/config_default.toml` to understand these tests.
1165        use crate::context::Context;
1166        use crate::front_matter::FrontMatter;
1167        use serde_json::json;
1168        //
1169        // Is empty.
1170        let input = "";
1171        let fm = FrontMatter::try_from(input).unwrap();
1172        let cx = Context::from(Path::new("does not matter")).unwrap();
1173        let cx = cx.insert_front_matter(&fm);
1174
1175        assert!(matches!(
1176            cx.assert_precoditions().unwrap_err(),
1177            NoteError::FrontMatterFieldMissing { .. }
1178        ));
1179
1180        //
1181        // Ok as long as no other file with that sort-tag exists.
1182        let input = "# document start
1183        title: The book
1184        sort_tag:    123b";
1185        let fm = FrontMatter::try_from(input).unwrap();
1186        let cx = Context::from(Path::new("./03b-test.md")).unwrap();
1187        let cx = cx.insert_front_matter(&fm);
1188
1189        assert!(matches!(cx.assert_precoditions(), Ok(())));
1190
1191        //
1192        // Should not be a compound type.
1193        let input = "# document start
1194        title: The book
1195        sort_tag:
1196        -    1234
1197        -    456";
1198        let fm = FrontMatter::try_from(input).unwrap();
1199        let cx = Context::from(Path::new("does not matter")).unwrap();
1200        let cx = cx.insert_front_matter(&fm);
1201
1202        assert!(matches!(
1203            cx.assert_precoditions().unwrap_err(),
1204            NoteError::FrontMatterFieldIsCompound { .. }
1205        ));
1206
1207        //
1208        // Should not be a compound type.
1209        let input = "# document start
1210        title: The book
1211        sort_tag:
1212          first:  1234
1213          second: 456";
1214        let fm = FrontMatter::try_from(input).unwrap();
1215        let cx = Context::from(Path::new("does not matter")).unwrap();
1216        let cx = cx.insert_front_matter(&fm);
1217
1218        assert!(matches!(
1219            cx.assert_precoditions().unwrap_err(),
1220            NoteError::FrontMatterFieldIsCompound { .. }
1221        ));
1222
1223        //
1224        // Not registered file extension.
1225        let input = "# document start
1226        title: The book
1227        file_ext:    xyz";
1228        let fm = FrontMatter::try_from(input).unwrap();
1229        let cx = Context::from(Path::new("does not matter")).unwrap();
1230        let cx = cx.insert_front_matter(&fm);
1231
1232        assert!(matches!(
1233            cx.assert_precoditions().unwrap_err(),
1234            NoteError::FrontMatterFieldIsNotTpnoteExtension { .. }
1235        ));
1236
1237        //
1238        // Check `bool`
1239        let input = "# document start
1240        title: The book
1241        filename_sync: error, here should be a bool";
1242        let fm = FrontMatter::try_from(input).unwrap();
1243        let cx = Context::from(Path::new("does not matter")).unwrap();
1244        let cx = cx.insert_front_matter(&fm);
1245
1246        assert!(matches!(
1247            cx.assert_precoditions().unwrap_err(),
1248            NoteError::FrontMatterFieldIsNotBool { .. }
1249        ));
1250
1251        //
1252        let input = "# document start
1253        title: my title
1254        subtitle: my subtitle
1255        ";
1256        let expected = json!({"fm_title": "my title", "fm_subtitle": "my subtitle"});
1257
1258        let fm = FrontMatter::try_from(input).unwrap();
1259        let cx = Context::from(Path::new("does not matter")).unwrap();
1260        let cx = cx.insert_front_matter(&fm);
1261        let fm_all_sj = serde_json::to_value(cx.get(TMPL_VAR_FM_ALL).unwrap()).unwrap();
1262        assert_eq!(fm_all_sj, expected);
1263
1264        //
1265        let input = "# document start
1266        title: my title
1267        file_ext: ''
1268        ";
1269        let expected = json!({"fm_title": "my title", "fm_file_ext": ""});
1270
1271        let fm = FrontMatter::try_from(input).unwrap();
1272        let cx = Context::from(Path::new("does not matter")).unwrap();
1273        let cx = cx.insert_front_matter(&fm);
1274        let fm_all_sj = serde_json::to_value(cx.get(TMPL_VAR_FM_ALL).unwrap()).unwrap();
1275        assert_eq!(fm_all_sj, expected);
1276
1277        //
1278        let input = "# document start
1279        title: ''
1280        subtitle: my subtitle
1281        ";
1282        let fm = FrontMatter::try_from(input).unwrap();
1283        let cx = Context::from(Path::new("does not matter")).unwrap();
1284        let cx = cx.insert_front_matter(&fm);
1285
1286        assert!(matches!(
1287            cx.assert_precoditions().unwrap_err(),
1288            NoteError::FrontMatterFieldIsEmptyString { .. }
1289        ));
1290
1291        //
1292        let input = "# document start
1293        title: My doc
1294        author:
1295        - First author
1296        - Second author
1297        ";
1298        let fm = FrontMatter::try_from(input).unwrap();
1299        let cx = Context::from(Path::new("does not matter")).unwrap();
1300        let cx = cx.insert_front_matter(&fm);
1301
1302        assert!(cx.assert_precoditions().is_ok());
1303
1304        //
1305        let input = "# document start
1306        title: My doc
1307        subtitle: my subtitle
1308        author:
1309        - First title
1310        - 1234
1311        ";
1312        let fm = FrontMatter::try_from(input).unwrap();
1313        let cx = Context::from(Path::new("does not matter")).unwrap();
1314        let cx = cx.insert_front_matter(&fm);
1315
1316        assert!(matches!(
1317            cx.assert_precoditions().unwrap_err(),
1318            NoteError::FrontMatterFieldIsNotString { .. }
1319        ));
1320    }
1321
1322    /// When an input stream has syntactically invalid YAML between `---`
1323    /// delimiters, `insert_front_matter_and_raw_text_from_existing_content`
1324    /// must succeed and expose the entire raw content as body with an empty
1325    /// header, instead of returning `InvalidInputYaml`.
1326    #[test]
1327    fn test_invalid_yaml_header_treated_as_body() {
1328        use crate::content::Content;
1329        use crate::content::ContentString;
1330        use crate::context::Context;
1331        use crate::settings::set_test_default_settings;
1332        use std::path::Path;
1333        set_test_default_settings().unwrap();
1334
1335        let raw = "---\n<invalid\n---\nhallo";
1336        let c = ContentString::from_string(raw.to_string(), "stdin".to_string());
1337        // Sanity check: the split logic does recognise a header here.
1338        assert!(!c.header().is_empty());
1339
1340        let context = Context::from(Path::new("/path/to/mynote.md")).unwrap();
1341        let result = context
1342            .insert_front_matter_and_raw_text_from_existing_content(&vec![&c]);
1343
1344        assert!(result.is_ok(), "expected Ok, got: {:?}", result.err());
1345
1346        let context = result.unwrap();
1347        // Header must be empty — the invalid YAML was discarded.
1348        assert_eq!(
1349            context
1350                .get("stdin")
1351                .unwrap()
1352                .get_from_path("header")
1353                .unwrap()
1354                .as_str()
1355                .unwrap(),
1356            ""
1357        );
1358        // Body must be the full raw input.
1359        assert_eq!(
1360            context
1361                .get("stdin")
1362                .unwrap()
1363                .get_from_path("body")
1364                .unwrap()
1365                .as_str()
1366                .unwrap(),
1367            raw
1368        );
1369    }
1370}