use std::collections::BTreeMap;
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use crate::{
ArtifactFormat, BinocResult, DataAccess, Diagnostic, ExtractResult, GlobalClaim,
IdentityExtractor, IdentityFailurePolicy, IdentityToken, ItemRef, Segment, Summary,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum TreeSide {
Left,
Right,
}
impl TreeSide {
pub fn label(self) -> &'static str {
match self {
TreeSide::Left => "left",
TreeSide::Right => "right",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct NodeId {
pub side: TreeSide,
pub index: u32,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct ProjectionHint {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub action: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub item_type: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tags: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub retract_tags: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub summary: Option<Summary>,
}
pub fn projection_hint_is_default(hint: &ProjectionHint) -> bool {
hint == &ProjectionHint::default()
}
impl ProjectionHint {
pub fn action(mut self, action: impl Into<String>) -> Self {
self.action = Some(action.into());
self
}
pub fn item_type(mut self, item_type: impl Into<String>) -> Self {
self.item_type = Some(item_type.into());
self
}
pub fn tag(mut self, tag: impl Into<String>) -> Self {
self.tags.push(tag.into());
self
}
pub fn retract_tag(mut self, tag: impl Into<String>) -> Self {
self.retract_tags.push(tag.into());
self
}
pub fn summary(mut self, summary: impl Into<Summary>) -> Self {
self.summary = Some(summary.into());
self
}
pub fn merge_from(&mut self, other: &ProjectionHint) {
if self.action.is_none() {
self.action = other.action.clone();
}
if self.item_type.is_none() {
self.item_type = other.item_type.clone();
}
if self.summary.is_none() {
self.summary = other.summary.clone();
}
self.merge_tags(other);
}
fn merge_tags(&mut self, other: &ProjectionHint) {
self.tags.extend(other.tags.iter().cloned());
self.tags.sort();
self.tags.dedup();
self.retract_tags.extend(other.retract_tags.iter().cloned());
self.retract_tags.sort();
self.retract_tags.dedup();
if !self.retract_tags.is_empty() {
self.tags.retain(|tag| !self.retract_tags.contains(tag));
}
}
pub fn overlay_from(&mut self, other: &ProjectionHint) {
if other.action.is_some() {
self.action = other.action.clone();
}
if other.item_type.is_some() {
self.item_type = other.item_type.clone();
}
if other.summary.is_some() {
self.summary = other.summary.clone();
}
self.merge_tags(other);
}
}
pub struct ProjectionAnnotationContext<'a> {
pub action: &'a str,
pub item_type: &'a str,
pub path: &'a str,
pub source_path: Option<&'a str>,
pub source_item_type: Option<&'a str>,
pub evidence: Option<&'a str>,
pub edits: &'a [Edit],
pub container: bool,
pub unlinked_side: Option<TreeSide>,
}
pub trait ProjectionAnnotator: Send + Sync {
fn name(&self) -> &str;
fn annotate(&self, ctx: &ProjectionAnnotationContext<'_>) -> ProjectionHint;
}
#[derive(Clone)]
pub enum CoreRule {
Expand(Arc<dyn ExpandRule>),
Parse(Arc<dyn ParseRule>),
Pair(Arc<dyn PairRule>),
}
impl CoreRule {
pub fn name(&self) -> String {
match self {
CoreRule::Expand(rule) => rule.descriptor().name,
CoreRule::Parse(rule) => rule.descriptor().name,
CoreRule::Pair(rule) => rule.descriptor().name,
}
}
}
#[derive(Default, Clone)]
pub struct CorrespondenceEngineConfig {
pub rules: Vec<CoreRule>,
pub writers: Vec<Arc<dyn EditListWriter>>,
pub compaction: Vec<Arc<dyn CompactionRule>>,
pub annotators: Vec<Arc<dyn ProjectionAnnotator>>,
pub identity_extractors: Vec<Arc<dyn IdentityExtractor>>,
pub row_keys: BTreeMap<String, Vec<String>>,
pub row_identity_policies: BTreeMap<String, RowIdentityPolicies>,
pub root_projection: ProjectionHint,
pub dataset_configurator: Option<Arc<dyn CorrespondenceDatasetConfigurator>>,
}
pub trait CorrespondenceDatasetConfigurator: Send + Sync {
fn configure(
&self,
config: &mut CorrespondenceEngineConfig,
dataset: &serde_json::Value,
left_root: &ItemRef,
right_root: &ItemRef,
data: &dyn DataAccess,
) -> BinocResult<Vec<Diagnostic>>;
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct NodeMatch {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub is_dir: Option<bool>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub extensions: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub media_types: Vec<String>,
}
impl NodeMatch {
pub fn matches(&self, item: &ItemRef) -> bool {
if let Some(expected) = self.is_dir {
if item.is_dir != expected {
return false;
}
}
if !self.extensions.is_empty() {
let ext = item.extension();
if !ext
.as_ref()
.is_some_and(|ext| self.extensions.iter().any(|candidate| candidate == ext))
{
return false;
}
}
if !self.media_types.is_empty() {
let media_type = item.media_type.as_deref().unwrap_or("");
if !self
.media_types
.iter()
.any(|candidate| candidate == media_type)
{
return false;
}
}
true
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum ShapeFilter {
#[default]
Any,
Container,
Leaf,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct ExpandDescriptor {
pub name: String,
pub input: NodeMatch,
#[serde(default)]
pub fires_beneath_settled: bool,
}
pub trait ExpandRule: Send + Sync {
fn descriptor(&self) -> ExpandDescriptor;
fn expand(&self, item: &ItemRef, data: &dyn DataAccess) -> BinocResult<ExpandOutput>;
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct ExpandOutput {
pub children: Vec<ItemRef>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub diagnostics: Vec<Diagnostic>,
}
impl From<Vec<ItemRef>> for ExpandOutput {
fn from(children: Vec<ItemRef>) -> Self {
Self {
children,
diagnostics: Vec::new(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct MemberMatch {
#[serde(rename = "match")]
pub matcher: NodeMatch,
#[serde(default)]
pub required: bool,
}
impl MemberMatch {
pub fn required(matcher: NodeMatch) -> Self {
Self {
matcher,
required: true,
}
}
pub fn optional(matcher: NodeMatch) -> Self {
Self {
matcher,
required: false,
}
}
}
impl From<NodeMatch> for MemberMatch {
fn from(matcher: NodeMatch) -> Self {
MemberMatch::required(matcher)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum Correlation {
#[default]
SharedStem,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct ParseDescriptor {
pub name: String,
pub input: NodeMatch,
pub output: ArtifactFormat,
#[serde(default)]
pub fires_beneath_settled: bool,
}
#[derive(Debug, Clone)]
pub struct ParseGroup {
pub anchor: ItemRef,
pub members: Vec<Option<ItemRef>>,
}
impl ParseGroup {
pub fn single(anchor: ItemRef) -> Self {
Self {
members: vec![Some(anchor.clone())],
anchor,
}
}
pub fn member(&self, index: usize) -> Option<&ItemRef> {
self.members.get(index).and_then(Option::as_ref)
}
pub fn present(&self) -> impl Iterator<Item = &ItemRef> {
self.members.iter().filter_map(Option::as_ref)
}
}
pub trait ParseRule: Send + Sync {
fn descriptor(&self) -> ParseDescriptor;
fn parse(&self, item: &ItemRef, data: &dyn DataAccess) -> BinocResult<ParseOutput>;
fn extra_members(&self) -> Vec<MemberMatch> {
Vec::new()
}
fn correlation(&self) -> Correlation {
Correlation::SharedStem
}
fn parse_group(&self, group: &ParseGroup, data: &dyn DataAccess) -> BinocResult<ParseOutput> {
self.parse(&group.anchor, data)
}
}
pub fn member_set(rule: &dyn ParseRule) -> Vec<MemberMatch> {
let mut members = vec![MemberMatch::required(rule.descriptor().input)];
members.extend(rule.extra_members());
members
}
pub fn parse_arity(rule: &dyn ParseRule) -> usize {
1 + rule.extra_members().len()
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct ParseOutput {
pub bytes: Vec<u8>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub diagnostics: Vec<Diagnostic>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub children: Vec<ParsedChild>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub artifacts: Vec<ParsedArtifact>,
#[serde(default, skip_serializing_if = "projection_hint_is_default")]
pub projection: ProjectionHint,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct ParsedChild {
pub item: ItemRef,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub artifacts: Vec<ParsedArtifact>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct ParsedArtifact {
pub format: ArtifactFormat,
pub bytes: Vec<u8>,
}
impl From<Vec<u8>> for ParseOutput {
fn from(bytes: Vec<u8>) -> Self {
Self {
bytes,
diagnostics: Vec::new(),
children: Vec::new(),
artifacts: Vec::new(),
projection: ProjectionHint::default(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct PairDescriptor {
pub name: String,
#[serde(default)]
pub emits: Vec<String>,
#[serde(default)]
pub reads: Vec<ArtifactFormat>,
#[serde(default)]
pub sees_beneath_settled: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct LinkProposal {
pub left: u32,
pub right: u32,
pub evidence: String,
#[serde(default)]
pub settled: bool,
#[serde(default)]
pub projection: ProjectionHint,
}
pub trait PairRule: Send + Sync {
fn descriptor(&self) -> PairDescriptor;
fn propose(&self, view: &dyn EngineView, data: &dyn DataAccess) -> BinocResult<PairOutput>;
fn final_diagnostics(
&self,
_view: &dyn EngineView,
_data: &dyn DataAccess,
) -> BinocResult<Vec<Diagnostic>> {
Ok(Vec::new())
}
fn final_claims(
&self,
_view: &dyn EngineView,
_data: &dyn DataAccess,
) -> BinocResult<Vec<GlobalClaim>> {
Ok(Vec::new())
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct PairOutput {
pub proposals: Vec<LinkProposal>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub diagnostics: Vec<Diagnostic>,
}
impl From<Vec<LinkProposal>> for PairOutput {
fn from(proposals: Vec<LinkProposal>) -> Self {
Self {
proposals,
diagnostics: Vec::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct LinkRef {
pub index: usize,
pub left: NodeId,
pub right: NodeId,
pub evidence: String,
pub proposer: String,
pub priority: u32,
pub settled: bool,
#[serde(default)]
pub projection: ProjectionHint,
}
pub trait EngineView {
fn root(&self, side: TreeSide) -> NodeId;
fn visible(&self, id: NodeId) -> bool;
fn nodes(&self, side: TreeSide) -> Vec<NodeId>;
fn item(&self, id: NodeId) -> &ItemRef;
fn parent(&self, id: NodeId) -> Option<NodeId>;
fn children(&self, id: NodeId) -> Vec<NodeId>;
fn has_children(&self, id: NodeId) -> bool;
fn is_linked(&self, id: NodeId) -> bool;
fn links(&self) -> Vec<LinkRef>;
fn links_of(&self, id: NodeId) -> Vec<LinkRef>;
fn artifact_bytes(
&self,
id: NodeId,
format: &ArtifactFormat,
data: &dyn DataAccess,
) -> BinocResult<Option<Vec<u8>>>;
fn identity_tokens(
&self,
_id: NodeId,
_data: &dyn DataAccess,
) -> BinocResult<Option<Vec<IdentityToken>>> {
Ok(None)
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct Edit {
pub verb: String,
pub params: serde_json::Value,
#[serde(default)]
pub projection: EditProjection,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provenance: Option<String>,
}
impl Edit {
pub fn new(verb: impl Into<String>, params: serde_json::Value) -> Self {
Self {
verb: verb.into(),
params,
projection: EditProjection::default(),
provenance: None,
}
}
pub fn with_provenance(mut self, provenance: impl Into<String>) -> Self {
self.provenance = Some(provenance.into());
self
}
pub fn hidden(mut self) -> Self {
self.projection.visible = false;
self
}
pub fn with_tag(mut self, tag: impl Into<String>) -> Self {
self.projection.hint.tags.push(tag.into());
self
}
pub fn with_item_type(mut self, item_type: impl Into<String>) -> Self {
self.projection.hint.item_type = Some(item_type.into());
self
}
pub fn with_summary(mut self, summary: impl Into<Summary>) -> Self {
self.projection.hint.summary = Some(summary.into());
self
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct EditProjection {
#[serde(default = "default_visible")]
pub visible: bool,
#[serde(default)]
pub hint: ProjectionHint,
}
impl Default for EditProjection {
fn default() -> Self {
Self {
visible: true,
hint: ProjectionHint::default(),
}
}
}
fn default_visible() -> bool {
true
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct WriterDescriptor {
pub name: String,
#[serde(default)]
pub formats: Vec<ArtifactFormat>,
pub input: NodeMatch,
#[serde(default)]
pub shape: ShapeFilter,
#[serde(default)]
pub fallback: bool,
}
pub struct LinkCtx<'a> {
pub view: &'a dyn EngineView,
pub link: LinkRef,
pub row_keys: &'a [String],
pub row_identity_policies: RowIdentityPolicies,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RowIdentityPolicies {
pub on_null_key: IdentityFailurePolicy,
pub on_duplicate_key: IdentityFailurePolicy,
}
impl Default for RowIdentityPolicies {
fn default() -> Self {
Self {
on_null_key: IdentityFailurePolicy::Diagnostic,
on_duplicate_key: IdentityFailurePolicy::Diagnostic,
}
}
}
pub trait EditListWriter: Send + Sync {
fn descriptor(&self) -> WriterDescriptor;
fn write(&self, ctx: &LinkCtx<'_>, data: &dyn DataAccess) -> BinocResult<Option<WriteOutput>>;
fn extract(
&self,
_ctx: &LinkCtx<'_>,
_edits: &[Edit],
_aspect: &str,
_data: &dyn DataAccess,
) -> BinocResult<Option<ExtractResult>> {
Ok(None)
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct WriteOutput {
pub edits: Vec<Edit>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub diagnostics: Vec<Diagnostic>,
}
impl From<Vec<Edit>> for WriteOutput {
fn from(edits: Vec<Edit>) -> Self {
Self {
edits,
diagnostics: Vec::new(),
}
}
}
pub trait CompactionRule: Send + Sync {
fn name(&self) -> &str;
fn format(&self) -> Option<ArtifactFormat> {
None
}
fn rewrite(
&self,
ctx: &LinkCtx<'_>,
edits: &[Edit],
data: &dyn DataAccess,
) -> BinocResult<Option<Vec<Edit>>>;
}
pub fn edit_count_summary(edit_count: usize) -> Summary {
Summary(vec![
Segment::Uint(edit_count as u64),
Segment::Text(format!(" edit{}", if edit_count == 1 { "" } else { "s" })),
])
}
#[cfg(test)]
mod projection_hint_tests {
use super::*;
#[test]
fn overlay_retracts_a_superseded_tag() {
let mut acc = ProjectionHint::default()
.tag("binoc.move")
.tag("binoc.keep");
let reshape = ProjectionHint::default()
.tag("binoc.container-reshape")
.retract_tag("binoc.move");
acc.overlay_from(&reshape);
assert!(acc.tags.contains(&"binoc.container-reshape".to_string()));
assert!(acc.tags.contains(&"binoc.keep".to_string()));
assert!(!acc.tags.contains(&"binoc.move".to_string()));
}
#[test]
fn retraction_holds_regardless_of_union_order() {
let mut acc = ProjectionHint::default();
let hint = ProjectionHint::default()
.tag("binoc.move")
.retract_tag("binoc.move");
acc.merge_from(&hint);
assert!(!acc.tags.contains(&"binoc.move".to_string()));
}
}