anda_config/
config.rs

1use serde::{Deserialize, Serialize};
2use std::collections::{BTreeMap, HashMap};
3use std::fs;
4use std::io::ErrorKind;
5use std::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#[instrument]
251pub fn load_from_file(path: &PathBuf) -> Result<Manifest, ProjectError> {
252    debug!("Reading hcl file: {path:?}");
253    let file = fs::read_to_string(path).map_err(|e| match e.kind() {
254        ErrorKind::NotFound => ProjectError::NoManifest,
255        _ => ProjectError::InvalidManifest(e.to_string()),
256    })?;
257
258    debug!("Loading config from {path:?}");
259    let mut config = load_from_string(&file)?;
260
261    // recursively merge configs
262
263    // get parent path of config file
264    let parent = if path.parent().unwrap().as_os_str().is_empty() {
265        PathBuf::from(".")
266    } else {
267        path.parent().unwrap().to_path_buf()
268    };
269
270    let walk = ignore::Walk::new(parent);
271
272    let path = path.canonicalize().expect("Invalid path");
273
274    for entry in walk {
275        trace!("Found {entry:?}");
276        let entry = entry.unwrap();
277
278        // assume entry.path() is canonicalised
279        if entry.path() == path {
280            continue;
281        }
282
283        if entry.file_type().unwrap().is_file() && entry.path().file_name().unwrap() == "anda.hcl" {
284            debug!("Loading: {entry:?}");
285            let readfile = fs::read_to_string(entry.path())
286                .map_err(|e| ProjectError::InvalidManifest(e.to_string()))?;
287
288            let en = entry.path().parent().unwrap();
289
290            let nested_config = prefix_config(
291                load_from_string(&readfile)?,
292                &en.strip_prefix("./").unwrap_or(en).display().to_string(),
293            );
294            // merge the btreemap
295            config.project.extend(nested_config.project);
296        }
297    }
298
299    trace!("Loaded config: {config:#?}");
300    generate_alias(&mut config);
301
302    check_config(config)
303}
304
305#[must_use]
306pub fn prefix_config(mut config: Manifest, prefix: &str) -> Manifest {
307    let mut new_config = config.clone();
308
309    for (project_name, project) in &mut config.project {
310        // set project name to prefix
311        let new_project_name = format!("{prefix}/{project_name}");
312        // modify project data
313        let mut new_project = std::mem::take(project);
314
315        macro_rules! default {
316            ($o:expr, $attr:ident, $d:expr) => {
317                if let Some($attr) = &mut $o.$attr {
318                    if $attr.as_os_str().is_empty() {
319                        *$attr = $d.into();
320                    }
321                    *$attr = PathBuf::from(format!("{prefix}/{}", $attr.display()));
322                } else {
323                    let p = PathBuf::from(format!("{prefix}/{}", $d));
324                    if p.exists() {
325                        $o.$attr = Some(p);
326                    }
327                }
328            };
329        } // default!(obj, attr, default_value);
330        if let Some(rpm) = &mut new_project.rpm {
331            rpm.spec = PathBuf::from(format!("{prefix}/{}", rpm.spec.display()));
332            default!(rpm, pre_script, "rpm_pre.rhai");
333            default!(rpm, post_script, "rpm_post.rhai");
334            default!(rpm, sources, ".");
335        }
336        default!(new_project, update, "update.rhai");
337        default!(new_project, pre_script, "pre.rhai");
338        default!(new_project, post_script, "post.rhai");
339
340        if let Some(scripts) = &mut new_project.scripts {
341            for scr in scripts {
342                *scr = PathBuf::from(format!("{prefix}/{}", scr.display()));
343            }
344        }
345
346        new_config.project.remove(project_name);
347        new_config.project.insert(new_project_name, new_project);
348    }
349    generate_alias(&mut new_config);
350    new_config
351}
352
353pub fn generate_alias(config: &mut Manifest) {
354    fn append_vec(vec: &mut Option<Vec<String>>, value: String) {
355        if let Some(vec) = vec {
356            if vec.contains(&value) {
357                return;
358            }
359
360            vec.push(value);
361        } else {
362            *vec = Some(vec![value]);
363        }
364    }
365
366    for (name, project) in &mut config.project {
367        #[allow(clippy::assigning_clones)]
368        if config.config.strip_prefix.is_some() || config.config.strip_suffix.is_some() {
369            let mut new_name = name.clone();
370            if let Some(strip_prefix) = &config.config.strip_prefix {
371                new_name = new_name.strip_prefix(strip_prefix).unwrap_or(&new_name).to_owned();
372            }
373            if let Some(strip_suffix) = &config.config.strip_suffix {
374                new_name = new_name.strip_suffix(strip_suffix).unwrap_or(&new_name).to_owned();
375            }
376
377            if name != &new_name {
378                append_vec(&mut project.alias, new_name);
379            }
380        }
381    }
382}
383
384#[instrument]
385pub fn load_from_string(config: &str) -> Result<Manifest, ProjectError> {
386    trace!(config, "Dump config");
387    let mut config: Manifest = hcl::eval::from_str(config, &crate::context::hcl_context())?;
388
389    generate_alias(&mut config);
390
391    check_config(config)
392}
393
394/// Lints and checks the config for errors.
395///
396/// # Errors
397/// - nothing. This function literally does nothing. For now.
398pub const fn check_config(config: Manifest) -> Result<Manifest, ProjectError> {
399    // do nothing for now
400    Ok(config)
401}
402
403#[allow(clippy::indexing_slicing)]
404#[cfg(test)]
405mod test_parser {
406    use super::*;
407
408    #[test]
409    fn test_parse() {
410        // set env var
411        std::env::set_var("RUST_LOG", "trace");
412        env_logger::init();
413        let config = r#"
414        hello = "world"
415        project "anda" {
416            pre_script {
417                commands = [
418                    "echo '${env.RUST_LOG}'",
419                ]
420            }
421            labels {
422                nightly = 1
423            }
424        }
425        "#;
426
427        let body = hcl::parse(config).unwrap();
428
429        print!("{body:#?}");
430
431        let config = load_from_string(config).unwrap();
432
433        println!("{config:#?}");
434
435        assert_eq!(config.project["anda"].labels.get("nightly"), Some(&"1".to_owned()));
436    }
437
438    #[test]
439    fn test_map() {
440        let m = [("foo".to_owned(), "bar".to_owned())].into();
441
442        assert_eq!(parse_labels(std::iter::once("foo=bar")), Some(m));
443
444        let multieq = [("foo".to_owned(), "bar=baz".to_owned())].into();
445
446        assert_eq!(parse_labels(std::iter::once("foo=bar=baz")), Some(multieq));
447
448        let multi =
449            [("foo".to_owned(), "bar".to_owned()), ("baz".to_owned(), "qux".to_owned())].into();
450
451        assert_eq!(parse_labels(std::iter::once("foo=bar,baz=qux")), Some(multi));
452    }
453}