aiken_project/
config.rs

1use crate::{
2    Error, error::TomlLoadingContext, github::repo::LatestRelease, package_name::PackageName, paths,
3};
4use aiken_lang::{
5    ast::{Annotation, ByteArrayFormatPreference, ModuleConstant, Span, UntypedDefinition},
6    expr::UntypedExpr,
7    parser::token::Base,
8};
9pub use aiken_lang::{plutus_version::PlutusVersion, version::compiler_version};
10use glob::glob;
11use miette::NamedSource;
12use semver::Version;
13use serde::{
14    Deserialize, Serialize, de,
15    ser::{self, SerializeSeq, SerializeStruct},
16};
17use std::{
18    collections::BTreeMap,
19    fmt::Display,
20    fs, io,
21    path::{Path, PathBuf},
22};
23
24#[derive(Deserialize, Serialize, Clone)]
25pub struct ProjectConfig {
26    pub name: PackageName,
27
28    pub version: String,
29
30    #[serde(
31        deserialize_with = "deserialize_version",
32        serialize_with = "serialize_version",
33        default = "default_version"
34    )]
35    pub compiler: Version,
36
37    #[serde(default, deserialize_with = "validate_v3_only")]
38    pub plutus: PlutusVersion,
39
40    pub license: Option<String>,
41
42    #[serde(default)]
43    pub description: String,
44
45    pub repository: Option<Repository>,
46
47    #[serde(default)]
48    pub dependencies: Vec<Dependency>,
49
50    #[serde(default)]
51    pub config: BTreeMap<String, BTreeMap<String, SimpleExpr>>,
52}
53
54#[derive(Deserialize, Serialize, Clone)]
55struct RawWorkspaceConfig {
56    members: Vec<String>,
57}
58
59impl RawWorkspaceConfig {
60    pub fn expand_members(self, root: &Path) -> Vec<PathBuf> {
61        let mut result = Vec::new();
62
63        for member in self.members {
64            let pattern = root.join(member);
65
66            let glob_result: Vec<_> = pattern
67                .to_str()
68                .and_then(|s| glob(s).ok())
69                .map_or(Vec::new(), |g| g.filter_map(Result::ok).collect());
70
71            if glob_result.is_empty() {
72                // No matches (or glob failed), treat as literal path
73                result.push(pattern);
74            } else {
75                // Glob worked, add all matches
76                result.extend(glob_result);
77            }
78        }
79
80        result
81    }
82}
83
84pub struct WorkspaceConfig {
85    pub members: Vec<PathBuf>,
86}
87
88impl WorkspaceConfig {
89    #[allow(clippy::result_large_err)]
90    pub fn load(dir: &Path) -> Result<WorkspaceConfig, Error> {
91        let config_path = dir.join(paths::project_config());
92        let raw_config = fs::read_to_string(&config_path).map_err(|_| Error::MissingManifest {
93            path: dir.to_path_buf(),
94        })?;
95
96        let raw: RawWorkspaceConfig = toml::from_str(&raw_config).map_err(|e| {
97            from_toml_de_error(e, config_path, raw_config, TomlLoadingContext::Workspace)
98        })?;
99
100        let members = raw.expand_members(dir);
101
102        Ok(WorkspaceConfig { members })
103    }
104}
105
106#[derive(Clone, Debug)]
107pub enum SimpleExpr {
108    Int(i64),
109    Bool(bool),
110    ByteArray(Vec<u8>, ByteArrayFormatPreference),
111    List(Vec<SimpleExpr>),
112}
113
114impl SimpleExpr {
115    pub fn as_untyped_expr(&self, annotation: &Annotation) -> UntypedExpr {
116        match self {
117            SimpleExpr::Bool(b) => UntypedExpr::Var {
118                location: Span::empty(),
119                name: if *b { "True" } else { "False" }.to_string(),
120            },
121            SimpleExpr::Int(i) => UntypedExpr::UInt {
122                location: Span::empty(),
123                value: format!("{i}"),
124                base: Base::Decimal {
125                    numeric_underscore: false,
126                },
127            },
128            SimpleExpr::ByteArray(bs, preferred_format) => UntypedExpr::ByteArray {
129                location: Span::empty(),
130                bytes: bs.iter().map(|b| (*b, Span::empty())).collect(),
131                preferred_format: *preferred_format,
132            },
133            SimpleExpr::List(es) => match annotation {
134                Annotation::Tuple { elems, .. } => UntypedExpr::Tuple {
135                    location: Span::empty(),
136                    elems: es
137                        .iter()
138                        .zip(elems)
139                        .map(|(e, ann)| e.as_untyped_expr(ann))
140                        .collect(),
141                },
142                Annotation::Constructor {
143                    module,
144                    name,
145                    arguments,
146                    ..
147                } if name == "List" && module.is_none() => UntypedExpr::List {
148                    location: Span::empty(),
149                    elements: es
150                        .iter()
151                        .map(|e| e.as_untyped_expr(arguments.first().unwrap()))
152                        .collect(),
153                    tail: None,
154                },
155                _ => unreachable!(
156                    "unexpected annotation for simple list expression: {annotation:#?}"
157                ),
158            },
159        }
160    }
161
162    pub fn as_annotation(&self) -> Annotation {
163        let location = Span::empty();
164        match self {
165            SimpleExpr::Bool(..) => Annotation::boolean(location),
166            SimpleExpr::Int(_) => Annotation::int(location),
167            SimpleExpr::ByteArray(_, _) => Annotation::bytearray(location),
168            SimpleExpr::List(elems) => {
169                let elems = elems.iter().map(|e| e.as_annotation()).collect::<Vec<_>>();
170
171                let (is_uniform, inner) =
172                    elems
173                        .iter()
174                        .fold((true, None), |(matches, ann), a| match ann {
175                            None => (matches, Some(a)),
176                            Some(b) => (matches && a == b, ann),
177                        });
178
179                if is_uniform {
180                    Annotation::list(
181                        inner.cloned().unwrap_or_else(|| Annotation::data(location)),
182                        location,
183                    )
184                } else {
185                    Annotation::tuple(elems, location)
186                }
187            }
188        }
189    }
190
191    pub fn as_definition(&self, identifier: &str) -> UntypedDefinition {
192        let annotation = self.as_annotation();
193        let value = self.as_untyped_expr(&annotation);
194        UntypedDefinition::ModuleConstant(ModuleConstant {
195            location: Span::empty(),
196            doc: None,
197            public: true,
198            name: identifier.to_string(),
199            annotation: Some(annotation),
200            value,
201        })
202    }
203}
204
205impl Serialize for SimpleExpr {
206    fn serialize<S: ser::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
207        match self {
208            SimpleExpr::Bool(b) => serializer.serialize_bool(*b),
209            SimpleExpr::Int(i) => serializer.serialize_i64(*i),
210            SimpleExpr::ByteArray(bs, preferred_format) => match preferred_format {
211                ByteArrayFormatPreference::Utf8String => {
212                    serializer.serialize_str(String::from_utf8(bs.to_vec()).unwrap().as_str())
213                }
214                ByteArrayFormatPreference::ArrayOfBytes(..)
215                | ByteArrayFormatPreference::HexadecimalString => {
216                    let mut s = serializer.serialize_struct("ByteArray", 2)?;
217                    s.serialize_field("bytes", &hex::encode(bs))?;
218                    s.serialize_field("encoding", "base16")?;
219                    s.end()
220                }
221            },
222            SimpleExpr::List(es) => {
223                let mut seq = serializer.serialize_seq(Some(es.len()))?;
224                for e in es {
225                    seq.serialize_element(e)?;
226                }
227                seq.end()
228            }
229        }
230    }
231}
232
233impl<'a> Deserialize<'a> for SimpleExpr {
234    fn deserialize<D: de::Deserializer<'a>>(deserializer: D) -> Result<Self, D::Error> {
235        struct SimpleExprVisitor;
236
237        #[derive(Deserialize)]
238        enum Encoding {
239            #[serde(rename(deserialize = "utf8"))]
240            Utf8,
241            #[serde(rename(deserialize = "utf-8"))]
242            Utf8Bis,
243            #[serde(rename(deserialize = "hex"))]
244            Hex,
245            #[serde(rename(deserialize = "base16"))]
246            Base16,
247        }
248
249        #[derive(Deserialize)]
250        struct Bytes {
251            bytes: String,
252            encoding: Encoding,
253        }
254
255        impl<'a> de::Visitor<'a> for SimpleExprVisitor {
256            type Value = SimpleExpr;
257
258            fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
259                formatter.write_str("Int | Bool | ByteArray | List<any_of_those>")
260            }
261
262            fn visit_bool<E>(self, b: bool) -> Result<Self::Value, E> {
263                Ok(SimpleExpr::Bool(b))
264            }
265
266            fn visit_i64<E>(self, i: i64) -> Result<Self::Value, E> {
267                Ok(SimpleExpr::Int(i))
268            }
269
270            fn visit_str<E>(self, s: &str) -> Result<Self::Value, E> {
271                Ok(SimpleExpr::ByteArray(
272                    s.as_bytes().to_vec(),
273                    ByteArrayFormatPreference::Utf8String,
274                ))
275            }
276
277            fn visit_map<V>(self, map: V) -> Result<Self::Value, V::Error>
278            where
279                V: de::MapAccess<'a>,
280            {
281                let Bytes { bytes, encoding } =
282                    Bytes::deserialize(de::value::MapAccessDeserializer::new(map))?;
283
284                match encoding {
285                    Encoding::Hex | Encoding::Base16 => match hex::decode(&bytes) {
286                        Err(e) => Err(de::Error::custom(format!("invalid base16 string: {e:?}"))),
287                        Ok(bytes) => Ok(SimpleExpr::ByteArray(
288                            bytes,
289                            ByteArrayFormatPreference::HexadecimalString,
290                        )),
291                    },
292                    Encoding::Utf8 | Encoding::Utf8Bis => Ok(SimpleExpr::ByteArray(
293                        bytes.as_bytes().to_vec(),
294                        ByteArrayFormatPreference::Utf8String,
295                    )),
296                }
297            }
298
299            fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
300            where
301                A: de::SeqAccess<'a>,
302            {
303                let mut es = Vec::new();
304
305                while let Some(e) = seq.next_element()? {
306                    es.push(e);
307                }
308
309                Ok(SimpleExpr::List(es))
310            }
311        }
312
313        deserializer.deserialize_any(SimpleExprVisitor)
314    }
315}
316
317fn deserialize_version<'de, D>(deserializer: D) -> Result<Version, D::Error>
318where
319    D: serde::Deserializer<'de>,
320{
321    let buf = String::deserialize(deserializer)?.replace('v', "");
322
323    Version::parse(&buf).map_err(serde::de::Error::custom)
324}
325
326fn serialize_version<S>(version: &Version, serializer: S) -> Result<S::Ok, S::Error>
327where
328    S: serde::Serializer,
329{
330    let version = format!("v{version}");
331
332    serializer.serialize_str(&version)
333}
334
335fn default_version() -> Version {
336    Version::parse(built_info::PKG_VERSION).unwrap()
337}
338
339#[derive(Deserialize, Serialize, Clone, Debug)]
340pub struct Repository {
341    pub user: String,
342    pub project: String,
343    pub platform: Platform,
344}
345
346#[derive(Deserialize, Serialize, PartialEq, Eq, Clone, Copy, Debug)]
347#[serde(rename_all = "lowercase")]
348pub enum Platform {
349    Github,
350    Gitlab,
351    Bitbucket,
352}
353
354#[derive(Deserialize, Serialize, PartialEq, Eq, Clone, Debug)]
355pub struct Dependency {
356    pub name: PackageName,
357    pub version: String,
358    pub source: Platform,
359}
360
361impl Display for Platform {
362    fn fmt(&self, f: &mut ::std::fmt::Formatter) -> ::std::result::Result<(), ::std::fmt::Error> {
363        match *self {
364            Platform::Github => f.write_str("github"),
365            Platform::Gitlab => f.write_str("gitlab"),
366            Platform::Bitbucket => f.write_str("bitbucket"),
367        }
368    }
369}
370
371impl ProjectConfig {
372    pub fn default(name: &PackageName) -> Self {
373        ProjectConfig {
374            name: name.clone(),
375            version: "0.0.0".to_string(),
376            compiler: default_version(),
377            plutus: PlutusVersion::default(),
378            license: Some("Apache-2.0".to_string()),
379            description: format!("Aiken contracts for project '{name}'"),
380            repository: Some(Repository {
381                user: name.owner.clone(),
382                project: name.repo.clone(),
383                platform: Platform::Github,
384            }),
385            dependencies: vec![Dependency {
386                name: PackageName {
387                    owner: "aiken-lang".to_string(),
388                    repo: "stdlib".to_string(),
389                },
390                version: match LatestRelease::of("aiken-lang/stdlib") {
391                    Ok(stdlib) => stdlib.tag_name,
392                    _ => "1.5.0".to_string(),
393                },
394                source: Platform::Github,
395            }],
396            config: BTreeMap::new(),
397        }
398    }
399
400    pub fn save(&self, dir: &Path) -> Result<(), io::Error> {
401        let aiken_toml_path = dir.join(paths::project_config());
402        let aiken_toml = toml::to_string_pretty(self).unwrap();
403        fs::write(aiken_toml_path, aiken_toml)
404    }
405
406    #[allow(clippy::result_large_err)]
407    pub fn load(dir: &Path) -> Result<ProjectConfig, Error> {
408        let config_path = dir.join(paths::project_config());
409        let raw_config = fs::read_to_string(&config_path).map_err(|_| Error::MissingManifest {
410            path: dir.to_path_buf(),
411        })?;
412
413        let result: Self = toml::from_str(&raw_config).map_err(|e| {
414            from_toml_de_error(e, config_path, raw_config, TomlLoadingContext::Project)
415        })?;
416
417        Ok(result)
418    }
419
420    pub fn insert(mut self, dependency: &Dependency, and_replace: bool) -> Option<Self> {
421        for existing in self.dependencies.iter_mut() {
422            if existing.name == dependency.name {
423                return if and_replace {
424                    existing.version.clone_from(&dependency.version);
425                    Some(self)
426                } else {
427                    None
428                };
429            }
430        }
431        self.dependencies.push(dependency.clone());
432        Some(self)
433    }
434}
435
436fn validate_v3_only<'de, D>(deserializer: D) -> Result<PlutusVersion, D::Error>
437where
438    D: serde::Deserializer<'de>,
439{
440    let version = PlutusVersion::deserialize(deserializer)?;
441
442    match version {
443        PlutusVersion::V3 => Ok(version),
444        _ => Err(serde::de::Error::custom("Aiken only supports Plutus V3")),
445    }
446}
447
448fn from_toml_de_error(
449    e: toml::de::Error,
450    config_path: PathBuf,
451    raw_config: String,
452    ctx: TomlLoadingContext,
453) -> Error {
454    Error::TomlLoading {
455        ctx,
456        path: config_path.clone(),
457        src: raw_config.clone(),
458        named: NamedSource::new(config_path.display().to_string(), raw_config).into(),
459        // this isn't actually a legit way to get the span
460        location: e.span().map(|range| Span {
461            start: range.start,
462            end: range.end,
463        }),
464        help: e.message().to_string(),
465    }
466}
467
468mod built_info {
469    include!(concat!(env!("OUT_DIR"), "/built.rs"));
470}
471
472pub fn compiler_info() -> String {
473    format!(
474        r#"
475Operating System: {}
476Architecture:     {}
477Version:          {}"#,
478        built_info::CFG_OS,
479        built_info::CFG_TARGET_ARCH,
480        compiler_version(true),
481    )
482}
483
484#[cfg(test)]
485mod tests {
486    use super::*;
487    use proptest::prelude::*;
488
489    #[allow(clippy::arc_with_non_send_sync)]
490    fn arbitrary_simple_expr() -> impl Strategy<Value = SimpleExpr> {
491        let leaf = prop_oneof![
492            (any::<i64>)().prop_map(SimpleExpr::Int),
493            (any::<bool>)().prop_map(SimpleExpr::Bool),
494            "[a-z0-9]*".prop_map(|bytes| SimpleExpr::ByteArray(
495                bytes.as_bytes().to_vec(),
496                ByteArrayFormatPreference::Utf8String
497            )),
498            "([0-9a-f][0-9a-f])*".prop_map(|bytes| SimpleExpr::ByteArray(
499                bytes.as_bytes().to_vec(),
500                ByteArrayFormatPreference::HexadecimalString
501            ))
502        ];
503
504        leaf.prop_recursive(3, 8, 3, |inner| {
505            prop_oneof![
506                inner.clone(),
507                prop::collection::vec(inner.clone(), 0..3).prop_map(SimpleExpr::List)
508            ]
509        })
510    }
511
512    #[derive(Deserialize, Serialize)]
513    struct TestConfig {
514        expr: SimpleExpr,
515    }
516
517    proptest! {
518        #[test]
519        fn round_trip_simple_expr(expr in arbitrary_simple_expr()) {
520            let pretty = toml::to_string_pretty(&TestConfig { expr });
521            assert!(
522                matches!(
523                    pretty.as_ref().map(|s| toml::from_str::<TestConfig>(s.as_str())),
524                    Ok(Ok(..)),
525                ),
526                "\ncounterexample: {}\n",
527                pretty.unwrap_or_default(),
528            )
529
530        }
531    }
532}