1use super::*;
2
3const WORKSPACE_STYLE_URL_PREFIX: &str = "workspace:///";
4
5#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
7#[serde(rename_all = "camelCase")]
8pub struct OmenaResolverReferenceContextV0 {
9 pub referencing_file: String,
11}
12
13#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
15#[serde(rename_all = "camelCase")]
16pub struct OmenaResolverCanonicalUrlV0 {
17 pub url: String,
19}
20
21impl OmenaResolverCanonicalUrlV0 {
22 pub fn workspace_style_path(path: &str) -> Self {
24 Self {
25 url: format!(
26 "{WORKSPACE_STYLE_URL_PREFIX}{}",
27 normalize_style_path(PathBuf::from(path))
28 ),
29 }
30 }
31
32 pub fn as_workspace_style_path(&self) -> Option<&str> {
35 self.url.strip_prefix(WORKSPACE_STYLE_URL_PREFIX)
36 }
37}
38
39#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
41#[serde(rename_all = "camelCase")]
42pub struct OmenaResolverLoadedSourceV0 {
43 pub canonical_url: OmenaResolverCanonicalUrlV0,
45 pub source: String,
47}
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
51#[serde(rename_all = "camelCase")]
52pub enum OmenaResolverBoundaryStateKindV0 {
53 Resolved,
54 Partial,
55 Stale,
56 Missing,
57 Unresolved,
58}
59
60impl OmenaResolverBoundaryStateKindV0 {
61 pub const fn as_str(self) -> &'static str {
62 match self {
63 Self::Resolved => "resolved",
64 Self::Partial => "partial",
65 Self::Stale => "stale",
66 Self::Missing => "missing",
67 Self::Unresolved => "unresolved",
68 }
69 }
70}
71
72#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
74#[serde(rename_all = "camelCase")]
75pub enum OmenaResolverBoundaryTopV0 {
76 TopOpaque,
79 TopAny,
82}
83
84impl OmenaResolverBoundaryTopV0 {
85 pub const fn as_str(self) -> &'static str {
86 match self {
87 Self::TopOpaque => "topOpaque",
88 Self::TopAny => "topAny",
89 }
90 }
91}
92
93#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
95#[serde(rename_all = "camelCase")]
96pub struct OmenaResolverBoundaryStateV0 {
97 pub state: OmenaResolverBoundaryStateKindV0,
98 pub state_name: &'static str,
99 pub top: OmenaResolverBoundaryTopV0,
100 pub top_name: &'static str,
101 pub canonical_url: Option<OmenaResolverCanonicalUrlV0>,
102 pub reason: String,
103}
104
105impl OmenaResolverBoundaryStateV0 {
106 pub fn resolved(canonical_url: OmenaResolverCanonicalUrlV0) -> Self {
107 Self::new(
108 OmenaResolverBoundaryStateKindV0::Resolved,
109 OmenaResolverBoundaryTopV0::TopOpaque,
110 Some(canonical_url),
111 "resolved local or SIF-backed interface",
112 )
113 }
114
115 pub fn partial(reason: impl Into<String>) -> Self {
116 Self::new(
117 OmenaResolverBoundaryStateKindV0::Partial,
118 OmenaResolverBoundaryTopV0::TopAny,
119 None,
120 reason,
121 )
122 }
123
124 pub fn stale(canonical_url: OmenaResolverCanonicalUrlV0, reason: impl Into<String>) -> Self {
125 Self::new(
126 OmenaResolverBoundaryStateKindV0::Stale,
127 OmenaResolverBoundaryTopV0::TopAny,
128 Some(canonical_url),
129 reason,
130 )
131 }
132
133 pub fn missing(
134 canonical_url: Option<OmenaResolverCanonicalUrlV0>,
135 reason: impl Into<String>,
136 ) -> Self {
137 Self::new(
138 OmenaResolverBoundaryStateKindV0::Missing,
139 OmenaResolverBoundaryTopV0::TopAny,
140 canonical_url,
141 reason,
142 )
143 }
144
145 pub fn unresolved(reason: impl Into<String>) -> Self {
146 Self::new(
147 OmenaResolverBoundaryStateKindV0::Unresolved,
148 OmenaResolverBoundaryTopV0::TopAny,
149 None,
150 reason,
151 )
152 }
153
154 fn new(
155 state: OmenaResolverBoundaryStateKindV0,
156 top: OmenaResolverBoundaryTopV0,
157 canonical_url: Option<OmenaResolverCanonicalUrlV0>,
158 reason: impl Into<String>,
159 ) -> Self {
160 Self {
161 state,
162 state_name: state.as_str(),
163 top,
164 top_name: top.as_str(),
165 canonical_url,
166 reason: reason.into(),
167 }
168 }
169}
170
171pub fn omena_resolver_boundary_state_from_error_v0(
172 error: &OmenaResolverErrorV0,
173) -> OmenaResolverBoundaryStateV0 {
174 match error.kind {
175 OmenaResolverErrorKindV0::ExternalIgnored => {
176 OmenaResolverBoundaryStateV0::partial(error.message.clone())
177 }
178 OmenaResolverErrorKindV0::NotFound => {
179 OmenaResolverBoundaryStateV0::missing(None, error.message.clone())
180 }
181 OmenaResolverErrorKindV0::Unresolved
182 | OmenaResolverErrorKindV0::NetworkForbidden
183 | OmenaResolverErrorKindV0::UnsupportedCanonicalUrl => {
184 OmenaResolverBoundaryStateV0::unresolved(error.message.clone())
185 }
186 }
187}
188
189#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
191#[serde(rename_all = "camelCase")]
192pub enum OmenaResolverErrorKindV0 {
193 Unresolved,
195 ExternalIgnored,
197 NetworkForbidden,
199 UnsupportedCanonicalUrl,
201 NotFound,
203}
204
205impl OmenaResolverErrorKindV0 {
206 pub const fn as_str(self) -> &'static str {
207 match self {
208 Self::Unresolved => "unresolved",
209 Self::ExternalIgnored => "externalIgnored",
210 Self::NetworkForbidden => "networkForbidden",
211 Self::UnsupportedCanonicalUrl => "unsupportedCanonicalUrl",
212 Self::NotFound => "notFound",
213 }
214 }
215}
216
217#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
219#[serde(rename_all = "camelCase")]
220pub struct OmenaResolverErrorV0 {
221 pub kind: OmenaResolverErrorKindV0,
222 pub kind_name: &'static str,
223 pub message: String,
224}
225
226impl OmenaResolverErrorV0 {
227 pub fn new(kind: OmenaResolverErrorKindV0, message: impl Into<String>) -> Self {
228 Self {
229 kind,
230 kind_name: kind.as_str(),
231 message: message.into(),
232 }
233 }
234}
235
236pub trait OmenaResolverV0 {
242 fn canonicalize(
243 &self,
244 context: &OmenaResolverReferenceContextV0,
245 raw_reference: &str,
246 ) -> Result<OmenaResolverCanonicalUrlV0, OmenaResolverErrorV0>;
247
248 fn load(
249 &self,
250 canonical_url: &OmenaResolverCanonicalUrlV0,
251 ) -> Result<OmenaResolverLoadedSourceV0, OmenaResolverErrorV0>;
252}
253
254#[derive(Debug, Clone, Default, PartialEq, Eq)]
257pub struct OmenaResolverStyleModuleSnapshotV0 {
258 pub available_style_paths: BTreeSet<String>,
259 pub file_sources: BTreeMap<String, String>,
260 pub package_manifests: Vec<OmenaResolverStylePackageManifestV0>,
261 pub bundler_path_mappings: Vec<OmenaResolverBundlerPathAliasMappingV0>,
262 pub tsconfig_path_mappings: Vec<OmenaResolverTsconfigPathMappingV0>,
263}
264
265impl OmenaResolverStyleModuleSnapshotV0 {
266 pub fn new<I, S>(paths: I) -> Self
267 where
268 I: IntoIterator<Item = S>,
269 S: Into<String>,
270 {
271 Self {
272 available_style_paths: paths.into_iter().map(Into::into).collect(),
273 ..Self::default()
274 }
275 }
276
277 pub fn with_file_source(mut self, path: impl Into<String>, source: impl Into<String>) -> Self {
278 self.file_sources.insert(path.into(), source.into());
279 self
280 }
281
282 pub fn with_package_manifests(
283 mut self,
284 manifests: Vec<OmenaResolverStylePackageManifestV0>,
285 ) -> Self {
286 self.package_manifests = manifests;
287 self
288 }
289
290 pub fn with_bundler_path_mappings(
291 mut self,
292 mappings: Vec<OmenaResolverBundlerPathAliasMappingV0>,
293 ) -> Self {
294 self.bundler_path_mappings = mappings;
295 self
296 }
297
298 pub fn with_tsconfig_path_mappings(
299 mut self,
300 mappings: Vec<OmenaResolverTsconfigPathMappingV0>,
301 ) -> Self {
302 self.tsconfig_path_mappings = mappings;
303 self
304 }
305
306 fn available_style_path_refs(&self) -> BTreeSet<&str> {
307 self.available_style_paths
308 .iter()
309 .map(String::as_str)
310 .collect()
311 }
312}
313
314impl OmenaResolverV0 for OmenaResolverStyleModuleSnapshotV0 {
315 fn canonicalize(
316 &self,
317 context: &OmenaResolverReferenceContextV0,
318 raw_reference: &str,
319 ) -> Result<OmenaResolverCanonicalUrlV0, OmenaResolverErrorV0> {
320 if raw_reference.starts_with("http://") || raw_reference.starts_with("https://") {
321 return Err(OmenaResolverErrorV0::new(
322 OmenaResolverErrorKindV0::NetworkForbidden,
323 "omena resolver canonicalization never fetches network references",
324 ));
325 }
326
327 let available_style_paths = self.available_style_path_refs();
328 let resolution = summarize_omena_resolver_style_module_resolution_with_path_mappings(
329 &context.referencing_file,
330 raw_reference,
331 &available_style_paths,
332 self.package_manifests.as_slice(),
333 self.bundler_path_mappings.as_slice(),
334 self.tsconfig_path_mappings.as_slice(),
335 );
336
337 if let Some(path) = resolution.resolved_style_path {
338 return Ok(OmenaResolverCanonicalUrlV0::workspace_style_path(&path));
339 }
340
341 let kind = if resolution.resolution_kind == "externalIgnored" {
342 OmenaResolverErrorKindV0::ExternalIgnored
343 } else {
344 OmenaResolverErrorKindV0::Unresolved
345 };
346 Err(OmenaResolverErrorV0::new(
347 kind,
348 format!(
349 "could not canonicalize `{raw_reference}` from `{}`",
350 context.referencing_file
351 ),
352 ))
353 }
354
355 fn load(
356 &self,
357 canonical_url: &OmenaResolverCanonicalUrlV0,
358 ) -> Result<OmenaResolverLoadedSourceV0, OmenaResolverErrorV0> {
359 let Some(path) = canonical_url.as_workspace_style_path() else {
360 return Err(OmenaResolverErrorV0::new(
361 OmenaResolverErrorKindV0::UnsupportedCanonicalUrl,
362 format!("unsupported canonical URL `{}`", canonical_url.url),
363 ));
364 };
365 let Some(source) = self.file_sources.get(path) else {
366 return Err(OmenaResolverErrorV0::new(
367 OmenaResolverErrorKindV0::NotFound,
368 format!("no source snapshot for `{path}`"),
369 ));
370 };
371 Ok(OmenaResolverLoadedSourceV0 {
372 canonical_url: canonical_url.clone(),
373 source: source.clone(),
374 })
375 }
376}
377
378#[cfg(test)]
379mod tests {
380 use super::*;
381
382 #[test]
383 fn snapshot_resolver_canonicalizes_and_loads_relative_style_modules() -> Result<(), String> {
384 let resolver = OmenaResolverStyleModuleSnapshotV0::new(["src/Button.module.scss"])
385 .with_file_source("src/Button.module.scss", ".button { color: red; }");
386 let context = OmenaResolverReferenceContextV0 {
387 referencing_file: "src/App.module.scss".to_string(),
388 };
389
390 let canonical = resolver
391 .canonicalize(&context, "./Button.module.scss")
392 .map_err(|error| format!("expected canonical style URL: {error:?}"))?;
393 assert_eq!(canonical.url, "workspace:///src/Button.module.scss");
394
395 let loaded = resolver
396 .load(&canonical)
397 .map_err(|error| format!("expected loaded style source: {error:?}"))?;
398 assert_eq!(loaded.source, ".button { color: red; }");
399 Ok(())
400 }
401
402 #[test]
403 fn snapshot_resolver_forbids_network_references_during_canonicalization() -> Result<(), String>
404 {
405 let resolver = OmenaResolverStyleModuleSnapshotV0::new(["src/Button.module.scss"]);
406 let context = OmenaResolverReferenceContextV0 {
407 referencing_file: "src/App.module.scss".to_string(),
408 };
409
410 let error = match resolver.canonicalize(&context, "https://example.com/reset.css") {
411 Ok(canonical) => {
412 return Err(format!(
413 "expected network reference to fail, got {canonical:?}"
414 ));
415 }
416 Err(error) => error,
417 };
418
419 assert_eq!(error.kind, OmenaResolverErrorKindV0::NetworkForbidden);
420 assert_eq!(error.kind_name, "networkForbidden");
421 Ok(())
422 }
423
424 #[test]
425 fn snapshot_resolver_reports_missing_snapshot_sources() -> Result<(), String> {
426 let resolver = OmenaResolverStyleModuleSnapshotV0::new(["src/Button.module.scss"]);
427 let context = OmenaResolverReferenceContextV0 {
428 referencing_file: "src/App.module.scss".to_string(),
429 };
430
431 let canonical = resolver
432 .canonicalize(&context, "./Button.module.scss")
433 .map_err(|error| format!("expected canonical style URL: {error:?}"))?;
434 let error = match resolver.load(&canonical) {
435 Ok(source) => return Err(format!("expected missing snapshot source, got {source:?}")),
436 Err(error) => error,
437 };
438
439 assert_eq!(error.kind, OmenaResolverErrorKindV0::NotFound);
440 Ok(())
441 }
442
443 #[test]
444 fn boundary_state_matrix_preserves_m7_external_states_and_top_semantics() {
445 let canonical = OmenaResolverCanonicalUrlV0::workspace_style_path("src/tokens.scss");
446 let states = [
447 OmenaResolverBoundaryStateV0::resolved(canonical.clone()),
448 OmenaResolverBoundaryStateV0::partial("external boundary kept in ignored mode"),
449 OmenaResolverBoundaryStateV0::stale(canonical.clone(), "lockfile hash drift"),
450 OmenaResolverBoundaryStateV0::missing(Some(canonical), "expected SIF is missing"),
451 OmenaResolverBoundaryStateV0::unresolved("specifier did not resolve"),
452 ];
453
454 assert_eq!(states[0].state_name, "resolved");
455 assert_eq!(states[0].top_name, "topOpaque");
456 assert_eq!(states[1].state_name, "partial");
457 assert_eq!(states[1].top_name, "topAny");
458 assert_eq!(states[2].state_name, "stale");
459 assert_eq!(states[2].top_name, "topAny");
460 assert_eq!(states[3].state_name, "missing");
461 assert_eq!(states[3].top_name, "topAny");
462 assert_eq!(states[4].state_name, "unresolved");
463 assert_eq!(states[4].top_name, "topAny");
464 }
465
466 #[test]
467 fn boundary_state_maps_existing_external_ignored_error_to_partial() {
468 let error = OmenaResolverErrorV0::new(
469 OmenaResolverErrorKindV0::ExternalIgnored,
470 "sass:map remains external in compatibility mode",
471 );
472
473 let state = omena_resolver_boundary_state_from_error_v0(&error);
474
475 assert_eq!(state.state, OmenaResolverBoundaryStateKindV0::Partial);
476 assert_eq!(state.top, OmenaResolverBoundaryTopV0::TopAny);
477 assert_eq!(
478 state.reason,
479 "sass:map remains external in compatibility mode"
480 );
481 }
482
483 #[test]
484 fn snapshot_resolver_preserves_tsconfig_path_mapping_resolution() -> Result<(), String> {
485 let resolver = OmenaResolverStyleModuleSnapshotV0::new([
486 "/fake/workspace/src/styles/Button.module.scss",
487 ])
488 .with_tsconfig_path_mappings(vec![OmenaResolverTsconfigPathMappingV0 {
489 base_path: "/fake/workspace".to_string(),
490 pattern: "@styles/*".to_string(),
491 target_patterns: vec!["src/styles/*".to_string()],
492 }]);
493 let context = OmenaResolverReferenceContextV0 {
494 referencing_file: "/fake/workspace/src/App.module.scss".to_string(),
495 };
496
497 let canonical = resolver
498 .canonicalize(&context, "@styles/Button")
499 .map_err(|error| format!("expected canonical style URL: {error:?}"))?;
500
501 assert_eq!(
502 canonical.as_workspace_style_path(),
503 Some("/fake/workspace/src/styles/Button.module.scss")
504 );
505 Ok(())
506 }
507
508 #[test]
509 fn snapshot_resolver_preserves_bundler_path_mapping_precedence() -> Result<(), String> {
510 let resolver = OmenaResolverStyleModuleSnapshotV0::new([
511 "/fake/workspace/src/bundler/Button.module.scss",
512 "/fake/workspace/src/tsconfig/Button.module.scss",
513 ])
514 .with_bundler_path_mappings(vec![OmenaResolverBundlerPathAliasMappingV0 {
515 pattern: "@styles".to_string(),
516 target_path: "/fake/workspace/src/bundler".to_string(),
517 }])
518 .with_tsconfig_path_mappings(vec![OmenaResolverTsconfigPathMappingV0 {
519 base_path: "/fake/workspace".to_string(),
520 pattern: "@styles/*".to_string(),
521 target_patterns: vec!["src/tsconfig/*".to_string()],
522 }]);
523 let context = OmenaResolverReferenceContextV0 {
524 referencing_file: "/fake/workspace/src/App.module.scss".to_string(),
525 };
526
527 let canonical = resolver
528 .canonicalize(&context, "@styles/Button")
529 .map_err(|error| format!("expected canonical style URL: {error:?}"))?;
530
531 assert_eq!(
532 canonical.as_workspace_style_path(),
533 Some("/fake/workspace/src/bundler/Button.module.scss")
534 );
535 Ok(())
536 }
537
538 #[test]
539 fn snapshot_resolver_preserves_package_manifest_resolution() -> Result<(), String> {
540 let resolver = OmenaResolverStyleModuleSnapshotV0::new([
541 "/fake/workspace/node_modules/@design/tokens/dist/theme.css",
542 ])
543 .with_package_manifests(vec![OmenaResolverStylePackageManifestV0 {
544 package_json_path: "/fake/workspace/node_modules/@design/tokens/package.json"
545 .to_string(),
546 package_json_source: r#"{"exports":{"./theme":{"style":"./dist/theme.css"}}}"#
547 .to_string(),
548 }]);
549 let context = OmenaResolverReferenceContextV0 {
550 referencing_file: "/fake/workspace/src/App.module.scss".to_string(),
551 };
552
553 let canonical = resolver
554 .canonicalize(&context, "@design/tokens/theme")
555 .map_err(|error| format!("expected canonical style URL: {error:?}"))?;
556
557 assert_eq!(
558 canonical.as_workspace_style_path(),
559 Some("/fake/workspace/node_modules/@design/tokens/dist/theme.css")
560 );
561 Ok(())
562 }
563}