Skip to main content

apcore_toolkit/output/
rust_writer.rs

1// Rust source-file generator.
2//
3// Generates language-native Rust handler stubs for each ScannedModule.
4// Provides structural parity with the Python PythonWriter and TypeScript
5// TypeScriptWriter; the generated files are starting-point stubs, not
6// production-grade implementations.
7
8use std::fs;
9use std::path::PathBuf;
10
11use tracing::debug;
12
13use crate::output::errors::WriteError;
14use crate::output::types::WriteResult;
15use crate::types::ScannedModule;
16
17/// Generates Rust source files from ScannedModule instances.
18///
19/// For each module, a `.rs` stub file is written under `output_dir`, with the
20/// module-id dot-separated path converted to a directory hierarchy
21/// (`users.get_user` → `users/get_user.rs`).
22///
23/// This writer provides structural parity with `PythonWriter` (Python SDK)
24/// and `TypeScriptWriter` (TypeScript SDK).  The generated files are
25/// starting-point stubs; callers are expected to fill in the `todo!` bodies.
26pub struct RustWriter {
27    /// Root directory under which stub `.rs` files are written.
28    pub output_dir: PathBuf,
29}
30
31impl RustWriter {
32    /// Create a new `RustWriter` targeting `output_dir`.
33    pub fn new(output_dir: impl Into<PathBuf>) -> Self {
34        Self {
35            output_dir: output_dir.into(),
36        }
37    }
38
39    /// Write Rust stub files for each `ScannedModule`.
40    ///
41    /// Returns one `WriteResult` per module.  I/O errors short-circuit with
42    /// `Err(WriteError)`.
43    pub fn write(&self, modules: &[ScannedModule]) -> Result<Vec<WriteResult>, WriteError> {
44        let mut results: Vec<WriteResult> = Vec::new();
45
46        for module in modules {
47            let file_path = self.module_path(&module.module_id);
48
49            // Ensure parent directory exists.
50            if let Some(parent) = file_path.parent() {
51                fs::create_dir_all(parent)
52                    .map_err(|e| WriteError::io(parent.display().to_string(), e))?;
53            }
54
55            let content = generate_stub(module);
56            fs::write(&file_path, content.as_bytes())
57                .map_err(|e| WriteError::io(file_path.display().to_string(), e))?;
58
59            debug!(file_path = %file_path.display(), "RustWriter: written");
60            results.push(WriteResult::with_path(
61                module.module_id.clone(),
62                file_path.display().to_string(),
63            ));
64        }
65
66        Ok(results)
67    }
68
69    /// Convert a dotted `module_id` to an output path under `output_dir`.
70    ///
71    /// `"users.get_user"` → `{output_dir}/users/get_user.rs`
72    fn module_path(&self, module_id: &str) -> PathBuf {
73        let mut path = self.output_dir.clone();
74        let parts: Vec<&str> = module_id.split('.').collect();
75        // All parts except the last become directory components.
76        if let Some((last, dirs)) = parts.split_last() {
77            for dir in dirs {
78                path.push(dir);
79            }
80            path.push(format!("{last}.rs"));
81        } else {
82            path.push(format!("{module_id}.rs"));
83        }
84        path
85    }
86}
87
88/// Generate the content of a Rust stub file for a single module.
89fn generate_stub(module: &ScannedModule) -> String {
90    let module_id = &module.module_id;
91    let description = module.description.replace('"', "\\\"");
92
93    format!(
94        "// Auto-generated by apcore-toolkit RustWriter\n\
95         // Module: {module_id}\n\
96         // Description: {description}\n\
97         // Do not edit manually unless you intend to customize the handler.\n\
98         \n\
99         use apcore::module;\n\
100         \n\
101         #[module(id = \"{module_id}\", description = \"{description}\")]\n\
102         pub async fn handler(\n\
103             inputs: serde_json::Value,\n\
104         ) -> serde_json::Value {{\n\
105             todo!(\"Implement handler for {module_id}\")\n\
106         }}\n"
107    )
108}
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113    use serde_json::json;
114    use tempfile::TempDir;
115
116    fn sample_module() -> ScannedModule {
117        ScannedModule::new(
118            "users.get_user".into(),
119            "Get a user by id".into(),
120            json!({"type": "object", "properties": {"id": {"type": "integer"}}}),
121            json!({"type": "object"}),
122            vec!["users".into()],
123            "myapp.views:get_user".into(),
124        )
125    }
126
127    #[test]
128    fn test_rust_writer_generates_file_with_module_id() {
129        let dir = TempDir::new().unwrap();
130        let writer = RustWriter::new(dir.path());
131        let modules = vec![sample_module()];
132        let results = writer.write(&modules).unwrap();
133
134        assert_eq!(results.len(), 1);
135        let path = results[0].path.as_deref().expect("path should be set");
136        assert!(
137            std::path::Path::new(path).exists(),
138            "file must exist: {path}"
139        );
140
141        let content = fs::read_to_string(path).unwrap();
142        assert!(
143            content.contains("users.get_user"),
144            "stub must contain module_id: {content}"
145        );
146        assert!(
147            content.contains("Auto-generated"),
148            "stub must contain header: {content}"
149        );
150    }
151
152    #[test]
153    fn test_rust_writer_creates_nested_directory() {
154        // "users.get_user" → users/get_user.rs — parent dir must be created.
155        let dir = TempDir::new().unwrap();
156        let writer = RustWriter::new(dir.path());
157        let modules = vec![sample_module()];
158        let results = writer.write(&modules).unwrap();
159
160        let path = results[0].path.as_deref().unwrap();
161        // The file should be at .../users/get_user.rs
162        assert!(
163            path.ends_with("users/get_user.rs"),
164            "unexpected path: {path}"
165        );
166        assert!(std::path::Path::new(path).exists());
167    }
168
169    #[test]
170    fn test_rust_writer_module_path_flat() {
171        // A module_id with no dots should produce a single file at the root.
172        let dir = TempDir::new().unwrap();
173        let writer = RustWriter::new(dir.path());
174        let module = ScannedModule::new(
175            "hello".into(),
176            "Simple module".into(),
177            json!({"type": "object"}),
178            json!({"type": "object"}),
179            vec![],
180            "app:hello".into(),
181        );
182        let results = writer.write(&[module]).unwrap();
183        let path = results[0].path.as_deref().unwrap();
184        assert!(path.ends_with("hello.rs"), "unexpected path: {path}");
185    }
186
187    #[test]
188    fn test_rust_writer_empty_modules() {
189        let dir = TempDir::new().unwrap();
190        let writer = RustWriter::new(dir.path());
191        let results = writer.write(&[]).unwrap();
192        assert!(results.is_empty());
193    }
194
195    #[test]
196    fn test_rust_writer_multiple_modules() {
197        let dir = TempDir::new().unwrap();
198        let writer = RustWriter::new(dir.path());
199        let modules = vec![
200            sample_module(),
201            ScannedModule::new(
202                "tasks.list".into(),
203                "List tasks".into(),
204                json!({"type": "object"}),
205                json!({"type": "object"}),
206                vec![],
207                "app:list_tasks".into(),
208            ),
209        ];
210        let results = writer.write(&modules).unwrap();
211        assert_eq!(results.len(), 2);
212        for r in &results {
213            let path = r.path.as_deref().unwrap();
214            assert!(
215                std::path::Path::new(path).exists(),
216                "file must exist: {path}"
217            );
218        }
219    }
220}