lmrc_cli/generator/
apps.rs

1//! Application template generation module
2//!
3//! This module handles generation of applications from embedded templates.
4//! It supports multiple template types: gateway (with auth), api (without auth), and migrator.
5
6use colored::Colorize;
7use include_dir::{Dir, include_dir};
8use lmrc_config_validator::{AppType, ApplicationEntry, LmrcConfig};
9use std::fs;
10use std::path::Path;
11
12use crate::error::Result;
13
14// Embed template directories at compile time (from embedded-apps/ directory within CLI crate)
15static API_SERVICE_TEMPLATE: Dir =
16    include_dir!("$CARGO_MANIFEST_DIR/embedded-apps/api-service-template");
17
18// Embed infrastructure apps (ready-to-use, no templating)
19static GATEWAY_APP: Dir = include_dir!("$CARGO_MANIFEST_DIR/embedded-apps/gateway");
20static INFRA_API_APP: Dir = include_dir!("$CARGO_MANIFEST_DIR/embedded-apps/infra-api");
21static INFRA_MIGRATOR_APP: Dir = include_dir!("$CARGO_MANIFEST_DIR/embedded-apps/infra-migrator");
22static APP_MIGRATOR_APP: Dir = include_dir!("$CARGO_MANIFEST_DIR/embedded-apps/app-migrator");
23static INFRA_FRONT_APP: Dir = include_dir!("$CARGO_MANIFEST_DIR/embedded-apps/infra-front");
24
25/// Generate all applications from config
26pub fn generate_applications(project_path: &Path, config: &LmrcConfig) -> Result<()> {
27    // First, bundle infrastructure apps (always included)
28    bundle_infrastructure_apps(project_path)?;
29
30    // Then generate user-defined applications
31    for app in &config.apps.applications {
32        generate_single_app_internal(project_path, app, config)?;
33    }
34    Ok(())
35}
36
37/// Generate a single application by name and type (for `lmrc add app` command)
38pub fn generate_single_app(
39    project_path: &Path,
40    app_name: &str,
41    app_type: Option<&AppType>,
42) -> Result<()> {
43    let app = ApplicationEntry {
44        name: app_name.to_string(),
45        app_type: app_type.cloned(),
46        docker: None,
47        deployment: None,
48    };
49
50    // Create a minimal config for templating
51    let config = create_minimal_config_for_templating(app_name);
52
53    generate_single_app_internal(project_path, &app, &config)
54}
55
56fn create_minimal_config_for_templating(app_name: &str) -> LmrcConfig {
57    use lmrc_config_validator::*;
58
59    LmrcConfig {
60        project: ProjectConfig {
61            name: "project".to_string(),
62            description: "Project".to_string(),
63        },
64        providers: ProviderConfig {
65            server: "hetzner".to_string(),
66            kubernetes: "k3s".to_string(),
67            database: "postgres".to_string(),
68            queue: "rabbitmq".to_string(),
69            dns: "cloudflare".to_string(),
70            git: "gitlab".to_string(),
71        },
72        apps: AppsConfig {
73            applications: vec![],
74        },
75        infrastructure: InfrastructureConfig {
76            provider: "hetzner".to_string(),
77            network: None,
78            servers: vec![],
79            load_balancer: None,
80            k3s: None,
81            postgres: None,
82            rabbitmq: None,
83            vault: None,
84            dns: None,
85            gitlab: None,
86        },
87    }
88}
89
90/// Bundle ready-to-use infrastructure apps (no templating)
91fn bundle_infrastructure_apps(project_path: &Path) -> Result<()> {
92    println!("  {} Infrastructure apps...", "Bundling:".cyan());
93
94    // Bundle gateway
95    let gateway_path = project_path.join("apps").join("gateway");
96    copy_app_as_is(&GATEWAY_APP, &gateway_path)?;
97    println!("    {} apps/gateway", "✓".green());
98
99    // Bundle infra-api
100    let infra_api_path = project_path.join("apps").join("infra-api");
101    copy_app_as_is(&INFRA_API_APP, &infra_api_path)?;
102    println!("    {} apps/infra-api", "✓".green());
103
104    // Bundle infra-migrator
105    let infra_migrator_path = project_path.join("apps").join("infra-migrator");
106    copy_app_as_is(&INFRA_MIGRATOR_APP, &infra_migrator_path)?;
107    println!("    {} apps/infra-migrator", "✓".green());
108
109    // Bundle app-migrator
110    let app_migrator_path = project_path.join("apps").join("app-migrator");
111    copy_app_as_is(&APP_MIGRATOR_APP, &app_migrator_path)?;
112    println!("    {} apps/app-migrator", "✓".green());
113
114    // Bundle infra-front
115    let infra_front_path = project_path.join("apps").join("infra-front");
116    copy_app_as_is(&INFRA_FRONT_APP, &infra_front_path)?;
117    println!("    {} apps/infra-front", "✓".green());
118
119    Ok(())
120}
121
122/// Copy an app directory as-is without any template processing
123fn copy_app_as_is(app_dir: &Dir, dest_path: &Path) -> Result<()> {
124    fs::create_dir_all(dest_path)?;
125
126    for entry in app_dir.entries() {
127        copy_entry_as_is(entry, dest_path)?;
128    }
129
130    Ok(())
131}
132
133/// Recursively copy directory entries without any processing
134/// Renames .template files back to their original names (e.g., Cargo.toml.template -> Cargo.toml)
135fn copy_entry_as_is(entry: &include_dir::DirEntry, base_path: &Path) -> Result<()> {
136    match entry {
137        include_dir::DirEntry::Dir(dir) => {
138            let dir_path = base_path.join(dir.path());
139            fs::create_dir_all(&dir_path)?;
140            for child in dir.entries() {
141                copy_entry_as_is(child, base_path)?;
142            }
143        }
144        include_dir::DirEntry::File(file) => {
145            let mut file_path = base_path.join(file.path());
146
147            // Rename .template files back to original (e.g., Cargo.toml.template -> Cargo.toml)
148            if let Some(path_str) = file_path.to_str() {
149                if path_str.ends_with(".template") {
150                    file_path = Path::new(&path_str.trim_end_matches(".template")).to_path_buf();
151                }
152            }
153
154            if let Some(parent) = file_path.parent() {
155                fs::create_dir_all(parent)?;
156            }
157            fs::write(&file_path, file.contents())?;
158        }
159    }
160    Ok(())
161}
162
163/// Generate a single application based on its type (internal version with full config)
164fn generate_single_app_internal(
165    project_path: &Path,
166    app: &ApplicationEntry,
167    config: &LmrcConfig,
168) -> Result<()> {
169    let app_path = project_path.join("apps").join(&app.name);
170    fs::create_dir_all(&app_path)?;
171
172    match &app.app_type {
173        Some(AppType::Gateway) => {
174            // Gateway is bundled as infrastructure app - copy it from embedded
175            copy_app_as_is(&GATEWAY_APP, &app_path)?;
176            println!("  {} apps/{} (API Gateway with auth)", "Created:".green(), app.name);
177        }
178        Some(AppType::Api) => {
179            generate_from_template(&API_SERVICE_TEMPLATE, &app_path, app, config)?;
180            println!("  {} apps/{} (API service)", "Created:".green(), app.name);
181        }
182        Some(AppType::Migrator) => {
183            // Migrator is bundled as app-migrator - copy it from embedded
184            copy_app_as_is(&APP_MIGRATOR_APP, &app_path)?;
185            println!("  {} apps/{} (Database migrator)", "Created:".green(), app.name);
186        }
187        Some(AppType::Basic) | None => {
188            // Fallback to basic app generation
189            generate_basic_app(&app_path, app)?;
190            println!("  {} apps/{} (basic app)", "Created:".green(), app.name);
191        }
192    }
193
194    Ok(())
195}
196
197/// Generate application from embedded template
198fn generate_from_template(
199    template: &Dir,
200    app_path: &Path,
201    app: &ApplicationEntry,
202    config: &LmrcConfig,
203) -> Result<()> {
204    for entry in template.entries() {
205        extract_and_process_entry(entry, app_path, app, config)?;
206    }
207    Ok(())
208}
209
210/// Recursively extract and process template entries
211fn extract_and_process_entry(
212    entry: &include_dir::DirEntry,
213    base_path: &Path,
214    app: &ApplicationEntry,
215    config: &LmrcConfig,
216) -> Result<()> {
217    match entry {
218        include_dir::DirEntry::Dir(dir) => {
219            let dir_path = base_path.join(dir.path());
220            fs::create_dir_all(&dir_path)?;
221            for child in dir.entries() {
222                extract_and_process_entry(child, base_path, app, config)?;
223            }
224        }
225        include_dir::DirEntry::File(file) => {
226            let mut file_path = base_path.join(file.path());
227
228            // Rename .template files back to original (e.g., Cargo.toml.template -> Cargo.toml)
229            if let Some(path_str) = file_path.to_str() {
230                if path_str.ends_with(".template") {
231                    file_path = Path::new(&path_str.trim_end_matches(".template")).to_path_buf();
232                }
233            }
234
235            if let Some(parent) = file_path.parent() {
236                fs::create_dir_all(parent)?;
237            }
238
239            // Get file contents
240            let contents = file.contents();
241
242            // Process text files for placeholder replacement
243            if let Ok(text) = std::str::from_utf8(contents) {
244                let processed = replace_placeholders(text, app, config);
245                fs::write(&file_path, processed)?;
246            } else {
247                // Binary file, write as-is
248                fs::write(&file_path, contents)?;
249            }
250        }
251    }
252    Ok(())
253}
254
255/// Replace template placeholders with actual values
256fn replace_placeholders(content: &str, app: &ApplicationEntry, config: &LmrcConfig) -> String {
257    let mut result = content.to_string();
258
259    // Replace app-specific placeholders
260    result = result.replace("{{app_name}}", &app.name);
261
262    // Replace port - use deployment port if available, otherwise default
263    let port = app
264        .deployment
265        .as_ref()
266        .map(|d| d.port.to_string())
267        .unwrap_or_else(|| "8080".to_string());
268    result = result.replace("{{app_port}}", &port);
269
270    // Replace project-specific placeholders
271    result = result.replace("{{project_name}}", &config.project.name);
272    result = result.replace("{{project_description}}", &config.project.description);
273
274    result
275}
276
277/// Generate a basic application (fallback for unknown types)
278fn generate_basic_app(app_path: &Path, app: &ApplicationEntry) -> Result<()> {
279    use lmrc_toml_writer::PackageToml;
280
281    // Create Cargo.toml
282    let cargo_toml = PackageToml::new(&app.name)
283        .version("0.1.0")
284        .edition("2021")
285        .dependency_inline("tokio", r#"{ version = "1.0", features = ["full"] }"#)
286        .build();
287
288    fs::write(app_path.join("Cargo.toml"), cargo_toml)?;
289
290    // Create src directory and main.rs
291    let src_dir = app_path.join("src");
292    fs::create_dir_all(&src_dir)?;
293
294    let main_rs = format!(
295        r#"#[tokio::main]
296async fn main() {{
297    println!("Hello from {}!");
298}}
299"#,
300        app.name
301    );
302
303    fs::write(src_dir.join("main.rs"), main_rs)?;
304
305    Ok(())
306}
307
308#[cfg(test)]
309mod tests {
310    use super::*;
311    use lmrc_config_validator::*;
312
313    #[test]
314    fn test_gateway_app_is_embedded() {
315        assert!(GATEWAY_APP.get_file("Cargo.toml.template").is_some());
316        assert!(GATEWAY_APP.get_file("src/main.rs").is_some());
317    }
318
319    #[test]
320    fn test_infra_api_app_is_embedded() {
321        assert!(INFRA_API_APP.get_file("Cargo.toml.template").is_some());
322        assert!(INFRA_API_APP.get_file("src/main.rs").is_some());
323    }
324
325    #[test]
326    fn test_infra_migrator_app_is_embedded() {
327        assert!(INFRA_MIGRATOR_APP.get_file("Cargo.toml.template").is_some());
328        assert!(INFRA_MIGRATOR_APP.get_file("src/main.rs").is_some());
329    }
330
331    #[test]
332    fn test_app_migrator_app_is_embedded() {
333        assert!(APP_MIGRATOR_APP.get_file("Cargo.toml.template").is_some());
334        assert!(APP_MIGRATOR_APP.get_file("src/main.rs").is_some());
335    }
336
337    #[test]
338    fn test_api_service_template_is_embedded() {
339        assert!(
340            API_SERVICE_TEMPLATE
341                .get_file("Cargo.toml.template")
342                .is_some()
343        );
344        assert!(API_SERVICE_TEMPLATE.get_file("src/main.rs").is_some());
345    }
346
347    #[test]
348    fn test_replace_placeholders() {
349        let app = ApplicationEntry {
350            name: "my-gateway".to_string(),
351            app_type: Some(AppType::Gateway),
352            docker: None,
353            deployment: Some(DeploymentConfig {
354                replicas: 1,
355                port: 3000,
356                cpu_request: None,
357                memory_request: None,
358                cpu_limit: None,
359                memory_limit: None,
360                env: vec![],
361            }),
362        };
363
364        let config = create_test_config();
365
366        let template =
367            "name = \"{{app_name}}\"\nport = {{app_port}}\nproject = \"{{project_name}}\"";
368        let result = replace_placeholders(template, &app, &config);
369
370        assert!(result.contains("name = \"my-gateway\""));
371        assert!(result.contains("port = 3000"));
372        assert!(result.contains("project = \"test-project\""));
373    }
374
375    #[test]
376    fn test_replace_placeholders_with_default_port() {
377        let app = ApplicationEntry {
378            name: "my-api".to_string(),
379            app_type: Some(AppType::Api),
380            docker: None,
381            deployment: None, // No deployment config = use default port
382        };
383
384        let config = create_test_config();
385
386        let template = "PORT={{app_port}}";
387        let result = replace_placeholders(template, &app, &config);
388
389        assert!(result.contains("PORT=8080"));
390    }
391
392    fn create_test_config() -> LmrcConfig {
393        LmrcConfig {
394            project: ProjectConfig {
395                name: "test-project".to_string(),
396                description: "Test project".to_string(),
397            },
398            providers: ProviderConfig {
399                server: "hetzner".to_string(),
400                kubernetes: "k3s".to_string(),
401                database: "postgres".to_string(),
402                queue: "rabbitmq".to_string(),
403                dns: "cloudflare".to_string(),
404                git: "gitlab".to_string(),
405            },
406            apps: AppsConfig {
407                applications: vec![],
408            },
409            infrastructure: InfrastructureConfig {
410                provider: "hetzner".to_string(),
411                network: None,
412                servers: vec![],
413                k3s: None,
414                postgres: None,
415                rabbitmq: None,
416                vault: None,
417                dns: None,
418                gitlab: None,
419                load_balancer: None,
420            },
421        }
422    }
423}