Skip to main content

oxirs_ttl/toolkit/
iri_normalizer.rs

1//! IRI Normalization per RFC 3987 Section 5.3
2//!
3//! This module provides IRI normalization to convert IRIs into a canonical form
4//! for efficient comparison and storage. Normalization includes:
5//!
6//! - Case normalization (scheme and host to lowercase)
7//! - Percent-encoding normalization (decode unreserved characters)
8//! - Path normalization (remove unnecessary dot-segments)
9//! - Default port removal (http:80, https:443, etc.)
10//! - Empty path to "/" for hierarchical URIs
11//!
12//! # RFC 3987 Compliance
13//!
14//! Implements the normalization algorithm from RFC 3987 Section 5.3 with
15//! additional enhancements from RFC 3986.
16//!
17//! # Example
18//!
19//! ```
20//! use oxirs_ttl::toolkit::iri_normalizer::{normalize_iri, NormalizedIri};
21//!
22//! // Case normalization
23//! let iri = normalize_iri("HTTP://EXAMPLE.ORG/Path").expect("should succeed");
24//! assert_eq!(iri.as_str(), "http://example.org/Path");
25//!
26//! // Percent-encoding normalization
27//! let iri = normalize_iri("http://example.org/%7Euser").expect("should succeed");
28//! assert_eq!(iri.as_str(), "http://example.org/~user");
29//!
30//! // Default port removal
31//! let iri = normalize_iri("http://example.org:80/path").expect("should succeed");
32//! assert_eq!(iri.as_str(), "http://example.org/path");
33//!
34//! // Path normalization
35//! let iri = normalize_iri("http://example.org/a/./b/../c").expect("should succeed");
36//! assert_eq!(iri.as_str(), "http://example.org/a/c");
37//! ```
38
39use std::borrow::Cow;
40use std::collections::HashMap;
41use std::fmt;
42
43/// A normalized IRI in canonical form
44///
45/// This type represents an IRI that has been normalized according to RFC 3987.
46/// Normalized IRIs can be efficiently compared for equivalence.
47#[derive(Debug, Clone, PartialEq, Eq, Hash)]
48pub struct NormalizedIri {
49    /// The normalized IRI string
50    iri: String,
51}
52
53impl NormalizedIri {
54    /// Create a normalized IRI from a string
55    ///
56    /// This constructor assumes the IRI is already normalized.
57    /// Use `normalize_iri()` to normalize an arbitrary IRI.
58    pub fn new_unchecked(iri: String) -> Self {
59        Self { iri }
60    }
61
62    /// Get the normalized IRI as a string slice
63    pub fn as_str(&self) -> &str {
64        &self.iri
65    }
66
67    /// Convert to owned String
68    pub fn into_string(self) -> String {
69        self.iri
70    }
71
72    /// Check if two IRIs are equivalent (same as PartialEq but explicit)
73    pub fn is_equivalent(&self, other: &Self) -> bool {
74        self.iri == other.iri
75    }
76}
77
78impl fmt::Display for NormalizedIri {
79    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
80        write!(f, "{}", self.iri)
81    }
82}
83
84impl AsRef<str> for NormalizedIri {
85    fn as_ref(&self) -> &str {
86        &self.iri
87    }
88}
89
90/// Error types for IRI normalization
91#[derive(Debug, Clone, PartialEq, Eq)]
92pub enum NormalizationError {
93    /// Invalid IRI format
94    InvalidFormat(String),
95    /// Invalid percent encoding
96    InvalidPercentEncoding(String),
97    /// Missing scheme
98    MissingScheme,
99    /// Invalid scheme
100    InvalidScheme(String),
101}
102
103impl fmt::Display for NormalizationError {
104    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
105        match self {
106            Self::InvalidFormat(msg) => write!(f, "Invalid IRI format: {}", msg),
107            Self::InvalidPercentEncoding(seq) => {
108                write!(f, "Invalid percent encoding: {}", seq)
109            }
110            Self::MissingScheme => write!(f, "IRI must have a scheme"),
111            Self::InvalidScheme(s) => write!(f, "Invalid scheme: {}", s),
112        }
113    }
114}
115
116impl std::error::Error for NormalizationError {}
117
118/// Result type for normalization operations
119pub type NormalizationResult<T> = Result<T, NormalizationError>;
120
121/// Normalize an IRI to canonical form
122///
123/// This function applies all normalization steps defined in RFC 3987:
124/// - Case normalization (scheme and host to lowercase)
125/// - Percent-encoding normalization
126/// - Path normalization
127/// - Default port removal
128///
129/// # Example
130///
131/// ```
132/// use oxirs_ttl::toolkit::iri_normalizer::normalize_iri;
133///
134/// let iri = normalize_iri("HTTP://EXAMPLE.ORG:80/A/./B/../C").expect("should succeed");
135/// assert_eq!(iri.as_str(), "http://example.org/A/C");
136/// ```
137pub fn normalize_iri(iri: &str) -> NormalizationResult<NormalizedIri> {
138    if iri.is_empty() {
139        return Err(NormalizationError::InvalidFormat(
140            "IRI cannot be empty".to_string(),
141        ));
142    }
143
144    // Parse components
145    let components = parse_iri_components(iri)?;
146
147    // Apply normalization
148    let normalized = normalize_components(&components)?;
149
150    Ok(NormalizedIri::new_unchecked(normalized))
151}
152
153/// IRI components for normalization
154#[derive(Debug, Clone)]
155struct IriComponents {
156    scheme: String,
157    authority: Option<Authority>,
158    path: String,
159    query: Option<String>,
160    fragment: Option<String>,
161}
162
163/// Authority component (userinfo@host:port)
164#[derive(Debug, Clone)]
165struct Authority {
166    userinfo: Option<String>,
167    host: String,
168    port: Option<u16>,
169}
170
171/// Parse IRI into components
172fn parse_iri_components(iri: &str) -> NormalizationResult<IriComponents> {
173    // Extract scheme
174    let colon_pos = iri.find(':').ok_or(NormalizationError::MissingScheme)?;
175    let scheme = iri[..colon_pos].to_string();
176
177    if scheme.is_empty() || !is_valid_scheme(&scheme) {
178        return Err(NormalizationError::InvalidScheme(scheme));
179    }
180
181    let rest = &iri[colon_pos + 1..];
182
183    // Check for authority (starts with //)
184    let (authority, path_query_fragment) = if let Some(after_slashes) = rest.strip_prefix("//") {
185        let auth_end = after_slashes
186            .find(['/', '?', '#'])
187            .unwrap_or(after_slashes.len());
188        let authority_str = &after_slashes[..auth_end];
189        let authority = parse_authority(authority_str)?;
190        (Some(authority), &after_slashes[auth_end..])
191    } else {
192        (None, rest)
193    };
194
195    // Parse path, query, and fragment
196    let (path, query_fragment) = if let Some(q_pos) = path_query_fragment.find('?') {
197        (
198            path_query_fragment[..q_pos].to_string(),
199            &path_query_fragment[q_pos + 1..],
200        )
201    } else if let Some(f_pos) = path_query_fragment.find('#') {
202        (
203            path_query_fragment[..f_pos].to_string(),
204            &path_query_fragment[f_pos..],
205        )
206    } else {
207        (path_query_fragment.to_string(), "")
208    };
209
210    let (query, fragment) = if !query_fragment.is_empty() {
211        if let Some(f_pos) = query_fragment.find('#') {
212            (
213                Some(query_fragment[..f_pos].to_string()),
214                Some(query_fragment[f_pos + 1..].to_string()),
215            )
216        } else {
217            (Some(query_fragment.to_string()), None)
218        }
219    } else {
220        (None, None)
221    };
222
223    Ok(IriComponents {
224        scheme,
225        authority,
226        path,
227        query,
228        fragment,
229    })
230}
231
232/// Parse authority component
233fn parse_authority(authority: &str) -> NormalizationResult<Authority> {
234    if authority.is_empty() {
235        return Ok(Authority {
236            userinfo: None,
237            host: String::new(),
238            port: None,
239        });
240    }
241
242    // Split userinfo@host:port
243    let (userinfo, host_port) = if let Some(at_pos) = authority.rfind('@') {
244        (
245            Some(authority[..at_pos].to_string()),
246            &authority[at_pos + 1..],
247        )
248    } else {
249        (None, authority)
250    };
251
252    // Parse host and port
253    let (host, port) = parse_host_port(host_port)?;
254
255    Ok(Authority {
256        userinfo,
257        host,
258        port,
259    })
260}
261
262/// Parse host:port
263fn parse_host_port(host_port: &str) -> NormalizationResult<(String, Option<u16>)> {
264    // IPv6 address
265    if let Some(bracket_start) = host_port.find('[') {
266        let bracket_end = host_port.find(']').ok_or_else(|| {
267            NormalizationError::InvalidFormat("Unclosed IPv6 bracket".to_string())
268        })?;
269        let host = host_port[bracket_start..=bracket_end].to_string();
270        let rest = &host_port[bracket_end + 1..];
271        let port = if let Some(port_str) = rest.strip_prefix(':') {
272            Some(port_str.parse::<u16>().map_err(|_| {
273                NormalizationError::InvalidFormat(format!("Invalid port: {}", port_str))
274            })?)
275        } else if rest.is_empty() {
276            None
277        } else {
278            return Err(NormalizationError::InvalidFormat(
279                "Invalid characters after IPv6 address".to_string(),
280            ));
281        };
282        return Ok((host, port));
283    }
284
285    // Regular host:port
286    if let Some(colon_pos) = host_port.rfind(':') {
287        let potential_port = &host_port[colon_pos + 1..];
288        // Only treat as port if it's all digits
289        if potential_port.chars().all(|c| c.is_ascii_digit()) {
290            let port = potential_port.parse::<u16>().map_err(|_| {
291                NormalizationError::InvalidFormat(format!("Invalid port: {}", potential_port))
292            })?;
293            Ok((host_port[..colon_pos].to_string(), Some(port)))
294        } else {
295            Ok((host_port.to_string(), None))
296        }
297    } else {
298        Ok((host_port.to_string(), None))
299    }
300}
301
302/// Normalize IRI components
303fn normalize_components(components: &IriComponents) -> NormalizationResult<String> {
304    // 1. Case normalization: scheme to lowercase
305    let scheme = components.scheme.to_lowercase();
306
307    // 2. Authority normalization
308    let authority_str = if let Some(ref auth) = components.authority {
309        let mut parts = Vec::new();
310
311        // Userinfo (if present)
312        if let Some(ref userinfo) = auth.userinfo {
313            let normalized_userinfo = normalize_percent_encoding(userinfo)?;
314            parts.push(format!("{}@", normalized_userinfo));
315        }
316
317        // Host: lowercase for reg-name (not IPv6)
318        let normalized_host = if auth.host.starts_with('[') {
319            // IPv6: keep as-is (case-insensitive hex is normalized to lowercase)
320            auth.host.to_lowercase()
321        } else {
322            // Reg-name: lowercase + percent-encoding normalization
323            normalize_percent_encoding(&auth.host.to_lowercase())?
324        };
325        parts.push(normalized_host);
326
327        // Port: remove default ports
328        if let Some(port) = auth.port {
329            if !is_default_port(&scheme, port) {
330                parts.push(format!(":{}", port));
331            }
332        }
333
334        format!("//{}", parts.concat())
335    } else {
336        String::new()
337    };
338
339    // 3. Path normalization
340    let normalized_path = normalize_path(&components.path, components.authority.is_some())?;
341
342    // 4. Query normalization
343    let query_str = if let Some(ref query) = components.query {
344        format!("?{}", normalize_percent_encoding(query)?)
345    } else {
346        String::new()
347    };
348
349    // 5. Fragment normalization
350    let fragment_str = if let Some(ref fragment) = components.fragment {
351        format!("#{}", normalize_percent_encoding(fragment)?)
352    } else {
353        String::new()
354    };
355
356    // Reconstruct normalized IRI
357    Ok(format!(
358        "{}:{}{}{}{}",
359        scheme, authority_str, normalized_path, query_str, fragment_str
360    ))
361}
362
363/// Normalize percent-encoding (decode unreserved characters)
364///
365/// RFC 3986: Unreserved characters are A-Z, a-z, 0-9, -, ., _, ~
366/// These should not be percent-encoded.
367fn normalize_percent_encoding(s: &str) -> NormalizationResult<String> {
368    let mut result = String::with_capacity(s.len());
369    let mut chars = s.chars().peekable();
370
371    while let Some(ch) = chars.next() {
372        if ch == '%' {
373            // Read next two characters
374            let hex1 = chars
375                .next()
376                .ok_or_else(|| NormalizationError::InvalidPercentEncoding(format!("%{}", s)))?;
377            let hex2 = chars.next().ok_or_else(|| {
378                NormalizationError::InvalidPercentEncoding(format!("%{}{}", hex1, s))
379            })?;
380
381            let hex_str = format!("{}{}", hex1, hex2);
382            let byte = u8::from_str_radix(&hex_str, 16)
383                .map_err(|_| NormalizationError::InvalidPercentEncoding(format!("%{}", hex_str)))?;
384
385            // Check if it's an unreserved character
386            let decoded = byte as char;
387            if is_unreserved(decoded) {
388                // Decode unreserved characters
389                result.push(decoded);
390            } else {
391                // Keep percent-encoded (normalize to uppercase)
392                result.push_str(&format!("%{}", hex_str.to_uppercase()));
393            }
394        } else {
395            result.push(ch);
396        }
397    }
398
399    Ok(result)
400}
401
402/// Check if a character is unreserved (RFC 3986)
403fn is_unreserved(ch: char) -> bool {
404    ch.is_ascii_alphanumeric() || ch == '-' || ch == '.' || ch == '_' || ch == '~'
405}
406
407/// Normalize path component
408fn normalize_path(path: &str, has_authority: bool) -> NormalizationResult<String> {
409    // Empty path for hierarchical URIs should become "/"
410    if path.is_empty() && has_authority {
411        return Ok("/".to_string());
412    }
413
414    // Remove dot segments
415    let normalized = remove_dot_segments(path);
416
417    // Percent-encoding normalization
418    normalize_percent_encoding(&normalized)
419}
420
421/// Remove dot segments from path (RFC 3986 Section 5.2.4)
422fn remove_dot_segments(path: &str) -> String {
423    let mut output = Vec::new();
424    let segments: Vec<&str> = path.split('/').collect();
425    let has_trailing_slash = path.ends_with('/') && path.len() > 1;
426
427    for (i, segment) in segments.iter().enumerate() {
428        match *segment {
429            "" => {
430                // Skip empty segments except at the beginning
431                if i == 0 {
432                    // Keep leading slash
433                }
434            }
435            "." => {
436                // Skip current directory
437            }
438            ".." => {
439                // Go up one level (pop last segment)
440                output.pop();
441            }
442            _ => {
443                // Regular segment
444                output.push(*segment);
445            }
446        }
447    }
448
449    // Reconstruct path
450    if path.starts_with('/') {
451        if output.is_empty() {
452            "/".to_string()
453        } else {
454            let base_path = format!("/{}", output.join("/"));
455            if has_trailing_slash {
456                format!("{}/", base_path)
457            } else {
458                base_path
459            }
460        }
461    } else if output.is_empty() {
462        String::new()
463    } else {
464        let base_path = output.join("/");
465        if has_trailing_slash {
466            format!("{}/", base_path)
467        } else {
468            base_path
469        }
470    }
471}
472
473/// Check if port is the default for the scheme
474fn is_default_port(scheme: &str, port: u16) -> bool {
475    get_default_port(scheme) == Some(port)
476}
477
478/// Get default port for a scheme
479fn get_default_port(scheme: &str) -> Option<u16> {
480    DEFAULT_PORTS.get(scheme).copied()
481}
482
483// Default ports for common schemes
484static DEFAULT_PORTS: once_cell::sync::Lazy<HashMap<&'static str, u16>> =
485    once_cell::sync::Lazy::new(|| {
486        let mut m = HashMap::new();
487        m.insert("http", 80);
488        m.insert("https", 443);
489        m.insert("ftp", 21);
490        m.insert("ftps", 990);
491        m.insert("ssh", 22);
492        m.insert("telnet", 23);
493        m.insert("smtp", 25);
494        m.insert("pop3", 110);
495        m.insert("imap", 143);
496        m.insert("ldap", 389);
497        m.insert("ldaps", 636);
498        m.insert("ws", 80);
499        m.insert("wss", 443);
500        m
501    });
502
503/// Check if a string is a valid scheme
504fn is_valid_scheme(scheme: &str) -> bool {
505    if scheme.is_empty() {
506        return false;
507    }
508    let mut chars = scheme.chars();
509
510    // First character must be ASCII letter
511    let first = chars.next().expect("iterator should have next element");
512    if !first.is_ascii_alphabetic() {
513        return false;
514    }
515
516    // Rest can be ASCII letter, digit, +, -, .
517    chars.all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '-' || c == '.')
518}
519
520/// Generic components of an IRI reference (RFC 3986 §3), where every component but
521/// `path` may be absent for a relative reference.
522struct ReferenceComponents {
523    scheme: Option<String>,
524    authority: Option<String>,
525    path: String,
526    query: Option<String>,
527    fragment: Option<String>,
528}
529
530/// Split an IRI reference into its generic components without requiring a scheme,
531/// unlike [`parse_iri_components`] which is only valid for absolute IRIs.
532fn split_generic_reference(reference: &str) -> ReferenceComponents {
533    let (before_fragment, fragment) = match reference.find('#') {
534        Some(i) => (&reference[..i], Some(reference[i + 1..].to_string())),
535        None => (reference, None),
536    };
537
538    let (before_query, query) = match before_fragment.find('?') {
539        Some(i) => (
540            &before_fragment[..i],
541            Some(before_fragment[i + 1..].to_string()),
542        ),
543        None => (before_fragment, None),
544    };
545
546    // A scheme, if present, is always the component preceding the first ':' and must
547    // consist solely of valid scheme characters (this also naturally rules out a ':'
548    // appearing later in a path segment, matching the RFC 3986 §3.3 rule that a
549    // relative-path reference's first segment cannot contain a colon).
550    let scheme_end = before_query.find(':').filter(|&i| {
551        let candidate = &before_query[..i];
552        !candidate.is_empty()
553            && candidate
554                .chars()
555                .next()
556                .is_some_and(|c| c.is_ascii_alphabetic())
557            && candidate
558                .chars()
559                .all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))
560    });
561
562    let (scheme, rest) = match scheme_end {
563        Some(i) => (Some(before_query[..i].to_string()), &before_query[i + 1..]),
564        None => (None, before_query),
565    };
566
567    let (authority, path) = if let Some(after_slashes) = rest.strip_prefix("//") {
568        let end = after_slashes.find('/').unwrap_or(after_slashes.len());
569        (
570            Some(after_slashes[..end].to_string()),
571            after_slashes[end..].to_string(),
572        )
573    } else {
574        (None, rest.to_string())
575    };
576
577    ReferenceComponents {
578        scheme,
579        authority,
580        path,
581        query,
582        fragment,
583    }
584}
585
586/// Recompose generic reference components back into an IRI string (RFC 3986 §5.3).
587fn recompose(components: &ReferenceComponents) -> String {
588    let mut result = String::new();
589    if let Some(scheme) = &components.scheme {
590        result.push_str(scheme);
591        result.push(':');
592    }
593    if let Some(authority) = &components.authority {
594        result.push_str("//");
595        result.push_str(authority);
596    }
597    result.push_str(&components.path);
598    if let Some(query) = &components.query {
599        result.push('?');
600        result.push_str(query);
601    }
602    if let Some(fragment) = &components.fragment {
603        result.push('#');
604        result.push_str(fragment);
605    }
606    result
607}
608
609/// Merge a base path with a relative-path reference (RFC 3986 §5.3, "merge").
610fn merge_paths(base_has_authority: bool, base_path: &str, ref_path: &str) -> String {
611    if base_has_authority && base_path.is_empty() {
612        format!("/{ref_path}")
613    } else {
614        match base_path.rfind('/') {
615            Some(i) => format!("{}{}", &base_path[..=i], ref_path),
616            None => ref_path.to_string(),
617        }
618    }
619}
620
621/// Remove `.` and `..` dot-segments from a path per the precise algorithm in
622/// RFC 3986 §5.2.4.
623///
624/// This differs from the internal `remove_dot_segments` used by [`normalize_iri`] in
625/// that it follows the RFC's step-by-step buffer algorithm exactly (including
626/// preserving a trailing slash produced by a trailing `.`/`..` segment), which matters
627/// for correct reference resolution.
628fn remove_dot_segments_rfc3986(path: &str) -> String {
629    fn remove_last_output_segment(output: &mut String) {
630        match output.rfind('/') {
631            Some(pos) => output.truncate(pos),
632            None => output.clear(),
633        }
634    }
635
636    let mut input = path;
637    let mut output = String::new();
638
639    while !input.is_empty() {
640        if let Some(rest) = input.strip_prefix("../") {
641            input = rest;
642        } else if let Some(rest) = input.strip_prefix("./") {
643            input = rest;
644        } else if input.starts_with("/./") {
645            // Replace the "/./ " prefix with "/", keeping the leading slash.
646            input = &input[2..];
647        } else if input == "/." {
648            input = "/";
649        } else if input.starts_with("/../") {
650            // Replace the "/../ " prefix with "/" and drop the last output segment.
651            input = &input[3..];
652            remove_last_output_segment(&mut output);
653        } else if input == "/.." {
654            input = "/";
655            remove_last_output_segment(&mut output);
656        } else if input == "." || input == ".." {
657            input = "";
658        } else {
659            // Move the first path segment (including a leading '/' if present) to
660            // the output buffer.
661            let start = usize::from(input.starts_with('/'));
662            let end = input[start..]
663                .find('/')
664                .map(|i| i + start)
665                .unwrap_or(input.len());
666            output.push_str(&input[..end]);
667            input = &input[end..];
668        }
669    }
670
671    output
672}
673
674/// Resolve a (possibly relative) IRI reference against a base IRI, following the
675/// reference resolution algorithm defined in RFC 3986 §5.2 / §5.3 (also applicable to
676/// IRIs per RFC 3987). Handles absolute references, network-path references
677/// (`//host/path`), absolute-path references (`/path`), relative-path references, and
678/// same-document references (differing only in query/fragment), including dot-segment
679/// removal.
680///
681/// # Example
682///
683/// ```
684/// use oxirs_ttl::toolkit::iri_normalizer::resolve_reference;
685///
686/// assert_eq!(
687///     resolve_reference("http://example.org/data", "foo"),
688///     "http://example.org/foo"
689/// );
690/// assert_eq!(
691///     resolve_reference("http://example.org/a/b/c", "/x/y"),
692///     "http://example.org/x/y"
693/// );
694/// assert_eq!(
695///     resolve_reference("http://example.org/a/b/c", "../d"),
696///     "http://example.org/a/d"
697/// );
698/// assert_eq!(
699///     resolve_reference("http://example.org/a/b#frag1", "#frag2"),
700///     "http://example.org/a/b#frag2"
701/// );
702/// ```
703pub fn resolve_reference(base: &str, reference: &str) -> String {
704    let ref_components = split_generic_reference(reference);
705
706    if ref_components.scheme.is_some() {
707        return recompose(&ReferenceComponents {
708            scheme: ref_components.scheme,
709            authority: ref_components.authority,
710            path: remove_dot_segments_rfc3986(&ref_components.path),
711            query: ref_components.query,
712            fragment: ref_components.fragment,
713        });
714    }
715
716    let base_components = split_generic_reference(base);
717
718    let (t_authority, t_path, t_query) = if ref_components.authority.is_some() {
719        (
720            ref_components.authority,
721            remove_dot_segments_rfc3986(&ref_components.path),
722            ref_components.query,
723        )
724    } else if ref_components.path.is_empty() {
725        (
726            base_components.authority,
727            base_components.path,
728            ref_components.query.or(base_components.query),
729        )
730    } else if ref_components.path.starts_with('/') {
731        (
732            base_components.authority,
733            remove_dot_segments_rfc3986(&ref_components.path),
734            ref_components.query,
735        )
736    } else {
737        let merged = merge_paths(
738            base_components.authority.is_some(),
739            &base_components.path,
740            &ref_components.path,
741        );
742        (
743            base_components.authority,
744            remove_dot_segments_rfc3986(&merged),
745            ref_components.query,
746        )
747    };
748
749    recompose(&ReferenceComponents {
750        scheme: base_components.scheme,
751        authority: t_authority,
752        path: t_path,
753        query: t_query,
754        fragment: ref_components.fragment,
755    })
756}
757
758/// Compare two IRIs for equivalence
759///
760/// This function normalizes both IRIs and compares them.
761/// Use this for IRI comparison instead of string equality.
762///
763/// # Example
764///
765/// ```
766/// use oxirs_ttl::toolkit::iri_normalizer::iris_equivalent;
767///
768/// assert!(iris_equivalent(
769///     "HTTP://EXAMPLE.ORG/path",
770///     "http://example.org/path"
771/// ).expect("should succeed"));
772///
773/// assert!(iris_equivalent(
774///     "http://example.org:80/path",
775///     "http://example.org/path"
776/// ).expect("should succeed"));
777///
778/// assert!(!iris_equivalent(
779///     "http://example.org/path1",
780///     "http://example.org/path2"
781/// ).expect("should succeed"));
782/// ```
783pub fn iris_equivalent(iri1: &str, iri2: &str) -> NormalizationResult<bool> {
784    let normalized1 = normalize_iri(iri1)?;
785    let normalized2 = normalize_iri(iri2)?;
786    Ok(normalized1.is_equivalent(&normalized2))
787}
788
789/// Normalize an IRI and return as a Cow (avoids allocation if already normalized)
790///
791/// This is more efficient than `normalize_iri()` when the IRI is likely already normalized.
792pub fn normalize_iri_cow(iri: &str) -> NormalizationResult<Cow<'_, str>> {
793    let normalized = normalize_iri(iri)?;
794    if normalized.as_str() == iri {
795        Ok(Cow::Borrowed(iri))
796    } else {
797        Ok(Cow::Owned(normalized.into_string()))
798    }
799}
800
801#[cfg(test)]
802mod tests {
803    use super::*;
804
805    #[test]
806    fn test_case_normalization() {
807        let iri = normalize_iri("HTTP://EXAMPLE.ORG/Path").expect("valid IRI");
808        assert_eq!(iri.as_str(), "http://example.org/Path");
809    }
810
811    #[test]
812    fn test_percent_encoding_normalization() {
813        // Decode unreserved characters
814        let iri = normalize_iri("http://example.org/%7Euser").expect("valid IRI");
815        assert_eq!(iri.as_str(), "http://example.org/~user");
816
817        let iri = normalize_iri("http://example.org/%41%42%43").expect("valid IRI");
818        assert_eq!(iri.as_str(), "http://example.org/ABC");
819
820        // Keep reserved characters encoded (but uppercase)
821        let iri = normalize_iri("http://example.org/path%20with%20spaces").expect("valid IRI");
822        assert_eq!(iri.as_str(), "http://example.org/path%20with%20spaces");
823    }
824
825    #[test]
826    fn test_default_port_removal() {
827        let iri = normalize_iri("http://example.org:80/path").expect("valid IRI");
828        assert_eq!(iri.as_str(), "http://example.org/path");
829
830        let iri = normalize_iri("https://example.org:443/path").expect("valid IRI");
831        assert_eq!(iri.as_str(), "https://example.org/path");
832
833        // Non-default port should be kept
834        let iri = normalize_iri("http://example.org:8080/path").expect("valid IRI");
835        assert_eq!(iri.as_str(), "http://example.org:8080/path");
836    }
837
838    #[test]
839    fn test_path_normalization() {
840        let iri = normalize_iri("http://example.org/a/./b/../c").expect("valid IRI");
841        assert_eq!(iri.as_str(), "http://example.org/a/c");
842
843        let iri = normalize_iri("http://example.org/./a/b").expect("valid IRI");
844        assert_eq!(iri.as_str(), "http://example.org/a/b");
845
846        let iri = normalize_iri("http://example.org/a/b/..").expect("valid IRI");
847        assert_eq!(iri.as_str(), "http://example.org/a");
848    }
849
850    #[test]
851    fn test_empty_path_normalization() {
852        let iri = normalize_iri("http://example.org").expect("valid IRI");
853        assert_eq!(iri.as_str(), "http://example.org/");
854    }
855
856    #[test]
857    fn test_query_and_fragment() {
858        let iri = normalize_iri("http://example.org/path?query=value#fragment").expect("valid IRI");
859        assert_eq!(iri.as_str(), "http://example.org/path?query=value#fragment");
860
861        // Percent-encoding in query and fragment
862        let iri = normalize_iri("http://example.org/path?q=%41#%42").expect("valid IRI");
863        assert_eq!(iri.as_str(), "http://example.org/path?q=A#B");
864    }
865
866    #[test]
867    fn test_ipv6_address() {
868        let iri = normalize_iri("http://[2001:db8::1]/path").expect("valid IRI");
869        assert_eq!(iri.as_str(), "http://[2001:db8::1]/path");
870
871        let iri = normalize_iri("http://[2001:DB8::1]:8080/path").expect("valid IRI");
872        assert_eq!(iri.as_str(), "http://[2001:db8::1]:8080/path");
873    }
874
875    #[test]
876    fn test_userinfo() {
877        let iri = normalize_iri("http://user:pass@example.org/path").expect("valid IRI");
878        assert_eq!(iri.as_str(), "http://user:pass@example.org/path");
879
880        let iri = normalize_iri("http://%41%42%43@example.org/path").expect("valid IRI");
881        assert_eq!(iri.as_str(), "http://ABC@example.org/path");
882    }
883
884    #[test]
885    fn test_iris_equivalent() {
886        assert!(
887            iris_equivalent("HTTP://EXAMPLE.ORG/path", "http://example.org/path")
888                .expect("valid IRI")
889        );
890
891        assert!(
892            iris_equivalent("http://example.org:80/path", "http://example.org/path")
893                .expect("valid IRI")
894        );
895
896        assert!(
897            iris_equivalent("http://example.org/a/./b/../c", "http://example.org/a/c")
898                .expect("valid IRI")
899        );
900
901        assert!(
902            !iris_equivalent("http://example.org/path1", "http://example.org/path2")
903                .expect("valid IRI")
904        );
905    }
906
907    #[test]
908    fn test_complex_normalization() {
909        let iri = normalize_iri("HTTP://USER@EXAMPLE.ORG:80/A/./B/../C/%7Euser?Q=%41#%42")
910            .expect("valid IRI");
911        assert_eq!(iri.as_str(), "http://USER@example.org/A/C/~user?Q=A#B");
912    }
913
914    #[test]
915    fn test_non_http_schemes() {
916        let iri = normalize_iri("ftp://example.org:21/path").expect("valid IRI");
917        assert_eq!(iri.as_str(), "ftp://example.org/path");
918
919        let iri = normalize_iri("urn:isbn:0451450523").expect("valid IRI");
920        assert_eq!(iri.as_str(), "urn:isbn:0451450523");
921    }
922
923    #[test]
924    fn test_invalid_iri() {
925        assert!(normalize_iri("").is_err());
926        assert!(normalize_iri("not an iri").is_err());
927        assert!(normalize_iri("http://example.org/%ZZ").is_err());
928    }
929
930    #[test]
931    fn test_normalized_iri_methods() {
932        let iri1 = normalize_iri("http://example.org/path").expect("valid IRI");
933        let iri2 = normalize_iri("HTTP://EXAMPLE.ORG/path").expect("valid IRI");
934
935        assert_eq!(iri1.as_str(), "http://example.org/path");
936        assert!(iri1.is_equivalent(&iri2));
937        assert_eq!(iri1, iri2);
938
939        let cloned = iri1.clone();
940        assert_eq!(iri1, cloned);
941    }
942
943    #[test]
944    fn test_normalize_iri_cow() {
945        // Already normalized - should return Borrowed
946        let iri = "http://example.org/path";
947        let result = normalize_iri_cow(iri).expect("valid IRI");
948        assert!(matches!(result, Cow::Borrowed(_)));
949        assert_eq!(result, iri);
950
951        // Not normalized - should return Owned
952        let iri = "HTTP://EXAMPLE.ORG/path";
953        let result = normalize_iri_cow(iri).expect("valid IRI");
954        assert!(matches!(result, Cow::Owned(_)));
955        assert_eq!(result, "http://example.org/path");
956    }
957
958    #[test]
959    fn test_urn_normalization() {
960        // URN scheme normalization
961        let iri = normalize_iri("URN:ISBN:0451450523").expect("valid IRI");
962        assert_eq!(iri.as_str(), "urn:ISBN:0451450523");
963    }
964
965    #[test]
966    fn test_resolve_reference_relative_path_no_trailing_slash_in_base() {
967        // Base without a trailing slash: the last path segment must be dropped, not
968        // treated as a directory (regression for naive `format!("{base}{iri}")`).
969        assert_eq!(
970            resolve_reference("http://example.org/data", "foo"),
971            "http://example.org/foo"
972        );
973    }
974
975    #[test]
976    fn test_resolve_reference_absolute_path() {
977        assert_eq!(
978            resolve_reference("http://example.org/a/b/c", "/x/y"),
979            "http://example.org/x/y"
980        );
981    }
982
983    #[test]
984    fn test_resolve_reference_dot_segments() {
985        assert_eq!(
986            resolve_reference("http://example.org/a/b/c", "../d"),
987            "http://example.org/a/d"
988        );
989        assert_eq!(
990            resolve_reference("http://a/b/c/d;p?q", "./g"),
991            "http://a/b/c/g"
992        );
993        assert_eq!(
994            resolve_reference("http://a/b/c/d;p?q", "../../../g"),
995            "http://a/g"
996        );
997    }
998
999    #[test]
1000    fn test_resolve_reference_fragment_only() {
1001        assert_eq!(
1002            resolve_reference("http://example.org/a/b#frag1", "#frag2"),
1003            "http://example.org/a/b#frag2"
1004        );
1005    }
1006
1007    #[test]
1008    fn test_resolve_reference_absolute_reference_unchanged() {
1009        assert_eq!(
1010            resolve_reference("http://example.org/a/", "http://other.org/z"),
1011            "http://other.org/z"
1012        );
1013    }
1014
1015    #[test]
1016    fn test_resolve_reference_network_path() {
1017        assert_eq!(
1018            resolve_reference("http://example.org/a/b", "//other.org/z"),
1019            "http://other.org/z"
1020        );
1021    }
1022
1023    #[test]
1024    fn test_resolve_reference_query_only() {
1025        assert_eq!(
1026            resolve_reference("http://example.org/a/b?x=1", "?y=2"),
1027            "http://example.org/a/b?y=2"
1028        );
1029    }
1030
1031    #[test]
1032    fn test_resolve_reference_empty_reference_same_document() {
1033        assert_eq!(
1034            resolve_reference("http://example.org/a/b?x=1#f", ""),
1035            "http://example.org/a/b?x=1"
1036        );
1037    }
1038
1039    #[test]
1040    fn test_trailing_slash() {
1041        let iri1 = normalize_iri("http://example.org/path/").expect("valid IRI");
1042        let iri2 = normalize_iri("http://example.org/path").expect("valid IRI");
1043
1044        // These should NOT be equivalent (trailing slash matters)
1045        assert_ne!(iri1, iri2);
1046        assert_eq!(iri1.as_str(), "http://example.org/path/");
1047        assert_eq!(iri2.as_str(), "http://example.org/path");
1048    }
1049}