Skip to main content

binoc_sdk/
types.rs

1use std::collections::BTreeMap;
2
3use serde::{Deserialize, Serialize};
4
5use crate::ir::DiffNode;
6
7// ── Artifact types ──────────────────────────────────────────────────
8
9/// Which side of a comparison an artifact describes.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
11#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
12pub enum ArtifactSubject {
13    #[serde(rename = "left")]
14    Left,
15    #[serde(rename = "right")]
16    Right,
17    #[serde(rename = "pair")]
18    Pair,
19}
20
21/// Identifies an artifact's data format as a structured tuple of
22/// (package, name, version).
23///
24/// - **`package`** — the package that owns and defines this format,
25///   resolvable through the language's normal package system
26///   (e.g. `"binoc"`, `"binoc-csv"`, `"acme-parquet"`).
27/// - **`name`** — the format name within that package
28///   (e.g. `"tabular"`, `"relational-schema"`).
29/// - **`version`** — a single integer. Bump only for breaking schema
30///   changes. Adding optional fields to an existing version is fine
31///   and does not require a bump (JSON/serde naturally ignore unknown
32///   fields and default missing ones).
33#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
34#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
35pub struct ArtifactFormat {
36    pub package: String,
37    pub name: String,
38    pub version: u32,
39}
40
41impl ArtifactFormat {
42    pub fn new(package: impl Into<String>, name: impl Into<String>, version: u32) -> Self {
43        Self {
44            package: package.into(),
45            name: name.into(),
46            version,
47        }
48    }
49}
50
51impl std::fmt::Display for ArtifactFormat {
52    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
53        write!(f, "{}.{}.v{}", self.package, self.name, self.version)
54    }
55}
56
57/// Descriptor for a published artifact attached to a node.
58///
59/// Artifacts are the unified mechanism for both private reuse and
60/// cross-plugin composition. Parse rules publish artifacts; downstream rules
61/// consume them by format.
62#[derive(Debug, Clone, Serialize, Deserialize)]
63#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
64pub struct ArtifactDescriptor {
65    pub format: ArtifactFormat,
66    pub subject: ArtifactSubject,
67    pub producer: String,
68    /// Opaque handle managed by the SDK's DataAccess implementation.
69    /// Plugins should not create or interpret this value directly.
70    pub handle: String,
71}
72
73// ── Standard artifact formats ───────────────────────────────────────
74
75/// Standard format for tabular data artifacts.
76///
77/// Any parser for a tabular source format (CSV, TSV, Excel, Parquet, ...)
78/// should publish artifacts with this format so that generic tabular writers,
79/// compaction rules, and extractors can consume them without
80/// knowing the source format.
81pub fn tabular_v1() -> ArtifactFormat {
82    ArtifactFormat::new("binoc", "tabular", 1)
83}
84
85/// Standard format for a generic, format-neutral value tree.
86///
87/// Produced by parsers for tree-structured formats (JSON, JSONL of mixed shape,
88/// YAML, TOML, ...) and consumed by the structured-document writer. This is the
89/// fallback for any structured source that is not a consistently-shaped record
90/// collection. See the typed-record ADR.
91pub fn structured_document_v1() -> ArtifactFormat {
92    ArtifactFormat::new("binoc", "structured_document", 1)
93}
94
95/// Standard format for tier-3 *parser metadata* — facts a parser extracted about
96/// a node that are not the node's primary data payload: source-format identity
97/// and version, file-level properties, cross-table dictionaries, creator/tooling
98/// provenance. Rides as a second artifact on the parsed node (alongside a
99/// `tabular_v1` leaf, or on a multi-table container that has no table of its
100/// own). Consumed by format, like any artifact; carrying it is useful even with
101/// no current consumer (see the tiered-artifact-metadata ADR).
102pub fn parser_metadata_v1() -> ArtifactFormat {
103    ArtifactFormat::new("binoc", "parser_metadata", 1)
104}
105
106// ── Cell value model ────────────────────────────────────────────────
107
108static NULL_VALUE: Value = Value::Null;
109
110/// A single tabular cell value.
111///
112/// Scalars (`Null`/`Bool`/`Number`/`String`) diff by content. `Nested` holds a
113/// canonicalized object/array (object keys sorted recursively) and participates
114/// in diffs by equality only — a changed nested cell is reported as a cell edit,
115/// but binoc does not recurse into it (see the typed-record ADR). `String` cells
116/// are the all-untyped case used by CSV and other typeless sources.
117#[derive(Debug, Clone, PartialEq, Eq)]
118pub enum Value {
119    Null,
120    Bool(bool),
121    Number(serde_json::Number),
122    String(String),
123    Nested(Box<serde_json::Value>),
124}
125
126impl Value {
127    /// Build a cell value from arbitrary JSON, canonicalizing nested containers
128    /// so that equality is order-independent.
129    pub fn from_json(value: serde_json::Value) -> Self {
130        match value {
131            serde_json::Value::Null => Value::Null,
132            serde_json::Value::Bool(b) => Value::Bool(b),
133            serde_json::Value::Number(n) => Value::Number(n),
134            serde_json::Value::String(s) => Value::String(s),
135            other => Value::Nested(Box::new(canonicalize_json(other))),
136        }
137    }
138
139    /// The JSON representation of this cell, used when building edit params.
140    pub fn to_json(&self) -> serde_json::Value {
141        match self {
142            Value::Null => serde_json::Value::Null,
143            Value::Bool(b) => serde_json::Value::Bool(*b),
144            Value::Number(n) => serde_json::Value::Number(n.clone()),
145            Value::String(s) => serde_json::Value::String(s.clone()),
146            Value::Nested(v) => (**v).clone(),
147        }
148    }
149
150    /// A flat textual rendering for tokenization, CSV serialization, and scoring.
151    pub fn as_text(&self) -> std::borrow::Cow<'_, str> {
152        match self {
153            Value::Null => std::borrow::Cow::Borrowed(""),
154            Value::Bool(true) => std::borrow::Cow::Borrowed("true"),
155            Value::Bool(false) => std::borrow::Cow::Borrowed("false"),
156            Value::Number(n) => std::borrow::Cow::Owned(n.to_string()),
157            Value::String(s) => std::borrow::Cow::Borrowed(s.as_str()),
158            Value::Nested(v) => std::borrow::Cow::Owned(v.to_string()),
159        }
160    }
161
162    /// True when the value carries no content for keying/identity purposes
163    /// (null, or an empty/whitespace string).
164    pub fn is_blank(&self) -> bool {
165        match self {
166            Value::Null => true,
167            Value::String(s) => s.trim().is_empty(),
168            _ => false,
169        }
170    }
171
172    /// Feed a stable, type-tagged byte signature into a hasher (row alignment).
173    pub fn hash_into(&self, hasher: &mut blake3::Hasher) {
174        match self {
175            Value::Null => {
176                hasher.update(&[0]);
177            }
178            Value::Bool(b) => {
179                hasher.update(&[1, *b as u8]);
180            }
181            Value::Number(n) => {
182                hasher.update(&[2]);
183                hasher.update(n.to_string().as_bytes());
184            }
185            Value::String(s) => {
186                hasher.update(&[3]);
187                hasher.update(&(s.len() as u64).to_le_bytes());
188                hasher.update(s.as_bytes());
189            }
190            Value::Nested(v) => {
191                hasher.update(&[4]);
192                hasher.update(v.to_string().as_bytes());
193            }
194        }
195    }
196}
197
198impl Serialize for Value {
199    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
200        self.to_json().serialize(serializer)
201    }
202}
203
204impl<'de> Deserialize<'de> for Value {
205    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
206        Ok(Value::from_json(serde_json::Value::deserialize(
207            deserializer,
208        )?))
209    }
210}
211
212/// Recursively sort object keys so that nested-value equality is order-independent.
213fn canonicalize_json(value: serde_json::Value) -> serde_json::Value {
214    match value {
215        serde_json::Value::Array(items) => {
216            serde_json::Value::Array(items.into_iter().map(canonicalize_json).collect())
217        }
218        serde_json::Value::Object(map) => {
219            let sorted: BTreeMap<String, serde_json::Value> = map
220                .into_iter()
221                .map(|(k, v)| (k, canonicalize_json(v)))
222                .collect();
223            serde_json::Value::Object(sorted.into_iter().collect())
224        }
225        other => other,
226    }
227}
228
229// ── Format-neutral data types ───────────────────────────────────────
230
231/// Format-neutral tabular data: an ordered list of records with a shared column
232/// schema. Produced by CSV, JSON record arrays, JSONL, Excel, Parquet, DB, and
233/// other tabular parsers; consumed by tabular writers, compaction rules, and
234/// extractors.
235///
236/// This is the codec type for the [`tabular_v1`] artifact format.
237/// Serialize with `serde_json::to_vec`, deserialize with `serde_json::from_slice`.
238///
239/// The shape spectrum (rectangular?, named columns?, typed cells?) is *derived*
240/// from the data via [`TabularData::is_rectangular`],
241/// [`TabularData::has_named_columns`], and the cell `Value` variants — rules gate
242/// their behavior on those facts rather than on artifact subtypes.
243#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
244pub struct TabularData {
245    /// Column names. For headerless sources these are synthesized positional
246    /// labels and `has_header` is `false`.
247    pub headers: Vec<String>,
248    pub rows: Vec<Vec<Value>>,
249    /// Whether the source supplied real column names (CSV header, object keys).
250    #[serde(default = "default_true")]
251    pub has_header: bool,
252    /// Declared identity column names, in order. Empty when the source declares
253    /// no key; drives keyed row alignment when present.
254    #[serde(default, skip_serializing_if = "Vec::is_empty")]
255    pub key: Vec<String>,
256    /// Optional source-declared type per column (parallel to `headers`), when the
257    /// source format carries one (DB, Parquet, Stata). Empty means "none".
258    #[serde(default, skip_serializing_if = "Vec::is_empty")]
259    pub column_types: Vec<Option<String>>,
260    /// Optional per-column metadata bag (parallel to `headers`), when the source
261    /// format carries column-scoped facts a generic tabular consumer would not
262    /// otherwise see — labels, display formats, value-label set names, units.
263    /// Each entry is an open object (or `Null` for a column with no metadata).
264    /// Empty means "none". This is tier 1 of the tiered-metadata design (see the
265    /// tiered-artifact-metadata ADR): facts keyed to a *column*.
266    #[serde(default, skip_serializing_if = "Vec::is_empty")]
267    pub column_metadata: Vec<serde_json::Value>,
268    /// Optional table-scoped metadata bag — facts about *this table as a whole*
269    /// that are not per-column and not per-file (a single-table file folds its
270    /// source-format facts here; a table inside a multi-table container carries
271    /// only its own facts, e.g. dataset name/label). `Null` means "none". This
272    /// is tier 2 of the tiered-metadata design.
273    #[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
274    pub table_metadata: serde_json::Value,
275}
276
277fn default_true() -> bool {
278    true
279}
280
281impl TabularData {
282    /// Construct from all-string cells (CSV and other untyped sources). Cells are
283    /// wrapped in [`Value::String`]; the result is byte-identical in behavior to
284    /// the legacy all-string tabular model.
285    pub fn from_string_rows(headers: Vec<String>, rows: Vec<Vec<String>>) -> Self {
286        Self {
287            headers,
288            rows: rows
289                .into_iter()
290                .map(|row| row.into_iter().map(Value::String).collect())
291                .collect(),
292            has_header: true,
293            key: Vec::new(),
294            column_types: Vec::new(),
295            column_metadata: Vec::new(),
296            table_metadata: serde_json::Value::Null,
297        }
298    }
299
300    /// Construct from typed rows with a named header.
301    pub fn new(headers: Vec<String>, rows: Vec<Vec<Value>>) -> Self {
302        Self {
303            headers,
304            rows,
305            has_header: true,
306            key: Vec::new(),
307            column_types: Vec::new(),
308            column_metadata: Vec::new(),
309            table_metadata: serde_json::Value::Null,
310        }
311    }
312
313    /// Attach tier-1 per-column metadata (parallel to `headers`). Builder-style
314    /// so producers can enrich a table without restating every field.
315    pub fn with_column_metadata(mut self, column_metadata: Vec<serde_json::Value>) -> Self {
316        self.column_metadata = column_metadata;
317        self
318    }
319
320    /// Attach tier-2 table-scoped metadata.
321    pub fn with_table_metadata(mut self, table_metadata: serde_json::Value) -> Self {
322        self.table_metadata = table_metadata;
323        self
324    }
325
326    pub fn column_index(&self, name: &str) -> Option<usize> {
327        self.headers.iter().position(|h| h == name)
328    }
329
330    pub fn column_values(&self, name: &str) -> Option<Vec<&Value>> {
331        let idx = self.column_index(name)?;
332        Some(
333            self.rows
334                .iter()
335                .map(|r| r.get(idx).unwrap_or(&NULL_VALUE))
336                .collect(),
337        )
338    }
339
340    /// Every row has arity equal to the column count.
341    pub fn is_rectangular(&self) -> bool {
342        let width = self.headers.len();
343        self.rows.iter().all(|row| row.len() == width)
344    }
345
346    /// The source supplied real, usable column names.
347    pub fn has_named_columns(&self) -> bool {
348        self.has_header && !self.headers.is_empty()
349    }
350
351    /// Columns can be identified across rows and snapshots — the precondition for
352    /// cell-grain and column-grain edits. Otherwise the writer degrades to
353    /// row-grain output.
354    pub fn stable_columns(&self) -> bool {
355        self.has_named_columns() || self.is_rectangular()
356    }
357
358    pub fn to_csv(&self) -> String {
359        let mut out = self.headers.join(",");
360        out.push('\n');
361        for row in &self.rows {
362            let cells: Vec<String> = row.iter().map(|v| v.as_text().into_owned()).collect();
363            out.push_str(&cells.join(","));
364            out.push('\n');
365        }
366        out
367    }
368}
369
370/// Generic format-neutral value tree. Codec type for [`structured_document_v1`].
371///
372/// All source formats transcode their content into a single `serde_json::Value`
373/// tree; `format` records the origin ("json", "yaml", "toml", ...) and `source`
374/// is an open bag of serialization facts (key order, indentation, BOM, trailing
375/// newline) that consumers ignore when unknown.
376#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
377pub struct StructuredDocument {
378    pub value: serde_json::Value,
379    pub format: String,
380    #[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
381    pub source: serde_json::Value,
382}
383
384/// Codec type for [`parser_metadata_v1`] — tier-3 parser metadata.
385///
386/// `format` is the producer's source-format identity (e.g. `"stata_dta"`,
387/// `"sas7bdat"`, `"sas_xport"`), so a consumer can interpret `value` without
388/// guessing. `value` is an open bag of parser-level facts; consumers diff it
389/// generically and ignore keys they do not recognize. Deliberately flat: this
390/// is "a matching subtype for a record artifact" today, and may grow typed
391/// structure in a future version rather than via artifact-format inheritance.
392#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
393pub struct ParserMetadata {
394    pub format: String,
395    pub value: serde_json::Value,
396}
397
398impl ParserMetadata {
399    pub fn new(format: impl Into<String>, value: serde_json::Value) -> Self {
400        Self {
401            format: format.into(),
402            value,
403        }
404    }
405}
406
407// ── Dataset semantics config ───────────────────────────────────────
408
409/// SDK-owned dataset semantics section shared by plugins.
410///
411/// Hosts pass this through unchanged; plugins deserialize the parts they
412/// understand. The schema is intentionally conservative in v1.
413#[derive(Debug, Clone, Default, Serialize, Deserialize)]
414pub struct DatasetSemanticsV1 {
415    #[serde(default)]
416    pub files: FileIdentityConfig,
417    #[serde(default)]
418    pub tables: TableConfig,
419    #[serde(default)]
420    pub correspondence: CorrespondenceConfig,
421}
422
423#[derive(Debug, Clone, Default, Serialize, Deserialize)]
424pub struct CorrespondenceConfig {
425    #[serde(default, skip_serializing_if = "Option::is_none")]
426    pub expand_renamed_unchanged_collections: Option<bool>,
427    /// Maximum decompressed size of a single gzip stream, in bytes. `None` uses
428    /// the stdlib default. Raise this for legitimately large `.gz` payloads;
429    /// the cap exists only as a decompression-bomb bound, so any value over a
430    /// bundle's real size is safe.
431    #[serde(default, skip_serializing_if = "Option::is_none")]
432    pub max_gzip_bytes: Option<u64>,
433    /// Maximum decompressed size of a single archive entry (one member of a
434    /// `.zip`/`.tar`/`.tgz`), in bytes. `None` uses the stdlib default.
435    #[serde(default, skip_serializing_if = "Option::is_none")]
436    pub max_archive_entry_bytes: Option<u64>,
437    /// Maximum total decompressed size of a whole archive (sum over all
438    /// extracted entries), in bytes. `None` uses the stdlib default. This is the
439    /// cap a real multi-GB government bundle is most likely to hit.
440    #[serde(default, skip_serializing_if = "Option::is_none")]
441    pub max_archive_total_bytes: Option<u64>,
442}
443
444#[derive(Debug, Clone, Default, Serialize, Deserialize)]
445pub struct FileIdentityConfig {
446    #[serde(default)]
447    pub correspondences: Vec<FileCorrespondenceRule>,
448}
449
450#[derive(Debug, Clone, Serialize, Deserialize)]
451pub struct FileCorrespondenceRule {
452    pub name: String,
453    #[serde(default)]
454    pub left: FileSelector,
455    #[serde(default)]
456    pub right: FileSelector,
457    pub key: String,
458    #[serde(default, skip_serializing_if = "Option::is_none")]
459    pub logical_path: Option<String>,
460    #[serde(default)]
461    pub cardinality: Cardinality,
462    #[serde(default)]
463    pub on_null_key: IdentityFailurePolicy,
464    #[serde(default)]
465    pub on_duplicate_key: IdentityFailurePolicy,
466    #[serde(default)]
467    pub report_path_change: bool,
468}
469
470#[derive(Debug, Clone, Default, Serialize, Deserialize)]
471pub struct FileSelector {
472    #[serde(default, skip_serializing_if = "Option::is_none")]
473    pub path: Option<String>,
474    #[serde(default, skip_serializing_if = "Option::is_none")]
475    pub path_regex: Option<String>,
476}
477
478#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
479#[serde(rename_all = "kebab-case")]
480pub enum Cardinality {
481    #[default]
482    OneToOne,
483}
484
485#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
486#[serde(rename_all = "snake_case")]
487pub enum IdentityFailurePolicy {
488    #[default]
489    Diagnostic,
490    Error,
491    Ignore,
492}
493
494#[derive(Debug, Clone, Default, Serialize)]
495pub struct TableConfig {
496    #[serde(default)]
497    pub defaults: TableDefaults,
498    #[serde(default)]
499    pub entries: Vec<TableEntry>,
500}
501
502impl<'de> Deserialize<'de> for TableConfig {
503    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
504    where
505        D: serde::Deserializer<'de>,
506    {
507        #[derive(Deserialize)]
508        #[serde(untagged)]
509        enum Repr {
510            Entries(Vec<TableEntry>),
511            Full {
512                #[serde(default)]
513                defaults: TableDefaults,
514                #[serde(default)]
515                entries: Vec<TableEntry>,
516            },
517        }
518
519        match Repr::deserialize(deserializer)? {
520            Repr::Entries(entries) => Ok(Self {
521                defaults: TableDefaults::default(),
522                entries,
523            }),
524            Repr::Full { defaults, entries } => Ok(Self { defaults, entries }),
525        }
526    }
527}
528
529#[derive(Debug, Clone, Default, Serialize, Deserialize)]
530pub struct TableDefaults {
531    #[serde(default)]
532    pub parse: TabularParseConfig,
533    #[serde(default)]
534    pub row_identity: RowIdentity,
535}
536
537#[derive(Debug, Clone, Default, Serialize)]
538pub struct TableEntry {
539    #[serde(default, rename = "match")]
540    pub match_: TableSelector,
541    #[serde(default)]
542    pub parse: TabularParseConfig,
543    #[serde(default)]
544    pub row_identity: RowIdentity,
545}
546
547impl<'de> Deserialize<'de> for TableEntry {
548    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
549    where
550        D: serde::Deserializer<'de>,
551    {
552        #[derive(Default, Deserialize)]
553        struct RawTableEntry {
554            #[serde(default, rename = "match")]
555            match_: TableSelector,
556            #[serde(default)]
557            parse: TabularParseConfig,
558            #[serde(default)]
559            row_identity: RowIdentity,
560            #[serde(default)]
561            logical_name: Option<String>,
562            #[serde(default)]
563            path: Option<String>,
564            #[serde(default)]
565            path_regex: Option<String>,
566            #[serde(default)]
567            columns: Vec<String>,
568            #[serde(default)]
569            on_null_key: Option<IdentityFailurePolicy>,
570            #[serde(default)]
571            on_duplicate_key: Option<IdentityFailurePolicy>,
572        }
573
574        let raw = RawTableEntry::deserialize(deserializer)?;
575        let mut match_ = raw.match_;
576        if match_.logical_name.is_none() {
577            match_.logical_name = raw.logical_name;
578        }
579        if match_.source.is_none() && (raw.path.is_some() || raw.path_regex.is_some()) {
580            match_.source = Some(FileSelector {
581                path: raw.path,
582                path_regex: raw.path_regex,
583            });
584        }
585
586        let mut row_identity = raw.row_identity;
587        if row_identity.columns.is_empty() {
588            row_identity.columns = raw.columns;
589        }
590        if let Some(policy) = raw.on_null_key {
591            row_identity.on_null_key = policy;
592        }
593        if let Some(policy) = raw.on_duplicate_key {
594            row_identity.on_duplicate_key = policy;
595        }
596
597        Ok(Self {
598            match_,
599            parse: raw.parse,
600            row_identity,
601        })
602    }
603}
604
605#[derive(Debug, Clone, Default, Serialize, Deserialize)]
606pub struct TableSelector {
607    #[serde(default, skip_serializing_if = "Option::is_none")]
608    pub logical_name: Option<String>,
609    #[serde(default, skip_serializing_if = "Option::is_none")]
610    pub source: Option<FileSelector>,
611}
612
613#[derive(Debug, Clone, Serialize, Deserialize)]
614pub struct TabularParseConfig {
615    #[serde(default = "default_header")]
616    pub header: bool,
617    #[serde(default, skip_serializing_if = "Option::is_none")]
618    pub delimiter: Option<String>,
619}
620
621impl Default for TabularParseConfig {
622    fn default() -> Self {
623        Self {
624            header: true,
625            delimiter: None,
626        }
627    }
628}
629
630fn default_header() -> bool {
631    true
632}
633
634#[derive(Debug, Clone, Default, Serialize, Deserialize)]
635pub struct RowIdentity {
636    #[serde(default)]
637    pub columns: Vec<String>,
638    #[serde(default)]
639    pub cardinality: Cardinality,
640    #[serde(default)]
641    pub on_null_key: IdentityFailurePolicy,
642    #[serde(default)]
643    pub on_duplicate_key: IdentityFailurePolicy,
644}
645
646/// A pair of tabular data (left/right sides of a comparison).
647#[derive(Debug, Clone, Serialize, Deserialize)]
648pub struct TabularDataPair {
649    pub left: Option<TabularData>,
650    pub right: Option<TabularData>,
651}
652
653impl TabularDataPair {
654    /// Build a `TabularDataPair` from [`tabular_v1`] artifacts on a node.
655    ///
656    /// Returns `None` if neither left nor right artifact is present.
657    /// This is the standard way for rules and extractors to obtain
658    /// tabular data without knowing the source format.
659    pub fn from_artifacts(
660        node: &crate::ir::DiffNode,
661        data: &dyn crate::traits::DataAccess,
662    ) -> Option<Self> {
663        let fmt = tabular_v1();
664        let left = node
665            .artifacts
666            .iter()
667            .find(|a| a.format == fmt && a.subject == ArtifactSubject::Left)
668            .and_then(|desc| data.get_artifact(desc).ok()?)
669            .and_then(|bytes| serde_json::from_slice(&bytes).ok());
670        let right = node
671            .artifacts
672            .iter()
673            .find(|a| a.format == fmt && a.subject == ArtifactSubject::Right)
674            .and_then(|desc| data.get_artifact(desc).ok()?)
675            .and_then(|bytes| serde_json::from_slice(&bytes).ok());
676        if left.is_none() && right.is_none() {
677            return None;
678        }
679        Some(Self { left, right })
680    }
681}
682
683// ── Tabular extraction ──────────────────────────────────────────────
684
685/// Shared extraction logic for tabular data.
686///
687/// Given a `TabularDataPair` and an aspect name, produces the
688/// corresponding `ExtractResult`. This is format-neutral — any
689/// writer or compatibility plugin that works with tabular artifacts can
690/// delegate extraction here.
691pub fn tabular_extract(
692    pair: &TabularDataPair,
693    _node: &DiffNode,
694    aspect: &str,
695) -> Option<ExtractResult> {
696    match aspect {
697        "rows_added" => {
698            let right = pair.right.as_ref()?;
699            let left_len = pair.left.as_ref().map_or(0, |l| l.rows.len());
700            if left_len >= right.rows.len() {
701                return Some(ExtractResult::Text("No rows added.\n".into()));
702            }
703            let added = TabularData::new(right.headers.clone(), right.rows[left_len..].to_vec());
704            Some(ExtractResult::Text(added.to_csv()))
705        }
706        "rows_removed" => {
707            let left = pair.left.as_ref()?;
708            let right_len = pair.right.as_ref().map_or(0, |r| r.rows.len());
709            if right_len >= left.rows.len() {
710                return Some(ExtractResult::Text("No rows removed.\n".into()));
711            }
712            let removed = TabularData::new(left.headers.clone(), left.rows[right_len..].to_vec());
713            Some(ExtractResult::Text(removed.to_csv()))
714        }
715        "cells_changed" => {
716            let left = pair.left.as_ref()?;
717            let right = pair.right.as_ref()?;
718            let common_cols = tabular_columns_in_common(left, right);
719            let min_rows = left.rows.len().min(right.rows.len());
720
721            let mut out = String::from("row,column,old_value,new_value\n");
722            for i in 0..min_rows {
723                for col in &common_cols {
724                    let li = left.column_index(col)?;
725                    let ri = right.column_index(col)?;
726                    let lv = left.rows[i].get(li).unwrap_or(&NULL_VALUE);
727                    let rv = right.rows[i].get(ri).unwrap_or(&NULL_VALUE);
728                    if lv != rv {
729                        out.push_str(&format!("{i},{col},{},{}\n", lv.as_text(), rv.as_text()));
730                    }
731                }
732            }
733            Some(ExtractResult::Text(out))
734        }
735        "columns_added" => {
736            let left = pair.left.as_ref()?;
737            let right = pair.right.as_ref()?;
738            let left_set: std::collections::BTreeSet<&str> =
739                left.headers.iter().map(|s| s.as_str()).collect();
740            let added: Vec<&str> = right
741                .headers
742                .iter()
743                .filter(|h| !left_set.contains(h.as_str()))
744                .map(|h| h.as_str())
745                .collect();
746            if added.is_empty() {
747                return Some(ExtractResult::Text("No columns added.\n".into()));
748            }
749            let mut out = String::new();
750            for col in &added {
751                out.push_str(&format!("{col}\n"));
752                if let Some(vals) = right.column_values(col) {
753                    for val in vals {
754                        out.push_str(&format!("  {}\n", val.as_text()));
755                    }
756                }
757            }
758            Some(ExtractResult::Text(out))
759        }
760        "columns_removed" => {
761            let left = pair.left.as_ref()?;
762            let right = pair.right.as_ref()?;
763            let right_set: std::collections::BTreeSet<&str> =
764                right.headers.iter().map(|s| s.as_str()).collect();
765            let removed: Vec<&str> = left
766                .headers
767                .iter()
768                .filter(|h| !right_set.contains(h.as_str()))
769                .map(|h| h.as_str())
770                .collect();
771            if removed.is_empty() {
772                return Some(ExtractResult::Text("No columns removed.\n".into()));
773            }
774            let mut out = String::new();
775            for col in &removed {
776                out.push_str(&format!("{col}\n"));
777                if let Some(vals) = left.column_values(col) {
778                    for val in vals {
779                        out.push_str(&format!("  {}\n", val.as_text()));
780                    }
781                }
782            }
783            Some(ExtractResult::Text(out))
784        }
785        "content" | "full" => {
786            let mut out = String::new();
787            if let Some(left) = &pair.left {
788                out.push_str("--- left\n");
789                out.push_str(&left.to_csv());
790            }
791            if let Some(right) = &pair.right {
792                out.push_str("+++ right\n");
793                out.push_str(&right.to_csv());
794            }
795            Some(ExtractResult::Text(out))
796        }
797        _ => None,
798    }
799}
800
801fn tabular_columns_in_common(left: &TabularData, right: &TabularData) -> Vec<String> {
802    let left_set: std::collections::BTreeSet<&str> =
803        left.headers.iter().map(|s| s.as_str()).collect();
804    right
805        .headers
806        .iter()
807        .filter(|h| left_set.contains(h.as_str()))
808        .cloned()
809        .collect()
810}
811
812// ── Item types ──────────────────────────────────────────────────────
813
814/// Metadata-only view of one side of a comparison. Carries logical identity
815/// and content metadata but NOT a filesystem path — data access goes through
816/// `DataAccess`.
817///
818/// # Metadata invariants
819///
820/// `content_hash`, `size`, and `media_type` are **opportunistic hints**.
821/// Producers (expand rules like directory/zip, or data backends)
822/// populate them when doing so is cheap — typically as a byproduct of work
823/// they were already performing. Consumers **must not assume presence**, but
824/// **may trust presence**: when a field is set, the value accurately reflects
825/// the current bytes. Use [`ItemRef::resolve_hash`] / [`ItemRef::resolve_size`]
826/// to obtain a value with a transparent fall-back read.
827///
828/// This keeps fast paths (directory-only listings, short-circuit identical
829/// detection) cheap while letting consumers that need a value — most notably
830/// the move detector, which correlates leaves across container boundaries —
831/// hydrate on demand.
832#[derive(Debug, Clone, Serialize, Deserialize)]
833#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
834pub struct ItemRef {
835    /// User-meaningful location within a snapshot. `/>` marks a
836    /// decompose boundary; a literal segment beginning with `>` is escaped
837    /// as `\>`.
838    pub logical_path: String,
839    pub is_dir: bool,
840    #[serde(default, skip_serializing_if = "Option::is_none")]
841    pub content_hash: Option<String>,
842    #[serde(default, skip_serializing_if = "Option::is_none")]
843    pub size: Option<u64>,
844    #[serde(default, skip_serializing_if = "Option::is_none")]
845    pub media_type: Option<String>,
846    /// Optional projection metadata supplied by rule packs while they still
847    /// know the vocabulary. Core carries this through but does not interpret
848    /// file names, media types, or plugin-specific tags.
849    #[serde(default, skip_serializing_if = "crate::projection_hint_is_default")]
850    pub projection_hint: crate::ProjectionHint,
851    /// Opaque identifier used by DataAccess implementations to locate data.
852    /// Plugin authors should not create or interpret this value directly.
853    #[serde(default)]
854    pub handle: String,
855}
856
857impl ItemRef {
858    pub fn extension(&self) -> Option<String> {
859        std::path::Path::new(&self.logical_path)
860            .extension()
861            .map(|e| format!(".{}", e.to_string_lossy().to_lowercase()))
862    }
863
864    /// Return the item's BLAKE3 content hash, computing it from bytes if
865    /// not already cached on this `ItemRef`. Never valid for directories.
866    pub fn resolve_hash(&self, data: &dyn crate::DataAccess) -> crate::BinocResult<String> {
867        if let Some(hash) = &self.content_hash {
868            return Ok(hash.clone());
869        }
870        let mut reader = data.open_read(self)?;
871        let mut hasher = blake3::Hasher::new();
872        std::io::copy(&mut reader, &mut hasher)?;
873        Ok(hasher.finalize().to_hex().to_string())
874    }
875
876    /// Return the item's byte length, reading from the backend if not already
877    /// cached on this `ItemRef`. Never valid for directories.
878    pub fn resolve_size(&self, data: &dyn crate::DataAccess) -> crate::BinocResult<u64> {
879        if let Some(size) = self.size {
880            return Ok(size);
881        }
882        let bytes = data.read_bytes(self)?;
883        Ok(bytes.len() as u64)
884    }
885}
886
887/// A pair of items to compare. Either side may be None (add/remove).
888#[derive(Debug, Clone, Serialize, Deserialize)]
889#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
890pub struct ItemPair {
891    pub left: Option<ItemRef>,
892    pub right: Option<ItemRef>,
893}
894
895impl ItemPair {
896    pub fn both(left: ItemRef, right: ItemRef) -> Self {
897        Self {
898            left: Some(left),
899            right: Some(right),
900        }
901    }
902
903    pub fn added(right: ItemRef) -> Self {
904        Self {
905            left: None,
906            right: Some(right),
907        }
908    }
909
910    pub fn removed(left: ItemRef) -> Self {
911        Self {
912            left: Some(left),
913            right: None,
914        }
915    }
916
917    pub fn logical_path(&self) -> &str {
918        self.right
919            .as_ref()
920            .or(self.left.as_ref())
921            .map(|i| i.logical_path.as_str())
922            .unwrap_or("")
923    }
924
925    pub fn extension(&self) -> Option<String> {
926        self.right
927            .as_ref()
928            .or(self.left.as_ref())
929            .and_then(|i| i.extension())
930    }
931
932    pub fn media_type(&self) -> Option<&str> {
933        self.right
934            .as_ref()
935            .or(self.left.as_ref())
936            .and_then(|i| i.media_type.as_deref())
937    }
938
939    pub fn is_dir(&self) -> bool {
940        self.right.as_ref().is_some_and(|i| i.is_dir)
941            || self.left.as_ref().is_some_and(|i| i.is_dir)
942    }
943
944    pub fn matching_content_hash(&self) -> Option<&str> {
945        match (&self.left, &self.right) {
946            (Some(l), Some(r)) => match (&l.content_hash, &r.content_hash) {
947                (Some(hl), Some(hr)) if hl == hr => Some(hl.as_str()),
948                _ => None,
949            },
950            _ => None,
951        }
952    }
953}
954
955/// Result of an extract (on-demand detail retrieval) operation.
956pub enum ExtractResult {
957    Text(String),
958    Binary(Vec<u8>),
959}
960
961#[cfg(test)]
962mod tests {
963    use super::*;
964
965    fn bare_item(logical: &str, is_dir: bool) -> ItemRef {
966        ItemRef {
967            logical_path: logical.into(),
968            is_dir,
969            content_hash: None,
970            size: None,
971            media_type: None,
972            projection_hint: Default::default(),
973            handle: String::new(),
974        }
975    }
976
977    #[test]
978    fn item_ref_extension() {
979        let item = bare_item("data.csv", false);
980        assert_eq!(item.extension(), Some(".csv".into()));
981    }
982
983    #[test]
984    fn item_ref_extension_none() {
985        let item = bare_item("Makefile", false);
986        assert_eq!(item.extension(), None);
987    }
988
989    #[test]
990    fn item_pair_logical_path_prefers_right() {
991        let left = bare_item("left.txt", false);
992        let right = bare_item("right.txt", false);
993        let pair = ItemPair::both(left, right);
994        assert_eq!(pair.logical_path(), "right.txt");
995    }
996
997    #[test]
998    fn item_pair_logical_path_falls_back_to_left() {
999        let left = bare_item("only.txt", false);
1000        let pair = ItemPair::removed(left);
1001        assert_eq!(pair.logical_path(), "only.txt");
1002    }
1003
1004    #[test]
1005    fn item_pair_is_dir() {
1006        let dir = bare_item("sub", true);
1007        let pair = ItemPair::added(dir);
1008        assert!(pair.is_dir());
1009    }
1010
1011    #[test]
1012    fn item_pair_matching_hash() {
1013        let mut left = bare_item("f", false);
1014        left.content_hash = Some("abc".into());
1015        let mut right = bare_item("f", false);
1016        right.content_hash = Some("abc".into());
1017        let pair = ItemPair::both(left, right);
1018        assert_eq!(pair.matching_content_hash(), Some("abc"));
1019    }
1020}