/ src / path.rs
path.rs
  1  //! Path utilities for AT-SPI object references.
  2  
  3  use atspi::proxy::accessible::AccessibleProxy;
  4  
  5  use crate::error::Result;
  6  
  7  const ATSPI_PREFIX: &str = "/org/a11y/atspi/";
  8  
  9  /// Strip the AT-SPI prefix from a D-Bus object path for display.
 10  ///
 11  /// Removes `/org/a11y/atspi/accessible/` prefix and converts "root" to empty string.
 12  pub fn strip_atspi_prefix(path: &str) -> &str {
 13      let mut short = path.strip_prefix(ATSPI_PREFIX).unwrap_or(path);
 14      short = short.strip_prefix("accessible/").unwrap_or(short);
 15      if short == "root" { "" } else { short }
 16  }
 17  
 18  /// Check if an `AccessibleProxy` is at a root level (registry or application root).
 19  /// Root objects have path "/org/a11y/atspi/accessible/root".
 20  fn is_root(proxy: &AccessibleProxy<'_>) -> bool {
 21      proxy.inner().path().as_str() == "/org/a11y/atspi/accessible/root"
 22  }
 23  
 24  /// Normalize a name for use in path references.
 25  /// Returns "unnamed" if the normalized result is empty.
 26  fn normalize_name(name: &str) -> String {
 27      let mut result = String::new();
 28      let mut last_was_special = false;
 29  
 30      for c in name.chars() {
 31          if c.is_alphanumeric() {
 32              result.push(c.to_lowercase().next().unwrap());
 33              last_was_special = false;
 34          } else if !last_was_special {
 35              result.push('-');
 36              last_was_special = true;
 37          }
 38      }
 39  
 40      let normalized = result.trim_matches('-');
 41      if normalized.is_empty() {
 42          "unnamed".to_string()
 43      } else {
 44          normalized.to_string()
 45      }
 46  }
 47  
 48  /// Generate a path reference segment for an accessible object.
 49  /// Format: `<normalized-name>-<id>` where:
 50  /// - name comes from the object's name property (or role if empty)
 51  /// - id comes from bus name (for root objects) or object path component (for children)
 52  pub async fn generate_reference(proxy: &AccessibleProxy<'_>) -> Result<String> {
 53      let name = proxy.name().await.unwrap_or_default();
 54      let base = if name.is_empty() {
 55          proxy.get_role().await?.to_string().to_lowercase()
 56      } else {
 57          name
 58      };
 59  
 60      let normalized = normalize_name(&base);
 61  
 62      let id = if is_root(proxy) {
 63          extract_bus_id(proxy.inner().destination())
 64      } else {
 65          extract_id_from_path(&proxy.inner().path().to_string())
 66      };
 67  
 68      Ok(format!("{normalized}-{id}"))
 69  }
 70  
 71  /// Extract the ID component from a D-Bus object path.
 72  /// For paths like "/org/a11y/atspi/accessible/2/3/4", returns "4" (last component)
 73  fn extract_id_from_path(path: &str) -> String {
 74      path.rsplit('/')
 75          .next()
 76          .filter(|s| !s.is_empty())
 77          .unwrap_or("0")
 78          .to_string()
 79  }
 80  
 81  /// Extract bus address from D-Bus destination (bus name).
 82  /// For ":1.45", returns "1.45"
 83  fn extract_bus_id(destination: &str) -> String {
 84      destination
 85          .strip_prefix(':')
 86          .unwrap_or(destination)
 87          .to_string()
 88  }
 89  
 90  #[cfg(test)]
 91  #[allow(clippy::unnecessary_wraps)]
 92  mod tests {
 93      use super::*;
 94  
 95      #[test]
 96      fn test_strip_atspi_prefix_root() {
 97          // Top-level "root" paths should return empty string
 98          assert_eq!(strip_atspi_prefix("/org/a11y/atspi/accessible/root"), "");
 99      }
100  
101      #[test]
102      fn test_strip_atspi_prefix_non_root() {
103          // Non-root paths like "2" should be preserved
104          assert_eq!(strip_atspi_prefix("/org/a11y/atspi/accessible/2"), "2");
105      }
106  
107      #[test]
108      fn test_strip_atspi_prefix_nested() {
109          assert_eq!(
110              strip_atspi_prefix("/org/a11y/atspi/accessible/foo/bar"),
111              "foo/bar"
112          );
113      }
114  
115      #[test]
116      fn test_strip_atspi_prefix_no_match() {
117          // Paths without the prefix should be unchanged
118          assert_eq!(strip_atspi_prefix("/some/other/path"), "/some/other/path");
119      }
120  
121      #[test]
122      fn test_normalize_name() -> Result<()> {
123          assert_eq!(normalize_name("Firefox"), "firefox");
124          assert_eq!(normalize_name("Firefox Web Browser"), "firefox-web-browser");
125          assert_eq!(normalize_name("OK"), "ok");
126          assert_eq!(normalize_name("__Private__"), "private");
127          assert_eq!(normalize_name("!!!"), "unnamed");
128          assert_eq!(normalize_name("  spaces  "), "spaces");
129          assert_eq!(normalize_name("multi---dash"), "multi-dash");
130          assert_eq!(normalize_name(""), "unnamed");
131          Ok(())
132      }
133  
134      #[test]
135      fn test_extract_id_from_path() -> Result<()> {
136          assert_eq!(extract_id_from_path("/org/a11y/atspi/accessible/2"), "2");
137          assert_eq!(
138              extract_id_from_path("/org/a11y/atspi/accessible/2/3/4"),
139              "4"
140          );
141          assert_eq!(
142              extract_id_from_path("/org/a11y/atspi/accessible/root"),
143              "root"
144          );
145          assert_eq!(extract_id_from_path(""), "0");
146          Ok(())
147      }
148  
149      #[test]
150      fn test_extract_bus_id() -> Result<()> {
151          assert_eq!(extract_bus_id(":1.45"), "1.45");
152          assert_eq!(extract_bus_id(":1.102"), "1.102");
153          assert_eq!(extract_bus_id("1.45"), "1.45");
154          Ok(())
155      }
156  }