gemini_adk_fluent_rs/spec/
codegen.rs

1//! Generate a standalone Rust application from a [`SessionSpec`].
2//!
3//! Because L2 never invents runtime semantics — every spec field lowers to a
4//! builder method — code generation is *pretty-printing*, not compilation:
5//! [`SessionSpec::to_rust`] emits the fluent `Live::builder()` chain the
6//! document is equivalent to, as a complete `main.rs` (with a matching
7//! `Cargo.toml` from [`SessionSpec::to_cargo_toml`]). The Studio's **Code**
8//! tab serves both: draw the flow, read the program; edit the JSON, the
9//! program follows.
10
11use std::fmt::Write as _;
12
13use gemini_adk_rs::flow::{Constraint, Flow, Guard, Pred};
14use serde_json::Value;
15
16use super::{
17    ContextDeliverySpec, EffectSpec, PersistenceSpec, PromotePolicy, RuntimeSpec, SessionSpec,
18    SpecModality, StateType, SteeringSpec, ToolSpec, TriggerSpec, WatchCondition,
19};
20
21impl SessionSpec {
22    /// The complete `main.rs` this spec is equivalent to.
23    pub fn to_rust(&self) -> String {
24        let mut out = String::new();
25        let name = if self.name.is_empty() {
26            "app"
27        } else {
28            &self.name
29        };
30        let _ = writeln!(
31            out,
32            "//! {name} — generated by Flow Studio from a SessionSpec."
33        );
34        if !self.description.is_empty() {
35            let _ = writeln!(out, "//! {}", self.description);
36        }
37        out.push_str("//!\n//! The chain below is exactly what the JSON document lowers to;\n");
38        out.push_str("//! edit either — they stay equivalent.\n\n");
39        out.push_str("use serde_json::json;\n");
40        out.push_str("use gemini_adk_fluent_rs::prelude::*;\n");
41        if !self.extract.is_empty() {
42            out.push_str(
43                "use gemini_adk_fluent_rs::gemini_adk_rs::live::extractor::{\n    \
44                 ExtractionTrigger, FieldPromotion, LlmExtractor,\n};\n",
45            );
46        }
47        if !self.computed.is_empty() {
48            out.push_str("use gemini_adk_fluent_rs::gemini_adk_rs::expr::Expr;\n");
49        }
50        if let Some(runtime) = &self.runtime {
51            let mut live_imports = Vec::new();
52            if runtime.steering.is_some() {
53                live_imports.push("SteeringMode");
54            }
55            if runtime.context_delivery.is_some() {
56                live_imports.push("ContextDelivery");
57            }
58            if runtime.repair.is_some() {
59                live_imports.push("RepairConfig");
60            }
61            match runtime.persistence {
62                Some(PersistenceSpec::Fs { .. }) => live_imports.push("FsPersistence"),
63                Some(PersistenceSpec::Memory) => live_imports.push("MemoryPersistence"),
64                None => {}
65            }
66            if !live_imports.is_empty() {
67                let _ = writeln!(
68                    out,
69                    "use gemini_adk_fluent_rs::live::{{{}}};",
70                    live_imports.join(", ")
71                );
72            }
73        }
74        if self.memory.is_some() {
75            out.push_str("use gemini_memory_rs::prelude::*;\n");
76            out.push_str("use gemini_memory_rs::runtime::LiveMemoryExt;\n");
77        }
78        let needs_arc = !self.extract.is_empty()
79            || self.memory.is_some()
80            || self
81                .runtime
82                .as_ref()
83                .is_some_and(|r| r.persistence.is_some());
84        if needs_arc {
85            out.push_str("use std::sync::Arc;\n");
86        }
87        out.push('\n');
88        if !self.state.is_empty() {
89            out.push_str(&gen_state_keys(&self.state));
90            out.push('\n');
91        }
92        out.push_str(
93            "#[tokio::main]\nasync fn main() -> Result<(), Box<dyn std::error::Error>> {\n",
94        );
95        out.push_str("    let state = State::new();\n");
96        for (key, field) in &self.state {
97            if let Some(default) = &field.default {
98                let _ = writeln!(
99                    out,
100                    "    let _ = state.set({}, json!({}));",
101                    rust_str(key),
102                    compact(default)
103                );
104            }
105        }
106        out.push('\n');
107        if self.memory.is_some() {
108            out.push_str("    // ── Memory ─────────────────────────────────────────────\n");
109            out.push_str(
110                "    // In-process engine for local runs; swap the repository and event\n    \
111                 // log for durable backends in production.\n",
112            );
113            out.push_str(
114                "    let memory_engine = MemoryEngine::in_memory(UserId::new(\"local-user\"));\n",
115            );
116            out.push_str(
117                "    let memory = Arc::new(memory_engine.begin_session(SessionId::new(\
118                 \"session-1\")));\n\n",
119            );
120        }
121
122        if !self.tools.is_empty() {
123            out.push_str("    // ── Tools ──────────────────────────────────────────────\n");
124            out.push_str("    let mut tools = ToolDispatcher::new();\n");
125            for tool in &self.tools {
126                out.push_str(&gen_tool(tool));
127            }
128            out.push('\n');
129        }
130
131        let flow = self.effective_flow().unwrap_or_default();
132        if !flow.steps.is_empty() {
133            out.push_str("    // ── Governed flow ──────────────────────────────────────\n");
134            out.push_str(&gen_flow(&flow));
135            out.push('\n');
136        }
137
138        out.push_str("    // ── Session ────────────────────────────────────────────\n");
139        out.push_str("    let session = Live::builder()\n");
140        let instruction = if self.instruction.is_empty() {
141            "Follow the conversation flow you are given."
142        } else {
143            &self.instruction
144        };
145        let _ = writeln!(out, "        .instruction({})", rust_str(instruction));
146        if let Some(greeting) = &self.greeting {
147            let _ = writeln!(out, "        .greeting({})", rust_str(greeting));
148        }
149        match self.modality {
150            SpecModality::Text => out.push_str("        .text_only()\n"),
151            SpecModality::Audio => {
152                let voice = self.voice.as_deref().unwrap_or("Puck");
153                let known = ["Puck", "Aoede", "Charon", "Fenrir", "Kore"];
154                if known.contains(&voice) {
155                    let _ = writeln!(out, "        .voice(Voice::{voice})");
156                } else {
157                    let _ = writeln!(
158                        out,
159                        "        .voice(Voice::Custom({}.into()))",
160                        rust_str(voice)
161                    );
162                }
163            }
164        }
165        out.push_str("        .state(state.clone())\n");
166        if !self.tools.is_empty() {
167            out.push_str("        .dispatcher(tools)\n");
168        }
169        for params in &self.mcp {
170            let _ = writeln!(out, "        .tools(T::mcp({}))", rust_str(params));
171        }
172        if !flow.steps.is_empty() {
173            out.push_str("        .govern(flow)\n");
174        }
175        for computed in &self.computed {
176            out.push_str(&gen_computed(computed));
177        }
178        if let Some(memory) = &self.memory {
179            if memory.slots.is_empty() {
180                out.push_str("        .with_memory(memory.clone())\n");
181            } else {
182                let slots: Vec<String> = memory
183                    .slots
184                    .iter()
185                    .map(|s| {
186                        format!(
187                            "MemorySlot::new({}, {})",
188                            rust_str(&s.predicate),
189                            rust_str(&s.to)
190                        )
191                    })
192                    .collect();
193                let _ = writeln!(
194                    out,
195                    "        .with_memory_slots(memory.clone(), [{}])",
196                    slots.join(", ")
197                );
198            }
199        }
200        for tool in &self.tools {
201            match (tool.background, tool.scheduling) {
202                (_, Some(scheduling)) => {
203                    let variant = match scheduling {
204                        super::SchedulingSpec::Interrupt => "Interrupt",
205                        super::SchedulingSpec::WhenIdle => "WhenIdle",
206                        super::SchedulingSpec::Silent => "Silent",
207                    };
208                    let _ = writeln!(
209                        out,
210                        "        .tool_background_with_scheduling({}, \
211                         FunctionResponseScheduling::{variant})",
212                        rust_str(&tool.name)
213                    );
214                }
215                (true, None) => {
216                    let _ = writeln!(out, "        .tool_background({})", rust_str(&tool.name));
217                }
218                (false, None) => {}
219            }
220        }
221        if let Some(runtime) = &self.runtime {
222            out.push_str(&gen_runtime(runtime));
223        }
224        for extract in &self.extract {
225            out.push_str(&gen_extract(extract));
226        }
227        for phase in &self.phases {
228            out.push_str(&gen_phase(phase));
229        }
230        if let Some(initial) = &self.initial_phase {
231            let _ = writeln!(out, "        .initial_phase({})", rust_str(initial));
232        }
233        for watch in &self.watch {
234            out.push_str(&gen_watch(watch));
235        }
236        for pattern in &self.patterns {
237            out.push_str(&gen_pattern(pattern));
238        }
239        if self.modality == SpecModality::Text {
240            out.push_str("        .on_text(|t| print!(\"{t}\"))\n");
241            out.push_str("        .on_turn_complete(|| async { println!(); })\n");
242        }
243        out.push_str("        .connect_from_env()\n        .await?;\n\n");
244
245        match self.modality {
246            SpecModality::Audio => {
247                out.push_str(
248                    "    // Microphone in, speakers out, barge-in handled.\n    \
249                     // Requires the `voice-io` feature (see Cargo.toml).\n    \
250                     session.talk().await?;\n",
251                );
252            }
253            SpecModality::Text => {
254                out.push_str(
255                    "    // Minimal text REPL — type a line, read the reply.\n    \
256                     let stdin = std::io::stdin();\n    \
257                     let mut line = String::new();\n    \
258                     while stdin.read_line(&mut line)? > 0 {\n        \
259                     session.send_text(line.trim()).await?;\n        \
260                     line.clear();\n    }\n    \
261                     session.disconnect().await?;\n",
262                );
263            }
264        }
265        out.push_str("    Ok(())\n}\n");
266        out
267    }
268
269    /// A `Cargo.toml` to pair with [`to_rust`](Self::to_rust).
270    pub fn to_cargo_toml(&self) -> String {
271        let name = if self.name.is_empty() {
272            "flow-app".to_string()
273        } else {
274            self.name.replace([' ', '_'], "-").to_lowercase()
275        };
276        let features = if self.modality == SpecModality::Audio {
277            r#", features = ["gemini-llm", "voice-io"]"#
278        } else {
279            r#", features = ["gemini-llm"]"#
280        };
281        let version = env!("CARGO_PKG_VERSION");
282        let memory_dep = if self.memory.is_some() {
283            format!("gemini-memory-rs = \"{version}\"\n")
284        } else {
285            String::new()
286        };
287        format!(
288            "[package]\nname = \"{name}\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n\
289             [dependencies]\ngemini-adk-fluent-rs = {{ version = \"{version}\"{features} }}\n\
290             {memory_dep}tokio = {{ version = \"1\", features = [\"full\"] }}\n\
291             serde_json = \"1\"\n"
292        )
293    }
294}
295
296/// One declared tool as a `SimpleTool` registration.
297fn gen_tool(tool: &ToolSpec) -> String {
298    let mut out = String::new();
299    let description = if tool.description.is_empty() {
300        format!("Tool '{}'", tool.name)
301    } else {
302        tool.description.clone()
303    };
304    let params = match &tool.parameters {
305        Some(schema) => format!("Some(json!({}))", compact(schema)),
306        None => "None".to_string(),
307    };
308    let writes_state = !tool.set_state.is_empty();
309    if writes_state {
310        out.push_str("    {\n        let state = state.clone();\n");
311    } else {
312        out.push_str("    {\n");
313    }
314    let _ = writeln!(
315        out,
316        "        tools.register(SimpleTool::new(\n            {},\n            {},\n            {params},",
317        rust_str(&tool.name),
318        rust_str(&description),
319    );
320    if writes_state {
321        out.push_str(
322            "            move |_args| {\n                let state = state.clone();\n                async move {\n",
323        );
324    } else {
325        out.push_str("            move |_args| async move {\n");
326    }
327    if let Some(http) = &tool.http {
328        let _ = writeln!(
329            out,
330            "                    // HTTP binding: {} {} — replace with your client of choice.",
331            http.method, http.url
332        );
333    }
334    for (key, value) in &tool.set_state {
335        let _ = writeln!(
336            out,
337            "                    let _ = state.set({}, json!({}));",
338            rust_str(key),
339            compact(value)
340        );
341    }
342    let response = tool
343        .response
344        .clone()
345        .unwrap_or_else(|| serde_json::json!({"ok": true}));
346    if writes_state {
347        let _ = writeln!(out, "                    Ok(json!({}))", compact(&response));
348        out.push_str("                }\n            },\n        ));\n    }\n");
349    } else {
350        let _ = writeln!(out, "                Ok(json!({}))", compact(&response));
351        out.push_str("            },\n        ));\n    }\n");
352    }
353    out
354}
355
356/// The flow as its builder chain.
357fn gen_flow(flow: &Flow) -> String {
358    let mut out = String::from("    let flow = Flow::new()\n");
359    for step in &flow.steps {
360        let _ = writeln!(out, "        .step({})", rust_str(&step.id));
361        for dep in &step.after {
362            match &dep.when {
363                Some(when) => {
364                    let _ = writeln!(
365                        out,
366                        "        .after_when({}, {})",
367                        rust_str(&dep.step),
368                        gen_guard(when)
369                    );
370                }
371                None => {
372                    let _ = writeln!(out, "        .after({})", rust_str(&dep.step));
373                }
374            }
375        }
376        if step.join == gemini_adk_rs::flow::Join::Any {
377            out.push_str("        .join_any()\n");
378        }
379        if let Some(posture) = &step.posture {
380            let _ = writeln!(out, "        .posture({})", rust_str(posture));
381        }
382        if let Some(ground) = &step.ground {
383            let _ = writeln!(out, "        .ground({})", rust_str(ground));
384        }
385        if !step.allow.is_empty() {
386            let _ = writeln!(out, "        .allow({})", str_array(&step.allow));
387        }
388        if !step.deny.is_empty() {
389            let _ = writeln!(out, "        .deny({})", str_array(&step.deny));
390        }
391        if let Some(gate) = &step.gate {
392            let _ = writeln!(out, "        .gate({})", gen_guard(gate));
393        }
394        if step.terminal {
395            out.push_str("        .terminal()\n");
396        }
397        if let Some(done) = &step.done {
398            let _ = writeln!(out, "        .done({})", gen_guard(done));
399        }
400    }
401    for constraint in &flow.constraints {
402        match constraint {
403            Constraint::Once(tool) => {
404                let _ = writeln!(out, "        .once({})", rust_str(tool));
405            }
406            Constraint::Before(a, b) => {
407                let _ = writeln!(out, "        .before({}, {})", rust_str(a), rust_str(b));
408            }
409            Constraint::NeverUntil { tool, until } => {
410                let _ = writeln!(
411                    out,
412                    "        .never({}).until({})",
413                    rust_str(tool),
414                    gen_guard(until)
415                );
416            }
417            Constraint::Require(steps) => {
418                let _ = writeln!(out, "        .require({})", str_array(steps));
419            }
420            Constraint::Reset { steps, when } => {
421                let _ = writeln!(
422                    out,
423                    "        .reset({}).when({})",
424                    str_array(steps),
425                    gen_guard(when)
426                );
427            }
428        }
429    }
430    if !flow.ambient.is_empty() {
431        let _ = writeln!(out, "        .ambient({})", str_array(&flow.ambient));
432    }
433    for tool in &flow.confirm_tools {
434        let _ = writeln!(
435            out,
436            "        // confirm-gated: {tool} (see Per-Tool Policies)"
437        );
438    }
439    out.push_str("        .build()\n        .expect(\"validated in the Flow Studio\");\n");
440    out
441}
442
443/// A guard as its constructor expression.
444fn gen_guard(guard: &Guard) -> String {
445    match guard {
446        Guard::Spec(pred) => gen_pred(pred),
447        Guard::Custom(_) => "Guard::custom(|_| todo!(\"custom guard\"))".to_string(),
448    }
449}
450
451fn gen_pred(pred: &Pred) -> String {
452    match pred {
453        Pred::Always => "Guard::always()".to_string(),
454        Pred::IsTrue(key) => format!("Guard::is_true({})", rust_str(key)),
455        Pred::IsSet(key) => format!("Guard::is_set({})", rust_str(key)),
456        Pred::Eq(key, value) => format!("Guard::eq({}, json!({}))", rust_str(key), compact(value)),
457        Pred::Captured(fields) => format!("Guard::captured({})", str_array(fields)),
458        Pred::CalledOk(tool) => format!("Guard::called_ok({})", rust_str(tool)),
459        Pred::Done(step) => format!("Guard::done({})", rust_str(step)),
460        Pred::All(preds) => format!(
461            "Guard::all([{}])",
462            preds.iter().map(gen_pred).collect::<Vec<_>>().join(", ")
463        ),
464        Pred::Any(preds) => format!(
465            "Guard::any([{}])",
466            preds.iter().map(gen_pred).collect::<Vec<_>>().join(", ")
467        ),
468        Pred::Not(pred) => format!("Guard::not({})", gen_pred(pred)),
469    }
470}
471
472fn gen_extract(extract: &super::ExtractSpec) -> String {
473    let mut out = String::new();
474    out.push_str("        .extractor(Arc::new(\n");
475    let _ = writeln!(
476        out,
477        "            LlmExtractor::new(\n                {}.to_string(),\n                \
478         Arc::new(GeminiLlm::new(Default::default())),\n                {}.to_string(),\n                {},\n            )",
479        rust_str(&extract.name),
480        rust_str(&extract.instruction),
481        extract.window
482    );
483    let _ = writeln!(
484        out,
485        "            .with_schema(json!({}))",
486        compact(&extract.schema)
487    );
488    let trigger = match extract.trigger {
489        TriggerSpec::EveryTurn => "EveryTurn",
490        TriggerSpec::AfterToolCall => "AfterToolCall",
491        TriggerSpec::OnGenerationComplete => "OnGenerationComplete",
492        TriggerSpec::OnPhaseChange => "OnPhaseChange",
493    };
494    let _ = writeln!(
495        out,
496        "            .with_trigger(ExtractionTrigger::{trigger})"
497    );
498    if !extract.promote.is_empty() {
499        out.push_str("            .with_promotions(vec![\n");
500        for promote in &extract.promote {
501            let ctor = match promote.policy {
502                PromotePolicy::KeepKnown => "keep_known",
503                PromotePolicy::Overwrite => "overwrite",
504                PromotePolicy::TrueOnly => "true_only",
505                PromotePolicy::NonEmpty => "non_empty",
506            };
507            let mut rule = format!("FieldPromotion::{ctor}({})", rust_str(&promote.field));
508            if let Some(to) = &promote.to {
509                let _ = write!(rule, ".to({})", rust_str(to));
510            }
511            let _ = writeln!(out, "                {rule},");
512        }
513        out.push_str("            ])\n");
514    }
515    out.push_str("        ))\n");
516    out
517}
518
519fn gen_phase(phase: &super::PhaseSpec) -> String {
520    let mut out = String::new();
521    let _ = writeln!(out, "        .phase({})", rust_str(&phase.name));
522    if let Some(instruction) = &phase.instruction {
523        let _ = writeln!(out, "            .instruction({})", rust_str(instruction));
524    }
525    if !phase.tools.is_empty() {
526        let owned: Vec<String> = phase
527            .tools
528            .iter()
529            .map(|t| format!("{}.into()", rust_str(t)))
530            .collect();
531        let _ = writeln!(out, "            .tools(vec![{}])", owned.join(", "));
532    }
533    if !phase.needs.is_empty() {
534        let _ = writeln!(out, "            .needs(&{})", str_slice(&phase.needs));
535    }
536    if phase.prompt_on_enter == Some(true) {
537        out.push_str("            .prompt_on_enter()\n");
538    }
539    for transition in &phase.transitions {
540        let guard = gen_guard(&transition.when);
541        let _ = writeln!(
542            out,
543            "            .transition({}, move |s: &State| {guard}.eval_state(s))",
544            rust_str(&transition.to)
545        );
546    }
547    if !phase.on_enter.is_empty() {
548        out.push_str("            .on_enter(move |_state, _writer| async move {\n");
549        out.push_str(&gen_effects(&phase.on_enter, "                "));
550        out.push_str("            })\n");
551    }
552    if phase.terminal {
553        out.push_str("            .terminal()\n");
554    }
555    out.push_str("            .done()\n");
556    out
557}
558
559fn gen_pattern(pattern: &super::PatternSpec) -> String {
560    let mut out = String::new();
561    let guard = gen_guard(&pattern.when);
562    let (method, arg) = match (pattern.sustained_secs, pattern.turns) {
563        (Some(secs), _) => (
564            "when_sustained",
565            format!("std::time::Duration::from_secs({secs})"),
566        ),
567        (_, Some(turns)) => ("when_turns", turns.to_string()),
568        _ => ("when_turns", "1".to_string()),
569    };
570    let _ = writeln!(
571        out,
572        "        .{method}({}, move |s: &State| {guard}.eval_state(s), {arg},",
573        rust_str(&pattern.name)
574    );
575    out.push_str("            move |_state, _writer| async move {\n");
576    out.push_str(&gen_effects(&pattern.effects, "                "));
577    out.push_str("            })\n");
578    out
579}
580
581/// The closed effect vocabulary as executable lines. `remember` becomes a
582/// documented seam (like an HTTP binding): the spec-driven runtime routes it
583/// through the session's `MemoryBinding`; a generated app names the note and
584/// leaves the engine call to the application.
585fn gen_effects(effects: &[EffectSpec], indent: &str) -> String {
586    let mut out = String::new();
587    for effect in effects {
588        match effect {
589            EffectSpec::Set(map) => {
590                for (key, value) in map {
591                    let _ = writeln!(
592                        out,
593                        "{indent}let _ = _state.set({}, json!({}));",
594                        rust_str(key),
595                        compact(value)
596                    );
597                }
598            }
599            EffectSpec::Context(text) => {
600                let _ = writeln!(
601                    out,
602                    "{indent}let _ = _writer.send_client_content(vec![Content::model({})], false).await;",
603                    rust_str(text)
604                );
605            }
606            EffectSpec::Prompt(text) => {
607                let _ = writeln!(
608                    out,
609                    "{indent}let _ = _writer.send_client_content(vec![Content::model({})], true).await;",
610                    rust_str(text)
611                );
612            }
613            EffectSpec::Remember(note) => {
614                let _ = writeln!(
615                    out,
616                    "{indent}// remember (durable): {} — route through your memory engine\n\
617                     {indent}// (`MemorySession::apply_explicit_command`, see gemini-memory-rs).",
618                    rust_str(note)
619                );
620            }
621        }
622    }
623    out
624}
625
626/// A computed variable: the expression rides along as data (its JSON is the
627/// source of truth in both phrasings) and evaluates through `Expr::eval`.
628fn gen_computed(computed: &super::ComputedSpec) -> String {
629    let mut out = String::new();
630    let expr_json = serde_json::to_value(&computed.from).unwrap_or(Value::Null);
631    let deps: Vec<String> = computed.from.keys_read().into_iter().collect();
632    let _ = writeln!(
633        out,
634        "        .computed({}, &{}, {{",
635        rust_str(&computed.key),
636        str_array(&deps)
637    );
638    let _ = writeln!(
639        out,
640        "            let expr: Expr = serde_json::from_value(json!({}))",
641        compact(&expr_json)
642    );
643    out.push_str("                .expect(\"expression validated in the Flow Studio\");\n");
644    out.push_str("            move |s: &State| expr.eval(s)\n        })\n");
645    out
646}
647
648/// The declared state dictionary as typed key constants.
649fn gen_state_keys(state: &std::collections::BTreeMap<String, super::StateFieldSpec>) -> String {
650    let mut out = String::new();
651    out.push_str("/// Typed keys for the declared state dictionary.\n");
652    out.push_str("#[allow(dead_code)]\nmod keys {\n");
653    out.push_str("    use gemini_adk_fluent_rs::prelude::StateKey;\n\n");
654    for (key, field) in state {
655        let rust_type = match field.kind {
656            Some(StateType::Boolean) => "bool",
657            Some(StateType::Number) => "f64",
658            Some(StateType::String) => "String",
659            _ => "serde_json::Value",
660        };
661        if !field.description.is_empty() {
662            let _ = writeln!(out, "    /// {}", field.description);
663        }
664        let _ = writeln!(
665            out,
666            "    pub const {}: StateKey<{rust_type}> = StateKey::new({});",
667            const_name(key),
668            rust_str(key)
669        );
670    }
671    out.push_str("}\n");
672    out
673}
674
675/// `session:turn_count` → `SESSION_TURN_COUNT`.
676fn const_name(key: &str) -> String {
677    let mut out = String::with_capacity(key.len());
678    for c in key.chars() {
679        if c.is_ascii_alphanumeric() {
680            out.push(c.to_ascii_uppercase());
681        } else {
682            out.push('_');
683        }
684    }
685    if out.is_empty() {
686        // Empty key produces empty string after char filtering; use "_" for valid Rust
687        return "_".to_string();
688    }
689    if out.chars().next().is_some_and(|c| c.is_ascii_digit()) {
690        out.insert(0, '_');
691    }
692    out
693}
694
695/// The `runtime` section as its builder setters.
696fn gen_runtime(runtime: &RuntimeSpec) -> String {
697    let mut out = String::new();
698    if let Some(t) = runtime.temperature {
699        let _ = writeln!(out, "        .temperature({t:?})");
700    }
701    if let Some(budget) = runtime.thinking_budget {
702        let _ = writeln!(out, "        .thinking({budget})");
703    }
704    if runtime.include_thoughts == Some(true) {
705        out.push_str("        .include_thoughts()\n");
706    }
707    if let Some(t) = runtime.transcription {
708        match (t.input, t.output) {
709            (true, true) => out.push_str("        .transcription()\n"),
710            (true, false) => out.push_str("        .input_transcription()\n"),
711            (false, true) => out.push_str("        .output_transcription()\n"),
712            (false, false) => {}
713        }
714    }
715    if runtime.proactive_audio == Some(true) {
716        out.push_str("        .proactive_audio()\n");
717    }
718    if let Some(vad) = &runtime.vad {
719        out.push_str("        .vad(AutomaticActivityDetection {\n");
720        let sens = |s: super::SensitivitySpec| match s {
721            super::SensitivitySpec::Low => "Sensitivity::SensitivityLow",
722            super::SensitivitySpec::Medium => "Sensitivity::SensitivityMedium",
723            super::SensitivitySpec::High => "Sensitivity::SensitivityHigh",
724        };
725        let _ = writeln!(
726            out,
727            "            start_of_speech_sensitivity: {},",
728            vad.start_sensitivity
729                .map_or("None".to_string(), |s| format!("Some({})", sens(s)))
730        );
731        let _ = writeln!(
732            out,
733            "            end_of_speech_sensitivity: {},",
734            vad.end_sensitivity
735                .map_or("None".to_string(), |s| format!("Some({})", sens(s)))
736        );
737        let _ = writeln!(
738            out,
739            "            prefix_padding_ms: {:?},",
740            vad.prefix_padding_ms
741        );
742        let _ = writeln!(
743            out,
744            "            silence_duration_ms: {:?},",
745            vad.silence_duration_ms
746        );
747        out.push_str("            disabled: None,\n        })\n");
748    }
749    if let Some(audio) = &runtime.audio {
750        if audio.denoise == Some(true) {
751            out.push_str("        .mic_denoise() // feature `denoise`\n");
752        }
753        if let Some(gate) = &audio.noise_gate {
754            let _ = writeln!(
755                out,
756                "        .mic_noise_gate({:?}, {})",
757                gate.threshold_rms, gate.hold_frames
758            );
759        }
760        if let Some(vad) = &audio.client_vad {
761            let base = match vad.preset {
762                Some(super::ClientVadPreset::NoisyStreet) => "VadConfig::noisy_street()",
763                _ => "VadConfig::default()",
764            };
765            let mut overrides = String::new();
766            if let Some(v) = vad.start_threshold_db {
767                let _ = write!(overrides, " start_threshold_db: {v:?},");
768            }
769            if let Some(v) = vad.stop_threshold_db {
770                let _ = write!(overrides, " stop_threshold_db: {v:?},");
771            }
772            if let Some(v) = vad.min_speech_frames {
773                let _ = write!(overrides, " min_speech_frames: {v},");
774            }
775            if let Some(v) = vad.hangover_frames {
776                let _ = write!(overrides, " hangover_frames: {v},");
777            }
778            if overrides.is_empty() {
779                let _ = writeln!(out, "        .input_vad({base})");
780            } else {
781                let _ = writeln!(
782                    out,
783                    "        .input_vad(VadConfig {{{overrides} ..{base} }})"
784                );
785            }
786        }
787        if audio.authority == Some(super::AuthoritySpec::Client) {
788            out.push_str("        .client_interruption_authority()\n");
789        }
790        if let Some(eot_ms) = audio.eot_hold_ms {
791            let _ = writeln!(out, "        .turn_commit_eot_hold_ms({eot_ms})");
792        }
793        if let Some(min_int_ms) = audio.min_interruption_ms {
794            let _ = writeln!(
795                out,
796                "        .turn_commit_min_interruption_ms({min_int_ms})"
797            );
798        }
799    }
800    if let Some(ms) = runtime.soft_turn_timeout_ms {
801        let _ = writeln!(
802            out,
803            "        .soft_turn_timeout(std::time::Duration::from_millis({ms}))"
804        );
805    }
806    if let Some(steering) = runtime.steering {
807        let variant = match steering {
808            SteeringSpec::InstructionUpdate => "InstructionUpdate",
809            SteeringSpec::ContextInjection => "ContextInjection",
810            SteeringSpec::Hybrid => "Hybrid",
811        };
812        let _ = writeln!(out, "        .steering_mode(SteeringMode::{variant})");
813    }
814    if let Some(delivery) = runtime.context_delivery {
815        let variant = match delivery {
816            ContextDeliverySpec::Immediate => "Immediate",
817            ContextDeliverySpec::Deferred => "Deferred",
818        };
819        let _ = writeln!(out, "        .context_delivery(ContextDelivery::{variant})");
820    }
821    if let Some(repair) = runtime.repair {
822        let _ = writeln!(
823            out,
824            "        .repair(RepairConfig {{ nudge_after: {}, escalate_after: {} }})",
825            repair.nudge_after, repair.escalate_after
826        );
827    }
828    match &runtime.persistence {
829        Some(PersistenceSpec::Fs { dir }) => {
830            let _ = writeln!(
831                out,
832                "        .persistence(Arc::new(FsPersistence::new({})))",
833                rust_str(dir)
834            );
835        }
836        Some(PersistenceSpec::Memory) => {
837            out.push_str("        .persistence(Arc::new(MemoryPersistence::new()))\n");
838        }
839        None => {}
840    }
841    if let Some(id) = &runtime.session_id {
842        let _ = writeln!(out, "        .session_id({})", rust_str(id));
843    }
844    if runtime.lossy_audio {
845        out.push_str("        .lossy_audio()\n");
846    }
847    if runtime.lossy_transcript {
848        out.push_str("        .lossy_transcript()\n");
849    }
850    out
851}
852
853fn gen_watch(watch: &super::WatchSpec) -> String {
854    let mut out = String::new();
855    let _ = writeln!(out, "        .watch({})", rust_str(&watch.key));
856    let condition = match &watch.condition {
857        WatchCondition::Changed => "            .changed()".to_string(),
858        WatchCondition::ChangedTo(value) => {
859            format!("            .changed_to(json!({}))", compact(value))
860        }
861        WatchCondition::CrossedAbove(threshold) => {
862            format!("            .crossed_above({threshold:?})")
863        }
864        WatchCondition::CrossedBelow(threshold) => {
865            format!("            .crossed_below({threshold:?})")
866        }
867        WatchCondition::BecameTrue => "            .became_true()".to_string(),
868        WatchCondition::BecameFalse => "            .became_false()".to_string(),
869    };
870    let _ = writeln!(out, "{condition}");
871    if watch.effects.is_empty() {
872        out.push_str("            .then(move |_old, _new, _state| async move {\n");
873    } else {
874        out.push_str(
875            "            .then_with_writer(move |_old, _new, _state, _writer| async move {\n",
876        );
877    }
878    for (key, value) in &watch.set {
879        let _ = writeln!(
880            out,
881            "                let _ = _state.set({}, json!({}));",
882            rust_str(key),
883            compact(value)
884        );
885    }
886    out.push_str(&gen_effects(&watch.effects, "                "));
887    out.push_str("            })\n");
888    out
889}
890
891/// A Rust string literal for `s`.
892fn rust_str(s: &str) -> String {
893    let mut out = String::with_capacity(s.len() + 2);
894    out.push('"');
895    for c in s.chars() {
896        match c {
897            '"' => out.push_str("\\\""),
898            '\\' => out.push_str("\\\\"),
899            '\n' => out.push_str("\\n"),
900            '\t' => out.push_str("\\t"),
901            '\r' => out.push_str("\\r"),
902            c if c.is_control() => {
903                let _ = write!(out, "\\u{{{:x}}}", c as u32);
904            }
905            c => out.push(c),
906        }
907    }
908    out.push('"');
909    out
910}
911
912/// `["a", "b"]` — an array literal of string literals.
913fn str_array(items: &[String]) -> String {
914    format!(
915        "[{}]",
916        items
917            .iter()
918            .map(|s| rust_str(s))
919            .collect::<Vec<_>>()
920            .join(", ")
921    )
922}
923
924/// `["a", "b"]` for `&[&str]` positions.
925fn str_slice(items: &[String]) -> String {
926    str_array(items)
927}
928
929/// Compact JSON — valid `json!` macro input.
930fn compact(value: &Value) -> String {
931    serde_json::to_string(value).unwrap_or_else(|_| "null".to_string())
932}
933
934#[cfg(test)]
935mod tests {
936    use super::super::SessionSpec;
937    use serde_json::json;
938
939    fn spec() -> SessionSpec {
940        SessionSpec::from_value(json!({
941            "name": "collections",
942            "instruction": "Collect payments.",
943            "greeting": "Say hello.",
944            "tools": [
945                {"name": "verify_identity", "description": "Verify the caller.",
946                 "set_state": {"identity_verified": true}},
947                {"name": "charge_card", "response": {"charged": true}}
948            ],
949            "extract": [{
950                "name": "ptp", "instruction": "Extract the promise to pay.",
951                "schema": {"type": "object"},
952                "promote": [{"field": "ptp_amount"}]
953            }],
954            "phases": [{"name": "greet", "instruction": "Welcome.",
955                        "transitions": [{"to": "main", "when": {"is_true": "greeted"}}]},
956                       {"name": "main"}],
957            "initial_phase": "greet",
958            "watch": [{"key": "risk", "condition": {"crossed_above": 0.9},
959                       "set": {"alert": true}}],
960            "flow": {
961                "steps": [
962                    {"id": "verify", "posture": "Verify.", "allow": ["verify_identity"],
963                     "done": {"is_true": "identity_verified"}},
964                    {"id": "pay", "after": ["verify"],
965                     "gate": {"all": [{"captured": ["ptp_amount"]},
966                                       {"not": {"is_true": "disputed"}}]},
967                     "allow": ["charge_card"], "done": {"called_ok": "charge_card"}}
968                ],
969                "constraints": [
970                    {"once": "charge_card"},
971                    {"never_until": {"tool": "charge_card",
972                                     "until": {"is_true": "identity_verified"}}},
973                    {"require": ["pay"]}
974                ]
975            }
976        }))
977        .expect("parses")
978    }
979
980    #[test]
981    fn generated_main_contains_the_full_chain() {
982        let code = spec().to_rust();
983        for fragment in [
984            "let flow = Flow::new()",
985            ".step(\"verify\")",
986            ".done(Guard::is_true(\"identity_verified\"))",
987            ".gate(Guard::all([Guard::captured([\"ptp_amount\"]), Guard::not(Guard::is_true(\"disputed\"))]))",
988            ".never(\"charge_card\").until(Guard::is_true(\"identity_verified\"))",
989            ".once(\"charge_card\")",
990            ".require([\"pay\"])",
991            "tools.register(SimpleTool::new(",
992            "state.set(\"identity_verified\", json!(true))",
993            "LlmExtractor::new(",
994            "FieldPromotion::keep_known(\"ptp_amount\")",
995            ".phase(\"greet\")",
996            ".transition(\"main\", move |s: &State| Guard::is_true(\"greeted\").eval_state(s))",
997            ".watch(\"risk\")",
998            ".crossed_above(0.9)",
999            ".connect_from_env()",
1000            "session.send_text(line.trim()).await?",
1001        ] {
1002            assert!(
1003                code.contains(fragment),
1004                "missing fragment: {fragment}\n---\n{code}"
1005            );
1006        }
1007    }
1008
1009    #[test]
1010    fn audio_specs_generate_talk_and_voice_feature() {
1011        let mut audio = spec();
1012        audio.modality = super::super::SpecModality::Audio;
1013        audio.voice = Some("Kore".into());
1014        let code = audio.to_rust();
1015        assert!(code.contains(".voice(Voice::Kore)"));
1016        assert!(code.contains("session.talk().await?"));
1017        assert!(audio.to_cargo_toml().contains("voice-io"));
1018    }
1019
1020    #[test]
1021    fn turn_commit_tuning_knobs_generate_builder_calls() {
1022        let spec = SessionSpec::from_value(json!({
1023            "name": "tuned",
1024            "instruction": "Talk to me.",
1025            "runtime": {
1026                "audio": {
1027                    "eot_hold_ms": 800,
1028                    "min_interruption_ms": 1400
1029                }
1030            }
1031        }))
1032        .expect("parses");
1033        let code = spec.to_rust();
1034        assert!(
1035            code.contains(".turn_commit_eot_hold_ms(800)"),
1036            "missing eot_hold_ms in codegen:\n{code}"
1037        );
1038        assert!(
1039            code.contains(".turn_commit_min_interruption_ms(1400)"),
1040            "missing min_interruption_ms in codegen:\n{code}"
1041        );
1042    }
1043
1044    #[test]
1045    fn string_escaping_is_sound() {
1046        assert_eq!(super::rust_str("say \"hi\"\n"), "\"say \\\"hi\\\"\\n\"");
1047    }
1048
1049    #[test]
1050    fn new_sections_generate_their_wiring() {
1051        let spec = SessionSpec::from_value(json!({
1052            "name": "concierge",
1053            "instruction": "Help the guest.",
1054            "state": {
1055                "verified": {"type": "boolean", "default": false,
1056                             "description": "Identity has been checked."},
1057                "session:turn_count": {"type": "number"}
1058            },
1059            "computed": [{"key": "high_risk",
1060                          "from": {"gt": [{"key": "score"}, {"const": 0.5}]}}],
1061            "memory": {"slots": [{"predicate": "dietary_identity", "to": "user:diet"}]},
1062            "tools": [
1063                {"name": "record_score", "set_state": {"score": 0.9}},
1064                {"name": "log_event", "scheduling": "silent"}
1065            ],
1066            "runtime": {
1067                "temperature": 0.4,
1068                "soft_turn_timeout_ms": 1500,
1069                "steering": "context_injection",
1070                "repair": {"nudge_after": 2, "escalate_after": 5},
1071                "persistence": {"fs": {"dir": "/tmp/sessions"}},
1072                "session_id": "guest-1"
1073            },
1074            "patterns": [{"name": "stuck", "when": {"is_true": "confused"}, "turns": 3,
1075                          "effects": [{"prompt": "Offer to help."},
1076                                       {"remember": "guest got stuck"}]}],
1077            "flow": {"steps": [
1078                {"id": "assess", "posture": "Assess.",
1079                 "allow": ["record_score", "log_event"],
1080                 "done": {"is_true": "high_risk"}}
1081            ]}
1082        }))
1083        .expect("parses");
1084        let code = spec.to_rust();
1085        for fragment in [
1086            "mod keys {",
1087            "pub const VERIFIED: StateKey<bool> = StateKey::new(\"verified\");",
1088            "pub const SESSION_TURN_COUNT: StateKey<f64> = StateKey::new(\"session:turn_count\");",
1089            "let _ = state.set(\"verified\", json!(false));",
1090            ".computed(\"high_risk\", &[\"score\"], {",
1091            "serde_json::from_value(json!({\"gt\":[{\"key\":\"score\"},{\"const\":0.5}]}))",
1092            "MemoryEngine::in_memory(UserId::new(\"local-user\"))",
1093            ".with_memory_slots(memory.clone(), [MemorySlot::new(\"dietary_identity\", \"user:diet\")])",
1094            ".tool_background_with_scheduling(\"log_event\", FunctionResponseScheduling::Silent)",
1095            ".temperature(0.4)",
1096            ".soft_turn_timeout(std::time::Duration::from_millis(1500))",
1097            ".steering_mode(SteeringMode::ContextInjection)",
1098            ".repair(RepairConfig { nudge_after: 2, escalate_after: 5 })",
1099            ".persistence(Arc::new(FsPersistence::new(\"/tmp/sessions\")))",
1100            ".session_id(\"guest-1\")",
1101            "send_client_content(vec![Content::model(\"Offer to help.\")], true)",
1102            "// remember (durable): \"guest got stuck\"",
1103        ] {
1104            assert!(
1105                code.contains(fragment),
1106                "missing fragment: {fragment}\n---\n{code}"
1107            );
1108        }
1109        assert!(spec.to_cargo_toml().contains("gemini-memory-rs"));
1110    }
1111
1112    #[test]
1113    fn empty_state_key_name_generates_valid_const_name() {
1114        // Test that empty state key names are handled gracefully in codegen
1115        let key = "";
1116        let name = super::const_name(key);
1117        // const_name should never return an empty string, should be at least "_"
1118        // to produce valid Rust code
1119        assert!(
1120            !name.is_empty(),
1121            "const_name produced empty string for empty key"
1122        );
1123        assert!(
1124            name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_'),
1125            "const_name produced invalid Rust identifier: {name}"
1126        );
1127    }
1128
1129    #[test]
1130    fn state_key_name_starting_with_digit_is_escaped() {
1131        let name = super::const_name("123abc");
1132        assert!(
1133            name.starts_with('_'),
1134            "const_name should prepend _ to digit-starting keys, got {name}"
1135        );
1136        assert_eq!(name, "_123ABC");
1137    }
1138}