1use std::collections::BTreeMap;
25
26use magma_cty::{CtyType, DynamicValue};
27use magma_protocol::{PluginProtocol, tfplugin5, tfplugin6};
28
29use crate::H2Channel;
30use crate::schema::{self, SchemaError};
31
32type Client5 = tfplugin5::provider_client::ProviderClient<H2Channel>;
33type Client6 = tfplugin6::provider_client::ProviderClient<H2Channel>;
34
35enum Client {
36 V5(Client5),
37 V6(Client6),
38}
39
40pub(crate) fn client_caps_v6() -> Option<tfplugin6::ClientCapabilities> {
53 Some(tfplugin6::ClientCapabilities {
54 deferral_allowed: false,
55 write_only_attributes_allowed: false,
56 })
57}
58
59fn client_caps_v5() -> Option<tfplugin5::ClientCapabilities> {
60 Some(tfplugin5::ClientCapabilities {
61 deferral_allowed: false,
62 write_only_attributes_allowed: false,
63 })
64}
65
66pub struct ProviderConn {
68 client: Client,
69}
70
71#[derive(Debug, Clone)]
74pub struct ProviderSchema {
75 pub provider_config: CtyType,
76 pub resources: BTreeMap<String, CtyType>,
77 pub data_sources: BTreeMap<String, CtyType>,
81 pub resource_versions: BTreeMap<String, i64>,
93}
94
95impl ProviderSchema {
96 pub fn resource(&self, type_name: &str) -> Option<&CtyType> {
97 self.resources.get(type_name)
98 }
99
100 pub fn data_source(&self, type_name: &str) -> Option<&CtyType> {
101 self.data_sources.get(type_name)
102 }
103
104 #[must_use]
111 pub fn resource_version(&self, type_name: &str) -> i64 {
112 self.resource_versions.get(type_name).copied().unwrap_or(0)
113 }
114
115 #[must_use]
122 pub fn resource_version_u64(&self, type_name: &str) -> u64 {
123 u64::try_from(self.resource_version(type_name)).unwrap_or(0)
124 }
125}
126
127#[derive(Debug, Clone, PartialEq, Eq)]
128pub struct Diag {
129 pub severity: Severity,
130 pub summary: String,
131 pub detail: String,
132}
133
134#[derive(Debug, Clone, PartialEq, Eq)]
148pub struct PlannedChange {
149 pub state: DynamicValue,
150 pub requires_replace: Vec<String>,
159}
160
161#[derive(Debug, Clone, Copy, PartialEq, Eq)]
162pub enum Severity {
163 Error,
164 Warning,
165 Unknown,
166}
167
168#[derive(Debug, thiserror::Error)]
169pub enum ProviderError {
170 #[error("provider RPC transport: {0}")]
171 Transport(String),
172 #[error("provider returned {} error diagnostic(s): {}", .0.len(), fmt_diags(.0))]
173 Diagnostics(Vec<Diag>),
174 #[error("provider returned no new_state from apply")]
175 NoNewState,
176 #[error("provider returned {} error diagnostic(s) WITH a new_state (partial apply — resource is committed): {}", .diags.len(), fmt_diags(.diags))]
198 PartiallyApplied {
199 diags: Vec<Diag>,
200 state: Box<DynamicValue>,
201 },
202 #[error("schema: {0}")]
203 Schema(#[from] SchemaError),
204}
205
206#[must_use]
214pub fn is_retryable(e: &ProviderError) -> bool {
215 const TRANSIENT: &[&str] = &[
216 "rate limit",
217 "secondary rate",
218 "too many request",
219 "abuse",
220 "quota",
221 "try again",
222 "retry",
223 "429",
224 "503",
225 "resource_exhausted",
226 "unavailable",
227 "timeout",
228 "timed out",
229 "connection reset",
230 "broken pipe",
231 "tls",
232 "transport",
233 "h2 protocol",
234 "eof",
235 ];
236 let hit = |s: &str| {
237 let l = s.to_ascii_lowercase();
238 TRANSIENT.iter().any(|p| l.contains(p))
239 };
240 match e {
241 ProviderError::Transport(s) => hit(s),
242 ProviderError::Diagnostics(diags) => {
243 diags.iter().any(|d| hit(&d.summary) || hit(&d.detail))
244 }
245 ProviderError::PartiallyApplied { .. } => false,
250 ProviderError::NoNewState | ProviderError::Schema(_) => false,
251 }
252}
253
254fn fmt_diags(diags: &[Diag]) -> String {
255 diags
256 .iter()
257 .map(|d| format!("{}: {}", d.summary, d.detail))
258 .collect::<Vec<_>>()
259 .join("; ")
260}
261
262impl ProviderConn {
263 pub fn new(channel: H2Channel, protocol: PluginProtocol) -> Self {
266 let client = match protocol {
267 PluginProtocol::V5 => {
268 Client::V5(Client5::new(channel).max_decoding_message_size(256 * 1024 * 1024))
269 }
270 PluginProtocol::V6 => {
271 Client::V6(Client6::new(channel).max_decoding_message_size(256 * 1024 * 1024))
272 }
273 };
274 Self { client }
275 }
276
277 pub async fn get_schema(&mut self) -> Result<ProviderSchema, ProviderError> {
280 match &mut self.client {
281 Client::V6(c) => {
282 let resp = c
283 .get_provider_schema(tfplugin6::get_provider_schema::Request::default())
284 .await
285 .map_err(transport)?
286 .into_inner();
287 check_diags(resp.diagnostics.iter().map(diag6))?;
288 let provider_config = match resp.provider.and_then(|s| s.block) {
289 Some(b) => schema::block_implied_type(&b)?,
290 None => CtyType::Object(BTreeMap::new()),
291 };
292 let mut resources = BTreeMap::new();
293 let mut resource_versions = BTreeMap::new();
294 for (name, sch) in resp.resource_schemas {
295 resource_versions.insert(name.clone(), sch.version);
300 if let Some(b) = sch.block {
301 resources.insert(name, schema::block_implied_type(&b)?);
302 }
303 }
304 let mut data_sources = BTreeMap::new();
305 for (name, sch) in resp.data_source_schemas {
306 if let Some(b) = sch.block {
307 data_sources.insert(name, schema::block_implied_type(&b)?);
308 }
309 }
310 Ok(ProviderSchema {
311 provider_config,
312 resources,
313 data_sources,
314 resource_versions,
315 })
316 }
317 Client::V5(c) => {
318 let resp = c
319 .get_schema(tfplugin5::get_provider_schema::Request::default())
320 .await
321 .map_err(transport)?
322 .into_inner();
323 check_diags(resp.diagnostics.iter().map(diag5))?;
324 let provider_config = match resp.provider.and_then(|s| s.block) {
325 Some(b) => schema::block5_implied_type(&b)?,
326 None => CtyType::Object(BTreeMap::new()),
327 };
328 let mut resources = BTreeMap::new();
329 let mut resource_versions = BTreeMap::new();
330 for (name, sch) in resp.resource_schemas {
331 resource_versions.insert(name.clone(), sch.version);
332 if let Some(b) = sch.block {
333 resources.insert(name, schema::block5_implied_type(&b)?);
334 }
335 }
336 let mut data_sources = BTreeMap::new();
337 for (name, sch) in resp.data_source_schemas {
338 if let Some(b) = sch.block {
339 data_sources.insert(name, schema::block5_implied_type(&b)?);
340 }
341 }
342 Ok(ProviderSchema {
343 provider_config,
344 resources,
345 data_sources,
346 resource_versions,
347 })
348 }
349 }
350 }
351
352 pub async fn configure(
354 &mut self,
355 config: &DynamicValue,
356 terraform_version: &str,
357 ) -> Result<(), ProviderError> {
358 match &mut self.client {
359 Client::V6(c) => {
360 let resp = c
361 .configure_provider(tfplugin6::configure_provider::Request {
362 terraform_version: terraform_version.to_string(),
363 config: Some(to_pb6(config)),
364 client_capabilities: client_caps_v6(),
365 ..Default::default()
366 })
367 .await
368 .map_err(transport)?
369 .into_inner();
370 check_diags(resp.diagnostics.iter().map(diag6))
371 }
372 Client::V5(c) => {
373 let resp = c
374 .configure(tfplugin5::configure::Request {
375 terraform_version: terraform_version.to_string(),
376 config: Some(to_pb5(config)),
377 client_capabilities: client_caps_v5(),
378 ..Default::default()
379 })
380 .await
381 .map_err(transport)?
382 .into_inner();
383 check_diags(resp.diagnostics.iter().map(diag5))
384 }
385 }
386 }
387
388 pub async fn plan_resource_change(
395 &mut self,
396 type_name: &str,
397 prior_state: &DynamicValue,
398 proposed_new_state: &DynamicValue,
399 config: &DynamicValue,
400 ) -> Result<PlannedChange, ProviderError> {
401 match &mut self.client {
402 Client::V6(c) => {
403 let resp = c
404 .plan_resource_change(tfplugin6::plan_resource_change::Request {
405 type_name: type_name.to_string(),
406 prior_state: Some(to_pb6(prior_state)),
407 proposed_new_state: Some(to_pb6(proposed_new_state)),
408 config: Some(to_pb6(config)),
409 client_capabilities: client_caps_v6(),
410 ..Default::default()
411 })
412 .await
413 .map_err(transport)?
414 .into_inner();
415 check_diags(resp.diagnostics.iter().map(diag6))?;
416 let requires_replace = resp
417 .requires_replace
418 .iter()
419 .map(attribute_path_to_string_v6)
420 .collect();
421 let state = resp
422 .planned_state
423 .map(from_pb6)
424 .ok_or(ProviderError::NoNewState)?;
425 Ok(PlannedChange {
426 state,
427 requires_replace,
428 })
429 }
430 Client::V5(c) => {
431 let resp = c
432 .plan_resource_change(tfplugin5::plan_resource_change::Request {
433 type_name: type_name.to_string(),
434 prior_state: Some(to_pb5(prior_state)),
435 proposed_new_state: Some(to_pb5(proposed_new_state)),
436 config: Some(to_pb5(config)),
437 client_capabilities: client_caps_v5(),
438 ..Default::default()
439 })
440 .await
441 .map_err(transport)?
442 .into_inner();
443 check_diags(resp.diagnostics.iter().map(diag5))?;
444 let requires_replace = resp
445 .requires_replace
446 .iter()
447 .map(attribute_path_to_string_v5)
448 .collect();
449 let state = resp
450 .planned_state
451 .map(from_pb5)
452 .ok_or(ProviderError::NoNewState)?;
453 Ok(PlannedChange {
454 state,
455 requires_replace,
456 })
457 }
458 }
459 }
460
461 pub async fn apply_resource_change(
463 &mut self,
464 type_name: &str,
465 prior_state: &DynamicValue,
466 planned_state: &DynamicValue,
467 config: &DynamicValue,
468 ) -> Result<DynamicValue, ProviderError> {
469 match &mut self.client {
470 Client::V6(c) => {
471 let resp = c
472 .apply_resource_change(tfplugin6::apply_resource_change::Request {
473 type_name: type_name.to_string(),
474 prior_state: Some(to_pb6(prior_state)),
475 planned_state: Some(to_pb6(planned_state)),
476 config: Some(to_pb6(config)),
477 ..Default::default()
478 })
479 .await
480 .map_err(transport)?
481 .into_inner();
482 apply_outcome(
487 error_diags(resp.diagnostics.iter().map(diag6)),
488 resp.new_state.map(from_pb6),
489 )
490 }
491 Client::V5(c) => {
492 let resp = c
493 .apply_resource_change(tfplugin5::apply_resource_change::Request {
494 type_name: type_name.to_string(),
495 prior_state: Some(to_pb5(prior_state)),
496 planned_state: Some(to_pb5(planned_state)),
497 config: Some(to_pb5(config)),
498 ..Default::default()
499 })
500 .await
501 .map_err(transport)?
502 .into_inner();
503 apply_outcome(
508 error_diags(resp.diagnostics.iter().map(diag5)),
509 resp.new_state.map(from_pb5),
510 )
511 }
512 }
513 }
514
515 pub async fn read_resource(
521 &mut self,
522 type_name: &str,
523 current_state: &DynamicValue,
524 ) -> Result<Option<DynamicValue>, ProviderError> {
525 match &mut self.client {
526 Client::V6(c) => {
527 let resp = c
528 .read_resource(tfplugin6::read_resource::Request {
529 type_name: type_name.to_string(),
530 current_state: Some(to_pb6(current_state)),
531 client_capabilities: client_caps_v6(),
532 ..Default::default()
533 })
534 .await
535 .map_err(transport)?
536 .into_inner();
537 check_diags(resp.diagnostics.iter().map(diag6))?;
538 Ok(resp.new_state.map(from_pb6).filter(|d| !d.is_null()))
539 }
540 Client::V5(c) => {
541 let resp = c
542 .read_resource(tfplugin5::read_resource::Request {
543 type_name: type_name.to_string(),
544 current_state: Some(to_pb5(current_state)),
545 client_capabilities: client_caps_v5(),
546 ..Default::default()
547 })
548 .await
549 .map_err(transport)?
550 .into_inner();
551 check_diags(resp.diagnostics.iter().map(diag5))?;
552 Ok(resp.new_state.map(from_pb5).filter(|d| !d.is_null()))
553 }
554 }
555 }
556
557 pub async fn read_data_source(
563 &mut self,
564 type_name: &str,
565 config: &DynamicValue,
566 ) -> Result<Option<DynamicValue>, ProviderError> {
567 match &mut self.client {
568 Client::V6(c) => {
569 let resp = c
570 .read_data_source(tfplugin6::read_data_source::Request {
571 type_name: type_name.to_string(),
572 config: Some(to_pb6(config)),
573 client_capabilities: client_caps_v6(),
574 ..Default::default()
575 })
576 .await
577 .map_err(transport)?
578 .into_inner();
579 check_diags(resp.diagnostics.iter().map(diag6))?;
580 Ok(resp.state.map(from_pb6).filter(|d| !d.is_null()))
581 }
582 Client::V5(c) => {
583 let resp = c
584 .read_data_source(tfplugin5::read_data_source::Request {
585 type_name: type_name.to_string(),
586 config: Some(to_pb5(config)),
587 client_capabilities: client_caps_v5(),
588 ..Default::default()
589 })
590 .await
591 .map_err(transport)?
592 .into_inner();
593 check_diags(resp.diagnostics.iter().map(diag5))?;
594 Ok(resp.state.map(from_pb5).filter(|d| !d.is_null()))
595 }
596 }
597 }
598
599 pub async fn import_resource_state(
607 &mut self,
608 type_name: &str,
609 id: &str,
610 ) -> Result<Option<DynamicValue>, ProviderError> {
611 match &mut self.client {
612 Client::V6(c) => {
613 let resp = c
614 .import_resource_state(tfplugin6::import_resource_state::Request {
615 type_name: type_name.to_string(),
616 id: id.to_string(),
617 client_capabilities: client_caps_v6(),
618 ..Default::default()
619 })
620 .await
621 .map_err(transport)?
622 .into_inner();
623 check_diags(resp.diagnostics.iter().map(diag6))?;
624 Ok(resp
625 .imported_resources
626 .into_iter()
627 .next()
628 .and_then(|ir| ir.state)
629 .map(from_pb6)
630 .filter(|d| !d.is_null()))
631 }
632 Client::V5(c) => {
633 let resp = c
634 .import_resource_state(tfplugin5::import_resource_state::Request {
635 type_name: type_name.to_string(),
636 id: id.to_string(),
637 client_capabilities: client_caps_v5(),
638 ..Default::default()
639 })
640 .await
641 .map_err(transport)?
642 .into_inner();
643 check_diags(resp.diagnostics.iter().map(diag5))?;
644 Ok(resp
645 .imported_resources
646 .into_iter()
647 .next()
648 .and_then(|ir| ir.state)
649 .map(from_pb5)
650 .filter(|d| !d.is_null()))
651 }
652 }
653 }
654
655 pub async fn upgrade_resource_state(
670 &mut self,
671 type_name: &str,
672 stored_version: i64,
673 raw_json: &[u8],
674 ) -> Result<DynamicValue, ProviderError> {
675 match &mut self.client {
676 Client::V6(c) => {
677 let resp = c
678 .upgrade_resource_state(tfplugin6::upgrade_resource_state::Request {
679 type_name: type_name.to_string(),
680 version: stored_version,
681 raw_state: Some(tfplugin6::RawState {
682 json: raw_json.to_vec(),
683 flatmap: Default::default(),
684 }),
685 })
686 .await
687 .map_err(transport)?
688 .into_inner();
689 check_diags(resp.diagnostics.iter().map(diag6))?;
690 resp.upgraded_state
691 .map(from_pb6)
692 .ok_or(ProviderError::NoNewState)
693 }
694 Client::V5(c) => {
695 let resp = c
696 .upgrade_resource_state(tfplugin5::upgrade_resource_state::Request {
697 type_name: type_name.to_string(),
698 version: stored_version,
699 raw_state: Some(tfplugin5::RawState {
700 json: raw_json.to_vec(),
701 flatmap: Default::default(),
702 }),
703 })
704 .await
705 .map_err(transport)?
706 .into_inner();
707 check_diags(resp.diagnostics.iter().map(diag5))?;
708 resp.upgraded_state
709 .map(from_pb5)
710 .ok_or(ProviderError::NoNewState)
711 }
712 }
713 }
714}
715
716fn transport(s: tonic::Status) -> ProviderError {
717 ProviderError::Transport(s.to_string())
718}
719
720fn to_pb6(dv: &DynamicValue) -> tfplugin6::DynamicValue {
721 tfplugin6::DynamicValue {
722 msgpack: dv.msgpack.clone(),
723 json: Vec::new(),
724 }
725}
726fn from_pb6(dv: tfplugin6::DynamicValue) -> DynamicValue {
727 DynamicValue {
728 msgpack: dv.msgpack,
729 }
730}
731fn to_pb5(dv: &DynamicValue) -> tfplugin5::DynamicValue {
732 tfplugin5::DynamicValue {
733 msgpack: dv.msgpack.clone(),
734 json: Vec::new(),
735 }
736}
737fn from_pb5(dv: tfplugin5::DynamicValue) -> DynamicValue {
738 DynamicValue {
739 msgpack: dv.msgpack,
740 }
741}
742
743enum PathStep {
748 Attribute(String),
749 ElementKeyString(String),
750 ElementKeyInt(i64),
751}
752
753fn render_attribute_path(steps: impl Iterator<Item = PathStep>) -> String {
759 let mut out = String::new();
760 for step in steps {
761 match step {
762 PathStep::Attribute(name) => {
763 if !out.is_empty() {
764 out.push('.');
765 }
766 out.push_str(&name);
767 }
768 PathStep::ElementKeyString(key) => {
769 out.push('[');
770 out.push_str(&key);
771 out.push(']');
772 }
773 PathStep::ElementKeyInt(i) => {
774 out.push('[');
775 out.push_str(&i.to_string());
776 out.push(']');
777 }
778 }
779 }
780 out
781}
782
783fn attribute_path_to_string_v6(path: &tfplugin6::AttributePath) -> String {
784 render_attribute_path(path.steps.iter().map(|s| match &s.selector {
785 Some(tfplugin6::attribute_path::step::Selector::AttributeName(n)) => {
786 PathStep::Attribute(n.clone())
787 }
788 Some(tfplugin6::attribute_path::step::Selector::ElementKeyString(k)) => {
789 PathStep::ElementKeyString(k.clone())
790 }
791 Some(tfplugin6::attribute_path::step::Selector::ElementKeyInt(i)) => {
792 PathStep::ElementKeyInt(*i)
793 }
794 None => PathStep::Attribute(String::new()),
798 }))
799}
800
801fn attribute_path_to_string_v5(path: &tfplugin5::AttributePath) -> String {
802 render_attribute_path(path.steps.iter().map(|s| match &s.selector {
803 Some(tfplugin5::attribute_path::step::Selector::AttributeName(n)) => {
804 PathStep::Attribute(n.clone())
805 }
806 Some(tfplugin5::attribute_path::step::Selector::ElementKeyString(k)) => {
807 PathStep::ElementKeyString(k.clone())
808 }
809 Some(tfplugin5::attribute_path::step::Selector::ElementKeyInt(i)) => {
810 PathStep::ElementKeyInt(*i)
811 }
812 None => PathStep::Attribute(String::new()),
813 }))
814}
815
816fn diag6(d: &tfplugin6::Diagnostic) -> (i32, String, String) {
818 (d.severity, d.summary.clone(), d.detail.clone())
819}
820fn diag5(d: &tfplugin5::Diagnostic) -> (i32, String, String) {
822 (d.severity, d.summary.clone(), d.detail.clone())
823}
824
825fn error_diags(diags: impl Iterator<Item = (i32, String, String)>) -> Vec<Diag> {
830 diags
831 .filter(|(sev, _, _)| *sev == 1)
832 .map(|(_, summary, detail)| Diag {
833 severity: Severity::Error,
834 summary,
835 detail,
836 })
837 .collect()
838}
839
840fn apply_outcome(
851 errs: Vec<Diag>,
852 new_state: Option<DynamicValue>,
853) -> Result<DynamicValue, ProviderError> {
854 match (errs.is_empty(), new_state) {
855 (true, Some(dv)) => Ok(dv),
856 (true, None) => Err(ProviderError::NoNewState),
857 (false, Some(dv)) => Err(ProviderError::PartiallyApplied {
858 diags: errs,
859 state: Box::new(dv),
860 }),
861 (false, None) => Err(ProviderError::Diagnostics(errs)),
862 }
863}
864
865fn check_diags(diags: impl Iterator<Item = (i32, String, String)>) -> Result<(), ProviderError> {
866 let errors = error_diags(diags);
867 if errors.is_empty() {
868 Ok(())
869 } else {
870 Err(ProviderError::Diagnostics(errors))
871 }
872}
873
874#[cfg(test)]
875mod tests {
876 use super::*;
877
878 fn err_diag(msg: &str) -> Vec<Diag> {
879 vec![Diag {
880 severity: Severity::Error,
881 summary: msg.to_string(),
882 detail: String::new(),
883 }]
884 }
885
886 fn eip_type() -> CtyType {
888 CtyType::Object(BTreeMap::from([("id".to_string(), CtyType::String)]))
889 }
890
891 fn some_state() -> DynamicValue {
892 DynamicValue::from_json(&serde_json::json!({"id": "eipalloc-1"}), &eip_type())
893 .expect("test fixture must encode")
894 }
895
896 #[test]
900 fn error_with_new_state_is_partially_applied_and_keeps_the_state() {
901 let out = apply_outcome(err_diag("tagging failed"), Some(some_state()));
902 match out {
903 Err(ProviderError::PartiallyApplied { diags, state }) => {
904 assert_eq!(diags.len(), 1);
905 assert_eq!(diags[0].summary, "tagging failed");
906 let attrs = state
909 .to_json(&eip_type())
910 .expect("partial state must decode");
911 assert_eq!(attrs["id"], "eipalloc-1");
912 }
913 other => panic!("expected PartiallyApplied, got {other:?}"),
914 }
915 }
916
917 #[test]
918 fn error_without_new_state_stays_plain_diagnostics() {
919 assert!(matches!(
920 apply_outcome(err_diag("boom"), None),
921 Err(ProviderError::Diagnostics(_))
922 ));
923 }
924
925 #[test]
926 fn clean_apply_with_state_is_ok() {
927 assert!(apply_outcome(Vec::new(), Some(some_state())).is_ok());
928 }
929
930 #[test]
931 fn clean_apply_without_state_is_no_new_state() {
932 assert!(matches!(
933 apply_outcome(Vec::new(), None),
934 Err(ProviderError::NoNewState)
935 ));
936 }
937
938 #[test]
943 fn a_partial_apply_is_never_retryable_even_when_the_text_looks_transient() {
944 let e = ProviderError::PartiallyApplied {
945 diags: err_diag("connection reset by peer: timeout"),
947 state: Box::new(some_state()),
948 };
949 assert!(
950 !is_retryable(&e),
951 "retrying a committed resource duplicates it"
952 );
953 }
954
955 #[test]
956 fn empty_diagnostics_is_ok() {
957 assert!(check_diags(std::iter::empty()).is_ok());
958 }
959
960 #[test]
961 fn warning_only_is_ok() {
962 let diags = vec![(2, "heads up".to_string(), String::new())];
963 assert!(check_diags(diags.into_iter()).is_ok());
964 }
965
966 #[test]
967 fn any_error_diagnostic_fails() {
968 let diags = vec![
969 (2, "warn".to_string(), String::new()),
970 (1, "boom".to_string(), "bad".to_string()),
971 ];
972 match check_diags(diags.into_iter()) {
973 Err(ProviderError::Diagnostics(errs)) => {
974 assert_eq!(errs.len(), 1, "only error-severity diags are fatal");
975 assert_eq!(errs[0].summary, "boom");
976 }
977 other => panic!("expected Diagnostics error, got {other:?}"),
978 }
979 }
980
981 #[test]
982 fn dynamic_value_pb_roundtrip_both_protocols() {
983 let dv = DynamicValue {
984 msgpack: vec![0xc0, 0x01, 0x02],
985 };
986 assert_eq!(from_pb6(to_pb6(&dv)), dv);
987 assert_eq!(from_pb5(to_pb5(&dv)), dv);
988 assert!(to_pb6(&dv).json.is_empty());
989 }
990
991 fn v6_attr_step(name: &str) -> tfplugin6::attribute_path::Step {
992 tfplugin6::attribute_path::Step {
993 selector: Some(tfplugin6::attribute_path::step::Selector::AttributeName(
994 name.to_string(),
995 )),
996 }
997 }
998
999 fn v6_index_step(i: i64) -> tfplugin6::attribute_path::Step {
1000 tfplugin6::attribute_path::Step {
1001 selector: Some(tfplugin6::attribute_path::step::Selector::ElementKeyInt(i)),
1002 }
1003 }
1004
1005 fn v6_key_step(k: &str) -> tfplugin6::attribute_path::Step {
1006 tfplugin6::attribute_path::Step {
1007 selector: Some(tfplugin6::attribute_path::step::Selector::ElementKeyString(
1008 k.to_string(),
1009 )),
1010 }
1011 }
1012
1013 #[test]
1014 fn attribute_path_to_string_v6_single_attribute() {
1015 let path = tfplugin6::AttributePath {
1016 steps: vec![v6_attr_step("instance_types")],
1017 };
1018 assert_eq!(attribute_path_to_string_v6(&path), "instance_types");
1019 }
1020
1021 #[test]
1022 fn attribute_path_to_string_v6_nested_key() {
1023 let path = tfplugin6::AttributePath {
1024 steps: vec![v6_attr_step("tags"), v6_key_step("Name")],
1025 };
1026 assert_eq!(attribute_path_to_string_v6(&path), "tags[Name]");
1027 }
1028
1029 #[test]
1030 fn attribute_path_to_string_v6_indexed_then_attribute() {
1031 let path = tfplugin6::AttributePath {
1032 steps: vec![
1033 v6_attr_step("rules"),
1034 v6_index_step(2),
1035 v6_attr_step("port"),
1036 ],
1037 };
1038 assert_eq!(attribute_path_to_string_v6(&path), "rules[2].port");
1039 }
1040
1041 fn v5_attr_step(name: &str) -> tfplugin5::attribute_path::Step {
1042 tfplugin5::attribute_path::Step {
1043 selector: Some(tfplugin5::attribute_path::step::Selector::AttributeName(
1044 name.to_string(),
1045 )),
1046 }
1047 }
1048
1049 #[test]
1050 fn attribute_path_to_string_v5_matches_v6_shape() {
1051 let path = tfplugin5::AttributePath {
1052 steps: vec![v5_attr_step("ami")],
1053 };
1054 assert_eq!(attribute_path_to_string_v5(&path), "ami");
1055 }
1056
1057 #[test]
1064 fn planned_change_requires_replace_is_empty_iff_no_paths() {
1065 let no_replace = PlannedChange {
1066 state: DynamicValue {
1067 msgpack: vec![0xc0],
1068 },
1069 requires_replace: vec![],
1070 };
1071 let must_replace = PlannedChange {
1072 state: DynamicValue {
1073 msgpack: vec![0xc0],
1074 },
1075 requires_replace: vec!["instance_types".to_string()],
1076 };
1077 assert!(no_replace.requires_replace.is_empty());
1078 assert!(!must_replace.requires_replace.is_empty());
1079 }
1080}