From 87b1d5ad092c13b60d913d74fba97604652475b4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 23 Feb 2026 19:56:04 +0000 Subject: [PATCH 1/2] Add resource-scoped exports and manifest validation Resource-scoped exports: - export_vars() now stores each exported variable under both its plain name (e.g. `role_name`) and a resource-scoped name (e.g. `aws_cross_account_role.role_name`) - Plain names follow last-writer-wins; scoped names are immutable - Template engine converts dotted context keys into nested Tera objects so {{ resource.var }} property-access syntax works naturally - Both render_template and render_with_filters support dotted keys Manifest validation: - New src/resource/validation.rs module with extensible rules-based architecture -- add a rule by appending a function to the rules vec - First rule: unique_resource_names -- rejects manifests with duplicate resource names (critical since scoped exports rely on unique names) - Validation runs in CommandRunner::new before any command proceeds - Unit tests with in-memory manifests and YAML file-based tests (positive and negative cases) Documentation: - New docs/exports.md covering unscoped/scoped references, aliased exports, protected exports, and stack-level exports https://claude.ai/code/session_01FikwxWpZdvhtNbECVWJiqK --- docs/exports.md | 144 ++++++++++++++++++++ src/commands/base.rs | 13 ++ src/core/utils.rs | 44 +++++- src/resource/mod.rs | 1 + src/resource/validation.rs | 270 +++++++++++++++++++++++++++++++++++++ src/template/engine.rs | 102 ++++++++++++-- 6 files changed, 561 insertions(+), 13 deletions(-) create mode 100644 docs/exports.md create mode 100644 src/resource/validation.rs 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 03a7056..8e83606 100644 --- a/src/commands/base.rs +++ b/src/commands/base.rs @@ -23,6 +23,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; // display imports available for future use @@ -55,6 +56,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 diff --git a/src/core/utils.rs b/src/core/utils.rs index 74a467c..2cda12c 100644 --- a/src/core/utils.rs +++ b/src/core/utils.rs @@ -458,21 +458,55 @@ fn is_version_higher(installed: &str, requested: &str) -> bool { } /// Update global context with exported values. -/// Matches Python's `export_vars`. +/// +/// Each exported variable is stored twice: +/// 1. **Unscoped** (`key`) – can be referenced as `{{ key }}` by subsequent +/// resources but may be overridden if a later resource exports the same name. +/// 2. **Resource-scoped** (`resource_name.key`) – immutable once set; provides +/// an unambiguous reference (e.g. `{{ aws_cross_account_role.role_name }}`). +/// +/// Tera templates use `.` in variable names without issue, so both forms are +/// usable in `{{ }}` expressions. pub fn export_vars( global_context: &mut HashMap, - _resource_name: &str, + resource_name: &str, export_data: &HashMap, protected_exports: &[String], ) { for (key, value) in export_data { - if protected_exports.contains(key) { - let mask = "*".repeat(value.len()); - info!("set protected variable [{}] to [{}] in exports", key, mask); + let is_protected = protected_exports.contains(key); + let display_value = if is_protected { + "*".repeat(value.len()) + } else { + value.clone() + }; + + // 1. Unscoped name (can be overridden by later resources) + if is_protected { + info!( + "set protected variable [{}] to [{}] in exports", + key, display_value + ); } else { info!("set [{}] to [{}] in exports", key, value); } global_context.insert(key.clone(), value.clone()); + + // 2. Resource-scoped name (immutable – never overwritten) + let scoped_key = format!("{}.{}", resource_name, key); + if let std::collections::hash_map::Entry::Vacant(entry) = + global_context.entry(scoped_key.clone()) + { + if is_protected { + info!( + "set protected variable [{}] to [{}] in exports", + scoped_key, display_value + ); + } else { + info!("set [{}] to [{}] in exports", scoped_key, value); + } + entry.insert(value.clone()); + } } } diff --git a/src/resource/mod.rs b/src/resource/mod.rs index 9707ca8..370f325 100644 --- a/src/resource/mod.rs +++ b/src/resource/mod.rs @@ -12,6 +12,7 @@ pub mod manifest; // pub mod operations; // pub mod queries; +pub mod validation; // /// Creates a combined error type for resource operations. // #[derive(thiserror::Error, Debug)] diff --git a/src/resource/validation.rs b/src/resource/validation.rs new file mode 100644 index 0000000..23b037c --- /dev/null +++ b/src/resource/validation.rs @@ -0,0 +1,270 @@ +// resource/validation.rs + +//! # Manifest Validation Module +//! +//! 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::HashMap; + +use crate::resource::manifest::Manifest; + +/// A single validation error with a rule name and human-readable message. +#[derive(Debug, Clone, PartialEq)] +pub struct ValidationError { + /// Machine-readable rule identifier (e.g. `"unique_resource_names"`). + pub rule: String, + /// Human-readable description of the violation. + pub message: String, +} + +impl std::fmt::Display for ValidationError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "[{}] {}", self.rule, self.message) + } +} + +/// Validate a manifest against all registered rules. +/// +/// 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> { + // 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]; + + let errors: Vec = rules.iter().flat_map(|rule| rule(manifest)).collect(); + + if errors.is_empty() { + Ok(()) + } else { + Err(errors) + } +} + +// --------------------------------------------------------------------------- +// Rules +// --------------------------------------------------------------------------- + +/// Resource names within a manifest must be unique. +/// +/// 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 (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".to_string(), + message: format!( + "Duplicate resource name '{}' at index {} (first seen at index {})", + resource.name, idx, first_idx + ), + }); + } else { + seen.insert(&resource.name, idx); + } + } + + errors +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::resource::manifest::{Manifest, Resource}; + use std::fs; + + /// 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 + // -------------------------------------------------- + + #[test] + fn test_unique_resource_names_valid() { + 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(), + "Empty resources list should be valid, got: {:?}", + result + ); + } + + #[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() { + 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!( + 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(), + 3, + "Expected 3 duplicate errors, got: {:?}", + errors + ); + } + + // -------------------------------------------------- + // 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!( + result.is_ok(), + "Valid manifest should pass, got: {:?}", + result + ); + } + + #[test] + fn test_duplicate_names_manifest_file_fails_validation() { + let dir = write_manifest_file( + r#" +version: 1 +name: bad-stack +description: manifest with duplicate resource names +providers: + - aws +resources: + - name: my_bucket + props: + - name: bucket_name + value: "bucket-one" + - name: my_role + props: + - name: role_name + value: "role-one" + - name: my_bucket + props: + - name: bucket_name + value: "bucket-two" +"#, + ); + + let manifest = Manifest::load_from_stack_dir(dir.path()).unwrap(); + let result = validate_manifest(&manifest); + assert!(result.is_err(), "Duplicate names should fail validation"); + + let errors = result.unwrap_err(); + assert_eq!(errors.len(), 1); + assert_eq!(errors[0].rule, "unique_resource_names"); + assert!(errors[0].message.contains("my_bucket")); + } +} 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"); + } } From 94279351b6a2034d34cfdbd54cbf567bd43ec25a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 27 Feb 2026 04:38:38 +0000 Subject: [PATCH 2/2] Fix formatting and failing validation test - Run cargo fmt to fix style issues in base.rs, utils.rs, globals.rs, common_args.rs, and display.rs - Fix test_duplicate_names_manifest_file_fails_validation: Manifest::load_from_stack_dir already calls validate_manifest internally, so a duplicate-name manifest fails at load time; assert on the load error instead of unwrapping it https://claude.ai/code/session_01CVjADJJNzKs6jQvf5RHyee --- src/commands/base.rs | 5 +---- src/commands/common_args.rs | 1 - src/core/utils.rs | 25 ++++++++++++++++++++----- src/globals.rs | 1 - src/resource/validation.rs | 18 ++++++++++-------- src/utils/display.rs | 1 - 6 files changed, 31 insertions(+), 20 deletions(-) diff --git a/src/commands/base.rs b/src/commands/base.rs index 7db6f23..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::{ @@ -817,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 d93f7ee..4f456fe 100644 --- a/src/resource/validation.rs +++ b/src/resource/validation.rs @@ -258,13 +258,15 @@ resources: "#, ); - let manifest = Manifest::load_from_stack_dir(dir.path()).unwrap(); - let result = validate_manifest(&manifest); - assert!(result.is_err(), "Duplicate names should fail validation"); - - let errors = result.unwrap_err(); - assert_eq!(errors.len(), 1); - assert_eq!(errors[0].rule, "unique_resource_names"); - assert!(errors[0].message.contains("my_bucket")); + // 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/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()) }}; } -