1pub mod cargo;
8pub mod manifest;
9pub mod mcp;
10pub mod rust_cli;
11pub mod rust_lib;
12pub mod skill;
13
14use std::fs;
15use std::path::{Path, PathBuf};
16
17use serde::{Deserialize, Serialize};
18
19use crate::error::{GenError, Result};
20use crate::ir::{ApiKind, ApiSpec};
21
22#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct EmitReport {
28 pub crate_dir: PathBuf,
30 pub files: Vec<PathBuf>,
32}
33
34impl EmitReport {
35 pub fn file_named(&self, name: &str) -> Option<&Path> {
37 self.files
38 .iter()
39 .find(|p| p.file_name().and_then(|s| s.to_str()) == Some(name))
40 .map(PathBuf::as_path)
41 }
42}
43
44pub fn emit_crate(spec: &ApiSpec, output_dir: &Path) -> Result<EmitReport> {
59 ensure_dir(output_dir)?;
60 let src_dir = output_dir.join("src");
61 ensure_dir(&src_dir)?;
62
63 let mut report = EmitReport {
64 crate_dir: output_dir.to_path_buf(),
65 files: Vec::new(),
66 };
67
68 if spec.kind == ApiKind::Grpc {
69 let proto_dir = output_dir.join("proto");
70 ensure_dir(&proto_dir)?;
71 if let Some(raw) = &spec.raw_spec {
72 write_file(&proto_dir.join("schema.proto"), raw, &mut report)?;
73 }
74 let build_rs = r##"fn main() -> Result<(), Box<dyn std::error::Error>> {
75 tonic_build::configure()
76 .type_attribute(".", "#[derive(serde::Serialize, serde::Deserialize)]")
77 .compile_protos(&["proto/schema.proto"], &["proto"])?;
78 Ok(())
79}
80"##;
81 write_file(&output_dir.join("build.rs"), build_rs, &mut report)?;
82 }
83
84 write_file(
85 &output_dir.join("Cargo.toml"),
86 &cargo::render(spec),
87 &mut report,
88 )?;
89 write_file(
90 &src_dir.join("lib.rs"),
91 &rust_lib::render(spec),
92 &mut report,
93 )?;
94 write_file(
95 &src_dir.join("main.rs"),
96 &rust_cli::render(spec),
97 &mut report,
98 )?;
99 write_file(
100 &output_dir.join("SKILL.md"),
101 &skill::render(spec),
102 &mut report,
103 )?;
104 write_file(
105 &output_dir.join("mcp.json"),
106 &mcp::render(spec)?,
107 &mut report,
108 )?;
109 write_file(
110 &output_dir.join("module.json"),
111 &manifest::render(spec)?,
112 &mut report,
113 )?;
114
115 if spec.kind == ApiKind::Grpc && spec.name == "demo" {
116 let smoke_test_dir = output_dir.join("tests");
117 ensure_dir(&smoke_test_dir)?;
118 let smoke_test_code = render_grpc_smoke_test(spec);
119 write_file(
120 &smoke_test_dir.join("smoke.rs"),
121 &smoke_test_code,
122 &mut report,
123 )?;
124 }
125
126 if spec.kind == ApiKind::GraphQl && spec.name == "gql_demo" {
127 let smoke_test_dir = output_dir.join("tests");
128 ensure_dir(&smoke_test_dir)?;
129 let smoke_test_code = render_graphql_smoke_test(spec);
130 write_file(
131 &smoke_test_dir.join("smoke.rs"),
132 &smoke_test_code,
133 &mut report,
134 )?;
135 }
136
137 Ok(report)
138}
139
140fn render_grpc_smoke_test(spec: &ApiSpec) -> String {
141 format!(
142 r#"use tokio::sync::oneshot;
143use tonic::{{transport::Server, Request, Response, Status}};
144
145use {name}::{{proto, Client, SayRequest, SayResponse}};
146
147#[derive(Debug, Default)]
148pub struct MockEchoServer;
149
150#[tonic::async_trait]
151impl proto::echo_server::Echo for MockEchoServer {{
152 async fn say(
153 &self,
154 request: Request<SayRequest>,
155 ) -> Result<Response<SayResponse>, Status> {{
156 let req = request.into_inner();
157 Ok(Response::new(SayResponse {{
158 echo: format!("echo: {{}}", req.text),
159 history: vec![req.text.clone()],
160 }}))
161 }}
162
163 async fn say_many(
164 &self,
165 request: Request<SayRequest>,
166 ) -> Result<Response<SayResponse>, Status> {{
167 let req = request.into_inner();
168 Ok(Response::new(SayResponse {{
169 echo: format!("many: {{}}", req.text),
170 history: vec![req.text.clone()],
171 }}))
172 }}
173
174 type StreamBackStream = tokio_stream::wrappers::ReceiverStream<Result<SayResponse, Status>>;
175 async fn stream_back(
176 &self,
177 _request: Request<SayRequest>,
178 ) -> Result<Response<Self::StreamBackStream>, Status> {{
179 Err(Status::unimplemented("stream_back"))
180 }}
181
182 type ChatStream = tokio_stream::wrappers::ReceiverStream<Result<SayResponse, Status>>;
183 async fn chat(
184 &self,
185 _request: Request<tonic::Streaming<SayRequest>>,
186 ) -> Result<Response<Self::ChatStream>, Status> {{
187 Err(Status::unimplemented("chat"))
188 }}
189}}
190
191#[tokio::test]
192async fn test_grpc_smoke() {{
193 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
194 let addr = listener.local_addr().unwrap();
195 let service = proto::echo_server::EchoServer::new(MockEchoServer::default());
196
197 let (tx, rx) = oneshot::channel::<()>();
198
199 let server_handle = tokio::spawn(async move {{
200 Server::builder()
201 .add_service(service)
202 .serve_with_incoming_shutdown(
203 tokio_stream::wrappers::TcpListenerStream::new(listener),
204 async {{
205 let _ = rx.await;
206 }},
207 )
208 .await
209 .unwrap();
210 }});
211
212 // Create client and call method
213 let client = Client::new(format!("http://{{}}", addr));
214 let req = SayRequest {{
215 text: "hello".to_string(),
216 repeat: 1,
217 }};
218 let res = client.say(req).await.unwrap();
219 assert_eq!(res.echo, "echo: hello");
220 assert_eq!(res.history, vec!["hello".to_string()]);
221
222 let req_many = SayRequest {{
223 text: "world".to_string(),
224 repeat: 2,
225 }};
226 let res_many = client.say_many(req_many).await.unwrap();
227 assert_eq!(res_many.echo, "many: world");
228
229 // Shutdown server
230 let _ = tx.send(());
231 let _ = server_handle.await;
232}}
233"#,
234 name = spec.name
235 )
236}
237
238fn render_graphql_smoke_test(spec: &ApiSpec) -> String {
239 format!(
240 r##"#![allow(unused_imports)]
241use tokio::sync::oneshot;
242use tokio::net::TcpListener;
243use futures_util::{{SinkExt, StreamExt}};
244use tokio_tungstenite::accept_async;
245use tokio_tungstenite::tungstenite::Message;
246
247use {name}::{{Client, Post, User, Role}};
248
249#[tokio::test]
250async fn test_graphql_subscription_smoke() {{
251 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
252 let addr = listener.local_addr().unwrap();
253
254 let (tx, rx) = oneshot::channel::<()>();
255
256 let server_handle = tokio::spawn(async move {{
257 let (stream, _) = listener.accept().await.unwrap();
258 let mut ws_stream = accept_async(stream).await.unwrap();
259
260 // 1. Read connection_init
261 if let Some(Ok(Message::Text(text))) = ws_stream.next().await {{
262 let init: serde_json::Value = serde_json::from_str(&text).unwrap();
263 assert_eq!(init["type"], "connection_init");
264 }}
265
266 // Send connection_ack
267 ws_stream
268 .send(Message::Text(r#"{{"type":"connection_ack"}}"#.into()))
269 .await
270 .unwrap();
271
272 // 2. Read subscribe
273 if let Some(Ok(Message::Text(text))) = ws_stream.next().await {{
274 let sub: serde_json::Value = serde_json::from_str(&text).unwrap();
275 assert_eq!(sub["type"], "subscribe");
276 assert_eq!(sub["id"], "sub_1");
277 }}
278
279 // Send next item
280 let item_payload = serde_json::json!({{
281 "type": "next",
282 "id": "sub_1",
283 "payload": {{
284 "data": {{
285 "postCreated": {{
286 "id": "1",
287 "title": "Hello World",
288 "body": "Smoke test body",
289 "author": {{
290 "id": "1",
291 "name": "Alice",
292 "email": "alice@example.com",
293 "role": "ADMIN"
294 }}
295 }}
296 }}
297 }}
298 }});
299 ws_stream
300 .send(Message::Text(serde_json::to_string(&item_payload).unwrap().into()))
301 .await
302 .unwrap();
303
304 // Send complete
305 ws_stream
306 .send(Message::Text(r#"{{"id":"sub_1","type":"complete"}}"#.into()))
307 .await
308 .unwrap();
309
310 // Wait for shutdown signal
311 let _ = rx.await;
312 }});
313
314 let client = Client::new(format!("http://{{}}", addr));
315 let mut stream = client.post_created().await.unwrap();
316
317 let first = stream.next().await.unwrap().unwrap();
318 assert_eq!(first.title, "Hello World");
319 assert_eq!(first.author.name, "Alice");
320
321 assert!(stream.next().await.is_none());
322
323 let _ = tx.send(());
324 let _ = server_handle.await;
325}}
326"##,
327 name = spec.name
328 )
329}
330
331fn ensure_dir(path: &Path) -> Result<()> {
332 fs::create_dir_all(path).map_err(|source| GenError::WriteOutput {
333 path: path.to_path_buf(),
334 source,
335 })
336}
337
338fn write_file(path: &Path, contents: &str, report: &mut EmitReport) -> Result<()> {
339 fs::write(path, contents).map_err(|source| GenError::WriteOutput {
340 path: path.to_path_buf(),
341 source,
342 })?;
343 report.files.push(path.to_path_buf());
344 Ok(())
345}