eure_config/
lib.rs

1//! Configuration types for Eure tools.
2//!
3//! This crate provides configuration data types for the Eure CLI and Language Server.
4//! The configuration is stored in `Eure.eure` files at project roots.
5//!
6//! This crate only defines data structures. Query logic and error reporting
7//! are in the `eure` crate.
8//!
9//! # Features
10//!
11//! - `lint` - Include lint configuration types
12//! - `ls` - Include language server configuration types
13//! - `cli` - Include CLI configuration (enables `lint` and `ls`)
14//! - `all` - Include all configuration types
15
16use std::collections::HashMap;
17use std::path::{Path, PathBuf};
18
19use eure_document::parse::{ParseContext, ParseDocument, ParseError};
20use eure_macros::ParseDocument;
21use eure_parol::EureParseError;
22
23/// The standard configuration filename.
24pub const CONFIG_FILENAME: &str = "Eure.eure";
25
26/// Error type for configuration parsing.
27///
28/// Note: Document construction errors are handled separately in the eure crate.
29#[derive(Debug, thiserror::Error)]
30pub enum ConfigError {
31    #[error("IO error: {0}")]
32    Io(#[from] std::io::Error),
33
34    #[error("Syntax error: {0}")]
35    Syntax(EureParseError),
36
37    #[error("Config error: {0}")]
38    Parse(#[from] ParseError),
39}
40
41impl PartialEq for ConfigError {
42    fn eq(&self, other: &Self) -> bool {
43        match (self, other) {
44            (ConfigError::Io(a), ConfigError::Io(b)) => a.kind() == b.kind(),
45            (ConfigError::Syntax(a), ConfigError::Syntax(b)) => a.to_string() == b.to_string(),
46            (ConfigError::Parse(a), ConfigError::Parse(b)) => a == b,
47            _ => false,
48        }
49    }
50}
51
52impl From<EureParseError> for ConfigError {
53    fn from(err: EureParseError) -> Self {
54        ConfigError::Syntax(err)
55    }
56}
57
58/// A check target definition.
59#[derive(Debug, Clone, ParseDocument, PartialEq, Eq, Hash)]
60#[eure(crate = eure_document, allow_unknown_fields)]
61pub struct Target {
62    /// Glob patterns for files to include in this target.
63    pub globs: Vec<String>,
64    /// Optional schema file path (relative to config file).
65    #[eure(default)]
66    pub schema: Option<String>,
67}
68
69/// CLI-specific configuration.
70#[cfg(feature = "cli")]
71#[derive(Debug, Clone, Default, ParseDocument, PartialEq)]
72#[eure(crate = eure_document, rename_all = "kebab-case", allow_unknown_fields)]
73pub struct CliConfig {
74    /// Default targets to check when running `eure check` without arguments.
75    #[eure(default)]
76    pub default_targets: Vec<String>,
77}
78
79/// Language server configuration.
80#[cfg(feature = "ls")]
81#[derive(Debug, Clone, Default, ParseDocument, PartialEq)]
82#[eure(crate = eure_document, rename_all = "kebab-case", allow_unknown_fields)]
83pub struct LsConfig {
84    /// Whether to format on save.
85    #[eure(default)]
86    pub format_on_save: bool,
87}
88
89/// The main Eure configuration.
90#[derive(Debug, Clone, Default, PartialEq)]
91pub struct EureConfig {
92    /// Check targets (name -> target definition).
93    pub targets: HashMap<String, Target>,
94
95    /// CLI-specific configuration.
96    #[cfg(feature = "cli")]
97    pub cli: Option<CliConfig>,
98
99    /// Language server configuration.
100    #[cfg(feature = "ls")]
101    pub ls: Option<LsConfig>,
102}
103
104impl ParseDocument<'_> for EureConfig {
105    type Error = ParseError;
106
107    fn parse(ctx: &ParseContext<'_>) -> Result<Self, Self::Error> {
108        let rec = ctx.parse_record()?;
109
110        // Parse targets as a map
111        let targets = if let Some(targets_ctx) = rec.field_optional("targets") {
112            let targets_rec = targets_ctx.parse_record()?;
113            let mut targets = HashMap::new();
114            for (name, target_ctx) in targets_rec.unknown_fields() {
115                let target = target_ctx.parse::<Target>()?;
116                targets.insert(name.to_string(), target);
117            }
118            targets_rec.allow_unknown_fields()?;
119            targets
120        } else {
121            HashMap::new()
122        };
123
124        #[cfg(feature = "cli")]
125        let cli = rec
126            .field_optional("cli")
127            .map(|ctx| ctx.parse::<CliConfig>())
128            .transpose()?;
129
130        #[cfg(feature = "ls")]
131        let ls = rec
132            .field_optional("ls")
133            .map(|ctx| ctx.parse::<LsConfig>())
134            .transpose()?;
135
136        rec.allow_unknown_fields()?;
137
138        Ok(EureConfig {
139            targets,
140            #[cfg(feature = "cli")]
141            cli,
142            #[cfg(feature = "ls")]
143            ls,
144        })
145    }
146}
147
148impl EureConfig {
149    /// Find the configuration file by searching upward from the given directory.
150    pub fn find_config_file(start_dir: &Path) -> Option<PathBuf> {
151        let mut current = start_dir.to_path_buf();
152        loop {
153            let config_path = current.join(CONFIG_FILENAME);
154            if config_path.exists() {
155                return Some(config_path);
156            }
157            if !current.pop() {
158                return None;
159            }
160        }
161    }
162
163    /// Get the default targets for CLI check command.
164    #[cfg(feature = "cli")]
165    pub fn default_targets(&self) -> &[String] {
166        self.cli
167            .as_ref()
168            .map(|c| c.default_targets.as_slice())
169            .unwrap_or(&[])
170    }
171
172    /// Get a target by name.
173    pub fn get_target(&self, name: &str) -> Option<&Target> {
174        self.targets.get(name)
175    }
176
177    /// Get all target names.
178    pub fn target_names(&self) -> impl Iterator<Item = &str> {
179        self.targets.keys().map(|s| s.as_str())
180    }
181
182    /// Find the schema for a file path by matching against target globs.
183    ///
184    /// Returns the first matching target's schema path, if any.
185    pub fn schema_for_path(&self, file_path: &Path, config_dir: &Path) -> Option<PathBuf> {
186        // Use explicit options for consistent cross-platform behavior
187        let options = glob::MatchOptions {
188            case_sensitive: true,
189            require_literal_separator: true,
190            require_literal_leading_dot: false,
191        };
192
193        for target in self.targets.values() {
194            if let Some(ref schema) = target.schema {
195                for glob_pattern in &target.globs {
196                    // Make glob pattern absolute relative to config dir
197                    let full_pattern = config_dir.join(glob_pattern);
198                    if let Ok(pattern) = glob::Pattern::new(&full_pattern.to_string_lossy())
199                        && pattern.matches_path_with(file_path, options)
200                    {
201                        // Return schema path relative to config dir
202                        return Some(config_dir.join(schema));
203                    }
204                }
205            }
206        }
207        None
208    }
209}