tfconfig/
lib.rs

1use hcl::ObjectKey;
2use std::{
3    collections::HashMap,
4    error, fs, io,
5    path::{Path, PathBuf},
6};
7use thiserror::Error;
8
9type Result<T> = std::result::Result<T, Error>;
10
11#[derive(Debug, Default)]
12pub struct Module {
13    pub path: PathBuf,
14    pub required_core: Vec<String>,
15    pub required_providers: HashMap<String, ProviderRequirement>,
16}
17
18impl Module {
19    pub fn new(path: PathBuf) -> Self {
20        Self {
21            path,
22            ..Default::default()
23        }
24    }
25}
26
27#[derive(Debug, Default)]
28pub struct ProviderRequirement {
29    pub source: String,
30    pub version_constraints: Vec<String>,
31    pub configuration_aliases: Vec<ProviderRef>,
32}
33
34impl ProviderRequirement {
35    pub fn new(source: String, version_constraints: Vec<String>) -> Self {
36        Self {
37            source,
38            version_constraints,
39            configuration_aliases: vec![],
40        }
41    }
42}
43
44#[derive(Debug, Default, Clone)]
45pub struct ProviderRef {
46    pub name: String,
47    pub alias: String,
48}
49
50impl ProviderRef {
51    pub fn new(name: String, alias: String) -> Self {
52        Self { name, alias }
53    }
54}
55
56#[derive(Error, Debug)]
57pub enum Error {
58    #[error(transparent)]
59    Io(#[from] io::Error),
60    #[error(transparent)]
61    Other(#[from] Box<dyn error::Error + Sync + Send>),
62    #[error(transparent)]
63    Parse(#[from] hcl::Error),
64    #[error("unexpected expression for attribute {attribute_key:?} in {file_name}: {expr:?}")]
65    UnexpectedExpr {
66        attribute_key: String,
67        expr: hcl::Expression,
68        file_name: PathBuf,
69    },
70}
71
72/// Reads the directory at the given path and attempts to interpret it as a Terraform module.
73///
74/// # Arguments
75///
76/// * `path` - Path to the directory containing the Terraform configuration
77/// * `strict` - Whether to immediately return an error if a file in the directory cannot be parsed
78pub fn load_module(path: &Path, strict: bool) -> Result<Module> {
79    let mut module = Module::new(path.to_path_buf());
80
81    let files = get_files_in_dir(path)?;
82
83    for file_name in files {
84        match fs::read_to_string(&file_name) {
85            Ok(file_contents) => {
86                let file = match hcl::parse(&file_contents) {
87                    Ok(body) => body,
88                    Err(e) => match e {
89                        hcl::Error::Parse(e) => {
90                            if strict {
91                                return Err(Error::Parse(hcl::Error::Parse(e)));
92                            } else {
93                                continue;
94                            }
95                        }
96                        _ => return Err(Error::Other(Box::new(e))),
97                    },
98                };
99
100                load_module_from_file(&file_name, file, &mut module)?;
101            }
102            Err(e) => {
103                if strict {
104                    return Err(Error::Io(e));
105                }
106            }
107        }
108    }
109
110    Ok(module)
111}
112
113/// Reads given file, interprets it and stores in given [`Module`][Module]
114pub fn load_module_from_file(
115    current_file: &Path,
116    file: hcl::Body,
117    module: &mut Module,
118) -> Result<()> {
119    for block in file.blocks() {
120        let body = block.body();
121
122        #[allow(clippy::all)]
123        match block.identifier() {
124            "terraform" => handle_terraform_block(current_file, body, module)?,
125            _ => (),
126        }
127    }
128
129    Ok(())
130}
131
132fn handle_terraform_block(
133    current_file: &Path,
134    body: &hcl::Body,
135    module: &mut Module,
136) -> Result<()> {
137    body.attributes()
138        .filter(|attr| attr.key() == "required_version")
139        .for_each(|attr| {
140            module
141                .required_core
142                .push(attr.expr().to_string().replace('"', ""))
143        });
144
145    for inner_block in body.blocks() {
146        #[allow(clippy::all)]
147        match inner_block.identifier() {
148            "required_providers" => {
149                handle_required_providers_block(current_file, inner_block.body(), module)?
150            }
151            _ => (),
152        }
153    }
154
155    Ok(())
156}
157
158fn handle_required_providers_block(
159    current_file: &Path,
160    required_providers: &hcl::Body,
161    module: &mut Module,
162) -> Result<()> {
163    for provider in required_providers.attributes() {
164        let provider_name = provider.key().to_string();
165        let mut provider_req = ProviderRequirement::default();
166
167        match provider.expr() {
168            hcl::Expression::Object(attr) => {
169                if let Some(source) = attr.get(&ObjectKey::Identifier("source".into())) {
170                    provider_req.source = source.to_string().replace('"', "");
171                }
172                if let Some(version) = attr.get(&ObjectKey::Identifier("version".into())) {
173                    provider_req
174                        .version_constraints
175                        .push(version.to_string().replace('"', ""));
176                }
177            }
178            _ => {
179                return Err(Error::UnexpectedExpr {
180                    attribute_key: provider_name,
181                    expr: provider.expr().clone(),
182                    file_name: current_file.to_path_buf(),
183                })
184            }
185        };
186
187        module
188            .required_providers
189            .insert(provider_name, provider_req);
190    }
191
192    Ok(())
193}
194
195fn get_files_in_dir(path: &Path) -> Result<Vec<PathBuf>> {
196    let mut primary = vec![];
197    let mut overrides = vec![];
198
199    for entry in std::fs::read_dir(path)? {
200        let file = entry?.path();
201        if file.is_dir() {
202            continue;
203        }
204
205        match file.extension() {
206            Some(ext) => {
207                match ext.to_str() {
208                    Some(ext) => {
209                        if ext.starts_with('.')
210                            || ext.starts_with('#')
211                            || ext.ends_with('~')
212                            || ext.ends_with('#')
213                        {
214                            continue;
215                        }
216                    }
217                    None => continue,
218                };
219            }
220            None => continue,
221        };
222
223        let basename = match file.file_stem() {
224            Some(basename) => basename.to_str().unwrap(),
225            None => continue,
226        };
227        let is_override = basename == "override" || basename.ends_with("_override");
228
229        if is_override {
230            overrides.push(file);
231        } else {
232            primary.push(file);
233        }
234    }
235
236    primary.append(&mut overrides);
237    Ok(primary)
238}