baobao_codegen/pipeline/phases/
lower.rs

1//! Lower phase - transforms manifest to Application IR.
2//!
3//! This module transforms the parsed manifest into the unified Application IR
4//! that generators consume.
5
6use std::{collections::HashMap, time::Duration};
7
8use baobao_ir::{
9    AppIR, AppMeta, CommandOp, DatabaseResource, DatabaseType, DefaultValue, HttpClientResource,
10    Input, InputKind, InputType, Operation, PoolConfig, Resource, SqliteOptions,
11};
12use baobao_manifest::{ArgType, Command, ContextField, Flag, Manifest};
13use eyre::Result;
14
15use crate::pipeline::{CompilationContext, Phase};
16
17/// Phase that transforms the manifest into Application IR.
18///
19/// This phase converts the parsed manifest into the unified IR that generators consume.
20pub struct LowerPhase;
21
22impl Phase for LowerPhase {
23    fn name(&self) -> &'static str {
24        "lower"
25    }
26
27    fn description(&self) -> &'static str {
28        "Transform Manifest to Application IR"
29    }
30
31    fn run(&self, ctx: &mut CompilationContext) -> Result<()> {
32        ctx.ir = Some(lower_manifest(&ctx.manifest));
33        Ok(())
34    }
35}
36
37/// Lower a manifest into an Application IR.
38fn lower_manifest(manifest: &Manifest) -> AppIR {
39    AppIR {
40        meta: lower_meta(manifest),
41        resources: lower_resources(manifest),
42        operations: lower_commands(&manifest.commands),
43    }
44}
45
46/// Lower CLI metadata from manifest.
47fn lower_meta(manifest: &Manifest) -> AppMeta {
48    AppMeta {
49        name: manifest.cli.name.clone(),
50        version: manifest.cli.version.to_string(),
51        description: manifest.cli.description.clone(),
52        author: manifest.cli.author.clone(),
53    }
54}
55
56/// Lower context resources from manifest.
57fn lower_resources(manifest: &Manifest) -> Vec<Resource> {
58    let mut resources = Vec::new();
59
60    if let Some(db) = &manifest.context.database
61        && let Some(resource) = lower_database_resource("db", db)
62    {
63        resources.push(Resource::Database(resource));
64    }
65
66    if manifest.context.http.is_some() {
67        resources.push(Resource::HttpClient(HttpClientResource {
68            name: "http".into(),
69        }));
70    }
71
72    resources
73}
74
75/// Lower a database context field to a DatabaseResource.
76fn lower_database_resource(name: &str, field: &ContextField) -> Option<DatabaseResource> {
77    let (db_type, env_var, pool_config, sqlite_opts) = match field {
78        ContextField::Postgres(config) => (
79            DatabaseType::Postgres,
80            default_env_var(config.env(), "DATABASE_URL"),
81            lower_pool_config(config.pool()),
82            None,
83        ),
84        ContextField::Mysql(config) => (
85            DatabaseType::Mysql,
86            default_env_var(config.env(), "DATABASE_URL"),
87            lower_pool_config(config.pool()),
88            None,
89        ),
90        ContextField::Sqlite(config) => (
91            DatabaseType::Sqlite,
92            default_env_var(config.env.as_deref(), "DATABASE_URL"),
93            lower_pool_config(&config.pool),
94            Some(lower_sqlite_options(config)),
95        ),
96        ContextField::Http(_) => return None,
97    };
98
99    Some(DatabaseResource {
100        name: name.into(),
101        db_type,
102        env_var,
103        pool: pool_config,
104        sqlite: sqlite_opts,
105    })
106}
107
108/// Get the environment variable or use default.
109fn default_env_var(env: Option<&str>, default: &str) -> String {
110    env.unwrap_or(default).into()
111}
112
113/// Lower pool configuration from manifest format.
114fn lower_pool_config(config: &baobao_manifest::PoolConfig) -> PoolConfig {
115    PoolConfig {
116        max_connections: config.max_connections,
117        min_connections: config.min_connections,
118        acquire_timeout: config.acquire_timeout.map(Duration::from_secs),
119        idle_timeout: config.idle_timeout.map(Duration::from_secs),
120        max_lifetime: config.max_lifetime.map(Duration::from_secs),
121    }
122}
123
124/// Lower SQLite-specific options.
125fn lower_sqlite_options(config: &baobao_manifest::SqliteConfig) -> SqliteOptions {
126    SqliteOptions {
127        path: config.path.clone(),
128        create_if_missing: config.create_if_missing,
129        read_only: config.read_only,
130        journal_mode: config.journal_mode.as_ref().map(|m| match m {
131            baobao_manifest::JournalMode::Wal => baobao_ir::JournalMode::Wal,
132            baobao_manifest::JournalMode::Delete => baobao_ir::JournalMode::Delete,
133            baobao_manifest::JournalMode::Truncate => baobao_ir::JournalMode::Truncate,
134            baobao_manifest::JournalMode::Persist => baobao_ir::JournalMode::Persist,
135            baobao_manifest::JournalMode::Memory => baobao_ir::JournalMode::Memory,
136            baobao_manifest::JournalMode::Off => baobao_ir::JournalMode::Off,
137        }),
138        synchronous: config.synchronous.as_ref().map(|s| match s {
139            baobao_manifest::SynchronousMode::Off => baobao_ir::SynchronousMode::Off,
140            baobao_manifest::SynchronousMode::Normal => baobao_ir::SynchronousMode::Normal,
141            baobao_manifest::SynchronousMode::Full => baobao_ir::SynchronousMode::Full,
142        }),
143        busy_timeout: config.busy_timeout.map(Duration::from_millis),
144        foreign_keys: config.foreign_keys,
145    }
146}
147
148/// Lower commands to operations.
149fn lower_commands(commands: &HashMap<String, Command>) -> Vec<Operation> {
150    // Sort commands for deterministic output
151    let mut names: Vec<_> = commands.keys().collect();
152    names.sort();
153
154    names
155        .into_iter()
156        .map(|name| {
157            let cmd = &commands[name];
158            Operation::Command(lower_command(name, cmd, vec![name.clone()]))
159        })
160        .collect()
161}
162
163/// Lower a single command.
164fn lower_command(name: &str, cmd: &Command, path: Vec<String>) -> CommandOp {
165    let mut inputs = Vec::new();
166
167    // Lower positional arguments (sorted for deterministic output)
168    let mut arg_names: Vec<_> = cmd.args.keys().collect();
169    arg_names.sort();
170    for arg_name in arg_names {
171        let arg = &cmd.args[arg_name];
172        inputs.push(Input {
173            name: arg_name.clone(),
174            ty: lower_arg_type(&arg.arg_type),
175            kind: InputKind::Positional,
176            required: arg.required,
177            default: arg.default.as_ref().and_then(lower_default_value),
178            description: arg.description.clone(),
179            choices: arg.choices.clone(),
180        });
181    }
182
183    // Lower flags (sorted for deterministic output)
184    let mut flag_names: Vec<_> = cmd.flags.keys().collect();
185    flag_names.sort();
186    for flag_name in flag_names {
187        let flag = &cmd.flags[flag_name];
188        inputs.push(lower_flag(flag_name, flag));
189    }
190
191    // Lower subcommands
192    let mut child_names: Vec<_> = cmd.commands.keys().collect();
193    child_names.sort();
194    let children: Vec<_> = child_names
195        .into_iter()
196        .map(|child_name| {
197            let child_cmd = &cmd.commands[child_name];
198            let mut child_path = path.clone();
199            child_path.push(child_name.clone());
200            lower_command(child_name, child_cmd, child_path)
201        })
202        .collect();
203
204    CommandOp {
205        name: name.into(),
206        path,
207        description: cmd.description.clone(),
208        inputs,
209        children,
210    }
211}
212
213/// Lower a flag to an Input.
214fn lower_flag(name: &str, flag: &Flag) -> Input {
215    Input {
216        name: name.into(),
217        ty: lower_arg_type(&flag.flag_type),
218        kind: InputKind::Flag {
219            short: flag.short.as_ref().map(|s| *s.get_ref()),
220        },
221        required: false,
222        default: flag.default.as_ref().and_then(lower_default_value),
223        description: flag.description.clone(),
224        choices: flag.choices.clone(),
225    }
226}
227
228/// Lower argument type.
229fn lower_arg_type(ty: &ArgType) -> InputType {
230    match ty {
231        ArgType::String => InputType::String,
232        ArgType::Int => InputType::Int,
233        ArgType::Float => InputType::Float,
234        ArgType::Bool => InputType::Bool,
235        ArgType::Path => InputType::Path,
236    }
237}
238
239/// Lower a TOML value to a DefaultValue.
240fn lower_default_value(value: &toml::Value) -> Option<DefaultValue> {
241    match value {
242        toml::Value::String(s) => Some(DefaultValue::String(s.clone())),
243        toml::Value::Integer(i) => Some(DefaultValue::Int(*i)),
244        toml::Value::Float(f) => Some(DefaultValue::Float(*f)),
245        toml::Value::Boolean(b) => Some(DefaultValue::Bool(*b)),
246        _ => None, // Arrays, tables, etc. are not supported as defaults
247    }
248}
249
250#[cfg(test)]
251mod tests {
252    use baobao_manifest::Manifest;
253
254    use super::*;
255
256    fn parse_manifest(content: &str) -> Manifest {
257        toml::from_str(content).expect("Failed to parse test manifest")
258    }
259
260    fn make_test_manifest() -> Manifest {
261        parse_manifest(
262            r#"
263            [cli]
264            name = "test"
265            version = "1.0.0"
266            description = "Test CLI"
267            language = "rust"
268        "#,
269        )
270    }
271
272    #[test]
273    fn test_lower_phase() {
274        let manifest = make_test_manifest();
275        let mut ctx = CompilationContext::new(manifest);
276
277        assert!(ctx.ir.is_none());
278
279        LowerPhase.run(&mut ctx).expect("lower should succeed");
280
281        assert!(ctx.ir.is_some());
282
283        let ir = ctx.ir.as_ref().unwrap();
284        assert_eq!(ir.meta.name, "test");
285        assert_eq!(ir.meta.version, "1.0.0");
286    }
287
288    #[test]
289    fn test_lower_arg_type() {
290        assert_eq!(lower_arg_type(&ArgType::String), InputType::String);
291        assert_eq!(lower_arg_type(&ArgType::Int), InputType::Int);
292        assert_eq!(lower_arg_type(&ArgType::Float), InputType::Float);
293        assert_eq!(lower_arg_type(&ArgType::Bool), InputType::Bool);
294        assert_eq!(lower_arg_type(&ArgType::Path), InputType::Path);
295    }
296
297    #[test]
298    fn test_lower_default_value() {
299        assert_eq!(
300            lower_default_value(&toml::Value::String("hello".into())),
301            Some(DefaultValue::String("hello".into()))
302        );
303        assert_eq!(
304            lower_default_value(&toml::Value::Integer(42)),
305            Some(DefaultValue::Int(42))
306        );
307        assert_eq!(
308            lower_default_value(&toml::Value::Boolean(true)),
309            Some(DefaultValue::Bool(true))
310        );
311    }
312
313    #[test]
314    fn test_lower_pool_config() {
315        let manifest_config = baobao_manifest::PoolConfig {
316            max_connections: Some(10),
317            min_connections: Some(2),
318            acquire_timeout: Some(30),
319            idle_timeout: Some(600),
320            max_lifetime: Some(1800),
321        };
322
323        let ir_config = lower_pool_config(&manifest_config);
324
325        assert_eq!(ir_config.max_connections, Some(10));
326        assert_eq!(ir_config.min_connections, Some(2));
327        assert_eq!(ir_config.acquire_timeout, Some(Duration::from_secs(30)));
328        assert_eq!(ir_config.idle_timeout, Some(Duration::from_secs(600)));
329        assert_eq!(ir_config.max_lifetime, Some(Duration::from_secs(1800)));
330    }
331}