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 }