Skip to main content

eure_env/
lib.rs

1//! Environment and configuration for Eure tools.
2//!
3//! This crate provides configuration data types and caching for the Eure CLI
4//! and Language Server. The configuration is stored in `Eure.eure` files at project roots.
5//!
6//! # Features
7//!
8//! - `lint` - Include lint configuration types
9//! - `ls` - Include language server configuration types
10//! - `cli` - Include CLI configuration (enables `lint` and `ls`)
11//! - `native` - Include native I/O for remote schema caching (requires network/filesystem dependencies)
12//! - `all` - Include all configuration types
13
14pub mod cache;
15
16use std::collections::HashMap;
17use std::path::{Path, PathBuf};
18
19use eure_document::parse::{FromEure, ParseContext, ParseError, ParseErrorKind};
20use eure_macros::FromEure;
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, FromEure, 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, FromEure, 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, FromEure, 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/// Security configuration for remote URL access.
90#[derive(Debug, Clone, Default, FromEure, PartialEq)]
91#[eure(crate = eure_document, rename_all = "kebab-case", allow_unknown_fields)]
92pub struct SecurityConfig {
93    /// Additional allowed hosts for remote URL fetching (beyond eure.dev).
94    ///
95    /// Supports exact matches (e.g., "example.com") and wildcard subdomains
96    /// (e.g., "*.example.com" matches "sub.example.com" and "example.com").
97    #[eure(default)]
98    pub allowed_hosts: Vec<String>,
99}
100
101/// The main Eure configuration.
102#[derive(Debug, Clone, Default, PartialEq)]
103pub struct EureConfig {
104    /// Check targets (name -> target definition).
105    pub targets: HashMap<String, Target>,
106
107    /// Security configuration (remote URL access control).
108    pub security: Option<SecurityConfig>,
109
110    /// CLI-specific configuration.
111    #[cfg(feature = "cli")]
112    pub cli: Option<CliConfig>,
113
114    /// Language server configuration.
115    #[cfg(feature = "ls")]
116    pub ls: Option<LsConfig>,
117}
118
119impl FromEure<'_> for EureConfig {
120    type Error = ParseError;
121
122    fn parse(ctx: &ParseContext<'_>) -> Result<Self, Self::Error> {
123        let rec = ctx.parse_record()?;
124
125        // Parse targets as a map
126        let targets = if let Some(targets_ctx) = rec.field_optional("targets") {
127            let targets_rec = targets_ctx.parse_record()?;
128            let mut targets = HashMap::new();
129            for result in targets_rec.unknown_fields() {
130                let (name, target_ctx) = result.map_err(|(key, ctx)| ParseError {
131                    node_id: ctx.node_id(),
132                    kind: ParseErrorKind::InvalidKeyType(key.clone()),
133                })?;
134                let target = target_ctx.parse::<Target>()?;
135                targets.insert(name.to_string(), target);
136            }
137            targets_rec.allow_unknown_fields()?;
138            targets
139        } else {
140            HashMap::new()
141        };
142
143        let security = rec
144            .field_optional("security")
145            .map(|ctx| ctx.parse::<SecurityConfig>())
146            .transpose()?;
147
148        #[cfg(feature = "cli")]
149        let cli = rec
150            .field_optional("cli")
151            .map(|ctx| ctx.parse::<CliConfig>())
152            .transpose()?;
153
154        #[cfg(feature = "ls")]
155        let ls = rec
156            .field_optional("ls")
157            .map(|ctx| ctx.parse::<LsConfig>())
158            .transpose()?;
159
160        rec.allow_unknown_fields()?;
161
162        Ok(EureConfig {
163            targets,
164            security,
165            #[cfg(feature = "cli")]
166            cli,
167            #[cfg(feature = "ls")]
168            ls,
169        })
170    }
171}
172
173impl EureConfig {
174    /// Find the configuration file by searching upward from the given directory.
175    pub fn find_config_file(start_dir: &Path) -> Option<PathBuf> {
176        let mut current = start_dir.to_path_buf();
177        loop {
178            let config_path = current.join(CONFIG_FILENAME);
179            if config_path.exists() {
180                return Some(config_path);
181            }
182            if !current.pop() {
183                return None;
184            }
185        }
186    }
187
188    /// Get the default targets for CLI check command.
189    #[cfg(feature = "cli")]
190    pub fn default_targets(&self) -> &[String] {
191        self.cli
192            .as_ref()
193            .map(|c| c.default_targets.as_slice())
194            .unwrap_or(&[])
195    }
196
197    /// Get a target by name.
198    pub fn get_target(&self, name: &str) -> Option<&Target> {
199        self.targets.get(name)
200    }
201
202    /// Get all target names.
203    pub fn target_names(&self) -> impl Iterator<Item = &str> {
204        self.targets.keys().map(|s| s.as_str())
205    }
206
207    /// Find the schema for a file path by matching against target globs.
208    ///
209    /// Returns the first matching target's schema path, if any.
210    pub fn schema_for_path(&self, file_path: &Path, config_dir: &Path) -> Option<String> {
211        // Use explicit options for consistent cross-platform behavior
212        let options = glob::MatchOptions {
213            case_sensitive: true,
214            require_literal_separator: true,
215            require_literal_leading_dot: false,
216        };
217
218        for target in self.targets.values() {
219            if let Some(ref schema) = target.schema {
220                for glob_pattern in &target.globs {
221                    // Make glob pattern absolute relative to config dir
222                    let full_pattern = config_dir.join(glob_pattern);
223                    if let Ok(pattern) = glob::Pattern::new(&full_pattern.to_string_lossy())
224                        && pattern.matches_path_with(file_path, options)
225                    {
226                        // Return schema path relative to config dir
227                        return Some(schema.clone());
228                    }
229                }
230            }
231        }
232        None
233    }
234
235    /// Get the allowed hosts for remote URL fetching from security config.
236    ///
237    /// Returns an empty slice if no security config is present.
238    /// Note: This does NOT include the default `eure.dev` - callers should
239    /// check that separately.
240    pub fn allowed_hosts(&self) -> &[String] {
241        self.security
242            .as_ref()
243            .map(|s| s.allowed_hosts.as_slice())
244            .unwrap_or(&[])
245    }
246}