cargo-nidus 1.0.0

Command-line project generator and inspection tooling for Nidus applications.
use anyhow::{Result, bail};
use syn::{
    Expr, Lit, LitInt, MetaNameValue, PathArguments, Token, parse::Parser, punctuated::Punctuated,
};

#[derive(Debug)]
pub(crate) struct OpenApiMetadata {
    pub(crate) summary: String,
    pub(crate) tags: Vec<String>,
    pub(crate) response_status: Option<u16>,
    pub(crate) request_schema: Option<String>,
    pub(crate) response_schema: Option<String>,
}

pub(crate) fn parse_openapi_args(args: &str) -> Result<OpenApiMetadata> {
    let args = Punctuated::<MetaNameValue, Token![,]>::parse_terminated
        .parse_str(args)
        .map_err(|error| anyhow::anyhow!("{error}"))?;
    let mut summary = None;
    let mut tags = Vec::new();
    let mut response_status = None;
    let mut request_schema = None;
    let mut response_schema = None;

    for arg in args {
        if arg.path.is_ident("summary") {
            summary = Some(summary_literal(arg)?);
        } else if arg.path.is_ident("tags") {
            tags = tag_literals(arg)?;
        } else if arg.path.is_ident("request") {
            request_schema = Some(schema_name(&arg.value, "request")?);
        } else if arg.path.is_ident("response") {
            response_schema = Some(schema_name(&arg.value, "response")?);
        } else if arg.path.is_ident("status") {
            response_status = Some(response_status_code(&arg.value)?);
        } else {
            bail!(
                "#[openapi] supports only summary = \"...\", tags = [\"...\"], status = 201, request = Type, and response = Type metadata"
            );
        }
    }

    let Some(summary) = summary else {
        bail!("#[openapi] requires summary = \"...\" metadata");
    };
    Ok(OpenApiMetadata {
        summary,
        tags,
        response_status,
        request_schema,
        response_schema,
    })
}

fn summary_literal(arg: MetaNameValue) -> Result<String> {
    let Expr::Lit(expr_lit) = arg.value else {
        bail!("#[openapi] summary must be a string literal");
    };
    let Lit::Str(value) = expr_lit.lit else {
        bail!("#[openapi] summary must be a string literal");
    };
    Ok(value.value())
}

fn tag_literals(arg: MetaNameValue) -> Result<Vec<String>> {
    let Expr::Array(array) = arg.value else {
        bail!("#[openapi] tags must be an array of string literals");
    };

    let mut tags = Vec::new();
    for element in array.elems {
        let Expr::Lit(expr_lit) = element else {
            bail!("#[openapi] tags must be string literals");
        };
        let Lit::Str(tag) = expr_lit.lit else {
            bail!("#[openapi] tags must be string literals");
        };
        tags.push(tag.value());
    }
    Ok(tags)
}

fn schema_name(value: &Expr, name: &str) -> Result<String> {
    let Expr::Path(expr_path) = value else {
        bail!("#[openapi] {name} must be a type path");
    };
    let Some(segment) = expr_path.path.segments.last() else {
        bail!("#[openapi] {name} must be a type path");
    };
    if expr_path
        .path
        .segments
        .iter()
        .any(|segment| !matches!(segment.arguments, PathArguments::None))
    {
        bail!("#[openapi] {name} must be a type path");
    }
    Ok(segment.ident.to_string())
}

fn response_status_code(value: &Expr) -> Result<u16> {
    let Expr::Lit(expr_lit) = value else {
        bail!("#[openapi] status must be an HTTP status code integer literal");
    };
    let Lit::Int(status) = &expr_lit.lit else {
        bail!("#[openapi] status must be an HTTP status code integer literal");
    };
    parse_status_literal(status)
}

fn parse_status_literal(status: &LitInt) -> Result<u16> {
    let value = status.base10_parse::<u16>().map_err(|_| {
        anyhow::anyhow!("#[openapi] status must be an HTTP status code integer literal")
    })?;
    if !(100..=599).contains(&value) {
        bail!("#[openapi] status must be in the HTTP status code range 100..=599");
    }
    Ok(value)
}

#[cfg(test)]
mod tests {
    use super::parse_openapi_args;

    #[test]
    fn openapi_parser_accepts_commas_inside_string_literals() {
        let metadata = parse_openapi_args(
            r#"summary = "Find, inspect, and return user", tags = ["users,read", "public"]"#,
        )
        .unwrap();

        assert_eq!(metadata.summary, "Find, inspect, and return user");
        assert_eq!(metadata.tags, ["users,read", "public"]);
    }

    #[test]
    fn openapi_parser_extracts_type_names_from_paths() {
        let metadata = parse_openapi_args(
            "summary = \"Create user\", request = crate::dto::CreateUserDto, response = api::UserDto",
        )
        .unwrap();

        assert_eq!(metadata.request_schema.as_deref(), Some("CreateUserDto"));
        assert_eq!(metadata.response_schema.as_deref(), Some("UserDto"));
    }
}