diff --git a/src/main.rs b/src/main.rs index 753a3c1..c7c6b10 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,7 +4,7 @@ use std::io::BufReader; use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; use std::process::ExitCode; -use anyhow::{anyhow, Result}; +use anyhow::Result; mod parsing; use parsing::*; @@ -73,8 +73,8 @@ impl Ctx { pub fn add_file(&mut self, file: &Path) -> anyhow::Result<()> { let cfg: Config = { let file = File::open(file)?; - let body: hcl::Body = hcl::from_reader(BufReader::new(file))?; - Config::from_body(&body).map_err(|s| anyhow!("{}", s))? + let config: Config = hcl::from_reader(BufReader::new(file))?; + config }; for (jobname, job) in cfg.jobs { diff --git a/src/parsing.rs b/src/parsing.rs index 20048c7..4d2788b 100644 --- a/src/parsing.rs +++ b/src/parsing.rs @@ -1,5 +1,10 @@ -use serde::Deserialize; -use hcl::{Structure, Body, Map, Expression}; +use serde::de::{ + value::{MapAccessDeserializer, SeqAccessDeserializer}, + MapAccess, SeqAccess, Visitor, +}; +use serde::{Deserialize, Deserializer}; +use std::fmt; +use std::marker::PhantomData; #[derive(Deserialize, Debug)] pub struct Config { @@ -10,142 +15,68 @@ pub struct Config { #[derive(Deserialize, Debug)] pub struct Job { pub datacenters: Vec, - #[serde(rename = "group")] + #[serde(rename = "group", default = "hcl::Map::new")] pub groups: hcl::Map, } #[derive(Deserialize, Debug)] pub struct Group { - #[serde(rename = "task")] + #[serde(rename = "task", default = "hcl::Map::new")] pub tasks: hcl::Map, } #[derive(Deserialize, Debug)] pub struct Task { - #[serde(rename = "service")] + #[serde(rename = "service", deserialize_with = "vec_or_map", default = "Vec::new")] pub services: Vec, } #[derive(Deserialize, Debug)] pub struct Service { pub name: Option, + #[serde(default = "Vec::new")] pub tags: Vec, } -// lots of manual boilerplate because I can't figure out how to use the -// automated deserialization for blocks without labels (e.g. "service") that may -// appear one or several times: -// https://github.com/martinohmann/hcl-rs/issues/380 - -impl Config { - pub fn from_body(b: &Body) -> Result { - Ok(Config { jobs: blocks_from_body(b, "job", Job::from_body)? }) - } -} - -impl Job { - fn from_body(b: &Body) -> Result { - let datacenters = - strings_from_expr(&attribute_from_body(b, "datacenters")?)?; - let groups = blocks_from_body(b, "group", Group::from_body)?; - Ok(Job { datacenters, groups }) - } -} - -impl Group { - fn from_body(b: &Body) -> Result { - Ok(Group { tasks: blocks_from_body(b, "task", Task::from_body)? }) - } -} - -impl Task { - fn from_body(b: &Body) -> Result { - Ok(Task { services: blocks_nolabel_from_body(b, "service", Service::from_body)? }) - } -} - -impl Service { - fn from_body(b: &Body) -> Result { - let name = - attribute_from_body(b, "name").ok() - .map(|e| string_from_expr(&e)).transpose()?; - let tags = match attribute_from_body(b, "tags") { - Err(_) => Vec::new(), - Ok(tags) => tags_from_expr(&tags)?, - }; - Ok(Service { name, tags }) - } -} - -fn blocks_from_body(b: &Body, id: &str, f: F) -> Result, String> - where F: Fn(&Body) -> Result +// In .hcl files, a field can sometimes container either a record (for a single +// item), or a sequence of records (for several items). This is not something +// that the built-in hcl deserializer handles. Instead, the following +// deserializer can be attached to fields that follow this schema with the +// "deserialize_with" serde attribute. +// +// Thanks to the hcl-rs maintener for the code snippet +// (https://github.com/martinohmann/hcl-rs/issues/380#issuecomment-2456546232) +fn vec_or_map<'de, T, D>(deserializer: D) -> Result, D::Error> +where + T: Deserialize<'de>, + D: Deserializer<'de>, { - let mut blocks = Map::new(); - for s in &b.0 { - if let Structure::Block(block) = s { - if block.identifier.as_str() == id { - if block.labels.len() != 1 { - return Err(format!("{} block: unexpected number of labels", id)) - } - blocks.insert(block.labels[0].as_str().to_string(), f(&block.body)?); - } + struct VecOrMap(PhantomData T>); + + impl<'de, T> Visitor<'de> for VecOrMap + where + T: Deserialize<'de>, + { + type Value = Vec; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("sequence or map") + } + + fn visit_seq(self, seq: A) -> Result + where + A: SeqAccess<'de>, + { + Deserialize::deserialize(SeqAccessDeserializer::new(seq)) + } + + fn visit_map(self, map: M) -> Result + where + M: MapAccess<'de>, + { + Deserialize::deserialize(MapAccessDeserializer::new(map)).map(|value| vec![value]) } } - Ok(blocks) -} -fn blocks_nolabel_from_body(b: &Body, id: &str, f: F) -> Result, String> - where F: Fn(&Body) -> Result -{ - let mut blocks = Vec::new(); - for s in &b.0 { - if let Structure::Block(block) = s { - if block.identifier.as_str() == id { - blocks.push(f(&block.body)?) - } - } - } - Ok(blocks) -} - -fn attribute_from_body(b: &Body, name: &str) -> Result -{ - b.0.iter().find_map(|s| { - if let Structure::Attribute(attr) = s { - if attr.key.as_str() == name { - return Some(attr.expr.clone()) - } - } - return None - }).ok_or(format!("attribute {} not found", name)) -} - -fn string_from_expr(e: &Expression) -> Result -{ - match e { - Expression::String(s) => Ok(s.to_string()), - _ => Err(format!("string expected, got {:?}", &e)), - } -} - -fn tags_from_expr(e: &Expression) -> Result, String> -{ - match e { - Expression::Array(es) => { - Ok(es.into_iter().filter_map(|e| match string_from_expr(e) { - Ok(e) => Some(e), - Err(_) => { println!("note: ignoring tag {:?}", &e); None }, - }).collect()) - }, - _ => Err(format!("array expected, got {:#?}", &e)) - } -} - -fn strings_from_expr(e: &Expression) -> Result, String> -{ - match e { - Expression::Array(es) => - es.into_iter().map(string_from_expr).collect(), - _ => Err(format!("array expected, got {:#?}", &e)) - } + deserializer.deserialize_any(VecOrMap(PhantomData)) }