Skip to main content

omena_query/
sdk_workspace.rs

1use std::collections::BTreeMap;
2
3use serde::Deserialize;
4
5use crate::{
6    EngineInputV2, IncrementalRevisionV0, OmenaBundlerHostResolveModuleRequestV0,
7    OmenaBundlerHostResolveModuleResponseV0, OmenaError, OmenaErrorClassV0, OmenaErrorContextV0,
8    OmenaErrorRecoverabilityV0, OmenaErrorSeverityV0, OmenaQueryBuildVerificationProfileV0,
9    OmenaQueryConsumerBuildOptionsV0, OmenaQueryExplainInputV0,
10    OmenaQuerySourceDiagnosticsForFileV0, OmenaQueryStylePackageManifestV0,
11    OmenaQueryStyleResolutionInputsV0, OmenaQueryStyleSourceInputV0,
12    OmenaQueryTransformStrictPolicyEventV0, OmenaQueryTransformStrictPolicyReasonV0,
13    OmenaQueryTransformStrictPolicySummaryV0, OmenaSdkBuildRequestV0, OmenaSdkBuildResponseV0,
14    OmenaSdkBuildVerificationEventV0, OmenaSdkBuildVerificationProfileV0,
15    OmenaSdkBuildVerificationReasonV0, OmenaSdkBuildVerificationSummaryV0,
16    OmenaSdkDiagnosticsRequestV0, OmenaSdkDiagnosticsResponseV0, OmenaSdkExplainRequestV0,
17    OmenaSdkExplainResponseV0, OmenaSdkQueryRequestV0, OmenaSdkQueryResponseV0,
18    OmenaSdkResponsePartitionV0, OmenaSdkSnapshotRequestV0, OmenaSdkSnapshotResponseV0,
19    OmenaWorkspaceSnapshotIdV0, ParserPositionV0, attach_omena_query_consumer_build_source_map_v3,
20    execute_omena_query_consumer_build_style_source_with_context_and_options,
21    execute_omena_sdk_diagnostics_workflow, explain_omena_query,
22    read_omena_query_cascade_at_position, resolve_omena_bundler_host_module_v0,
23    summarize_omena_query_consumer_check_style_source,
24    summarize_omena_query_source_diagnostics_for_workspace_file_with_resolution_inputs,
25    summarize_omena_query_style_document, summarize_omena_query_style_hover_candidates,
26};
27
28#[derive(Debug, Clone)]
29pub struct OmenaSdkWorkspaceV0 {
30    workspace_root: String,
31    style_sources: BTreeMap<String, String>,
32    style_resolution_inputs: OmenaQueryStyleResolutionInputsV0,
33    revision: IncrementalRevisionV0,
34}
35
36impl OmenaSdkWorkspaceV0 {
37    pub fn open(
38        request: OmenaSdkSnapshotRequestV0,
39        style_sources: impl IntoIterator<Item = OmenaQueryStyleSourceInputV0>,
40    ) -> Result<Self, OmenaError> {
41        Self::open_with_resolution_inputs(
42            request,
43            style_sources,
44            OmenaQueryStyleResolutionInputsV0::default(),
45        )
46    }
47
48    pub fn open_with_resolution_inputs(
49        request: OmenaSdkSnapshotRequestV0,
50        style_sources: impl IntoIterator<Item = OmenaQueryStyleSourceInputV0>,
51        style_resolution_inputs: OmenaQueryStyleResolutionInputsV0,
52    ) -> Result<Self, OmenaError> {
53        Self::open_at_snapshot_with_resolution_inputs(
54            request,
55            style_sources,
56            OmenaWorkspaceSnapshotIdV0::from_revision(IncrementalRevisionV0 { value: 1 }),
57            style_resolution_inputs,
58        )
59    }
60
61    pub fn open_at_snapshot(
62        request: OmenaSdkSnapshotRequestV0,
63        style_sources: impl IntoIterator<Item = OmenaQueryStyleSourceInputV0>,
64        snapshot_id: OmenaWorkspaceSnapshotIdV0,
65    ) -> Result<Self, OmenaError> {
66        Self::open_at_snapshot_with_resolution_inputs(
67            request,
68            style_sources,
69            snapshot_id,
70            OmenaQueryStyleResolutionInputsV0::default(),
71        )
72    }
73
74    pub fn open_at_snapshot_with_resolution_inputs(
75        request: OmenaSdkSnapshotRequestV0,
76        style_sources: impl IntoIterator<Item = OmenaQueryStyleSourceInputV0>,
77        snapshot_id: OmenaWorkspaceSnapshotIdV0,
78        style_resolution_inputs: OmenaQueryStyleResolutionInputsV0,
79    ) -> Result<Self, OmenaError> {
80        if request.workspace_root.trim().is_empty() {
81            return Err(sdk_error(
82                OmenaErrorClassV0::Input,
83                "workspace root must not be empty",
84                "workspace.empty-root",
85                OmenaErrorRecoverabilityV0::UserAction,
86            ));
87        }
88        let mut sources = BTreeMap::new();
89        for source in style_sources {
90            let style_path = normalize_style_path(source.style_path.as_str());
91            if sources
92                .insert(style_path.clone(), source.style_source)
93                .is_some()
94            {
95                return Err(sdk_error(
96                    OmenaErrorClassV0::Input,
97                    format!("workspace contains duplicate style path {style_path:?}"),
98                    "workspace.duplicate-style-path",
99                    OmenaErrorRecoverabilityV0::UserAction,
100                ));
101            }
102        }
103        Ok(Self {
104            workspace_root: request.workspace_root,
105            style_sources: sources,
106            style_resolution_inputs,
107            revision: snapshot_id.revision(),
108        })
109    }
110
111    pub fn snapshot_id(&self) -> OmenaWorkspaceSnapshotIdV0 {
112        OmenaWorkspaceSnapshotIdV0::from_revision(self.revision)
113    }
114
115    pub fn snapshot(&self) -> OmenaSdkSnapshotResponseV0 {
116        OmenaSdkSnapshotResponseV0 {
117            snapshot_id: self.snapshot_id(),
118            partition: OmenaSdkResponsePartitionV0::Public,
119            workspace_root: self.workspace_root.clone(),
120        }
121    }
122
123    pub fn replace_style_sources(
124        &mut self,
125        style_sources: impl IntoIterator<Item = OmenaQueryStyleSourceInputV0>,
126    ) -> Result<OmenaSdkSnapshotResponseV0, OmenaError> {
127        let mut replacement = BTreeMap::new();
128        for source in style_sources {
129            let style_path = normalize_style_path(source.style_path.as_str());
130            if replacement
131                .insert(style_path.clone(), source.style_source)
132                .is_some()
133            {
134                return Err(sdk_error(
135                    OmenaErrorClassV0::Input,
136                    format!("workspace contains duplicate style path {style_path:?}"),
137                    "workspace.duplicate-style-path",
138                    OmenaErrorRecoverabilityV0::UserAction,
139                ));
140            }
141        }
142        if replacement != self.style_sources {
143            self.style_sources = replacement;
144            self.revision.value = self.revision.value.saturating_add(1);
145        }
146        Ok(self.snapshot())
147    }
148
149    pub fn replace_style_resolution_inputs(
150        &mut self,
151        style_resolution_inputs: OmenaQueryStyleResolutionInputsV0,
152    ) -> OmenaSdkSnapshotResponseV0 {
153        if style_resolution_inputs != self.style_resolution_inputs {
154            self.style_resolution_inputs = style_resolution_inputs;
155            self.revision.value = self.revision.value.saturating_add(1);
156        }
157        self.snapshot()
158    }
159
160    pub fn execute_query(
161        &self,
162        request: OmenaSdkQueryRequestV0,
163    ) -> Result<OmenaSdkQueryResponseV0, OmenaError> {
164        self.ensure_snapshot(request.snapshot_id, "query")?;
165        let input = query_input(request.input.as_ref())?;
166        let (style_path, style_source) = self.style_source(input.style_path.as_str())?;
167        let payload = match request.query_kind.as_str() {
168            "styleSummary" => summarize_omena_query_style_document(style_path, style_source)
169                .map(|summary| serde_json::to_value(summary).map_err(serialize_error))
170                .transpose()?
171                .ok_or_else(|| {
172                    sdk_error(
173                        OmenaErrorClassV0::Analysis,
174                        format!("style summary is unavailable for {style_path:?}"),
175                        "query.style-summary-unavailable",
176                        OmenaErrorRecoverabilityV0::Retry,
177                    )
178                })?,
179            "hoverCandidates" => serde_json::to_value(
180                summarize_omena_query_style_hover_candidates(style_path, style_source).ok_or_else(
181                    || {
182                        sdk_error(
183                            OmenaErrorClassV0::Analysis,
184                            format!("hover candidates are unavailable for {style_path:?}"),
185                            "query.hover-candidates-unavailable",
186                            OmenaErrorRecoverabilityV0::Retry,
187                        )
188                    },
189                )?,
190            )
191            .map_err(serialize_error)?,
192            _ => {
193                return Err(sdk_error(
194                    OmenaErrorClassV0::Unsupported,
195                    format!("unsupported SDK query kind {:?}", request.query_kind),
196                    "query.unsupported-kind",
197                    OmenaErrorRecoverabilityV0::UserAction,
198                ));
199            }
200        };
201        Ok(OmenaSdkQueryResponseV0 {
202            snapshot_id: self.snapshot_id(),
203            partition: OmenaSdkResponsePartitionV0::Public,
204            payload,
205        })
206    }
207
208    pub fn execute_diagnostics(
209        &self,
210        mut request: OmenaSdkDiagnosticsRequestV0,
211    ) -> Result<OmenaSdkDiagnosticsResponseV0, OmenaError> {
212        self.ensure_snapshot(request.snapshot_id, "diagnostics")?;
213        let (style_path, style_source) = self.style_source(request.style_path.as_str())?;
214        if request.style_source != style_source {
215            return Err(sdk_error(
216                OmenaErrorClassV0::Workspace,
217                format!("diagnostics source does not match snapshot for {style_path:?}"),
218                "workspace.style-source-mismatch",
219                OmenaErrorRecoverabilityV0::Retry,
220            ));
221        }
222        request.style_path = style_path.to_string();
223        execute_omena_sdk_diagnostics_workflow(request, self.snapshot_id())
224    }
225
226    pub fn execute_consumer_check(
227        &self,
228        snapshot_id: OmenaWorkspaceSnapshotIdV0,
229        style_path: &str,
230    ) -> Result<serde_json::Value, OmenaError> {
231        self.ensure_snapshot(snapshot_id, "check")?;
232        let (style_path, style_source) = self.style_source(style_path)?;
233        serde_json::to_value(summarize_omena_query_consumer_check_style_source(
234            style_path,
235            style_source,
236        ))
237        .map_err(serialize_error)
238    }
239
240    pub fn execute_source_diagnostics(
241        &self,
242        snapshot_id: OmenaWorkspaceSnapshotIdV0,
243        source_path: &str,
244        source: &str,
245        package_manifests: &[OmenaQueryStylePackageManifestV0],
246    ) -> Result<OmenaQuerySourceDiagnosticsForFileV0, OmenaError> {
247        self.ensure_snapshot(snapshot_id, "source diagnostics")?;
248        let style_sources = self.style_source_inputs();
249        Ok(
250            summarize_omena_query_source_diagnostics_for_workspace_file_with_resolution_inputs(
251                source_path,
252                source,
253                style_sources.as_slice(),
254                package_manifests,
255                &self.style_resolution_inputs,
256            ),
257        )
258    }
259
260    pub fn execute_bundler_resolve(
261        &self,
262        snapshot_id: OmenaWorkspaceSnapshotIdV0,
263        style_path: String,
264        package_manifests: Vec<OmenaQueryStylePackageManifestV0>,
265    ) -> Result<OmenaBundlerHostResolveModuleResponseV0, OmenaError> {
266        self.ensure_snapshot(snapshot_id, "bundler resolve")?;
267        Ok(resolve_omena_bundler_host_module_v0(
268            OmenaBundlerHostResolveModuleRequestV0 {
269                snapshot_id: self.snapshot_id(),
270                workspace_root: self.workspace_root.clone(),
271                style_path,
272                style_sources: self.style_source_inputs(),
273                package_manifests,
274            },
275        ))
276    }
277
278    pub fn execute_build(
279        &self,
280        mut request: OmenaSdkBuildRequestV0,
281    ) -> Result<OmenaSdkBuildResponseV0, OmenaError> {
282        self.ensure_snapshot(request.snapshot_id, "build")?;
283        let (style_path, style_source) = self.style_source(request.style_path.as_str())?;
284        if request.style_source != style_source {
285            return Err(sdk_error(
286                OmenaErrorClassV0::Workspace,
287                format!("build source does not match snapshot for {style_path:?}"),
288                "workspace.style-source-mismatch",
289                OmenaErrorRecoverabilityV0::Retry,
290            ));
291        }
292        request.style_path = style_path.to_string();
293        let build_options = OmenaQueryConsumerBuildOptionsV0 {
294            verification_profile: match request.verification_profile {
295                Some(OmenaSdkBuildVerificationProfileV0::Strict) => {
296                    OmenaQueryBuildVerificationProfileV0::Strict
297                }
298                Some(OmenaSdkBuildVerificationProfileV0::Descriptive) | None => {
299                    OmenaQueryBuildVerificationProfileV0::Descriptive
300                }
301            },
302            ..OmenaQueryConsumerBuildOptionsV0::default()
303        };
304        let default_context = crate::OmenaQueryTransformExecutionContextV0::default();
305        let context = request.context.as_ref().unwrap_or(&default_context);
306        let mut summary = execute_omena_query_consumer_build_style_source_with_context_and_options(
307            style_path,
308            style_source,
309            request.pass_ids.as_slice(),
310            context,
311            &build_options,
312        );
313        attach_omena_query_consumer_build_source_map_v3(&mut summary, style_source);
314        let verification = sdk_build_verification_summary(&summary.execution.strict_policy);
315        Ok(OmenaSdkBuildResponseV0 {
316            snapshot_id: self.snapshot_id(),
317            partition: OmenaSdkResponsePartitionV0::Public,
318            verification,
319            summary: serde_json::to_value(summary).map_err(serialize_error)?,
320        })
321    }
322
323    pub fn execute_explain(
324        &self,
325        request: OmenaSdkExplainRequestV0,
326    ) -> Result<OmenaSdkExplainResponseV0, OmenaError> {
327        self.ensure_snapshot(request.snapshot_id, "explain")?;
328        let (style_path, style_source) = self.style_source(request.style_path.as_str())?;
329        let position = parser_position(request.position.line, request.position.character)?;
330        let empty_input = EngineInputV2 {
331            version: "2".to_string(),
332            sources: Vec::new(),
333            styles: Vec::new(),
334            type_facts: Vec::new(),
335        };
336        let report = match read_omena_query_cascade_at_position(
337            style_path,
338            style_source,
339            &empty_input,
340            position,
341        ) {
342            Some(cascade) => {
343                explain_omena_query(OmenaQueryExplainInputV0::Cascade { result: &cascade })
344            }
345            None => {
346                let candidate_count =
347                    summarize_omena_query_style_hover_candidates(style_path, style_source)
348                        .map_or(0, |candidates| candidates.candidates.len());
349                explain_omena_query(OmenaQueryExplainInputV0::HoverTrace {
350                    document_uri: style_path,
351                    position: Some(position),
352                    reason_code: "style-position",
353                    matched: candidate_count > 0,
354                    candidate_count,
355                    definition_count: 0,
356                })
357            }
358        };
359        let source_identity = serde_json::json!({
360            "originalSource": style_path,
361            "line": position.line,
362            "character": position.character,
363        });
364        Ok(OmenaSdkExplainResponseV0 {
365            snapshot_id: self.snapshot_id(),
366            partition: OmenaSdkResponsePartitionV0::Public,
367            report: serde_json::json!({
368                "explanation": report,
369                "sourceIdentity": source_identity,
370            }),
371        })
372    }
373
374    fn ensure_snapshot(
375        &self,
376        requested: OmenaWorkspaceSnapshotIdV0,
377        operation: &str,
378    ) -> Result<(), OmenaError> {
379        if requested == self.snapshot_id() {
380            return Ok(());
381        }
382        Err(sdk_error(
383            OmenaErrorClassV0::Workspace,
384            format!("{operation} request does not match the current workspace snapshot"),
385            "workspace.snapshot-mismatch",
386            OmenaErrorRecoverabilityV0::Retry,
387        ))
388    }
389
390    fn style_source(&self, style_path: &str) -> Result<(&str, &str), OmenaError> {
391        let style_path = normalize_style_path(style_path);
392        self.style_sources
393            .get_key_value(style_path.as_str())
394            .map(|(path, source)| (path.as_str(), source.as_str()))
395            .ok_or_else(|| {
396                sdk_error(
397                    OmenaErrorClassV0::Resolution,
398                    format!("style path {style_path:?} is not present in the workspace snapshot"),
399                    "workspace.style-path-not-found",
400                    OmenaErrorRecoverabilityV0::UserAction,
401                )
402            })
403    }
404
405    fn style_source_inputs(&self) -> Vec<OmenaQueryStyleSourceInputV0> {
406        self.style_sources
407            .iter()
408            .map(|(style_path, style_source)| OmenaQueryStyleSourceInputV0 {
409                style_path: style_path.clone(),
410                style_source: style_source.clone(),
411            })
412            .collect()
413    }
414}
415
416fn sdk_build_verification_summary(
417    summary: &OmenaQueryTransformStrictPolicySummaryV0,
418) -> OmenaSdkBuildVerificationSummaryV0 {
419    OmenaSdkBuildVerificationSummaryV0 {
420        profile_id: summary.profile_id.clone(),
421        refused_count: summary.refused_count as u64,
422        rolled_back_count: summary.rolled_back_count as u64,
423        refusal_reasons: summary
424            .refusal_reasons
425            .iter()
426            .map(sdk_build_verification_event)
427            .collect(),
428        rollback_reasons: summary
429            .rollback_reasons
430            .iter()
431            .map(sdk_build_verification_event)
432            .collect(),
433    }
434}
435
436fn sdk_build_verification_event(
437    event: &OmenaQueryTransformStrictPolicyEventV0,
438) -> OmenaSdkBuildVerificationEventV0 {
439    OmenaSdkBuildVerificationEventV0 {
440        pass_id: event.pass_id.clone(),
441        reasons: event
442            .reasons
443            .iter()
444            .map(sdk_build_verification_reason)
445            .collect(),
446    }
447}
448
449fn sdk_build_verification_reason(
450    reason: &OmenaQueryTransformStrictPolicyReasonV0,
451) -> OmenaSdkBuildVerificationReasonV0 {
452    match reason {
453        OmenaQueryTransformStrictPolicyReasonV0::RequiredAxisUnavailable { .. } => {
454            OmenaSdkBuildVerificationReasonV0::RequiredAxisUnavailable
455        }
456        OmenaQueryTransformStrictPolicyReasonV0::CascadeEnvironmentUnavailable => {
457            OmenaSdkBuildVerificationReasonV0::CascadeEnvironmentUnavailable
458        }
459        OmenaQueryTransformStrictPolicyReasonV0::WinnerChanged { .. } => {
460            OmenaSdkBuildVerificationReasonV0::WinnerChanged
461        }
462        OmenaQueryTransformStrictPolicyReasonV0::ObservationUnavailable { .. } => {
463            OmenaSdkBuildVerificationReasonV0::ObservationUnavailable
464        }
465        OmenaQueryTransformStrictPolicyReasonV0::UnknownPass => {
466            OmenaSdkBuildVerificationReasonV0::UnknownPass
467        }
468        OmenaQueryTransformStrictPolicyReasonV0::ClosedWorldEvidenceUnavailable => {
469            OmenaSdkBuildVerificationReasonV0::ClosedWorldEvidenceUnavailable
470        }
471        OmenaQueryTransformStrictPolicyReasonV0::DecisionCoverageIncomplete => {
472            OmenaSdkBuildVerificationReasonV0::DecisionCoverageIncomplete
473        }
474        OmenaQueryTransformStrictPolicyReasonV0::ClosedWorldEvidenceIncomplete { .. }
475        | OmenaQueryTransformStrictPolicyReasonV0::LivenessNotClosed { .. }
476        | OmenaQueryTransformStrictPolicyReasonV0::EvidenceUnavailable
477        | OmenaQueryTransformStrictPolicyReasonV0::OwnershipNotSeparable { .. } => {
478            // Admission-tier events are serialized through closedWorldAdmission,
479            // never through the strict-policy SDK summary.
480            OmenaSdkBuildVerificationReasonV0::ClosedWorldEvidenceUnavailable
481        }
482    }
483}
484
485#[derive(Debug, Deserialize)]
486#[serde(rename_all = "camelCase")]
487struct OmenaSdkStyleQueryInputV0 {
488    style_path: String,
489}
490
491fn query_input(input: Option<&serde_json::Value>) -> Result<OmenaSdkStyleQueryInputV0, OmenaError> {
492    serde_json::from_value(input.cloned().unwrap_or(serde_json::Value::Null)).map_err(|error| {
493        sdk_error(
494            OmenaErrorClassV0::Input,
495            format!("SDK query input is invalid: {error}"),
496            "query.invalid-input",
497            OmenaErrorRecoverabilityV0::UserAction,
498        )
499    })
500}
501
502fn parser_position(line: i32, character: i32) -> Result<ParserPositionV0, OmenaError> {
503    let line = usize::try_from(line).map_err(|_| {
504        sdk_error(
505            OmenaErrorClassV0::Input,
506            "explain line must be non-negative",
507            "explain.invalid-position",
508            OmenaErrorRecoverabilityV0::UserAction,
509        )
510    })?;
511    let character = usize::try_from(character).map_err(|_| {
512        sdk_error(
513            OmenaErrorClassV0::Input,
514            "explain character must be non-negative",
515            "explain.invalid-position",
516            OmenaErrorRecoverabilityV0::UserAction,
517        )
518    })?;
519    Ok(ParserPositionV0 { line, character })
520}
521
522fn normalize_style_path(style_path: &str) -> String {
523    if style_path.trim().is_empty() {
524        "style.css".to_string()
525    } else {
526        style_path.to_string()
527    }
528}
529
530fn serialize_error(error: serde_json::Error) -> OmenaError {
531    sdk_error(
532        OmenaErrorClassV0::Internal,
533        format!("failed to serialize SDK workflow response: {error}"),
534        "sdk.response-serialization",
535        OmenaErrorRecoverabilityV0::Retry,
536    )
537}
538
539fn sdk_error(
540    class: OmenaErrorClassV0,
541    message: impl Into<String>,
542    code: &str,
543    recoverability: OmenaErrorRecoverabilityV0,
544) -> OmenaError {
545    OmenaError::new(
546        class,
547        message,
548        OmenaErrorContextV0 {
549            code: code.to_string(),
550            severity: OmenaErrorSeverityV0::Error,
551            recoverability,
552            evidence: Vec::new(),
553        },
554    )
555}