ntro 0.3.4

Introspect configuration files and generate typescript type declarations or other useful typescript code.
Documentation
use colored::Colorize;
use serde_json::Value;
use std::{
    collections::BTreeMap,
    fmt::Display,
    fs::File,
    io::{BufReader, BufWriter, Write},
    ops::Deref,
    path::{Path, PathBuf},
    sync::Arc,
};

use anyhow::{anyhow, Context, Result};

use crate::command::prettify;

use super::{
    parse::{get_texts, parse_variables_with_type_hints, Variable},
    typehint_parser::TypeHint,
};

use thiserror::Error;

#[derive(Error, Debug)]
pub enum ParseError {
    #[error("\n{a}\n\nconflicts with:\n\n{b}\n")]
    ConflictingTypes { a: TypeHintAt, b: TypeHintAt },
}

#[derive(Debug)]
pub struct TypeHintAt {
    pub th: TypeHint,
    pub line: usize,
    pub meta: Metadata,
}

impl Display for TypeHintAt {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        let mut lines = self.meta.source.lines().skip(self.line);

        let curr_line = lines
            .next()
            .expect("assumption that the type hint was parsed along with its line number, failed");
        let next_line = lines
            .next()
            .expect("assumption that the type hint was parsed along with the variable it was decorating, failed");

        writeln!(f, "{}", self.meta.path.to_string_lossy().dimmed())?;
        writeln!(
            f,
            "  {}| {}",
            self.line + 1,
            curr_line
                .replace(
                    self.th.to_string().as_str(),
                    self.th.to_string().green().to_string().as_str()
                )
                .bold()
        )?;
        write!(f, "  {}| {}", self.line + 2, next_line)?;

        Ok(())
    }
}

impl From<(&Metadata, &(TypeHint, usize))> for TypeHintAt {
    fn from(value: (&Metadata, &(TypeHint, usize))) -> Self {
        Self {
            th: value.1 .0.clone(),
            meta: value.0.clone(),
            line: value.1 .1,
        }
    }
}

#[derive(Debug, Clone)]
pub struct Metadata {
    source: Arc<str>,
    path: Arc<Path>,
}

pub fn generate_zod_schema(files: &[PathBuf], import_dotenv: bool) -> Result<String> {
    let text_and_file_names = get_texts(files);

    let sources = text_and_file_names.iter().map(|(source, path)| Metadata {
        source: source.as_str().into(),
        path: path.as_path().into(),
    });

    generate_zod_schema_from_texts(sources, import_dotenv)
}

pub fn generate_zod_schema_from_texts(
    sources: impl Iterator<Item = Metadata>,
    import_dotenv: bool,
) -> Result<String> {
    let mut map: BTreeMap<String, (Variable, Metadata)> = BTreeMap::new();

    let variables = sources.flat_map(|meta| -> Vec<(Variable, Metadata)> {
        let vars = parse_variables_with_type_hints(meta.source.deref())
            .into_iter()
            .map(|var| (var, meta.clone()))
            .collect();

        return vars;
    });

    for (var, meta) in variables {
        if let Some((v, o_meta)) = map.get(&var.key) {
            if let (Some(lt), Some(rt)) = (&v.type_hint, &var.type_hint) {
                if lt.0 != rt.0 {
                    return Err(ParseError::ConflictingTypes {
                        a: (o_meta, lt).into(),
                        b: (&meta, rt).into(),
                    })
                    .context(
                        "found some conflicting types while parsing variables with type hints",
                    );
                }
            }
        }

        let no_type_hint_or_variable_yet = map
            .get(&var.key)
            .map(|(var, _)| var.type_hint.is_none())
            .unwrap_or(true);

        if no_type_hint_or_variable_yet {
            map.insert(var.key.clone(), (var, meta));
        }
    }

    let vars = map.into_values().collect::<Vec<_>>();

    let next_public_vars = vars
        .iter()
        .filter(|&v| v.0.is_next_public())
        .collect::<Vec<_>>();
    let other_vars = vars
        .iter()
        .filter(|v| !v.0.is_next_public())
        .collect::<Vec<_>>();

    let to_field_schema = |(var, meta): &&(Variable, Metadata)| -> String {
        format!(
            r#"    {}: {},"#,
            var.key,
            match &var.type_hint {
                Some(th) => {
                    let schema = match &th.0 {
                        super::typehint_parser::TypeHint::String => "z.string()".to_string(),
                        super::typehint_parser::TypeHint::Number => "z.coerce.number()".to_string(),
                        super::typehint_parser::TypeHint::Boolean => {
                            "z.coerce.boolean()".to_string()
                        }
                        super::typehint_parser::TypeHint::Union(values) => {
                            format!("z.enum([{}])", values.join(","))
                        }
                    };

                    // including comment for the file and the line from which
                    // to find the type hint
                    format!(
                        r#"{} /* from {:?} on line {} */"#,
                        schema,
                        meta.path,
                        th.1 + 1
                    )
                }
                None => "z.string()".to_string(),
            }
        )
    };

    let js_code = include_str!("module.ts");

    let js_import_line: &str = js_code
        .lines()
        .next()
        .expect("should have an import line at the top of the js implementation");

    let js_impl = js_code
        .lines()
        .skip_while(|line| !line.contains("/* --- MAIN IMPLEMENTATION BELOW --- */"))
        .skip(1)
        .collect::<Vec<_>>()
        .join("\n");

    let dotenv_import = if import_dotenv {
        "import dotenv from \"dotenv\";\ndotenv.configDotenv();"
    } else {
        ""
    };

    let output = format!(
        r#"
{js_import_line}
{dotenv_import}

const clientEnvSchemas = {{
{}
}}

const serverEnvSchemas = {{
    ...clientEnvSchemas,
{}
}}

{js_impl}

const processEnv = {{
{}
}}
               "#,
        next_public_vars
            .iter()
            .map(to_field_schema)
            .collect::<Vec<_>>()
            .join("\n"),
        other_vars
            .iter()
            .map(to_field_schema)
            .collect::<Vec<_>>()
            .join("\n"),
        vars.iter()
            .map(|var| format!("   {}: process.env.{},", var.0.key, var.0.key))
            .collect::<Vec<_>>()
            .join("\n"),
    );

    Ok(output)
}

pub fn add_tsconfig_path<P: AsRef<Path>>(path: P) -> Result<()> {
    let mut ts_config: Value = File::open("./tsconfig.json")
        .context("couldn't open tsconfig.json")
        .map(BufReader::new)
        .and_then(|reader| serde_json::from_reader(reader).context("failed to parse tsconfig.json"))
        .context("failed to read tsconfig.json")?;

    ts_config
        .get_mut("compilerOptions")
        .context("couldn't find compilerOptions in tsconfig.json")
        .and_then(|paths| {
            paths
                .get_mut("paths")
                .and_then(|node| node.as_object_mut())
                .map(|paths| {
                    paths.insert(
                        "$env".to_string(),
                        Value::Array(vec![Value::String(
                            path.as_ref().to_string_lossy().to_string(),
                        )]),
                    )
                })
                .ok_or(anyhow!("failed to add $env as a path on tsconfig.json"))
        })?;

    File::create("./tsconfig.json")
        .context("failed to open tsconfig.json")
        .map(BufWriter::new)
        .and_then(|mut w| {
            let new_content = serde_json::to_string::<Value>(&ts_config)?;
            let pretty = prettify(new_content.as_bytes(), ".json")?;
            w.write_all(&pretty)
                .context("failed to write to tsconfig.json")?;
            w.flush()
                .context("failed to flush updated tsconfig.json contents")
        })
        .context("failed to update tsconfig.json contents")?;

    Ok(())
}

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

    use insta::{assert_debug_snapshot, assert_display_snapshot};

    use crate::dotenv::zod::{generate_zod_schema, generate_zod_schema_from_texts};

    #[test]
    fn zod_schema_gen() {
        let output = generate_zod_schema(
            &[
                PathBuf::from("src/dotenv/.env.test"),
                PathBuf::from("src/dotenv/.env.test2"),
            ],
            false,
        )
        .unwrap();
        assert_display_snapshot!(output);
    }

    #[test]
    fn zod_schema_gen_with_type_conflicts() {
        let case = |s: &str| {
            format!(
                r#"
# @type {}
KEY=
            "#,
                s
            )
        };

        fn generate(sources: &[String]) -> Result<String, anyhow::Error> {
            let sources = sources.iter().cloned().enumerate().map(|(i, source)| {
                crate::dotenv::zod::Metadata {
                    source: source.as_str().into(),
                    path: Path::new(&format!("src/dotenv/.env.test.{}", i)).into(),
                }
            });

            generate_zod_schema_from_texts(sources, false)
        }

        fn gen_err(sources: &[String]) {
            let output = generate(sources).unwrap_err();
            assert_debug_snapshot!(output);
        }

        gen_err(&[case("string"), case("number")]);
        gen_err(&[case("number"), case("boolean")]);
        gen_err(&[case("string"), case("boolean")]);
        gen_err(&[case("'a' | 'b'"), case("number")]);

        gen_err(&[case("string"), case("boolean"), case("number")]);

        // This is not a conflict
        generate(&[case("string"), case("string")]).unwrap();
    }
}