Skip to main content

oxide_gen/emit/
mod.rs

1//! Emitters convert an [`ApiSpec`] into on-disk artifacts.
2//!
3//! Each sub-module knows how to render exactly one kind of output file. The
4//! top-level [`emit_crate`] orchestrator runs them all into a single
5//! destination directory.
6
7pub 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/// Summary of artifacts produced by [`emit_crate`].
23///
24/// Returned so callers (CLI, tests, the kernel) know exactly which files were
25/// written and where.
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct EmitReport {
28    /// The crate root.
29    pub crate_dir: PathBuf,
30    /// All files written, in the order they were created.
31    pub files: Vec<PathBuf>,
32}
33
34impl EmitReport {
35    /// Convenience: look up a path inside this report by file name.
36    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
44/// Emit a full generated crate under `output_dir`.
45///
46/// Layout:
47///
48/// ```text
49/// {output_dir}/
50/// ├── Cargo.toml
51/// ├── module.json         # oxide-k discovery manifest
52/// ├── SKILL.md            # Claude Code skill descriptor
53/// ├── mcp.json            # MCP server config
54/// └── src/
55///     ├── lib.rs          # types + client
56///     └── main.rs         # clap CLI
57/// ```
58pub 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}