1use std::collections::BTreeMap;
2
3use serde::{Deserialize, Serialize};
4
5use crate::ir::DiffNode;
6
7#[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#[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#[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 pub handle: String,
71}
72
73pub fn tabular_v1() -> ArtifactFormat {
82 ArtifactFormat::new("binoc", "tabular", 1)
83}
84
85pub fn structured_document_v1() -> ArtifactFormat {
92 ArtifactFormat::new("binoc", "structured_document", 1)
93}
94
95pub fn parser_metadata_v1() -> ArtifactFormat {
103 ArtifactFormat::new("binoc", "parser_metadata", 1)
104}
105
106static NULL_VALUE: Value = Value::Null;
109
110#[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 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 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 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 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 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
212fn 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
244pub struct TabularData {
245 pub headers: Vec<String>,
248 pub rows: Vec<Vec<Value>>,
249 #[serde(default = "default_true")]
251 pub has_header: bool,
252 #[serde(default, skip_serializing_if = "Vec::is_empty")]
255 pub key: Vec<String>,
256 #[serde(default, skip_serializing_if = "Vec::is_empty")]
259 pub column_types: Vec<Option<String>>,
260 #[serde(default, skip_serializing_if = "Vec::is_empty")]
267 pub column_metadata: Vec<serde_json::Value>,
268 #[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 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 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 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 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 pub fn is_rectangular(&self) -> bool {
342 let width = self.headers.len();
343 self.rows.iter().all(|row| row.len() == width)
344 }
345
346 pub fn has_named_columns(&self) -> bool {
348 self.has_header && !self.headers.is_empty()
349 }
350
351 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#[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#[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#[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 #[serde(default, skip_serializing_if = "Option::is_none")]
432 pub max_gzip_bytes: Option<u64>,
433 #[serde(default, skip_serializing_if = "Option::is_none")]
436 pub max_archive_entry_bytes: Option<u64>,
437 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
648pub struct TabularDataPair {
649 pub left: Option<TabularData>,
650 pub right: Option<TabularData>,
651}
652
653impl TabularDataPair {
654 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
683pub 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#[derive(Debug, Clone, Serialize, Deserialize)]
833#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
834pub struct ItemRef {
835 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 #[serde(default, skip_serializing_if = "crate::projection_hint_is_default")]
850 pub projection_hint: crate::ProjectionHint,
851 #[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 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 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#[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
955pub 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}