Skip to main content

iscc_lib/
lib.rs

1//! High-performance Rust implementation of ISO 24138:2024 (ISCC).
2//!
3//! This crate provides the core ISCC algorithm implementations. All 10 `gen_*_v0`
4//! functions are the public Tier 1 API surface, designed to be compatible with
5//! the `iscc-core` Python reference implementation.
6
7pub(crate) mod cdc;
8pub mod codec;
9pub(crate) mod conformance;
10pub(crate) mod dct;
11pub(crate) mod minhash;
12pub(crate) mod simhash;
13pub mod streaming;
14pub mod types;
15pub(crate) mod utils;
16pub(crate) mod wtahash;
17
18pub use cdc::alg_cdc_chunks;
19pub use codec::encode_base64;
20pub use codec::iscc_decompose;
21pub use conformance::conformance_selftest;
22pub use minhash::alg_minhash_256;
23pub use simhash::{alg_simhash, sliding_window};
24pub use streaming::{DataHasher, InstanceHasher};
25pub use types::*;
26#[cfg(feature = "text-processing")]
27pub use utils::{text_clean, text_collapse};
28pub use utils::{text_remove_newlines, text_trim};
29
30/// Max UTF-8 byte length for name metadata trimming.
31#[cfg(feature = "meta-code")]
32pub const META_TRIM_NAME: usize = 128;
33
34/// Max UTF-8 byte length for description metadata trimming.
35#[cfg(feature = "meta-code")]
36pub const META_TRIM_DESCRIPTION: usize = 4096;
37
38/// Max decoded payload size in bytes for the meta element.
39#[cfg(feature = "meta-code")]
40pub const META_TRIM_META: usize = 128_000;
41
42/// Buffer size in bytes for streaming file reads (4 MB).
43pub const IO_READ_SIZE: usize = 4_194_304;
44
45/// Character n-gram width for text content features.
46pub const TEXT_NGRAM_SIZE: usize = 13;
47
48/// Error type for ISCC operations.
49#[derive(Debug, thiserror::Error)]
50pub enum IsccError {
51    /// Input data is invalid.
52    #[error("invalid input: {0}")]
53    InvalidInput(String),
54}
55
56/// Result type alias for ISCC operations.
57pub type IsccResult<T> = Result<T, IsccError>;
58
59/// Interleave two 32-byte SimHash digests in 4-byte chunks.
60///
61/// Takes the first 16 bytes of each digest and interleaves them into
62/// a 32-byte result: 4 bytes from `a`, 4 bytes from `b`, alternating
63/// for 4 rounds (8 chunks total).
64#[cfg(feature = "meta-code")]
65fn interleave_digests(a: &[u8], b: &[u8]) -> Vec<u8> {
66    let mut result = vec![0u8; 32];
67    for chunk in 0..4 {
68        let src = chunk * 4;
69        let dst_a = chunk * 8;
70        let dst_b = chunk * 8 + 4;
71        result[dst_a..dst_a + 4].copy_from_slice(&a[src..src + 4]);
72        result[dst_b..dst_b + 4].copy_from_slice(&b[src..src + 4]);
73    }
74    result
75}
76
77/// Compute a SimHash digest from the name text for meta hashing.
78///
79/// Applies `text_collapse`, generates width-3 sliding window n-grams,
80/// hashes each with BLAKE3, and produces a SimHash.
81#[cfg(feature = "meta-code")]
82fn meta_name_simhash(name: &str) -> Vec<u8> {
83    let collapsed_name = utils::text_collapse(name);
84    let name_ngrams = simhash::sliding_window_strs(&collapsed_name, 3);
85    let name_hashes: Vec<[u8; 32]> = name_ngrams
86        .iter()
87        .map(|ng| *blake3::hash(ng.as_bytes()).as_bytes())
88        .collect();
89    simhash::alg_simhash_inner(&name_hashes)
90}
91
92/// Compute a similarity-preserving 256-bit hash from metadata text.
93///
94/// Produces a SimHash digest from `name` n-grams. When `extra` is provided,
95/// interleaves the name and extra SimHash digests in 4-byte chunks.
96#[cfg(feature = "meta-code")]
97fn soft_hash_meta_v0(name: &str, extra: Option<&str>) -> Vec<u8> {
98    let name_simhash = meta_name_simhash(name);
99
100    match extra {
101        None | Some("") => name_simhash,
102        Some(extra_str) => {
103            let collapsed_extra = utils::text_collapse(extra_str);
104            let extra_ngrams = simhash::sliding_window_strs(&collapsed_extra, 3);
105            let extra_hashes: Vec<[u8; 32]> = extra_ngrams
106                .iter()
107                .map(|ng| *blake3::hash(ng.as_bytes()).as_bytes())
108                .collect();
109            let extra_simhash = simhash::alg_simhash_inner(&extra_hashes);
110
111            interleave_digests(&name_simhash, &extra_simhash)
112        }
113    }
114}
115
116/// Compute a similarity-preserving 256-bit hash from name text and raw bytes.
117///
118/// Like `soft_hash_meta_v0` but the extra data is raw bytes instead of text.
119/// Uses width-4 byte n-grams (no `text_collapse`) for the bytes path,
120/// and interleaves name/bytes SimHash digests in 4-byte chunks.
121#[cfg(feature = "meta-code")]
122fn soft_hash_meta_v0_with_bytes(name: &str, extra: &[u8]) -> Vec<u8> {
123    let name_simhash = meta_name_simhash(name);
124
125    if extra.is_empty() {
126        return name_simhash;
127    }
128
129    let byte_ngrams = simhash::sliding_window_bytes(extra, 4);
130    let byte_hashes: Vec<[u8; 32]> = byte_ngrams
131        .iter()
132        .map(|ng| *blake3::hash(ng).as_bytes())
133        .collect();
134    let byte_simhash = simhash::alg_simhash_inner(&byte_hashes);
135
136    interleave_digests(&name_simhash, &byte_simhash)
137}
138
139/// Decode a Data-URL's base64 payload.
140///
141/// Expects a string starting with `"data:"`. Splits on the first `,` and
142/// decodes the remainder as standard base64. Returns `InvalidInput` on
143/// missing comma or invalid base64.
144#[cfg(feature = "meta-code")]
145fn decode_data_url(data_url: &str) -> IsccResult<Vec<u8>> {
146    let payload_b64 = data_url
147        .split_once(',')
148        .map(|(_, b64)| b64)
149        .ok_or_else(|| IsccError::InvalidInput("Data-URL missing comma separator".into()))?;
150    data_encoding::BASE64
151        .decode(payload_b64.as_bytes())
152        .map_err(|e| IsccError::InvalidInput(format!("invalid base64 in Data-URL: {e}")))
153}
154
155/// Parse a meta string as JSON and re-serialize to RFC 8785 (JCS) canonical bytes.
156#[cfg(feature = "meta-code")]
157fn parse_meta_json(meta_str: &str) -> IsccResult<Vec<u8>> {
158    let parsed: serde_json::Value = serde_json::from_str(meta_str)
159        .map_err(|e| IsccError::InvalidInput(format!("invalid JSON in meta: {e}")))?;
160    let mut buf = Vec::new();
161    serde_json_canonicalizer::to_writer(&parsed, &mut buf)
162        .map_err(|e| IsccError::InvalidInput(format!("JSON canonicalization failed: {e}")))?;
163    Ok(buf)
164}
165
166/// Build a Data-URL from canonical JSON bytes.
167///
168/// Uses `application/ld+json` media type if the JSON has an `@context` key,
169/// otherwise `application/json`. Encodes payload as standard base64 with padding.
170#[cfg(feature = "meta-code")]
171fn build_meta_data_url(json_bytes: &[u8], json_value: &serde_json::Value) -> String {
172    let media_type = if json_value.get("@context").is_some() {
173        "application/ld+json"
174    } else {
175        "application/json"
176    };
177    let b64 = data_encoding::BASE64.encode(json_bytes);
178    format!("data:{media_type};base64,{b64}")
179}
180
181/// Encode a raw digest into an ISCC unit string.
182///
183/// Takes integer type identifiers (matching `MainType`, `SubType`, `Version` enum values)
184/// and a raw digest, returns a base32-encoded ISCC unit string.
185///
186/// # Errors
187///
188/// Returns `IsccError::InvalidInput` if enum values are out of range, if `mtype` is
189/// `MainType::Iscc` (5), or if `digest.len() < bit_length / 8`.
190pub fn encode_component(
191    mtype: u8,
192    stype: u8,
193    version: u8,
194    bit_length: u32,
195    digest: &[u8],
196) -> IsccResult<String> {
197    let mt = codec::MainType::try_from(mtype)?;
198    let st = codec::SubType::try_from(stype)?;
199    let vs = codec::Version::try_from(version)?;
200    let needed = (bit_length / 8) as usize;
201    if digest.len() < needed {
202        return Err(IsccError::InvalidInput(format!(
203            "digest length {} < bit_length/8 ({})",
204            digest.len(),
205            needed
206        )));
207    }
208    codec::encode_component(mt, st, vs, bit_length, digest)
209}
210
211/// Decode an ISCC unit string into its header components and raw digest.
212///
213/// Inverse of [`encode_component`]. Strips an optional `"ISCC:"` prefix and
214/// dashes, base32-decodes the string, parses the variable-length header, and
215/// returns the digest truncated to exactly the encoded bit-length.
216///
217/// Returns `(maintype, subtype, version, length_index, digest)` where the
218/// integer fields match [`codec::MainType`], [`codec::SubType`], and
219/// [`codec::Version`] enum values.
220///
221/// # Errors
222///
223/// Returns `IsccError::InvalidInput` on invalid base32 input, malformed
224/// header, or if the decoded body is shorter than the expected digest length.
225pub fn iscc_decode(iscc: &str) -> IsccResult<(u8, u8, u8, u8, Vec<u8>)> {
226    // Strip optional "ISCC:" prefix (case-sensitive, matching iscc_decompose)
227    let clean = iscc.strip_prefix("ISCC:").unwrap_or(iscc);
228    // Remove dashes (matching iscc_clean behavior for base32 input)
229    let clean = clean.replace('-', "");
230    let raw = codec::decode_base32(&clean)?;
231    let (mt, st, vs, length_index, tail) = codec::decode_header(&raw)?;
232    let bit_length = codec::decode_length(mt, length_index, st);
233    let nbytes = (bit_length / 8) as usize;
234    if tail.len() < nbytes {
235        return Err(IsccError::InvalidInput(format!(
236            "decoded body too short: expected {nbytes} digest bytes, got {}",
237            tail.len()
238        )));
239    }
240    Ok((
241        mt as u8,
242        st as u8,
243        vs as u8,
244        length_index as u8,
245        tail[..nbytes].to_vec(),
246    ))
247}
248
249/// Convert a JSON string into a `data:` URL with JCS canonicalization.
250///
251/// Parses the JSON, re-serializes to [RFC 8785 (JCS)](https://www.rfc-editor.org/rfc/rfc8785)
252/// canonical form, base64-encodes the result, and wraps it in a `data:` URL.
253/// Uses `application/ld+json` media type when the JSON contains an `@context`
254/// key, otherwise `application/json`.
255///
256/// This enables all language bindings to support dict/object meta parameters
257/// by serializing to JSON once (language-specific) then delegating encoding
258/// to Rust.
259///
260/// # Errors
261///
262/// Returns [`IsccError::InvalidInput`] if `json` is not valid JSON or if
263/// JCS canonicalization fails.
264///
265/// # Examples
266///
267/// ```
268/// # use iscc_lib::json_to_data_url;
269/// let url = json_to_data_url(r#"{"key": "value"}"#).unwrap();
270/// assert!(url.starts_with("data:application/json;base64,"));
271///
272/// let ld_url = json_to_data_url(r#"{"@context": "https://schema.org"}"#).unwrap();
273/// assert!(ld_url.starts_with("data:application/ld+json;base64,"));
274/// ```
275#[cfg(feature = "meta-code")]
276pub fn json_to_data_url(json: &str) -> IsccResult<String> {
277    let parsed: serde_json::Value = serde_json::from_str(json)
278        .map_err(|e| IsccError::InvalidInput(format!("invalid JSON: {e}")))?;
279    let mut canonical_bytes = Vec::new();
280    serde_json_canonicalizer::to_writer(&parsed, &mut canonical_bytes)
281        .map_err(|e| IsccError::InvalidInput(format!("JSON canonicalization failed: {e}")))?;
282    Ok(build_meta_data_url(&canonical_bytes, &parsed))
283}
284
285/// Generate a Meta-Code from name and optional metadata.
286///
287/// Produces an ISCC Meta-Code by hashing the provided name, description,
288/// and metadata fields using the SimHash algorithm. When `meta` is provided,
289/// it is treated as either a Data-URL (if starting with `"data:"`) or a JSON
290/// string, and the decoded/serialized bytes are used for similarity hashing
291/// and metahash computation.
292#[cfg(feature = "meta-code")]
293pub fn gen_meta_code_v0(
294    name: &str,
295    description: Option<&str>,
296    meta: Option<&str>,
297    bits: u32,
298) -> IsccResult<MetaCodeResult> {
299    // Normalize name: clean → remove newlines → trim to 128 bytes
300    let name = utils::text_clean(name);
301    let name = utils::text_remove_newlines(&name);
302    let name = utils::text_trim(&name, META_TRIM_NAME);
303
304    if name.is_empty() {
305        return Err(IsccError::InvalidInput(
306            "name is empty after normalization".into(),
307        ));
308    }
309
310    // Normalize description: clean → trim to 4096 bytes
311    let desc_str = description.unwrap_or("");
312    let desc_clean = utils::text_clean(desc_str);
313    let desc_clean = utils::text_trim(&desc_clean, META_TRIM_DESCRIPTION);
314
315    // Pre-decode fast check: reject obviously oversized meta strings
316    if let Some(meta_str) = meta {
317        const PRE_DECODE_LIMIT: usize = META_TRIM_META * 4 / 3 + 256;
318        if meta_str.len() > PRE_DECODE_LIMIT {
319            return Err(IsccError::InvalidInput(format!(
320                "meta string exceeds size limit ({} > {PRE_DECODE_LIMIT} bytes)",
321                meta_str.len()
322            )));
323        }
324    }
325
326    // Resolve meta payload bytes (if meta is provided)
327    let meta_payload: Option<Vec<u8>> = match meta {
328        Some(meta_str) if meta_str.starts_with("data:") => Some(decode_data_url(meta_str)?),
329        Some(meta_str) => Some(parse_meta_json(meta_str)?),
330        None => None,
331    };
332
333    // Post-decode check: reject payloads exceeding META_TRIM_META
334    if let Some(ref payload) = meta_payload {
335        if payload.len() > META_TRIM_META {
336            return Err(IsccError::InvalidInput(format!(
337                "decoded meta payload exceeds size limit ({} > {META_TRIM_META} bytes)",
338                payload.len()
339            )));
340        }
341    }
342
343    // Branch: meta bytes path vs. description text path
344    if let Some(ref payload) = meta_payload {
345        let meta_code_digest = soft_hash_meta_v0_with_bytes(&name, payload);
346        let metahash = utils::multi_hash_blake3(payload);
347
348        let meta_code = codec::encode_component(
349            codec::MainType::Meta,
350            codec::SubType::None,
351            codec::Version::V0,
352            bits,
353            &meta_code_digest,
354        )?;
355
356        // Build the meta Data-URL for the result
357        let meta_value = match meta {
358            Some(meta_str) if meta_str.starts_with("data:") => meta_str.to_string(),
359            Some(meta_str) => {
360                let parsed: serde_json::Value = serde_json::from_str(meta_str)
361                    .map_err(|e| IsccError::InvalidInput(format!("invalid JSON: {e}")))?;
362                build_meta_data_url(payload, &parsed)
363            }
364            None => unreachable!(),
365        };
366
367        Ok(MetaCodeResult {
368            iscc: format!("ISCC:{meta_code}"),
369            name: name.clone(),
370            description: if desc_clean.is_empty() {
371                None
372            } else {
373                Some(desc_clean)
374            },
375            meta: Some(meta_value),
376            metahash,
377        })
378    } else {
379        // Compute metahash from normalized text payload
380        let payload = if desc_clean.is_empty() {
381            name.clone()
382        } else {
383            format!("{name} {desc_clean}")
384        };
385        let payload = payload.trim().to_string();
386        let metahash = utils::multi_hash_blake3(payload.as_bytes());
387
388        // Compute similarity digest
389        let extra = if desc_clean.is_empty() {
390            None
391        } else {
392            Some(desc_clean.as_str())
393        };
394        let meta_code_digest = soft_hash_meta_v0(&name, extra);
395
396        let meta_code = codec::encode_component(
397            codec::MainType::Meta,
398            codec::SubType::None,
399            codec::Version::V0,
400            bits,
401            &meta_code_digest,
402        )?;
403
404        Ok(MetaCodeResult {
405            iscc: format!("ISCC:{meta_code}"),
406            name: name.clone(),
407            description: if desc_clean.is_empty() {
408                None
409            } else {
410                Some(desc_clean)
411            },
412            meta: None,
413            metahash,
414        })
415    }
416}
417
418/// Compute a 256-bit similarity-preserving hash from collapsed text.
419///
420/// Generates character n-grams with a sliding window of width 13,
421/// hashes each with xxh32, then applies MinHash to produce a 32-byte digest.
422#[cfg(feature = "text-processing")]
423fn soft_hash_text_v0(text: &str) -> Vec<u8> {
424    let ngrams = simhash::sliding_window_strs(text, TEXT_NGRAM_SIZE);
425    let features: Vec<u32> = ngrams
426        .iter()
427        .map(|ng| xxhash_rust::xxh32::xxh32(ng.as_bytes(), 0))
428        .collect();
429    minhash::alg_minhash_256(&features)
430}
431
432/// Generate a Text-Code from plain text content.
433///
434/// Produces an ISCC Content-Code for text by collapsing the input,
435/// extracting character n-gram features, and applying MinHash to
436/// create a similarity-preserving fingerprint.
437#[cfg(feature = "text-processing")]
438pub fn gen_text_code_v0(text: &str, bits: u32) -> IsccResult<TextCodeResult> {
439    let collapsed = utils::text_collapse(text);
440    let characters = collapsed.chars().count();
441    let hash_digest = soft_hash_text_v0(&collapsed);
442    let component = codec::encode_component(
443        codec::MainType::Content,
444        codec::SubType::TEXT,
445        codec::Version::V0,
446        bits,
447        &hash_digest,
448    )?;
449    Ok(TextCodeResult {
450        iscc: format!("ISCC:{component}"),
451        characters,
452    })
453}
454
455/// Transpose a matrix represented as a Vec of Vecs.
456fn transpose_matrix(matrix: &[Vec<f64>]) -> Vec<Vec<f64>> {
457    let rows = matrix.len();
458    if rows == 0 {
459        return vec![];
460    }
461    let cols = matrix[0].len();
462    let mut result = vec![vec![0.0f64; rows]; cols];
463    for (r, row) in matrix.iter().enumerate() {
464        for (c, &val) in row.iter().enumerate() {
465            result[c][r] = val;
466        }
467    }
468    result
469}
470
471/// Extract an 8×8 block from a matrix and flatten to 64 values.
472///
473/// Block position `(col, row)` means the block starts at
474/// `matrix[row][col]` and spans 8 rows and 8 columns.
475fn flatten_8x8(matrix: &[Vec<f64>], col: usize, row: usize) -> Vec<f64> {
476    let mut flat = Vec::with_capacity(64);
477    for matrix_row in matrix.iter().skip(row).take(8) {
478        for &val in matrix_row.iter().skip(col).take(8) {
479            flat.push(val);
480        }
481    }
482    flat
483}
484
485/// Compute the median of a slice of f64 values.
486///
487/// For even-length slices, returns the average of the two middle values
488/// (matching Python `statistics.median` behavior).
489fn compute_median(values: &[f64]) -> f64 {
490    let mut sorted: Vec<f64> = values.to_vec();
491    sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
492    let n = sorted.len();
493    if n % 2 == 1 {
494        sorted[n / 2]
495    } else {
496        (sorted[n / 2 - 1] + sorted[n / 2]) / 2.0
497    }
498}
499
500/// Convert a slice of bools to a byte vector (MSB first per byte).
501fn bits_to_bytes(bits: &[bool]) -> Vec<u8> {
502    bits.chunks(8)
503        .map(|chunk| {
504            let mut byte = 0u8;
505            for (i, &bit) in chunk.iter().enumerate() {
506                if bit {
507                    byte |= 1 << (7 - i);
508                }
509            }
510            byte
511        })
512        .collect()
513}
514
515/// Compute a DCT-based perceptual hash from 32×32 grayscale pixels.
516///
517/// Applies a 2D DCT to the pixel matrix, extracts four 8×8 low-frequency
518/// blocks, and generates a bitstring by comparing each coefficient against
519/// the block median. Returns up to `bits` bits as a byte vector.
520fn soft_hash_image_v0(pixels: &[u8], bits: u32) -> IsccResult<Vec<u8>> {
521    if pixels.len() != 1024 {
522        return Err(IsccError::InvalidInput(format!(
523            "expected 1024 pixels, got {}",
524            pixels.len()
525        )));
526    }
527    if bits > 256 {
528        return Err(IsccError::InvalidInput(format!(
529            "bits must be <= 256, got {bits}"
530        )));
531    }
532
533    // Step 1: Row-wise DCT (32 rows of 32 pixels)
534    let rows: Vec<Vec<f64>> = pixels
535        .chunks(32)
536        .map(|row| {
537            let row_f64: Vec<f64> = row.iter().map(|&p| p as f64).collect();
538            dct::alg_dct(&row_f64)
539        })
540        .collect::<IsccResult<Vec<Vec<f64>>>>()?;
541
542    // Step 2: Transpose
543    let transposed = transpose_matrix(&rows);
544
545    // Step 3: Column-wise DCT
546    let dct_cols: Vec<Vec<f64>> = transposed
547        .iter()
548        .map(|col| dct::alg_dct(col))
549        .collect::<IsccResult<Vec<Vec<f64>>>>()?;
550
551    // Step 4: Transpose back → dct_matrix
552    let dct_matrix = transpose_matrix(&dct_cols);
553
554    // Step 5: Extract 8×8 blocks at positions (0,0), (1,0), (0,1), (1,1)
555    let positions = [(0, 0), (1, 0), (0, 1), (1, 1)];
556    let mut bitstring = Vec::<bool>::with_capacity(256);
557
558    for (col, row) in positions {
559        let flat = flatten_8x8(&dct_matrix, col, row);
560        let median = compute_median(&flat);
561        for val in &flat {
562            bitstring.push(*val > median);
563        }
564        if bitstring.len() >= bits as usize {
565            break;
566        }
567    }
568
569    // Step 6: Convert first `bits` bools to bytes
570    Ok(bits_to_bytes(&bitstring[..bits as usize]))
571}
572
573/// Generate an Image-Code from pixel data.
574///
575/// Produces an ISCC Content-Code for images from a sequence of 1024
576/// grayscale pixel values (32×32, values 0-255) using a DCT-based
577/// perceptual hash.
578pub fn gen_image_code_v0(pixels: &[u8], bits: u32) -> IsccResult<ImageCodeResult> {
579    let hash_digest = soft_hash_image_v0(pixels, bits)?;
580    let component = codec::encode_component(
581        codec::MainType::Content,
582        codec::SubType::Image,
583        codec::Version::V0,
584        bits,
585        &hash_digest,
586    )?;
587    Ok(ImageCodeResult {
588        iscc: format!("ISCC:{component}"),
589    })
590}
591
592/// Split a slice into `n` parts, distributing remainder across first chunks.
593///
594/// Equivalent to `numpy.array_split` / `more_itertools.divide`:
595/// each part gets `len / n` elements, and the first `len % n` parts
596/// get one extra element. Returns empty slices for excess parts.
597fn array_split<T>(slice: &[T], n: usize) -> Vec<&[T]> {
598    if n == 0 {
599        return vec![];
600    }
601    let len = slice.len();
602    let base = len / n;
603    let remainder = len % n;
604    let mut parts = Vec::with_capacity(n);
605    let mut offset = 0;
606    for i in 0..n {
607        let size = base + if i < remainder { 1 } else { 0 };
608        parts.push(&slice[offset..offset + size]);
609        offset += size;
610    }
611    parts
612}
613
614/// Compute a multi-stage SimHash digest from Chromaprint features.
615///
616/// Builds a 32-byte digest by concatenating 4-byte SimHash chunks:
617/// - Stage 1: overall SimHash of all features (4 bytes)
618/// - Stage 2: SimHash of each quarter of features (4 × 4 = 16 bytes)
619/// - Stage 3: SimHash of each third of sorted features (3 × 4 = 12 bytes)
620fn soft_hash_audio_v0(cv: &[i32]) -> Vec<u8> {
621    // Convert each i32 to 4-byte big-endian digest
622    let digests: Vec<[u8; 4]> = cv.iter().map(|&v| v.to_be_bytes()).collect();
623
624    if digests.is_empty() {
625        return vec![0u8; 32];
626    }
627
628    // Stage 1: overall SimHash (4 bytes)
629    let mut parts: Vec<u8> = simhash::alg_simhash_inner(&digests);
630
631    // Stage 2: quarter-based SimHashes (4 × 4 = 16 bytes)
632    let quarters = array_split(&digests, 4);
633    for quarter in &quarters {
634        if quarter.is_empty() {
635            parts.extend_from_slice(&[0u8; 4]);
636        } else {
637            parts.extend_from_slice(&simhash::alg_simhash_inner(quarter));
638        }
639    }
640
641    // Stage 3: sorted-third-based SimHashes (3 × 4 = 12 bytes)
642    let mut sorted_values: Vec<i32> = cv.to_vec();
643    sorted_values.sort();
644    let sorted_digests: Vec<[u8; 4]> = sorted_values.iter().map(|&v| v.to_be_bytes()).collect();
645    let thirds = array_split(&sorted_digests, 3);
646    for third in &thirds {
647        if third.is_empty() {
648            parts.extend_from_slice(&[0u8; 4]);
649        } else {
650            parts.extend_from_slice(&simhash::alg_simhash_inner(third));
651        }
652    }
653
654    parts
655}
656
657/// Generate an Audio-Code from a Chromaprint feature vector.
658///
659/// Produces an ISCC Content-Code for audio from a Chromaprint signed
660/// integer fingerprint vector using multi-stage SimHash.
661pub fn gen_audio_code_v0(cv: &[i32], bits: u32) -> IsccResult<AudioCodeResult> {
662    let hash_digest = soft_hash_audio_v0(cv);
663    let component = codec::encode_component(
664        codec::MainType::Content,
665        codec::SubType::Audio,
666        codec::Version::V0,
667        bits,
668        &hash_digest,
669    )?;
670    Ok(AudioCodeResult {
671        iscc: format!("ISCC:{component}"),
672    })
673}
674
675/// Compute a similarity-preserving hash from video frame signatures.
676///
677/// Deduplicates frame signatures, computes column-wise sums across all
678/// unique frames, then applies WTA-Hash to produce a digest of `bits/8` bytes.
679pub fn soft_hash_video_v0<S: AsRef<[i32]> + Ord>(
680    frame_sigs: &[S],
681    bits: u32,
682) -> IsccResult<Vec<u8>> {
683    if frame_sigs.is_empty() {
684        return Err(IsccError::InvalidInput(
685            "frame_sigs must not be empty".into(),
686        ));
687    }
688
689    // Deduplicate using BTreeSet (S: Ord)
690    let unique: std::collections::BTreeSet<&S> = frame_sigs.iter().collect();
691
692    // Column-wise sum into i64 to avoid overflow
693    let cols = frame_sigs[0].as_ref().len();
694    let mut vecsum = vec![0i64; cols];
695    for sig in &unique {
696        for (c, &val) in sig.as_ref().iter().enumerate() {
697            vecsum[c] += val as i64;
698        }
699    }
700
701    wtahash::alg_wtahash(&vecsum, bits)
702}
703
704/// Generate a Video-Code from frame signature data.
705///
706/// Produces an ISCC Content-Code for video from a sequence of MPEG-7 frame
707/// signatures. Each frame signature is a 380-element integer vector.
708pub fn gen_video_code_v0<S: AsRef<[i32]> + Ord>(
709    frame_sigs: &[S],
710    bits: u32,
711) -> IsccResult<VideoCodeResult> {
712    let digest = soft_hash_video_v0(frame_sigs, bits)?;
713    let component = codec::encode_component(
714        codec::MainType::Content,
715        codec::SubType::Video,
716        codec::Version::V0,
717        bits,
718        &digest,
719    )?;
720    Ok(VideoCodeResult {
721        iscc: format!("ISCC:{component}"),
722    })
723}
724
725/// Combine multiple Content-Code digests into a single similarity hash.
726///
727/// Takes raw decoded ISCC bytes (header + body) for each Content-Code and
728/// produces a SimHash digest. Each input is trimmed to `bits/8` bytes by
729/// keeping the first header byte (encodes type info) plus `nbytes-1` body bytes.
730/// Requires at least 2 codes, all of MainType::Content.
731fn soft_hash_codes_v0(cc_digests: &[Vec<u8>], bits: u32) -> IsccResult<Vec<u8>> {
732    if cc_digests.len() < 2 {
733        return Err(IsccError::InvalidInput(
734            "at least 2 Content-Codes required for mixing".into(),
735        ));
736    }
737
738    let nbytes = (bits / 8) as usize;
739    let mut prepared: Vec<Vec<u8>> = Vec::with_capacity(cc_digests.len());
740
741    for raw in cc_digests {
742        let (mtype, stype, _ver, blen, body) = codec::decode_header(raw)?;
743        if mtype != codec::MainType::Content {
744            return Err(IsccError::InvalidInput(
745                "all codes must be Content-Codes".into(),
746            ));
747        }
748        let unit_bits = codec::decode_length(mtype, blen, stype);
749        if unit_bits < bits {
750            return Err(IsccError::InvalidInput(format!(
751                "Content-Code too short for {bits}-bit length (has {unit_bits} bits)"
752            )));
753        }
754        let mut entry = Vec::with_capacity(nbytes);
755        entry.push(raw[0]); // first byte preserves type info
756        let take = std::cmp::min(nbytes - 1, body.len());
757        entry.extend_from_slice(&body[..take]);
758        // Pad with zeros if body is shorter than nbytes-1
759        while entry.len() < nbytes {
760            entry.push(0);
761        }
762        prepared.push(entry);
763    }
764
765    Ok(simhash::alg_simhash_inner(&prepared))
766}
767
768/// Generate a Mixed-Code from multiple Content-Code strings.
769///
770/// Produces a Mixed Content-Code by combining multiple ISCC Content-Codes
771/// of different types (text, image, audio, video) using SimHash. Input codes
772/// may optionally include the "ISCC:" prefix.
773pub fn gen_mixed_code_v0(codes: &[&str], bits: u32) -> IsccResult<MixedCodeResult> {
774    let decoded: Vec<Vec<u8>> = codes
775        .iter()
776        .map(|code| {
777            let clean = code.strip_prefix("ISCC:").unwrap_or(code);
778            codec::decode_base32(clean)
779        })
780        .collect::<IsccResult<Vec<Vec<u8>>>>()?;
781
782    let digest = soft_hash_codes_v0(&decoded, bits)?;
783
784    let component = codec::encode_component(
785        codec::MainType::Content,
786        codec::SubType::Mixed,
787        codec::Version::V0,
788        bits,
789        &digest,
790    )?;
791
792    Ok(MixedCodeResult {
793        iscc: format!("ISCC:{component}"),
794        parts: codes.iter().map(|s| s.to_string()).collect(),
795    })
796}
797
798/// Generate a Data-Code from raw byte data.
799///
800/// Produces an ISCC Data-Code by splitting data into content-defined chunks,
801/// hashing each chunk with xxh32, and applying MinHash to create a
802/// similarity-preserving fingerprint.
803pub fn gen_data_code_v0(data: &[u8], bits: u32) -> IsccResult<DataCodeResult> {
804    let chunks = cdc::alg_cdc_chunks_unchecked(data, false, cdc::DATA_AVG_CHUNK_SIZE);
805    let mut features: Vec<u32> = chunks
806        .iter()
807        .map(|chunk| xxhash_rust::xxh32::xxh32(chunk, 0))
808        .collect();
809
810    // Defensive: ensure at least one feature (alg_cdc_chunks guarantees >= 1 chunk)
811    if features.is_empty() {
812        features.push(xxhash_rust::xxh32::xxh32(b"", 0));
813    }
814
815    let digest = minhash::alg_minhash_256(&features);
816    let component = codec::encode_component(
817        codec::MainType::Data,
818        codec::SubType::None,
819        codec::Version::V0,
820        bits,
821        &digest,
822    )?;
823
824    Ok(DataCodeResult {
825        iscc: format!("ISCC:{component}"),
826    })
827}
828
829/// Generate an Instance-Code from raw byte data.
830///
831/// Produces an ISCC Instance-Code by hashing the complete byte stream
832/// with BLAKE3. Captures the exact binary identity of the data.
833pub fn gen_instance_code_v0(data: &[u8], bits: u32) -> IsccResult<InstanceCodeResult> {
834    let digest = blake3::hash(data);
835    let datahash = utils::multi_hash_blake3(data);
836    let filesize = data.len() as u64;
837    let component = codec::encode_component(
838        codec::MainType::Instance,
839        codec::SubType::None,
840        codec::Version::V0,
841        bits,
842        digest.as_bytes(),
843    )?;
844    Ok(InstanceCodeResult {
845        iscc: format!("ISCC:{component}"),
846        datahash,
847        filesize,
848    })
849}
850
851/// Generate a composite ISCC-CODE from individual ISCC unit codes.
852///
853/// Combines multiple ISCC unit codes (Meta-Code, Content-Code, Data-Code,
854/// Instance-Code) into a single composite ISCC-CODE. Input codes may
855/// optionally include the "ISCC:" prefix. At least Data-Code and
856/// Instance-Code are required. When `wide` is true and exactly two
857/// 128-bit+ codes (Data + Instance) are provided, produces a 256-bit
858/// wide-mode code.
859pub fn gen_iscc_code_v0(codes: &[&str], wide: bool) -> IsccResult<IsccCodeResult> {
860    // Step 1: Clean inputs — strip "ISCC:" prefix
861    let cleaned: Vec<&str> = codes
862        .iter()
863        .map(|c| c.strip_prefix("ISCC:").unwrap_or(c))
864        .collect();
865
866    // Step 2: Validate minimum count
867    if cleaned.len() < 2 {
868        return Err(IsccError::InvalidInput(
869            "at least 2 ISCC unit codes required".into(),
870        ));
871    }
872
873    // Step 3: Validate minimum length (16 base32 chars = 64-bit minimum)
874    for code in &cleaned {
875        if code.len() < 16 {
876            return Err(IsccError::InvalidInput(format!(
877                "ISCC unit code too short (min 16 chars): {code}"
878            )));
879        }
880    }
881
882    // Step 4: Decode each code
883    let mut decoded: Vec<(
884        codec::MainType,
885        codec::SubType,
886        codec::Version,
887        u32,
888        Vec<u8>,
889    )> = Vec::with_capacity(cleaned.len());
890    for code in &cleaned {
891        let raw = codec::decode_base32(code)?;
892        let header = codec::decode_header(&raw)?;
893        decoded.push(header);
894    }
895
896    // Step 5: Sort by MainType (ascending)
897    decoded.sort_by_key(|&(mt, ..)| mt);
898
899    // Step 6: Extract main_types
900    let main_types: Vec<codec::MainType> = decoded.iter().map(|&(mt, ..)| mt).collect();
901
902    // Step 7: Validate last two are Data + Instance (mandatory)
903    let n = main_types.len();
904    if main_types[n - 2] != codec::MainType::Data || main_types[n - 1] != codec::MainType::Instance
905    {
906        return Err(IsccError::InvalidInput(
907            "Data-Code and Instance-Code are mandatory".into(),
908        ));
909    }
910
911    // Step 8: Determine wide composite
912    let is_wide = wide
913        && decoded.len() == 2
914        && main_types == [codec::MainType::Data, codec::MainType::Instance]
915        && decoded
916            .iter()
917            .all(|&(mt, st, _, len, _)| codec::decode_length(mt, len, st) >= 128);
918
919    // Step 9: Determine SubType
920    let st = if is_wide {
921        codec::SubType::Wide
922    } else {
923        // Collect SubTypes of Semantic/Content units
924        let sc_subtypes: Vec<codec::SubType> = decoded
925            .iter()
926            .filter(|&&(mt, ..)| mt == codec::MainType::Semantic || mt == codec::MainType::Content)
927            .map(|&(_, st, ..)| st)
928            .collect();
929
930        if !sc_subtypes.is_empty() {
931            // All must be the same
932            let first = sc_subtypes[0];
933            if sc_subtypes.iter().all(|&s| s == first) {
934                first
935            } else {
936                return Err(IsccError::InvalidInput(
937                    "mixed SubTypes among Content/Semantic units".into(),
938                ));
939            }
940        } else if decoded.len() == 2 {
941            codec::SubType::Sum
942        } else {
943            codec::SubType::IsccNone
944        }
945    };
946
947    // Step 10–11: Get optional MainTypes and encode
948    let optional_types = &main_types[..n - 2];
949    let encoded_length = codec::encode_units(optional_types)?;
950
951    // Step 12: Build digest body
952    let bytes_per_unit = if is_wide { 16 } else { 8 };
953    let mut digest = Vec::with_capacity(decoded.len() * bytes_per_unit);
954    for (_, _, _, _, tail) in &decoded {
955        let take = bytes_per_unit.min(tail.len());
956        digest.extend_from_slice(&tail[..take]);
957    }
958
959    // Step 13–14: Encode header + digest as base32
960    let header = codec::encode_header(
961        codec::MainType::Iscc,
962        st,
963        codec::Version::V0,
964        encoded_length,
965    )?;
966    let mut code_bytes = header;
967    code_bytes.extend_from_slice(&digest);
968    let code = codec::encode_base32(&code_bytes);
969
970    // Step 15: Return with prefix
971    Ok(IsccCodeResult {
972        iscc: format!("ISCC:{code}"),
973    })
974}
975
976/// Generate a composite ISCC-CODE from a file in a single pass.
977///
978/// Opens the file at `path`, reads it with an optimal buffer size, and feeds
979/// both `DataHasher` (CDC/MinHash) and `InstanceHasher` (BLAKE3) from the
980/// same read buffer. Composes the final ISCC-CODE from the Data-Code and
981/// Instance-Code internally. This avoids multiple passes over the file and
982/// eliminates per-chunk FFI overhead in language bindings.
983///
984/// When `add_units` is `true`, the result includes the individual Data-Code
985/// and Instance-Code ISCC strings at the requested `bits` precision.
986pub fn gen_sum_code_v0(
987    path: &std::path::Path,
988    bits: u32,
989    wide: bool,
990    add_units: bool,
991) -> IsccResult<SumCodeResult> {
992    use std::io::Read;
993
994    let mut file = std::fs::File::open(path)
995        .map_err(|e| IsccError::InvalidInput(format!("Cannot open file: {e}")))?;
996
997    let mut hasher = streaming::SumHasher::new();
998
999    let mut buf = vec![0u8; IO_READ_SIZE];
1000    loop {
1001        let n = file
1002            .read(&mut buf)
1003            .map_err(|e| IsccError::InvalidInput(format!("Cannot read file: {e}")))?;
1004        if n == 0 {
1005            break;
1006        }
1007        hasher.update(&buf[..n]);
1008    }
1009
1010    hasher.finalize(bits, wide, add_units)
1011}
1012
1013#[cfg(test)]
1014mod tests {
1015    use super::*;
1016
1017    #[cfg(feature = "meta-code")]
1018    #[test]
1019    fn test_gen_meta_code_v0_title_only() {
1020        let result = gen_meta_code_v0("Die Unendliche Geschichte", None, None, 64).unwrap();
1021        assert_eq!(result.iscc, "ISCC:AAAZXZ6OU74YAZIM");
1022        assert_eq!(result.name, "Die Unendliche Geschichte");
1023        assert_eq!(result.description, None);
1024        assert_eq!(result.meta, None);
1025    }
1026
1027    #[cfg(feature = "meta-code")]
1028    #[test]
1029    fn test_gen_meta_code_v0_title_description() {
1030        let result = gen_meta_code_v0(
1031            "Die Unendliche Geschichte",
1032            Some("Von Michael Ende"),
1033            None,
1034            64,
1035        )
1036        .unwrap();
1037        assert_eq!(result.iscc, "ISCC:AAAZXZ6OU4E45RB5");
1038        assert_eq!(result.name, "Die Unendliche Geschichte");
1039        assert_eq!(result.description, Some("Von Michael Ende".to_string()));
1040        assert_eq!(result.meta, None);
1041    }
1042
1043    #[cfg(feature = "meta-code")]
1044    #[test]
1045    fn test_gen_meta_code_v0_json_meta() {
1046        let result = gen_meta_code_v0("Hello", None, Some(r#"{"some":"object"}"#), 64).unwrap();
1047        assert_eq!(result.iscc, "ISCC:AAAWKLHFXN63LHL2");
1048        assert!(result.meta.is_some());
1049        assert!(
1050            result
1051                .meta
1052                .unwrap()
1053                .starts_with("data:application/json;base64,")
1054        );
1055    }
1056
1057    #[cfg(feature = "meta-code")]
1058    #[test]
1059    fn test_gen_meta_code_v0_data_url_meta() {
1060        let result = gen_meta_code_v0(
1061            "Hello",
1062            None,
1063            Some("data:application/json;charset=utf-8;base64,eyJzb21lIjogIm9iamVjdCJ9"),
1064            64,
1065        )
1066        .unwrap();
1067        assert_eq!(result.iscc, "ISCC:AAAWKLHFXN43ICP2");
1068        // Data-URL is passed through as-is
1069        assert_eq!(
1070            result.meta,
1071            Some("data:application/json;charset=utf-8;base64,eyJzb21lIjogIm9iamVjdCJ9".to_string())
1072        );
1073    }
1074
1075    /// Verify that JSON metadata with float values is canonicalized per RFC 8785 (JCS).
1076    ///
1077    /// JCS serializes `1.0` as `1` (integer form), while `serde_json` preserves `1.0`.
1078    /// This causes different canonical bytes, different metahash, and different ISCC codes.
1079    /// Expected values generated by `iscc-core` with `jcs.canonicalize({"value": 1.0})`.
1080    #[cfg(feature = "meta-code")]
1081    #[test]
1082    fn test_gen_meta_code_v0_jcs_float_canonicalization() {
1083        // JCS canonicalizes {"value": 1.0} → {"value":1} (integer form)
1084        // serde_json produces {"value":1.0} (preserves float notation)
1085        let result = gen_meta_code_v0("Test", None, Some(r#"{"value":1.0}"#), 64).unwrap();
1086
1087        // Expected values from iscc-core (Python) using jcs.canonicalize()
1088        assert_eq!(
1089            result.iscc, "ISCC:AAAX4GX3RZH2I6QZ",
1090            "ISCC mismatch: parse_meta_json must use RFC 8785 (JCS) canonicalization"
1091        );
1092        assert_eq!(
1093            result.meta,
1094            Some("data:application/json;base64,eyJ2YWx1ZSI6MX0=".to_string()),
1095            "meta Data-URL mismatch: JCS should serialize 1.0 as 1"
1096        );
1097        assert_eq!(
1098            result.metahash, "1e2010b291d392b6999ffe4aa4661fb343fc371fca3bfb5bb4e8d8226fdf85743232",
1099            "metahash mismatch: canonical bytes differ between JCS and serde_json"
1100        );
1101    }
1102
1103    /// Verify JCS number formatting for large floats (scientific notation edge case).
1104    ///
1105    /// JCS serializes `1e20` as `100000000000000000000` (expanded integer form).
1106    /// Expected values generated by `iscc-core` with `jcs.canonicalize({"value": 1e20})`.
1107    #[cfg(feature = "meta-code")]
1108    #[test]
1109    fn test_gen_meta_code_v0_jcs_large_float_canonicalization() {
1110        let result = gen_meta_code_v0("Test", None, Some(r#"{"value":1e20}"#), 64).unwrap();
1111
1112        assert_eq!(
1113            result.iscc, "ISCC:AAAX4GX3R32YH5P7",
1114            "ISCC mismatch: JCS should expand 1e20 to 100000000000000000000"
1115        );
1116        assert_eq!(
1117            result.meta,
1118            Some(
1119                "data:application/json;base64,eyJ2YWx1ZSI6MTAwMDAwMDAwMDAwMDAwMDAwMDAwfQ=="
1120                    .to_string()
1121            ),
1122            "meta Data-URL mismatch: JCS should expand large float to integer form"
1123        );
1124        assert_eq!(
1125            result.metahash, "1e201ff83c1822c348717658a0b4713739646da7c59832691b337a457416ddd1c73d",
1126            "metahash mismatch: canonical bytes differ for large float"
1127        );
1128    }
1129
1130    #[cfg(feature = "meta-code")]
1131    #[test]
1132    fn test_gen_meta_code_v0_invalid_json() {
1133        assert!(matches!(
1134            gen_meta_code_v0("test", None, Some("not json"), 64),
1135            Err(IsccError::InvalidInput(_))
1136        ));
1137    }
1138
1139    #[cfg(feature = "meta-code")]
1140    #[test]
1141    fn test_gen_meta_code_v0_invalid_data_url() {
1142        assert!(matches!(
1143            gen_meta_code_v0("test", None, Some("data:no-comma-here"), 64),
1144            Err(IsccError::InvalidInput(_))
1145        ));
1146    }
1147
1148    #[cfg(feature = "meta-code")]
1149    #[test]
1150    fn test_gen_meta_code_v0_conformance() {
1151        let json_str = include_str!("../tests/data.json");
1152        let data: serde_json::Value = serde_json::from_str(json_str).unwrap();
1153        let section = &data["gen_meta_code_v0"];
1154        let cases = section.as_object().unwrap();
1155
1156        let mut tested = 0;
1157
1158        for (tc_name, tc) in cases {
1159            let inputs = tc["inputs"].as_array().unwrap();
1160            let input_name = inputs[0].as_str().unwrap();
1161            let input_desc = inputs[1].as_str().unwrap();
1162            let meta_val = &inputs[2];
1163            let bits = inputs[3].as_u64().unwrap() as u32;
1164
1165            let expected_iscc = tc["outputs"]["iscc"].as_str().unwrap();
1166            let expected_metahash = tc["outputs"]["metahash"].as_str().unwrap();
1167
1168            // Dispatch meta parameter based on JSON value type
1169            let meta_arg: Option<String> = match meta_val {
1170                serde_json::Value::Null => None,
1171                serde_json::Value::String(s) => Some(s.clone()),
1172                serde_json::Value::Object(_) => Some(serde_json::to_string(meta_val).unwrap()),
1173                other => panic!("unexpected meta type in {tc_name}: {other:?}"),
1174            };
1175
1176            let desc = if input_desc.is_empty() {
1177                None
1178            } else {
1179                Some(input_desc)
1180            };
1181
1182            // Verify ISCC output from struct
1183            let result = gen_meta_code_v0(input_name, desc, meta_arg.as_deref(), bits)
1184                .unwrap_or_else(|e| panic!("gen_meta_code_v0 failed for {tc_name}: {e}"));
1185            assert_eq!(
1186                result.iscc, expected_iscc,
1187                "ISCC mismatch in test case {tc_name}"
1188            );
1189
1190            // Verify metahash from struct
1191            assert_eq!(
1192                result.metahash, expected_metahash,
1193                "metahash mismatch in test case {tc_name}"
1194            );
1195
1196            // Verify name from struct
1197            if let Some(expected_name) = tc["outputs"].get("name") {
1198                let expected_name = expected_name.as_str().unwrap();
1199                assert_eq!(
1200                    result.name, expected_name,
1201                    "name mismatch in test case {tc_name}"
1202                );
1203            }
1204
1205            // Verify description from struct
1206            if let Some(expected_desc) = tc["outputs"].get("description") {
1207                let expected_desc = expected_desc.as_str().unwrap();
1208                assert_eq!(
1209                    result.description.as_deref(),
1210                    Some(expected_desc),
1211                    "description mismatch in test case {tc_name}"
1212                );
1213            }
1214
1215            // Verify meta from struct
1216            if meta_arg.is_some() {
1217                assert!(
1218                    result.meta.is_some(),
1219                    "meta should be present in test case {tc_name}"
1220                );
1221            } else {
1222                assert!(
1223                    result.meta.is_none(),
1224                    "meta should be absent in test case {tc_name}"
1225                );
1226            }
1227
1228            tested += 1;
1229        }
1230
1231        assert_eq!(tested, 20, "expected 20 conformance tests to run");
1232    }
1233
1234    #[cfg(feature = "text-processing")]
1235    #[test]
1236    fn test_gen_text_code_v0_empty() {
1237        let result = gen_text_code_v0("", 64).unwrap();
1238        assert_eq!(result.iscc, "ISCC:EAASL4F2WZY7KBXB");
1239        assert_eq!(result.characters, 0);
1240    }
1241
1242    #[cfg(feature = "text-processing")]
1243    #[test]
1244    fn test_gen_text_code_v0_hello_world() {
1245        let result = gen_text_code_v0("Hello World", 64).unwrap();
1246        assert_eq!(result.iscc, "ISCC:EAASKDNZNYGUUF5A");
1247        assert_eq!(result.characters, 10); // "helloworld" after collapse
1248    }
1249
1250    #[cfg(feature = "text-processing")]
1251    #[test]
1252    fn test_gen_text_code_v0_conformance() {
1253        let json_str = include_str!("../tests/data.json");
1254        let data: serde_json::Value = serde_json::from_str(json_str).unwrap();
1255        let section = &data["gen_text_code_v0"];
1256        let cases = section.as_object().unwrap();
1257
1258        let mut tested = 0;
1259
1260        for (tc_name, tc) in cases {
1261            let inputs = tc["inputs"].as_array().unwrap();
1262            let input_text = inputs[0].as_str().unwrap();
1263            let bits = inputs[1].as_u64().unwrap() as u32;
1264
1265            let expected_iscc = tc["outputs"]["iscc"].as_str().unwrap();
1266            let expected_chars = tc["outputs"]["characters"].as_u64().unwrap() as usize;
1267
1268            // Verify ISCC output from struct
1269            let result = gen_text_code_v0(input_text, bits)
1270                .unwrap_or_else(|e| panic!("gen_text_code_v0 failed for {tc_name}: {e}"));
1271            assert_eq!(
1272                result.iscc, expected_iscc,
1273                "ISCC mismatch in test case {tc_name}"
1274            );
1275
1276            // Verify character count from struct
1277            assert_eq!(
1278                result.characters, expected_chars,
1279                "character count mismatch in test case {tc_name}"
1280            );
1281
1282            tested += 1;
1283        }
1284
1285        assert_eq!(tested, 5, "expected 5 conformance tests to run");
1286    }
1287
1288    #[test]
1289    fn test_gen_image_code_v0_all_black() {
1290        let pixels = vec![0u8; 1024];
1291        let result = gen_image_code_v0(&pixels, 64).unwrap();
1292        assert_eq!(result.iscc, "ISCC:EEAQAAAAAAAAAAAA");
1293    }
1294
1295    #[test]
1296    fn test_gen_image_code_v0_all_white() {
1297        let pixels = vec![255u8; 1024];
1298        let result = gen_image_code_v0(&pixels, 128).unwrap();
1299        assert_eq!(result.iscc, "ISCC:EEBYAAAAAAAAAAAAAAAAAAAAAAAAA");
1300    }
1301
1302    #[test]
1303    fn test_gen_image_code_v0_invalid_pixel_count() {
1304        assert!(gen_image_code_v0(&[0u8; 100], 64).is_err());
1305    }
1306
1307    #[test]
1308    fn test_gen_image_code_v0_conformance() {
1309        let json_str = include_str!("../tests/data.json");
1310        let data: serde_json::Value = serde_json::from_str(json_str).unwrap();
1311        let section = &data["gen_image_code_v0"];
1312        let cases = section.as_object().unwrap();
1313
1314        let mut tested = 0;
1315
1316        for (tc_name, tc) in cases {
1317            let inputs = tc["inputs"].as_array().unwrap();
1318            let pixels_json = inputs[0].as_array().unwrap();
1319            let bits = inputs[1].as_u64().unwrap() as u32;
1320            let expected_iscc = tc["outputs"]["iscc"].as_str().unwrap();
1321
1322            let pixels: Vec<u8> = pixels_json
1323                .iter()
1324                .map(|v| v.as_u64().unwrap() as u8)
1325                .collect();
1326
1327            let result = gen_image_code_v0(&pixels, bits)
1328                .unwrap_or_else(|e| panic!("gen_image_code_v0 failed for {tc_name}: {e}"));
1329            assert_eq!(
1330                result.iscc, expected_iscc,
1331                "ISCC mismatch in test case {tc_name}"
1332            );
1333
1334            tested += 1;
1335        }
1336
1337        assert_eq!(tested, 3, "expected 3 conformance tests to run");
1338    }
1339
1340    #[test]
1341    fn test_gen_audio_code_v0_empty() {
1342        let result = gen_audio_code_v0(&[], 64).unwrap();
1343        assert_eq!(result.iscc, "ISCC:EIAQAAAAAAAAAAAA");
1344    }
1345
1346    #[test]
1347    fn test_gen_audio_code_v0_single() {
1348        let result = gen_audio_code_v0(&[1], 128).unwrap();
1349        assert_eq!(result.iscc, "ISCC:EIBQAAAAAEAAAAABAAAAAAAAAAAAA");
1350    }
1351
1352    #[test]
1353    fn test_gen_audio_code_v0_negative() {
1354        let result = gen_audio_code_v0(&[-1, 0, 1], 256).unwrap();
1355        assert_eq!(
1356            result.iscc,
1357            "ISCC:EIDQAAAAAH777777AAAAAAAAAAAACAAAAAAP777774AAAAAAAAAAAAI"
1358        );
1359    }
1360
1361    #[test]
1362    fn test_gen_audio_code_v0_conformance() {
1363        let json_str = include_str!("../tests/data.json");
1364        let data: serde_json::Value = serde_json::from_str(json_str).unwrap();
1365        let section = &data["gen_audio_code_v0"];
1366        let cases = section.as_object().unwrap();
1367
1368        let mut tested = 0;
1369
1370        for (tc_name, tc) in cases {
1371            let inputs = tc["inputs"].as_array().unwrap();
1372            let cv_json = inputs[0].as_array().unwrap();
1373            let bits = inputs[1].as_u64().unwrap() as u32;
1374            let expected_iscc = tc["outputs"]["iscc"].as_str().unwrap();
1375
1376            let cv: Vec<i32> = cv_json.iter().map(|v| v.as_i64().unwrap() as i32).collect();
1377
1378            let result = gen_audio_code_v0(&cv, bits)
1379                .unwrap_or_else(|e| panic!("gen_audio_code_v0 failed for {tc_name}: {e}"));
1380            assert_eq!(
1381                result.iscc, expected_iscc,
1382                "ISCC mismatch in test case {tc_name}"
1383            );
1384
1385            tested += 1;
1386        }
1387
1388        assert_eq!(tested, 5, "expected 5 conformance tests to run");
1389    }
1390
1391    #[test]
1392    fn test_array_split_even() {
1393        let data = vec![1, 2, 3, 4];
1394        let parts = array_split(&data, 4);
1395        assert_eq!(parts, vec![&[1][..], &[2][..], &[3][..], &[4][..]]);
1396    }
1397
1398    #[test]
1399    fn test_array_split_remainder() {
1400        let data = vec![1, 2, 3, 4, 5];
1401        let parts = array_split(&data, 3);
1402        assert_eq!(parts, vec![&[1, 2][..], &[3, 4][..], &[5][..]]);
1403    }
1404
1405    #[test]
1406    fn test_array_split_more_parts_than_elements() {
1407        let data = vec![1, 2];
1408        let parts = array_split(&data, 4);
1409        assert_eq!(
1410            parts,
1411            vec![&[1][..], &[2][..], &[][..] as &[i32], &[][..] as &[i32]]
1412        );
1413    }
1414
1415    #[test]
1416    fn test_array_split_empty() {
1417        let data: Vec<i32> = vec![];
1418        let parts = array_split(&data, 3);
1419        assert_eq!(
1420            parts,
1421            vec![&[][..] as &[i32], &[][..] as &[i32], &[][..] as &[i32]]
1422        );
1423    }
1424
1425    #[test]
1426    fn test_gen_video_code_v0_empty_frames() {
1427        let frames: Vec<Vec<i32>> = vec![];
1428        assert!(matches!(
1429            gen_video_code_v0(&frames, 64),
1430            Err(IsccError::InvalidInput(_))
1431        ));
1432    }
1433
1434    #[test]
1435    fn test_gen_video_code_v0_conformance() {
1436        let json_str = include_str!("../tests/data.json");
1437        let data: serde_json::Value = serde_json::from_str(json_str).unwrap();
1438        let section = &data["gen_video_code_v0"];
1439        let cases = section.as_object().unwrap();
1440
1441        let mut tested = 0;
1442
1443        for (tc_name, tc) in cases {
1444            let inputs = tc["inputs"].as_array().unwrap();
1445            let frames_json = inputs[0].as_array().unwrap();
1446            let bits = inputs[1].as_u64().unwrap() as u32;
1447            let expected_iscc = tc["outputs"]["iscc"].as_str().unwrap();
1448
1449            let frame_sigs: Vec<Vec<i32>> = frames_json
1450                .iter()
1451                .map(|frame| {
1452                    frame
1453                        .as_array()
1454                        .unwrap()
1455                        .iter()
1456                        .map(|v| v.as_i64().unwrap() as i32)
1457                        .collect()
1458                })
1459                .collect();
1460
1461            let result = gen_video_code_v0(&frame_sigs, bits)
1462                .unwrap_or_else(|e| panic!("gen_video_code_v0 failed for {tc_name}: {e}"));
1463            assert_eq!(
1464                result.iscc, expected_iscc,
1465                "ISCC mismatch in test case {tc_name}"
1466            );
1467
1468            tested += 1;
1469        }
1470
1471        assert_eq!(tested, 3, "expected 3 conformance tests to run");
1472    }
1473
1474    #[test]
1475    fn test_gen_mixed_code_v0_conformance() {
1476        let json_str = include_str!("../tests/data.json");
1477        let data: serde_json::Value = serde_json::from_str(json_str).unwrap();
1478        let section = &data["gen_mixed_code_v0"];
1479        let cases = section.as_object().unwrap();
1480
1481        let mut tested = 0;
1482
1483        for (tc_name, tc) in cases {
1484            let inputs = tc["inputs"].as_array().unwrap();
1485            let codes_json = inputs[0].as_array().unwrap();
1486            let bits = inputs[1].as_u64().unwrap() as u32;
1487            let expected_iscc = tc["outputs"]["iscc"].as_str().unwrap();
1488            let expected_parts: Vec<&str> = tc["outputs"]["parts"]
1489                .as_array()
1490                .unwrap()
1491                .iter()
1492                .map(|v| v.as_str().unwrap())
1493                .collect();
1494
1495            let codes: Vec<&str> = codes_json.iter().map(|v| v.as_str().unwrap()).collect();
1496
1497            let result = gen_mixed_code_v0(&codes, bits)
1498                .unwrap_or_else(|e| panic!("gen_mixed_code_v0 failed for {tc_name}: {e}"));
1499            assert_eq!(
1500                result.iscc, expected_iscc,
1501                "ISCC mismatch in test case {tc_name}"
1502            );
1503
1504            // Verify parts from struct match expected
1505            let result_parts: Vec<&str> = result.parts.iter().map(|s| s.as_str()).collect();
1506            assert_eq!(
1507                result_parts, expected_parts,
1508                "parts mismatch in test case {tc_name}"
1509            );
1510
1511            tested += 1;
1512        }
1513
1514        assert_eq!(tested, 2, "expected 2 conformance tests to run");
1515    }
1516
1517    #[test]
1518    fn test_gen_mixed_code_v0_too_few_codes() {
1519        assert!(matches!(
1520            gen_mixed_code_v0(&["EUA6GIKXN42IQV3S"], 64),
1521            Err(IsccError::InvalidInput(_))
1522        ));
1523    }
1524
1525    /// Build raw Content-Code bytes (header + body) for a given bit length.
1526    fn make_content_code_raw(stype: codec::SubType, bit_length: u32) -> Vec<u8> {
1527        let nbytes = (bit_length / 8) as usize;
1528        let body: Vec<u8> = (0..nbytes).map(|i| (i & 0xFF) as u8).collect();
1529        let base32 = codec::encode_component(
1530            codec::MainType::Content,
1531            stype,
1532            codec::Version::V0,
1533            bit_length,
1534            &body,
1535        )
1536        .unwrap();
1537        codec::decode_base32(&base32).unwrap()
1538    }
1539
1540    #[test]
1541    fn test_soft_hash_codes_v0_rejects_short_code() {
1542        // One code with 64 bits, one with only 32 bits — should reject when requesting 64
1543        let code_64 = make_content_code_raw(codec::SubType::None, 64);
1544        let code_32 = make_content_code_raw(codec::SubType::Image, 32);
1545        let result = soft_hash_codes_v0(&[code_64, code_32], 64);
1546        assert!(
1547            matches!(&result, Err(IsccError::InvalidInput(msg)) if msg.contains("too short")),
1548            "expected InvalidInput with 'too short', got {result:?}"
1549        );
1550    }
1551
1552    #[test]
1553    fn test_soft_hash_codes_v0_accepts_exact_length() {
1554        // Two codes with exactly 64 bits each — should succeed when requesting 64
1555        let code_a = make_content_code_raw(codec::SubType::None, 64);
1556        let code_b = make_content_code_raw(codec::SubType::Image, 64);
1557        let result = soft_hash_codes_v0(&[code_a, code_b], 64);
1558        assert!(result.is_ok(), "expected Ok, got {result:?}");
1559    }
1560
1561    #[test]
1562    fn test_soft_hash_codes_v0_accepts_longer_codes() {
1563        // Two codes with 128 bits each — should succeed when requesting 64
1564        let code_a = make_content_code_raw(codec::SubType::None, 128);
1565        let code_b = make_content_code_raw(codec::SubType::Audio, 128);
1566        let result = soft_hash_codes_v0(&[code_a, code_b], 64);
1567        assert!(result.is_ok(), "expected Ok, got {result:?}");
1568    }
1569
1570    #[test]
1571    fn test_gen_data_code_v0_conformance() {
1572        let json_str = include_str!("../tests/data.json");
1573        let data: serde_json::Value = serde_json::from_str(json_str).unwrap();
1574        let section = &data["gen_data_code_v0"];
1575        let cases = section.as_object().unwrap();
1576
1577        let mut tested = 0;
1578
1579        for (tc_name, tc) in cases {
1580            let inputs = tc["inputs"].as_array().unwrap();
1581            let stream_str = inputs[0].as_str().unwrap();
1582            let bits = inputs[1].as_u64().unwrap() as u32;
1583            let expected_iscc = tc["outputs"]["iscc"].as_str().unwrap();
1584
1585            // Parse "stream:" prefix — remainder is hex-encoded bytes
1586            let hex_data = stream_str
1587                .strip_prefix("stream:")
1588                .unwrap_or_else(|| panic!("expected 'stream:' prefix in test case {tc_name}"));
1589            let input_bytes = hex::decode(hex_data)
1590                .unwrap_or_else(|e| panic!("invalid hex in test case {tc_name}: {e}"));
1591
1592            let result = gen_data_code_v0(&input_bytes, bits)
1593                .unwrap_or_else(|e| panic!("gen_data_code_v0 failed for {tc_name}: {e}"));
1594            assert_eq!(
1595                result.iscc, expected_iscc,
1596                "ISCC mismatch in test case {tc_name}"
1597            );
1598
1599            tested += 1;
1600        }
1601
1602        assert_eq!(tested, 4, "expected 4 conformance tests to run");
1603    }
1604
1605    #[test]
1606    fn test_gen_instance_code_v0_empty() {
1607        let result = gen_instance_code_v0(b"", 64).unwrap();
1608        assert_eq!(result.iscc, "ISCC:IAA26E2JXH27TING");
1609        assert_eq!(result.filesize, 0);
1610        assert_eq!(
1611            result.datahash,
1612            "1e20af1349b9f5f9a1a6a0404dea36dcc9499bcb25c9adc112b7cc9a93cae41f3262"
1613        );
1614    }
1615
1616    #[test]
1617    fn test_gen_instance_code_v0_conformance() {
1618        let json_str = include_str!("../tests/data.json");
1619        let data: serde_json::Value = serde_json::from_str(json_str).unwrap();
1620        let section = &data["gen_instance_code_v0"];
1621        let cases = section.as_object().unwrap();
1622
1623        for (name, tc) in cases {
1624            let inputs = tc["inputs"].as_array().unwrap();
1625            let stream_str = inputs[0].as_str().unwrap();
1626            let bits = inputs[1].as_u64().unwrap() as u32;
1627            let expected_iscc = tc["outputs"]["iscc"].as_str().unwrap();
1628
1629            // Parse "stream:" prefix — remainder is hex-encoded bytes
1630            let hex_data = stream_str
1631                .strip_prefix("stream:")
1632                .unwrap_or_else(|| panic!("expected 'stream:' prefix in test case {name}"));
1633            let input_bytes = hex::decode(hex_data)
1634                .unwrap_or_else(|e| panic!("invalid hex in test case {name}: {e}"));
1635
1636            let result = gen_instance_code_v0(&input_bytes, bits)
1637                .unwrap_or_else(|e| panic!("gen_instance_code_v0 failed for {name}: {e}"));
1638            assert_eq!(
1639                result.iscc, expected_iscc,
1640                "ISCC mismatch in test case {name}"
1641            );
1642
1643            // Verify datahash from struct
1644            if let Some(expected_datahash) = tc["outputs"].get("datahash") {
1645                let expected_datahash = expected_datahash.as_str().unwrap();
1646                assert_eq!(
1647                    result.datahash, expected_datahash,
1648                    "datahash mismatch in test case {name}"
1649                );
1650            }
1651
1652            // Verify filesize from struct
1653            if let Some(expected_filesize) = tc["outputs"].get("filesize") {
1654                let expected_filesize = expected_filesize.as_u64().unwrap();
1655                assert_eq!(
1656                    result.filesize, expected_filesize,
1657                    "filesize mismatch in test case {name}"
1658                );
1659            }
1660
1661            // Also verify filesize matches input data length
1662            assert_eq!(
1663                result.filesize,
1664                input_bytes.len() as u64,
1665                "filesize should match input length in test case {name}"
1666            );
1667        }
1668    }
1669
1670    #[test]
1671    fn test_gen_iscc_code_v0_conformance() {
1672        let json_str = include_str!("../tests/data.json");
1673        let data: serde_json::Value = serde_json::from_str(json_str).unwrap();
1674        let section = &data["gen_iscc_code_v0"];
1675        let cases = section.as_object().unwrap();
1676
1677        let mut tested = 0;
1678
1679        for (tc_name, tc) in cases {
1680            let inputs = tc["inputs"].as_array().unwrap();
1681            let codes_json = inputs[0].as_array().unwrap();
1682            let expected_iscc = tc["outputs"]["iscc"].as_str().unwrap();
1683
1684            let codes: Vec<&str> = codes_json.iter().map(|v| v.as_str().unwrap()).collect();
1685
1686            let result = gen_iscc_code_v0(&codes, false)
1687                .unwrap_or_else(|e| panic!("gen_iscc_code_v0 failed for {tc_name}: {e}"));
1688            assert_eq!(
1689                result.iscc, expected_iscc,
1690                "ISCC mismatch in test case {tc_name}"
1691            );
1692
1693            tested += 1;
1694        }
1695
1696        assert_eq!(tested, 5, "expected 5 conformance tests to run");
1697    }
1698
1699    #[test]
1700    fn test_gen_iscc_code_v0_too_few_codes() {
1701        assert!(matches!(
1702            gen_iscc_code_v0(&["AAAWKLHFPV6OPKDG"], false),
1703            Err(IsccError::InvalidInput(_))
1704        ));
1705    }
1706
1707    #[test]
1708    fn test_gen_iscc_code_v0_missing_instance() {
1709        // Two Meta codes — missing Data and Instance
1710        assert!(matches!(
1711            gen_iscc_code_v0(&["AAAWKLHFPV6OPKDG", "AAAWKLHFPV6OPKDG"], false),
1712            Err(IsccError::InvalidInput(_))
1713        ));
1714    }
1715
1716    #[test]
1717    fn test_gen_iscc_code_v0_short_code() {
1718        // Code too short (< 16 chars)
1719        assert!(matches!(
1720            gen_iscc_code_v0(&["AAAWKLHFPV6", "AAAWKLHFPV6OPKDG"], false),
1721            Err(IsccError::InvalidInput(_))
1722        ));
1723    }
1724
1725    /// Verify that a Data-URL with empty base64 payload enters the meta bytes path.
1726    ///
1727    /// Python reference: `if meta:` is truthy for `"data:application/json;base64,"` (non-empty
1728    /// string), so it enters the meta branch with `payload = b""`. The result must have
1729    /// `meta = Some(...)` containing the original Data-URL and `metahash` equal to
1730    /// `multi_hash_blake3(&[])` (BLAKE3 of empty bytes).
1731    #[cfg(feature = "meta-code")]
1732    #[test]
1733    fn test_gen_meta_code_empty_data_url_enters_meta_branch() {
1734        let result =
1735            gen_meta_code_v0("Test", None, Some("data:application/json;base64,"), 64).unwrap();
1736
1737        // Result should be Ok
1738        assert_eq!(result.name, "Test");
1739
1740        // meta should contain the original Data-URL string (not None)
1741        assert_eq!(
1742            result.meta,
1743            Some("data:application/json;base64,".to_string()),
1744            "empty Data-URL payload should still enter meta branch"
1745        );
1746
1747        // metahash should be BLAKE3 of empty bytes
1748        let expected_metahash = utils::multi_hash_blake3(&[]);
1749        assert_eq!(
1750            result.metahash, expected_metahash,
1751            "metahash should be BLAKE3 of empty bytes"
1752        );
1753    }
1754
1755    /// Verify that `soft_hash_meta_v0_with_bytes` with empty bytes produces the same
1756    /// digest as `soft_hash_meta_v0` with no extra text.
1757    ///
1758    /// Python reference (`code_meta.py:142`): `if extra in {None, "", b""}:` returns
1759    /// name-only simhash without interleaving for all empty-like values.
1760    #[cfg(feature = "meta-code")]
1761    #[test]
1762    fn test_soft_hash_meta_v0_with_bytes_empty_equals_name_only() {
1763        let name_only = soft_hash_meta_v0("test", None);
1764        let empty_bytes = soft_hash_meta_v0_with_bytes("test", &[]);
1765        assert_eq!(
1766            name_only, empty_bytes,
1767            "empty bytes should produce same digest as name-only (no interleaving)"
1768        );
1769    }
1770
1771    // ---- Algorithm constants tests ----
1772
1773    #[cfg(feature = "meta-code")]
1774    #[test]
1775    fn test_meta_trim_name_value() {
1776        assert_eq!(META_TRIM_NAME, 128);
1777    }
1778
1779    #[cfg(feature = "meta-code")]
1780    #[test]
1781    fn test_meta_trim_description_value() {
1782        assert_eq!(META_TRIM_DESCRIPTION, 4096);
1783    }
1784
1785    #[test]
1786    fn test_io_read_size_value() {
1787        assert_eq!(IO_READ_SIZE, 4_194_304);
1788    }
1789
1790    #[test]
1791    fn test_text_ngram_size_value() {
1792        assert_eq!(TEXT_NGRAM_SIZE, 13);
1793    }
1794
1795    // ---- encode_component Tier 1 wrapper tests ----
1796
1797    /// Encode a known digest and verify the output matches the codec version.
1798    #[test]
1799    fn test_encode_component_matches_codec() {
1800        let digest = [0xABu8; 8];
1801        let tier1 = encode_component(3, 0, 0, 64, &digest).unwrap();
1802        let tier2 = codec::encode_component(
1803            codec::MainType::Data,
1804            codec::SubType::None,
1805            codec::Version::V0,
1806            64,
1807            &digest,
1808        )
1809        .unwrap();
1810        assert_eq!(tier1, tier2);
1811    }
1812
1813    /// Round-trip: encode a digest and verify the result is a valid ISCC unit.
1814    #[test]
1815    fn test_encode_component_round_trip() {
1816        let digest = [0x42u8; 32];
1817        let result = encode_component(0, 0, 0, 64, &digest).unwrap();
1818        // Meta-Code with 64-bit digest should start with "AA"
1819        assert!(!result.is_empty());
1820    }
1821
1822    /// Reject MainType::Iscc (value 5).
1823    #[test]
1824    fn test_encode_component_rejects_iscc() {
1825        let result = encode_component(5, 0, 0, 64, &[0u8; 8]);
1826        assert!(result.is_err());
1827    }
1828
1829    /// Reject digest shorter than bit_length / 8.
1830    #[test]
1831    fn test_encode_component_rejects_short_digest() {
1832        let result = encode_component(0, 0, 0, 64, &[0u8; 4]);
1833        assert!(result.is_err());
1834        let err = result.unwrap_err().to_string();
1835        assert!(
1836            err.contains("digest length 4 < bit_length/8 (8)"),
1837            "unexpected error: {err}"
1838        );
1839    }
1840
1841    /// Reject invalid MainType value.
1842    #[test]
1843    fn test_encode_component_rejects_invalid_mtype() {
1844        let result = encode_component(99, 0, 0, 64, &[0u8; 8]);
1845        assert!(result.is_err());
1846    }
1847
1848    /// Reject invalid SubType value.
1849    #[test]
1850    fn test_encode_component_rejects_invalid_stype() {
1851        let result = encode_component(0, 99, 0, 64, &[0u8; 8]);
1852        assert!(result.is_err());
1853    }
1854
1855    /// Reject invalid Version value.
1856    #[test]
1857    fn test_encode_component_rejects_invalid_version() {
1858        let result = encode_component(0, 0, 99, 64, &[0u8; 8]);
1859        assert!(result.is_err());
1860    }
1861
1862    // ---- iscc_decode tests ----
1863
1864    /// Round-trip: encode a Meta-Code digest, decode back, verify all fields match.
1865    #[test]
1866    fn test_iscc_decode_round_trip_meta() {
1867        let digest = [0xaa_u8; 8];
1868        let encoded = encode_component(0, 0, 0, 64, &digest).unwrap();
1869        let (mt, st, vs, li, decoded_digest) = iscc_decode(&encoded).unwrap();
1870        assert_eq!(mt, 0, "MainType::Meta");
1871        assert_eq!(st, 0, "SubType::None");
1872        assert_eq!(vs, 0, "Version::V0");
1873        // encode_length(Meta, 64) → 64/32 - 1 = 1
1874        assert_eq!(li, 1, "length_index");
1875        assert_eq!(decoded_digest, digest.to_vec());
1876    }
1877
1878    /// Round-trip with Content-Code (MainType=2, SubType::TEXT=0).
1879    #[test]
1880    fn test_iscc_decode_round_trip_content() {
1881        let digest = [0xbb_u8; 8];
1882        let encoded = encode_component(2, 0, 0, 64, &digest).unwrap();
1883        let (mt, st, vs, _li, decoded_digest) = iscc_decode(&encoded).unwrap();
1884        assert_eq!(mt, 2, "MainType::Content");
1885        assert_eq!(st, 0, "SubType::TEXT");
1886        assert_eq!(vs, 0, "Version::V0");
1887        assert_eq!(decoded_digest, digest.to_vec());
1888    }
1889
1890    /// Round-trip with Data-Code (MainType=3).
1891    #[test]
1892    fn test_iscc_decode_round_trip_data() {
1893        let digest = [0xcc_u8; 8];
1894        let encoded = encode_component(3, 0, 0, 64, &digest).unwrap();
1895        let (mt, _st, _vs, _li, decoded_digest) = iscc_decode(&encoded).unwrap();
1896        assert_eq!(mt, 3, "MainType::Data");
1897        assert_eq!(decoded_digest, digest.to_vec());
1898    }
1899
1900    /// Round-trip with Instance-Code (MainType=4).
1901    #[test]
1902    fn test_iscc_decode_round_trip_instance() {
1903        let digest = [0xdd_u8; 8];
1904        let encoded = encode_component(4, 0, 0, 64, &digest).unwrap();
1905        let (mt, _st, _vs, _li, decoded_digest) = iscc_decode(&encoded).unwrap();
1906        assert_eq!(mt, 4, "MainType::Instance");
1907        assert_eq!(decoded_digest, digest.to_vec());
1908    }
1909
1910    /// Decode with "ISCC:" prefix produces the same result.
1911    #[test]
1912    fn test_iscc_decode_with_prefix() {
1913        let digest = [0xaa_u8; 8];
1914        let encoded = encode_component(0, 0, 0, 64, &digest).unwrap();
1915        let with_prefix = format!("ISCC:{encoded}");
1916        let (mt, st, vs, li, decoded_digest) = iscc_decode(&with_prefix).unwrap();
1917        assert_eq!(mt, 0);
1918        assert_eq!(st, 0);
1919        assert_eq!(vs, 0);
1920        assert_eq!(li, 1);
1921        assert_eq!(decoded_digest, digest.to_vec());
1922    }
1923
1924    /// Decode with dashes inserted in the string.
1925    #[test]
1926    fn test_iscc_decode_with_dashes() {
1927        let digest = [0xaa_u8; 8];
1928        let encoded = encode_component(0, 0, 0, 64, &digest).unwrap();
1929        // Insert dashes at arbitrary positions
1930        let with_dashes = format!("{}-{}-{}", &encoded[..4], &encoded[4..8], &encoded[8..]);
1931        let (mt, st, vs, li, decoded_digest) = iscc_decode(&with_dashes).unwrap();
1932        assert_eq!(mt, 0);
1933        assert_eq!(st, 0);
1934        assert_eq!(vs, 0);
1935        assert_eq!(li, 1);
1936        assert_eq!(decoded_digest, digest.to_vec());
1937    }
1938
1939    /// Error on invalid base32 characters.
1940    #[test]
1941    fn test_iscc_decode_invalid_base32() {
1942        let result = iscc_decode("!!!INVALID!!!");
1943        assert!(result.is_err());
1944        let err = result.unwrap_err().to_string();
1945        assert!(err.contains("base32"), "expected base32 error: {err}");
1946    }
1947
1948    /// Known value from conformance vectors: Meta-Code "ISCC:AAAZXZ6OU74YAZIM".
1949    /// MainType=Meta(0), SubType=None(0), Version=V0(0), 64-bit digest.
1950    #[test]
1951    fn test_iscc_decode_known_meta_code() {
1952        let (mt, st, vs, li, digest) = iscc_decode("ISCC:AAAZXZ6OU74YAZIM").unwrap();
1953        assert_eq!(mt, 0, "MainType::Meta");
1954        assert_eq!(st, 0, "SubType::None");
1955        assert_eq!(vs, 0, "Version::V0");
1956        assert_eq!(li, 1, "length_index for 64-bit");
1957        assert_eq!(digest.len(), 8, "64-bit = 8 bytes");
1958    }
1959
1960    /// Known value from conformance vectors: Instance-Code "ISCC:IAA26E2JXH27TING".
1961    /// MainType=Instance(4), SubType=None(0), Version=V0(0), 64-bit digest.
1962    #[test]
1963    fn test_iscc_decode_known_instance_code() {
1964        let (mt, st, vs, li, digest) = iscc_decode("ISCC:IAA26E2JXH27TING").unwrap();
1965        assert_eq!(mt, 4, "MainType::Instance");
1966        assert_eq!(st, 0, "SubType::None");
1967        assert_eq!(vs, 0, "Version::V0");
1968        assert_eq!(li, 1, "length_index for 64-bit");
1969        assert_eq!(digest.len(), 8, "64-bit = 8 bytes");
1970    }
1971
1972    /// Known value: Data-Code "ISCC:GAAXL2XYM5BQIAZ3".
1973    /// MainType=Data(3), SubType=None(0), Version=V0(0), 64-bit digest.
1974    #[test]
1975    fn test_iscc_decode_known_data_code() {
1976        let (mt, st, vs, _li, digest) = iscc_decode("ISCC:GAAXL2XYM5BQIAZ3").unwrap();
1977        assert_eq!(mt, 3, "MainType::Data");
1978        assert_eq!(st, 0, "SubType::None");
1979        assert_eq!(vs, 0, "Version::V0");
1980        assert_eq!(digest.len(), 8, "64-bit = 8 bytes");
1981    }
1982
1983    /// Verification criterion: round-trip with specific known values.
1984    /// encode_component(0, 0, 0, 64, &[0xaa;8]) → iscc_decode → (0, 0, 0, 1, vec![0xaa;8])
1985    #[test]
1986    fn test_iscc_decode_verification_round_trip() {
1987        let digest = [0xaa_u8; 8];
1988        let encoded = encode_component(0, 0, 0, 64, &digest).unwrap();
1989        let result = iscc_decode(&encoded).unwrap();
1990        assert_eq!(result, (0, 0, 0, 1, vec![0xaa; 8]));
1991    }
1992
1993    /// Error on truncated input where body is shorter than expected digest length.
1994    #[test]
1995    fn test_iscc_decode_truncated_input() {
1996        // Encode a valid 256-bit Meta-Code, then truncate the base32 string
1997        let digest = [0xff_u8; 32];
1998        let encoded = encode_component(0, 0, 0, 256, &digest).unwrap();
1999        // Truncate to just the header portion (first few chars)
2000        let truncated = &encoded[..6];
2001        let result = iscc_decode(truncated);
2002        assert!(result.is_err(), "should fail on truncated input");
2003    }
2004
2005    // --- json_to_data_url tests ---
2006
2007    /// Basic JSON object produces a data URL with application/json media type.
2008    #[cfg(feature = "meta-code")]
2009    #[test]
2010    fn test_json_to_data_url_basic() {
2011        let url = json_to_data_url(r#"{"key": "value"}"#).unwrap();
2012        assert!(
2013            url.starts_with("data:application/json;base64,"),
2014            "expected application/json prefix, got: {url}"
2015        );
2016    }
2017
2018    /// JSON with `@context` key uses application/ld+json media type.
2019    #[cfg(feature = "meta-code")]
2020    #[test]
2021    fn test_json_to_data_url_ld_json() {
2022        let url = json_to_data_url(r#"{"@context": "https://schema.org"}"#).unwrap();
2023        assert!(
2024            url.starts_with("data:application/ld+json;base64,"),
2025            "expected application/ld+json prefix, got: {url}"
2026        );
2027    }
2028
2029    /// JCS canonicalization reorders keys alphabetically.
2030    #[cfg(feature = "meta-code")]
2031    #[test]
2032    fn test_json_to_data_url_jcs_ordering() {
2033        let url = json_to_data_url(r#"{"b":1,"a":2}"#).unwrap();
2034        // Extract and decode the base64 payload
2035        let b64 = url.split_once(',').unwrap().1;
2036        let decoded = data_encoding::BASE64.decode(b64.as_bytes()).unwrap();
2037        let canonical = std::str::from_utf8(&decoded).unwrap();
2038        assert_eq!(canonical, r#"{"a":2,"b":1}"#, "JCS should sort keys");
2039    }
2040
2041    /// Round-trip: json_to_data_url output fed into decode_data_url recovers
2042    /// the JCS-canonical bytes.
2043    #[cfg(feature = "meta-code")]
2044    #[test]
2045    fn test_json_to_data_url_round_trip() {
2046        let input = r#"{"hello": "world", "num": 42}"#;
2047        let url = json_to_data_url(input).unwrap();
2048        let decoded_bytes = decode_data_url(&url).unwrap();
2049        // The decoded bytes should be JCS-canonical JSON
2050        let canonical: serde_json::Value =
2051            serde_json::from_slice(&decoded_bytes).expect("decoded bytes should be valid JSON");
2052        let original: serde_json::Value = serde_json::from_str(input).unwrap();
2053        assert_eq!(canonical, original, "round-trip preserves JSON semantics");
2054    }
2055
2056    /// Invalid JSON string returns an error.
2057    #[cfg(feature = "meta-code")]
2058    #[test]
2059    fn test_json_to_data_url_invalid_json() {
2060        let result = json_to_data_url("not json");
2061        assert!(result.is_err(), "should reject invalid JSON");
2062        let err = result.unwrap_err().to_string();
2063        assert!(
2064            err.contains("invalid JSON"),
2065            "expected 'invalid JSON' in error: {err}"
2066        );
2067    }
2068
2069    /// Compatibility with conformance vector test_0016_meta_data_url.
2070    ///
2071    /// The conformance vector's meta field is:
2072    ///   data:application/json;charset=utf-8;base64,eyJzb21lIjogIm9iamVjdCJ9
2073    /// which encodes `{"some": "object"}` (with space after colon).
2074    ///
2075    /// Our function differs in two ways:
2076    /// 1. No `charset=utf-8` parameter (matching Python's DataURL.from_byte_data)
2077    /// 2. JCS canonicalization removes whitespace: `{"some":"object"}` (no space)
2078    ///
2079    /// We verify: (a) correct media type prefix, and (b) decoded payload equals
2080    /// JCS-canonical form of the same JSON input.
2081    #[cfg(feature = "meta-code")]
2082    #[test]
2083    fn test_json_to_data_url_conformance_0016() {
2084        let url = json_to_data_url(r#"{"some": "object"}"#).unwrap();
2085        // (a) Correct media type prefix (no charset, no @context → application/json)
2086        assert!(
2087            url.starts_with("data:application/json;base64,"),
2088            "expected application/json prefix"
2089        );
2090        // (b) Decoded payload is JCS-canonical (no whitespace)
2091        let b64 = url.split_once(',').unwrap().1;
2092        let decoded = data_encoding::BASE64.decode(b64.as_bytes()).unwrap();
2093        let canonical = std::str::from_utf8(&decoded).unwrap();
2094        assert_eq!(
2095            canonical, r#"{"some":"object"}"#,
2096            "JCS removes whitespace from JSON"
2097        );
2098    }
2099
2100    #[cfg(feature = "meta-code")]
2101    #[test]
2102    fn test_meta_trim_meta_value() {
2103        assert_eq!(META_TRIM_META, 128_000);
2104    }
2105
2106    #[cfg(feature = "meta-code")]
2107    #[test]
2108    fn test_gen_meta_code_v0_meta_at_limit() {
2109        // Create a JSON payload that decodes to exactly 128,000 bytes
2110        // JSON: {"x":"<padding>"} where padding fills to 128,000 bytes
2111        // The canonical JSON overhead is {"x":""} = 8 bytes, so padding = 127,992 bytes
2112        let padding = "a".repeat(128_000 - 8);
2113        let json_str = format!(r#"{{"x":"{padding}"}}"#);
2114        let result = gen_meta_code_v0("test", None, Some(&json_str), 64);
2115        assert!(
2116            result.is_ok(),
2117            "payload at exactly META_TRIM_META should succeed"
2118        );
2119    }
2120
2121    #[cfg(feature = "meta-code")]
2122    #[test]
2123    fn test_gen_meta_code_v0_meta_over_limit() {
2124        // Create a JSON payload that decodes to 128,001 bytes (one over limit)
2125        let padding = "a".repeat(128_000 - 8 + 1);
2126        let json_str = format!(r#"{{"x":"{padding}"}}"#);
2127        let result = gen_meta_code_v0("test", None, Some(&json_str), 64);
2128        assert!(
2129            matches!(result, Err(IsccError::InvalidInput(ref msg)) if msg.contains("size limit")),
2130            "payload exceeding META_TRIM_META should return InvalidInput"
2131        );
2132    }
2133
2134    #[cfg(feature = "meta-code")]
2135    #[test]
2136    fn test_gen_meta_code_v0_data_url_pre_decode_reject() {
2137        // Create a Data-URL string exceeding the pre-decode limit
2138        // PRE_DECODE_LIMIT = META_TRIM_META * 4 / 3 + 256 = 170,922
2139        let pre_decode_limit = META_TRIM_META * 4 / 3 + 256;
2140        let padding = "A".repeat(pre_decode_limit + 1);
2141        let data_url = format!("data:application/octet-stream;base64,{padding}");
2142        let result = gen_meta_code_v0("test", None, Some(&data_url), 64);
2143        assert!(
2144            matches!(result, Err(IsccError::InvalidInput(ref msg)) if msg.contains("size limit")),
2145            "oversized Data-URL should be rejected before decoding"
2146        );
2147    }
2148
2149    // ---- gen_sum_code_v0 tests ----
2150
2151    /// Helper: write data to a unique temp file and return the path.
2152    fn write_temp_file(name: &str, data: &[u8]) -> std::path::PathBuf {
2153        let path = std::env::temp_dir().join(format!("iscc_test_{name}"));
2154        std::fs::write(&path, data).expect("failed to write temp file");
2155        path
2156    }
2157
2158    #[test]
2159    fn test_gen_sum_code_v0_equivalence() {
2160        let data = b"Hello, ISCC World! This is a test of gen_sum_code_v0.";
2161        let path = write_temp_file("sum_equiv", data);
2162
2163        let sum_result = gen_sum_code_v0(&path, 64, false, false).unwrap();
2164
2165        // Compute the same result via separate functions
2166        let data_result = gen_data_code_v0(data, 64).unwrap();
2167        let instance_result = gen_instance_code_v0(data, 64).unwrap();
2168        let iscc_result =
2169            gen_iscc_code_v0(&[&data_result.iscc, &instance_result.iscc], false).unwrap();
2170
2171        assert_eq!(sum_result.iscc, iscc_result.iscc);
2172        assert_eq!(sum_result.datahash, instance_result.datahash);
2173        assert_eq!(sum_result.filesize, instance_result.filesize);
2174        assert_eq!(sum_result.filesize, data.len() as u64);
2175        assert_eq!(sum_result.units, None);
2176
2177        std::fs::remove_file(&path).ok();
2178    }
2179
2180    #[test]
2181    fn test_gen_sum_code_v0_empty_file() {
2182        let path = write_temp_file("sum_empty", b"");
2183
2184        let sum_result = gen_sum_code_v0(&path, 64, false, false).unwrap();
2185
2186        let data_result = gen_data_code_v0(b"", 64).unwrap();
2187        let instance_result = gen_instance_code_v0(b"", 64).unwrap();
2188        let iscc_result =
2189            gen_iscc_code_v0(&[&data_result.iscc, &instance_result.iscc], false).unwrap();
2190
2191        assert_eq!(sum_result.iscc, iscc_result.iscc);
2192        assert_eq!(sum_result.datahash, instance_result.datahash);
2193        assert_eq!(sum_result.filesize, 0);
2194
2195        std::fs::remove_file(&path).ok();
2196    }
2197
2198    #[test]
2199    fn test_gen_sum_code_v0_file_not_found() {
2200        let path = std::env::temp_dir().join("iscc_test_nonexistent_file_xyz");
2201        let result = gen_sum_code_v0(&path, 64, false, false);
2202        assert!(result.is_err());
2203        let err_msg = result.unwrap_err().to_string();
2204        assert!(
2205            err_msg.contains("Cannot open file"),
2206            "error message should mention file open failure: {err_msg}"
2207        );
2208    }
2209
2210    #[test]
2211    fn test_gen_sum_code_v0_wide_mode() {
2212        let data = b"Testing wide mode for gen_sum_code_v0 function.";
2213        let path = write_temp_file("sum_wide", data);
2214
2215        let narrow = gen_sum_code_v0(&path, 64, false, false).unwrap();
2216        let wide = gen_sum_code_v0(&path, 64, true, false).unwrap();
2217
2218        // Wide mode with 64-bit codes doesn't trigger (need 128+), so they should be equal
2219        assert_eq!(narrow.iscc, wide.iscc);
2220
2221        // With 128 bits, wide mode should produce a different (longer) ISCC
2222        let narrow_128 = gen_sum_code_v0(&path, 128, false, false).unwrap();
2223        let wide_128 = gen_sum_code_v0(&path, 128, true, false).unwrap();
2224        assert_ne!(narrow_128.iscc, wide_128.iscc);
2225
2226        // Both should have the same datahash and filesize
2227        assert_eq!(narrow_128.datahash, wide_128.datahash);
2228        assert_eq!(narrow_128.filesize, wide_128.filesize);
2229
2230        std::fs::remove_file(&path).ok();
2231    }
2232
2233    #[test]
2234    fn test_gen_sum_code_v0_bits_64() {
2235        let data = b"Testing 64-bit gen_sum_code_v0.";
2236        let path = write_temp_file("sum_bits64", data);
2237
2238        let sum_result = gen_sum_code_v0(&path, 64, false, false).unwrap();
2239
2240        let data_result = gen_data_code_v0(data, 64).unwrap();
2241        let instance_result = gen_instance_code_v0(data, 64).unwrap();
2242        let iscc_result =
2243            gen_iscc_code_v0(&[&data_result.iscc, &instance_result.iscc], false).unwrap();
2244
2245        assert_eq!(sum_result.iscc, iscc_result.iscc);
2246
2247        std::fs::remove_file(&path).ok();
2248    }
2249
2250    #[test]
2251    fn test_gen_sum_code_v0_bits_128() {
2252        let data = b"Testing 128-bit gen_sum_code_v0.";
2253        let path = write_temp_file("sum_bits128", data);
2254
2255        let sum_result = gen_sum_code_v0(&path, 128, false, false).unwrap();
2256
2257        let data_result = gen_data_code_v0(data, 128).unwrap();
2258        let instance_result = gen_instance_code_v0(data, 128).unwrap();
2259        let iscc_result =
2260            gen_iscc_code_v0(&[&data_result.iscc, &instance_result.iscc], false).unwrap();
2261
2262        assert_eq!(sum_result.iscc, iscc_result.iscc);
2263        assert_eq!(sum_result.datahash, instance_result.datahash);
2264        assert_eq!(sum_result.filesize, data.len() as u64);
2265
2266        std::fs::remove_file(&path).ok();
2267    }
2268
2269    #[test]
2270    fn test_gen_sum_code_v0_large_data() {
2271        // Generate data large enough to produce multiple CDC chunks
2272        let data: Vec<u8> = (0..50_000).map(|i| (i % 256) as u8).collect();
2273        let path = write_temp_file("sum_large", &data);
2274
2275        let sum_result = gen_sum_code_v0(&path, 64, false, false).unwrap();
2276
2277        let data_result = gen_data_code_v0(&data, 64).unwrap();
2278        let instance_result = gen_instance_code_v0(&data, 64).unwrap();
2279        let iscc_result =
2280            gen_iscc_code_v0(&[&data_result.iscc, &instance_result.iscc], false).unwrap();
2281
2282        assert_eq!(sum_result.iscc, iscc_result.iscc);
2283        assert_eq!(sum_result.datahash, instance_result.datahash);
2284        assert_eq!(sum_result.filesize, data.len() as u64);
2285
2286        std::fs::remove_file(&path).ok();
2287    }
2288
2289    #[test]
2290    fn test_gen_sum_code_v0_units_enabled() {
2291        let data = b"Hello, ISCC World! This is a test of gen_sum_code_v0 units.";
2292        let path = write_temp_file("sum_units_on", data);
2293
2294        let sum_result = gen_sum_code_v0(&path, 64, false, true).unwrap();
2295
2296        // units should be Some with exactly 2 elements
2297        let units = sum_result.units.as_ref().expect("units should be Some");
2298        assert_eq!(
2299            units.len(),
2300            2,
2301            "units should contain [Data-Code, Instance-Code]"
2302        );
2303
2304        // First unit should be a Data-Code (MainType::Data = 3)
2305        let (maintype, ..) = iscc_decode(&units[0]).unwrap();
2306        assert_eq!(
2307            maintype, 3,
2308            "first unit should be a Data-Code (MainType::Data = 3)"
2309        );
2310
2311        // Second unit should be an Instance-Code (MainType::Instance = 4)
2312        let (maintype, ..) = iscc_decode(&units[1]).unwrap();
2313        assert_eq!(
2314            maintype, 4,
2315            "second unit should be an Instance-Code (MainType::Instance = 4)"
2316        );
2317
2318        // Units should match individually computed codes
2319        let data_result = gen_data_code_v0(data, 64).unwrap();
2320        let instance_result = gen_instance_code_v0(data, 64).unwrap();
2321        assert_eq!(units[0], data_result.iscc);
2322        assert_eq!(units[1], instance_result.iscc);
2323
2324        // The composite ISCC should still be correct
2325        let iscc_result =
2326            gen_iscc_code_v0(&[&data_result.iscc, &instance_result.iscc], false).unwrap();
2327        assert_eq!(sum_result.iscc, iscc_result.iscc);
2328
2329        std::fs::remove_file(&path).ok();
2330    }
2331
2332    #[test]
2333    fn test_gen_sum_code_v0_units_disabled() {
2334        let data = b"Hello, ISCC World! This is a test of gen_sum_code_v0 no units.";
2335        let path = write_temp_file("sum_units_off", data);
2336
2337        let sum_result = gen_sum_code_v0(&path, 64, false, false).unwrap();
2338
2339        assert_eq!(
2340            sum_result.units, None,
2341            "units should be None when add_units is false"
2342        );
2343
2344        // The composite ISCC should still be correct
2345        let data_result = gen_data_code_v0(data, 64).unwrap();
2346        let instance_result = gen_instance_code_v0(data, 64).unwrap();
2347        let iscc_result =
2348            gen_iscc_code_v0(&[&data_result.iscc, &instance_result.iscc], false).unwrap();
2349        assert_eq!(sum_result.iscc, iscc_result.iscc);
2350
2351        std::fs::remove_file(&path).ok();
2352    }
2353}