Deserialize hcl with labels

Viewed 52

I'm attempting to use hcl-rs = 0.7.0 to parse some HCL. I'm just experimenting with arbitrary HCL, so I'm not looking to parse terraform specific code.

I'd like to be able to parse a block like this and get it's label as part of result

nested_block "nested_block_label" {
    foo = 123
}

This currently doesn't work, but hopefully it shows my intention. Is something like this possible?

#[test]
fn deserialize_struct_with_label() {
    #[derive(Deserialize, PartialEq, Debug)]
    struct TestRoot {
        nested_block: TestNested,
    }
    #[derive(Deserialize, PartialEq, Debug)]
    struct TestNested {
        label: String,
        foo: u32,
    }


    let input = r#"
    nested_block "nested_block_label" {
        foo = 123
    }"#;
    let expected = TestRoot{ nested_block: TestNested { label: String::from("nested_block_label"), foo: 123 } };
    assert_eq!(expected, from_str::<TestRoot>(input).unwrap());
}
1 Answers

Your problem is that hcl by default seems to interpret

nested_block "nested_block_label" {
    foo = 123
}

as the following "serde structure":

"nested_block" -> {
  "nested_block_label" -> {
    "foo" -> 123
  }
}

but for your Rust structs, it would have to be

"nested_block" -> {
  "label" -> "nested_block_label"
  "foo" -> 123
}

I'm not aware of any attributes that would allow you to bend the former into the latter.

As usual, when faced with this kind of situation, it is often easiest to first deserialize to a generic structure like hcl::Block and then convert to whatever struct you want manually. Disadvantage is that you'd have to do that for every struct separately.

You could, in theory, implement a generic deserialization function that wraps the Deserializer it receives and flattens the two-level structure you get into the one-level structure you want. But implementing Deserializers requires massive boilerplate. Possibly, somebody has done that before, but I'm not aware of any crate that would help you here, either.

As a sort of medium effort solution, you could have a special Labelled wrapper structure that always catches and flattens this intermediate level:

#[derive(Debug, PartialEq)]
struct Labelled<T> {
    label: String,
    t: T,
}

impl<'de, T: Deserialize<'de>> Deserialize<'de> for Labelled<T> {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: serde::Deserializer<'de>,
    {
        struct V<T>(std::marker::PhantomData<T>);
        impl<'de, T: Deserialize<'de>> serde::de::Visitor<'de> for V<T> {
            type Value = Labelled<T>;
            fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
            where
                A: serde::de::MapAccess<'de>,
            {
                if let (Some((label, t)), None) =
                    (map.next_entry()?, map.next_entry::<String, ()>()?)
                {
                    Ok(Labelled { label, t })
                } else {
                    Err(serde::de::Error::invalid_type(
                        serde::de::Unexpected::Other("Singleton map"),
                        &self,
                    ))
                }
            }
        }
        deserializer.deserialize_map(V::<T>(Default::default()))
    }
}

would be used like this:

#[derive(Deserialize, PartialEq, Debug)]
struct TestRoot {
    nested_block: Labelled<TestNested>,
}
#[derive(Deserialize, PartialEq, Debug)]
struct TestNested {
    foo: u32,
}

Lastly, there may be some trick like adding #[serde(rename = "$hcl::label")]. Other serialization libraries (e.g. quick-xml) have similar and allow marking fields as something special this way. hcl-rs does the same internally, but it's undocumented and I can't figure out from the source whether what you need is possible.

Related