1use std::{
4 collections::HashMap,
5 path::{Path, PathBuf},
6};
7
8use anyhow::{Context, Result};
9use semver::VersionReq;
10use serde::{Deserialize, Serialize};
11use tokio::io::AsyncWriteExt;
12
13pub const CONFIG_FILE_NAME: &str = "wkg.toml";
15
16#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
19#[serde(deny_unknown_fields)]
20pub struct Config {
21 #[serde(default, skip_serializing_if = "Option::is_none")]
23 pub overrides: Option<HashMap<String, Override>>,
24 #[serde(default, skip_serializing_if = "Option::is_none")]
27 pub metadata: Option<Metadata>,
28}
29
30impl Config {
31 pub async fn load_from_path(path: impl AsRef<Path>) -> Result<Config> {
33 let contents = tokio::fs::read_to_string(path)
34 .await
35 .context("unable to load config from file")?;
36 let config: Config = toml::from_str(&contents).context("unable to parse config file")?;
37 Ok(config)
38 }
39
40 pub async fn load() -> Result<Config> {
45 let config_path = PathBuf::from(CONFIG_FILE_NAME);
46 if !tokio::fs::try_exists(&config_path).await? {
47 return Ok(Config::default());
48 }
49 Self::load_from_path(config_path).await
50 }
51
52 pub async fn write(&self, path: impl AsRef<Path>) -> Result<()> {
54 let contents = toml::to_string_pretty(self)?;
55 let mut file = tokio::fs::File::create(path).await?;
56 file.write_all(contents.as_bytes())
57 .await
58 .context("unable to write config to path")
59 }
60}
61
62#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
63#[serde(deny_unknown_fields)]
64pub struct Override {
65 #[serde(default, skip_serializing_if = "Option::is_none")]
68 pub path: Option<PathBuf>,
69 #[serde(default, skip_serializing_if = "Option::is_none")]
72 pub version: Option<VersionReq>,
73}
74
75#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
76#[serde(deny_unknown_fields)]
77pub struct Metadata {
78 #[serde(default, skip_serializing_if = "Option::is_none", alias = "author")]
81 pub authors: Option<String>,
82 #[serde(default, skip_serializing_if = "Option::is_none")]
84 pub description: Option<String>,
85 #[serde(default, skip_serializing_if = "Option::is_none", alias = "license")]
87 pub licenses: Option<String>,
88 #[serde(default, skip_serializing_if = "Option::is_none", alias = "repository")]
90 pub source: Option<String>,
91 #[serde(default, skip_serializing_if = "Option::is_none")]
93 pub homepage: Option<String>,
94 #[serde(default, skip_serializing_if = "Option::is_none")]
96 pub revision: Option<String>,
97}
98
99#[cfg(test)]
100mod tests {
101 use super::*;
102
103 #[tokio::test]
104 async fn test_roundtrip() {
105 let tempdir = tempfile::tempdir().unwrap();
106 let config_path = tempdir.path().join(CONFIG_FILE_NAME);
107 let config = Config {
108 overrides: Some(HashMap::from([(
109 "foo:bar".to_string(),
110 Override {
111 path: Some(PathBuf::from("bar")),
112 version: Some(VersionReq::parse("1.0.0").unwrap()),
113 },
114 )])),
115 metadata: Some(Metadata {
116 authors: Some("Foo Bar".to_string()),
117 description: Some("Foobar baz".to_string()),
118 licenses: Some("FBB".to_string()),
119 source: Some("https://gitfoo/bar".to_string()),
120 homepage: Some("https://foo.bar".to_string()),
121 revision: Some("f00ba4".to_string()),
122 }),
123 };
124
125 config
126 .write(&config_path)
127 .await
128 .expect("unable to write config");
129 let loaded_config = Config::load_from_path(config_path)
130 .await
131 .expect("unable to load config");
132 assert_eq!(
133 config, loaded_config,
134 "config loaded from file does not match original config"
135 );
136 }
137}