Skip to main content

stylance_core/
config.rs

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
39/**
40 * Represents the stylance config applying to a single crate.
41 * Unlike PartialConfig, the paths in this struct should be interpreted
42 * as relative to CWD instead of relative to a manifest dir.
43 */
44pub 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                // Absolutize workspace config paths against the workspace root
95                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    /// Explicit workspace path, e.g. `workspace = "../my-workspace"`
163    #[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
182/// Find the workspace root directory and its parsed CargoToml.
183/// First checks if the crate's own Cargo.toml has `[workspace]` (root crate).
184/// Then checks for an explicit `[package] workspace` field.
185/// Otherwise, walks up the directory tree looking for a Cargo.toml with `[workspace]`.
186fn 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    // The crate's own Cargo.toml has [workspace] — it is the workspace root
193    if cargo_toml.workspace.is_some() {
194        return Ok((manifest_dir, cargo_toml));
195    }
196
197    // Check for explicit workspace path in [package] workspace = "path"
198    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    // Walk up looking for a Cargo.toml with [workspace]
215    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    /// Check that Config::load supports parsing a multiline inline table
242    /// (introduced in TOML 1.1)
243    #[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}