sugar_path 3.0.0

Host-native lexical path manipulation with borrowed and owned-buffer APIs
Documentation
#![cfg(any(unix, windows))]

use std::{borrow::Cow, path::Path};

use sugar_path::SugarPath;

#[cfg(unix)]
mod unix {
  use std::{
    ffi::OsString,
    os::unix::ffi::{OsStrExt, OsStringExt},
    path::PathBuf,
  };

  use super::*;

  fn path(bytes: &[u8]) -> PathBuf {
    PathBuf::from(OsString::from_vec(bytes.to_vec()))
  }

  fn assert_bytes(actual: &Path, expected: &[u8]) {
    assert_eq!(actual.as_os_str().as_bytes(), expected);
  }

  fn assert_normalizes_exactly_and_is_idempotent(input: &[u8], expected: &[u8]) {
    let once = path(input).normalize().into_owned();
    assert_bytes(&once, expected);

    let twice = once.normalize().into_owned();
    assert_bytes(&twice, expected);
  }

  #[test]
  fn normalize_preserves_invalid_bytes() {
    assert_normalizes_exactly_and_is_idempotent(b"clean-\x80-name", b"clean-\x80-name");
    assert_normalizes_exactly_and_is_idempotent(
      b"./segment-\x80/./child/../tail/",
      b"segment-\x80/tail/",
    );
  }

  #[test]
  fn relative_fallback_preserves_an_invalid_normal_component() {
    let target = path(b"/base/segment-\x80/file");
    let base = path(b"/base");
    let relative = target.relative(&base);

    assert_bytes(&relative, b"segment-\x80/file");
  }

  #[test]
  fn lexical_relative_fast_path_preserves_an_invalid_normal_component() {
    let target = path(b"../../dist/asset-\x80.js");
    let base = path(b"../../dist/chunks");
    let relative = target.relative(&base);

    assert_bytes(&relative, b"../asset-\x80.js");
  }

  #[test]
  fn lexical_relative_common_prefix_compares_invalid_encoding_exactly() {
    let same_base = path(b"../../dist/segment-\x80/chunks");
    let same_target = path(b"../../dist/segment-\x80/assets");
    assert_bytes(&same_target.relative(&same_base), b"../assets");

    assert_eq!(path(b"segment-\x80").to_string_lossy(), path(b"segment-\x81").to_string_lossy());
    let distinct_base = path(b"../../dist/segment-\x80/chunks");
    let distinct_target = path(b"../../dist/segment-\x81/assets");
    assert_bytes(&distinct_target.relative(&distinct_base), b"../../segment-\x81/assets");
  }

  #[test]
  fn slash_conversion_reports_and_replaces_invalid_encoding() {
    let input = path(b"dir/\x80/file");

    assert!(input.try_to_slash().is_none());
    assert!(std::panic::catch_unwind(|| input.to_slash()).is_err());
    let lossy = input.to_slash_lossy();
    assert_eq!(lossy, "dir/\u{fffd}/file");
    assert!(matches!(&lossy, Cow::Owned(_)));
  }

  #[test]
  fn absolutize_with_preserves_invalid_bytes_in_base_and_input() {
    let base = path(b"/workspace/base-\x81");
    let input = path(b"pkg/input-\x80/./file");
    let absolute = input.absolutize_with(base.as_path());

    assert_bytes(&absolute, b"/workspace/base-\x81/pkg/input-\x80/file");
  }
}

#[cfg(windows)]
mod windows {
  use std::{
    ffi::OsString,
    os::windows::ffi::{OsStrExt, OsStringExt},
    path::PathBuf,
  };

  use super::*;

  const LONE_HIGH_SURROGATE: u16 = 0xd800;

  fn wide_with_invalid(prefix: &str, suffix: &str) -> Vec<u16> {
    wide_with_invalid_unit(prefix, LONE_HIGH_SURROGATE, suffix)
  }

  fn wide_with_invalid_unit(prefix: &str, invalid: u16, suffix: &str) -> Vec<u16> {
    let mut wide: Vec<u16> = prefix.encode_utf16().collect();
    wide.push(invalid);
    wide.extend(suffix.encode_utf16());
    wide
  }

  fn path(wide: &[u16]) -> PathBuf {
    PathBuf::from(OsString::from_wide(wide))
  }

  fn invalid_path(prefix: &str, suffix: &str) -> PathBuf {
    path(&wide_with_invalid(prefix, suffix))
  }

  fn invalid_path_with_unit(prefix: &str, invalid: u16, suffix: &str) -> PathBuf {
    path(&wide_with_invalid_unit(prefix, invalid, suffix))
  }

  fn assert_wide(actual: &Path, expected: &[u16]) {
    assert_eq!(actual.as_os_str().encode_wide().collect::<Vec<_>>(), expected);
  }

  fn assert_relative_cases(cases: &[(&str, &str, &str, &str)]) {
    for &(name, target_prefix, base, expected_prefix) in cases {
      let target = invalid_path(target_prefix, r"\file");
      let relative = target.relative(Path::new(base));
      let actual: Vec<u16> = relative.as_os_str().encode_wide().collect();
      let expected = wide_with_invalid(expected_prefix, r"\file");
      assert_eq!(actual, expected, "{name}");
    }
  }

  fn assert_normalizes_exactly_and_is_idempotent(
    input_prefix: &str,
    input_suffix: &str,
    expected_prefix: &str,
    expected_suffix: &str,
  ) {
    let once = invalid_path(input_prefix, input_suffix).normalize().into_owned();
    let expected = wide_with_invalid(expected_prefix, expected_suffix);
    assert_wide(&once, &expected);

    let twice = once.normalize().into_owned();
    assert_wide(&twice, &expected);
  }

  #[test]
  fn normalize_preserves_invalid_wide_encoding() {
    assert_normalizes_exactly_and_is_idempotent("clean-", "-name", "clean-", "-name");
    assert_normalizes_exactly_and_is_idempotent(
      r".\segment-",
      r"\.\child\..\tail\",
      "segment-",
      r"\tail\",
    );
  }

  #[test]
  fn relative_fallback_preserves_an_invalid_normal_component() {
    assert_relative_cases(&[
      ("disk", r"c:\BASE\segment-", r"C:\base", "segment-"),
      ("verbatim disk", r"\\?\c:\BASE\segment-", r"\\?\C:\base", "segment-"),
      ("UNC", r"\\Server\Share\BASE\segment-", r"\\server\share\base", "segment-"),
      (
        "verbatim UNC",
        r"\\?\UNC\Server\Share\BASE\segment-",
        r"\\?\UNC\server\share\base",
        "segment-",
      ),
      ("device namespace", r"\\.\PIPE\BASE\segment-", r"\\.\pipe\base", "segment-"),
      ("generic verbatim", r"\\?\GLOBALROOT\BASE\segment-", r"\\?\globalroot\base", "segment-"),
    ]);
  }

  #[test]
  fn relative_fallback_keeps_different_windows_namespaces_absolute() {
    assert_relative_cases(&[
      ("different disk", r"c:\base\segment-", r"D:\base", r"c:\base\segment-"),
      ("different verbatim disk", r"\\?\c:\base\segment-", r"\\?\D:\base", r"\\?\c:\base\segment-"),
      (
        "different UNC share",
        r"\\server\share\base\segment-",
        r"\\server\other\base",
        r"\\server\share\base\segment-",
      ),
      (
        "different verbatim UNC server",
        r"\\?\UNC\server\share\base\segment-",
        r"\\?\UNC\other\share\base",
        r"\\?\UNC\server\share\base\segment-",
      ),
      (
        "different device namespace",
        r"\\.\PIPE\base\segment-",
        r"\\.\MAILSLOT\base",
        r"\\.\PIPE\base\segment-",
      ),
      (
        "different generic verbatim namespace",
        r"\\?\GLOBALROOT\base\segment-",
        r"\\?\Volume{abc}\base",
        r"\\?\GLOBALROOT\base\segment-",
      ),
      (
        "ordinary and verbatim disk stay separate",
        r"C:\base\segment-",
        r"\\?\C:\base",
        r"C:\base\segment-",
      ),
      (
        "verbatim and ordinary disk stay separate",
        r"\\?\C:\base\segment-",
        r"C:\base",
        r"\\?\C:\base\segment-",
      ),
      (
        "ordinary and verbatim UNC stay separate",
        r"\\server\share\base\segment-",
        r"\\?\UNC\server\share\base",
        r"\\server\share\base\segment-",
      ),
      (
        "verbatim and ordinary UNC stay separate",
        r"\\?\UNC\server\share\base\segment-",
        r"\\server\share\base",
        r"\\?\UNC\server\share\base\segment-",
      ),
    ]);
  }

  #[test]
  fn lexical_relative_fast_path_preserves_an_invalid_normal_component() {
    let target = invalid_path(r"..\..\dist\asset-", ".js");
    let base = Path::new(r"..\..\dist\chunks");
    let relative = target.relative(base);
    let expected = wide_with_invalid(r"..\asset-", ".js");

    assert_wide(&relative, &expected);
  }

  #[test]
  fn lexical_relative_common_prefix_compares_invalid_encoding_exactly() {
    let same_base = invalid_path(r"..\..\dist\segment-", r"\chunks");
    let same_target = invalid_path(r"..\..\dist\segment-", r"\assets");
    assert_wide(
      &same_target.relative(&same_base),
      &r"..\assets".encode_utf16().collect::<Vec<_>>(),
    );

    const DISTINCT_HIGH_SURROGATE: u16 = 0xd801;
    assert_eq!(
      invalid_path("segment-", "").to_string_lossy(),
      invalid_path_with_unit("segment-", DISTINCT_HIGH_SURROGATE, "").to_string_lossy(),
    );
    let distinct_base = invalid_path(r"..\..\dist\segment-", r"\chunks");
    let distinct_target =
      invalid_path_with_unit(r"..\..\dist\segment-", DISTINCT_HIGH_SURROGATE, r"\assets");
    let expected = wide_with_invalid_unit(r"..\..\segment-", DISTINCT_HIGH_SURROGATE, r"\assets");
    assert_wide(&distinct_target.relative(&distinct_base), &expected);
  }

  #[test]
  fn root_relative_inputs_cancel_the_unknown_drive_without_losing_invalid_encoding() {
    let target = invalid_path(r"\dist\asset-", r"\file");
    let relative = target.relative_with(r"\dist\chunks", "not/absolute");
    let expected = wide_with_invalid(r"..\asset-", r"\file");
    assert_wide(&relative, &expected);
  }

  #[test]
  fn lowercase_unc_marker_stays_in_the_generic_verbatim_namespace() {
    let target = Path::new(r"\\?\unc\server\share\file");
    let base = Path::new(r"\\?\UNC\server\share");
    let relative = target.relative(base);
    let normalized = target.normalize();

    assert!(relative.is_absolute());
    assert_eq!(relative.as_os_str(), normalized.as_os_str());
  }

  #[test]
  fn slash_conversion_reports_and_replaces_invalid_encoding() {
    let input = invalid_path(r"dir\", r"\file");

    assert!(input.try_to_slash().is_none());
    assert!(std::panic::catch_unwind(|| input.to_slash()).is_err());
    let lossy = input.to_slash_lossy();
    assert_eq!(lossy, "dir/\u{fffd}/file");
    assert!(matches!(&lossy, Cow::Owned(_)));
  }

  #[test]
  fn absolutize_with_preserves_invalid_wide_encoding_in_base_and_input() {
    let base = invalid_path(r"C:\workspace\base-", "");
    let input = invalid_path(r"pkg\input-", r"\.\file");
    let absolute = input.absolutize_with(base.as_path());

    let mut expected = wide_with_invalid(r"C:\workspace\base-", r"\pkg\input-");
    expected.push(LONE_HIGH_SURROGATE);
    expected.extend(r"\file".encode_utf16());
    assert_wide(&absolute, &expected);
  }
}