1use std::collections::BTreeSet;
4use std::fs;
5use std::path::Path;
6
7use thiserror::Error;
8
9use crate::llm::ConvertError;
10use crate::{DxDocument, DxLlmValue, llm_to_document};
11
12const BIOME_SECTION: &str = "biome";
13const TARGET_COLUMN: &str = "target";
14const PATH_COLUMN: &str = "path";
15const ENABLED_COLUMN: &str = "enabled";
16const DEFAULT_PATH: &str = ".";
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum DxBiomeTarget {
21 All,
23 Lint,
25 Format,
27}
28
29impl DxBiomeTarget {
30 #[must_use]
32 pub fn includes(self, requested: Self) -> bool {
33 self == Self::All || self == requested
34 }
35}
36
37#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct DxBiomeConfig {
40 entries: Vec<DxBiomeConfigEntry>,
41}
42
43impl DxBiomeConfig {
44 #[must_use]
46 pub fn entries(&self) -> &[DxBiomeConfigEntry] {
47 &self.entries
48 }
49
50 #[must_use]
52 pub fn is_enabled_for(&self, target: DxBiomeTarget) -> bool {
53 self.entries
54 .iter()
55 .any(|entry| entry.target.includes(target))
56 }
57
58 #[must_use]
60 pub fn paths_for(&self, target: DxBiomeTarget) -> Vec<String> {
61 let mut seen = BTreeSet::new();
62 let mut paths = Vec::new();
63 for entry in self
64 .entries
65 .iter()
66 .filter(|entry| entry.target.includes(target))
67 {
68 if seen.insert(entry.path.clone()) {
69 paths.push(entry.path.clone());
70 }
71 }
72 paths
73 }
74}
75
76#[derive(Debug, Clone, PartialEq, Eq)]
78pub struct DxBiomeConfigEntry {
79 pub target: DxBiomeTarget,
81 pub path: String,
83}
84
85#[derive(Debug, Error)]
87pub enum DxBiomeConfigError {
88 #[error("failed to read Biome dx config at {path}: {source}")]
90 Read {
91 path: String,
93 #[source]
95 source: std::io::Error,
96 },
97 #[error("failed to parse Biome dx config: {0}")]
99 Parse(#[from] ConvertError),
100 #[error("biome table must include a {column} column")]
102 MissingColumn {
103 column: &'static str,
105 },
106 #[error("invalid Biome target at row {row}: {value}")]
108 InvalidTarget {
109 row: usize,
111 value: String,
113 },
114 #[error("invalid Biome enabled value at row {row}: {value}")]
116 InvalidEnabled {
117 row: usize,
119 value: String,
121 },
122 #[error("invalid Biome path at row {row}: {value} ({reason})")]
124 InvalidPath {
125 row: usize,
127 value: String,
129 reason: &'static str,
131 },
132}
133
134pub fn load_biome_config(
141 path: impl AsRef<Path>,
142) -> Result<Option<DxBiomeConfig>, DxBiomeConfigError> {
143 let path = path.as_ref();
144 if !path.is_file() {
145 return Ok(None);
146 }
147
148 let source = fs::read_to_string(path).map_err(|source| DxBiomeConfigError::Read {
149 path: path.display().to_string(),
150 source,
151 })?;
152 biome_config_from_source(&source)
153}
154
155pub fn biome_config_from_source(source: &str) -> Result<Option<DxBiomeConfig>, DxBiomeConfigError> {
162 let document = llm_to_document(source)?;
163 biome_config_from_document(&document)
164}
165
166pub fn biome_config_from_document(
172 document: &DxDocument,
173) -> Result<Option<DxBiomeConfig>, DxBiomeConfigError> {
174 let Some(section) = document.section_by_name(BIOME_SECTION) else {
175 return Ok(None);
176 };
177
178 let target_index =
179 section
180 .column_index(TARGET_COLUMN)
181 .ok_or(DxBiomeConfigError::MissingColumn {
182 column: TARGET_COLUMN,
183 })?;
184 let path_index = section.column_index(PATH_COLUMN);
185 let enabled_index = section.column_index(ENABLED_COLUMN);
186 let mut entries = Vec::new();
187
188 for (index, row) in section.rows.iter().enumerate() {
189 let row_number = index + 1;
190 if !enabled_value(row, enabled_index, row_number)? {
191 continue;
192 }
193
194 let target_value = row
195 .get(target_index)
196 .map(cell_text)
197 .filter(|value| !value.trim().is_empty())
198 .ok_or_else(|| DxBiomeConfigError::InvalidTarget {
199 row: row_number,
200 value: String::new(),
201 })?;
202 let target =
203 parse_target(&target_value).ok_or_else(|| DxBiomeConfigError::InvalidTarget {
204 row: row_number,
205 value: target_value.clone(),
206 })?;
207
208 let path = path_value(row, path_index, row_number)?;
209 validate_path(&path, row_number)?;
210 entries.push(DxBiomeConfigEntry {
211 target,
212 path: normalize_relative_path(&path),
213 });
214 }
215
216 Ok(Some(DxBiomeConfig { entries }))
217}
218
219fn enabled_value(
220 row: &[DxLlmValue],
221 enabled_index: Option<usize>,
222 row_number: usize,
223) -> Result<bool, DxBiomeConfigError> {
224 let Some(index) = enabled_index else {
225 return Ok(true);
226 };
227 let Some(value) = row.get(index) else {
228 return Ok(true);
229 };
230
231 match value {
232 DxLlmValue::Bool(value) => Ok(*value),
233 DxLlmValue::Num(value) if value.abs() < f64::EPSILON => Ok(false),
234 DxLlmValue::Num(value) if (*value - 1.0).abs() < f64::EPSILON => Ok(true),
235 DxLlmValue::Str(value) => match value.trim().to_ascii_lowercase().as_str() {
236 "true" | "yes" | "on" | "1" | "enabled" => Ok(true),
237 "false" | "no" | "off" | "0" | "disabled" => Ok(false),
238 _ => Err(DxBiomeConfigError::InvalidEnabled {
239 row: row_number,
240 value: value.clone(),
241 }),
242 },
243 DxLlmValue::Null => Ok(true),
244 _ => Err(DxBiomeConfigError::InvalidEnabled {
245 row: row_number,
246 value: value.to_string(),
247 }),
248 }
249}
250
251fn parse_target(value: &str) -> Option<DxBiomeTarget> {
252 match value.trim().to_ascii_lowercase().as_str() {
253 "all" | "check" | "lint-format" | "lint_format" | "lint+format" => Some(DxBiomeTarget::All),
254 "lint" => Some(DxBiomeTarget::Lint),
255 "format" | "formatter" => Some(DxBiomeTarget::Format),
256 _ => None,
257 }
258}
259
260fn cell_text(value: &DxLlmValue) -> String {
261 value.to_string()
262}
263
264fn path_value(
265 row: &[DxLlmValue],
266 path_index: Option<usize>,
267 row_number: usize,
268) -> Result<String, DxBiomeConfigError> {
269 let Some(index) = path_index else {
270 return Ok(DEFAULT_PATH.to_string());
271 };
272 let Some(value) = row.get(index) else {
273 return Ok(DEFAULT_PATH.to_string());
274 };
275 match value {
276 DxLlmValue::Str(value) => {
277 let trimmed = value.trim();
278 if trimmed.is_empty() {
279 return Err(DxBiomeConfigError::InvalidPath {
280 row: row_number,
281 value: value.clone(),
282 reason: "path cannot be empty",
283 });
284 }
285 Ok(trimmed.to_string())
286 }
287 _ => Err(DxBiomeConfigError::InvalidPath {
288 row: row_number,
289 value: value.to_string(),
290 reason: "path must be a string",
291 }),
292 }
293}
294
295fn validate_path(path: &str, row_number: usize) -> Result<(), DxBiomeConfigError> {
296 if path.trim().is_empty() {
297 return Err(DxBiomeConfigError::InvalidPath {
298 row: row_number,
299 value: path.to_string(),
300 reason: "path cannot be empty",
301 });
302 }
303 if path.starts_with('-') {
304 return Err(DxBiomeConfigError::InvalidPath {
305 row: row_number,
306 value: path.to_string(),
307 reason: "path cannot look like a command-line option",
308 });
309 }
310 let normalized = path.replace('\\', "/");
311 if is_absolute_like(&normalized) {
312 return Err(DxBiomeConfigError::InvalidPath {
313 row: row_number,
314 value: path.to_string(),
315 reason: "path must be relative to the project root",
316 });
317 }
318 if normalized.split('/').any(|component| component == "..") {
319 return Err(DxBiomeConfigError::InvalidPath {
320 row: row_number,
321 value: path.to_string(),
322 reason: "path cannot escape the project root",
323 });
324 }
325 if path.chars().any(char::is_control) {
326 return Err(DxBiomeConfigError::InvalidPath {
327 row: row_number,
328 value: path.to_string(),
329 reason: "path cannot contain control characters",
330 });
331 }
332 Ok(())
333}
334
335fn is_absolute_like(path: &str) -> bool {
336 path.starts_with('/')
337 || path.starts_with("//")
338 || path
339 .as_bytes()
340 .get(1)
341 .is_some_and(|separator| *separator == b':')
342}
343
344fn normalize_relative_path(path: &str) -> String {
345 let normalized = path.replace('\\', "/");
346 let parts = normalized
347 .split('/')
348 .filter(|part| !part.is_empty() && *part != ".")
349 .collect::<Vec<_>>();
350 if parts.is_empty() {
351 DEFAULT_PATH.to_string()
352 } else {
353 parts.join("/")
354 }
355}
356
357#[cfg(test)]
358mod tests {
359 use super::*;
360
361 #[test]
362 fn parses_biome_config_from_extensionless_dx_source() {
363 let source = r#"
364project(name=demo kind=dx-project)
365
366biome[target path enabled](
367lint src true
368format tests true
369all packages/app true
370)
371"#;
372
373 let config = biome_config_from_source(source).unwrap().unwrap();
374
375 assert_eq!(
376 config.paths_for(DxBiomeTarget::Lint),
377 vec!["src".to_string(), "packages/app".to_string()]
378 );
379 assert_eq!(
380 config.paths_for(DxBiomeTarget::Format),
381 vec!["tests".to_string(), "packages/app".to_string()]
382 );
383 }
384
385 #[test]
386 fn missing_biome_table_returns_none() {
387 let config = biome_config_from_source("project(name=demo kind=dx-project)").unwrap();
388
389 assert!(config.is_none());
390 }
391
392 #[test]
393 fn rejects_paths_that_would_be_cli_options() {
394 let error = biome_config_from_source(
395 r#"
396biome[target path](
397lint "--write"
398)
399"#,
400 )
401 .unwrap_err();
402
403 assert!(error.to_string().contains("--write"));
404 }
405
406 #[test]
407 fn trims_and_deduplicates_biome_paths_in_source_order() {
408 let config = biome_config_from_source(
409 r#"
410biome[target path](
411lint " src "
412lint src
413lint ./src
414lint src/.
415lint tests
416)
417"#,
418 )
419 .unwrap()
420 .unwrap();
421
422 assert_eq!(
423 config.paths_for(DxBiomeTarget::Lint),
424 vec!["src".to_string(), "tests".to_string()]
425 );
426 }
427
428 #[test]
429 fn rejects_absolute_and_parent_traversal_biome_paths() {
430 for path in [
431 "/tmp/project",
432 "C:/repo/src",
433 "//server/share",
434 "../outside",
435 ] {
436 let error = biome_config_from_source(&format!(
437 r#"
438biome[target path](
439lint "{path}"
440)
441"#
442 ))
443 .unwrap_err();
444
445 assert!(
446 error.to_string().contains(path),
447 "error should mention rejected path {path}: {error}"
448 );
449 }
450
451 for path in ["..\\outside", "src\\..\\outside", "C:\\repo\\src"] {
452 assert!(
453 validate_path(path, 1).is_err(),
454 "path should be rejected independent of host OS: {path}"
455 );
456 }
457 }
458
459 #[test]
460 fn rejects_non_scalar_biome_paths() {
461 let mut document = DxDocument::new();
462 let mut section = crate::DxSection::new(vec!["target".to_string(), "path".to_string()]);
463 section
464 .add_row(vec![
465 DxLlmValue::Str("lint".to_string()),
466 DxLlmValue::Arr(vec![DxLlmValue::Str("src".to_string())]),
467 ])
468 .unwrap();
469 document.sections.insert('b', section);
470 document.section_names.insert('b', "biome".to_string());
471
472 let error = biome_config_from_document(&document).unwrap_err();
473
474 assert!(error.to_string().contains("path"));
475 }
476
477 #[test]
478 fn disabled_rows_do_not_validate_draft_target_or_path() {
479 let config = biome_config_from_source(
480 r#"
481biome[target path enabled](
482future "--write" false
483lint src true
484)
485"#,
486 )
487 .unwrap()
488 .unwrap();
489
490 assert_eq!(
491 config.paths_for(DxBiomeTarget::Lint),
492 vec!["src".to_string()]
493 );
494 }
495}