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::{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/// Security configuration for remote URL access.
90#[derive(Debug, Clone, Default, ParseDocument, 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 ParseDocument<'_> 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 (name, target_ctx) in targets_rec.unknown_fields() {
130                let target = target_ctx.parse::<Target>()?;
131                targets.insert(name.to_string(), target);
132            }
133            targets_rec.allow_unknown_fields()?;
134            targets
135        } else {
136            HashMap::new()
137        };
138
139        let security = rec
140            .field_optional("security")
141            .map(|ctx| ctx.parse::<SecurityConfig>())
142            .transpose()?;
143
144        #[cfg(feature = "cli")]
145        let cli = rec
146            .field_optional("cli")
147            .map(|ctx| ctx.parse::<CliConfig>())
148            .transpose()?;
149
150        #[cfg(feature = "ls")]
151        let ls = rec
152            .field_optional("ls")
153            .map(|ctx| ctx.parse::<LsConfig>())
154            .transpose()?;
155
156        rec.allow_unknown_fields()?;
157
158        Ok(EureConfig {
159            targets,
160            security,
161            #[cfg(feature = "cli")]
162            cli,
163            #[cfg(feature = "ls")]
164            ls,
165        })
166    }
167}
168
169impl EureConfig {
170    /// Find the configuration file by searching upward from the given directory.
171    pub fn find_config_file(start_dir: &Path) -> Option<PathBuf> {
172        let mut current = start_dir.to_path_buf();
173        loop {
174            let config_path = current.join(CONFIG_FILENAME);
175            if config_path.exists() {
176                return Some(config_path);
177            }
178            if !current.pop() {
179                return None;
180            }
181        }
182    }
183
184    /// Get the default targets for CLI check command.
185    #[cfg(feature = "cli")]
186    pub fn default_targets(&self) -> &[String] {
187        self.cli
188            .as_ref()
189            .map(|c| c.default_targets.as_slice())
190            .unwrap_or(&[])
191    }
192
193    /// Get a target by name.
194    pub fn get_target(&self, name: &str) -> Option<&Target> {
195        self.targets.get(name)
196    }
197
198    /// Get all target names.
199    pub fn target_names(&self) -> impl Iterator<Item = &str> {
200        self.targets.keys().map(|s| s.as_str())
201    }
202
203    /// Find the schema for a file path by matching against target globs.
204    ///
205    /// Returns the first matching target's schema path, if any.
206    pub fn schema_for_path(&self, file_path: &Path, config_dir: &Path) -> Option<String> {
207        // Use explicit options for consistent cross-platform behavior
208        let options = glob::MatchOptions {
209            case_sensitive: true,
210            require_literal_separator: true,
211            require_literal_leading_dot: false,
212        };
213
214        for target in self.targets.values() {
215            if let Some(ref schema) = target.schema {
216                for glob_pattern in &target.globs {
217                    // Make glob pattern absolute relative to config dir
218                    let full_pattern = config_dir.join(glob_pattern);
219                    if let Ok(pattern) = glob::Pattern::new(&full_pattern.to_string_lossy())
220                        && pattern.matches_path_with(file_path, options)
221                    {
222                        // Return schema path relative to config dir
223                        return Some(schema.clone());
224                    }
225                }
226            }
227        }
228        None
229    }
230
231    /// Get the allowed hosts for remote URL fetching from security config.
232    ///
233    /// Returns an empty slice if no security config is present.
234    /// Note: This does NOT include the default `eure.dev` - callers should
235    /// check that separately.
236    pub fn allowed_hosts(&self) -> &[String] {
237        self.security
238            .as_ref()
239            .map(|s| s.allowed_hosts.as_slice())
240            .unwrap_or(&[])
241    }
242}