libinput-rs 0.3.5

100% drop-in Rust replacement for the libinput 1.31.3 C ABI and tools
Documentation
#![cfg(unix)]

pub use evdev_upstream::*;

use std::path::{Path, PathBuf};

/// Crawls `/dev/input` for event-device paths without opening device nodes.
///
/// Opening remains the caller's responsibility so a compositor can discover
/// nodes first and then use its restricted-open callback through logind.
pub fn enumerate() -> EnumerateDevices {
    enumerate_directory(Path::new("/dev/input"))
}

/// Event-device paths discovered without requiring read access to the nodes.
pub struct EnumerateDevices {
    paths: std::vec::IntoIter<PathBuf>,
}

impl Iterator for EnumerateDevices {
    type Item = (PathBuf, ());

    fn next(&mut self) -> Option<Self::Item> {
        self.paths.next().map(|path| (path, ()))
    }
}

fn enumerate_directory(directory: &Path) -> EnumerateDevices {
    let mut paths = std::fs::read_dir(directory)
        .into_iter()
        .flatten()
        .filter_map(Result::ok)
        .map(|entry| entry.path())
        .filter(|path| is_event_node(path))
        .collect::<Vec<_>>();
    paths.sort();
    EnumerateDevices {
        paths: paths.into_iter(),
    }
}

fn is_event_node(path: &Path) -> bool {
    let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
        return false;
    };
    let Some(index) = name.strip_prefix("event") else {
        return false;
    };
    !index.is_empty() && index.bytes().all(|byte| byte.is_ascii_digit())
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::fs;
    use std::os::unix::fs::symlink;
    use std::time::{SystemTime, UNIX_EPOCH};

    struct TestDirectory(PathBuf);

    impl TestDirectory {
        fn new() -> Self {
            let nonce = SystemTime::now()
                .duration_since(UNIX_EPOCH)
                .expect("system clock precedes the Unix epoch")
                .as_nanos();
            let path = std::env::temp_dir().join(format!(
                "libinput-rs-discovery-{}-{nonce}",
                std::process::id()
            ));
            fs::create_dir(&path).expect("create test input directory");
            Self(path)
        }
    }

    impl Drop for TestDirectory {
        fn drop(&mut self) {
            let _ = fs::remove_dir_all(&self.0);
        }
    }

    #[test]
    fn enumeration_does_not_open_event_nodes() {
        let directory = TestDirectory::new();
        symlink(
            directory.0.join("missing-target"),
            directory.0.join("event0"),
        )
        .expect("create unopenable event node");
        fs::write(directory.0.join("event12"), b"").expect("create event node");
        fs::write(directory.0.join("eventx"), b"").expect("create invalid event name");
        fs::write(directory.0.join("mouse0"), b"").expect("create non-event node");

        let names = enumerate_directory(&directory.0)
            .map(|(path, ())| {
                path.file_name()
                    .expect("event path has a file name")
                    .to_string_lossy()
                    .into_owned()
            })
            .collect::<Vec<_>>();

        assert_eq!(names, vec!["event0".to_string(), "event12".to_string()]);
    }

    #[test]
    fn missing_input_directory_is_empty() {
        let directory = TestDirectory::new();
        let missing = directory.0.join("missing");
        assert_eq!(enumerate_directory(&missing).count(), 0);
    }
}