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 memory_dep = if self.memory.is_some() {
282            "gemini-memory-rs = \"0.8\"\n"
283        } else {
284            ""
285        };
286        format!(
287            "[package]\nname = \"{name}\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n\
288             [dependencies]\ngemini-adk-fluent-rs = {{ version = \"0.8\"{features} }}\n\
289             {memory_dep}tokio = {{ version = \"1\", features = [\"full\"] }}\n\
290             serde_json = \"1\"\n"
291        )
292    }
293}
294
295/// One declared tool as a `SimpleTool` registration.
296fn gen_tool(tool: &ToolSpec) -> String {
297    let mut out = String::new();
298    let description = if tool.description.is_empty() {
299        format!("Tool '{}'", tool.name)
300    } else {
301        tool.description.clone()
302    };
303    let params = match &tool.parameters {
304        Some(schema) => format!("Some(json!({}))", compact(schema)),
305        None => "None".to_string(),
306    };
307    let writes_state = !tool.set_state.is_empty();
308    if writes_state {
309        out.push_str("    {\n        let state = state.clone();\n");
310    } else {
311        out.push_str("    {\n");
312    }
313    let _ = writeln!(
314        out,
315        "        tools.register(SimpleTool::new(\n            {},\n            {},\n            {params},",
316        rust_str(&tool.name),
317        rust_str(&description),
318    );
319    if writes_state {
320        out.push_str(
321            "            move |_args| {\n                let state = state.clone();\n                async move {\n",
322        );
323    } else {
324        out.push_str("            move |_args| async move {\n");
325    }
326    if let Some(http) = &tool.http {
327        let _ = writeln!(
328            out,
329            "                    // HTTP binding: {} {} — replace with your client of choice.",
330            http.method, http.url
331        );
332    }
333    for (key, value) in &tool.set_state {
334        let _ = writeln!(
335            out,
336            "                    let _ = state.set({}, json!({}));",
337            rust_str(key),
338            compact(value)
339        );
340    }
341    let response = tool
342        .response
343        .clone()
344        .unwrap_or_else(|| serde_json::json!({"ok": true}));
345    if writes_state {
346        let _ = writeln!(out, "                    Ok(json!({}))", compact(&response));
347        out.push_str("                }\n            },\n        ));\n    }\n");
348    } else {
349        let _ = writeln!(out, "                Ok(json!({}))", compact(&response));
350        out.push_str("            },\n        ));\n    }\n");
351    }
352    out
353}
354
355/// The flow as its builder chain.
356fn gen_flow(flow: &Flow) -> String {
357    let mut out = String::from("    let flow = Flow::new()\n");
358    for step in &flow.steps {
359        let _ = writeln!(out, "        .step({})", rust_str(&step.id));
360        for dep in &step.after {
361            match &dep.when {
362                Some(when) => {
363                    let _ = writeln!(
364                        out,
365                        "        .after_when({}, {})",
366                        rust_str(&dep.step),
367                        gen_guard(when)
368                    );
369                }
370                None => {
371                    let _ = writeln!(out, "        .after({})", rust_str(&dep.step));
372                }
373            }
374        }
375        if step.join == gemini_adk_rs::flow::Join::Any {
376            out.push_str("        .join_any()\n");
377        }
378        if let Some(posture) = &step.posture {
379            let _ = writeln!(out, "        .posture({})", rust_str(posture));
380        }
381        if let Some(ground) = &step.ground {
382            let _ = writeln!(out, "        .ground({})", rust_str(ground));
383        }
384        if !step.allow.is_empty() {
385            let _ = writeln!(out, "        .allow({})", str_array(&step.allow));
386        }
387        if !step.deny.is_empty() {
388            let _ = writeln!(out, "        .deny({})", str_array(&step.deny));
389        }
390        if let Some(gate) = &step.gate {
391            let _ = writeln!(out, "        .gate({})", gen_guard(gate));
392        }
393        if step.terminal {
394            out.push_str("        .terminal()\n");
395        }
396        if let Some(done) = &step.done {
397            let _ = writeln!(out, "        .done({})", gen_guard(done));
398        }
399    }
400    for constraint in &flow.constraints {
401        match constraint {
402            Constraint::Once(tool) => {
403                let _ = writeln!(out, "        .once({})", rust_str(tool));
404            }
405            Constraint::Before(a, b) => {
406                let _ = writeln!(out, "        .before({}, {})", rust_str(a), rust_str(b));
407            }
408            Constraint::NeverUntil { tool, until } => {
409                let _ = writeln!(
410                    out,
411                    "        .never({}).until({})",
412                    rust_str(tool),
413                    gen_guard(until)
414                );
415            }
416            Constraint::Require(steps) => {
417                let _ = writeln!(out, "        .require({})", str_array(steps));
418            }
419            Constraint::Reset { steps, when } => {
420                let _ = writeln!(
421                    out,
422                    "        .reset({}).when({})",
423                    str_array(steps),
424                    gen_guard(when)
425                );
426            }
427        }
428    }
429    if !flow.ambient.is_empty() {
430        let _ = writeln!(out, "        .ambient({})", str_array(&flow.ambient));
431    }
432    for tool in &flow.confirm_tools {
433        let _ = writeln!(
434            out,
435            "        // confirm-gated: {tool} (see Per-Tool Policies)"
436        );
437    }
438    out.push_str("        .build()\n        .expect(\"validated in the Flow Studio\");\n");
439    out
440}
441
442/// A guard as its constructor expression.
443fn gen_guard(guard: &Guard) -> String {
444    match guard {
445        Guard::Spec(pred) => gen_pred(pred),
446        Guard::Custom(_) => "Guard::custom(|_| todo!(\"custom guard\"))".to_string(),
447    }
448}
449
450fn gen_pred(pred: &Pred) -> String {
451    match pred {
452        Pred::Always => "Guard::always()".to_string(),
453        Pred::IsTrue(key) => format!("Guard::is_true({})", rust_str(key)),
454        Pred::IsSet(key) => format!("Guard::is_set({})", rust_str(key)),
455        Pred::Eq(key, value) => format!("Guard::eq({}, json!({}))", rust_str(key), compact(value)),
456        Pred::Captured(fields) => format!("Guard::captured({})", str_array(fields)),
457        Pred::CalledOk(tool) => format!("Guard::called_ok({})", rust_str(tool)),
458        Pred::Done(step) => format!("Guard::done({})", rust_str(step)),
459        Pred::All(preds) => format!(
460            "Guard::all([{}])",
461            preds.iter().map(gen_pred).collect::<Vec<_>>().join(", ")
462        ),
463        Pred::Any(preds) => format!(
464            "Guard::any([{}])",
465            preds.iter().map(gen_pred).collect::<Vec<_>>().join(", ")
466        ),
467        Pred::Not(pred) => format!("Guard::not({})", gen_pred(pred)),
468    }
469}
470
471fn gen_extract(extract: &super::ExtractSpec) -> String {
472    let mut out = String::new();
473    out.push_str("        .extractor(Arc::new(\n");
474    let _ = writeln!(
475        out,
476        "            LlmExtractor::new(\n                {}.to_string(),\n                \
477         Arc::new(GeminiLlm::new(Default::default())),\n                {}.to_string(),\n                {},\n            )",
478        rust_str(&extract.name),
479        rust_str(&extract.instruction),
480        extract.window
481    );
482    let _ = writeln!(
483        out,
484        "            .with_schema(json!({}))",
485        compact(&extract.schema)
486    );
487    let trigger = match extract.trigger {
488        TriggerSpec::EveryTurn => "EveryTurn",
489        TriggerSpec::AfterToolCall => "AfterToolCall",
490        TriggerSpec::OnGenerationComplete => "OnGenerationComplete",
491        TriggerSpec::OnPhaseChange => "OnPhaseChange",
492    };
493    let _ = writeln!(
494        out,
495        "            .with_trigger(ExtractionTrigger::{trigger})"
496    );
497    if !extract.promote.is_empty() {
498        out.push_str("            .with_promotions(vec![\n");
499        for promote in &extract.promote {
500            let ctor = match promote.policy {
501                PromotePolicy::KeepKnown => "keep_known",
502                PromotePolicy::Overwrite => "overwrite",
503                PromotePolicy::TrueOnly => "true_only",
504                PromotePolicy::NonEmpty => "non_empty",
505            };
506            let mut rule = format!("FieldPromotion::{ctor}({})", rust_str(&promote.field));
507            if let Some(to) = &promote.to {
508                let _ = write!(rule, ".to({})", rust_str(to));
509            }
510            let _ = writeln!(out, "                {rule},");
511        }
512        out.push_str("            ])\n");
513    }
514    out.push_str("        ))\n");
515    out
516}
517
518fn gen_phase(phase: &super::PhaseSpec) -> String {
519    let mut out = String::new();
520    let _ = writeln!(out, "        .phase({})", rust_str(&phase.name));
521    if let Some(instruction) = &phase.instruction {
522        let _ = writeln!(out, "            .instruction({})", rust_str(instruction));
523    }
524    if !phase.tools.is_empty() {
525        let owned: Vec<String> = phase
526            .tools
527            .iter()
528            .map(|t| format!("{}.into()", rust_str(t)))
529            .collect();
530        let _ = writeln!(out, "            .tools(vec![{}])", owned.join(", "));
531    }
532    if !phase.needs.is_empty() {
533        let _ = writeln!(out, "            .needs(&{})", str_slice(&phase.needs));
534    }
535    if phase.prompt_on_enter == Some(true) {
536        out.push_str("            .prompt_on_enter()\n");
537    }
538    for transition in &phase.transitions {
539        let guard = gen_guard(&transition.when);
540        let _ = writeln!(
541            out,
542            "            .transition({}, move |s: &State| {guard}.eval_state(s))",
543            rust_str(&transition.to)
544        );
545    }
546    if !phase.on_enter.is_empty() {
547        out.push_str("            .on_enter(move |_state, _writer| async move {\n");
548        out.push_str(&gen_effects(&phase.on_enter, "                "));
549        out.push_str("            })\n");
550    }
551    if phase.terminal {
552        out.push_str("            .terminal()\n");
553    }
554    out.push_str("            .done()\n");
555    out
556}
557
558fn gen_pattern(pattern: &super::PatternSpec) -> String {
559    let mut out = String::new();
560    let guard = gen_guard(&pattern.when);
561    let (method, arg) = match (pattern.sustained_secs, pattern.turns) {
562        (Some(secs), _) => (
563            "when_sustained",
564            format!("std::time::Duration::from_secs({secs})"),
565        ),
566        (_, Some(turns)) => ("when_turns", turns.to_string()),
567        _ => ("when_turns", "1".to_string()),
568    };
569    let _ = writeln!(
570        out,
571        "        .{method}({}, move |s: &State| {guard}.eval_state(s), {arg},",
572        rust_str(&pattern.name)
573    );
574    out.push_str("            move |_state, _writer| async move {\n");
575    out.push_str(&gen_effects(&pattern.effects, "                "));
576    out.push_str("            })\n");
577    out
578}
579
580/// The closed effect vocabulary as executable lines. `remember` becomes a
581/// documented seam (like an HTTP binding): the spec-driven runtime routes it
582/// through the session's `MemoryBinding`; a generated app names the note and
583/// leaves the engine call to the application.
584fn gen_effects(effects: &[EffectSpec], indent: &str) -> String {
585    let mut out = String::new();
586    for effect in effects {
587        match effect {
588            EffectSpec::Set(map) => {
589                for (key, value) in map {
590                    let _ = writeln!(
591                        out,
592                        "{indent}let _ = _state.set({}, json!({}));",
593                        rust_str(key),
594                        compact(value)
595                    );
596                }
597            }
598            EffectSpec::Context(text) => {
599                let _ = writeln!(
600                    out,
601                    "{indent}let _ = _writer.send_client_content(vec![Content::model({})], false).await;",
602                    rust_str(text)
603                );
604            }
605            EffectSpec::Prompt(text) => {
606                let _ = writeln!(
607                    out,
608                    "{indent}let _ = _writer.send_client_content(vec![Content::model({})], true).await;",
609                    rust_str(text)
610                );
611            }
612            EffectSpec::Remember(note) => {
613                let _ = writeln!(
614                    out,
615                    "{indent}// remember (durable): {} — route through your memory engine\n\
616                     {indent}// (`MemorySession::apply_explicit_command`, see gemini-memory-rs).",
617                    rust_str(note)
618                );
619            }
620        }
621    }
622    out
623}
624
625/// A computed variable: the expression rides along as data (its JSON is the
626/// source of truth in both phrasings) and evaluates through `Expr::eval`.
627fn gen_computed(computed: &super::ComputedSpec) -> String {
628    let mut out = String::new();
629    let expr_json = serde_json::to_value(&computed.from).unwrap_or(Value::Null);
630    let deps: Vec<String> = computed.from.keys_read().into_iter().collect();
631    let _ = writeln!(
632        out,
633        "        .computed({}, &{}, {{",
634        rust_str(&computed.key),
635        str_array(&deps)
636    );
637    let _ = writeln!(
638        out,
639        "            let expr: Expr = serde_json::from_value(json!({}))",
640        compact(&expr_json)
641    );
642    out.push_str("                .expect(\"expression validated in the Flow Studio\");\n");
643    out.push_str("            move |s: &State| expr.eval(s)\n        })\n");
644    out
645}
646
647/// The declared state dictionary as typed key constants.
648fn gen_state_keys(state: &std::collections::BTreeMap<String, super::StateFieldSpec>) -> String {
649    let mut out = String::new();
650    out.push_str("/// Typed keys for the declared state dictionary.\n");
651    out.push_str("#[allow(dead_code)]\nmod keys {\n");
652    out.push_str("    use gemini_adk_fluent_rs::prelude::StateKey;\n\n");
653    for (key, field) in state {
654        let rust_type = match field.kind {
655            Some(StateType::Boolean) => "bool",
656            Some(StateType::Number) => "f64",
657            Some(StateType::String) => "String",
658            _ => "serde_json::Value",
659        };
660        if !field.description.is_empty() {
661            let _ = writeln!(out, "    /// {}", field.description);
662        }
663        let _ = writeln!(
664            out,
665            "    pub const {}: StateKey<{rust_type}> = StateKey::new({});",
666            const_name(key),
667            rust_str(key)
668        );
669    }
670    out.push_str("}\n");
671    out
672}
673
674/// `session:turn_count` → `SESSION_TURN_COUNT`.
675fn const_name(key: &str) -> String {
676    let mut out = String::with_capacity(key.len());
677    for c in key.chars() {
678        if c.is_ascii_alphanumeric() {
679            out.push(c.to_ascii_uppercase());
680        } else {
681            out.push('_');
682        }
683    }
684    if out.is_empty() {
685        // Empty key produces empty string after char filtering; use "_" for valid Rust
686        return "_".to_string();
687    }
688    if out.chars().next().is_some_and(|c| c.is_ascii_digit()) {
689        out.insert(0, '_');
690    }
691    out
692}
693
694/// The `runtime` section as its builder setters.
695fn gen_runtime(runtime: &RuntimeSpec) -> String {
696    let mut out = String::new();
697    if let Some(t) = runtime.temperature {
698        let _ = writeln!(out, "        .temperature({t:?})");
699    }
700    if let Some(budget) = runtime.thinking_budget {
701        let _ = writeln!(out, "        .thinking({budget})");
702    }
703    if runtime.include_thoughts == Some(true) {
704        out.push_str("        .include_thoughts()\n");
705    }
706    if let Some(t) = runtime.transcription {
707        match (t.input, t.output) {
708            (true, true) => out.push_str("        .transcription()\n"),
709            (true, false) => out.push_str("        .input_transcription()\n"),
710            (false, true) => out.push_str("        .output_transcription()\n"),
711            (false, false) => {}
712        }
713    }
714    if runtime.proactive_audio == Some(true) {
715        out.push_str("        .proactive_audio()\n");
716    }
717    if let Some(vad) = &runtime.vad {
718        out.push_str("        .vad(AutomaticActivityDetection {\n");
719        let sens = |s: super::SensitivitySpec| match s {
720            super::SensitivitySpec::Low => "Sensitivity::SensitivityLow",
721            super::SensitivitySpec::Medium => "Sensitivity::SensitivityMedium",
722            super::SensitivitySpec::High => "Sensitivity::SensitivityHigh",
723        };
724        let _ = writeln!(
725            out,
726            "            start_of_speech_sensitivity: {},",
727            vad.start_sensitivity
728                .map_or("None".to_string(), |s| format!("Some({})", sens(s)))
729        );
730        let _ = writeln!(
731            out,
732            "            end_of_speech_sensitivity: {},",
733            vad.end_sensitivity
734                .map_or("None".to_string(), |s| format!("Some({})", sens(s)))
735        );
736        let _ = writeln!(
737            out,
738            "            prefix_padding_ms: {:?},",
739            vad.prefix_padding_ms
740        );
741        let _ = writeln!(
742            out,
743            "            silence_duration_ms: {:?},",
744            vad.silence_duration_ms
745        );
746        out.push_str("            disabled: None,\n        })\n");
747    }
748    if let Some(audio) = &runtime.audio {
749        if audio.denoise == Some(true) {
750            out.push_str("        .mic_denoise() // feature `denoise`\n");
751        }
752        if let Some(gate) = &audio.noise_gate {
753            let _ = writeln!(
754                out,
755                "        .mic_noise_gate({:?}, {})",
756                gate.threshold_rms, gate.hold_frames
757            );
758        }
759        if let Some(vad) = &audio.client_vad {
760            let base = match vad.preset {
761                Some(super::ClientVadPreset::NoisyStreet) => "VadConfig::noisy_street()",
762                _ => "VadConfig::default()",
763            };
764            let mut overrides = String::new();
765            if let Some(v) = vad.start_threshold_db {
766                let _ = write!(overrides, " start_threshold_db: {v:?},");
767            }
768            if let Some(v) = vad.stop_threshold_db {
769                let _ = write!(overrides, " stop_threshold_db: {v:?},");
770            }
771            if let Some(v) = vad.min_speech_frames {
772                let _ = write!(overrides, " min_speech_frames: {v},");
773            }
774            if let Some(v) = vad.hangover_frames {
775                let _ = write!(overrides, " hangover_frames: {v},");
776            }
777            if overrides.is_empty() {
778                let _ = writeln!(out, "        .input_vad({base})");
779            } else {
780                let _ = writeln!(
781                    out,
782                    "        .input_vad(VadConfig {{{overrides} ..{base} }})"
783                );
784            }
785        }
786        if audio.authority == Some(super::AuthoritySpec::Client) {
787            out.push_str("        .client_interruption_authority()\n");
788        }
789        if let Some(eot_ms) = audio.eot_hold_ms {
790            let _ = writeln!(out, "        .turn_commit_eot_hold_ms({eot_ms})");
791        }
792        if let Some(min_int_ms) = audio.min_interruption_ms {
793            let _ = writeln!(
794                out,
795                "        .turn_commit_min_interruption_ms({min_int_ms})"
796            );
797        }
798    }
799    if let Some(ms) = runtime.soft_turn_timeout_ms {
800        let _ = writeln!(
801            out,
802            "        .soft_turn_timeout(std::time::Duration::from_millis({ms}))"
803        );
804    }
805    if let Some(steering) = runtime.steering {
806        let variant = match steering {
807            SteeringSpec::InstructionUpdate => "InstructionUpdate",
808            SteeringSpec::ContextInjection => "ContextInjection",
809            SteeringSpec::Hybrid => "Hybrid",
810        };
811        let _ = writeln!(out, "        .steering_mode(SteeringMode::{variant})");
812    }
813    if let Some(delivery) = runtime.context_delivery {
814        let variant = match delivery {
815            ContextDeliverySpec::Immediate => "Immediate",
816            ContextDeliverySpec::Deferred => "Deferred",
817        };
818        let _ = writeln!(out, "        .context_delivery(ContextDelivery::{variant})");
819    }
820    if let Some(repair) = runtime.repair {
821        let _ = writeln!(
822            out,
823            "        .repair(RepairConfig {{ nudge_after: {}, escalate_after: {} }})",
824            repair.nudge_after, repair.escalate_after
825        );
826    }
827    match &runtime.persistence {
828        Some(PersistenceSpec::Fs { dir }) => {
829            let _ = writeln!(
830                out,
831                "        .persistence(Arc::new(FsPersistence::new({})))",
832                rust_str(dir)
833            );
834        }
835        Some(PersistenceSpec::Memory) => {
836            out.push_str("        .persistence(Arc::new(MemoryPersistence::new()))\n");
837        }
838        None => {}
839    }
840    if let Some(id) = &runtime.session_id {
841        let _ = writeln!(out, "        .session_id({})", rust_str(id));
842    }
843    if runtime.lossy_audio {
844        out.push_str("        .lossy_audio()\n");
845    }
846    if runtime.lossy_transcript {
847        out.push_str("        .lossy_transcript()\n");
848    }
849    out
850}
851
852fn gen_watch(watch: &super::WatchSpec) -> String {
853    let mut out = String::new();
854    let _ = writeln!(out, "        .watch({})", rust_str(&watch.key));
855    let condition = match &watch.condition {
856        WatchCondition::Changed => "            .changed()".to_string(),
857        WatchCondition::ChangedTo(value) => {
858            format!("            .changed_to(json!({}))", compact(value))
859        }
860        WatchCondition::CrossedAbove(threshold) => {
861            format!("            .crossed_above({threshold:?})")
862        }
863        WatchCondition::CrossedBelow(threshold) => {
864            format!("            .crossed_below({threshold:?})")
865        }
866        WatchCondition::BecameTrue => "            .became_true()".to_string(),
867        WatchCondition::BecameFalse => "            .became_false()".to_string(),
868    };
869    let _ = writeln!(out, "{condition}");
870    if watch.effects.is_empty() {
871        out.push_str("            .then(move |_old, _new, _state| async move {\n");
872    } else {
873        out.push_str(
874            "            .then_with_writer(move |_old, _new, _state, _writer| async move {\n",
875        );
876    }
877    for (key, value) in &watch.set {
878        let _ = writeln!(
879            out,
880            "                let _ = _state.set({}, json!({}));",
881            rust_str(key),
882            compact(value)
883        );
884    }
885    out.push_str(&gen_effects(&watch.effects, "                "));
886    out.push_str("            })\n");
887    out
888}
889
890/// A Rust string literal for `s`.
891fn rust_str(s: &str) -> String {
892    let mut out = String::with_capacity(s.len() + 2);
893    out.push('"');
894    for c in s.chars() {
895        match c {
896            '"' => out.push_str("\\\""),
897            '\\' => out.push_str("\\\\"),
898            '\n' => out.push_str("\\n"),
899            '\t' => out.push_str("\\t"),
900            '\r' => out.push_str("\\r"),
901            c if c.is_control() => {
902                let _ = write!(out, "\\u{{{:x}}}", c as u32);
903            }
904            c => out.push(c),
905        }
906    }
907    out.push('"');
908    out
909}
910
911/// `["a", "b"]` — an array literal of string literals.
912fn str_array(items: &[String]) -> String {
913    format!(
914        "[{}]",
915        items
916            .iter()
917            .map(|s| rust_str(s))
918            .collect::<Vec<_>>()
919            .join(", ")
920    )
921}
922
923/// `["a", "b"]` for `&[&str]` positions.
924fn str_slice(items: &[String]) -> String {
925    str_array(items)
926}
927
928/// Compact JSON — valid `json!` macro input.
929fn compact(value: &Value) -> String {
930    serde_json::to_string(value).unwrap_or_else(|_| "null".to_string())
931}
932
933#[cfg(test)]
934mod tests {
935    use super::super::SessionSpec;
936    use serde_json::json;
937
938    fn spec() -> SessionSpec {
939        SessionSpec::from_value(json!({
940            "name": "collections",
941            "instruction": "Collect payments.",
942            "greeting": "Say hello.",
943            "tools": [
944                {"name": "verify_identity", "description": "Verify the caller.",
945                 "set_state": {"identity_verified": true}},
946                {"name": "charge_card", "response": {"charged": true}}
947            ],
948            "extract": [{
949                "name": "ptp", "instruction": "Extract the promise to pay.",
950                "schema": {"type": "object"},
951                "promote": [{"field": "ptp_amount"}]
952            }],
953            "phases": [{"name": "greet", "instruction": "Welcome.",
954                        "transitions": [{"to": "main", "when": {"is_true": "greeted"}}]},
955                       {"name": "main"}],
956            "initial_phase": "greet",
957            "watch": [{"key": "risk", "condition": {"crossed_above": 0.9},
958                       "set": {"alert": true}}],
959            "flow": {
960                "steps": [
961                    {"id": "verify", "posture": "Verify.", "allow": ["verify_identity"],
962                     "done": {"is_true": "identity_verified"}},
963                    {"id": "pay", "after": ["verify"],
964                     "gate": {"all": [{"captured": ["ptp_amount"]},
965                                       {"not": {"is_true": "disputed"}}]},
966                     "allow": ["charge_card"], "done": {"called_ok": "charge_card"}}
967                ],
968                "constraints": [
969                    {"once": "charge_card"},
970                    {"never_until": {"tool": "charge_card",
971                                     "until": {"is_true": "identity_verified"}}},
972                    {"require": ["pay"]}
973                ]
974            }
975        }))
976        .expect("parses")
977    }
978
979    #[test]
980    fn generated_main_contains_the_full_chain() {
981        let code = spec().to_rust();
982        for fragment in [
983            "let flow = Flow::new()",
984            ".step(\"verify\")",
985            ".done(Guard::is_true(\"identity_verified\"))",
986            ".gate(Guard::all([Guard::captured([\"ptp_amount\"]), Guard::not(Guard::is_true(\"disputed\"))]))",
987            ".never(\"charge_card\").until(Guard::is_true(\"identity_verified\"))",
988            ".once(\"charge_card\")",
989            ".require([\"pay\"])",
990            "tools.register(SimpleTool::new(",
991            "state.set(\"identity_verified\", json!(true))",
992            "LlmExtractor::new(",
993            "FieldPromotion::keep_known(\"ptp_amount\")",
994            ".phase(\"greet\")",
995            ".transition(\"main\", move |s: &State| Guard::is_true(\"greeted\").eval_state(s))",
996            ".watch(\"risk\")",
997            ".crossed_above(0.9)",
998            ".connect_from_env()",
999            "session.send_text(line.trim()).await?",
1000        ] {
1001            assert!(
1002                code.contains(fragment),
1003                "missing fragment: {fragment}\n---\n{code}"
1004            );
1005        }
1006    }
1007
1008    #[test]
1009    fn audio_specs_generate_talk_and_voice_feature() {
1010        let mut audio = spec();
1011        audio.modality = super::super::SpecModality::Audio;
1012        audio.voice = Some("Kore".into());
1013        let code = audio.to_rust();
1014        assert!(code.contains(".voice(Voice::Kore)"));
1015        assert!(code.contains("session.talk().await?"));
1016        assert!(audio.to_cargo_toml().contains("voice-io"));
1017    }
1018
1019    #[test]
1020    fn turn_commit_tuning_knobs_generate_builder_calls() {
1021        let spec = SessionSpec::from_value(json!({
1022            "name": "tuned",
1023            "instruction": "Talk to me.",
1024            "runtime": {
1025                "audio": {
1026                    "eot_hold_ms": 800,
1027                    "min_interruption_ms": 1400
1028                }
1029            }
1030        }))
1031        .expect("parses");
1032        let code = spec.to_rust();
1033        assert!(
1034            code.contains(".turn_commit_eot_hold_ms(800)"),
1035            "missing eot_hold_ms in codegen:\n{code}"
1036        );
1037        assert!(
1038            code.contains(".turn_commit_min_interruption_ms(1400)"),
1039            "missing min_interruption_ms in codegen:\n{code}"
1040        );
1041    }
1042
1043    #[test]
1044    fn string_escaping_is_sound() {
1045        assert_eq!(super::rust_str("say \"hi\"\n"), "\"say \\\"hi\\\"\\n\"");
1046    }
1047
1048    #[test]
1049    fn new_sections_generate_their_wiring() {
1050        let spec = SessionSpec::from_value(json!({
1051            "name": "concierge",
1052            "instruction": "Help the guest.",
1053            "state": {
1054                "verified": {"type": "boolean", "default": false,
1055                             "description": "Identity has been checked."},
1056                "session:turn_count": {"type": "number"}
1057            },
1058            "computed": [{"key": "high_risk",
1059                          "from": {"gt": [{"key": "score"}, {"const": 0.5}]}}],
1060            "memory": {"slots": [{"predicate": "dietary_identity", "to": "user:diet"}]},
1061            "tools": [
1062                {"name": "record_score", "set_state": {"score": 0.9}},
1063                {"name": "log_event", "scheduling": "silent"}
1064            ],
1065            "runtime": {
1066                "temperature": 0.4,
1067                "soft_turn_timeout_ms": 1500,
1068                "steering": "context_injection",
1069                "repair": {"nudge_after": 2, "escalate_after": 5},
1070                "persistence": {"fs": {"dir": "/tmp/sessions"}},
1071                "session_id": "guest-1"
1072            },
1073            "patterns": [{"name": "stuck", "when": {"is_true": "confused"}, "turns": 3,
1074                          "effects": [{"prompt": "Offer to help."},
1075                                       {"remember": "guest got stuck"}]}],
1076            "flow": {"steps": [
1077                {"id": "assess", "posture": "Assess.",
1078                 "allow": ["record_score", "log_event"],
1079                 "done": {"is_true": "high_risk"}}
1080            ]}
1081        }))
1082        .expect("parses");
1083        let code = spec.to_rust();
1084        for fragment in [
1085            "mod keys {",
1086            "pub const VERIFIED: StateKey<bool> = StateKey::new(\"verified\");",
1087            "pub const SESSION_TURN_COUNT: StateKey<f64> = StateKey::new(\"session:turn_count\");",
1088            "let _ = state.set(\"verified\", json!(false));",
1089            ".computed(\"high_risk\", &[\"score\"], {",
1090            "serde_json::from_value(json!({\"gt\":[{\"key\":\"score\"},{\"const\":0.5}]}))",
1091            "MemoryEngine::in_memory(UserId::new(\"local-user\"))",
1092            ".with_memory_slots(memory.clone(), [MemorySlot::new(\"dietary_identity\", \"user:diet\")])",
1093            ".tool_background_with_scheduling(\"log_event\", FunctionResponseScheduling::Silent)",
1094            ".temperature(0.4)",
1095            ".soft_turn_timeout(std::time::Duration::from_millis(1500))",
1096            ".steering_mode(SteeringMode::ContextInjection)",
1097            ".repair(RepairConfig { nudge_after: 2, escalate_after: 5 })",
1098            ".persistence(Arc::new(FsPersistence::new(\"/tmp/sessions\")))",
1099            ".session_id(\"guest-1\")",
1100            "send_client_content(vec![Content::model(\"Offer to help.\")], true)",
1101            "// remember (durable): \"guest got stuck\"",
1102        ] {
1103            assert!(
1104                code.contains(fragment),
1105                "missing fragment: {fragment}\n---\n{code}"
1106            );
1107        }
1108        assert!(spec.to_cargo_toml().contains("gemini-memory-rs"));
1109    }
1110
1111    #[test]
1112    fn empty_state_key_name_generates_valid_const_name() {
1113        // Test that empty state key names are handled gracefully in codegen
1114        let key = "";
1115        let name = super::const_name(key);
1116        // const_name should never return an empty string, should be at least "_"
1117        // to produce valid Rust code
1118        assert!(
1119            !name.is_empty(),
1120            "const_name produced empty string for empty key"
1121        );
1122        assert!(
1123            name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_'),
1124            "const_name produced invalid Rust identifier: {name}"
1125        );
1126    }
1127
1128    #[test]
1129    fn state_key_name_starting_with_digit_is_escaped() {
1130        let name = super::const_name("123abc");
1131        assert!(
1132            name.starts_with('_'),
1133            "const_name should prepend _ to digit-starting keys, got {name}"
1134        );
1135        assert_eq!(name, "_123ABC");
1136    }
1137}