Skip to main content

omena_resolver/
protocol.rs

1use super::*;
2
3const WORKSPACE_STYLE_URL_PREFIX: &str = "workspace:///";
4
5/// Reference context for the shared omena resolver protocol.
6#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
7#[serde(rename_all = "camelCase")]
8pub struct OmenaResolverReferenceContextV0 {
9    /// Workspace-relative style file that contains the reference.
10    pub referencing_file: String,
11}
12
13/// Canonical URL returned by `OmenaResolverV0::canonicalize`.
14#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
15#[serde(rename_all = "camelCase")]
16pub struct OmenaResolverCanonicalUrlV0 {
17    /// Stable canonical URL used as the resolver/SIF/lockfile key.
18    pub url: String,
19}
20
21impl OmenaResolverCanonicalUrlV0 {
22    /// Build a workspace-local canonical URL for a style source path.
23    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    /// Return the workspace-local style path when this URL uses omena's
33    /// workspace scheme.
34    pub fn as_workspace_style_path(&self) -> Option<&str> {
35        self.url.strip_prefix(WORKSPACE_STYLE_URL_PREFIX)
36    }
37}
38
39/// Successful source load result for a canonical URL.
40#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
41#[serde(rename_all = "camelCase")]
42pub struct OmenaResolverLoadedSourceV0 {
43    /// Canonical URL that was loaded.
44    pub canonical_url: OmenaResolverCanonicalUrlV0,
45    /// Loaded UTF-8 source text.
46    pub source: String,
47}
48
49/// Five-state external boundary model used by the SIF migration path.
50#[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/// Abstract value top used when a boundary is not fully resolved.
73#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
74#[serde(rename_all = "camelCase")]
75pub enum OmenaResolverBoundaryTopV0 {
76    /// The interface is known enough to keep diagnostics scoped to declared
77    /// exported symbols.
78    TopOpaque,
79    /// The interface is unknown and diagnostics must avoid false positives
80    /// over potentially valid external symbols.
81    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/// Boundary-state witness for external-reference diagnostics.
94#[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
171#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
172#[serde(rename_all = "camelCase")]
173pub struct OmenaResolverStyleResolutionPolicyV0 {
174    pub schema_version: &'static str,
175    pub product: &'static str,
176    pub candidate_strategy: &'static str,
177    pub network_access: &'static str,
178    pub steps: Vec<OmenaResolverStyleResolutionPolicyStepV0>,
179    pub ready_surfaces: Vec<&'static str>,
180}
181
182#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
183#[serde(rename_all = "camelCase")]
184pub struct OmenaResolverStyleResolutionPolicyStepV0 {
185    pub order: u8,
186    pub key: &'static str,
187    pub applies_to: &'static str,
188    pub precedence: &'static str,
189    pub candidate_semantics: &'static str,
190}
191
192pub fn omena_resolver_boundary_state_from_error_v0(
193    error: &OmenaResolverErrorV0,
194) -> OmenaResolverBoundaryStateV0 {
195    match error.kind {
196        OmenaResolverErrorKindV0::ExternalIgnored => {
197            OmenaResolverBoundaryStateV0::partial(error.message.clone())
198        }
199        OmenaResolverErrorKindV0::NotFound => {
200            OmenaResolverBoundaryStateV0::missing(None, error.message.clone())
201        }
202        OmenaResolverErrorKindV0::Unresolved
203        | OmenaResolverErrorKindV0::NetworkForbidden
204        | OmenaResolverErrorKindV0::UnsupportedCanonicalUrl => {
205            OmenaResolverBoundaryStateV0::unresolved(error.message.clone())
206        }
207    }
208}
209
210/// Surface the resolver-error channel for a reference whose canonical URL could not be
211/// canonicalized, then fold it onto the boundary lattice's `Unresolved` state (#34).
212///
213/// This is the cross-crate hop that lets the diagnostics layer reach the fifth boundary
214/// state without re-implementing the error taxonomy: a network reference is
215/// `NetworkForbidden` (the resolver never fetches it), and any other non-canonicalizable
216/// specifier is `Unresolved`. Both fold to a `top == TopAny`, `state == Unresolved`
217/// witness via the shared `omena_resolver_boundary_state_from_error_v0` mapper.
218pub fn omena_resolver_boundary_state_for_unresolved_reference_v0(
219    source: &str,
220) -> OmenaResolverBoundaryStateV0 {
221    let kind = if source.starts_with("http://") || source.starts_with("https://") {
222        OmenaResolverErrorKindV0::NetworkForbidden
223    } else {
224        OmenaResolverErrorKindV0::Unresolved
225    };
226    let error = OmenaResolverErrorV0::new(
227        kind,
228        format!("external reference `{source}` is not canonicalizable by the omena resolver"),
229    );
230    omena_resolver_boundary_state_from_error_v0(&error)
231}
232
233/// Error family for the shared resolver protocol.
234#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
235#[serde(rename_all = "camelCase")]
236pub enum OmenaResolverErrorKindV0 {
237    /// The reference is not resolvable from the current workspace snapshot.
238    Unresolved,
239    /// The reference is intentionally left at the existing external boundary.
240    ExternalIgnored,
241    /// Network references are never fetched by omena's resolver protocol.
242    NetworkForbidden,
243    /// The canonical URL is not loadable by this resolver implementation.
244    UnsupportedCanonicalUrl,
245    /// The canonical URL is valid but no local source is available.
246    NotFound,
247}
248
249impl OmenaResolverErrorKindV0 {
250    pub const fn as_str(self) -> &'static str {
251        match self {
252            Self::Unresolved => "unresolved",
253            Self::ExternalIgnored => "externalIgnored",
254            Self::NetworkForbidden => "networkForbidden",
255            Self::UnsupportedCanonicalUrl => "unsupportedCanonicalUrl",
256            Self::NotFound => "notFound",
257        }
258    }
259}
260
261/// Resolver protocol error.
262#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
263#[serde(rename_all = "camelCase")]
264pub struct OmenaResolverErrorV0 {
265    pub kind: OmenaResolverErrorKindV0,
266    pub kind_name: &'static str,
267    pub message: String,
268}
269
270impl OmenaResolverErrorV0 {
271    pub fn new(kind: OmenaResolverErrorKindV0, message: impl Into<String>) -> Self {
272        Self {
273            kind,
274            kind_name: kind.as_str(),
275            message: message.into(),
276        }
277    }
278}
279
280/// Shared resolver protocol for CLI, LSP, fixture, and query paths.
281///
282/// `canonicalize` must be deterministic over an immutable workspace snapshot
283/// and must not perform filesystem or network I/O. `load` may be implemented
284/// by local-disk-backed resolvers, but it must never fetch from the network.
285pub trait OmenaResolverV0 {
286    fn canonicalize(
287        &self,
288        context: &OmenaResolverReferenceContextV0,
289        raw_reference: &str,
290    ) -> Result<OmenaResolverCanonicalUrlV0, OmenaResolverErrorV0>;
291
292    fn load(
293        &self,
294        canonical_url: &OmenaResolverCanonicalUrlV0,
295    ) -> Result<OmenaResolverLoadedSourceV0, OmenaResolverErrorV0>;
296}
297
298/// Snapshot-backed resolver that adapts today's style module resolver to the
299/// RFC 0004 protocol without adding I/O to canonicalization.
300#[derive(Debug, Clone, Default, PartialEq, Eq)]
301pub struct OmenaResolverStyleModuleSnapshotV0 {
302    pub available_style_paths: BTreeSet<String>,
303    pub file_sources: BTreeMap<String, String>,
304    pub package_manifests: Vec<OmenaResolverStylePackageManifestV0>,
305    pub bundler_path_mappings: Vec<OmenaResolverBundlerPathAliasMappingV0>,
306    pub tsconfig_path_mappings: Vec<OmenaResolverTsconfigPathMappingV0>,
307}
308
309impl OmenaResolverStyleModuleSnapshotV0 {
310    pub fn new<I, S>(paths: I) -> Self
311    where
312        I: IntoIterator<Item = S>,
313        S: Into<String>,
314    {
315        Self {
316            available_style_paths: paths.into_iter().map(Into::into).collect(),
317            ..Self::default()
318        }
319    }
320
321    pub fn with_file_source(mut self, path: impl Into<String>, source: impl Into<String>) -> Self {
322        self.file_sources.insert(path.into(), source.into());
323        self
324    }
325
326    pub fn with_package_manifests(
327        mut self,
328        manifests: Vec<OmenaResolverStylePackageManifestV0>,
329    ) -> Self {
330        self.package_manifests = manifests;
331        self
332    }
333
334    pub fn with_bundler_path_mappings(
335        mut self,
336        mappings: Vec<OmenaResolverBundlerPathAliasMappingV0>,
337    ) -> Self {
338        self.bundler_path_mappings = mappings;
339        self
340    }
341
342    pub fn with_tsconfig_path_mappings(
343        mut self,
344        mappings: Vec<OmenaResolverTsconfigPathMappingV0>,
345    ) -> Self {
346        self.tsconfig_path_mappings = mappings;
347        self
348    }
349
350    fn available_style_path_refs(&self) -> BTreeSet<&str> {
351        self.available_style_paths
352            .iter()
353            .map(String::as_str)
354            .collect()
355    }
356}
357
358impl OmenaResolverV0 for OmenaResolverStyleModuleSnapshotV0 {
359    fn canonicalize(
360        &self,
361        context: &OmenaResolverReferenceContextV0,
362        raw_reference: &str,
363    ) -> Result<OmenaResolverCanonicalUrlV0, OmenaResolverErrorV0> {
364        if raw_reference.starts_with("http://") || raw_reference.starts_with("https://") {
365            return Err(OmenaResolverErrorV0::new(
366                OmenaResolverErrorKindV0::NetworkForbidden,
367                "omena resolver canonicalization never fetches network references",
368            ));
369        }
370
371        let available_style_paths = self.available_style_path_refs();
372        let resolution = summarize_omena_resolver_style_module_resolution_with_path_mappings(
373            &context.referencing_file,
374            raw_reference,
375            &available_style_paths,
376            self.package_manifests.as_slice(),
377            self.bundler_path_mappings.as_slice(),
378            self.tsconfig_path_mappings.as_slice(),
379        );
380
381        if let Some(path) = resolution.resolved_style_path {
382            return Ok(OmenaResolverCanonicalUrlV0::workspace_style_path(&path));
383        }
384
385        let kind = if resolution.resolution_kind == "externalIgnored" {
386            OmenaResolverErrorKindV0::ExternalIgnored
387        } else {
388            OmenaResolverErrorKindV0::Unresolved
389        };
390        Err(OmenaResolverErrorV0::new(
391            kind,
392            format!(
393                "could not canonicalize `{raw_reference}` from `{}`",
394                context.referencing_file
395            ),
396        ))
397    }
398
399    fn load(
400        &self,
401        canonical_url: &OmenaResolverCanonicalUrlV0,
402    ) -> Result<OmenaResolverLoadedSourceV0, OmenaResolverErrorV0> {
403        let Some(path) = canonical_url.as_workspace_style_path() else {
404            return Err(OmenaResolverErrorV0::new(
405                OmenaResolverErrorKindV0::UnsupportedCanonicalUrl,
406                format!("unsupported canonical URL `{}`", canonical_url.url),
407            ));
408        };
409        let Some(source) = self.file_sources.get(path) else {
410            return Err(OmenaResolverErrorV0::new(
411                OmenaResolverErrorKindV0::NotFound,
412                format!("no source snapshot for `{path}`"),
413            ));
414        };
415        Ok(OmenaResolverLoadedSourceV0 {
416            canonical_url: canonical_url.clone(),
417            source: source.clone(),
418        })
419    }
420}
421
422#[cfg(test)]
423mod tests {
424    use super::*;
425
426    #[test]
427    fn snapshot_resolver_canonicalizes_and_loads_relative_style_modules() -> Result<(), String> {
428        let resolver = OmenaResolverStyleModuleSnapshotV0::new(["src/Button.module.scss"])
429            .with_file_source("src/Button.module.scss", ".button { color: red; }");
430        let context = OmenaResolverReferenceContextV0 {
431            referencing_file: "src/App.module.scss".to_string(),
432        };
433
434        let canonical = resolver
435            .canonicalize(&context, "./Button.module.scss")
436            .map_err(|error| format!("expected canonical style URL: {error:?}"))?;
437        assert_eq!(canonical.url, "workspace:///src/Button.module.scss");
438
439        let loaded = resolver
440            .load(&canonical)
441            .map_err(|error| format!("expected loaded style source: {error:?}"))?;
442        assert_eq!(loaded.source, ".button { color: red; }");
443        Ok(())
444    }
445
446    #[test]
447    fn snapshot_resolver_forbids_network_references_during_canonicalization() -> Result<(), String>
448    {
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 error = match resolver.canonicalize(&context, "https://example.com/reset.css") {
455            Ok(canonical) => {
456                return Err(format!(
457                    "expected network reference to fail, got {canonical:?}"
458                ));
459            }
460            Err(error) => error,
461        };
462
463        assert_eq!(error.kind, OmenaResolverErrorKindV0::NetworkForbidden);
464        assert_eq!(error.kind_name, "networkForbidden");
465        Ok(())
466    }
467
468    #[test]
469    fn snapshot_resolver_reports_missing_snapshot_sources() -> Result<(), String> {
470        let resolver = OmenaResolverStyleModuleSnapshotV0::new(["src/Button.module.scss"]);
471        let context = OmenaResolverReferenceContextV0 {
472            referencing_file: "src/App.module.scss".to_string(),
473        };
474
475        let canonical = resolver
476            .canonicalize(&context, "./Button.module.scss")
477            .map_err(|error| format!("expected canonical style URL: {error:?}"))?;
478        let error = match resolver.load(&canonical) {
479            Ok(source) => return Err(format!("expected missing snapshot source, got {source:?}")),
480            Err(error) => error,
481        };
482
483        assert_eq!(error.kind, OmenaResolverErrorKindV0::NotFound);
484        Ok(())
485    }
486
487    #[test]
488    fn boundary_state_matrix_preserves_m7_external_states_and_top_semantics() {
489        let canonical = OmenaResolverCanonicalUrlV0::workspace_style_path("src/tokens.scss");
490        let states = [
491            OmenaResolverBoundaryStateV0::resolved(canonical.clone()),
492            OmenaResolverBoundaryStateV0::partial("external boundary kept in ignored mode"),
493            OmenaResolverBoundaryStateV0::stale(canonical.clone(), "lockfile hash drift"),
494            OmenaResolverBoundaryStateV0::missing(Some(canonical), "expected SIF is missing"),
495            OmenaResolverBoundaryStateV0::unresolved("specifier did not resolve"),
496        ];
497
498        assert_eq!(states[0].state_name, "resolved");
499        assert_eq!(states[0].top_name, "topOpaque");
500        assert_eq!(states[1].state_name, "partial");
501        assert_eq!(states[1].top_name, "topAny");
502        assert_eq!(states[2].state_name, "stale");
503        assert_eq!(states[2].top_name, "topAny");
504        assert_eq!(states[3].state_name, "missing");
505        assert_eq!(states[3].top_name, "topAny");
506        assert_eq!(states[4].state_name, "unresolved");
507        assert_eq!(states[4].top_name, "topAny");
508    }
509
510    #[test]
511    fn boundary_state_for_unresolved_reference_folds_to_unresolved_via_error_channel() {
512        // A non-canonicalizable bare/relative reference is `Unresolved`.
513        let bare = omena_resolver_boundary_state_for_unresolved_reference_v0("bootstrap");
514        assert_eq!(bare.state, OmenaResolverBoundaryStateKindV0::Unresolved);
515        assert_eq!(bare.top, OmenaResolverBoundaryTopV0::TopAny);
516        assert!(bare.reason.contains("bootstrap"));
517
518        // A network reference folds through `NetworkForbidden` to the same boundary state.
519        let network =
520            omena_resolver_boundary_state_for_unresolved_reference_v0("https://cdn.example/x.scss");
521        assert_eq!(network.state, OmenaResolverBoundaryStateKindV0::Unresolved);
522        assert_eq!(network.top, OmenaResolverBoundaryTopV0::TopAny);
523    }
524
525    #[test]
526    fn boundary_state_maps_existing_external_ignored_error_to_partial() {
527        let error = OmenaResolverErrorV0::new(
528            OmenaResolverErrorKindV0::ExternalIgnored,
529            "sass:map remains external in compatibility mode",
530        );
531
532        let state = omena_resolver_boundary_state_from_error_v0(&error);
533
534        assert_eq!(state.state, OmenaResolverBoundaryStateKindV0::Partial);
535        assert_eq!(state.top, OmenaResolverBoundaryTopV0::TopAny);
536        assert_eq!(
537            state.reason,
538            "sass:map remains external in compatibility mode"
539        );
540    }
541
542    #[test]
543    fn snapshot_resolver_preserves_tsconfig_path_mapping_resolution() -> Result<(), String> {
544        let resolver = OmenaResolverStyleModuleSnapshotV0::new([
545            "/fake/workspace/src/styles/Button.module.scss",
546        ])
547        .with_tsconfig_path_mappings(vec![OmenaResolverTsconfigPathMappingV0 {
548            base_path: "/fake/workspace".to_string(),
549            pattern: "@styles/*".to_string(),
550            target_patterns: vec!["src/styles/*".to_string()],
551        }]);
552        let context = OmenaResolverReferenceContextV0 {
553            referencing_file: "/fake/workspace/src/App.module.scss".to_string(),
554        };
555
556        let canonical = resolver
557            .canonicalize(&context, "@styles/Button")
558            .map_err(|error| format!("expected canonical style URL: {error:?}"))?;
559
560        assert_eq!(
561            canonical.as_workspace_style_path(),
562            Some("/fake/workspace/src/styles/Button.module.scss")
563        );
564        Ok(())
565    }
566
567    #[test]
568    fn snapshot_resolver_preserves_bundler_path_mapping_precedence() -> Result<(), String> {
569        let resolver = OmenaResolverStyleModuleSnapshotV0::new([
570            "/fake/workspace/src/bundler/Button.module.scss",
571            "/fake/workspace/src/tsconfig/Button.module.scss",
572        ])
573        .with_bundler_path_mappings(vec![OmenaResolverBundlerPathAliasMappingV0 {
574            pattern: "@styles".to_string(),
575            target_path: "/fake/workspace/src/bundler".to_string(),
576        }])
577        .with_tsconfig_path_mappings(vec![OmenaResolverTsconfigPathMappingV0 {
578            base_path: "/fake/workspace".to_string(),
579            pattern: "@styles/*".to_string(),
580            target_patterns: vec!["src/tsconfig/*".to_string()],
581        }]);
582        let context = OmenaResolverReferenceContextV0 {
583            referencing_file: "/fake/workspace/src/App.module.scss".to_string(),
584        };
585
586        let canonical = resolver
587            .canonicalize(&context, "@styles/Button")
588            .map_err(|error| format!("expected canonical style URL: {error:?}"))?;
589
590        assert_eq!(
591            canonical.as_workspace_style_path(),
592            Some("/fake/workspace/src/bundler/Button.module.scss")
593        );
594        Ok(())
595    }
596
597    #[test]
598    fn snapshot_resolver_preserves_package_manifest_resolution() -> Result<(), String> {
599        let resolver = OmenaResolverStyleModuleSnapshotV0::new([
600            "/fake/workspace/node_modules/@design/tokens/dist/theme.css",
601        ])
602        .with_package_manifests(vec![OmenaResolverStylePackageManifestV0 {
603            package_json_path: "/fake/workspace/node_modules/@design/tokens/package.json"
604                .to_string(),
605            package_json_source: r#"{"exports":{"./theme":{"style":"./dist/theme.css"}}}"#
606                .to_string(),
607        }]);
608        let context = OmenaResolverReferenceContextV0 {
609            referencing_file: "/fake/workspace/src/App.module.scss".to_string(),
610        };
611
612        let canonical = resolver
613            .canonicalize(&context, "@design/tokens/theme")
614            .map_err(|error| format!("expected canonical style URL: {error:?}"))?;
615
616        assert_eq!(
617            canonical.as_workspace_style_path(),
618            Some("/fake/workspace/node_modules/@design/tokens/dist/theme.css")
619        );
620        Ok(())
621    }
622}