apcore-toolkit 0.8.0

Shared scanner, schema extraction, and output toolkit for apcore framework adapters
Documentation
// Rust source-file generator.
//
// Generates language-native Rust handler stubs for each ScannedModule.
// Provides structural parity with the Python PythonWriter and TypeScript
// TypeScriptWriter; the generated files are starting-point stubs, not
// production-grade implementations.

use std::fs;
use std::path::PathBuf;

use tracing::debug;

use crate::output::errors::WriteError;
use crate::output::types::WriteResult;
use crate::types::ScannedModule;

/// Generates Rust source files from ScannedModule instances.
///
/// For each module, a `.rs` stub file is written under `output_dir`, with the
/// module-id dot-separated path converted to a directory hierarchy
/// (`users.get_user` → `users/get_user.rs`).
///
/// This writer provides structural parity with `PythonWriter` (Python SDK)
/// and `TypeScriptWriter` (TypeScript SDK).  The generated files are
/// starting-point stubs; callers are expected to fill in the `todo!` bodies.
pub struct RustWriter {
    /// Root directory under which stub `.rs` files are written.
    pub output_dir: PathBuf,
}

impl RustWriter {
    /// Create a new `RustWriter` targeting `output_dir`.
    pub fn new(output_dir: impl Into<PathBuf>) -> Self {
        Self {
            output_dir: output_dir.into(),
        }
    }

    /// Write Rust stub files for each `ScannedModule`.
    ///
    /// Returns one `WriteResult` per module.  I/O errors short-circuit with
    /// `Err(WriteError)`.
    pub fn write(&self, modules: &[ScannedModule]) -> Result<Vec<WriteResult>, WriteError> {
        let mut results: Vec<WriteResult> = Vec::new();

        for module in modules {
            let file_path = self.module_path(&module.module_id);

            // Ensure parent directory exists.
            if let Some(parent) = file_path.parent() {
                fs::create_dir_all(parent)
                    .map_err(|e| WriteError::io(parent.display().to_string(), e))?;
            }

            let content = generate_stub(module);
            fs::write(&file_path, content.as_bytes())
                .map_err(|e| WriteError::io(file_path.display().to_string(), e))?;

            debug!(file_path = %file_path.display(), "RustWriter: written");
            results.push(WriteResult::with_path(
                module.module_id.clone(),
                file_path.display().to_string(),
            ));
        }

        Ok(results)
    }

    /// Convert a dotted `module_id` to an output path under `output_dir`.
    ///
    /// `"users.get_user"` → `{output_dir}/users/get_user.rs`
    fn module_path(&self, module_id: &str) -> PathBuf {
        let mut path = self.output_dir.clone();
        let parts: Vec<&str> = module_id.split('.').collect();
        // All parts except the last become directory components.
        if let Some((last, dirs)) = parts.split_last() {
            for dir in dirs {
                path.push(dir);
            }
            path.push(format!("{last}.rs"));
        } else {
            path.push(format!("{module_id}.rs"));
        }
        path
    }
}

/// Generate the content of a Rust stub file for a single module.
fn generate_stub(module: &ScannedModule) -> String {
    let module_id = &module.module_id;
    let description = module.description.replace('"', "\\\"");

    format!(
        "// Auto-generated by apcore-toolkit RustWriter\n\
         // Module: {module_id}\n\
         // Description: {description}\n\
         // Do not edit manually unless you intend to customize the handler.\n\
         \n\
         use apcore::module;\n\
         \n\
         #[module(id = \"{module_id}\", description = \"{description}\")]\n\
         pub async fn handler(\n\
             inputs: serde_json::Value,\n\
         ) -> serde_json::Value {{\n\
             todo!(\"Implement handler for {module_id}\")\n\
         }}\n"
    )
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;
    use tempfile::TempDir;

    fn sample_module() -> ScannedModule {
        ScannedModule::new(
            "users.get_user".into(),
            "Get a user by id".into(),
            json!({"type": "object", "properties": {"id": {"type": "integer"}}}),
            json!({"type": "object"}),
            vec!["users".into()],
            "myapp.views:get_user".into(),
        )
    }

    #[test]
    fn test_rust_writer_generates_file_with_module_id() {
        let dir = TempDir::new().unwrap();
        let writer = RustWriter::new(dir.path());
        let modules = vec![sample_module()];
        let results = writer.write(&modules).unwrap();

        assert_eq!(results.len(), 1);
        let path = results[0].path.as_deref().expect("path should be set");
        assert!(
            std::path::Path::new(path).exists(),
            "file must exist: {path}"
        );

        let content = fs::read_to_string(path).unwrap();
        assert!(
            content.contains("users.get_user"),
            "stub must contain module_id: {content}"
        );
        assert!(
            content.contains("Auto-generated"),
            "stub must contain header: {content}"
        );
    }

    #[test]
    fn test_rust_writer_creates_nested_directory() {
        // "users.get_user" → users/get_user.rs — parent dir must be created.
        let dir = TempDir::new().unwrap();
        let writer = RustWriter::new(dir.path());
        let modules = vec![sample_module()];
        let results = writer.write(&modules).unwrap();

        let path = results[0].path.as_deref().unwrap();
        // The file should be at .../users/get_user.rs
        assert!(
            path.ends_with("users/get_user.rs"),
            "unexpected path: {path}"
        );
        assert!(std::path::Path::new(path).exists());
    }

    #[test]
    fn test_rust_writer_module_path_flat() {
        // A module_id with no dots should produce a single file at the root.
        let dir = TempDir::new().unwrap();
        let writer = RustWriter::new(dir.path());
        let module = ScannedModule::new(
            "hello".into(),
            "Simple module".into(),
            json!({"type": "object"}),
            json!({"type": "object"}),
            vec![],
            "app:hello".into(),
        );
        let results = writer.write(&[module]).unwrap();
        let path = results[0].path.as_deref().unwrap();
        assert!(path.ends_with("hello.rs"), "unexpected path: {path}");
    }

    #[test]
    fn test_rust_writer_empty_modules() {
        let dir = TempDir::new().unwrap();
        let writer = RustWriter::new(dir.path());
        let results = writer.write(&[]).unwrap();
        assert!(results.is_empty());
    }

    #[test]
    fn test_rust_writer_multiple_modules() {
        let dir = TempDir::new().unwrap();
        let writer = RustWriter::new(dir.path());
        let modules = vec![
            sample_module(),
            ScannedModule::new(
                "tasks.list".into(),
                "List tasks".into(),
                json!({"type": "object"}),
                json!({"type": "object"}),
                vec![],
                "app:list_tasks".into(),
            ),
        ];
        let results = writer.write(&modules).unwrap();
        assert_eq!(results.len(), 2);
        for r in &results {
            let path = r.path.as_deref().unwrap();
            assert!(
                std::path::Path::new(path).exists(),
                "file must exist: {path}"
            );
        }
    }
}