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                style_path,
271                style_sources: self.style_source_inputs(),
272                package_manifests,
273            },
274        ))
275    }
276
277    pub fn execute_build(
278        &self,
279        mut request: OmenaSdkBuildRequestV0,
280    ) -> Result<OmenaSdkBuildResponseV0, OmenaError> {
281        self.ensure_snapshot(request.snapshot_id, "build")?;
282        let (style_path, style_source) = self.style_source(request.style_path.as_str())?;
283        if request.style_source != style_source {
284            return Err(sdk_error(
285                OmenaErrorClassV0::Workspace,
286                format!("build source does not match snapshot for {style_path:?}"),
287                "workspace.style-source-mismatch",
288                OmenaErrorRecoverabilityV0::Retry,
289            ));
290        }
291        request.style_path = style_path.to_string();
292        let build_options = OmenaQueryConsumerBuildOptionsV0 {
293            verification_profile: match request.verification_profile {
294                Some(OmenaSdkBuildVerificationProfileV0::Strict) => {
295                    OmenaQueryBuildVerificationProfileV0::Strict
296                }
297                Some(OmenaSdkBuildVerificationProfileV0::Descriptive) | None => {
298                    OmenaQueryBuildVerificationProfileV0::Descriptive
299                }
300            },
301            ..OmenaQueryConsumerBuildOptionsV0::default()
302        };
303        let default_context = crate::OmenaQueryTransformExecutionContextV0::default();
304        let context = request.context.as_ref().unwrap_or(&default_context);
305        let mut summary = execute_omena_query_consumer_build_style_source_with_context_and_options(
306            style_path,
307            style_source,
308            request.pass_ids.as_slice(),
309            context,
310            &build_options,
311        );
312        attach_omena_query_consumer_build_source_map_v3(&mut summary, style_source);
313        let verification = sdk_build_verification_summary(&summary.execution.strict_policy);
314        Ok(OmenaSdkBuildResponseV0 {
315            snapshot_id: self.snapshot_id(),
316            partition: OmenaSdkResponsePartitionV0::Public,
317            verification,
318            summary: serde_json::to_value(summary).map_err(serialize_error)?,
319        })
320    }
321
322    pub fn execute_explain(
323        &self,
324        request: OmenaSdkExplainRequestV0,
325    ) -> Result<OmenaSdkExplainResponseV0, OmenaError> {
326        self.ensure_snapshot(request.snapshot_id, "explain")?;
327        let (style_path, style_source) = self.style_source(request.style_path.as_str())?;
328        let position = parser_position(request.position.line, request.position.character)?;
329        let empty_input = EngineInputV2 {
330            version: "2".to_string(),
331            sources: Vec::new(),
332            styles: Vec::new(),
333            type_facts: Vec::new(),
334        };
335        let report = match read_omena_query_cascade_at_position(
336            style_path,
337            style_source,
338            &empty_input,
339            position,
340        ) {
341            Some(cascade) => {
342                explain_omena_query(OmenaQueryExplainInputV0::Cascade { result: &cascade })
343            }
344            None => {
345                let candidate_count =
346                    summarize_omena_query_style_hover_candidates(style_path, style_source)
347                        .map_or(0, |candidates| candidates.candidates.len());
348                explain_omena_query(OmenaQueryExplainInputV0::HoverTrace {
349                    document_uri: style_path,
350                    position: Some(position),
351                    reason_code: "style-position",
352                    matched: candidate_count > 0,
353                    candidate_count,
354                    definition_count: 0,
355                })
356            }
357        };
358        let source_identity = serde_json::json!({
359            "originalSource": style_path,
360            "line": position.line,
361            "character": position.character,
362        });
363        Ok(OmenaSdkExplainResponseV0 {
364            snapshot_id: self.snapshot_id(),
365            partition: OmenaSdkResponsePartitionV0::Public,
366            report: serde_json::json!({
367                "explanation": report,
368                "sourceIdentity": source_identity,
369            }),
370        })
371    }
372
373    fn ensure_snapshot(
374        &self,
375        requested: OmenaWorkspaceSnapshotIdV0,
376        operation: &str,
377    ) -> Result<(), OmenaError> {
378        if requested == self.snapshot_id() {
379            return Ok(());
380        }
381        Err(sdk_error(
382            OmenaErrorClassV0::Workspace,
383            format!("{operation} request does not match the current workspace snapshot"),
384            "workspace.snapshot-mismatch",
385            OmenaErrorRecoverabilityV0::Retry,
386        ))
387    }
388
389    fn style_source(&self, style_path: &str) -> Result<(&str, &str), OmenaError> {
390        let style_path = normalize_style_path(style_path);
391        self.style_sources
392            .get_key_value(style_path.as_str())
393            .map(|(path, source)| (path.as_str(), source.as_str()))
394            .ok_or_else(|| {
395                sdk_error(
396                    OmenaErrorClassV0::Resolution,
397                    format!("style path {style_path:?} is not present in the workspace snapshot"),
398                    "workspace.style-path-not-found",
399                    OmenaErrorRecoverabilityV0::UserAction,
400                )
401            })
402    }
403
404    fn style_source_inputs(&self) -> Vec<OmenaQueryStyleSourceInputV0> {
405        self.style_sources
406            .iter()
407            .map(|(style_path, style_source)| OmenaQueryStyleSourceInputV0 {
408                style_path: style_path.clone(),
409                style_source: style_source.clone(),
410            })
411            .collect()
412    }
413}
414
415fn sdk_build_verification_summary(
416    summary: &OmenaQueryTransformStrictPolicySummaryV0,
417) -> OmenaSdkBuildVerificationSummaryV0 {
418    OmenaSdkBuildVerificationSummaryV0 {
419        profile_id: summary.profile_id.clone(),
420        refused_count: summary.refused_count as u64,
421        rolled_back_count: summary.rolled_back_count as u64,
422        refusal_reasons: summary
423            .refusal_reasons
424            .iter()
425            .map(sdk_build_verification_event)
426            .collect(),
427        rollback_reasons: summary
428            .rollback_reasons
429            .iter()
430            .map(sdk_build_verification_event)
431            .collect(),
432    }
433}
434
435fn sdk_build_verification_event(
436    event: &OmenaQueryTransformStrictPolicyEventV0,
437) -> OmenaSdkBuildVerificationEventV0 {
438    OmenaSdkBuildVerificationEventV0 {
439        pass_id: event.pass_id.clone(),
440        reasons: event
441            .reasons
442            .iter()
443            .map(sdk_build_verification_reason)
444            .collect(),
445    }
446}
447
448fn sdk_build_verification_reason(
449    reason: &OmenaQueryTransformStrictPolicyReasonV0,
450) -> OmenaSdkBuildVerificationReasonV0 {
451    match reason {
452        OmenaQueryTransformStrictPolicyReasonV0::RequiredAxisUnavailable { .. } => {
453            OmenaSdkBuildVerificationReasonV0::RequiredAxisUnavailable
454        }
455        OmenaQueryTransformStrictPolicyReasonV0::CascadeEnvironmentUnavailable => {
456            OmenaSdkBuildVerificationReasonV0::CascadeEnvironmentUnavailable
457        }
458        OmenaQueryTransformStrictPolicyReasonV0::WinnerChanged { .. } => {
459            OmenaSdkBuildVerificationReasonV0::WinnerChanged
460        }
461        OmenaQueryTransformStrictPolicyReasonV0::ObservationUnavailable { .. } => {
462            OmenaSdkBuildVerificationReasonV0::ObservationUnavailable
463        }
464        OmenaQueryTransformStrictPolicyReasonV0::UnknownPass => {
465            OmenaSdkBuildVerificationReasonV0::UnknownPass
466        }
467        OmenaQueryTransformStrictPolicyReasonV0::ClosedWorldEvidenceUnavailable => {
468            OmenaSdkBuildVerificationReasonV0::ClosedWorldEvidenceUnavailable
469        }
470        OmenaQueryTransformStrictPolicyReasonV0::DecisionCoverageIncomplete => {
471            OmenaSdkBuildVerificationReasonV0::DecisionCoverageIncomplete
472        }
473    }
474}
475
476#[derive(Debug, Deserialize)]
477#[serde(rename_all = "camelCase")]
478struct OmenaSdkStyleQueryInputV0 {
479    style_path: String,
480}
481
482fn query_input(input: Option<&serde_json::Value>) -> Result<OmenaSdkStyleQueryInputV0, OmenaError> {
483    serde_json::from_value(input.cloned().unwrap_or(serde_json::Value::Null)).map_err(|error| {
484        sdk_error(
485            OmenaErrorClassV0::Input,
486            format!("SDK query input is invalid: {error}"),
487            "query.invalid-input",
488            OmenaErrorRecoverabilityV0::UserAction,
489        )
490    })
491}
492
493fn parser_position(line: i32, character: i32) -> Result<ParserPositionV0, OmenaError> {
494    let line = usize::try_from(line).map_err(|_| {
495        sdk_error(
496            OmenaErrorClassV0::Input,
497            "explain line must be non-negative",
498            "explain.invalid-position",
499            OmenaErrorRecoverabilityV0::UserAction,
500        )
501    })?;
502    let character = usize::try_from(character).map_err(|_| {
503        sdk_error(
504            OmenaErrorClassV0::Input,
505            "explain character must be non-negative",
506            "explain.invalid-position",
507            OmenaErrorRecoverabilityV0::UserAction,
508        )
509    })?;
510    Ok(ParserPositionV0 { line, character })
511}
512
513fn normalize_style_path(style_path: &str) -> String {
514    if style_path.trim().is_empty() {
515        "style.css".to_string()
516    } else {
517        style_path.to_string()
518    }
519}
520
521fn serialize_error(error: serde_json::Error) -> OmenaError {
522    sdk_error(
523        OmenaErrorClassV0::Internal,
524        format!("failed to serialize SDK workflow response: {error}"),
525        "sdk.response-serialization",
526        OmenaErrorRecoverabilityV0::Retry,
527    )
528}
529
530fn sdk_error(
531    class: OmenaErrorClassV0,
532    message: impl Into<String>,
533    code: &str,
534    recoverability: OmenaErrorRecoverabilityV0,
535) -> OmenaError {
536    OmenaError::new(
537        class,
538        message,
539        OmenaErrorContextV0 {
540            code: code.to_string(),
541            severity: OmenaErrorSeverityV0::Error,
542            recoverability,
543            evidence: Vec::new(),
544        },
545    )
546}