Skip to main content

iac_forge/
resolve.rs

1use std::collections::{BTreeMap, HashSet};
2
3use openapi_forge::{Field, Spec};
4
5use crate::error::IacForgeError;
6use crate::ir::{
7    AuthInfo, CrudInfo, IacAction, IacAttribute, IacDataSource, IacProvider, IacResource,
8    IdentityInfo,
9};
10use crate::naming::to_snake_case;
11use crate::spec::{DataSourceSpec, FieldOverride, ProviderDefaults, ProviderSpec, ResourceSpec};
12use crate::type_map::{apply_enum_constraint, openapi_to_iac};
13
14/// Build a single `IacAttribute` from an `OpenAPI` field with optional overrides.
15///
16/// Returns `None` if the field should be skipped (via override or global skip list).
17///
18/// Parameters:
19/// - `field`: the `OpenAPI` field definition
20/// - `override_cfg`: optional per-field override from the TOML spec
21/// - `force_new_fields`: list of field names that force resource replacement
22/// - `reverse_mapping`: maps canonical name -> API response read path
23/// - `is_resource`: `true` for resources, `false` for data sources
24/// - `update_only`: whether this field is update-only (only meaningful for resources)
25#[allow(clippy::fn_params_excessive_bools)]
26fn build_attribute(
27    field: &Field,
28    override_cfg: Option<&FieldOverride>,
29    force_new_fields: &[String],
30    reverse_mapping: &BTreeMap<String, String>,
31    is_resource: bool,
32    update_only: bool,
33) -> Option<IacAttribute> {
34    if override_cfg.is_some_and(|o| o.skip) {
35        return None;
36    }
37
38    let canonical = to_snake_case(&field.name);
39
40    let computed = override_cfg.is_some_and(|o| o.computed);
41    let sensitive = override_cfg.is_some_and(|o| o.sensitive);
42
43    let immutable = if is_resource {
44        override_cfg.is_some_and(|o| o.force_new) || force_new_fields.contains(&field.name)
45    } else {
46        false
47    };
48
49    let type_override = override_cfg.and_then(|o| o.type_override.as_deref());
50    let iac_type = openapi_to_iac(&field.type_info, type_override);
51    let iac_type = apply_enum_constraint(iac_type, &field.enum_values);
52
53    let (required, computed_final, update_only_final) = if is_resource {
54        let req = if computed { false } else { field.required };
55        (req, computed, update_only)
56    } else {
57        // Data sources: all fields are computed unless they're required inputs
58        (false, computed || !field.required, false)
59    };
60
61    let description = override_cfg
62        .and_then(|o| o.description.clone())
63        .or_else(|| field.description.clone())
64        .unwrap_or_default();
65
66    let read_path = reverse_mapping.get(&canonical).cloned();
67
68    Some(IacAttribute {
69        api_name: field.name.clone(),
70        canonical_name: canonical,
71        description,
72        iac_type,
73        required,
74        optional: !required,
75        computed: computed_final,
76        sensitive,
77        json_encoded: false,
78        immutable,
79        default_value: field.default.clone(),
80        enum_values: field.enum_values.clone(),
81        read_path,
82        update_only: update_only_final,
83    })
84}
85
86/// Resolve a resource spec + `OpenAPI` spec into a platform-independent `IacResource`.
87///
88/// This is the core resolution step: it reads `OpenAPI` schema fields, applies
89/// TOML overrides (skip, computed, sensitive, `type_override`, `force_new`), and
90/// produces a fully resolved IR ready for any backend.
91///
92/// # Errors
93///
94/// Returns an error if referenced schemas are missing from the `OpenAPI` spec.
95pub fn resolve_resource(
96    resource: &ResourceSpec,
97    api: &Spec,
98    defaults: &ProviderDefaults,
99) -> Result<IacResource, IacForgeError> {
100    if resource.is_action() {
101        return Err(IacForgeError::ValidationError(format!(
102            "resource '{}' is kind = \"action\"; dispatch to resolve_action instead",
103            resource.resource.name
104        )));
105    }
106    let create_fields = api.fields(&resource.crud.create_schema)?;
107
108    let update_required: HashSet<String> =
109        if let Some(ref update_schema) = resource.crud.update_schema {
110            api.fields(update_schema)
111                .unwrap_or_default()
112                .iter()
113                .filter(|f| f.required)
114                .map(|f| f.name.clone())
115                .collect()
116        } else {
117            HashSet::new()
118        };
119
120    let skip_fields: HashSet<&str> = defaults.skip_fields.iter().map(String::as_str).collect();
121
122    // Build reverse read_mapping: tf_name -> json_path
123    let reverse_mapping: BTreeMap<String, String> = resource
124        .read_mapping
125        .iter()
126        .map(|(json_path, tf_name)| (to_snake_case(tf_name), json_path.clone()))
127        .collect();
128
129    let mut attributes = Vec::new();
130
131    for field in &create_fields {
132        if skip_fields.contains(field.name.as_str()) {
133            continue;
134        }
135
136        let override_cfg = resource.fields.get(&field.name);
137
138        let is_update_required = update_required.contains(&field.name);
139        let update_only = is_update_required && !field.required;
140
141        if let Some(attr) = build_attribute(
142            field,
143            override_cfg,
144            &resource.identity.force_new_fields,
145            &reverse_mapping,
146            true,
147            update_only,
148        ) {
149            attributes.push(attr);
150        }
151    }
152
153    Ok(IacResource {
154        name: resource.resource.name.clone(),
155        description: resource.resource.description.clone(),
156        category: resource.resource.category.clone(),
157        crud: CrudInfo::from(&resource.crud),
158        attributes,
159        identity: IdentityInfo::from(&resource.identity),
160        read_mapping: resource.read_mapping.clone(),
161    })
162}
163
164/// Resolve an action resource spec + `OpenAPI` spec into a platform-independent
165/// [`IacAction`].
166///
167/// Mirrors [`resolve_resource`] but draws the input attributes from the request
168/// body schema named in `spec.action.schema`. The `mutating` flag defaults to
169/// `true` when the TOML leaves it unspecified — see the action TOML schema in
170/// the project docs.
171///
172/// # Errors
173///
174/// Returns:
175/// - [`IacForgeError::ValidationError`] if `spec.is_action()` is `false` or
176///   the `[action]` table is missing.
177/// - [`IacForgeError::SchemaNotFound`] / [`IacForgeError::OpenApiError`] when
178///   the request schema can't be loaded from the `OpenAPI` spec.
179pub fn resolve_action(
180    spec: &ResourceSpec,
181    api: &Spec,
182    defaults: &ProviderDefaults,
183) -> Result<IacAction, IacForgeError> {
184    if !spec.is_action() {
185        return Err(IacForgeError::ValidationError(format!(
186            "resource '{}' has no kind = \"action\"; dispatch to resolve_resource instead",
187            spec.resource.name
188        )));
189    }
190    let action = spec.action.as_ref().ok_or_else(|| {
191        IacForgeError::ValidationError(format!(
192            "resource '{}' has kind = \"action\" but no [action] table",
193            spec.resource.name
194        ))
195    })?;
196
197    let request_fields = api.fields(&action.schema)?;
198
199    let skip_fields: HashSet<&str> = defaults.skip_fields.iter().map(String::as_str).collect();
200
201    // Action inputs aren't keyed by a TOML read_mapping — actions don't have
202    // a read step. Pass an empty map so build_attribute keeps read_path = None.
203    let reverse_mapping: BTreeMap<String, String> = BTreeMap::new();
204
205    let mut attributes = Vec::new();
206    for field in &request_fields {
207        if skip_fields.contains(field.name.as_str()) {
208            continue;
209        }
210        let override_cfg = spec.fields.get(&field.name);
211
212        if let Some(attr) = build_attribute(
213            field,
214            override_cfg,
215            // Actions have no force-replace semantics; the field has no
216            // longer-term identity for the runner to track.
217            &[],
218            &reverse_mapping,
219            // Treat each attribute as a resource input (required/optional
220            // honoured from OpenAPI), but never compute immutability.
221            true,
222            false,
223        ) {
224            attributes.push(attr);
225        }
226    }
227
228    Ok(IacAction {
229        name: spec.resource.name.clone(),
230        description: spec.resource.description.clone(),
231        category: spec.resource.category.clone(),
232        endpoint: action.endpoint.clone(),
233        schema: action.schema.clone(),
234        response_schema: action.response_schema.clone(),
235        mutating: action.mutating.unwrap_or(true),
236        sensitive_response_fields: action.sensitive_response_fields.clone(),
237        attributes,
238        sdk_method: action.sdk_method.clone(),
239    })
240}
241
242/// Resolve a data source spec + `OpenAPI` spec into a platform-independent `IacDataSource`.
243///
244/// # Errors
245///
246/// Returns an error if referenced schemas are missing from the `OpenAPI` spec.
247pub fn resolve_data_source(
248    ds: &DataSourceSpec,
249    api: &Spec,
250    defaults: &ProviderDefaults,
251) -> Result<IacDataSource, IacForgeError> {
252    let read_fields = api.fields(&ds.read.schema)?;
253
254    let skip_fields: HashSet<&str> = defaults.skip_fields.iter().map(String::as_str).collect();
255
256    let reverse_mapping: BTreeMap<String, String> = ds
257        .read_mapping
258        .iter()
259        .map(|(json_path, tf_name)| (to_snake_case(tf_name), json_path.clone()))
260        .collect();
261
262    let mut attributes = Vec::new();
263
264    for field in &read_fields {
265        if skip_fields.contains(field.name.as_str()) {
266            continue;
267        }
268
269        let override_cfg = ds.fields.get(&field.name);
270
271        if let Some(attr) =
272            build_attribute(field, override_cfg, &[], &reverse_mapping, false, false)
273        {
274            attributes.push(attr);
275        }
276    }
277
278    Ok(IacDataSource {
279        name: ds.data_source.name.clone(),
280        description: ds.data_source.description.clone(),
281        read_endpoint: ds.read.endpoint.clone(),
282        read_schema: ds.read.schema.clone(),
283        read_response_schema: ds.read.response_schema.clone(),
284        attributes,
285        read_mapping: ds.read_mapping.clone(),
286    })
287}
288
289/// Resolve a provider spec into a platform-independent `IacProvider`.
290#[must_use]
291pub fn resolve_provider(provider: &ProviderSpec) -> IacProvider {
292    IacProvider {
293        name: provider.provider.name.clone(),
294        description: provider.provider.description.clone(),
295        version: provider.provider.version.clone(),
296        auth: AuthInfo::from(&provider.auth),
297        skip_fields: provider.defaults.skip_fields.clone(),
298        platform_config: provider.platforms.clone(),
299    }
300}
301
302#[cfg(test)]
303mod tests {
304    use super::*;
305    // `Spec::from_str` is `impl FromStr for Spec`, not an inherent method, so
306    // the trait has to be in scope. This built while openapi-forge came from
307    // git and broke the moment it came from the registry — not an API change,
308    // just a different edition/prelude reach at the call site.
309    use std::str::FromStr;
310
311    fn make_test_spec() -> (ResourceSpec, Spec) {
312        let toml_str = r#"
313[resource]
314name = "akeyless_static_secret"
315description = "Static secret"
316category = "secret"
317
318[crud]
319create_endpoint = "/create-secret"
320create_schema = "CreateSecret"
321read_endpoint = "/get-secret-value"
322read_schema = "GetSecretValue"
323delete_endpoint = "/delete-item"
324delete_schema = "DeleteItem"
325
326[identity]
327id_field = "name"
328force_new_fields = ["name"]
329
330[fields]
331token = { skip = true }
332delete_protection = { type_override = "bool" }
333
334[read_mapping]
335"item_name" = "name"
336"item_tags" = "tags"
337"#;
338        let resource: ResourceSpec = toml::from_str(toml_str).expect("parse resource");
339
340        let api_str = r#"
341openapi: "3.0.0"
342info:
343  title: Test
344  version: "1.0"
345paths:
346  /create-secret:
347    post:
348      operationId: createSecret
349      requestBody:
350        content:
351          application/json:
352            schema:
353              $ref: '#/components/schemas/CreateSecret'
354      responses:
355        "200":
356          description: ok
357  /get-secret-value:
358    post:
359      operationId: getSecretValue
360      responses:
361        "200":
362          description: ok
363  /delete-item:
364    post:
365      operationId: deleteItem
366      responses:
367        "200":
368          description: ok
369components:
370  schemas:
371    CreateSecret:
372      type: object
373      required:
374        - name
375        - value
376      properties:
377        name:
378          type: string
379          description: Secret name
380        value:
381          type: string
382          description: Secret value
383        tags:
384          type: array
385          items:
386            type: string
387        token:
388          type: string
389        delete_protection:
390          type: string
391    GetSecretValue:
392      type: object
393      properties:
394        names:
395          type: array
396          items:
397            type: string
398    DeleteItem:
399      type: object
400      properties:
401        name:
402          type: string
403"#;
404        let api = Spec::from_str(api_str).expect("parse api");
405        (resource, api)
406    }
407
408    #[test]
409    fn resolve_resource_basic() {
410        let (resource, api) = make_test_spec();
411        let defaults = ProviderDefaults::default();
412        let iac = resolve_resource(&resource, &api, &defaults).expect("resolve");
413
414        assert_eq!(iac.name, "akeyless_static_secret");
415        assert_eq!(iac.category, "secret");
416        assert_eq!(iac.identity.id_field, "name");
417        assert_eq!(iac.identity.force_replace_fields, vec!["name"]);
418    }
419
420    #[test]
421    fn resolve_resource_skips_token() {
422        let (resource, api) = make_test_spec();
423        let defaults = ProviderDefaults::default();
424        let iac = resolve_resource(&resource, &api, &defaults).expect("resolve");
425
426        assert!(iac.attributes.iter().all(|a| a.api_name != "token"));
427    }
428
429    #[test]
430    fn resolve_resource_type_override() {
431        let (resource, api) = make_test_spec();
432        let defaults = ProviderDefaults::default();
433        let iac = resolve_resource(&resource, &api, &defaults).expect("resolve");
434
435        let dp = iac
436            .attributes
437            .iter()
438            .find(|a| a.canonical_name == "delete_protection")
439            .expect("dp");
440        assert_eq!(dp.iac_type, crate::ir::IacType::Boolean);
441    }
442
443    #[test]
444    fn resolve_resource_required_fields() {
445        let (resource, api) = make_test_spec();
446        let defaults = ProviderDefaults::default();
447        let iac = resolve_resource(&resource, &api, &defaults).expect("resolve");
448
449        let name = iac
450            .attributes
451            .iter()
452            .find(|a| a.canonical_name == "name")
453            .expect("name");
454        assert!(name.required);
455        assert!(name.immutable);
456    }
457
458    #[test]
459    fn resolve_resource_read_mapping() {
460        let (resource, api) = make_test_spec();
461        let defaults = ProviderDefaults::default();
462        let iac = resolve_resource(&resource, &api, &defaults).expect("resolve");
463
464        let name = iac
465            .attributes
466            .iter()
467            .find(|a| a.canonical_name == "name")
468            .expect("name");
469        assert_eq!(name.read_path, Some("item_name".to_string()));
470
471        let tags = iac
472            .attributes
473            .iter()
474            .find(|a| a.canonical_name == "tags")
475            .expect("tags");
476        assert_eq!(tags.read_path, Some("item_tags".to_string()));
477    }
478
479    #[test]
480    fn resolve_resource_read_mapping_populates_field() {
481        let (resource, api) = make_test_spec();
482        let defaults = ProviderDefaults::default();
483        let iac = resolve_resource(&resource, &api, &defaults).expect("resolve");
484
485        // The TOML stores json_response_key -> canonical_param_name.
486        assert_eq!(iac.read_mapping.get("item_name"), Some(&"name".to_string()));
487        assert_eq!(iac.read_mapping.get("item_tags"), Some(&"tags".to_string()));
488        assert_eq!(iac.read_mapping.len(), 2);
489    }
490
491    #[test]
492    fn resolve_data_source_read_mapping_populates_field() {
493        let toml_str = r#"
494[data_source]
495name = "mapped_ds"
496description = "DS with read mapping"
497
498[read]
499endpoint = "/read"
500schema = "ReadSchema"
501
502[read_mapping]
503"resp_name" = "name"
504"#;
505        let ds: DataSourceSpec = toml::from_str(toml_str).expect("parse");
506
507        let api_str = r#"
508openapi: "3.0.0"
509info: { title: Test, version: "1.0" }
510paths: {}
511components:
512  schemas:
513    ReadSchema:
514      type: object
515      required: [name]
516      properties:
517        name: { type: string }
518"#;
519        let api = Spec::from_str(api_str).expect("parse");
520        let defaults = ProviderDefaults::default();
521        let iac = resolve_data_source(&ds, &api, &defaults).expect("resolve");
522
523        assert_eq!(iac.read_mapping.get("resp_name"), Some(&"name".to_string()));
524        assert_eq!(iac.read_mapping.len(), 1);
525    }
526
527    #[test]
528    fn resolve_resource_list_type() {
529        let (resource, api) = make_test_spec();
530        let defaults = ProviderDefaults::default();
531        let iac = resolve_resource(&resource, &api, &defaults).expect("resolve");
532
533        let tags = iac
534            .attributes
535            .iter()
536            .find(|a| a.canonical_name == "tags")
537            .expect("tags");
538        assert_eq!(
539            tags.iac_type,
540            crate::ir::IacType::List(Box::new(crate::ir::IacType::String))
541        );
542    }
543
544    #[test]
545    fn resolve_resource_global_skip() {
546        let (resource, api) = make_test_spec();
547        let defaults = ProviderDefaults {
548            skip_fields: vec!["value".to_string()],
549        };
550        let iac = resolve_resource(&resource, &api, &defaults).expect("resolve");
551
552        assert!(iac.attributes.iter().all(|a| a.api_name != "value"));
553    }
554
555    #[test]
556    fn resolve_resource_update_only() {
557        let (resource, api) = make_test_spec();
558        let defaults = ProviderDefaults::default();
559        let iac = resolve_resource(&resource, &api, &defaults).expect("resolve");
560
561        // Without an update schema, all fields should have update_only = false
562        for attr in &iac.attributes {
563            assert!(
564                !attr.update_only,
565                "field {} should not be update_only",
566                attr.canonical_name
567            );
568        }
569    }
570
571    #[test]
572    fn resolve_provider_basic() {
573        let toml_str = r#"
574[provider]
575name = "akeyless"
576description = "Akeyless Vault Provider"
577version = "1.0.0"
578sdk_import = "github.com/akeylesslabs/akeyless-go/v5"
579
580[auth]
581token_field = "token"
582env_var = "AKEYLESS_ACCESS_TOKEN"
583gateway_url_field = "api_gateway_address"
584gateway_env_var = "AKEYLESS_GATEWAY"
585
586[defaults]
587skip_fields = ["token", "uid-token"]
588"#;
589        let provider: ProviderSpec = toml::from_str(toml_str).expect("parse");
590        let iac = resolve_provider(&provider);
591
592        assert_eq!(iac.name, "akeyless");
593        assert_eq!(iac.auth.env_var, "AKEYLESS_ACCESS_TOKEN");
594        assert_eq!(iac.skip_fields.len(), 2);
595    }
596
597    #[test]
598    fn resolve_data_source_basic() {
599        let toml_str = r#"
600[data_source]
601name = "akeyless_auth_method"
602description = "Read an auth method"
603
604[read]
605endpoint = "/get-auth-method"
606schema = "GetAuthMethod"
607
608[fields]
609token = { skip = true }
610
611[read_mapping]
612"auth_method_access_id" = "access_id"
613"#;
614        let ds: DataSourceSpec = toml::from_str(toml_str).expect("parse");
615
616        let api_str = r#"
617openapi: "3.0.0"
618info: { title: Test, version: "1.0" }
619paths:
620  /get-auth-method:
621    post:
622      operationId: getAuthMethod
623      requestBody:
624        content:
625          application/json:
626            schema:
627              $ref: '#/components/schemas/GetAuthMethod'
628      responses:
629        "200": { description: ok }
630components:
631  schemas:
632    GetAuthMethod:
633      type: object
634      required: [name]
635      properties:
636        name: { type: string, description: "Auth method name" }
637        access_id: { type: string, description: "Access ID" }
638        token: { type: string }
639"#;
640        let api = Spec::from_str(api_str).expect("parse");
641        let defaults = ProviderDefaults::default();
642        let iac = resolve_data_source(&ds, &api, &defaults).expect("resolve");
643
644        assert_eq!(iac.name, "akeyless_auth_method");
645        assert!(iac.attributes.iter().all(|a| a.api_name != "token"));
646
647        let access = iac
648            .attributes
649            .iter()
650            .find(|a| a.canonical_name == "access_id")
651            .expect("access_id");
652        assert!(access.computed);
653        assert_eq!(access.read_path, Some("auth_method_access_id".to_string()));
654    }
655
656    #[test]
657    fn resolve_data_source_update_only_always_false() {
658        let toml_str = r#"
659[data_source]
660name = "test_ds"
661description = "Test"
662
663[read]
664endpoint = "/read"
665schema = "ReadSchema"
666"#;
667        let ds: DataSourceSpec = toml::from_str(toml_str).expect("parse");
668
669        let api_str = r#"
670openapi: "3.0.0"
671info: { title: Test, version: "1.0" }
672paths:
673  /read:
674    post:
675      operationId: read
676      responses:
677        "200": { description: ok }
678components:
679  schemas:
680    ReadSchema:
681      type: object
682      properties:
683        name: { type: string }
684"#;
685        let api = Spec::from_str(api_str).expect("parse");
686        let defaults = ProviderDefaults::default();
687        let iac = resolve_data_source(&ds, &api, &defaults).expect("resolve");
688
689        for attr in &iac.attributes {
690            assert!(!attr.update_only);
691        }
692    }
693
694    #[test]
695    fn resolve_resource_missing_schema() {
696        let toml_str = r#"
697[resource]
698name = "test"
699description = "Test"
700category = "test"
701
702[crud]
703create_endpoint = "/create"
704create_schema = "NonExistentSchema"
705read_endpoint = "/read"
706read_schema = "Read"
707delete_endpoint = "/delete"
708delete_schema = "Delete"
709
710[identity]
711id_field = "id"
712"#;
713        let resource: ResourceSpec = toml::from_str(toml_str).expect("parse");
714
715        let api_str = r#"
716openapi: "3.0.0"
717info: { title: Test, version: "1.0" }
718paths: {}
719components:
720  schemas:
721    Read:
722      type: object
723      properties:
724        id: { type: string }
725    Delete:
726      type: object
727      properties:
728        id: { type: string }
729"#;
730        let api = Spec::from_str(api_str).expect("parse");
731        let defaults = ProviderDefaults::default();
732        let result = resolve_resource(&resource, &api, &defaults);
733
734        assert!(result.is_err(), "should fail when create_schema is missing");
735    }
736
737    #[test]
738    fn resolve_resource_empty_fields() {
739        let toml_str = r#"
740[resource]
741name = "empty_resource"
742description = "Has no fields"
743category = "test"
744
745[crud]
746create_endpoint = "/create"
747create_schema = "EmptySchema"
748read_endpoint = "/read"
749read_schema = "Read"
750delete_endpoint = "/delete"
751delete_schema = "Delete"
752
753[identity]
754id_field = "id"
755"#;
756        let resource: ResourceSpec = toml::from_str(toml_str).expect("parse");
757
758        let api_str = r#"
759openapi: "3.0.0"
760info: { title: Test, version: "1.0" }
761paths: {}
762components:
763  schemas:
764    EmptySchema:
765      type: object
766      properties: {}
767    Read:
768      type: object
769      properties: {}
770    Delete:
771      type: object
772      properties: {}
773"#;
774        let api = Spec::from_str(api_str).expect("parse");
775        let defaults = ProviderDefaults::default();
776        let iac = resolve_resource(&resource, &api, &defaults).expect("resolve");
777
778        assert!(
779            iac.attributes.is_empty(),
780            "empty schema should produce zero attributes"
781        );
782    }
783
784    #[test]
785    fn resolve_data_source_missing_schema() {
786        let toml_str = r#"
787[data_source]
788name = "test_ds"
789description = "Test"
790
791[read]
792endpoint = "/read"
793schema = "NonExistentSchema"
794"#;
795        let ds: DataSourceSpec = toml::from_str(toml_str).expect("parse");
796
797        let api_str = r#"
798openapi: "3.0.0"
799info: { title: Test, version: "1.0" }
800paths: {}
801components:
802  schemas: {}
803"#;
804        let api = Spec::from_str(api_str).expect("parse");
805        let defaults = ProviderDefaults::default();
806        let result = resolve_data_source(&ds, &api, &defaults);
807
808        assert!(result.is_err(), "should fail when read schema is missing");
809    }
810
811    #[test]
812    fn resolve_resource_update_schema_different_required_fields() {
813        let toml_str = r#"
814[resource]
815name = "test_update"
816description = "Test update_only"
817category = "test"
818
819[crud]
820create_endpoint = "/create"
821create_schema = "CreateSchema"
822update_endpoint = "/update"
823update_schema = "UpdateSchema"
824read_endpoint = "/read"
825read_schema = "ReadSchema"
826delete_endpoint = "/delete"
827delete_schema = "DeleteSchema"
828
829[identity]
830id_field = "id"
831"#;
832        let resource: ResourceSpec = toml::from_str(toml_str).expect("parse");
833
834        let api_str = r#"
835openapi: "3.0.0"
836info: { title: Test, version: "1.0" }
837paths: {}
838components:
839  schemas:
840    CreateSchema:
841      type: object
842      required: [name]
843      properties:
844        name: { type: string }
845        extra_field: { type: string }
846    UpdateSchema:
847      type: object
848      required: [extra_field]
849      properties:
850        name: { type: string }
851        extra_field: { type: string }
852    ReadSchema:
853      type: object
854      properties:
855        name: { type: string }
856    DeleteSchema:
857      type: object
858      properties:
859        name: { type: string }
860"#;
861        let api = Spec::from_str(api_str).expect("parse");
862        let defaults = ProviderDefaults::default();
863        let iac = resolve_resource(&resource, &api, &defaults).expect("resolve");
864
865        // extra_field: required in UpdateSchema but not required in CreateSchema
866        // => update_only = true
867        let extra = iac
868            .attributes
869            .iter()
870            .find(|a| a.canonical_name == "extra_field")
871            .expect("extra_field");
872        assert!(extra.update_only, "extra_field should be update_only");
873
874        // name: required in CreateSchema and NOT required in UpdateSchema
875        // => update_only = false
876        let name = iac
877            .attributes
878            .iter()
879            .find(|a| a.canonical_name == "name")
880            .expect("name");
881        assert!(!name.update_only, "name should NOT be update_only");
882    }
883
884    #[test]
885    fn resolve_resource_computed_and_required_makes_required_false() {
886        let toml_str = r#"
887[resource]
888name = "test_computed_req"
889description = "Test computed+required"
890category = "test"
891
892[crud]
893create_endpoint = "/create"
894create_schema = "Schema"
895read_endpoint = "/read"
896read_schema = "ReadSchema"
897delete_endpoint = "/delete"
898delete_schema = "DeleteSchema"
899
900[identity]
901id_field = "id"
902
903[fields]
904server_id = { computed = true }
905"#;
906        let resource: ResourceSpec = toml::from_str(toml_str).expect("parse");
907
908        let api_str = r#"
909openapi: "3.0.0"
910info: { title: Test, version: "1.0" }
911paths: {}
912components:
913  schemas:
914    Schema:
915      type: object
916      required: [server_id]
917      properties:
918        server_id: { type: string }
919    ReadSchema:
920      type: object
921      properties:
922        server_id: { type: string }
923    DeleteSchema:
924      type: object
925      properties:
926        server_id: { type: string }
927"#;
928        let api = Spec::from_str(api_str).expect("parse");
929        let defaults = ProviderDefaults::default();
930        let iac = resolve_resource(&resource, &api, &defaults).expect("resolve");
931
932        let field = iac
933            .attributes
934            .iter()
935            .find(|a| a.canonical_name == "server_id")
936            .expect("server_id");
937        // When computed=true, required is forced to false
938        assert!(!field.required, "computed field should have required=false");
939        assert!(field.computed, "field should still be computed");
940    }
941
942    #[test]
943    fn resolve_resource_all_fields_skipped_by_provider_defaults() {
944        let toml_str = r#"
945[resource]
946name = "all_skipped"
947description = "All fields skipped"
948category = "test"
949
950[crud]
951create_endpoint = "/create"
952create_schema = "Schema"
953read_endpoint = "/read"
954read_schema = "ReadSchema"
955delete_endpoint = "/delete"
956delete_schema = "DeleteSchema"
957
958[identity]
959id_field = "id"
960"#;
961        let resource: ResourceSpec = toml::from_str(toml_str).expect("parse");
962
963        let api_str = r#"
964openapi: "3.0.0"
965info: { title: Test, version: "1.0" }
966paths: {}
967components:
968  schemas:
969    Schema:
970      type: object
971      properties:
972        token: { type: string }
973        uid_token: { type: string }
974    ReadSchema:
975      type: object
976      properties: {}
977    DeleteSchema:
978      type: object
979      properties: {}
980"#;
981        let api = Spec::from_str(api_str).expect("parse");
982        let defaults = ProviderDefaults {
983            skip_fields: vec!["token".to_string(), "uid_token".to_string()],
984        };
985        let iac = resolve_resource(&resource, &api, &defaults).expect("resolve");
986
987        assert!(
988            iac.attributes.is_empty(),
989            "all fields should be skipped by provider defaults"
990        );
991    }
992
993    #[test]
994    fn resolve_resource_per_field_and_provider_skip_overlap() {
995        let toml_str = r#"
996[resource]
997name = "double_skip"
998description = "Both skip mechanisms"
999category = "test"
1000
1001[crud]
1002create_endpoint = "/create"
1003create_schema = "Schema"
1004read_endpoint = "/read"
1005read_schema = "ReadSchema"
1006delete_endpoint = "/delete"
1007delete_schema = "DeleteSchema"
1008
1009[identity]
1010id_field = "id"
1011
1012[fields]
1013token = { skip = true }
1014"#;
1015        let resource: ResourceSpec = toml::from_str(toml_str).expect("parse");
1016
1017        let api_str = r#"
1018openapi: "3.0.0"
1019info: { title: Test, version: "1.0" }
1020paths: {}
1021components:
1022  schemas:
1023    Schema:
1024      type: object
1025      properties:
1026        token: { type: string }
1027        name: { type: string }
1028    ReadSchema:
1029      type: object
1030      properties: {}
1031    DeleteSchema:
1032      type: object
1033      properties: {}
1034"#;
1035        let api = Spec::from_str(api_str).expect("parse");
1036        let defaults = ProviderDefaults {
1037            skip_fields: vec!["token".to_string()],
1038        };
1039        let iac = resolve_resource(&resource, &api, &defaults).expect("resolve");
1040
1041        // token is skipped by both mechanisms; only name remains
1042        assert_eq!(iac.attributes.len(), 1);
1043        assert_eq!(iac.attributes[0].canonical_name, "name");
1044    }
1045
1046    #[test]
1047    fn resolve_data_source_all_fields_computed() {
1048        let toml_str = r#"
1049[data_source]
1050name = "all_computed_ds"
1051description = "All computed"
1052
1053[read]
1054endpoint = "/read"
1055schema = "ReadSchema"
1056"#;
1057        let ds: DataSourceSpec = toml::from_str(toml_str).expect("parse");
1058
1059        let api_str = r#"
1060openapi: "3.0.0"
1061info: { title: Test, version: "1.0" }
1062paths: {}
1063components:
1064  schemas:
1065    ReadSchema:
1066      type: object
1067      properties:
1068        value: { type: string }
1069        count: { type: integer }
1070"#;
1071        let api = Spec::from_str(api_str).expect("parse");
1072        let defaults = ProviderDefaults::default();
1073        let iac = resolve_data_source(&ds, &api, &defaults).expect("resolve");
1074
1075        // No fields are required, so all should be computed
1076        for attr in &iac.attributes {
1077            assert!(
1078                attr.computed,
1079                "field {} should be computed",
1080                attr.canonical_name
1081            );
1082            assert!(
1083                !attr.required,
1084                "field {} should not be required",
1085                attr.canonical_name
1086            );
1087            assert!(
1088                !attr.update_only,
1089                "data source field should never be update_only"
1090            );
1091        }
1092    }
1093
1094    #[test]
1095    fn resolve_provider_empty_auth() {
1096        let toml_str = r#"
1097[provider]
1098name = "minimal"
1099description = "Minimal provider"
1100version = "0.1.0"
1101"#;
1102        let provider: ProviderSpec = toml::from_str(toml_str).expect("parse");
1103        let iac = resolve_provider(&provider);
1104
1105        assert_eq!(iac.name, "minimal");
1106        assert!(iac.auth.token_field.is_empty());
1107        assert!(iac.auth.env_var.is_empty());
1108        assert!(iac.auth.gateway_url_field.is_empty());
1109        assert!(iac.auth.gateway_env_var.is_empty());
1110        assert!(!iac.auth.has_token());
1111        assert!(!iac.auth.has_gateway());
1112        assert!(iac.skip_fields.is_empty());
1113        assert!(iac.platform_config.is_empty());
1114    }
1115
1116    #[test]
1117    fn resolve_provider_with_platform_config() {
1118        let toml_str = r#"
1119[provider]
1120name = "platformed"
1121description = "With platforms"
1122version = "1.0.0"
1123
1124[auth]
1125token_field = "token"
1126env_var = "TOKEN"
1127
1128[defaults]
1129skip_fields = []
1130
1131[platforms.terraform]
1132sdk_import = "github.com/example/sdk"
1133
1134[platforms.pulumi]
1135module = "index"
1136"#;
1137        let provider: ProviderSpec = toml::from_str(toml_str).expect("parse");
1138        let iac = resolve_provider(&provider);
1139
1140        assert_eq!(iac.platform_config.len(), 2);
1141        assert!(iac.platform_config.contains_key("terraform"));
1142        assert!(iac.platform_config.contains_key("pulumi"));
1143    }
1144
1145    #[test]
1146    fn resolve_resource_import_field_defaults_to_id_field() {
1147        let toml_str = r#"
1148[resource]
1149name = "no_import_field"
1150description = "No import_field set"
1151category = "test"
1152
1153[crud]
1154create_endpoint = "/create"
1155create_schema = "Schema"
1156read_endpoint = "/read"
1157read_schema = "ReadSchema"
1158delete_endpoint = "/delete"
1159delete_schema = "DeleteSchema"
1160
1161[identity]
1162id_field = "my_id"
1163"#;
1164        let resource: ResourceSpec = toml::from_str(toml_str).expect("parse");
1165
1166        let api_str = r#"
1167openapi: "3.0.0"
1168info: { title: Test, version: "1.0" }
1169paths: {}
1170components:
1171  schemas:
1172    Schema:
1173      type: object
1174      properties:
1175        my_id: { type: string }
1176    ReadSchema:
1177      type: object
1178      properties: {}
1179    DeleteSchema:
1180      type: object
1181      properties: {}
1182"#;
1183        let api = Spec::from_str(api_str).expect("parse");
1184        let defaults = ProviderDefaults::default();
1185        let iac = resolve_resource(&resource, &api, &defaults).expect("resolve");
1186
1187        assert_eq!(iac.identity.id_field, "my_id");
1188        assert_eq!(iac.identity.import_field, "my_id");
1189    }
1190
1191    #[test]
1192    fn resolve_resource_explicit_import_field() {
1193        let toml_str = r#"
1194[resource]
1195name = "with_import_field"
1196description = "Explicit import_field"
1197category = "test"
1198
1199[crud]
1200create_endpoint = "/create"
1201create_schema = "Schema"
1202read_endpoint = "/read"
1203read_schema = "ReadSchema"
1204delete_endpoint = "/delete"
1205delete_schema = "DeleteSchema"
1206
1207[identity]
1208id_field = "name"
1209import_field = "path"
1210"#;
1211        let resource: ResourceSpec = toml::from_str(toml_str).expect("parse");
1212
1213        let api_str = r#"
1214openapi: "3.0.0"
1215info: { title: Test, version: "1.0" }
1216paths: {}
1217components:
1218  schemas:
1219    Schema:
1220      type: object
1221      properties:
1222        name: { type: string }
1223    ReadSchema:
1224      type: object
1225      properties: {}
1226    DeleteSchema:
1227      type: object
1228      properties: {}
1229"#;
1230        let api = Spec::from_str(api_str).expect("parse");
1231        let defaults = ProviderDefaults::default();
1232        let iac = resolve_resource(&resource, &api, &defaults).expect("resolve");
1233
1234        assert_eq!(iac.identity.id_field, "name");
1235        assert_eq!(iac.identity.import_field, "path");
1236    }
1237
1238    #[test]
1239    fn resolve_resource_sensitive_field() {
1240        let toml_str = r#"
1241[resource]
1242name = "with_sensitive"
1243description = "Has sensitive field"
1244category = "test"
1245
1246[crud]
1247create_endpoint = "/create"
1248create_schema = "Schema"
1249read_endpoint = "/read"
1250read_schema = "ReadSchema"
1251delete_endpoint = "/delete"
1252delete_schema = "DeleteSchema"
1253
1254[identity]
1255id_field = "name"
1256
1257[fields]
1258password = { sensitive = true }
1259"#;
1260        let resource: ResourceSpec = toml::from_str(toml_str).expect("parse");
1261
1262        let api_str = r#"
1263openapi: "3.0.0"
1264info: { title: Test, version: "1.0" }
1265paths: {}
1266components:
1267  schemas:
1268    Schema:
1269      type: object
1270      properties:
1271        name: { type: string }
1272        password: { type: string }
1273    ReadSchema:
1274      type: object
1275      properties: {}
1276    DeleteSchema:
1277      type: object
1278      properties: {}
1279"#;
1280        let api = Spec::from_str(api_str).expect("parse");
1281        let defaults = ProviderDefaults::default();
1282        let iac = resolve_resource(&resource, &api, &defaults).expect("resolve");
1283
1284        let pw = iac
1285            .attributes
1286            .iter()
1287            .find(|a| a.canonical_name == "password")
1288            .expect("password");
1289        assert!(pw.sensitive);
1290    }
1291
1292    #[test]
1293    fn resolve_resource_force_new_via_field_override() {
1294        let toml_str = r#"
1295[resource]
1296name = "force_new_override"
1297description = "Force new via field override"
1298category = "test"
1299
1300[crud]
1301create_endpoint = "/create"
1302create_schema = "Schema"
1303read_endpoint = "/read"
1304read_schema = "ReadSchema"
1305delete_endpoint = "/delete"
1306delete_schema = "DeleteSchema"
1307
1308[identity]
1309id_field = "name"
1310
1311[fields]
1312region = { force_new = true }
1313"#;
1314        let resource: ResourceSpec = toml::from_str(toml_str).expect("parse");
1315
1316        let api_str = r#"
1317openapi: "3.0.0"
1318info: { title: Test, version: "1.0" }
1319paths: {}
1320components:
1321  schemas:
1322    Schema:
1323      type: object
1324      properties:
1325        name: { type: string }
1326        region: { type: string }
1327    ReadSchema:
1328      type: object
1329      properties: {}
1330    DeleteSchema:
1331      type: object
1332      properties: {}
1333"#;
1334        let api = Spec::from_str(api_str).expect("parse");
1335        let defaults = ProviderDefaults::default();
1336        let iac = resolve_resource(&resource, &api, &defaults).expect("resolve");
1337
1338        let region = iac
1339            .attributes
1340            .iter()
1341            .find(|a| a.canonical_name == "region")
1342            .expect("region");
1343        assert!(
1344            region.immutable,
1345            "region should be immutable via force_new override"
1346        );
1347    }
1348
1349    #[test]
1350    fn resolve_resource_description_override() {
1351        let toml_str = r#"
1352[resource]
1353name = "desc_override"
1354description = "Test description override"
1355category = "test"
1356
1357[crud]
1358create_endpoint = "/create"
1359create_schema = "Schema"
1360read_endpoint = "/read"
1361read_schema = "ReadSchema"
1362delete_endpoint = "/delete"
1363delete_schema = "DeleteSchema"
1364
1365[identity]
1366id_field = "name"
1367
1368[fields]
1369name = { description = "Custom description" }
1370"#;
1371        let resource: ResourceSpec = toml::from_str(toml_str).expect("parse");
1372
1373        let api_str = r#"
1374openapi: "3.0.0"
1375info: { title: Test, version: "1.0" }
1376paths: {}
1377components:
1378  schemas:
1379    Schema:
1380      type: object
1381      properties:
1382        name: { type: string, description: "Original description" }
1383    ReadSchema:
1384      type: object
1385      properties: {}
1386    DeleteSchema:
1387      type: object
1388      properties: {}
1389"#;
1390        let api = Spec::from_str(api_str).expect("parse");
1391        let defaults = ProviderDefaults::default();
1392        let iac = resolve_resource(&resource, &api, &defaults).expect("resolve");
1393
1394        let name = iac
1395            .attributes
1396            .iter()
1397            .find(|a| a.canonical_name == "name")
1398            .expect("name");
1399        assert_eq!(name.description, "Custom description");
1400    }
1401
1402    #[test]
1403    fn resolve_data_source_immutable_always_false() {
1404        let toml_str = r#"
1405[data_source]
1406name = "ds_no_immutable"
1407description = "Data sources never have immutable fields"
1408
1409[read]
1410endpoint = "/read"
1411schema = "ReadSchema"
1412
1413[fields]
1414name = { force_new = true }
1415"#;
1416        let ds: DataSourceSpec = toml::from_str(toml_str).expect("parse");
1417
1418        let api_str = r#"
1419openapi: "3.0.0"
1420info: { title: Test, version: "1.0" }
1421paths: {}
1422components:
1423  schemas:
1424    ReadSchema:
1425      type: object
1426      required: [name]
1427      properties:
1428        name: { type: string }
1429"#;
1430        let api = Spec::from_str(api_str).expect("parse");
1431        let defaults = ProviderDefaults::default();
1432        let iac = resolve_data_source(&ds, &api, &defaults).expect("resolve");
1433
1434        for attr in &iac.attributes {
1435            assert!(
1436                !attr.immutable,
1437                "data source field should never be immutable"
1438            );
1439        }
1440    }
1441
1442    #[test]
1443    fn resolve_data_source_required_input_not_computed() {
1444        let toml_str = r#"
1445[data_source]
1446name = "ds_input"
1447description = "Data source with required input"
1448
1449[read]
1450endpoint = "/read"
1451schema = "ReadSchema"
1452"#;
1453        let ds: DataSourceSpec = toml::from_str(toml_str).expect("parse");
1454
1455        let api_str = r#"
1456openapi: "3.0.0"
1457info: { title: Test, version: "1.0" }
1458paths: {}
1459components:
1460  schemas:
1461    ReadSchema:
1462      type: object
1463      required: [name]
1464      properties:
1465        name: { type: string, description: "Lookup key" }
1466        result: { type: string, description: "Computed result" }
1467"#;
1468        let api = Spec::from_str(api_str).expect("parse");
1469        let defaults = ProviderDefaults::default();
1470        let iac = resolve_data_source(&ds, &api, &defaults).expect("resolve");
1471
1472        let name = iac
1473            .attributes
1474            .iter()
1475            .find(|a| a.canonical_name == "name")
1476            .expect("name");
1477        // required field in data source: required=false but computed=false (it's an input)
1478        assert!(!name.computed, "required input should not be computed");
1479
1480        let result = iac
1481            .attributes
1482            .iter()
1483            .find(|a| a.canonical_name == "result")
1484            .expect("result");
1485        assert!(
1486            result.computed,
1487            "optional non-required field should be computed in data source"
1488        );
1489    }
1490
1491    #[test]
1492    fn resolve_data_source_global_skip_fields() {
1493        let toml_str = r#"
1494[data_source]
1495name = "ds_skip"
1496description = "DS with global skip"
1497
1498[read]
1499endpoint = "/read"
1500schema = "ReadSchema"
1501"#;
1502        let ds: DataSourceSpec = toml::from_str(toml_str).expect("parse");
1503
1504        let api_str = r#"
1505openapi: "3.0.0"
1506info: { title: Test, version: "1.0" }
1507paths: {}
1508components:
1509  schemas:
1510    ReadSchema:
1511      type: object
1512      required: [name]
1513      properties:
1514        name: { type: string }
1515        token: { type: string }
1516        uid_token: { type: string }
1517"#;
1518        let api = Spec::from_str(api_str).expect("parse");
1519        let defaults = ProviderDefaults {
1520            skip_fields: vec!["token".to_string(), "uid_token".to_string()],
1521        };
1522        let iac = resolve_data_source(&ds, &api, &defaults).expect("resolve");
1523        assert_eq!(iac.attributes.len(), 1);
1524        assert_eq!(iac.attributes[0].canonical_name, "name");
1525    }
1526
1527    #[test]
1528    fn resolve_resource_enum_field_from_openapi() {
1529        let toml_str = r#"
1530[resource]
1531name = "enum_res"
1532description = "Resource with enum field"
1533category = "test"
1534
1535[crud]
1536create_endpoint = "/create"
1537create_schema = "CreateSchema"
1538read_endpoint = "/read"
1539read_schema = "ReadSchema"
1540delete_endpoint = "/delete"
1541delete_schema = "DeleteSchema"
1542
1543[identity]
1544id_field = "name"
1545"#;
1546        let resource: ResourceSpec = toml::from_str(toml_str).expect("parse");
1547
1548        let api_str = r#"
1549openapi: "3.0.0"
1550info: { title: Test, version: "1.0" }
1551paths: {}
1552components:
1553  schemas:
1554    CreateSchema:
1555      type: object
1556      required: [name]
1557      properties:
1558        name: { type: string }
1559        mode:
1560          type: string
1561          enum: [active, passive, disabled]
1562    ReadSchema:
1563      type: object
1564      properties: {}
1565    DeleteSchema:
1566      type: object
1567      properties: {}
1568"#;
1569        let api = Spec::from_str(api_str).expect("parse");
1570        let defaults = ProviderDefaults::default();
1571        let iac = resolve_resource(&resource, &api, &defaults).expect("resolve");
1572
1573        let mode = iac
1574            .attributes
1575            .iter()
1576            .find(|a| a.canonical_name == "mode")
1577            .expect("mode field");
1578        assert!(
1579            matches!(&mode.iac_type, crate::ir::IacType::Enum { values, .. } if values.len() == 3),
1580            "mode should be an Enum with 3 values, got {:?}",
1581            mode.iac_type
1582        );
1583        assert_eq!(
1584            mode.enum_values,
1585            Some(vec![
1586                "active".to_string(),
1587                "passive".to_string(),
1588                "disabled".to_string()
1589            ])
1590        );
1591    }
1592
1593    #[test]
1594    fn resolve_resource_field_with_default_value() {
1595        let toml_str = r#"
1596[resource]
1597name = "default_val"
1598description = "Resource with default"
1599category = "test"
1600
1601[crud]
1602create_endpoint = "/create"
1603create_schema = "CreateSchema"
1604read_endpoint = "/read"
1605read_schema = "ReadSchema"
1606delete_endpoint = "/delete"
1607delete_schema = "DeleteSchema"
1608
1609[identity]
1610id_field = "name"
1611"#;
1612        let resource: ResourceSpec = toml::from_str(toml_str).expect("parse");
1613
1614        let api_str = r#"
1615openapi: "3.0.0"
1616info: { title: Test, version: "1.0" }
1617paths: {}
1618components:
1619  schemas:
1620    CreateSchema:
1621      type: object
1622      required: [name]
1623      properties:
1624        name: { type: string }
1625        count: { type: integer, default: 5 }
1626    ReadSchema:
1627      type: object
1628      properties: {}
1629    DeleteSchema:
1630      type: object
1631      properties: {}
1632"#;
1633        let api = Spec::from_str(api_str).expect("parse");
1634        let defaults = ProviderDefaults::default();
1635        let iac = resolve_resource(&resource, &api, &defaults).expect("resolve");
1636
1637        let count = iac
1638            .attributes
1639            .iter()
1640            .find(|a| a.canonical_name == "count")
1641            .expect("count field");
1642        assert_eq!(count.default_value, Some(serde_json::json!(5)));
1643        assert!(!count.required, "field with default should not be required");
1644    }
1645
1646    #[test]
1647    fn resolve_resource_hyphenated_field_name_becomes_snake_case() {
1648        let toml_str = r#"
1649[resource]
1650name = "hyphen_res"
1651description = "Hyphenated names"
1652category = "test"
1653
1654[crud]
1655create_endpoint = "/create"
1656create_schema = "CreateSchema"
1657read_endpoint = "/read"
1658read_schema = "ReadSchema"
1659delete_endpoint = "/delete"
1660delete_schema = "DeleteSchema"
1661
1662[identity]
1663id_field = "name"
1664"#;
1665        let resource: ResourceSpec = toml::from_str(toml_str).expect("parse");
1666
1667        let api_str = r#"
1668openapi: "3.0.0"
1669info: { title: Test, version: "1.0" }
1670paths: {}
1671components:
1672  schemas:
1673    CreateSchema:
1674      type: object
1675      properties:
1676        my-field-name: { type: string }
1677    ReadSchema:
1678      type: object
1679      properties: {}
1680    DeleteSchema:
1681      type: object
1682      properties: {}
1683"#;
1684        let api = Spec::from_str(api_str).expect("parse");
1685        let defaults = ProviderDefaults::default();
1686        let iac = resolve_resource(&resource, &api, &defaults).expect("resolve");
1687
1688        let field = iac
1689            .attributes
1690            .iter()
1691            .find(|a| a.api_name == "my-field-name")
1692            .expect("hyphenated field");
1693        assert_eq!(field.canonical_name, "my_field_name");
1694        assert_eq!(field.api_name, "my-field-name");
1695    }
1696
1697    #[test]
1698    fn resolve_resource_optional_flag_set_correctly() {
1699        let (resource, api) = make_test_spec();
1700        let defaults = ProviderDefaults::default();
1701        let iac = resolve_resource(&resource, &api, &defaults).expect("resolve");
1702
1703        let name = iac
1704            .attributes
1705            .iter()
1706            .find(|a| a.canonical_name == "name")
1707            .expect("name");
1708        assert!(name.required);
1709        assert!(!name.optional, "required field should have optional=false");
1710
1711        let tags = iac
1712            .attributes
1713            .iter()
1714            .find(|a| a.canonical_name == "tags")
1715            .expect("tags");
1716        assert!(!tags.required);
1717        assert!(
1718            tags.optional,
1719            "non-required field should have optional=true"
1720        );
1721    }
1722
1723    #[test]
1724    fn resolve_resource_json_encoded_is_false_for_openapi_fields() {
1725        let (resource, api) = make_test_spec();
1726        let defaults = ProviderDefaults::default();
1727        let iac = resolve_resource(&resource, &api, &defaults).expect("resolve");
1728
1729        for attr in &iac.attributes {
1730            assert!(
1731                !attr.json_encoded,
1732                "OpenAPI-resolved fields should have json_encoded=false, but {} was true",
1733                attr.canonical_name
1734            );
1735        }
1736    }
1737
1738    #[test]
1739    fn resolve_data_source_with_computed_override() {
1740        let toml_str = r#"
1741[data_source]
1742name = "comp_override"
1743description = "DS with computed override"
1744
1745[read]
1746endpoint = "/read"
1747schema = "ReadSchema"
1748
1749[fields]
1750extra = { computed = true }
1751"#;
1752        let ds: DataSourceSpec = toml::from_str(toml_str).expect("parse");
1753
1754        let api_str = r#"
1755openapi: "3.0.0"
1756info: { title: Test, version: "1.0" }
1757paths: {}
1758components:
1759  schemas:
1760    ReadSchema:
1761      type: object
1762      required: [name, extra]
1763      properties:
1764        name: { type: string }
1765        extra: { type: string }
1766"#;
1767        let api = Spec::from_str(api_str).expect("parse");
1768        let defaults = ProviderDefaults::default();
1769        let iac = resolve_data_source(&ds, &api, &defaults).expect("resolve");
1770
1771        let extra = iac
1772            .attributes
1773            .iter()
1774            .find(|a| a.canonical_name == "extra")
1775            .expect("extra");
1776        assert!(
1777            extra.computed,
1778            "explicitly computed field should be computed"
1779        );
1780    }
1781
1782    #[test]
1783    fn resolve_data_source_read_mapping() {
1784        let toml_str = r#"
1785[data_source]
1786name = "mapped_ds"
1787description = "DS with read mapping"
1788
1789[read]
1790endpoint = "/read"
1791schema = "ReadSchema"
1792
1793[read_mapping]
1794"resp_name" = "name"
1795"#;
1796        let ds: DataSourceSpec = toml::from_str(toml_str).expect("parse");
1797
1798        let api_str = r#"
1799openapi: "3.0.0"
1800info: { title: Test, version: "1.0" }
1801paths: {}
1802components:
1803  schemas:
1804    ReadSchema:
1805      type: object
1806      required: [name]
1807      properties:
1808        name: { type: string }
1809"#;
1810        let api = Spec::from_str(api_str).expect("parse");
1811        let defaults = ProviderDefaults::default();
1812        let iac = resolve_data_source(&ds, &api, &defaults).expect("resolve");
1813
1814        let name = iac
1815            .attributes
1816            .iter()
1817            .find(|a| a.canonical_name == "name")
1818            .expect("name");
1819        assert_eq!(name.read_path, Some("resp_name".to_string()));
1820    }
1821
1822    #[test]
1823    fn resolve_resource_array_of_integers() {
1824        let toml_str = r#"
1825[resource]
1826name = "int_array"
1827description = "Array of int"
1828category = "test"
1829
1830[crud]
1831create_endpoint = "/create"
1832create_schema = "CreateSchema"
1833read_endpoint = "/read"
1834read_schema = "ReadSchema"
1835delete_endpoint = "/delete"
1836delete_schema = "DeleteSchema"
1837
1838[identity]
1839id_field = "name"
1840"#;
1841        let resource: ResourceSpec = toml::from_str(toml_str).expect("parse");
1842
1843        let api_str = r#"
1844openapi: "3.0.0"
1845info: { title: Test, version: "1.0" }
1846paths: {}
1847components:
1848  schemas:
1849    CreateSchema:
1850      type: object
1851      properties:
1852        name: { type: string }
1853        ports:
1854          type: array
1855          items:
1856            type: integer
1857    ReadSchema:
1858      type: object
1859      properties: {}
1860    DeleteSchema:
1861      type: object
1862      properties: {}
1863"#;
1864        let api = Spec::from_str(api_str).expect("parse");
1865        let defaults = ProviderDefaults::default();
1866        let iac = resolve_resource(&resource, &api, &defaults).expect("resolve");
1867
1868        let ports = iac
1869            .attributes
1870            .iter()
1871            .find(|a| a.canonical_name == "ports")
1872            .expect("ports");
1873        assert_eq!(
1874            ports.iac_type,
1875            crate::ir::IacType::List(Box::new(crate::ir::IacType::Integer))
1876        );
1877    }
1878
1879    #[test]
1880    fn resolve_resource_description_from_openapi_when_no_override() {
1881        let toml_str = r#"
1882[resource]
1883name = "desc_api"
1884description = "Test"
1885category = "test"
1886
1887[crud]
1888create_endpoint = "/create"
1889create_schema = "Schema"
1890read_endpoint = "/read"
1891read_schema = "ReadSchema"
1892delete_endpoint = "/delete"
1893delete_schema = "DeleteSchema"
1894
1895[identity]
1896id_field = "name"
1897"#;
1898        let resource: ResourceSpec = toml::from_str(toml_str).expect("parse");
1899
1900        let api_str = r#"
1901openapi: "3.0.0"
1902info: { title: Test, version: "1.0" }
1903paths: {}
1904components:
1905  schemas:
1906    Schema:
1907      type: object
1908      properties:
1909        name: { type: string, description: "API description for name" }
1910    ReadSchema:
1911      type: object
1912      properties: {}
1913    DeleteSchema:
1914      type: object
1915      properties: {}
1916"#;
1917        let api = Spec::from_str(api_str).expect("parse");
1918        let defaults = ProviderDefaults::default();
1919        let iac = resolve_resource(&resource, &api, &defaults).expect("resolve");
1920
1921        let name = iac
1922            .attributes
1923            .iter()
1924            .find(|a| a.canonical_name == "name")
1925            .expect("name");
1926        assert_eq!(name.description, "API description for name");
1927    }
1928
1929    #[test]
1930    fn resolve_resource_no_description_defaults_to_empty() {
1931        let toml_str = r#"
1932[resource]
1933name = "no_desc"
1934description = "Test"
1935category = "test"
1936
1937[crud]
1938create_endpoint = "/create"
1939create_schema = "Schema"
1940read_endpoint = "/read"
1941read_schema = "ReadSchema"
1942delete_endpoint = "/delete"
1943delete_schema = "DeleteSchema"
1944
1945[identity]
1946id_field = "name"
1947"#;
1948        let resource: ResourceSpec = toml::from_str(toml_str).expect("parse");
1949
1950        let api_str = r#"
1951openapi: "3.0.0"
1952info: { title: Test, version: "1.0" }
1953paths: {}
1954components:
1955  schemas:
1956    Schema:
1957      type: object
1958      properties:
1959        name: { type: string }
1960    ReadSchema:
1961      type: object
1962      properties: {}
1963    DeleteSchema:
1964      type: object
1965      properties: {}
1966"#;
1967        let api = Spec::from_str(api_str).expect("parse");
1968        let defaults = ProviderDefaults::default();
1969        let iac = resolve_resource(&resource, &api, &defaults).expect("resolve");
1970
1971        let name = iac
1972            .attributes
1973            .iter()
1974            .find(|a| a.canonical_name == "name")
1975            .expect("name");
1976        assert!(name.description.is_empty());
1977    }
1978
1979    #[test]
1980    fn resolve_resource_update_schema_missing_gracefully_uses_empty_set() {
1981        let toml_str = r#"
1982[resource]
1983name = "missing_update_schema"
1984description = "Update schema is listed but not in API"
1985category = "test"
1986
1987[crud]
1988create_endpoint = "/create"
1989create_schema = "CreateSchema"
1990update_endpoint = "/update"
1991update_schema = "NonExistentUpdateSchema"
1992read_endpoint = "/read"
1993read_schema = "ReadSchema"
1994delete_endpoint = "/delete"
1995delete_schema = "DeleteSchema"
1996
1997[identity]
1998id_field = "name"
1999"#;
2000        let resource: ResourceSpec = toml::from_str(toml_str).expect("parse");
2001
2002        let api_str = r#"
2003openapi: "3.0.0"
2004info: { title: Test, version: "1.0" }
2005paths: {}
2006components:
2007  schemas:
2008    CreateSchema:
2009      type: object
2010      properties:
2011        name: { type: string }
2012    ReadSchema:
2013      type: object
2014      properties: {}
2015    DeleteSchema:
2016      type: object
2017      properties: {}
2018"#;
2019        let api = Spec::from_str(api_str).expect("parse");
2020        let defaults = ProviderDefaults::default();
2021        let iac = resolve_resource(&resource, &api, &defaults).expect("resolve");
2022
2023        for attr in &iac.attributes {
2024            assert!(
2025                !attr.update_only,
2026                "with missing update schema, nothing should be update_only"
2027            );
2028        }
2029    }
2030
2031    #[test]
2032    fn resolve_data_source_response_schema_passthrough() {
2033        let toml_str = r#"
2034[data_source]
2035name = "resp_schema_ds"
2036description = "DS with response schema"
2037
2038[read]
2039endpoint = "/read"
2040schema = "ReadSchema"
2041response_schema = "ReadResponse"
2042"#;
2043        let ds: DataSourceSpec = toml::from_str(toml_str).expect("parse");
2044
2045        let api_str = r#"
2046openapi: "3.0.0"
2047info: { title: Test, version: "1.0" }
2048paths: {}
2049components:
2050  schemas:
2051    ReadSchema:
2052      type: object
2053      properties:
2054        name: { type: string }
2055"#;
2056        let api = Spec::from_str(api_str).expect("parse");
2057        let defaults = ProviderDefaults::default();
2058        let iac = resolve_data_source(&ds, &api, &defaults).expect("resolve");
2059
2060        assert_eq!(iac.read_response_schema, Some("ReadResponse".to_string()));
2061    }
2062
2063    #[test]
2064    fn resolve_data_source_skip_field_via_override() {
2065        let toml_str = r#"
2066[data_source]
2067name = "skip_ds"
2068description = "DS field skip"
2069
2070[read]
2071endpoint = "/read"
2072schema = "ReadSchema"
2073
2074[fields]
2075internal = { skip = true }
2076"#;
2077        let ds: DataSourceSpec = toml::from_str(toml_str).expect("parse");
2078
2079        let api_str = r#"
2080openapi: "3.0.0"
2081info: { title: Test, version: "1.0" }
2082paths: {}
2083components:
2084  schemas:
2085    ReadSchema:
2086      type: object
2087      required: [name]
2088      properties:
2089        name: { type: string }
2090        internal: { type: string }
2091"#;
2092        let api = Spec::from_str(api_str).expect("parse");
2093        let defaults = ProviderDefaults::default();
2094        let iac = resolve_data_source(&ds, &api, &defaults).expect("resolve");
2095
2096        assert_eq!(iac.attributes.len(), 1);
2097        assert_eq!(iac.attributes[0].canonical_name, "name");
2098    }
2099
2100    #[test]
2101    fn resolve_data_source_sensitive_override() {
2102        let toml_str = r#"
2103[data_source]
2104name = "sens_ds"
2105description = "DS with sensitive"
2106
2107[read]
2108endpoint = "/read"
2109schema = "ReadSchema"
2110
2111[fields]
2112secret = { sensitive = true }
2113"#;
2114        let ds: DataSourceSpec = toml::from_str(toml_str).expect("parse");
2115
2116        let api_str = r#"
2117openapi: "3.0.0"
2118info: { title: Test, version: "1.0" }
2119paths: {}
2120components:
2121  schemas:
2122    ReadSchema:
2123      type: object
2124      properties:
2125        secret: { type: string }
2126"#;
2127        let api = Spec::from_str(api_str).expect("parse");
2128        let defaults = ProviderDefaults::default();
2129        let iac = resolve_data_source(&ds, &api, &defaults).expect("resolve");
2130
2131        let secret = iac
2132            .attributes
2133            .iter()
2134            .find(|a| a.canonical_name == "secret")
2135            .expect("secret");
2136        assert!(secret.sensitive);
2137    }
2138
2139    #[test]
2140    fn resolve_data_source_type_override() {
2141        let toml_str = r#"
2142[data_source]
2143name = "type_ds"
2144description = "DS with type override"
2145
2146[read]
2147endpoint = "/read"
2148schema = "ReadSchema"
2149
2150[fields]
2151flag = { type_override = "bool" }
2152"#;
2153        let ds: DataSourceSpec = toml::from_str(toml_str).expect("parse");
2154
2155        let api_str = r#"
2156openapi: "3.0.0"
2157info: { title: Test, version: "1.0" }
2158paths: {}
2159components:
2160  schemas:
2161    ReadSchema:
2162      type: object
2163      properties:
2164        flag: { type: string }
2165"#;
2166        let api = Spec::from_str(api_str).expect("parse");
2167        let defaults = ProviderDefaults::default();
2168        let iac = resolve_data_source(&ds, &api, &defaults).expect("resolve");
2169
2170        let flag = iac
2171            .attributes
2172            .iter()
2173            .find(|a| a.canonical_name == "flag")
2174            .expect("flag");
2175        assert_eq!(flag.iac_type, crate::ir::IacType::Boolean);
2176    }
2177
2178    #[test]
2179    fn resolve_provider_skip_fields_passthrough() {
2180        let toml_str = r#"
2181[provider]
2182name = "skip_test"
2183description = "Provider with skip fields"
2184version = "1.0.0"
2185
2186[auth]
2187token_field = "token"
2188env_var = "TOK"
2189
2190[defaults]
2191skip_fields = ["token", "uid-token", "json"]
2192"#;
2193        let provider: ProviderSpec = toml::from_str(toml_str).expect("parse");
2194        let iac = resolve_provider(&provider);
2195        assert_eq!(iac.skip_fields, vec!["token", "uid-token", "json"]);
2196    }
2197
2198    // ── resolve_action ──────────────────────────────────────────────
2199
2200    fn make_action_test_pair() -> (ResourceSpec, Spec) {
2201        let toml_str = r#"
2202[resource]
2203name = "akeyless_uid_generate_token"
2204description = "Generate a Universal Identity token"
2205category = "uid"
2206kind = "action"
2207
2208[action]
2209endpoint = "/uid-generate-token"
2210schema = "uidGenerateToken"
2211response_schema = "uidGenerateTokenOutput"
2212mutating = true
2213sensitive_response_fields = ["token"]
2214
2215[fields]
2216"auth-method-name" = { required = true }
2217"#;
2218        let spec: ResourceSpec = toml::from_str(toml_str).expect("parse spec");
2219
2220        let api_str = r#"
2221openapi: "3.0.0"
2222info:
2223  title: Test
2224  version: "1.0"
2225paths:
2226  /uid-generate-token:
2227    post:
2228      operationId: uidGenerateToken
2229      requestBody:
2230        content:
2231          application/json:
2232            schema:
2233              $ref: '#/components/schemas/uidGenerateToken'
2234      responses:
2235        "200":
2236          description: ok
2237components:
2238  schemas:
2239    uidGenerateToken:
2240      type: object
2241      required:
2242        - auth-method-name
2243      properties:
2244        auth-method-name:
2245          type: string
2246          description: The universal identity auth method name
2247        uid-token:
2248          type: string
2249          description: The universal identity token
2250        token:
2251          type: string
2252    uidGenerateTokenOutput:
2253      type: object
2254      properties:
2255        token:
2256          type: string
2257"#;
2258        let api = Spec::from_str(api_str).expect("parse api");
2259        (spec, api)
2260    }
2261
2262    #[test]
2263    fn resolve_action_basic() {
2264        let (spec, api) = make_action_test_pair();
2265        let defaults = ProviderDefaults {
2266            skip_fields: vec!["token".into(), "json".into()],
2267        };
2268        let action = resolve_action(&spec, &api, &defaults).expect("resolve_action");
2269        assert_eq!(action.name, "akeyless_uid_generate_token");
2270        assert_eq!(action.endpoint, "/uid-generate-token");
2271        assert_eq!(action.schema, "uidGenerateToken");
2272        assert_eq!(
2273            action.response_schema.as_deref(),
2274            Some("uidGenerateTokenOutput")
2275        );
2276        assert!(action.mutating);
2277        assert_eq!(action.sensitive_response_fields, vec!["token".to_string()]);
2278        // Skip filter dropped `token`; field overrides marked auth-method-name required.
2279        let names: Vec<&str> = action
2280            .attributes
2281            .iter()
2282            .map(|a| a.canonical_name.as_str())
2283            .collect();
2284        assert!(names.contains(&"auth_method_name"));
2285        assert!(names.contains(&"uid_token"));
2286        assert!(!names.contains(&"token"));
2287        let amn = action
2288            .attributes
2289            .iter()
2290            .find(|a| a.canonical_name == "auth_method_name")
2291            .expect("auth_method_name attr");
2292        assert!(amn.required);
2293    }
2294
2295    #[test]
2296    fn resolve_action_defaults_mutating_to_true() {
2297        let toml_str = r#"
2298[resource]
2299name = "akeyless_uid_list_children"
2300description = "List UID children"
2301category = "uid"
2302kind = "action"
2303
2304[action]
2305endpoint = "/uid-list-children"
2306schema = "uidListChildren"
2307"#;
2308        let spec: ResourceSpec = toml::from_str(toml_str).expect("parse spec");
2309        let api_str = r#"
2310openapi: "3.0.0"
2311info: { title: t, version: "1.0" }
2312paths:
2313  /uid-list-children:
2314    post:
2315      operationId: uidListChildren
2316      responses:
2317        "200": { description: ok }
2318components:
2319  schemas:
2320    uidListChildren:
2321      type: object
2322      properties:
2323        auth-method-name:
2324          type: string
2325"#;
2326        let api = Spec::from_str(api_str).expect("parse api");
2327        let defaults = ProviderDefaults::default();
2328        let action = resolve_action(&spec, &api, &defaults).expect("resolve");
2329        // mutating defaults to true when the TOML leaves it unspecified.
2330        assert!(action.mutating);
2331        assert!(action.sensitive_response_fields.is_empty());
2332        assert!(action.response_schema.is_none());
2333    }
2334
2335    #[test]
2336    fn resolve_action_rejects_crud_spec() {
2337        let toml_str = r#"
2338[resource]
2339name = "akeyless_static_secret"
2340description = "Static secret"
2341category = "secret"
2342
2343[crud]
2344create_endpoint = "/create-secret"
2345create_schema = "CreateSecret"
2346read_endpoint = "/get-secret-value"
2347read_schema = "GetSecretValue"
2348delete_endpoint = "/delete-item"
2349delete_schema = "DeleteItem"
2350
2351[identity]
2352id_field = "name"
2353"#;
2354        let spec: ResourceSpec = toml::from_str(toml_str).expect("parse");
2355        let api_str = r#"
2356openapi: "3.0.0"
2357info: { title: t, version: "1.0" }
2358paths:
2359  /noop:
2360    post:
2361      operationId: noop
2362      responses:
2363        "200": { description: ok }
2364components:
2365  schemas:
2366    Stub:
2367      type: object
2368"#;
2369        let api = Spec::from_str(api_str).expect("parse api");
2370        let err = resolve_action(&spec, &api, &ProviderDefaults::default()).unwrap_err();
2371        assert!(format!("{err}").contains("no kind = \"action\""));
2372    }
2373
2374    #[test]
2375    fn resolve_resource_rejects_action_spec() {
2376        let toml_str = r#"
2377[resource]
2378name = "akeyless_uid_generate_token"
2379description = "Generate UID token"
2380category = "uid"
2381kind = "action"
2382
2383[action]
2384endpoint = "/uid-generate-token"
2385schema = "uidGenerateToken"
2386"#;
2387        let spec: ResourceSpec = toml::from_str(toml_str).expect("parse");
2388        let api_str = r#"
2389openapi: "3.0.0"
2390info: { title: t, version: "1.0" }
2391paths:
2392  /noop:
2393    post:
2394      operationId: noop
2395      responses:
2396        "200": { description: ok }
2397components:
2398  schemas:
2399    Stub:
2400      type: object
2401"#;
2402        let api = Spec::from_str(api_str).expect("parse api");
2403        let err = resolve_resource(&spec, &api, &ProviderDefaults::default()).unwrap_err();
2404        assert!(format!("{err}").contains("kind = \"action\""));
2405    }
2406}