diff --git a/docs/exports.md b/docs/exports.md new file mode 100644 index 0000000..4cadff3 --- /dev/null +++ b/docs/exports.md @@ -0,0 +1,144 @@ +# Exports + +Exports allow resources to publish values (e.g. IDs, ARNs, names) so that +subsequent resources in the stack can reference them. + +## Defining exports + +Add an `exports` field to a resource in `stackql_manifest.yml`. The exports +query in the resource's `.iql` file must return columns that match the +export names. + +### Simple exports + +```yaml +resources: + - name: example_vpc + props: + - name: cidr_block + value: "10.0.0.0/16" + exports: + - vpc_id + - vpc_cidr_block +``` + +The `exports` anchor in the `.iql` file must return these columns: + +```sql +/*+ exports */ +SELECT vpc_id, cidr_block AS vpc_cidr_block +FROM awscc.ec2.vpcs +WHERE region = '{{ region }}' AND vpc_id = '{{ vpc_id }}'; +``` + +### Aliased exports + +Use the mapping format to rename columns on export: + +```yaml +exports: + - arn: aws_iam_cross_account_role_arn + - role_name: aws_iam_role_name +``` + +Here the exports query returns columns `arn` and `role_name`, but they are +stored in the context under the alias names `aws_iam_cross_account_role_arn` +and `aws_iam_role_name`. + +## Referencing exported values + +Exported values are injected into the global template context and can be +referenced by any subsequent resource using `{{ variable_name }}`. + +### Unscoped references + +The simplest way to reference an export is by its name (or alias): + +```yaml +- name: example_subnet + props: + - name: vpc_id + value: "{{ vpc_id }}" +``` + +Unscoped names follow **last-writer-wins** semantics: if two resources both +export a variable called `vpc_id`, subsequent resources will see the value +from whichever resource was processed last. + +### Resource-scoped references + +Every export is also available under a **resource-scoped** name of the form +`resource_name.variable_name`. This name is **immutable** -- once set, it +cannot be overwritten by a later resource: + +```yaml +- name: example_subnet + props: + - name: vpc_id + value: "{{ example_vpc.vpc_id }}" +``` + +Resource-scoped names are useful when: + +- Multiple resources export variables with the same name and you need to + reference a specific one unambiguously. +- You want to make it clear which resource a value originates from for + readability and maintainability. + +### Example + +```yaml +resources: + - name: aws_cross_account_role + file: aws/iam/roles.iql + props: + - name: role_name + value: "{{ stack_name }}-{{ stack_env }}-role" + # ... + exports: + - role_name: aws_iam_cross_account_role_name + - arn: aws_iam_cross_account_role_arn + + - name: databricks_account/credentials + props: + - name: credentials_name + value: "{{ stack_name }}-{{ stack_env }}-credentials" + - name: aws_credentials + value: + sts_role: + # Unscoped -- works because only one resource exports this name + role_arn: "{{ aws_iam_cross_account_role_arn }}" + # Resource-scoped -- always unambiguous + # role_arn: "{{ aws_cross_account_role.aws_iam_cross_account_role_arn }}" +``` + +## Protected exports + +Sensitive values can be masked in log output by listing them under +`protected`: + +```yaml +- name: secret_resource + props: [] + exports: + - api_key + protected: + - api_key +``` + +The actual value is still stored in the context and usable by templates; +only the log messages are masked. + +## Stack-level exports + +The top-level `exports` field in the manifest lists variables that are +written to a JSON output file (when `--output` is specified): + +```yaml +exports: + - vpc_id + - subnet_id +``` + +The output file always includes `stack_name`, `stack_env`, and +`elapsed_time` automatically. diff --git a/src/commands/base.rs b/src/commands/base.rs index b947dbf..6feb76e 100644 --- a/src/commands/base.rs +++ b/src/commands/base.rs @@ -13,9 +13,7 @@ use std::process; use log::{debug, error, info}; use pgwire_lite::PgwireLite; -use crate::core::config::{ - get_full_context, render_globals, render_string_value, -}; +use crate::core::config::{get_full_context, render_globals, render_string_value}; use crate::core::env::load_env_vars; use crate::core::templating::{self, ParsedQuery}; use crate::core::utils::{ @@ -23,6 +21,7 @@ use crate::core::utils::{ pull_providers, run_ext_script, run_stackql_command, run_stackql_query, show_query, }; use crate::resource::manifest::{Manifest, Resource}; +use crate::resource::validation::validate_manifest; use crate::template::engine::TemplateEngine; /// Core state for all command operations, equivalent to Python's StackQLBase. @@ -54,6 +53,18 @@ impl CommandRunner { // Load manifest let manifest = Manifest::load_from_dir_or_exit(stack_dir); + + // Validate manifest rules + if let Err(errors) = validate_manifest(&manifest) { + for err in &errors { + error!("{}", err); + } + catch_error_and_exit(&format!( + "Manifest validation failed with {} error(s)", + errors.len() + )); + } + let stack_name = manifest.name.clone(); // Render globals @@ -804,4 +815,3 @@ fn evaluate_simple_condition(condition: &str) -> Option { None } - diff --git a/src/commands/common_args.rs b/src/commands/common_args.rs index 342c0ec..ff87503 100644 --- a/src/commands/common_args.rs +++ b/src/commands/common_args.rs @@ -98,4 +98,3 @@ pub fn on_failure() -> Arg { .value_parser(value_parser!(FailureAction)) .default_value("error") } - diff --git a/src/core/utils.rs b/src/core/utils.rs index b29fb86..69065dd 100644 --- a/src/core/utils.rs +++ b/src/core/utils.rs @@ -489,7 +489,11 @@ pub fn export_vars( global_context.entry(scoped_key.clone()).or_insert_with(|| { info!( "set {} [{}] to [{}] in exports", - if is_protected { "protected variable" } else { "variable" }, + if is_protected { + "protected variable" + } else { + "variable" + }, scoped_key, display_value, ); @@ -499,7 +503,11 @@ pub fn export_vars( // --- global (unscoped) key (can be overridden by later resources) --- info!( "set {} [{}] to [{}] in exports", - if is_protected { "protected variable" } else { "variable" }, + if is_protected { + "protected variable" + } else { + "variable" + }, key, display_value, ); @@ -618,7 +626,8 @@ mod tests { assert_eq!(ctx.get("role_name").map(|s| s.as_str()), Some("my-role")); // Resource-scoped key assert_eq!( - ctx.get("aws_cross_account_role.role_name").map(|s| s.as_str()), + ctx.get("aws_cross_account_role.role_name") + .map(|s| s.as_str()), Some("my-role"), ); } @@ -638,7 +647,10 @@ mod tests { export_vars(&mut ctx, "resource_b", &data2, &[]); // Global key reflects the most recent export - assert_eq!(ctx.get("role_name").map(|s| s.as_str()), Some("second-role")); + assert_eq!( + ctx.get("role_name").map(|s| s.as_str()), + Some("second-role") + ); } #[test] @@ -677,7 +689,10 @@ mod tests { export_vars(&mut ctx, "vault", &data, &["secret_key".to_string()]); - assert_eq!(ctx.get("secret_key").map(|s| s.as_str()), Some("super-secret")); + assert_eq!( + ctx.get("secret_key").map(|s| s.as_str()), + Some("super-secret") + ); assert_eq!( ctx.get("vault.secret_key").map(|s| s.as_str()), Some("super-secret"), diff --git a/src/globals.rs b/src/globals.rs index 00f56b5..8704685 100644 --- a/src/globals.rs +++ b/src/globals.rs @@ -118,4 +118,3 @@ pub fn server_port() -> u16 { .copied() .unwrap_or(DEFAULT_SERVER_PORT) } - diff --git a/src/resource/validation.rs b/src/resource/validation.rs index 8cd5519..4f456fe 100644 --- a/src/resource/validation.rs +++ b/src/resource/validation.rs @@ -2,61 +2,43 @@ //! # Manifest Validation Module //! -//! Contains validation rules applied to a [`Manifest`] before any command -//! (`build`, `plan`, `teardown`, `test`) is executed. -//! -//! Each rule is a standalone function that returns either `Ok(())` or a list of -//! [`ValidationError`] values describing what failed. New rules should be added -//! as additional functions and wired into [`validate_manifest`]. -//! -//! ## Current rules -//! -//! | Rule ID | Description | -//! |---------------------------|-------------------------------------------------| -//! | `unique-resource-names` | Every resource name in the stack must be unique | +//! Validates a parsed manifest against a set of rules before any command +//! (build, test, teardown) proceeds. Each rule is a standalone function +//! that returns a list of validation errors. New rules can be added by +//! implementing a function with the signature +//! `fn(manifest: &Manifest) -> Vec` and appending it to +//! the `RULES` array in [`validate_manifest`]. -use std::collections::HashSet; -use std::fmt; +use std::collections::HashMap; use crate::resource::manifest::Manifest; -// --------------------------------------------------------------------------- -// Error type -// --------------------------------------------------------------------------- - -/// A single manifest validation failure. -#[derive(Debug, Clone)] +/// A single validation error with a rule name and human-readable message. +#[derive(Debug, Clone, PartialEq)] pub struct ValidationError { - /// Short, machine-readable identifier for the rule that was violated. - pub rule: &'static str, - - /// Human-readable explanation of what failed. - pub detail: String, + /// Machine-readable rule identifier (e.g. `"unique_resource_names"`). + pub rule: String, + /// Human-readable description of the violation. + pub message: String, } -impl fmt::Display for ValidationError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "[{}] {}", self.rule, self.detail) +impl std::fmt::Display for ValidationError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "[{}] {}", self.rule, self.message) } } -// --------------------------------------------------------------------------- -// Entry point -// --------------------------------------------------------------------------- - -/// Run all manifest validation rules against `manifest`. +/// Validate a manifest against all registered rules. /// -/// All rules are evaluated (fail-all, not fail-fast) so that callers receive a -/// complete picture of every problem at once. -/// -/// Returns `Ok(())` when the manifest passes every rule, or -/// `Err(Vec)` containing one entry per failing check. +/// Returns `Ok(())` when the manifest is valid, or `Err(Vec)` +/// containing every violation found (rules are not short-circuited). pub fn validate_manifest(manifest: &Manifest) -> Result<(), Vec> { - let mut errors: Vec = Vec::new(); + // Register rules here. Each entry is a function that accepts a &Manifest + // and returns a Vec. Adding a new rule is as simple as + // appending another entry to this list. + let rules: Vec Vec> = vec![rule_unique_resource_names]; - collect(&mut errors, validate_unique_resource_names(manifest)); - // Wire in additional rules here as the list grows: - // collect(&mut errors, validate_some_other_rule(manifest)); + let errors: Vec = rules.iter().flat_map(|rule| rule(manifest)).collect(); if errors.is_empty() { Ok(()) @@ -65,199 +47,226 @@ pub fn validate_manifest(manifest: &Manifest) -> Result<(), Vec } } -/// Append errors from a rule result into the accumulator. -fn collect(acc: &mut Vec, result: Result<(), Vec>) { - if let Err(mut errs) = result { - acc.append(&mut errs); - } -} - // --------------------------------------------------------------------------- -// Rule: unique-resource-names +// Rules // --------------------------------------------------------------------------- -/// Validates that every resource `name` in the stack is unique. -/// -/// Resource names must be unique because: -/// -/// * Resources are processed in declaration order — a duplicate name leads to -/// ambiguous processing behaviour. -/// * Resource-scoped export keys (`{resource_name}.{export}`) are immutable -/// once written. A second resource with the same name would attempt to write -/// the same scoped keys, making them permanently incorrect. +/// Resource names within a manifest must be unique. /// -/// **Rule ID**: `unique-resource-names` -fn validate_unique_resource_names(manifest: &Manifest) -> Result<(), Vec> { - let mut seen: HashSet<&str> = HashSet::new(); - let mut errors: Vec = Vec::new(); +/// Because resource-scoped exports use the resource name as a namespace +/// (e.g. `{{ my_resource.var }}`), duplicate names would create ambiguous +/// references and silently overwrite immutable scoped exports. +fn rule_unique_resource_names(manifest: &Manifest) -> Vec { + let mut seen: HashMap<&str, usize> = HashMap::new(); + let mut errors = Vec::new(); - for resource in &manifest.resources { - if !seen.insert(resource.name.as_str()) { + for (idx, resource) in manifest.resources.iter().enumerate() { + if let Some(&first_idx) = seen.get(resource.name.as_str()) { errors.push(ValidationError { - rule: "unique-resource-names", - detail: format!( - "resource name '{}' appears more than once in stack '{}'; \ - every resource name must be unique within a stack", - resource.name, manifest.name, + rule: "unique_resource_names".to_string(), + message: format!( + "Duplicate resource name '{}' at index {} (first seen at index {})", + resource.name, idx, first_idx ), }); + } else { + seen.insert(&resource.name, idx); } } - if errors.is_empty() { - Ok(()) - } else { - Err(errors) - } + errors } -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - #[cfg(test)] mod tests { use super::*; + use crate::resource::manifest::{Manifest, Resource}; + use std::fs; - // ----------------------------------------------------------------------- - // Helpers - // ----------------------------------------------------------------------- - - /// Parse a manifest from an inline YAML string. - /// - /// Panics with a clear message if the YAML is malformed, so test failures - /// are easy to diagnose. - fn parse(yaml: &str) -> Manifest { - serde_yaml::from_str(yaml).unwrap_or_else(|e| { - panic!("test manifest YAML is invalid: {}\n\nYAML:\n{}", e, yaml) - }) + /// Helper to build a minimal valid manifest with the given resource names. + fn manifest_with_resources(names: &[&str]) -> Manifest { + Manifest { + version: 1, + name: "test-stack".to_string(), + description: String::new(), + providers: vec!["aws".to_string()], + globals: vec![], + resources: names + .iter() + .map(|n| Resource { + name: n.to_string(), + r#type: "resource".to_string(), + file: None, + sql: None, + run: None, + props: vec![], + exports: vec![], + protected: vec![], + description: String::new(), + r#if: None, + skip_validation: None, + auth: None, + }) + .collect(), + exports: vec![], + } } - // ----------------------------------------------------------------------- - // Rule: unique-resource-names — positive (valid) fixture - // ----------------------------------------------------------------------- - - /// Manifest where every resource name is distinct. Should pass. - const VALID_UNIQUE_NAMES: &str = r#" -version: 1 -name: test-stack -providers: - - aws -resources: - - name: vpc - props: - - name: vpc_name - value: my-vpc - - name: subnet - props: - - name: subnet_name - value: my-subnet - - name: role - props: - - name: role_name - value: my-role -"#; + // -------------------------------------------------- + // rule_unique_resource_names + // -------------------------------------------------- #[test] fn test_unique_resource_names_valid() { - let manifest = parse(VALID_UNIQUE_NAMES); + let manifest = manifest_with_resources(&["vpc", "subnet", "security_group"]); + let result = validate_manifest(&manifest); + assert!(result.is_ok(), "Expected valid manifest, got: {:?}", result); + } + + #[test] + fn test_unique_resource_names_empty_resources() { + let manifest = manifest_with_resources(&[]); let result = validate_manifest(&manifest); assert!( result.is_ok(), - "A manifest with distinct resource names should pass validation, got: {:?}", - result, + "Empty resources list should be valid, got: {:?}", + result ); } - // ----------------------------------------------------------------------- - // Rule: unique-resource-names — negative (invalid) fixture - // ----------------------------------------------------------------------- - - /// Manifest where `vpc` appears twice. Should fail with exactly one error. - const INVALID_DUPLICATE_NAMES: &str = r#" -version: 1 -name: test-stack -providers: - - aws -resources: - - name: vpc - props: - - name: vpc_name - value: my-vpc - - name: role - props: - - name: role_name - value: my-role - - name: vpc - props: - - name: vpc_name - value: another-vpc -"#; + #[test] + fn test_unique_resource_names_single_resource() { + let manifest = manifest_with_resources(&["only_one"]); + let result = validate_manifest(&manifest); + assert!(result.is_ok()); + } #[test] - fn test_unique_resource_names_duplicate_fails() { - let manifest = parse(INVALID_DUPLICATE_NAMES); + fn test_unique_resource_names_duplicate() { + let manifest = manifest_with_resources(&["vpc", "subnet", "vpc"]); let result = validate_manifest(&manifest); + assert!(result.is_err(), "Expected duplicate to be detected"); + let errors = result.unwrap_err(); + assert_eq!(errors.len(), 1); + assert_eq!(errors[0].rule, "unique_resource_names"); assert!( - result.is_err(), - "A manifest with duplicate resource names must fail validation", + errors[0].message.contains("vpc"), + "Error should mention the duplicate name, got: {}", + errors[0].message ); + } + + #[test] + fn test_unique_resource_names_multiple_duplicates() { + let manifest = manifest_with_resources(&["a", "b", "a", "c", "b", "a"]); + let result = validate_manifest(&manifest); + assert!(result.is_err()); let errors = result.unwrap_err(); + // "a" appears at indices 0, 2, 5 → 2 errors + // "b" appears at indices 1, 4 → 1 error assert_eq!( errors.len(), - 1, - "Expected exactly one validation error for one duplicate, got: {:?}", - errors, + 3, + "Expected 3 duplicate errors, got: {:?}", + errors ); - assert_eq!( - errors[0].rule, "unique-resource-names", - "Error must reference the correct rule ID", + } + + // -------------------------------------------------- + // validate_manifest integration + // -------------------------------------------------- + + #[test] + fn test_validate_manifest_reports_all_rule_violations() { + // Currently only one rule, but this test verifies the aggregation logic + let manifest = manifest_with_resources(&["dup", "dup"]); + let errors = validate_manifest(&manifest).unwrap_err(); + assert!(!errors.is_empty()); + assert_eq!(errors[0].rule, "unique_resource_names"); + } + + // -------------------------------------------------- + // YAML file-based tests (positive & negative) + // -------------------------------------------------- + + /// Helper: create a temp stack directory with a manifest and empty resources/. + fn write_manifest_file(content: &str) -> tempfile::TempDir { + let dir = tempfile::tempdir().unwrap(); + fs::create_dir_all(dir.path().join("resources")).unwrap(); + fs::write(dir.path().join("stackql_manifest.yml"), content).unwrap(); + dir + } + + #[test] + fn test_valid_manifest_file_passes_validation() { + let dir = write_manifest_file( + r#" +version: 1 +name: valid-stack +description: a valid manifest +providers: + - aws +resources: + - name: vpc + props: + - name: cidr + value: "10.0.0.0/16" + - name: subnet + props: + - name: cidr + value: "10.0.1.0/24" + - name: security_group + props: + - name: description + value: "web traffic" +"#, ); + + let manifest = Manifest::load_from_stack_dir(dir.path()).unwrap(); + let result = validate_manifest(&manifest); assert!( - errors[0].detail.contains("vpc"), - "Error detail must mention the duplicate resource name 'vpc', got: {}", - errors[0].detail, + result.is_ok(), + "Valid manifest should pass, got: {:?}", + result ); } #[test] - fn test_unique_resource_names_multiple_duplicates() { - // Two independent duplicate pairs: 'vpc' and 'role' each appear twice. - let yaml = r#" + fn test_duplicate_names_manifest_file_fails_validation() { + let dir = write_manifest_file( + r#" version: 1 -name: test-stack +name: bad-stack +description: manifest with duplicate resource names providers: - aws resources: - - name: vpc - props: - - name: vpc_name - value: my-vpc - - name: vpc + - name: my_bucket props: - - name: vpc_name - value: another-vpc - - name: role + - name: bucket_name + value: "bucket-one" + - name: my_role props: - name: role_name - value: my-role - - name: role + value: "role-one" + - name: my_bucket props: - - name: role_name - value: another-role -"#; - let manifest = parse(yaml); - let result = validate_manifest(&manifest); + - name: bucket_name + value: "bucket-two" +"#, + ); - assert!(result.is_err()); - let errors = result.unwrap_err(); - assert_eq!( - errors.len(), - 2, - "Expected two errors (one per duplicate pair), got: {:?}", - errors, + // load_from_stack_dir already runs validate_manifest internally, + // so a manifest with duplicate names should fail to load. + let result = Manifest::load_from_stack_dir(dir.path()); + assert!(result.is_err(), "Duplicate names should fail to load"); + let err_msg = result.unwrap_err().to_string(); + assert!( + err_msg.contains("my_bucket"), + "Error should mention the duplicate name, got: {}", + err_msg ); } } diff --git a/src/template/engine.rs b/src/template/engine.rs index a29281a..6aa660e 100644 --- a/src/template/engine.rs +++ b/src/template/engine.rs @@ -77,15 +77,13 @@ impl TemplateEngine { } /// Renders a template string using a HashMap context. + /// Dotted keys are converted to nested objects for Tera property access. pub fn render_template( &self, template: &str, context: &HashMap, ) -> TemplateResult { - let mut tera_context = TeraContext::new(); - for (key, value) in context { - tera_context.insert(key, value); - } + let tera_context = build_tera_context(context); self.render_with_tera_context(template, &tera_context) } @@ -113,6 +111,10 @@ impl TemplateEngine { /// Renders a template string with context and custom filters. /// This method creates a fresh Tera instance with the template registered, /// which allows custom filters to work. + /// + /// Dotted keys in `context` (e.g. `"resource.var"`) are automatically + /// converted to nested objects so that Tera's native property-access syntax + /// (`{{ resource.var }}`) works correctly. pub fn render_with_filters( &self, template_name: &str, @@ -125,10 +127,7 @@ impl TemplateEngine { tera.add_raw_template(template_name, template) .map_err(|e| TemplateError::SyntaxError(full_error_chain(&e)))?; - let mut tera_context = TeraContext::new(); - for (key, value) in context { - tera_context.insert(key, value); - } + let mut tera_context = build_tera_context(context); // Add uuid global function via context let uuid_val = uuid::Uuid::new_v4().to_string(); @@ -158,6 +157,44 @@ fn full_error_chain(err: &dyn StdError) -> String { parts.join(": ") } +/// Build a Tera context from a flat `HashMap`. +/// +/// Keys that contain a `.` (e.g. `"resource_name.var"`) are grouped into +/// nested objects so that Tera's property-access syntax works: +/// +/// ```text +/// context["my_vpc.vpc_id"] = "vpc-123" +/// ↓ +/// Tera context: { my_vpc: { vpc_id: "vpc-123" }, ... } +/// → {{ my_vpc.vpc_id }} renders as "vpc-123" +/// ``` +/// +/// Non-dotted keys are inserted as top-level strings as before. +fn build_tera_context(context: &HashMap) -> TeraContext { + let mut tera_context = TeraContext::new(); + + // Collect dotted keys grouped by prefix + let mut nested: HashMap> = HashMap::new(); + + for (key, value) in context { + if let Some((prefix, suffix)) = key.split_once('.') { + nested + .entry(prefix.to_string()) + .or_default() + .insert(suffix.to_string(), value.clone()); + } else { + tera_context.insert(key, value); + } + } + + // Insert each prefix group as a nested object + for (prefix, map) in &nested { + tera_context.insert(prefix, map); + } + + tera_context +} + /// Register all custom Jinja2 filters matching the Python implementation. fn register_custom_filters(tera: &mut Tera) { tera.register_filter("from_json", filter_from_json); @@ -424,4 +461,53 @@ mod tests { other => panic!("Expected VariableNotFound error, got: {:?}", other), } } + + #[test] + fn test_dotted_key_renders_as_nested_property() { + let engine = TemplateEngine::new(); + let mut context = HashMap::new(); + context.insert("my_vpc.vpc_id".to_string(), "vpc-abc123".to_string()); + // Also insert unscoped key (mirrors export_vars behaviour) + context.insert("vpc_id".to_string(), "vpc-abc123".to_string()); + + // Resource-scoped reference + let result = engine + .render_with_filters("t1", "{{ my_vpc.vpc_id }}", &context) + .unwrap(); + assert_eq!(result, "vpc-abc123"); + + // Unscoped reference still works + let result2 = engine + .render_with_filters("t2", "{{ vpc_id }}", &context) + .unwrap(); + assert_eq!(result2, "vpc-abc123"); + } + + #[test] + fn test_dotted_key_in_simple_render() { + let engine = TemplateEngine::new(); + let mut context = HashMap::new(); + context.insert( + "aws_cross_account_role.role_arn".to_string(), + "arn:aws:iam::123:role/test".to_string(), + ); + + let result = engine + .render("ARN: {{ aws_cross_account_role.role_arn }}", &context) + .unwrap(); + assert_eq!(result, "ARN: arn:aws:iam::123:role/test"); + } + + #[test] + fn test_multiple_dotted_keys_same_prefix() { + let engine = TemplateEngine::new(); + let mut context = HashMap::new(); + context.insert("res.a".to_string(), "val_a".to_string()); + context.insert("res.b".to_string(), "val_b".to_string()); + + let result = engine + .render_with_filters("t", "{{ res.a }}-{{ res.b }}", &context) + .unwrap(); + assert_eq!(result, "val_a-val_b"); + } } diff --git a/src/utils/display.rs b/src/utils/display.rs index 7e303c4..23f0a62 100644 --- a/src/utils/display.rs +++ b/src/utils/display.rs @@ -90,4 +90,3 @@ macro_rules! print_success { println!("{}", format!($($arg)*).green()) }}; } -