Skip to main content

eure/query/
assets.rs

1//! Asset definitions for query-flow.
2//!
3//! Assets are an abstract mechanism for external data sources.
4//! Each consumer provides asset values differently:
5//! - eure-ls: provides from `didOpen`/`didChange` notifications
6//! - eure-cli: provides by reading from disk (query-flow caches)
7//! - test-suite: provides from test case strings
8
9use std::path::{Path, PathBuf};
10use std::sync::Arc;
11
12use query_flow::asset_key;
13use url::Url;
14
15use super::error::EureQueryError;
16
17/// Asset key for text file content.
18#[asset_key(asset = TextFileContent)]
19pub enum TextFile {
20    /// Local file path.
21    Local(Arc<PathBuf>),
22    /// Remote URL (HTTPS).
23    Remote(Url),
24}
25
26impl TextFile {
27    /// Create a TextFile from a local path.
28    pub fn from_path(path: PathBuf) -> Self {
29        Self::Local(Arc::new(path))
30    }
31
32    /// Create a TextFile from a URL.
33    pub fn from_url(url: Url) -> Self {
34        Self::Remote(url)
35    }
36
37    /// Parse a string as either a URL (if starts with https://) or a local path.
38    pub fn parse(s: &str) -> Result<Self, EureQueryError> {
39        if s.starts_with("https://") {
40            Url::parse(s)
41                .map(Self::from_url)
42                .map_err(|e| EureQueryError::InvalidUrl {
43                    url: s.to_string(),
44                    reason: e.to_string(),
45                })
46        } else {
47            Ok(Self::from_path(PathBuf::from(s)))
48        }
49    }
50
51    /// Resolve a schema/file reference relative to a base directory.
52    ///
53    /// - If `target` starts with "https://", returns a `TextFile::Remote`
54    /// - Otherwise, joins `target` with `base_dir` and returns a `TextFile::Local`
55    pub fn resolve(target: &str, base_dir: &Path) -> Result<Self, EureQueryError> {
56        if target.starts_with("https://") {
57            Self::parse(target)
58        } else {
59            Ok(Self::from_path(base_dir.join(target)))
60        }
61    }
62
63    /// Create a TextFile from an Arc<PathBuf> (for backward compatibility).
64    pub fn new(path: Arc<PathBuf>) -> Self {
65        Self::Local(path)
66    }
67
68    /// Get the local path if this is a local file.
69    pub fn as_local_path(&self) -> Option<&Path> {
70        match self {
71            Self::Local(p) => Some(p),
72            Self::Remote(_) => None,
73        }
74    }
75
76    /// Get the URL if this is a remote file.
77    pub fn as_url(&self) -> Option<&Url> {
78        match self {
79            Self::Local(_) => None,
80            Self::Remote(url) => Some(url),
81        }
82    }
83
84    /// Check if this is a local file (not a remote URL).
85    pub fn is_local(&self) -> bool {
86        matches!(self, Self::Local(_))
87    }
88
89    /// Check if the file path/URL ends with the given suffix.
90    pub fn ends_with(&self, suffix: &str) -> bool {
91        match self {
92            Self::Local(path) => path
93                .file_name()
94                .is_some_and(|name| name.to_string_lossy().ends_with(suffix)),
95            Self::Remote(url) => url.path().ends_with(suffix),
96        }
97    }
98}
99
100impl std::fmt::Display for TextFile {
101    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
102        match self {
103            Self::Local(path) => write!(f, "{}", path.display()),
104            Self::Remote(url) => write!(f, "{}", url),
105        }
106    }
107}
108
109/// Content of a text file.
110#[derive(Clone, PartialEq, Debug)]
111pub struct TextFileContent(pub String);
112
113impl TextFileContent {
114    pub fn get(&self) -> &str {
115        &self.0
116    }
117}
118
119/// Asset key for workspace information.
120#[asset_key(asset = Workspace)]
121pub struct WorkspaceId(pub String);
122
123/// Workspace information.
124#[derive(Clone, PartialEq)]
125pub struct Workspace {
126    pub path: PathBuf,
127    pub config_path: PathBuf,
128}
129
130/// Asset key for glob pattern expansion.
131///
132/// Resolves a glob pattern relative to a base directory into a list of matching files.
133/// Each platform implements this differently:
134/// - CLI: uses `glob::glob()` on the filesystem
135/// - LSP (wasm32): returns empty or queries client for file list
136/// - test-suite: pre-resolves with test files
137#[asset_key(asset = GlobResult)]
138pub struct Glob {
139    pub base_dir: PathBuf,
140    pub pattern: String,
141}
142
143impl Glob {
144    pub fn new(base_dir: impl Into<PathBuf>, pattern: impl Into<String>) -> Self {
145        Self {
146            base_dir: base_dir.into(),
147            pattern: pattern.into(),
148        }
149    }
150
151    /// Returns the full pattern path (base_dir joined with pattern).
152    pub fn full_pattern(&self) -> PathBuf {
153        self.base_dir.join(&self.pattern)
154    }
155}
156
157/// Result of glob pattern expansion.
158#[derive(Clone, PartialEq, Debug)]
159pub struct GlobResult(pub Vec<TextFile>);
160
161/// Asset key for open documents list.
162///
163/// Used by LSP to track currently open documents.
164/// Collection queries depend on this asset to invalidate when documents open/close.
165#[asset_key(asset = OpenDocumentsList)]
166pub struct OpenDocuments;
167
168/// List of currently open documents.
169#[derive(Clone, PartialEq, Debug)]
170pub struct OpenDocumentsList(pub Vec<TextFile>);
171
172/// Box-drawing style for error reports.
173#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
174pub enum DecorStyle {
175    /// Unicode box-drawing characters (e.g., ╭, ╰, │, ━)
176    #[default]
177    Unicode,
178    /// ASCII characters (e.g., |, -, ^)
179    Ascii,
180}
181
182/// Asset key for decor style preference.
183///
184/// This is a user/environment preference, not a project configuration.
185/// Each runtime (CLI, test-suite, LSP) registers its own preference.
186#[asset_key(asset = DecorStyle)]
187pub struct DecorStyleKey;
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192
193    mod text_file_parse {
194        use super::*;
195
196        #[test]
197        fn parses_https_url() {
198            let file = TextFile::parse("https://example.com/schema.eure").unwrap();
199            assert!(file.as_url().is_some());
200            assert!(file.as_local_path().is_none());
201            assert_eq!(
202                file.as_url().unwrap().as_str(),
203                "https://example.com/schema.eure"
204            );
205        }
206
207        #[test]
208        fn parses_local_path() {
209            let file = TextFile::parse("/path/to/file.eure").unwrap();
210            assert!(file.as_local_path().is_some());
211            assert!(file.as_url().is_none());
212            assert_eq!(
213                file.as_local_path().unwrap(),
214                Path::new("/path/to/file.eure")
215            );
216        }
217
218        #[test]
219        fn parses_relative_path() {
220            let file = TextFile::parse("relative/path.eure").unwrap();
221            assert!(file.as_local_path().is_some());
222            assert_eq!(
223                file.as_local_path().unwrap(),
224                Path::new("relative/path.eure")
225            );
226        }
227
228        #[test]
229        fn http_without_s_is_local_path() {
230            // http:// (without s) is treated as a local path, not a URL
231            let file = TextFile::parse("http://example.com").unwrap();
232            assert!(file.as_local_path().is_some());
233        }
234
235        #[test]
236        fn invalid_url_returns_error() {
237            // Empty host
238            let result = TextFile::parse("https://");
239            assert!(result.is_err());
240
241            // Invalid characters in host
242            let result = TextFile::parse("https://[invalid");
243            assert!(result.is_err());
244        }
245    }
246
247    mod text_file_ends_with {
248        use super::*;
249
250        #[test]
251        fn local_file_ends_with_extension() {
252            let file = TextFile::from_path(PathBuf::from("/path/to/file.schema.eure"));
253            assert!(file.ends_with(".schema.eure"));
254            assert!(file.ends_with(".eure"));
255            assert!(!file.ends_with(".json"));
256        }
257
258        #[test]
259        fn local_file_ends_with_filename() {
260            let file = TextFile::from_path(PathBuf::from("/path/to/config.eure"));
261            assert!(file.ends_with("config.eure"));
262            assert!(!file.ends_with("other.eure"));
263        }
264
265        #[test]
266        fn remote_url_ends_with_extension() {
267            let file = TextFile::parse("https://example.com/schemas/user.schema.eure").unwrap();
268            assert!(file.ends_with(".schema.eure"));
269            assert!(file.ends_with(".eure"));
270            assert!(!file.ends_with(".json"));
271        }
272
273        #[test]
274        fn remote_url_ignores_query_params() {
275            // For Remote URLs, ends_with uses url.path() which excludes query params.
276            // So "https://example.com/file.eure?version=1" has path "/file.eure"
277            let file = TextFile::parse("https://example.com/file.eure?version=1").unwrap();
278            assert!(file.ends_with(".eure"));
279            assert!(!file.ends_with("?version=1"));
280        }
281
282        #[test]
283        fn remote_url_ignores_fragment() {
284            let file = TextFile::parse("https://example.com/file.eure#section").unwrap();
285            assert!(file.ends_with(".eure"));
286            assert!(!file.ends_with("#section"));
287        }
288    }
289}