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