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
189pub fn omena_resolver_boundary_state_for_unresolved_reference_v0(
198 source: &str,
199) -> OmenaResolverBoundaryStateV0 {
200 let kind = if source.starts_with("http://") || source.starts_with("https://") {
201 OmenaResolverErrorKindV0::NetworkForbidden
202 } else {
203 OmenaResolverErrorKindV0::Unresolved
204 };
205 let error = OmenaResolverErrorV0::new(
206 kind,
207 format!("external reference `{source}` is not canonicalizable by the omena resolver"),
208 );
209 omena_resolver_boundary_state_from_error_v0(&error)
210}
211
212#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
214#[serde(rename_all = "camelCase")]
215pub enum OmenaResolverErrorKindV0 {
216 Unresolved,
218 ExternalIgnored,
220 NetworkForbidden,
222 UnsupportedCanonicalUrl,
224 NotFound,
226}
227
228impl OmenaResolverErrorKindV0 {
229 pub const fn as_str(self) -> &'static str {
230 match self {
231 Self::Unresolved => "unresolved",
232 Self::ExternalIgnored => "externalIgnored",
233 Self::NetworkForbidden => "networkForbidden",
234 Self::UnsupportedCanonicalUrl => "unsupportedCanonicalUrl",
235 Self::NotFound => "notFound",
236 }
237 }
238}
239
240#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
242#[serde(rename_all = "camelCase")]
243pub struct OmenaResolverErrorV0 {
244 pub kind: OmenaResolverErrorKindV0,
245 pub kind_name: &'static str,
246 pub message: String,
247}
248
249impl OmenaResolverErrorV0 {
250 pub fn new(kind: OmenaResolverErrorKindV0, message: impl Into<String>) -> Self {
251 Self {
252 kind,
253 kind_name: kind.as_str(),
254 message: message.into(),
255 }
256 }
257}
258
259pub trait OmenaResolverV0 {
265 fn canonicalize(
266 &self,
267 context: &OmenaResolverReferenceContextV0,
268 raw_reference: &str,
269 ) -> Result<OmenaResolverCanonicalUrlV0, OmenaResolverErrorV0>;
270
271 fn load(
272 &self,
273 canonical_url: &OmenaResolverCanonicalUrlV0,
274 ) -> Result<OmenaResolverLoadedSourceV0, OmenaResolverErrorV0>;
275}
276
277#[derive(Debug, Clone, Default, PartialEq, Eq)]
280pub struct OmenaResolverStyleModuleSnapshotV0 {
281 pub available_style_paths: BTreeSet<String>,
282 pub file_sources: BTreeMap<String, String>,
283 pub package_manifests: Vec<OmenaResolverStylePackageManifestV0>,
284 pub bundler_path_mappings: Vec<OmenaResolverBundlerPathAliasMappingV0>,
285 pub tsconfig_path_mappings: Vec<OmenaResolverTsconfigPathMappingV0>,
286}
287
288impl OmenaResolverStyleModuleSnapshotV0 {
289 pub fn new<I, S>(paths: I) -> Self
290 where
291 I: IntoIterator<Item = S>,
292 S: Into<String>,
293 {
294 Self {
295 available_style_paths: paths.into_iter().map(Into::into).collect(),
296 ..Self::default()
297 }
298 }
299
300 pub fn with_file_source(mut self, path: impl Into<String>, source: impl Into<String>) -> Self {
301 self.file_sources.insert(path.into(), source.into());
302 self
303 }
304
305 pub fn with_package_manifests(
306 mut self,
307 manifests: Vec<OmenaResolverStylePackageManifestV0>,
308 ) -> Self {
309 self.package_manifests = manifests;
310 self
311 }
312
313 pub fn with_bundler_path_mappings(
314 mut self,
315 mappings: Vec<OmenaResolverBundlerPathAliasMappingV0>,
316 ) -> Self {
317 self.bundler_path_mappings = mappings;
318 self
319 }
320
321 pub fn with_tsconfig_path_mappings(
322 mut self,
323 mappings: Vec<OmenaResolverTsconfigPathMappingV0>,
324 ) -> Self {
325 self.tsconfig_path_mappings = mappings;
326 self
327 }
328
329 fn available_style_path_refs(&self) -> BTreeSet<&str> {
330 self.available_style_paths
331 .iter()
332 .map(String::as_str)
333 .collect()
334 }
335}
336
337impl OmenaResolverV0 for OmenaResolverStyleModuleSnapshotV0 {
338 fn canonicalize(
339 &self,
340 context: &OmenaResolverReferenceContextV0,
341 raw_reference: &str,
342 ) -> Result<OmenaResolverCanonicalUrlV0, OmenaResolverErrorV0> {
343 if raw_reference.starts_with("http://") || raw_reference.starts_with("https://") {
344 return Err(OmenaResolverErrorV0::new(
345 OmenaResolverErrorKindV0::NetworkForbidden,
346 "omena resolver canonicalization never fetches network references",
347 ));
348 }
349
350 let available_style_paths = self.available_style_path_refs();
351 let resolution = summarize_omena_resolver_style_module_resolution_with_path_mappings(
352 &context.referencing_file,
353 raw_reference,
354 &available_style_paths,
355 self.package_manifests.as_slice(),
356 self.bundler_path_mappings.as_slice(),
357 self.tsconfig_path_mappings.as_slice(),
358 );
359
360 if let Some(path) = resolution.resolved_style_path {
361 return Ok(OmenaResolverCanonicalUrlV0::workspace_style_path(&path));
362 }
363
364 let kind = if resolution.resolution_kind == "externalIgnored" {
365 OmenaResolverErrorKindV0::ExternalIgnored
366 } else {
367 OmenaResolverErrorKindV0::Unresolved
368 };
369 Err(OmenaResolverErrorV0::new(
370 kind,
371 format!(
372 "could not canonicalize `{raw_reference}` from `{}`",
373 context.referencing_file
374 ),
375 ))
376 }
377
378 fn load(
379 &self,
380 canonical_url: &OmenaResolverCanonicalUrlV0,
381 ) -> Result<OmenaResolverLoadedSourceV0, OmenaResolverErrorV0> {
382 let Some(path) = canonical_url.as_workspace_style_path() else {
383 return Err(OmenaResolverErrorV0::new(
384 OmenaResolverErrorKindV0::UnsupportedCanonicalUrl,
385 format!("unsupported canonical URL `{}`", canonical_url.url),
386 ));
387 };
388 let Some(source) = self.file_sources.get(path) else {
389 return Err(OmenaResolverErrorV0::new(
390 OmenaResolverErrorKindV0::NotFound,
391 format!("no source snapshot for `{path}`"),
392 ));
393 };
394 Ok(OmenaResolverLoadedSourceV0 {
395 canonical_url: canonical_url.clone(),
396 source: source.clone(),
397 })
398 }
399}
400
401#[cfg(test)]
402mod tests {
403 use super::*;
404
405 #[test]
406 fn snapshot_resolver_canonicalizes_and_loads_relative_style_modules() -> Result<(), String> {
407 let resolver = OmenaResolverStyleModuleSnapshotV0::new(["src/Button.module.scss"])
408 .with_file_source("src/Button.module.scss", ".button { color: red; }");
409 let context = OmenaResolverReferenceContextV0 {
410 referencing_file: "src/App.module.scss".to_string(),
411 };
412
413 let canonical = resolver
414 .canonicalize(&context, "./Button.module.scss")
415 .map_err(|error| format!("expected canonical style URL: {error:?}"))?;
416 assert_eq!(canonical.url, "workspace:///src/Button.module.scss");
417
418 let loaded = resolver
419 .load(&canonical)
420 .map_err(|error| format!("expected loaded style source: {error:?}"))?;
421 assert_eq!(loaded.source, ".button { color: red; }");
422 Ok(())
423 }
424
425 #[test]
426 fn snapshot_resolver_forbids_network_references_during_canonicalization() -> Result<(), String>
427 {
428 let resolver = OmenaResolverStyleModuleSnapshotV0::new(["src/Button.module.scss"]);
429 let context = OmenaResolverReferenceContextV0 {
430 referencing_file: "src/App.module.scss".to_string(),
431 };
432
433 let error = match resolver.canonicalize(&context, "https://example.com/reset.css") {
434 Ok(canonical) => {
435 return Err(format!(
436 "expected network reference to fail, got {canonical:?}"
437 ));
438 }
439 Err(error) => error,
440 };
441
442 assert_eq!(error.kind, OmenaResolverErrorKindV0::NetworkForbidden);
443 assert_eq!(error.kind_name, "networkForbidden");
444 Ok(())
445 }
446
447 #[test]
448 fn snapshot_resolver_reports_missing_snapshot_sources() -> Result<(), String> {
449 let resolver = OmenaResolverStyleModuleSnapshotV0::new(["src/Button.module.scss"]);
450 let context = OmenaResolverReferenceContextV0 {
451 referencing_file: "src/App.module.scss".to_string(),
452 };
453
454 let canonical = resolver
455 .canonicalize(&context, "./Button.module.scss")
456 .map_err(|error| format!("expected canonical style URL: {error:?}"))?;
457 let error = match resolver.load(&canonical) {
458 Ok(source) => return Err(format!("expected missing snapshot source, got {source:?}")),
459 Err(error) => error,
460 };
461
462 assert_eq!(error.kind, OmenaResolverErrorKindV0::NotFound);
463 Ok(())
464 }
465
466 #[test]
467 fn boundary_state_matrix_preserves_m7_external_states_and_top_semantics() {
468 let canonical = OmenaResolverCanonicalUrlV0::workspace_style_path("src/tokens.scss");
469 let states = [
470 OmenaResolverBoundaryStateV0::resolved(canonical.clone()),
471 OmenaResolverBoundaryStateV0::partial("external boundary kept in ignored mode"),
472 OmenaResolverBoundaryStateV0::stale(canonical.clone(), "lockfile hash drift"),
473 OmenaResolverBoundaryStateV0::missing(Some(canonical), "expected SIF is missing"),
474 OmenaResolverBoundaryStateV0::unresolved("specifier did not resolve"),
475 ];
476
477 assert_eq!(states[0].state_name, "resolved");
478 assert_eq!(states[0].top_name, "topOpaque");
479 assert_eq!(states[1].state_name, "partial");
480 assert_eq!(states[1].top_name, "topAny");
481 assert_eq!(states[2].state_name, "stale");
482 assert_eq!(states[2].top_name, "topAny");
483 assert_eq!(states[3].state_name, "missing");
484 assert_eq!(states[3].top_name, "topAny");
485 assert_eq!(states[4].state_name, "unresolved");
486 assert_eq!(states[4].top_name, "topAny");
487 }
488
489 #[test]
490 fn boundary_state_for_unresolved_reference_folds_to_unresolved_via_error_channel() {
491 let bare = omena_resolver_boundary_state_for_unresolved_reference_v0("bootstrap");
493 assert_eq!(bare.state, OmenaResolverBoundaryStateKindV0::Unresolved);
494 assert_eq!(bare.top, OmenaResolverBoundaryTopV0::TopAny);
495 assert!(bare.reason.contains("bootstrap"));
496
497 let network =
499 omena_resolver_boundary_state_for_unresolved_reference_v0("https://cdn.example/x.scss");
500 assert_eq!(network.state, OmenaResolverBoundaryStateKindV0::Unresolved);
501 assert_eq!(network.top, OmenaResolverBoundaryTopV0::TopAny);
502 }
503
504 #[test]
505 fn boundary_state_maps_existing_external_ignored_error_to_partial() {
506 let error = OmenaResolverErrorV0::new(
507 OmenaResolverErrorKindV0::ExternalIgnored,
508 "sass:map remains external in compatibility mode",
509 );
510
511 let state = omena_resolver_boundary_state_from_error_v0(&error);
512
513 assert_eq!(state.state, OmenaResolverBoundaryStateKindV0::Partial);
514 assert_eq!(state.top, OmenaResolverBoundaryTopV0::TopAny);
515 assert_eq!(
516 state.reason,
517 "sass:map remains external in compatibility mode"
518 );
519 }
520
521 #[test]
522 fn snapshot_resolver_preserves_tsconfig_path_mapping_resolution() -> Result<(), String> {
523 let resolver = OmenaResolverStyleModuleSnapshotV0::new([
524 "/fake/workspace/src/styles/Button.module.scss",
525 ])
526 .with_tsconfig_path_mappings(vec![OmenaResolverTsconfigPathMappingV0 {
527 base_path: "/fake/workspace".to_string(),
528 pattern: "@styles/*".to_string(),
529 target_patterns: vec!["src/styles/*".to_string()],
530 }]);
531 let context = OmenaResolverReferenceContextV0 {
532 referencing_file: "/fake/workspace/src/App.module.scss".to_string(),
533 };
534
535 let canonical = resolver
536 .canonicalize(&context, "@styles/Button")
537 .map_err(|error| format!("expected canonical style URL: {error:?}"))?;
538
539 assert_eq!(
540 canonical.as_workspace_style_path(),
541 Some("/fake/workspace/src/styles/Button.module.scss")
542 );
543 Ok(())
544 }
545
546 #[test]
547 fn snapshot_resolver_preserves_bundler_path_mapping_precedence() -> Result<(), String> {
548 let resolver = OmenaResolverStyleModuleSnapshotV0::new([
549 "/fake/workspace/src/bundler/Button.module.scss",
550 "/fake/workspace/src/tsconfig/Button.module.scss",
551 ])
552 .with_bundler_path_mappings(vec![OmenaResolverBundlerPathAliasMappingV0 {
553 pattern: "@styles".to_string(),
554 target_path: "/fake/workspace/src/bundler".to_string(),
555 }])
556 .with_tsconfig_path_mappings(vec![OmenaResolverTsconfigPathMappingV0 {
557 base_path: "/fake/workspace".to_string(),
558 pattern: "@styles/*".to_string(),
559 target_patterns: vec!["src/tsconfig/*".to_string()],
560 }]);
561 let context = OmenaResolverReferenceContextV0 {
562 referencing_file: "/fake/workspace/src/App.module.scss".to_string(),
563 };
564
565 let canonical = resolver
566 .canonicalize(&context, "@styles/Button")
567 .map_err(|error| format!("expected canonical style URL: {error:?}"))?;
568
569 assert_eq!(
570 canonical.as_workspace_style_path(),
571 Some("/fake/workspace/src/bundler/Button.module.scss")
572 );
573 Ok(())
574 }
575
576 #[test]
577 fn snapshot_resolver_preserves_package_manifest_resolution() -> Result<(), String> {
578 let resolver = OmenaResolverStyleModuleSnapshotV0::new([
579 "/fake/workspace/node_modules/@design/tokens/dist/theme.css",
580 ])
581 .with_package_manifests(vec![OmenaResolverStylePackageManifestV0 {
582 package_json_path: "/fake/workspace/node_modules/@design/tokens/package.json"
583 .to_string(),
584 package_json_source: r#"{"exports":{"./theme":{"style":"./dist/theme.css"}}}"#
585 .to_string(),
586 }]);
587 let context = OmenaResolverReferenceContextV0 {
588 referencing_file: "/fake/workspace/src/App.module.scss".to_string(),
589 };
590
591 let canonical = resolver
592 .canonicalize(&context, "@design/tokens/theme")
593 .map_err(|error| format!("expected canonical style URL: {error:?}"))?;
594
595 assert_eq!(
596 canonical.as_workspace_style_path(),
597 Some("/fake/workspace/node_modules/@design/tokens/dist/theme.css")
598 );
599 Ok(())
600 }
601}