ntro 0.3.4

Introspect configuration files and generate typescript type declarations or other useful typescript code.
Documentation
use std::{fs::File, io::BufReader, path::Path};

use anyhow::Result;
use serde::Deserialize;
use serde_yaml::Value;

pub fn generate_typescript_types(file: &Path) -> Result<String> {
    match parse_yaml(file)? {
        Parsed::One(document) => Ok(format!(
            "declare type {} = {:#}",
            file_name_to_type_name(
                file.file_stem()
                    .expect("couldn't parse a filename from input")
                    .to_str()
                    .expect("path given should be in utf-8")
            ),
            introspect_typescript_types(document)
        )),
        Parsed::Many(documents) => {
            let type_strings = documents
                .into_iter()
                .map(introspect_typescript_types)
                .collect::<Vec<_>>();

            let number_of_types = type_strings.len();

            Ok(format!(
                "declare namespace {} {{ {:#};\n export type All = [{:#}] }}",
                file_name_to_type_name(
                    file.file_stem()
                        .expect("couldn't parse a filename from input")
                        .to_str()
                        .expect("path given should be in utf-8")
                ),
                type_strings
                    .into_iter()
                    .enumerate()
                    .map(|(idx, text)| format!("export type Document{idx} = {text}"))
                    .collect::<Vec<_>>()
                    .join(";\n"),
                (0..number_of_types)
                    .map(|idx| format!("Document{idx}"))
                    .collect::<Vec<_>>()
                    .join(",")
            ))
        }
    }
}

enum Parsed {
    One(Value),
    Many(Vec<Value>),
}

fn parse_yaml(file: &Path) -> Result<Parsed> {
    let rdr = BufReader::new(File::open(file)?);
    let mut values = vec![];

    for doc in serde_yaml::Deserializer::from_reader(rdr) {
        let value = Value::deserialize(doc)?;
        values.push(value);
    }

    if values.len() == 1 {
        return Ok(Parsed::One(values[0].clone()));
    }

    Ok(Parsed::Many(values))
}

fn introspect_typescript_types(value: Value) -> String {
    match value {
        Value::Null => "null".to_string(),
        Value::Bool(b) => b.to_string(),
        Value::Number(n) => n.to_string(),
        Value::String(s) => format!("'{s}'"),
        Value::Sequence(s) => {
            let mut buf = String::new();
            buf.push('[');

            let elements: Vec<_> = s.into_iter().map(introspect_typescript_types).collect();

            buf.push_str(&elements.join(","));

            buf.push(']');
            buf
        }
        Value::Mapping(m) => {
            let mut buf = String::new();
            buf.push('{');

            let kvs: Vec<_> = m
                .into_iter()
                .map(|(key, value)| {
                    format!(
                        "{}: {}",
                        &introspect_typescript_types(key),
                        &introspect_typescript_types(value)
                    )
                })
                .collect();

            buf.push_str(&kvs.join(","));

            buf.push('}');
            buf
        }
        Value::Tagged(tv) => introspect_typescript_types(tv.value),
    }
}

fn file_name_to_type_name(fname: &str) -> String {
    fname
        .split(['-', '.'])
        .map(to_first_uppercase)
        .collect::<Vec<_>>()
        .join("")
}

fn to_first_uppercase(n: &str) -> String {
    let mut buf = n.to_owned();
    let fc = buf.get(0..1).unwrap_or_default().to_owned().to_uppercase();
    buf.replace_range(0..1, &fc);
    buf
}

#[cfg(test)]
mod tests {
    use std::path::Path;

    use insta::assert_display_snapshot;

    use super::{file_name_to_type_name, generate_typescript_types};

    #[test]
    fn file_name_to_type_name_conversion() {
        assert_eq!(file_name_to_type_name("test"), "Test".to_string());
        assert_eq!(
            file_name_to_type_name("test.config"),
            "TestConfig".to_string()
        );
        assert_eq!(
            file_name_to_type_name("test-config"),
            "TestConfig".to_string()
        );
        assert_eq!(
            file_name_to_type_name("test-config-tee.prod"),
            "TestConfigTeeProd".to_string()
        );
    }

    #[test]
    fn introspect_typescript_types_gen() {
        let output = generate_typescript_types(Path::new("src/test.yaml")).unwrap();
        assert_display_snapshot!(output);

        let output = generate_typescript_types(Path::new("src/test.multiple.yaml")).unwrap();
        assert_display_snapshot!(output)
    }
}