Skip to main content

hdds_gen/
qos_generator.rs

1// SPDX-License-Identifier: Apache-2.0 OR MIT
2// Copyright (c) 2025-2026 naskel.com
3//
4// QoS Validator Generator
5//
6// Generates 48 QoS XML profiles + 96 test scripts from:
7// - config.yaml (policy definitions)
8// - baseline.xml (template)
9// - test_script.sh.j2 (template)
10
11use anyhow::{Context, Result};
12use regex::Regex;
13use serde::{Deserialize, Serialize};
14use std::collections::HashMap;
15use std::fs;
16use std::path::PathBuf;
17use tera::Tera;
18
19/// Policy variant definition
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct PolicyVariant {
22    pub name: String,
23    pub values: HashMap<String, serde_yaml::Value>,
24    pub description: Option<String>,
25}
26
27/// Single QoS policy definition
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct QosPolicy {
30    pub id: usize,
31    pub name: String,
32    pub category: String,
33    pub description: Option<String>,
34    pub xml_path: Vec<String>,
35    pub scope: Vec<String>,
36    pub variants: Vec<PolicyVariant>,
37}
38
39/// Complete configuration structure
40#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct QosValidatorConfig {
42    pub policies: Vec<QosPolicy>,
43    pub generator: GeneratorConfig,
44    pub baseline_config: BaselineConfig,
45    pub test_config: TestConfig,
46    pub metadata: Metadata,
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct GeneratorConfig {
51    pub output_dirs: HashMap<String, String>,
52    pub baseline_template: String,
53    pub test_script_template: String,
54    pub shared_utilities: Vec<String>,
55    pub manifest_output: String,
56    pub agent_snippet_output: String,
57    pub makefile_snippet_output: String,
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct BaselineConfig {
62    pub transport: String,
63    pub topic_name: String,
64    pub data_type: String,
65    pub defaults: HashMap<String, serde_yaml::Value>,
66}
67
68#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct TestConfig {
70    pub subscriber_timeout: u32,
71    pub publisher_timeout: u32,
72    pub discovery_delay: u32,
73    pub network: HashMap<String, String>,
74    pub remote: HashMap<String, String>,
75}
76
77#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct Metadata {
79    pub version: String,
80    pub created_date: String,
81    pub description: String,
82}
83
84/// Generator state
85pub struct QosGenerator {
86    config: QosValidatorConfig,
87    base_dir: PathBuf,
88    tera: Tera,
89}
90
91impl QosGenerator {
92    /// Load configuration and initialize generator
93    pub fn new(base_dir: PathBuf) -> Result<Self> {
94        let config_path = base_dir.join("interop/validator/generator/config.yaml");
95
96        tracing::info!("Loading config from: {:?}", config_path);
97        let config_content =
98            fs::read_to_string(&config_path).context("Failed to read config.yaml")?;
99        let config: QosValidatorConfig =
100            serde_yaml::from_str(&config_content).context("Failed to parse config.yaml")?;
101
102        // Initialize Tera template engine
103        let mut tera = Tera::default();
104
105        // Load baseline.xml template
106        let baseline_path = base_dir.join(&config.generator.baseline_template);
107        tracing::info!("Loading baseline template: {:?}", baseline_path);
108        let baseline_content =
109            fs::read_to_string(&baseline_path).context("Failed to read baseline.xml")?;
110        tera.add_raw_template("baseline", &baseline_content)
111            .context("Failed to parse baseline.xml template")?;
112
113        // Load test_script.sh.j2 template
114        let test_template_path = base_dir.join(&config.generator.test_script_template);
115        tracing::info!("Loading test script template: {:?}", test_template_path);
116        let test_template_content =
117            fs::read_to_string(&test_template_path).context("Failed to read test_script.sh.j2")?;
118        tera.add_raw_template("test_script", &test_template_content)
119            .context("Failed to parse test_script.sh.j2 template")?;
120
121        Ok(Self {
122            config,
123            base_dir,
124            tera,
125        })
126    }
127
128    /// Generate all artifacts (48 QoS + 96 scripts + manifest + snippets)
129    pub fn generate(&self) -> Result<GenerationReport> {
130        tracing::info!("Starting QoS validator generation");
131
132        let mut report = GenerationReport::new();
133
134        // Stage 1: Generate QoS profiles
135        tracing::info!("Stage 1: Generating QoS XML profiles");
136        self.generate_qos_profiles(&mut report)?;
137
138        // Stage 2: Generate test scripts
139        tracing::info!("Stage 2: Generating test scripts");
140        self.generate_test_scripts(&mut report)?;
141
142        // Stage 3: Generate manifest
143        tracing::info!("Stage 3: Generating manifest");
144        self.generate_manifest(&report)?;
145
146        // Stage 4: Generate integration snippets
147        tracing::info!("Stage 4: Generating integration snippets");
148        self.generate_agent_snippet(&report)?;
149        self.generate_makefile_snippet(&report)?;
150
151        tracing::info!("[OK] Generation complete");
152        Ok(report)
153    }
154
155    /// Generate 48 QoS XML profiles
156    fn generate_qos_profiles(&self, report: &mut GenerationReport) -> Result<()> {
157        let output_dir = self.base_dir.join("interop/validator/qos");
158        fs::create_dir_all(&output_dir).context("Failed to create qos directory")?;
159
160        for policy in &self.config.policies {
161            for variant in &policy.variants {
162                let filename =
163                    format!("policy_{:03}_{}_{}", policy.id, policy.name, variant.name) + ".xml";
164                let filepath = output_dir.join(&filename);
165
166                // Build context for template rendering
167                let mut ctx = tera::Context::new();
168                ctx.insert("policy_id", &format!("{:03}", policy.id));
169                ctx.insert("policy_name", &policy.name);
170                ctx.insert("variant_name", &variant.name);
171
172                // Render baseline template
173                let mut rendered = self
174                    .tera
175                    .render("baseline", &ctx)
176                    .context(format!("Failed to render baseline for {}", policy.name))?;
177
178                // Apply variant-specific XML overrides
179                rendered = self
180                    .apply_variant_overrides(&rendered, policy, variant)
181                    .context(format!(
182                        "Failed to apply overrides for policy {}",
183                        policy.id
184                    ))?;
185
186                fs::write(&filepath, &rendered).context(format!("Failed to write {}", filename))?;
187
188                report.qos_files_generated.push(filename);
189            }
190        }
191
192        tracing::info!(
193            "[OK] Generated {} QoS profiles",
194            report.qos_files_generated.len()
195        );
196        Ok(())
197    }
198
199    /// Apply variant-specific XML value overrides to baseline XML
200    fn apply_variant_overrides(
201        &self,
202        baseline_xml: &str,
203        _policy: &QosPolicy,
204        variant: &PolicyVariant,
205    ) -> Result<String> {
206        let mut result = baseline_xml.to_string();
207
208        // Apply each variant value to the XML
209        // The xml_path format is "parent/child" (e.g., "reliability/kind")
210        for (xml_path, override_value) in &variant.values {
211            // Convert YAML value to string
212            let value_str = match override_value {
213                serde_yaml::Value::String(s) => s.clone(),
214                serde_yaml::Value::Number(n) => n.to_string(),
215                serde_yaml::Value::Bool(b) => if *b { "TRUE" } else { "FALSE" }.to_string(),
216                _ => continue,
217            };
218
219            // Split path into parent and child
220            let parts: Vec<&str> = xml_path.split('/').collect();
221            if parts.len() < 2 {
222                continue; // Need at least parent/child
223            }
224
225            let parent_elem = parts[parts.len() - 2];
226            let child_elem = parts[parts.len() - 1];
227
228            // Build a regex pattern that matches <parent>...<child>...</child>...</parent>
229            // Using [\s\S]*? for non-greedy multiline matching
230            let pattern = format!(
231                "<{}>[\\s\\S]*?<{}>[^<]*</{}>",
232                regex::escape(parent_elem),
233                regex::escape(child_elem),
234                regex::escape(child_elem)
235            );
236
237            if let Ok(re) = Regex::new(&pattern) {
238                // Build replacement that preserves the parent element
239                let replacement = format!(
240                    "<{}><{}>{}</{}>",
241                    parent_elem, child_elem, value_str, child_elem
242                );
243
244                result = re.replace_all(&result, &replacement).to_string();
245            }
246        }
247
248        Ok(result)
249    }
250
251    /// Generate 96 test scripts (48 variants x 2 directions)
252    fn generate_test_scripts(&self, report: &mut GenerationReport) -> Result<()> {
253        let output_dir = self.base_dir.join("interop/validator/scripts");
254        fs::create_dir_all(&output_dir).context("Failed to create scripts directory")?;
255
256        let directions = vec![("fd2hd", "FastDDS to HDDS"), ("hd2fd", "HDDS to FastDDS")];
257
258        for policy in &self.config.policies {
259            for variant in &policy.variants {
260                for (dir, dir_label) in &directions {
261                    let filename = format!(
262                        "policy_{:03}_{}_{}_{}",
263                        policy.id, policy.name, variant.name, dir
264                    ) + ".sh";
265                    let filepath = output_dir.join(&filename);
266
267                    let qos_filename = format!(
268                        "policy_{:03}_{}_{}.xml",
269                        policy.id, policy.name, variant.name
270                    );
271
272                    // Build context for test script template
273                    let mut ctx = tera::Context::new();
274                    ctx.insert("policy_id", &format!("{:03}", policy.id));
275                    ctx.insert("policy_name", &policy.name);
276                    ctx.insert("policy_category", &policy.category);
277                    ctx.insert("variant_name", &variant.name);
278                    ctx.insert(
279                        "variant_description",
280                        &variant.description.as_ref().unwrap_or(&"".to_string()),
281                    );
282                    ctx.insert("direction", dir);
283                    ctx.insert("direction_label", dir_label);
284                    ctx.insert(
285                        "profile_name",
286                        &format!("policy_{:03}_{}_{}", policy.id, policy.name, variant.name),
287                    );
288                    ctx.insert("qos_file", &qos_filename);
289
290                    let rendered = match self.tera.render("test_script", &ctx) {
291                        Ok(r) => r,
292                        Err(e) => {
293                            eprintln!("Tera error: {}", e);
294                            eprintln!("Context: policy_id={}, direction={}", policy.id, dir);
295                            Err(e).context(format!(
296                                "Failed to render test script for policy {} variant {}",
297                                policy.id, variant.name
298                            ))?
299                        }
300                    };
301
302                    fs::write(&filepath, &rendered)
303                        .context(format!("Failed to write {}", filename))?;
304
305                    // Make executable
306                    #[cfg(unix)]
307                    {
308                        use std::os::unix::fs::PermissionsExt;
309                        let perms = fs::Permissions::from_mode(0o755);
310                        fs::set_permissions(&filepath, perms)
311                            .context(format!("Failed to chmod {}", filename))?;
312                    }
313
314                    report.test_scripts_generated.push(filename);
315                }
316            }
317        }
318
319        tracing::info!(
320            "[OK] Generated {} test scripts",
321            report.test_scripts_generated.len()
322        );
323        Ok(())
324    }
325
326    /// Generate manifest.json
327    fn generate_manifest(&self, report: &GenerationReport) -> Result<()> {
328        let output_dir = self.base_dir.join("interop/validator/generator/output");
329        fs::create_dir_all(&output_dir).context("Failed to create output directory")?;
330
331        let manifest = serde_json::json!({
332            "generated_date": chrono::Local::now().to_rfc3339(),
333            "qos_profiles": report.qos_files_generated,
334            "test_scripts": report.test_scripts_generated,
335            "total_tests": report.test_scripts_generated.len(),
336            "policies_count": self.config.policies.len(),
337            "variants_count": report.qos_files_generated.len(),
338        });
339
340        let manifest_path = output_dir.join("manifest.json");
341        fs::write(&manifest_path, serde_json::to_string_pretty(&manifest)?)
342            .context("Failed to write manifest.json")?;
343
344        tracing::info!("[OK] Generated manifest.json");
345        Ok(())
346    }
347
348    /// Generate agent_watch.sh snippet
349    fn generate_agent_snippet(&self, _report: &GenerationReport) -> Result<()> {
350        let output_dir = self.base_dir.join("interop/validator/generator/output");
351        fs::create_dir_all(&output_dir).context("Failed to create output directory")?;
352
353        let mut snippet =
354            String::from("# Auto-generated agent cases (append to agent_watch.sh)\n\n");
355
356        let directions = vec!["fd2hd", "hd2fd"];
357        for policy in &self.config.policies {
358            for variant in &policy.variants {
359                for dir in &directions {
360                    let task_name = format!(
361                        "validator_policy_{:03}_{}_{}_{}",
362                        policy.id, policy.name, variant.name, dir
363                    );
364                    let script_name = format!(
365                        "policy_{:03}_{}_{}_{}.sh",
366                        policy.id, policy.name, variant.name, dir
367                    );
368
369                    snippet.push_str(&format!(
370                        "    {}) \n        bash {}\n        ;;\n\n",
371                        task_name, script_name
372                    ));
373                }
374            }
375        }
376
377        let snippet_path = output_dir.join("agent_watch_snippet.sh");
378        fs::write(&snippet_path, snippet).context("Failed to write agent_watch_snippet.sh")?;
379
380        tracing::info!("[OK] Generated agent_watch_snippet.sh");
381        Ok(())
382    }
383
384    /// Generate Makefile snippet
385    fn generate_makefile_snippet(&self, _report: &GenerationReport) -> Result<()> {
386        let output_dir = self.base_dir.join("interop/validator/generator/output");
387        fs::create_dir_all(&output_dir).context("Failed to create output directory")?;
388
389        let mut snippet =
390            String::from("# Auto-generated Makefile targets (append to Makefile)\n\n");
391
392        let directions = vec!["fd2hd", "hd2fd"];
393        for policy in &self.config.policies {
394            for variant in &policy.variants {
395                for dir in &directions {
396                    let target_name = format!(
397                        "run-validator-policy-{:03}-{}-{}-{}",
398                        policy.id,
399                        policy.name.replace("_", "-"),
400                        variant.name.replace("_", "-"),
401                        dir
402                    );
403                    let task_name = format!(
404                        "validator_policy_{:03}_{}_{}_{}",
405                        policy.id, policy.name, variant.name, dir
406                    );
407
408                    snippet.push_str(&format!(
409                        "{}:\n\t@printf 'TASK={}\\n' > agent_triggers/run\n\n",
410                        target_name, task_name
411                    ));
412                }
413            }
414        }
415
416        let snippet_path = output_dir.join("makefile_snippet.mk");
417        fs::write(&snippet_path, snippet).context("Failed to write makefile_snippet.mk")?;
418
419        tracing::info!("[OK] Generated makefile_snippet.mk");
420        Ok(())
421    }
422}
423
424/// Generation report
425#[derive(Default)]
426pub struct GenerationReport {
427    pub qos_files_generated: Vec<String>,
428    pub test_scripts_generated: Vec<String>,
429}
430
431impl GenerationReport {
432    pub fn new() -> Self {
433        Self::default()
434    }
435
436    pub fn summary(&self) {
437        println!("\n{}", "=".repeat(60));
438        println!("  QoS Validator Generation Report");
439        println!("{}", "=".repeat(60));
440        println!();
441        println!(
442            "  [OK] QoS Profiles:    {} files",
443            self.qos_files_generated.len()
444        );
445        println!(
446            "  [OK] Test Scripts:    {} files",
447            self.test_scripts_generated.len()
448        );
449        println!(
450            "  [OK] Total Tests:     {} (variants x 2 directions)",
451            self.test_scripts_generated.len() / 2
452        );
453        println!();
454        println!("  Generated in:");
455        println!("    - <base_dir>/interop/validator/qos/");
456        println!("    - <base_dir>/interop/validator/scripts/");
457        println!("    - <base_dir>/interop/validator/generator/output/");
458        println!();
459        println!("{}", "=".repeat(60));
460    }
461}