actr-cli 0.2.1

Command line tool for Actor-RTC framework projects
Documentation
use crate::error::{ActrCliError, Result};
use crate::utils::to_snake_case;
use actr_config::ManifestConfig;
use actr_protocol::ActrType;
use std::collections::HashMap;
use std::path::{Path, PathBuf};

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProtoSide {
    Local,
    Remote,
}

#[derive(Debug, Clone)]
pub struct ProtoModel {
    pub files: Vec<ProtoFileModel>,
    pub local_services: Vec<ServiceModel>,
    pub remote_services: Vec<ServiceModel>,
}

#[derive(Debug, Clone)]
pub struct ProtoFileModel {
    pub proto_file: PathBuf,
    pub relative_path: PathBuf,
    pub package: String,
    pub side: ProtoSide,
    pub declared_type_names: Vec<String>,
    pub services: Vec<ServiceModel>,
}

#[derive(Debug, Clone)]
pub struct ServiceModel {
    pub name: String,
    pub package: String,
    pub proto_file: PathBuf,
    pub relative_path: PathBuf,
    pub side: ProtoSide,
    pub methods: Vec<MethodModel>,
    pub actr_type: Option<String>,
}

#[derive(Debug, Clone)]
pub struct MethodModel {
    pub name: String,
    pub snake_name: String,
    pub input_type: String,
    pub output_type: String,
    pub route_key: String,
}

impl ProtoModel {
    pub fn parse(
        proto_files: &[PathBuf],
        input_path: &Path,
        config: &ManifestConfig,
    ) -> Result<Self> {
        let proto_root = if input_path.is_file() {
            input_path.parent().unwrap_or_else(|| Path::new("."))
        } else {
            input_path
        };

        let dependency_actr_types: HashMap<String, String> = config
            .dependencies
            .iter()
            .filter_map(|dependency| {
                dependency
                    .actr_type
                    .as_ref()
                    .map(|actr_type| (dependency.alias.clone(), actr_type.to_string_repr()))
            })
            .collect();

        let default_manufacturer = config.package.actr_type.manufacturer.clone();

        let mut files = Vec::new();
        let mut local_services = Vec::new();
        let mut remote_services = Vec::new();

        for proto_file in proto_files {
            let relative_path = proto_file
                .strip_prefix(proto_root)
                .unwrap_or(proto_file)
                .to_path_buf();
            let side = classify_proto_side(&relative_path);
            let parsed = parse_proto_file(proto_file)?;

            let remote_actr_type = if side == ProtoSide::Remote {
                infer_remote_actr_type(
                    &relative_path,
                    &dependency_actr_types,
                    &default_manufacturer,
                    parsed.services.first().map(|service| service.name.as_str()),
                )
            } else {
                None
            };

            let services: Vec<ServiceModel> = parsed
                .services
                .into_iter()
                .map(|service| {
                    let service_model = ServiceModel {
                        name: service.name,
                        package: parsed.package.clone(),
                        proto_file: proto_file.clone(),
                        relative_path: relative_path.clone(),
                        side,
                        methods: service.methods,
                        actr_type: remote_actr_type.clone(),
                    };

                    if side == ProtoSide::Local {
                        local_services.push(service_model.clone());
                    } else {
                        remote_services.push(service_model.clone());
                    }

                    service_model
                })
                .collect();

            files.push(ProtoFileModel {
                proto_file: proto_file.clone(),
                relative_path,
                package: parsed.package,
                side,
                declared_type_names: parsed.declared_type_names,
                services,
            });
        }

        Ok(Self {
            files,
            local_services,
            remote_services,
        })
    }
}

#[derive(Debug)]
struct ParsedProtoFile {
    package: String,
    declared_type_names: Vec<String>,
    services: Vec<ParsedService>,
}

#[derive(Debug)]
struct ParsedService {
    name: String,
    methods: Vec<MethodModel>,
}

fn classify_proto_side(relative_path: &Path) -> ProtoSide {
    let first_component = relative_path
        .components()
        .next()
        .and_then(|component| component.as_os_str().to_str());

    if first_component == Some("remote") {
        ProtoSide::Remote
    } else {
        ProtoSide::Local
    }
}

fn infer_remote_actr_type(
    relative_path: &Path,
    dependency_actr_types: &HashMap<String, String>,
    default_manufacturer: &str,
    service_name: Option<&str>,
) -> Option<String> {
    let alias = relative_path
        .components()
        .nth(1)
        .and_then(|component| component.as_os_str().to_str());

    if let Some(alias) = alias
        && let Some(actr_type) = dependency_actr_types.get(alias)
    {
        return Some(actr_type.clone());
    }

    service_name.map(|service_name| {
        ActrType {
            manufacturer: default_manufacturer.to_string(),
            name: service_name.to_string(),
            version: "1.0.0".to_string(),
        }
        .to_string_repr()
    })
}

fn parse_proto_file(proto_file: &Path) -> Result<ParsedProtoFile> {
    let content = std::fs::read_to_string(proto_file).map_err(|e| {
        ActrCliError::config_error(format!(
            "Failed to read proto file {}: {e}",
            proto_file.display()
        ))
    })?;

    let mut package = String::new();
    let mut declared_type_names = Vec::new();
    let mut current_service: Option<ParsedService> = None;
    let mut services = Vec::new();

    for raw_line in content.lines() {
        let line = raw_line.trim();
        if line.is_empty() || line.starts_with("//") {
            continue;
        }

        if let Some(rest) = line.strip_prefix("package ") {
            package = rest
                .trim_end_matches(';')
                .split_whitespace()
                .next()
                .unwrap_or_default()
                .to_string();
            continue;
        }

        if let Some(rest) = line.strip_prefix("service ") {
            if let Some(service) = current_service.take() {
                services.push(service);
            }

            let name = rest
                .split(|character: char| character.is_whitespace() || character == '{')
                .find(|segment| !segment.is_empty())
                .unwrap_or_default()
                .to_string();

            if !name.is_empty() {
                declared_type_names.push(name.clone());
                current_service = Some(ParsedService {
                    name,
                    methods: Vec::new(),
                });
            }
            continue;
        }

        if let Some(name) = extract_declared_type_name(line, "message ") {
            declared_type_names.push(name);
            continue;
        }

        if let Some(name) = extract_declared_type_name(line, "enum ") {
            declared_type_names.push(name);
            continue;
        }

        if let Some(rest) = line.strip_prefix("rpc ")
            && let Some(service) = current_service.as_mut()
        {
            if let Some(method) = parse_rpc_method(rest, &package, &service.name) {
                service.methods.push(method);
            }
            continue;
        }

        if line.starts_with('}')
            && let Some(service) = current_service.take()
        {
            services.push(service);
        }
    }

    if let Some(service) = current_service.take() {
        services.push(service);
    }

    Ok(ParsedProtoFile {
        package,
        declared_type_names,
        services,
    })
}

fn parse_rpc_method(rest: &str, package: &str, service_name: &str) -> Option<MethodModel> {
    let input_start = rest.find('(')?;
    let method_name = rest[..input_start]
        .split_whitespace()
        .next()
        .unwrap_or_default()
        .to_string();
    if method_name.is_empty() {
        return None;
    }

    let after_input_start = &rest[input_start + 1..];
    let input_end = after_input_start.find(')')?;
    let input_type = normalize_proto_type(&after_input_start[..input_end]);

    let returns_pos = after_input_start.find("returns")?;
    let after_returns = &after_input_start[returns_pos + "returns".len()..];
    let output_start = after_returns.find('(')?;
    let output_end = after_returns[output_start + 1..].find(')')?;
    let output_type =
        normalize_proto_type(&after_returns[output_start + 1..output_start + 1 + output_end]);

    let route_key = if package.is_empty() {
        format!("{service_name}.{method_name}")
    } else {
        format!("{package}.{service_name}.{method_name}")
    };

    Some(MethodModel {
        snake_name: to_snake_case(&method_name),
        name: method_name,
        input_type,
        output_type,
        route_key,
    })
}

fn normalize_proto_type(raw_type: &str) -> String {
    raw_type.trim().trim_start_matches('.').to_string()
}

fn extract_declared_type_name(line: &str, prefix: &str) -> Option<String> {
    let rest = line.strip_prefix(prefix)?;
    let name = rest
        .split(|character: char| character.is_whitespace() || character == '{')
        .find(|segment| !segment.is_empty())?;
    Some(name.to_string())
}