Skip to main content

anda_config/
config.rs

1use serde::{Deserialize, Serialize};
2use std::collections::{BTreeMap, HashMap};
3use std::fs;
4use std::io::ErrorKind;
5use std::path::{Path, PathBuf};
6use tracing::{debug, instrument, trace};
7
8use crate::error::ProjectError;
9
10#[derive(Deserialize, Serialize, Debug, Clone)]
11#[serde_with::skip_serializing_none]
12pub struct ProjectData {
13    pub manifest: HashMap<String, String>,
14}
15
16#[derive(Deserialize, Serialize, Debug, Clone)]
17#[serde_with::skip_serializing_none]
18pub struct Manifest {
19    pub project: BTreeMap<String, Project>,
20    #[serde(default)]
21    pub config: Config,
22}
23
24#[derive(Deserialize, Serialize, Debug, Clone, Default)]
25#[serde_with::skip_serializing_none]
26pub struct Config {
27    pub mock_config: Option<String>,
28    pub strip_prefix: Option<String>,
29    pub strip_suffix: Option<String>,
30    pub project_regex: Option<String>,
31}
32
33impl Manifest {
34    #[must_use]
35    pub fn find_key_for_value(&self, value: &Project) -> Option<&String> {
36        self.project.iter().find_map(|(key, val)| (val == value).then_some(key))
37    }
38
39    #[must_use]
40    pub fn get_project(&self, key: &str) -> Option<&Project> {
41        self.project.get(key).map_or_else(
42            || {
43                self.project.iter().find_map(|(_k, v)| {
44                    let alias = v.alias.as_ref()?;
45                    alias.contains(&key.to_owned()).then_some(v)
46                })
47            },
48            Some,
49        )
50    }
51}
52
53#[derive(Deserialize, PartialEq, Eq, Serialize, Debug, Clone, Default)]
54#[serde_with::skip_serializing_none]
55pub struct Project {
56    pub rpm: Option<RpmBuild>,
57    pub podman: Option<Docker>,
58    pub docker: Option<Docker>,
59    pub flatpak: Option<Flatpak>,
60    pub pre_script: Option<PathBuf>,
61    pub post_script: Option<PathBuf>,
62    pub env: Option<BTreeMap<String, String>>,
63    pub alias: Option<Vec<String>>,
64    pub scripts: Option<Vec<PathBuf>>,
65    #[serde(default)]
66    #[serde(deserialize_with = "btree_wild_string")]
67    pub labels: BTreeMap<String, String>,
68    pub update: Option<PathBuf>,
69    pub arches: Option<Vec<String>>,
70}
71
72/// Deserialize the value of the BTreeMap into a String even if they are some other types.
73///
74/// # Errors
75/// This function itself does not raise any errors unless the given value has the wrong type.
76/// However, it inherits errors from `serde::Deserializer`.
77fn btree_wild_string<'de, D>(deserializer: D) -> Result<BTreeMap<String, String>, D::Error>
78where
79    D: serde::Deserializer<'de>,
80{
81    struct WildString;
82
83    impl serde::de::Visitor<'_> for WildString {
84        type Value = String;
85
86        fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
87            formatter.write_str("string, integer, bool or unit")
88        }
89
90        fn visit_string<E>(self, v: String) -> Result<Self::Value, E>
91        where
92            E: serde::de::Error,
93        {
94            Ok(v)
95        }
96
97        fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
98        where
99            E: serde::de::Error,
100        {
101            Ok(v.to_owned())
102        }
103
104        fn visit_i64<E>(self, v: i64) -> Result<Self::Value, E>
105        where
106            E: serde::de::Error,
107        {
108            Ok(format!("{v}"))
109        }
110
111        fn visit_i128<E>(self, v: i128) -> Result<Self::Value, E>
112        where
113            E: serde::de::Error,
114        {
115            Ok(format!("{v}"))
116        }
117
118        fn visit_u64<E>(self, v: u64) -> Result<Self::Value, E>
119        where
120            E: serde::de::Error,
121        {
122            Ok(format!("{v}"))
123        }
124
125        fn visit_u128<E>(self, v: u128) -> Result<Self::Value, E>
126        where
127            E: serde::de::Error,
128        {
129            Ok(format!("{v}"))
130        }
131
132        fn visit_unit<E>(self) -> Result<Self::Value, E>
133        where
134            E: serde::de::Error,
135        {
136            Ok(String::new())
137        }
138
139        fn visit_bool<E>(self, v: bool) -> Result<Self::Value, E>
140        where
141            E: serde::de::Error,
142        {
143            Ok(format!("{v}"))
144        }
145    }
146
147    struct RealWildString(String);
148
149    impl<'de> Deserialize<'de> for RealWildString {
150        fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
151        where
152            D: serde::Deserializer<'de>,
153        {
154            deserializer.deserialize_any(WildString).map(Self)
155        }
156    }
157
158    struct BTreeWildStringVisitor;
159
160    impl<'de> serde::de::Visitor<'de> for BTreeWildStringVisitor {
161        type Value = BTreeMap<String, String>;
162
163        fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
164            formatter.write_str("map (key: string, value: wild string)")
165        }
166
167        fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
168        where
169            A: serde::de::MapAccess<'de>,
170        {
171            let mut res = Self::Value::new();
172            while let Some((k, v)) = map.next_entry::<String, RealWildString>()? {
173                res.insert(k, v.0);
174            }
175            Ok(res)
176        }
177    }
178
179    deserializer.deserialize_map(BTreeWildStringVisitor)
180}
181
182#[derive(Deserialize, PartialEq, Eq, Serialize, Debug, Clone, Default)]
183#[serde_with::skip_serializing_none]
184pub struct RpmBuild {
185    pub spec: PathBuf,
186    pub sources: Option<PathBuf>,
187    pub package: Option<String>,
188    pub pre_script: Option<PathBuf>,
189    pub post_script: Option<PathBuf>,
190    pub enable_scm: Option<bool>,
191    #[serde(default)]
192    pub extra_repos: Vec<String>,
193    pub scm_opts: Option<BTreeMap<String, String>>,
194    pub config: Option<BTreeMap<String, String>>,
195    pub mock_config: Option<String>,
196    pub plugin_opts: Option<BTreeMap<String, String>>,
197    pub macros: Option<BTreeMap<String, String>>,
198    pub opts: Option<BTreeMap<String, String>>,
199}
200
201#[derive(Deserialize, PartialEq, Eq, Serialize, Debug, Clone, Default)]
202#[serde_with::skip_serializing_none]
203pub struct Docker {
204    pub image: BTreeMap<String, DockerImage>, // tag, file
205}
206
207pub fn parse_kv(input: &str) -> impl Iterator<Item = Option<(String, String)>> + '_ {
208    input
209        .split(',')
210        .filter(|item| !item.trim().is_empty())
211        .map(|item| item.split_once('=').map(|(l, r)| (l.to_owned(), r.to_owned())))
212}
213
214pub fn parse_filters(filters: &[String]) -> Option<Vec<Vec<(String, String)>>> {
215    filters.iter().map(std::ops::Deref::deref).map(crate::parse_kv).map(Iterator::collect).collect()
216}
217
218/// Turn a string into a BTreeMap<String, String>
219pub fn parse_labels<'a, I: Iterator<Item = &'a str>>(labels: I) -> Option<Vec<(String, String)>> {
220    labels.flat_map(parse_kv).collect()
221}
222
223#[derive(Deserialize, PartialEq, Eq, Serialize, Debug, Clone, Default)]
224#[serde_with::skip_serializing_none]
225pub struct DockerImage {
226    pub dockerfile: Option<String>,
227    pub import: Option<PathBuf>,
228    pub tag_latest: Option<bool>,
229    pub context: String,
230    pub version: Option<String>,
231}
232
233#[derive(Deserialize, PartialEq, Eq, Serialize, Debug, Clone)]
234#[serde_with::skip_serializing_none]
235pub struct Flatpak {
236    pub manifest: PathBuf,
237    pub pre_script: Option<PathBuf>,
238    pub post_script: Option<PathBuf>,
239}
240
241/// Converts a [`Manifest`] to `String` (.hcl).
242///
243/// # Errors
244/// - [`hcl::Error`] : Cannot convert to HCL.
245pub fn to_string(config: &Manifest) -> Result<String, hcl::Error> {
246    let config = hcl::to_string(&config)?;
247    Ok(config)
248}
249
250///
251/// # Errors
252/// Returns an error when the manifest or one of its nested configurations cannot be read or parsed.
253#[instrument]
254pub fn load_from_file(path: &PathBuf) -> Result<Manifest, ProjectError> {
255    debug!("Reading hcl file: {path:?}");
256    let file = fs::read_to_string(path).map_err(|e| match e.kind() {
257        ErrorKind::NotFound => ProjectError::NoManifest,
258        _ => ProjectError::InvalidManifest(e.to_string()),
259    })?;
260
261    debug!("Loading config from {path:?}");
262    let mut config = load_from_string(&file)?;
263
264    // recursively merge configs
265
266    // get parent path of config file
267    let parent = if path.parent().unwrap_or_else(|| Path::new(".")).as_os_str().is_empty() {
268        PathBuf::from(".")
269    } else {
270        path.parent().unwrap_or_else(|| Path::new(".")).to_path_buf()
271    };
272
273    let walk = ignore::Walk::new(parent);
274
275    let path = path.canonicalize().map_err(|e| ProjectError::InvalidManifest(e.to_string()))?;
276
277    for entry in walk {
278        trace!("Found {entry:?}");
279        let entry = entry.map_err(|e| ProjectError::InvalidManifest(e.to_string()))?;
280
281        // assume entry.path() is canonicalised
282        if entry.path() == path {
283            continue;
284        }
285
286        if entry.file_type().is_some_and(|kind| kind.is_file())
287            && entry.path().file_name().is_some_and(|name| name == "anda.hcl")
288        {
289            debug!("Loading: {entry:?}");
290            let readfile = fs::read_to_string(entry.path())
291                .map_err(|e| ProjectError::InvalidManifest(e.to_string()))?;
292
293            let en = entry.path().parent().unwrap_or_else(|| Path::new("."));
294
295            let nested_config = prefix_config(
296                load_from_string(&readfile)?,
297                &en.strip_prefix("./").unwrap_or(en).display().to_string(),
298            );
299            // merge the btreemap
300            config.project.extend(nested_config.project);
301        }
302    }
303
304    trace!("Loaded config: {config:#?}");
305    generate_alias(&mut config);
306
307    check_config(config)
308}
309
310#[must_use]
311pub fn prefix_config(mut config: Manifest, prefix: &str) -> Manifest {
312    let mut new_config = config.clone();
313
314    for (project_name, project) in &mut config.project {
315        // set project name to prefix
316        let new_project_name = format!("{prefix}/{project_name}");
317        // modify project data
318        let mut new_project = std::mem::take(project);
319
320        macro_rules! default {
321            ($o:expr, $attr:ident, $d:expr) => {
322                if let Some($attr) = &mut $o.$attr {
323                    if $attr.as_os_str().is_empty() {
324                        *$attr = $d.into();
325                    }
326                    *$attr = PathBuf::from(format!("{prefix}/{}", $attr.display()));
327                } else {
328                    let p = PathBuf::from(format!("{prefix}/{}", $d));
329                    if p.exists() {
330                        $o.$attr = Some(p);
331                    }
332                }
333            };
334        } // default!(obj, attr, default_value);
335        if let Some(rpm) = &mut new_project.rpm {
336            rpm.spec = PathBuf::from(format!("{prefix}/{}", rpm.spec.display()));
337            default!(rpm, pre_script, "rpm_pre.rhai");
338            default!(rpm, post_script, "rpm_post.rhai");
339            default!(rpm, sources, ".");
340        }
341        default!(new_project, update, "update.rhai");
342        default!(new_project, pre_script, "pre.rhai");
343        default!(new_project, post_script, "post.rhai");
344
345        if let Some(scripts) = &mut new_project.scripts {
346            for scr in scripts {
347                *scr = PathBuf::from(format!("{prefix}/{}", scr.display()));
348            }
349        }
350
351        new_config.project.remove(project_name);
352        new_config.project.insert(new_project_name, new_project);
353    }
354    generate_alias(&mut new_config);
355    new_config
356}
357
358pub fn generate_alias(config: &mut Manifest) {
359    fn append_vec(vec: &mut Option<Vec<String>>, value: String) {
360        if let Some(vec) = vec {
361            if vec.contains(&value) {
362                return;
363            }
364
365            vec.push(value);
366        } else {
367            *vec = Some(vec![value]);
368        }
369    }
370
371    for (name, project) in &mut config.project {
372        #[allow(clippy::assigning_clones)]
373        if config.config.strip_prefix.is_some() || config.config.strip_suffix.is_some() {
374            let mut new_name = name.clone();
375            if let Some(strip_prefix) = &config.config.strip_prefix {
376                new_name = new_name.strip_prefix(strip_prefix).unwrap_or(&new_name).to_owned();
377            }
378            if let Some(strip_suffix) = &config.config.strip_suffix {
379                new_name = new_name.strip_suffix(strip_suffix).unwrap_or(&new_name).to_owned();
380            }
381
382            if name != &new_name {
383                append_vec(&mut project.alias, new_name);
384            }
385        }
386    }
387}
388
389///
390/// # Errors
391/// Returns an error when the HCL configuration cannot be parsed.
392#[instrument]
393pub fn load_from_string(config: &str) -> Result<Manifest, ProjectError> {
394    trace!(config, "Dump config");
395    let mut config: Manifest = hcl::eval::from_str(config, &crate::context::hcl_context())?;
396
397    generate_alias(&mut config);
398
399    check_config(config)
400}
401
402/// Lints and checks the config for errors.
403///
404/// # Errors
405/// - nothing. This function literally does nothing. For now.
406pub const fn check_config(config: Manifest) -> Result<Manifest, ProjectError> {
407    // do nothing for now
408    Ok(config)
409}
410
411#[allow(clippy::indexing_slicing)]
412#[cfg(test)]
413mod test_parser {
414    use super::*;
415
416    #[test]
417    fn test_parse() {
418        // set env var
419        std::env::set_var("RUST_LOG", "trace");
420        env_logger::init();
421        let config = r#"
422        hello = "world"
423        project "anda" {
424            pre_script {
425                commands = [
426                    "echo '${env.RUST_LOG}'",
427                ]
428            }
429            labels {
430                nightly = 1
431            }
432        }
433        "#;
434
435        let body = hcl::parse(config).unwrap();
436
437        print!("{body:#?}");
438
439        let config = load_from_string(config).unwrap();
440
441        println!("{config:#?}");
442
443        assert_eq!(config.project["anda"].labels.get("nightly"), Some(&"1".to_owned()));
444    }
445
446    #[test]
447    fn test_map() {
448        let m = [("foo".to_owned(), "bar".to_owned())].into();
449
450        assert_eq!(parse_labels(std::iter::once("foo=bar")), Some(m));
451
452        let multieq = [("foo".to_owned(), "bar=baz".to_owned())].into();
453
454        assert_eq!(parse_labels(std::iter::once("foo=bar=baz")), Some(multieq));
455
456        let multi =
457            [("foo".to_owned(), "bar".to_owned()), ("baz".to_owned(), "qux".to_owned())].into();
458
459        assert_eq!(parse_labels(std::iter::once("foo=bar,baz=qux")), Some(multi));
460    }
461}