ironic 0.2.7

A batteries-included, type-safe application framework for Rust
Documentation
use std::{fs, path::Path};

use quote::quote;
use syn::{Ident, Token, Type, bracketed, parse::Parse, parse::ParseStream};

use crate::CliError;

pub(crate) fn write_generated(path: &Path, contents: &str) -> Result<bool, CliError> {
    if path.exists() {
        let existing =
            fs::read_to_string(path).map_err(|error| CliError::io("read", path, error))?;
        if existing == contents {
            return Ok(false);
        }
        return Err(CliError::FileConflict {
            path: path.to_owned(),
        });
    }
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent)
            .map_err(|error| CliError::io("create directory", parent, error))?;
    }
    fs::write(path, contents).map_err(|error| CliError::io("write", path, error))?;
    Ok(true)
}

pub(crate) fn write_module_shell(path: &Path, pascal: &str) -> Result<bool, CliError> {
    if !path.exists() {
        return write_generated(path, &super::templates::module(pascal));
    }
    let source = fs::read_to_string(path).map_err(|error| CliError::io("read", path, error))?;
    let file = syn::parse_file(&source).map_err(|error| CliError::SourceParse {
        path: path.to_owned(),
        message: error.to_string(),
    })?;
    let expected = format!("{pascal}Module");
    if file
        .items
        .iter()
        .any(|item| matches!(item, syn::Item::Struct(item) if item.ident == expected.as_str()))
    {
        Ok(false)
    } else {
        Err(CliError::FileConflict {
            path: path.to_owned(),
        })
    }
}

pub(crate) fn ensure_items(path: &Path, declarations: &[&str]) -> Result<bool, CliError> {
    let source = if path.exists() {
        fs::read_to_string(path).map_err(|error| CliError::io("read", path, error))?
    } else {
        String::new()
    };
    let mut file = syn::parse_file(&source).map_err(|error| CliError::SourceParse {
        path: path.to_owned(),
        message: error.to_string(),
    })?;
    let mut changed = false;
    for declaration in declarations {
        let item =
            syn::parse_str::<syn::Item>(declaration).map_err(|error| CliError::SourceParse {
                path: path.to_owned(),
                message: error.to_string(),
            })?;
        let canonical = quote::quote!(#item).to_string();
        let exists = file
            .items
            .iter()
            .any(|existing| quote::quote!(#existing).to_string() == canonical);
        if !exists {
            file.items.push(item);
            changed = true;
        }
    }
    if changed {
        if let Some(parent) = path.parent() {
            fs::create_dir_all(parent)
                .map_err(|error| CliError::io("create directory", parent, error))?;
        }
        fs::write(path, prettyplease::unparse(&file))
            .map_err(|error| CliError::io("write", path, error))?;
    }
    Ok(changed)
}

#[derive(Default)]
struct ModuleMetadata {
    imports: Vec<Type>,
    providers: Vec<Type>,
    controllers: Vec<Type>,
    exports: Vec<Type>,
}

impl Parse for ModuleMetadata {
    fn parse(input: ParseStream<'_>) -> syn::Result<Self> {
        let mut metadata = Self::default();
        while !input.is_empty() {
            let key = input.parse::<Ident>()?;
            input.parse::<Token![=]>()?;
            let content;
            bracketed!(content in input);
            let values = content
                .parse_terminated(Type::parse, Token![,])?
                .into_iter()
                .collect();
            match key.to_string().as_str() {
                "imports" => metadata.imports = values,
                "providers" => metadata.providers = values,
                "controllers" => metadata.controllers = values,
                "exports" => metadata.exports = values,
                _ => {
                    return Err(syn::Error::new_spanned(
                        key,
                        "unsupported module metadata key",
                    ));
                }
            }
            if !input.is_empty() {
                input.parse::<Token![,]>()?;
            }
        }
        Ok(metadata)
    }
}

pub(crate) fn ensure_module_import(path: &Path, import: &str) -> Result<bool, CliError> {
    let source = fs::read_to_string(path).map_err(|error| CliError::io("read", path, error))?;
    let mut file = syn::parse_file(&source).map_err(|error| CliError::SourceParse {
        path: path.to_owned(),
        message: error.to_string(),
    })?;
    let import = syn::parse_str::<Type>(import).map_err(|error| CliError::SourceParse {
        path: path.to_owned(),
        message: error.to_string(),
    })?;
    let mut candidates = file.items.iter_mut().filter_map(|item| {
        let syn::Item::Struct(item) = item else {
            return None;
        };
        item.attrs
            .iter_mut()
            .find(|attribute| attribute.path().is_ident("module"))
    });
    let Some(attribute) = candidates.next() else {
        return Err(CliError::SourceParse {
            path: path.to_owned(),
            message: "no `#[module(...)]` metadata found".to_owned(),
        });
    };
    if candidates.next().is_some() {
        return Err(CliError::SourceParse {
            path: path.to_owned(),
            message: "multiple module declarations are ambiguous".to_owned(),
        });
    }
    let mut metadata =
        attribute
            .parse_args::<ModuleMetadata>()
            .map_err(|error| CliError::SourceParse {
                path: path.to_owned(),
                message: error.to_string(),
            })?;
    let canonical = quote!(#import).to_string();
    if metadata
        .imports
        .iter()
        .any(|existing| quote!(#existing).to_string() == canonical)
    {
        return Ok(false);
    }
    metadata.imports.push(import);
    let imports = metadata.imports;
    let providers = metadata.providers;
    let controllers = metadata.controllers;
    let exports = metadata.exports;
    attribute.meta = syn::parse_quote!(module(
        imports = [#(#imports),*],
        providers = [#(#providers),*],
        controllers = [#(#controllers),*],
        exports = [#(#exports),*],
    ));
    fs::write(path, prettyplease::unparse(&file))
        .map_err(|error| CliError::io("write", path, error))?;
    Ok(true)
}