1use std::{
2 fs,
3 path::{Path, PathBuf},
4 str::FromStr as _,
5};
6
7use anyhow::{bail, Context};
8use serde::Deserialize;
9
10use crate::{class_name_pattern::ClassNamePattern, path_utils::normalize};
11
12fn default_extensions() -> Vec<String> {
13 vec![".module.css".to_owned(), ".module.scss".to_owned()]
14}
15
16fn default_folders() -> Vec<PathBuf> {
17 vec![PathBuf::from_str("./src/").expect("path is valid")]
18}
19
20fn default_hash_len() -> usize {
21 7
22}
23
24#[derive(Deserialize, Debug, Default, Clone)]
25#[serde(deny_unknown_fields)]
26pub struct PartialConfig {
27 pub output_file: Option<PathBuf>,
28 pub output_dir: Option<PathBuf>,
29 pub extensions: Option<Vec<String>>,
30 pub folders: Option<Vec<PathBuf>>,
31 pub scss_prelude: Option<String>,
32 pub hash_len: Option<usize>,
33 pub class_name_pattern: Option<ClassNamePattern>,
34 pub hash_root_path: Option<PathBuf>,
35 #[serde(default)]
36 pub workspace: bool,
37}
38
39pub struct Config {
45 pub manifest_dir: PathBuf,
46 pub workspace_dir: Option<PathBuf>,
47 pub output_file: Option<PathBuf>,
48 pub output_dir: Option<PathBuf>,
49 pub extensions: Vec<String>,
50 pub folders: Vec<PathBuf>,
51 pub scss_prelude: Option<String>,
52 pub hash_len: usize,
53 pub class_name_pattern: ClassNamePattern,
54 pub hash_root_path: PathBuf,
55}
56
57impl Config {
58 pub fn load(manifest_dir: PathBuf) -> anyhow::Result<Self> {
59 let cargo_toml_contents = fs::read_to_string(manifest_dir.join("Cargo.toml"))
60 .context("Failed to read Cargo.toml")?;
61 let mut cargo_toml: CargoToml = toml::from_str(&cargo_toml_contents)?;
62
63 let config = cargo_toml
64 .package
65 .as_mut()
66 .and_then(|p| p.metadata.as_mut())
67 .and_then(|m| m.stylance.take())
68 .unwrap_or_default();
69
70 let workspace = if config.workspace {
71 let (workspace_root, mut ws_cargo_toml) =
72 find_workspace_root(&manifest_dir, cargo_toml)?;
73 let ws_config = ws_cargo_toml
74 .workspace
75 .as_mut()
76 .and_then(|w| w.metadata.as_mut())
77 .and_then(|m| m.stylance.take())
78 .unwrap_or_default();
79 Some((workspace_root, ws_config))
80 } else {
81 None
82 };
83
84 Self::from_partials(manifest_dir, config, workspace)
85 }
86
87 pub fn from_partials(
88 manifest_dir: PathBuf,
89 config: PartialConfig,
90 workspace: Option<(PathBuf, PartialConfig)>,
91 ) -> anyhow::Result<Self> {
92 let (workspace_dir, ws_config) = match workspace {
93 Some((workspace_dir, mut ws_config)) => {
94 ws_config.hash_root_path = Some(
96 ws_config
97 .hash_root_path
98 .map_or_else(|| workspace_dir.clone(), |p| workspace_dir.join(p)),
99 );
100 ws_config.output_file = ws_config.output_file.map(|p| workspace_dir.join(p));
101 ws_config.output_dir = ws_config.output_dir.map(|p| workspace_dir.join(p));
102 (Some(workspace_dir), ws_config)
103 }
104 None => (None, PartialConfig::default()),
105 };
106
107 let config = Self {
108 output_file: config
109 .output_file
110 .or(ws_config.output_file)
111 .map(|p| manifest_dir.join(p)),
112 output_dir: config
113 .output_dir
114 .or(ws_config.output_dir)
115 .map(|p| manifest_dir.join(p)),
116 extensions: config
117 .extensions
118 .or(ws_config.extensions)
119 .unwrap_or_else(default_extensions),
120 folders: config
121 .folders
122 .or(ws_config.folders)
123 .unwrap_or_else(default_folders)
124 .into_iter()
125 .map(|p| manifest_dir.join(p))
126 .collect(),
127 scss_prelude: config.scss_prelude.or(ws_config.scss_prelude),
128 hash_len: config
129 .hash_len
130 .or(ws_config.hash_len)
131 .unwrap_or(default_hash_len()),
132 class_name_pattern: config
133 .class_name_pattern
134 .or(ws_config.class_name_pattern)
135 .unwrap_or_default(),
136 hash_root_path: config
137 .hash_root_path
138 .or(ws_config.hash_root_path)
139 .map(|p| manifest_dir.join(p))
140 .unwrap_or_else(|| manifest_dir.to_path_buf()),
141 workspace_dir,
142 manifest_dir,
143 };
144
145 if config.extensions.iter().any(|e| e.is_empty()) {
146 bail!("Stylance config extensions can't be empty strings");
147 }
148
149 Ok(config)
150 }
151}
152
153#[derive(Deserialize)]
154struct CargoToml {
155 package: Option<CargoTomlPackage>,
156 workspace: Option<CargoTomlWorkspace>,
157}
158
159#[derive(Deserialize)]
160struct CargoTomlPackage {
161 metadata: Option<CargoTomlPackageMetadata>,
162 #[serde(rename = "workspace")]
164 workspace_path: Option<toml::Value>,
165}
166
167#[derive(Deserialize)]
168struct CargoTomlPackageMetadata {
169 stylance: Option<PartialConfig>,
170}
171
172#[derive(Deserialize)]
173struct CargoTomlWorkspace {
174 metadata: Option<CargoTomlWorkspaceMetadata>,
175}
176
177#[derive(Deserialize)]
178struct CargoTomlWorkspaceMetadata {
179 stylance: Option<PartialConfig>,
180}
181
182fn find_workspace_root(
187 manifest_dir: &Path,
188 cargo_toml: CargoToml,
189) -> anyhow::Result<(PathBuf, CargoToml)> {
190 let manifest_dir = normalize(manifest_dir)?;
191
192 if cargo_toml.workspace.is_some() {
194 return Ok((manifest_dir, cargo_toml));
195 }
196
197 if let Some(CargoTomlPackage {
199 workspace_path: Some(toml::Value::String(workspace_path)),
200 ..
201 }) = &cargo_toml.package
202 {
203 let ws_root = manifest_dir.join(workspace_path);
204 let contents = fs::read_to_string(ws_root.join("Cargo.toml")).with_context(|| {
205 format!(
206 "Failed to read workspace Cargo.toml at {}",
207 ws_root.display()
208 )
209 })?;
210 let parsed: CargoToml = toml::from_str(&contents)?;
211 return Ok((ws_root, parsed));
212 }
213
214 let mut current = manifest_dir.to_path_buf();
216 loop {
217 if !current.pop() {
218 bail!(
219 "Could not find workspace root for {}. \
220 No parent Cargo.toml with [workspace] was found.",
221 manifest_dir.display()
222 );
223 }
224
225 let candidate = current.join("Cargo.toml");
226 if candidate.exists() {
227 let contents = fs::read_to_string(&candidate)
228 .with_context(|| format!("Failed to read {}", candidate.display()))?;
229 let parsed: CargoToml = toml::from_str(&contents)?;
230 if parsed.workspace.is_some() {
231 return Ok((current, parsed));
232 }
233 }
234 }
235}
236
237#[cfg(test)]
238mod tests {
239 use super::*;
240
241 #[test]
244 fn toml_1_1_multiline_inline_table() {
245 let dir = tempfile::tempdir().expect("tempdir");
246 std::fs::write(
247 dir.path().join("Cargo.toml"),
248 r#"[package]
249name = "test-crate"
250version = "0.1.0"
251edition = "2021"
252metadata = {
253 stylance = { output_file = "output.css" }
254}
255"#,
256 )
257 .expect("write Cargo.toml");
258
259 let config = Config::load(dir.path().to_path_buf())
260 .expect("Config::load should succeed with TOML 1.1 multiline inline tables");
261
262 assert_eq!(
263 config.output_file,
264 Some(dir.path().join("output.css")),
265 "output_file should be parsed from the multiline inline table"
266 );
267 }
268}