ark-cli 0.1.2

Architectural boundary enforcer for .NET solutions
use crate::commands::init::scan::LayerDef;
use miette::Result;

pub struct WizardAnswers {
    pub layers: Vec<LayerDef>,
    pub rules: Vec<(String, String, bool)>,
    pub ignore_patterns: Vec<String>,
    pub package_policies: Vec<(String, String)>,
}

/// Escapes special characters in a string for TOML output.
/// Escapes backslashes and quotes so the result can be safely embedded in TOML string values.
fn toml_escape(s: &str) -> String {
    s.replace('\\', "\\\\").replace('"', "\\\"")
}

pub fn build_toml(answers: &WizardAnswers) -> Result<String> {
    let mut out = String::from("# Generated by `ark init`\n\n");

    out.push_str("layers = [\n");
    for layer in &answers.layers {
        let pats = infer_patterns(&layer.projects)
            .iter()
            .map(|p| format!("\"{}\"", toml_escape(p)))
            .collect::<Vec<_>>()
            .join(", ");
        out.push_str(&format!(
            "  {{ name = \"{}\", patterns = [{}] }},\n",
            toml_escape(&layer.name),
            pats
        ));
    }
    out.push_str("]\n\n");

    out.push_str("# Any dependency not listed here is forbidden by default.\n");
    out.push_str("dependency_rules = [\n");
    for (from, to, allowed) in &answers.rules {
        out.push_str(&format!(
            "  {{ from = \"{}\", to = \"{}\", allowed = {} }},\n",
            toml_escape(from),
            toml_escape(to),
            allowed
        ));
    }
    out.push_str("]\n");

    if !answers.package_policies.is_empty() {
        out.push('\n');
        out.push_str("package_policies = [\n");
        for (layer, pkg) in &answers.package_policies {
            out.push_str(&format!(
                "  {{ layer = \"{}\", forbidden = [\"{}\"] }},\n",
                toml_escape(layer),
                toml_escape(pkg)
            ));
        }
        out.push_str("]\n");
    }

    if !answers.ignore_patterns.is_empty() {
        out.push('\n');
        let pats = answers
            .ignore_patterns
            .iter()
            .map(|p| format!("\"{}\"", toml_escape(p)))
            .collect::<Vec<_>>()
            .join(", ");
        out.push_str(&format!("ignore_patterns = [{}]\n", pats));
    }

    Ok(out)
}

pub fn infer_patterns(projects: &[String]) -> Vec<String> {
    let mut patterns: Vec<String> = Vec::new();
    for project in projects {
        let pat = if let Some(pos) = project.rfind('.') {
            format!("*{}", &project[pos..])
        } else {
            project.clone()
        };
        if !patterns.contains(&pat) {
            patterns.push(pat);
        }
    }
    patterns
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::commands::init::scan::LayerDef;

    #[test]
    fn infers_glob_from_suffix() {
        let patterns = infer_patterns(&["MyApp.Domain".into(), "OtherApp.Domain".into()]);
        assert_eq!(patterns, vec!["*.Domain"]);
    }

    #[test]
    fn multiple_suffixes_multiple_patterns() {
        let patterns = infer_patterns(&["MyApp.Domain".into(), "MyApp.Core".into()]);
        assert!(patterns.contains(&"*.Domain".to_string()));
        assert!(patterns.contains(&"*.Core".to_string()));
    }

    #[test]
    fn no_dot_uses_exact_name() {
        let patterns = infer_patterns(&["Shared".into()]);
        assert_eq!(patterns, vec!["Shared"]);
    }

    #[test]
    fn build_toml_contains_expected_sections() {
        let answers = WizardAnswers {
            layers: vec![
                LayerDef {
                    name: "Domain".into(),
                    projects: vec!["MyApp.Domain".into()],
                },
                LayerDef {
                    name: "Presentation".into(),
                    projects: vec!["MyApp.Api".into()],
                },
            ],
            rules: vec![("Presentation".into(), "Domain".into(), true)],
            ignore_patterns: vec!["*.Tests".into()],
            package_policies: vec![],
        };
        let toml = build_toml(&answers).unwrap();
        assert!(toml.contains("name = \"Domain\""));
        assert!(toml.contains("name = \"Presentation\""));
        assert!(toml.contains("allowed = true"));
        assert!(toml.contains("*.Tests"));
    }

    #[test]
    fn build_toml_escapes_special_chars() {
        let answers = WizardAnswers {
            layers: vec![LayerDef {
                name: r#"My"Layer"#.into(),
                projects: vec![r#"My"App.Domain"#.into()],
            }],
            rules: vec![],
            ignore_patterns: vec![],
            package_policies: vec![],
        };
        let toml = build_toml(&answers).unwrap();
        // Must not contain unescaped quote after name =
        assert!(toml.contains(r#"name = "My\"Layer""#));
        // Must be parseable by the toml crate
        toml::from_str::<toml::Value>(&toml).expect("output must be valid TOML");
    }
}