Skip to main content

tpnote_lib/
context.rs

1//! Extends the built-in Tera filters.
2use crate::config::Assertion;
3use crate::config::LIB_CFG;
4#[cfg(feature = "viewer")]
5use crate::config::TMPL_HTML_VAR_DOC_ERROR;
6#[cfg(feature = "viewer")]
7use crate::config::TMPL_HTML_VAR_DOC_TEXT;
8use crate::config::TMPL_HTML_VAR_EXPORTER_DOC_CSS;
9use crate::config::TMPL_HTML_VAR_EXPORTER_HIGHLIGHTING_CSS;
10use crate::config::TMPL_HTML_VAR_VIEWER_DOC_CSS_PATH;
11use crate::config::TMPL_HTML_VAR_VIEWER_DOC_CSS_PATH_VALUE;
12use crate::config::TMPL_HTML_VAR_VIEWER_DOC_JS;
13use crate::config::TMPL_HTML_VAR_VIEWER_HIGHLIGHTING_CSS_PATH;
14use crate::config::TMPL_HTML_VAR_VIEWER_HIGHLIGHTING_CSS_PATH_VALUE;
15use crate::config::TMPL_VAR_BODY;
16use crate::config::TMPL_VAR_CURRENT_SCHEME;
17use crate::config::TMPL_VAR_DIR_PATH;
18use crate::config::TMPL_VAR_DOC_FILE_DATE;
19use crate::config::TMPL_VAR_EXTENSION_DEFAULT;
20use crate::config::TMPL_VAR_FM_;
21use crate::config::TMPL_VAR_FM_ALL;
22use crate::config::TMPL_VAR_FM_SCHEME;
23use crate::config::TMPL_VAR_FORCE_LANG;
24use crate::config::TMPL_VAR_HEADER;
25use crate::config::TMPL_VAR_LANG;
26use crate::config::TMPL_VAR_PATH;
27use crate::config::TMPL_VAR_ROOT_PATH;
28use crate::config::TMPL_VAR_SCHEME_SYNC_DEFAULT;
29use crate::config::TMPL_VAR_USERNAME;
30use crate::content::Content;
31use crate::error::FileError;
32use crate::error::LibCfgError;
33use crate::error::NoteError;
34use crate::filename::Extension;
35use crate::filename::NotePath;
36use crate::filename::NotePathStr;
37use crate::filter::name;
38use crate::front_matter::FrontMatter;
39use crate::settings::SETTINGS;
40use std::borrow::Cow;
41use std::fs::File;
42use std::marker::PhantomData;
43use std::ops::Deref;
44use std::path::Path;
45use std::path::PathBuf;
46use std::time::SystemTime;
47
48/// At trait setting up a state machine as described below.
49/// Its implementors represent one specific state defining the amount and the
50/// type of data the `Context` type holds at that moment.
51pub trait ContextState {}
52
53#[derive(Debug, PartialEq, Clone)]
54/// See description in the `ContextState` implementor list.
55pub struct Invalid;
56
57#[derive(Debug, PartialEq, Clone)]
58/// See description in the `ContextState` implementor list.
59pub struct HasSettings;
60
61#[derive(Debug, PartialEq, Clone)]
62/// See description in the `ContextState` implementor list.
63pub(crate) struct ReadyForFilenameTemplate;
64
65#[derive(Debug, PartialEq, Clone)]
66/// See description in the `ContextState` implementor list.
67pub(crate) struct HasExistingContent;
68
69#[derive(Debug, PartialEq, Clone)]
70/// See description in the `ContextState` implementor list.
71pub(crate) struct ReadyForContentTemplate;
72
73#[derive(Debug, PartialEq, Clone)]
74/// See description in the `ContextState` implementor list.
75pub(crate) struct ReadyForHtmlTemplate;
76
77#[cfg(feature = "viewer")]
78#[derive(Debug, PartialEq, Clone)]
79/// See description in the `ContextState` implementor list.
80pub(crate) struct ReadyForHtmlErrorTemplate;
81
82/// The `Context` object is in an invalid state. Either it was not initialized
83/// or its data does not correspond any more to the `Content` it represents.
84///
85/// |  State order   |                                       |
86/// |----------------|---------------------------------------|
87/// | Previous state | none                                  |
88/// | Current state  | `Invalid`                             |
89/// | Next state     | `HasSettings`                         |
90///
91impl ContextState for Invalid {}
92
93/// The `Context` has the following initialized and valid fields: `path`,
94/// `dir_path`, `root_path` and `ct`. The context `ct` contains data from
95/// `insert_config_vars()` and `insert_settings()`.
96/// `Context<HasSettings>` has the following variables set:
97///
98/// * `TMPL_VAR_CURRENT_SCHEME`
99/// * `TMPL_VAR_DIR_PATH` in sync with `self.dir_path` and
100/// * `TMPL_VAR_DOC_FILE_DATE` in sync with `self.doc_file_date` (only if
101///   available).
102/// * `TMPL_VAR_EXTENSION_DEFAULT`
103/// * `TMPL_VAR_LANG`
104/// * `TMPL_VAR_PATH` in sync with `self.path`,
105/// * `TMPL_VAR_ROOT_PATH` in sync with `self.root_path`.
106/// * `TMPL_VAR_SCHEME_SYNC_DEFAULT`.
107/// * `TMPL_VAR_USERNAME`
108///
109/// The variables are inserted by the following methods: `self.from()`,
110/// `self.insert_config_vars()` and `self.insert_settings()`.
111/// Once this state is achieved, `Context` is constant and write protected until
112/// the next state transition.
113///
114/// |  State order   |                                       |
115/// |----------------|---------------------------------------|
116/// | Previous state | `Invalid`                             |
117/// | Current state  | `HasSettings`                         |
118/// | Next state     | `ReadyForFilenameTemplate` or `HasExistingContent` |
119///
120impl ContextState for HasSettings {}
121
122/// In addition to `HasSettings`, the `context.ct` contains template variables
123/// deserialized from some note's front matter. E.g. a field named `title:`
124/// appears in the context as `fm.fm_title` template variable.
125/// In `Note` objects the `Content` is always associated with a
126/// `Context<ReadyForFilenameTemplate>`.
127/// Once this state is achieved, `Context` is constant and write protected until
128/// the next state transition.
129///
130/// |  State order   |                                       |
131/// |----------------|---------------------------------------|
132/// | Previous state | `HasSettings`                         |
133/// | Current state  | `ReadyForFilenameTemplate `           |
134/// | Next state     | none or `ReadyForHtmlTemplate`        |
135///
136impl ContextState for ReadyForFilenameTemplate {}
137
138/// In addition to the `HasSettings` the YAML headers of all clipboard
139/// `Content` objects are registered as front matter variables `fm.fm*` in the
140/// `Context`.
141/// This stage is also used for the `TemplateKind::FromTextFile` template.
142/// In this case the last inserted `Content` comes from the text file
143/// the command line parameter `<path>` points to. This adds the following key:
144///
145/// * `TMPL_VAR_DOC`
146///
147/// This state can evolve as the
148/// `insert_front_matter_and_raw_text_from_existing_content()` function can be
149/// called several times.
150///
151/// |  State order   |                                       |
152/// |----------------|---------------------------------------|
153/// | Previous state | `HasSettings` or `HasExistingContent` |
154/// | Current state  | `HasExistingContent`                  |
155/// | Next state     | `ReadyForContentTemplate`             |
156///
157impl ContextState for HasExistingContent {}
158
159/// This marker state means that enough information have been collected
160/// in the `HasExistingContent` state to be passed to a
161/// content template renderer.
162/// Once this state is achieved, `Context` is constant and write protected until
163/// the next state transition.
164///
165/// |  State order   |                                       |
166/// |----------------|---------------------------------------|
167/// | Previous state | `HasExistingContent`                  |
168/// | Current state  | `ReadyForContentTemplate`             |
169/// | Next state     | none                                  |
170///
171impl ContextState for ReadyForContentTemplate {}
172
173/// In addition to the `ReadyForFilenameTemplate` state this state has the
174/// following variables set:
175///
176/// * `TMPL_HTML_VAR_EXPORTER_DOC_CSS`
177/// * `TMPL_HTML_VAR_EXPORTER_HIGHLIGHTING_CSS`
178/// * `TMPL_HTML_VAR_EXPORTER_HIGHLIGHTING_CSS`
179/// * `TMPL_HTML_VAR_VIEWER_DOC_CSS_PATH`
180/// * `TMPL_HTML_VAR_VIEWER_DOC_CSS_PATH_VALUE`
181/// * `TMPL_HTML_VAR_VIEWER_DOC_JS` from `viewer_doc_js`
182/// * `TMPL_HTML_VAR_VIEWER_HIGHLIGHTING_CSS_PATH`
183/// * `TMPL_HTML_VAR_VIEWER_HIGHLIGHTING_CSS_PATH_VALUE`
184/// * `TMPL_VAR_DOC`
185///
186/// Once this state is achieved, `Context` is constant and write protected until
187/// the next state transition.
188///
189/// |  State order   |                                       |
190/// |----------------|---------------------------------------|
191/// | Previous state | `ReadyForFilenameTemplate`            |
192/// | Current state  | `ReadyForHtmlTemplate`                |
193/// | Next state     | none                                  |
194///
195impl ContextState for ReadyForHtmlTemplate {}
196
197/// The `Context` has all data for the intended template.
198///
199/// * `TMPL_HTML_VAR_DOC_ERROR` from `error_message`
200/// * `TMPL_HTML_VAR_DOC_TEXT` from `note_erroneous_content`
201/// * `TMPL_HTML_VAR_VIEWER_DOC_JS` from `viewer_doc_js`
202///
203/// Once this state is achieved, `Context` is constant and write protected until
204/// the next state transition.
205///
206/// |  State order   |                                       |
207/// |----------------|---------------------------------------|
208/// | Previous state | `HasSettings`                         |
209/// | Current state  | `ReadyForHtmlErrorTemplate`           |
210/// | Next state     | none                                  |
211///
212#[cfg(feature = "viewer")]
213impl ContextState for ReadyForHtmlErrorTemplate {}
214
215/// Recursively checks that all leaf values of a `tera::Value` tree satisfy `f`.
216/// Arrays and maps are traversed; other values are passed to `f`.
217fn tera_all_leaves(val: &tera::Value, f: &dyn Fn(&tera::Value) -> bool) -> bool {
218    if let Some(a) = val.as_array() {
219        a.iter().all(|i| tera_all_leaves(i, f))
220    } else if let Some(map) = val.as_map() {
221        map.values().all(|v| tera_all_leaves(v, f))
222    } else {
223        f(val)
224    }
225}
226
227/// Tiny wrapper around "Tera context" with some additional information.
228#[derive(Clone, Debug, PartialEq)]
229pub struct Context<S: ContextState + ?Sized> {
230    /// Collection of substitution variables.
231    ct: tera::Context,
232    /// First positional command line argument.
233    path: PathBuf,
234    /// The directory (only) path corresponding to the first positional
235    /// command line argument. The is our working directory and
236    /// the directory where the note file is (will be) located.
237    dir_path: PathBuf,
238    /// `dir_path` is a subdirectory of `root_path`. `root_path` is the
239    /// first directory, that upwards from `dir_path`, contains a file named
240    /// `FILENAME_ROOT_PATH_MARKER` (or `/` if no project configuration
241    /// 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"), Path::new("/path/to").to_path_buf()).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"), Path::new("/path/to").to_path_buf()).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    /// `root_path` is the document root: the directory Tp-Note's viewer
586    /// treats as its security boundary for rewriting local links, and the
587    /// value stored under the `TMPL_VAR_ROOT_PATH` template key. This
588    /// constructor does not discover it: the caller must supply it, e.g. by
589    /// searching upward for a `tpnote.toml` project configuration file (a
590    /// convention the `tpnote` binary implements; cf. its CUSTOMIZATION man
591    /// page section) or by any other means appropriate to the embedding
592    /// application.
593    ///
594    /// A copy of `path` is stored in `self.ct` as key `TMPL_VAR_PATH`. Its
595    /// directory path as key `TMPL_VAR_DIR_PATH`. If `path` points to a
596    /// file, its file creation date is stored with the key
597    /// `TMPL_VAR_DOC_FILE_DATE`.
598    ///
599    /// ```rust
600    /// use std::path::Path;
601    /// use tpnote_lib::settings::set_test_default_settings;
602    /// use tpnote_lib::config::TMPL_VAR_DIR_PATH;
603    /// use tpnote_lib::config::TMPL_VAR_PATH;
604    /// use tpnote_lib::context::Context;
605    /// set_test_default_settings().unwrap();
606    ///
607    /// let mut context = Context::from(&Path::new("/path/to/mynote.md"), Path::new("/path/to").to_path_buf()).unwrap();
608    ///
609    /// assert_eq!(context.get_path(), Path::new("/path/to/mynote.md"));
610    /// assert_eq!(context.get_dir_path(), Path::new("/path/to/"));
611    /// assert_eq!(&context.get(TMPL_VAR_PATH).unwrap().to_string(),
612    ///             "/path/to/mynote.md");
613    /// assert_eq!(&context.get(TMPL_VAR_DIR_PATH).unwrap().to_string(),
614    ///             "/path/to");
615    /// ```
616    ///
617    /// # Panics
618    ///
619    /// Debug builds assert that `path`'s directory is a subdirectory of
620    /// `root_path`, catching a caller that passes a `root_path` computed
621    /// for a different directory.
622    pub fn from(path: &Path, root_path: PathBuf) -> Result<Context<HasSettings>, FileError> {
623        // `dir_path` is a directory as fully qualified path, ending
624        // by a separator.
625        let dir_path = if path.is_dir() {
626            path.to_path_buf()
627        } else {
628            path.parent()
629                .unwrap_or_else(|| Path::new("./"))
630                .to_path_buf()
631        };
632        let path = path.to_path_buf();
633
634        debug_assert!(dir_path.starts_with(&root_path));
635
636        // Get the file's creation date. Fail silently.
637        let file_creation_date = if let Ok(file) = File::open(&path) {
638            let metadata = file.metadata()?;
639            metadata.created().or_else(|_| metadata.modified()).ok()
640        } else {
641            None
642        };
643
644        // Insert environment.
645        let mut context = Context {
646            ct: tera::Context::new(),
647            path,
648            dir_path,
649            root_path,
650            doc_file_date: file_creation_date,
651            _marker: PhantomData,
652        };
653
654        context.sync_paths_to_map();
655        context.insert_config_vars();
656        context.insert_settings();
657        Ok(context)
658    }
659}
660
661impl Context<HasSettings> {
662    /// Merges `fm` into `self.ct`.
663    pub(crate) fn insert_front_matter(
664        mut self,
665        fm: &FrontMatter,
666    ) -> Context<ReadyForFilenameTemplate> {
667        Context::insert_front_matter2(&mut self, fm);
668        Context {
669            ct: self.ct,
670            path: self.path,
671            dir_path: self.dir_path,
672            root_path: self.root_path,
673            doc_file_date: self.doc_file_date,
674            _marker: PhantomData,
675        }
676    }
677
678    /// Inserts clipboard data, standard input data and/or existing note file
679    /// content into the context. The data may contain some copied text with
680    /// or without a YAML header. The latter usually carries front matter
681    /// variables. The `input` data below is registered with the key name given
682    /// by `tmpl_var_body_name`. Typical names are `"clipboard"` or `"stdin"`.
683    /// If the below `input` contains a valid YAML header, it will be registered
684    /// in the context with the key name given by `tmpl_var_header_name`. The
685    /// templates expect the key names `clipboard_header` or `std_header`. The
686    /// raw header text will be inserted with this key name.
687    ///
688    pub(crate) fn insert_front_matter_and_raw_text_from_existing_content(
689        mut self,
690        clipboards: &Vec<&impl Content>,
691    ) -> Result<Context<HasExistingContent>, NoteError> {
692        //
693        self.insert_front_matter_and_raw_text_from_existing_content2(clipboards)?;
694
695        Ok(Context {
696            ct: self.ct,
697            path: self.path,
698            dir_path: self.dir_path,
699            root_path: self.root_path,
700            doc_file_date: self.doc_file_date,
701            _marker: PhantomData,
702        })
703    }
704
705    /// This adds the following variables to `self`:
706    ///
707    /// * `TMPL_HTML_VAR_VIEWER_DOC_JS` from `viewer_doc_js`
708    /// * `TMPL_HTML_VAR_DOC_ERROR` from `error_message`
709    /// * `TMPL_HTML_VAR_DOC_TEXT` from `note_erroneous_content`
710    ///
711    #[cfg(feature = "viewer")]
712    pub(crate) fn insert_error_content(
713        mut self,
714        note_erroneous_content: &impl Content,
715        error_message: &str,
716        // Java Script live updater inject code. Will be inserted into
717        // `tmpl_html.viewer`.
718        viewer_doc_js: &str,
719    ) -> Context<ReadyForHtmlErrorTemplate> {
720        //
721        self.ct.insert(TMPL_HTML_VAR_VIEWER_DOC_JS, viewer_doc_js);
722
723        self.ct.insert(TMPL_HTML_VAR_DOC_ERROR, error_message);
724        self.ct
725            .insert(TMPL_HTML_VAR_DOC_TEXT, &note_erroneous_content.as_str());
726
727        Context {
728            ct: self.ct,
729            path: self.path,
730            dir_path: self.dir_path,
731            root_path: self.root_path,
732            doc_file_date: self.doc_file_date,
733            _marker: PhantomData,
734        }
735    }
736}
737
738impl Context<HasExistingContent> {
739    /// See same method in `Context<HasSettings>`.
740    pub(crate) fn insert_front_matter_and_raw_text_from_existing_content(
741        mut self,
742        clipboards: &Vec<&impl Content>,
743    ) -> Result<Context<HasExistingContent>, NoteError> {
744        //
745        self.insert_front_matter_and_raw_text_from_existing_content2(clipboards)?;
746
747        Ok(Context {
748            ct: self.ct,
749            path: self.path,
750            dir_path: self.dir_path,
751            root_path: self.root_path,
752            doc_file_date: self.doc_file_date,
753            _marker: PhantomData,
754        })
755    }
756
757    /// Mark this as ready for a content template.
758    pub(crate) fn set_state_ready_for_content_template(self) -> Context<ReadyForContentTemplate> {
759        Context {
760            ct: self.ct,
761            path: self.path,
762            dir_path: self.dir_path,
763            root_path: self.root_path,
764            doc_file_date: self.doc_file_date,
765            _marker: PhantomData,
766        }
767    }
768}
769
770impl Context<ReadyForFilenameTemplate> {
771    /// Checks if the front matter variables satisfy preconditions.
772    /// `self.path` is the path to the current document.
773    #[inline]
774    pub(crate) fn assert_precoditions(&self) -> Result<(), NoteError> {
775        let path = &self.path;
776        let lib_cfg = &LIB_CFG.read_recursive();
777
778        // Get front matter scheme if there is any.
779        let fm_all = self.get(TMPL_VAR_FM_ALL);
780        if fm_all.is_none() {
781            return Ok(());
782        }
783        let fm_all = fm_all.unwrap();
784        let fm_scheme = fm_all
785            .get_from_path(TMPL_VAR_FM_SCHEME)
786            .and_then(|v| v.as_str());
787        let scheme_idx = fm_scheme.and_then(|scheme_name| {
788            lib_cfg
789                .scheme
790                .iter()
791                .enumerate()
792                .find_map(|(i, s)| (s.name == scheme_name).then_some(i))
793        });
794        // If not use `current_scheme` from `SETTINGS`
795        let scheme_idx = scheme_idx.unwrap_or_else(|| SETTINGS.read_recursive().current_scheme);
796        let scheme = &lib_cfg.scheme[scheme_idx];
797
798        for (key, conditions) in scheme.tmpl.fm_var.assertions.iter() {
799            if let Some(value) = fm_all.get_from_path(key) {
800                for cond in conditions {
801                    match cond {
802                        Assertion::IsDefined => {}
803
804                        Assertion::IsString => {
805                            if !tera_all_leaves(value, &|v| v.is_string()) {
806                                return Err(NoteError::FrontMatterFieldIsNotString {
807                                    field_name: name(scheme, key).to_string(),
808                                });
809                            }
810                        }
811
812                        Assertion::IsNotEmptyString => {
813                            if !tera_all_leaves(value, &|v| v.is_string() && v.as_str() != Some(""))
814                            {
815                                return Err(NoteError::FrontMatterFieldIsEmptyString {
816                                    field_name: name(scheme, key).to_string(),
817                                });
818                            }
819                        }
820
821                        Assertion::IsNumber => {
822                            if !tera_all_leaves(value, &|v| v.is_number()) {
823                                return Err(NoteError::FrontMatterFieldIsNotNumber {
824                                    field_name: name(scheme, key).to_string(),
825                                });
826                            }
827                        }
828
829                        Assertion::IsBool => {
830                            if !tera_all_leaves(value, &|v| v.is_bool()) {
831                                return Err(NoteError::FrontMatterFieldIsNotBool {
832                                    field_name: name(scheme, key).to_string(),
833                                });
834                            }
835                        }
836
837                        Assertion::IsNotCompound => {
838                            if value.is_array() || value.is_map() {
839                                return Err(NoteError::FrontMatterFieldIsCompound {
840                                    field_name: name(scheme, key).to_string(),
841                                });
842                            }
843                        }
844
845                        Assertion::IsValidSortTag => {
846                            let fm_sort_tag = value.as_str().unwrap_or_default();
847                            if !fm_sort_tag.is_empty() {
848                                // Check for forbidden characters.
849                                let (_, rest, is_sequential) = fm_sort_tag.split_sort_tag(true);
850                                if !rest.is_empty() {
851                                    return Err(NoteError::FrontMatterFieldIsInvalidSortTag {
852                                        sort_tag: fm_sort_tag.to_owned(),
853                                        sort_tag_extra_chars: scheme
854                                            .filename
855                                            .sort_tag
856                                            .extra_chars
857                                            .escape_default()
858                                            .to_string(),
859                                        filename_sort_tag_letters_in_succession_max: scheme
860                                            .filename
861                                            .sort_tag
862                                            .letters_in_succession_max,
863                                    });
864                                }
865
866                                // Check for duplicate sequential sort-tags.
867                                if !is_sequential {
868                                    // No further checks.
869                                    return Ok(());
870                                }
871                                let docpath = path.to_str().unwrap_or_default();
872
873                                let (dirpath, filename) =
874                                    docpath.rsplit_once(['/', '\\']).unwrap_or(("", docpath));
875                                let sort_tag = filename.split_sort_tag(false).0;
876                                // No further check if filename(path) has no sort-tag
877                                // or if sort-tags are identical.
878                                if sort_tag.is_empty() || sort_tag == fm_sort_tag {
879                                    return Ok(());
880                                }
881                                let dirpath = Path::new(dirpath);
882
883                                if let Some(other_file) =
884                                    dirpath.has_file_with_sort_tag(fm_sort_tag)
885                                {
886                                    return Err(NoteError::FrontMatterFieldIsDuplicateSortTag {
887                                        sort_tag: fm_sort_tag.to_string(),
888                                        existing_file: other_file,
889                                    });
890                                }
891                            }
892                        }
893
894                        Assertion::IsTpnoteExtension => {
895                            let file_ext = value.as_str().unwrap_or_default();
896
897                            if !file_ext.is_empty() && !(*file_ext).is_tpnote_ext() {
898                                return Err(NoteError::FrontMatterFieldIsNotTpnoteExtension {
899                                    extension: file_ext.to_string(),
900                                    extensions: {
901                                        use std::fmt::Write;
902                                        let mut errstr = scheme.filename.extensions.iter().fold(
903                                            String::new(),
904                                            |mut output, (k, _v1, _v2)| {
905                                                let _ = write!(output, "{k}, ");
906                                                output
907                                            },
908                                        );
909                                        errstr.truncate(errstr.len().saturating_sub(2));
910                                        errstr
911                                    },
912                                });
913                            }
914                        }
915
916                        Assertion::IsConfiguredScheme => {
917                            let fm_scheme = value.as_str().unwrap_or_default();
918                            match lib_cfg.scheme_idx(fm_scheme) {
919                                Ok(_) => {}
920                                Err(LibCfgError::SchemeNotFound {
921                                    scheme_name,
922                                    schemes,
923                                }) => {
924                                    return Err(NoteError::SchemeNotFound {
925                                        scheme_val: scheme_name,
926                                        scheme_key: key.to_string(),
927                                        schemes,
928                                    });
929                                }
930                                Err(e) => return Err(e.into()),
931                            };
932                        }
933
934                        Assertion::NoOperation => {}
935                    } //
936                }
937                //
938            } else if conditions.contains(&Assertion::IsDefined) {
939                return Err(NoteError::FrontMatterFieldMissing {
940                    field_name: name(scheme, key).to_string(),
941                });
942            }
943        }
944        Ok(())
945    }
946
947    /// Indicates that this context contains all we need for the content
948    /// template.
949    #[cfg(test)]
950    pub(crate) fn set_state_ready_for_content_template(self) -> Context<ReadyForContentTemplate> {
951        //
952        Context {
953            ct: self.ct,
954            path: self.path,
955            dir_path: self.dir_path,
956            root_path: self.root_path,
957            doc_file_date: self.doc_file_date,
958            _marker: PhantomData,
959        }
960    }
961
962    /// Inserts the following variables into `self`:
963    ///
964    /// * `TMPL_HTML_VAR_VIEWER_DOC_JS` from `viewer_doc_js`
965    /// * `TMPL_VAR_DOC` from `content.header()` and `content.body()`
966    /// * `TMPL_HTML_VAR_EXPORTER_DOC_CSS`
967    /// * `TMPL_HTML_VAR_EXPORTER_HIGHLIGHTING_CSS`
968    /// * `TMPL_HTML_VAR_EXPORTER_HIGHLIGHTING_CSS`
969    /// * `TMPL_HTML_VAR_VIEWER_DOC_CSS_PATH`
970    /// * `TMPL_HTML_VAR_VIEWER_DOC_CSS_PATH_VALUE`
971    /// * `TMPL_HTML_VAR_VIEWER_HIGHLIGHTING_CSS_PATH`
972    /// * `TMPL_HTML_VAR_VIEWER_HIGHLIGHTING_CSS_PATH_VALUE`
973    ///
974    pub(crate) fn insert_raw_content_and_css(
975        mut self,
976        content: &impl Content,
977        viewer_doc_js: &str,
978    ) -> Context<ReadyForHtmlTemplate> {
979        //
980        self.ct.insert(TMPL_HTML_VAR_VIEWER_DOC_JS, viewer_doc_js);
981
982        self.insert_raw_text_from_existing_content(content);
983
984        {
985            let lib_cfg = &LIB_CFG.read_recursive();
986
987            // Insert the raw CSS
988            self.ct.insert(
989                TMPL_HTML_VAR_EXPORTER_DOC_CSS,
990                &(lib_cfg.tmpl_html.exporter_doc_css),
991            );
992
993            // Insert the raw CSS
994            #[cfg(feature = "renderer")]
995            self.ct.insert(
996                TMPL_HTML_VAR_EXPORTER_HIGHLIGHTING_CSS,
997                &(lib_cfg.tmpl_html.exporter_highlighting_css),
998            );
999        } // Drop `lib_cfg`.
1000
1001        // Insert the raw CSS
1002        #[cfg(not(feature = "renderer"))]
1003        self.ct.insert(TMPL_HTML_VAR_EXPORTER_HIGHLIGHTING_CSS, "");
1004
1005        // Insert the web server path to get the Tp-Note's CSS loaded.
1006        self.ct.insert(
1007            TMPL_HTML_VAR_VIEWER_DOC_CSS_PATH,
1008            TMPL_HTML_VAR_VIEWER_DOC_CSS_PATH_VALUE,
1009        );
1010
1011        // Insert the web server path to get the highlighting CSS loaded.
1012        self.ct.insert(
1013            TMPL_HTML_VAR_VIEWER_HIGHLIGHTING_CSS_PATH,
1014            TMPL_HTML_VAR_VIEWER_HIGHLIGHTING_CSS_PATH_VALUE,
1015        );
1016        Context {
1017            ct: self.ct,
1018            path: self.path,
1019            dir_path: self.dir_path,
1020            root_path: self.root_path,
1021            doc_file_date: self.doc_file_date,
1022            _marker: PhantomData,
1023        }
1024    }
1025}
1026
1027/// Auto dereferences for convenient access to `tera::Context`.
1028impl<S: ContextState> Deref for Context<S> {
1029    type Target = tera::Context;
1030
1031    fn deref(&self) -> &Self::Target {
1032        &self.ct
1033    }
1034}
1035
1036#[cfg(test)]
1037mod tests {
1038
1039    use crate::{config::TMPL_VAR_FM_ALL, error::NoteError};
1040
1041    use std::path::Path;
1042    use std::path::PathBuf;
1043
1044    #[test]
1045    fn test_insert_front_matter() {
1046        use crate::context::Context;
1047        use crate::front_matter::FrontMatter;
1048        use std::path::Path;
1049        let context = Context::from(Path::new("/path/to/mynote.md"), Path::new("/path/to").to_path_buf()).unwrap();
1050        let context = context
1051            .insert_front_matter(&FrontMatter::try_from("title: My Stdin.\nsome: text").unwrap());
1052
1053        assert_eq!(
1054            &context
1055                .get(TMPL_VAR_FM_ALL)
1056                .unwrap()
1057                .get_from_path("fm_title")
1058                .unwrap()
1059                .to_string(),
1060            "My Stdin."
1061        );
1062        assert_eq!(
1063            &context
1064                .get(TMPL_VAR_FM_ALL)
1065                .unwrap()
1066                .get_from_path("fm_some")
1067                .unwrap()
1068                .to_string(),
1069            "text"
1070        );
1071    }
1072
1073    #[test]
1074    fn test_insert_front_matter2() {
1075        use crate::context::Context;
1076        use crate::front_matter::FrontMatter;
1077        use std::path::Path;
1078        let context = Context::from(Path::new("/path/to/mynote.md"), Path::new("/path/to").to_path_buf()).unwrap();
1079        let context = context
1080            .insert_front_matter(&FrontMatter::try_from("title: My Stdin.\nsome: text").unwrap());
1081        let context = context.set_state_ready_for_content_template();
1082
1083        assert_eq!(
1084            &context
1085                .get(TMPL_VAR_FM_ALL)
1086                .unwrap()
1087                .get_from_path("fm_title")
1088                .unwrap()
1089                .to_string(),
1090            "My Stdin."
1091        );
1092        assert_eq!(
1093            &context
1094                .get(TMPL_VAR_FM_ALL)
1095                .unwrap()
1096                .get_from_path("fm_some")
1097                .unwrap()
1098                .to_string(),
1099            "text"
1100        );
1101    }
1102
1103    #[test]
1104    fn test_insert_front_matter_and_raw_text_from_existing_content() {
1105        use crate::content::Content;
1106        use crate::content::ContentString;
1107        use crate::context::Context;
1108        use crate::settings::set_test_default_settings;
1109        use std::path::Path;
1110        set_test_default_settings().unwrap();
1111        let context = Context::from(Path::new("/path/to/mynote.md"), Path::new("/path/to").to_path_buf()).unwrap();
1112        let c1 = ContentString::from_string(
1113            String::from("Data from clipboard."),
1114            "txt_clipboard".to_string(),
1115        );
1116        let c2 = ContentString::from_string(
1117            "---\ntitle: My Stdin.\n---\nbody".to_string(),
1118            "stdin".to_string(),
1119        );
1120        let c = vec![&c1, &c2];
1121        let context = context
1122            .insert_front_matter_and_raw_text_from_existing_content(&c)
1123            .unwrap();
1124        assert_eq!(
1125            &context
1126                .get("txt_clipboard")
1127                .unwrap()
1128                .get_from_path("body")
1129                .unwrap()
1130                .to_string(),
1131            "Data from clipboard."
1132        );
1133        assert_eq!(
1134            &context
1135                .get("stdin")
1136                .unwrap()
1137                .get_from_path("body")
1138                .unwrap()
1139                .to_string(),
1140            "body"
1141        );
1142        assert_eq!(
1143            &context
1144                .get("stdin")
1145                .unwrap()
1146                .get_from_path("header")
1147                .unwrap()
1148                .to_string(),
1149            "title: My Stdin."
1150        );
1151        // "fm_title" is dynamically generated from the header variable "title".
1152        assert_eq!(
1153            &context
1154                .get("fm")
1155                .unwrap()
1156                .get_from_path("fm_title")
1157                .unwrap()
1158                .to_string(),
1159            "My Stdin."
1160        );
1161    }
1162
1163    #[test]
1164    fn test_assert_preconditions() {
1165        // Check `tmpl.filter.assert_preconditions` in
1166        // `tpnote_lib/src/config_default.toml` to understand these tests.
1167        use crate::context::Context;
1168        use crate::front_matter::FrontMatter;
1169        use serde_json::json;
1170        //
1171        // Is empty.
1172        let input = "";
1173        let fm = FrontMatter::try_from(input).unwrap();
1174        let cx = Context::from(Path::new("does not matter"), PathBuf::new()).unwrap();
1175        let cx = cx.insert_front_matter(&fm);
1176
1177        assert!(matches!(
1178            cx.assert_precoditions().unwrap_err(),
1179            NoteError::FrontMatterFieldMissing { .. }
1180        ));
1181
1182        //
1183        // Ok as long as no other file with that sort-tag exists.
1184        let input = "# document start
1185        title: The book
1186        sort_tag:    123b";
1187        let fm = FrontMatter::try_from(input).unwrap();
1188        let cx = Context::from(Path::new("./03b-test.md"), PathBuf::new()).unwrap();
1189        let cx = cx.insert_front_matter(&fm);
1190
1191        assert!(matches!(cx.assert_precoditions(), Ok(())));
1192
1193        //
1194        // Should not be a compound type.
1195        let input = "# document start
1196        title: The book
1197        sort_tag:
1198        -    1234
1199        -    456";
1200        let fm = FrontMatter::try_from(input).unwrap();
1201        let cx = Context::from(Path::new("does not matter"), PathBuf::new()).unwrap();
1202        let cx = cx.insert_front_matter(&fm);
1203
1204        assert!(matches!(
1205            cx.assert_precoditions().unwrap_err(),
1206            NoteError::FrontMatterFieldIsCompound { .. }
1207        ));
1208
1209        //
1210        // Should not be a compound type.
1211        let input = "# document start
1212        title: The book
1213        sort_tag:
1214          first:  1234
1215          second: 456";
1216        let fm = FrontMatter::try_from(input).unwrap();
1217        let cx = Context::from(Path::new("does not matter"), PathBuf::new()).unwrap();
1218        let cx = cx.insert_front_matter(&fm);
1219
1220        assert!(matches!(
1221            cx.assert_precoditions().unwrap_err(),
1222            NoteError::FrontMatterFieldIsCompound { .. }
1223        ));
1224
1225        //
1226        // Not registered file extension.
1227        let input = "# document start
1228        title: The book
1229        file_ext:    xyz";
1230        let fm = FrontMatter::try_from(input).unwrap();
1231        let cx = Context::from(Path::new("does not matter"), PathBuf::new()).unwrap();
1232        let cx = cx.insert_front_matter(&fm);
1233
1234        assert!(matches!(
1235            cx.assert_precoditions().unwrap_err(),
1236            NoteError::FrontMatterFieldIsNotTpnoteExtension { .. }
1237        ));
1238
1239        //
1240        // Check `bool`
1241        let input = "# document start
1242        title: The book
1243        filename_sync: error, here should be a bool";
1244        let fm = FrontMatter::try_from(input).unwrap();
1245        let cx = Context::from(Path::new("does not matter"), PathBuf::new()).unwrap();
1246        let cx = cx.insert_front_matter(&fm);
1247
1248        assert!(matches!(
1249            cx.assert_precoditions().unwrap_err(),
1250            NoteError::FrontMatterFieldIsNotBool { .. }
1251        ));
1252
1253        //
1254        let input = "# document start
1255        title: my title
1256        subtitle: my subtitle
1257        ";
1258        let expected = json!({"fm_title": "my title", "fm_subtitle": "my subtitle"});
1259
1260        let fm = FrontMatter::try_from(input).unwrap();
1261        let cx = Context::from(Path::new("does not matter"), PathBuf::new()).unwrap();
1262        let cx = cx.insert_front_matter(&fm);
1263        let fm_all_sj = serde_json::to_value(cx.get(TMPL_VAR_FM_ALL).unwrap()).unwrap();
1264        assert_eq!(fm_all_sj, expected);
1265
1266        //
1267        let input = "# document start
1268        title: my title
1269        file_ext: ''
1270        ";
1271        let expected = json!({"fm_title": "my title", "fm_file_ext": ""});
1272
1273        let fm = FrontMatter::try_from(input).unwrap();
1274        let cx = Context::from(Path::new("does not matter"), PathBuf::new()).unwrap();
1275        let cx = cx.insert_front_matter(&fm);
1276        let fm_all_sj = serde_json::to_value(cx.get(TMPL_VAR_FM_ALL).unwrap()).unwrap();
1277        assert_eq!(fm_all_sj, expected);
1278
1279        //
1280        let input = "# document start
1281        title: ''
1282        subtitle: my subtitle
1283        ";
1284        let fm = FrontMatter::try_from(input).unwrap();
1285        let cx = Context::from(Path::new("does not matter"), PathBuf::new()).unwrap();
1286        let cx = cx.insert_front_matter(&fm);
1287
1288        assert!(matches!(
1289            cx.assert_precoditions().unwrap_err(),
1290            NoteError::FrontMatterFieldIsEmptyString { .. }
1291        ));
1292
1293        //
1294        let input = "# document start
1295        title: My doc
1296        author:
1297        - First author
1298        - Second author
1299        ";
1300        let fm = FrontMatter::try_from(input).unwrap();
1301        let cx = Context::from(Path::new("does not matter"), PathBuf::new()).unwrap();
1302        let cx = cx.insert_front_matter(&fm);
1303
1304        assert!(cx.assert_precoditions().is_ok());
1305
1306        //
1307        let input = "# document start
1308        title: My doc
1309        subtitle: my subtitle
1310        author:
1311        - First title
1312        - 1234
1313        ";
1314        let fm = FrontMatter::try_from(input).unwrap();
1315        let cx = Context::from(Path::new("does not matter"), PathBuf::new()).unwrap();
1316        let cx = cx.insert_front_matter(&fm);
1317
1318        assert!(matches!(
1319            cx.assert_precoditions().unwrap_err(),
1320            NoteError::FrontMatterFieldIsNotString { .. }
1321        ));
1322    }
1323
1324    /// When an input stream has syntactically invalid YAML between `---`
1325    /// delimiters, `insert_front_matter_and_raw_text_from_existing_content`
1326    /// must succeed and expose the entire raw content as body with an empty
1327    /// header, instead of returning `InvalidInputYaml`.
1328    #[test]
1329    fn test_invalid_yaml_header_treated_as_body() {
1330        use crate::content::Content;
1331        use crate::content::ContentString;
1332        use crate::context::Context;
1333        use crate::settings::set_test_default_settings;
1334        use std::path::Path;
1335        set_test_default_settings().unwrap();
1336
1337        let raw = "---\n<invalid\n---\nhallo";
1338        let c = ContentString::from_string(raw.to_string(), "stdin".to_string());
1339        // Sanity check: the split logic does recognise a header here.
1340        assert!(!c.header().is_empty());
1341
1342        let context = Context::from(Path::new("/path/to/mynote.md"), Path::new("/path/to").to_path_buf()).unwrap();
1343        let result = context
1344            .insert_front_matter_and_raw_text_from_existing_content(&vec![&c]);
1345
1346        assert!(result.is_ok(), "expected Ok, got: {:?}", result.err());
1347
1348        let context = result.unwrap();
1349        // Header must be empty — the invalid YAML was discarded.
1350        assert_eq!(
1351            context
1352                .get("stdin")
1353                .unwrap()
1354                .get_from_path("header")
1355                .unwrap()
1356                .as_str()
1357                .unwrap(),
1358            ""
1359        );
1360        // Body must be the full raw input.
1361        assert_eq!(
1362            context
1363                .get("stdin")
1364                .unwrap()
1365                .get_from_path("body")
1366                .unwrap()
1367                .as_str()
1368                .unwrap(),
1369            raw
1370        );
1371    }
1372}