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