Skip to main content

mdmodels_core/
pipeline.rs

1/*
2 * Copyright (c) 2025 Jan Range
3 *
4 * Permission is hereby granted, free of charge, to any person obtaining a copy
5 * of this software and associated documentation files (the "Software"), to deal
6 * in the Software without restriction, including without limitation the rights
7 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8 * copies of the Software, and to permit persons to whom the Software is
9 * furnished to do so, subject to the following conditions:
10 *
11 * The above copyright notice and this permission notice shall be included in
12 * all copies or substantial portions of the Software.
13 *
14 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
20 * THE SOFTWARE.
21 *
22 */
23
24use crate::{datamodel::DataModel, exporters::Templates};
25use colored::Colorize;
26use convert_case::Casing;
27use serde::{Deserialize, Serialize};
28use std::{
29    collections::HashMap,
30    error::Error,
31    fs,
32    path::{Path, PathBuf},
33    str::FromStr,
34};
35
36/// Represents a template with metadata and generation specifications.
37#[derive(Debug, Serialize, Deserialize)]
38struct GenTemplate {
39    meta: Meta,
40    generate: HashMap<String, GenSpecs>,
41}
42
43impl GenTemplate {
44    pub fn prepend_root(&mut self, path: &Path) {
45        for (_, specs) in self.generate.iter_mut() {
46            specs.prepend_root(path);
47        }
48
49        self.meta.paths = self
50            .meta
51            .paths
52            .iter_mut()
53            .map(|spec| path.join(spec))
54            .collect();
55    }
56}
57
58/// Represents metadata for the template.
59#[derive(Debug, Serialize, Deserialize)]
60struct Meta {
61    name: Option<String>,
62    description: Option<String>,
63    paths: Vec<PathBuf>,
64}
65
66/// Represents generation specifications for a template.
67#[derive(Debug, Serialize, Deserialize)]
68struct GenSpecs {
69    description: Option<String>,
70    out: PathBuf,
71    root: Option<String>,
72    #[serde(rename = "per-spec")]
73    per_spec: Option<bool>,
74    #[serde(flatten)]
75    #[serde(deserialize_with = "deserialize_config_map")]
76    config: HashMap<String, String>,
77    #[serde(rename = "fname-case", default)]
78    fname_case: Option<NameCase>,
79}
80
81fn deserialize_config_map<'de, D>(deserializer: D) -> Result<HashMap<String, String>, D::Error>
82where
83    D: serde::Deserializer<'de>,
84{
85    let map: HashMap<String, toml::Value> = HashMap::deserialize(deserializer)?;
86    Ok(map.into_iter().map(|(k, v)| (k, v.to_string())).collect())
87}
88
89impl GenSpecs {
90    pub fn prepend_root(&mut self, path: &Path) {
91        if path.is_file() {
92            panic!("Root to prepend is not a directory.");
93        }
94
95        self.out = path.join(&self.out);
96    }
97}
98
99/// Sate that determines whether objects are merged or not.
100#[derive(Debug)]
101enum MergeState {
102    Merge,
103    NoMerge,
104}
105
106impl From<bool> for MergeState {
107    fn from(value: bool) -> Self {
108        if value {
109            MergeState::NoMerge
110        } else {
111            MergeState::Merge
112        }
113    }
114}
115
116/// Processes the pipeline by reading the template file, building the data model, and generating files based on the specifications.
117///
118/// # Arguments
119///
120/// * `path` - Path to the template file.
121///
122/// # Returns
123///
124/// A Result indicating success or failure.
125pub fn process_pipeline(path: &PathBuf) -> Result<(), Box<dyn std::error::Error>> {
126    let content = std::fs::read_to_string(path)?;
127    let mut gen_template: GenTemplate = toml::from_str(content.as_str()).unwrap();
128
129    if let Some(parent) = path.parent() {
130        gen_template.prepend_root(parent);
131    }
132
133    let paths = gen_template.meta.paths.as_slice();
134
135    for (name, mut specs) in gen_template.generate.into_iter() {
136        let template = Templates::from_str(name.as_str())?;
137        let merge_state = MergeState::from(specs.per_spec.unwrap_or(false));
138
139        match template {
140            Templates::JsonSchema => {
141                serialize_by_template(
142                    &specs.out,
143                    paths,
144                    &merge_state,
145                    &template,
146                    &specs.config,
147                    &specs.fname_case,
148                )?;
149            }
150            Templates::JsonSchemaAll => {
151                serialize_all_json_schemes(&specs.out, paths, &merge_state)?;
152            }
153            Templates::JsonLd => {
154                serialize_by_template(
155                    &specs.out,
156                    paths,
157                    &merge_state,
158                    &template,
159                    &specs.config,
160                    &specs.fname_case,
161                )?;
162            }
163            Templates::Linkml => {
164                serialize_by_template(
165                    &specs.out,
166                    paths,
167                    &merge_state,
168                    &template,
169                    &specs.config,
170                    &specs.fname_case,
171                )?;
172            }
173            Templates::Shex => {
174                serialize_by_template(
175                    &specs.out,
176                    paths,
177                    &merge_state,
178                    &template,
179                    &specs.config,
180                    &specs.fname_case,
181                )?;
182            }
183            Templates::Shacl => {
184                serialize_by_template(
185                    &specs.out,
186                    paths,
187                    &merge_state,
188                    &template,
189                    &specs.config,
190                    &specs.fname_case,
191                )?;
192            }
193            Templates::Markdown => {
194                serialize_by_template(
195                    &specs.out,
196                    paths,
197                    &merge_state,
198                    &template,
199                    &specs.config,
200                    &specs.fname_case,
201                )?;
202            }
203            Templates::Owl => {
204                serialize_by_template(
205                    &specs.out,
206                    paths,
207                    &merge_state,
208                    &template,
209                    &specs.config,
210                    &specs.fname_case,
211                )?;
212            }
213            Templates::CompactMarkdown => {
214                serialize_by_template(
215                    &specs.out,
216                    paths,
217                    &merge_state,
218                    &template,
219                    &specs.config,
220                    &specs.fname_case,
221                )?;
222            }
223            Templates::PythonDataclass => {
224                serialize_by_template(
225                    &specs.out,
226                    paths,
227                    &merge_state,
228                    &template,
229                    &specs.config,
230                    &specs.fname_case,
231                )?;
232            }
233            Templates::PythonPydantic => {
234                serialize_by_template(
235                    &specs.out,
236                    paths,
237                    &merge_state,
238                    &template,
239                    &specs.config,
240                    &specs.fname_case,
241                )?;
242            }
243            Templates::PythonPydanticXML => {
244                serialize_by_template(
245                    &specs.out,
246                    paths,
247                    &merge_state,
248                    &template,
249                    &specs.config,
250                    &specs.fname_case,
251                )?;
252            }
253            Templates::XmlSchema => {
254                serialize_by_template(
255                    &specs.out,
256                    paths,
257                    &merge_state,
258                    &template,
259                    &specs.config,
260                    &specs.fname_case,
261                )?;
262            }
263            Templates::Typescript => {
264                serialize_by_template(
265                    &specs.out,
266                    paths,
267                    &merge_state,
268                    &template,
269                    &specs.config,
270                    &specs.fname_case,
271                )?;
272            }
273            Templates::TypescriptZod => {
274                serialize_by_template(
275                    &specs.out,
276                    paths,
277                    &merge_state,
278                    &template,
279                    &specs.config,
280                    &specs.fname_case,
281                )?;
282            }
283            Templates::Rust => {
284                serialize_by_template(
285                    &specs.out,
286                    paths,
287                    &merge_state,
288                    &template,
289                    &specs.config,
290                    &specs.fname_case,
291                )?;
292            }
293            Templates::Golang => {
294                serialize_by_template(
295                    &specs.out,
296                    paths,
297                    &merge_state,
298                    &template,
299                    &specs.config,
300                    &specs.fname_case,
301                )?;
302            }
303            Templates::Julia => {
304                serialize_by_template(
305                    &specs.out,
306                    paths,
307                    &merge_state,
308                    &template,
309                    &specs.config,
310                    &specs.fname_case,
311                )?;
312            }
313            Templates::Protobuf => {
314                serialize_by_template(
315                    &specs.out,
316                    paths,
317                    &merge_state,
318                    &template,
319                    &specs.config,
320                    &specs.fname_case,
321                )?;
322            }
323            Templates::Graphql => {
324                serialize_by_template(
325                    &specs.out,
326                    paths,
327                    &merge_state,
328                    &template,
329                    &specs.config,
330                    &specs.fname_case,
331                )?;
332            }
333            Templates::MkDocs => {
334                // If the template is not set to merge, then disable the navigation.
335                if let MergeState::Merge = merge_state {
336                    if !specs.config.contains_key("nav") {
337                        specs.config.insert("nav".to_string(), "false".to_string());
338                    }
339                }
340
341                serialize_by_template(
342                    &specs.out,
343                    paths,
344                    &merge_state,
345                    &template,
346                    &specs.config,
347                    &specs.fname_case,
348                )?;
349            }
350            Templates::Mermaid => {
351                serialize_by_template(
352                    &specs.out,
353                    paths,
354                    &merge_state,
355                    &template,
356                    &specs.config,
357                    &specs.fname_case,
358                )?;
359            }
360            Templates::Internal => {
361                let model = build_models(paths)?;
362                serialize_to_internal_schema(model, &specs.out, &merge_state)?;
363            }
364        }
365    }
366
367    Ok(())
368}
369
370/// Builds the data model by reading and merging multiple paths.
371///
372/// # Arguments
373///
374/// * `paths` - A slice of PathBuf representing the paths to read.
375///
376/// # Returns
377///
378/// A Result containing the DataModel or an error.
379fn build_models(paths: &[PathBuf]) -> Result<DataModel, Box<dyn Error>> {
380    let first_path = paths.first().unwrap();
381    path_exists(first_path)?;
382
383    let mut model = DataModel::from_markdown(first_path).map_err(|e| {
384        e.log_result();
385        format!("Error parsing markdown content: {e:#?}")
386    })?;
387
388    if paths.len() == 1 {
389        return Ok(model);
390    }
391
392    for path in paths.iter().skip(1) {
393        path_exists(path)?;
394        let new_model = DataModel::from_markdown(path)?;
395        model.merge(&new_model);
396    }
397
398    Ok(model)
399}
400
401/// Checks if the given path exists.
402///
403/// # Arguments
404///
405/// * `path` - A reference to a PathBuf to check.
406///
407/// # Returns
408///
409/// A Result indicating success or failure.
410fn path_exists(path: &PathBuf) -> Result<(), Box<dyn Error>> {
411    if !path.exists() {
412        return Err(format!("Path does not exist: {path:?}").into());
413    }
414    Ok(())
415}
416
417/// Serializes the data model to the internal schema.
418///
419/// Please note, this format may only be used for internal purposes.
420///
421/// # Arguments
422///
423/// * `model` - The DataModel to serialize.
424/// * `out` - The output path for the internal schema file.
425///
426/// # Returns
427///
428/// A Result indicating success or failure.
429fn serialize_to_internal_schema(
430    model: DataModel,
431    out: &PathBuf,
432    merge_state: &MergeState,
433) -> Result<(), Box<dyn Error>> {
434    match merge_state {
435        MergeState::Merge => {
436            let schema = model.internal_schema();
437            save_to_file(out, &schema)?;
438            print_render_msg(out, &Templates::Internal);
439            Ok(())
440        }
441        MergeState::NoMerge => {
442            Err("Per spec is not supported for internal schema generation at the moment.".into())
443        }
444    }
445}
446
447/// Serializes all JSON schemas for the data model to the specified output directory.
448///
449/// # Arguments
450///
451/// * `model` - The DataModel to serialize.
452/// * `out` - The output directory for the JSON schema files.
453///
454/// # Returns
455///
456/// A Result indicating success or failure.
457fn serialize_all_json_schemes(
458    out: &PathBuf,
459    specs: &[PathBuf],
460    merge_state: &MergeState,
461) -> Result<(), Box<dyn Error>> {
462    if out.is_file() {
463        return Err("Output path is a file".into());
464    }
465    if !out.exists() {
466        fs::create_dir_all(out)?;
467    }
468
469    match merge_state {
470        MergeState::Merge => {
471            let model = build_models(specs)?;
472            model.json_schema_all(out.to_path_buf(), false)?;
473            print_render_msg(out, &Templates::JsonSchemaAll);
474            Ok(())
475        }
476        MergeState::NoMerge => {
477            for spec in specs {
478                let model = DataModel::from_markdown(spec)?;
479                let path = out.join(get_file_name(spec));
480                model.json_schema_all(path.to_path_buf(), false)?;
481                print_render_msg(&path, &Templates::JsonSchemaAll);
482            }
483            Ok(())
484        }
485    }
486}
487
488/// Serializes the data model by the specified template.
489///
490/// # Arguments
491///
492/// * `out` - The output path for the serialized data model.
493/// * `specs` - A slice of PathBuf representing the paths to read.
494/// * `merge_state` - The merge state.
495/// * `template` - The template to use for serialization.
496///
497/// # Returns
498///
499/// A Result indicating success or failure.
500fn serialize_by_template(
501    out: &PathBuf,
502    specs: &[PathBuf],
503    merge_state: &MergeState,
504    template: &Templates,
505    config: &HashMap<String, String>,
506    case: &Option<NameCase>,
507) -> Result<(), Box<dyn Error>> {
508    match merge_state {
509        MergeState::Merge => {
510            print_render_msg(out, template);
511
512            let mut model = build_models(specs)?;
513            let content = model.convert_to(template, Some(config))?;
514
515            return save_to_file(out, content.as_str());
516        }
517        MergeState::NoMerge => {
518            if !has_wildcard_fname(out) {
519                return Err("
520                    Output file name must contain a wildcard.
521                    For example, a valid wildcard is 'path/to/*.json'"
522                    .into());
523            }
524
525            for spec in specs {
526                if !spec.exists() {
527                    return Err(format!("Path does not exist: {spec:?}").into());
528                }
529
530                let mut fname = get_file_name(spec);
531
532                if let Some(case) = case {
533                    fname = casify_filename(fname, case.into());
534                }
535
536                let path = replace_wildcard(out, &fname);
537                print_render_msg(&path, template);
538
539                let mut model = DataModel::from_markdown(spec)?;
540                let content = model.convert_to(template, Some(config))?;
541
542                save_to_file(&path, content.as_str())?;
543            }
544        }
545    }
546
547    Ok(())
548}
549
550/// Converts a filename to the specified case format.
551///
552/// # Arguments
553///
554/// * `name` - The filename to convert
555/// * `case` - The case format to convert to
556///
557/// # Returns
558///
559/// The converted filename as a String
560fn casify_filename(name: String, case: Option<convert_case::Case>) -> String {
561    if let Some(c) = case {
562        let (name, _) = name.split_once('.').unwrap_or((name.as_str(), ""));
563        let new_name = name.to_case(c);
564
565        new_name.to_string()
566    } else {
567        name
568    }
569}
570
571/// Checks if the given path has a wildcard file name.
572///
573/// # Arguments
574///
575/// * `path` - The path to check.
576///
577/// # Returns
578///
579/// A boolean indicating if the path has a wildcard file name.
580fn has_wildcard_fname(path: &Path) -> bool {
581    let path_str = path.to_str().unwrap();
582    path_str.contains("*")
583}
584
585/// Replaces the wildcard in the given path with the given name.
586///
587/// # Arguments
588///
589/// * `path` - The path to replace the wildcard file name.
590/// * `name` - The name to replace the wildcard file name with.
591///
592/// # Returns
593///
594/// A PathBuf with the wildcard replaced.
595fn replace_wildcard(path: &Path, name: &str) -> PathBuf {
596    let path_str = path.to_str().unwrap();
597    let new_path = path_str.replace('*', name);
598    PathBuf::from(new_path)
599}
600
601/// Gets the file name without the extension.
602///
603/// # Arguments
604///
605/// * `path` - The path to get the file name from.
606///
607/// # Returns
608///
609/// A string containing the file name without the extension.
610fn get_file_name(path: &Path) -> String {
611    // Get the filename without the extension
612    let file_name = path.file_name().unwrap().to_str().unwrap();
613    let file_name = file_name.split('.').collect::<Vec<&str>>()[0];
614    file_name.to_string()
615}
616
617/// Saves the given content to the specified file.
618///
619/// # Arguments
620///
621/// * `out` - The output path for the file.
622/// * `content` - The content to write to the file.
623///
624/// # Returns
625///
626/// A Result indicating success or failure.
627fn save_to_file(out: &PathBuf, content: &str) -> Result<(), Box<dyn Error>> {
628    let dir = out.parent().unwrap();
629    if !dir.exists() {
630        fs::create_dir_all(dir)?;
631    }
632
633    fs::write(out, content.trim()).map_err(|e| format!("Error writing to file: {e:#?}"))?;
634    Ok(())
635}
636
637fn print_render_msg(out: &Path, template: &Templates) {
638    println!(
639        " [{}] Writing to '{}'",
640        template.to_string().green().bold(),
641        out.to_str().unwrap().to_string().bold(),
642    );
643}
644
645/// Represents different case styles for naming files.
646///
647/// Supports common case conventions used in programming:
648/// - Pascal case (e.g. "MyFileName")
649/// - Snake case (e.g. "my_file_name")
650/// - Kebab case (e.g. "my-file-name")
651/// - Camel case (e.g. "myFileName")
652/// - None (no case transformation)
653#[derive(Debug, Deserialize, Serialize)]
654enum NameCase {
655    Pascal,
656    Snake,
657    Kebab,
658    Camel,
659    None,
660}
661
662impl FromStr for NameCase {
663    type Err = String;
664
665    /// Converts a string to a NameCase variant.
666    ///
667    /// # Arguments
668    ///
669    /// * `s` - The string to convert
670    ///
671    /// # Returns
672    ///
673    /// A Result containing the NameCase variant or an error string if invalid.
674    fn from_str(s: &str) -> Result<Self, Self::Err> {
675        match s {
676            "pascal" => Ok(NameCase::Pascal),
677            "snake" => Ok(NameCase::Snake),
678            "kebab" => Ok(NameCase::Kebab),
679            "camel" => Ok(NameCase::Camel),
680            _ => Err("Invalid name case".to_string()),
681        }
682    }
683}
684
685impl<'a> From<&'a NameCase> for Option<convert_case::Case<'a>> {
686    /// Converts a NameCase variant to the corresponding convert_case::Case variant.
687    ///
688    /// # Arguments
689    ///
690    /// * `value` - The NameCase variant to convert
691    ///
692    /// # Returns
693    ///
694    /// An Option containing the convert_case::Case variant, or None if no transformation needed.
695    fn from(value: &NameCase) -> Self {
696        match value {
697            NameCase::Pascal => Some(convert_case::Case::Pascal),
698            NameCase::Snake => Some(convert_case::Case::Snake),
699            NameCase::Kebab => Some(convert_case::Case::Kebab),
700            NameCase::Camel => Some(convert_case::Case::Camel),
701            NameCase::None => None,
702        }
703    }
704}
705
706#[cfg(test)]
707mod tests {
708    use super::*;
709    use std::path::PathBuf;
710
711    #[test]
712    fn test_has_wildcard_fname() {
713        let path = PathBuf::from("path/to/*.json");
714        let result = has_wildcard_fname(&path);
715        assert!(result);
716    }
717
718    #[test]
719    fn test_has_wildcard_fname_no_wildcard() {
720        let path = PathBuf::from("path/to/file.json");
721        let result = has_wildcard_fname(&path);
722        assert!(!result);
723    }
724
725    #[test]
726    fn test_build_models() {
727        let specs = vec![
728            PathBuf::from("tests/data/model.md"),
729            PathBuf::from("tests/data/model_merge.md"),
730        ];
731        let result = build_models(&specs);
732        assert!(result.is_ok());
733    }
734
735    #[test]
736    fn test_prepend_root() {
737        let mut gen_template = GenTemplate {
738            meta: Meta {
739                name: None,
740                description: None,
741                paths: vec![PathBuf::from("model.md")],
742            },
743            generate: HashMap::from_iter(vec![(
744                "json-schema".to_string(),
745                GenSpecs {
746                    description: None,
747                    out: PathBuf::from("schema.json"),
748                    root: None,
749                    per_spec: None,
750                    config: HashMap::new(),
751                    fname_case: None,
752                },
753            )]),
754        };
755
756        let path = PathBuf::from("tests/data");
757        gen_template.prepend_root(&path);
758
759        assert_eq!(
760            gen_template.meta.paths[0],
761            PathBuf::from("tests/data/model.md")
762        );
763        assert_eq!(
764            gen_template.generate["json-schema"].out,
765            PathBuf::from("tests/data/schema.json")
766        );
767    }
768}