rebecca-core 0.3.0

Core planning, safety, scanning, and history models for Rebecca.
Documentation
use std::path::Path;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum PathRelation {
    Same,
    Ancestor,
    Descendant,
    Unrelated,
}

pub(crate) fn paths_overlap(left: &Path, right: &Path) -> bool {
    !matches!(path_relation(left, right), PathRelation::Unrelated)
}

pub(crate) fn path_is_same_or_child(parent: &Path, child: &Path) -> bool {
    matches!(
        path_relation(child, parent),
        PathRelation::Same | PathRelation::Descendant
    )
}

pub(crate) fn path_relation(left: &Path, right: &Path) -> PathRelation {
    let left = comparable_components(left);
    let right = comparable_components(right);

    if left.is_empty() || right.is_empty() {
        return PathRelation::Unrelated;
    }

    if left == right {
        return PathRelation::Same;
    }

    if right.len() > left.len() && right.starts_with(&left) {
        return PathRelation::Ancestor;
    }

    if left.len() > right.len() && left.starts_with(&right) {
        return PathRelation::Descendant;
    }

    PathRelation::Unrelated
}

fn comparable_components(path: &Path) -> Vec<String> {
    let raw = path.as_os_str().to_string_lossy();
    let windows_like = cfg!(windows) || raw.contains('\\') || has_drive_prefix(&raw);
    let normalized = raw.replace('\\', "/");
    let mut cursor = normalized.as_str();
    let mut components = Vec::new();

    if has_drive_prefix(cursor) {
        components.push(cursor[..2].to_string());
        cursor = &cursor[2..];
    }

    if cursor.starts_with('/') {
        components.push("/".to_string());
        cursor = cursor.trim_start_matches('/');
    }

    components.extend(cursor.split('/').filter_map(|component| match component {
        "" | "." => None,
        value => Some(value.to_string()),
    }));

    if windows_like {
        components
            .into_iter()
            .map(|component| component.to_ascii_lowercase())
            .collect()
    } else {
        components
    }
}

fn has_drive_prefix(value: &str) -> bool {
    let bytes = value.as_bytes();
    bytes.len() >= 2 && bytes[1] == b':' && bytes[0].is_ascii_alphabetic()
}

#[cfg(test)]
mod tests {
    use std::path::Path;

    use super::{PathRelation, path_relation, paths_overlap};

    #[test]
    fn detects_same_child_and_parent_paths() {
        let temp = tempfile::tempdir().unwrap();
        let root = temp.path().join("Rebecca");
        let child = root.join("cache").join("scan");

        assert!(paths_overlap(&root, &root));
        assert!(paths_overlap(&root, &child));
        assert!(paths_overlap(&child, &root));
        assert!(!paths_overlap(&root, &temp.path().join("Other")));
    }

    #[test]
    fn reports_directional_path_relation() {
        let temp = tempfile::tempdir().unwrap();
        let root = temp.path().join("root");
        let child = root.join("cache");

        assert_eq!(path_relation(&root, &root), PathRelation::Same);
        assert_eq!(path_relation(&root, &child), PathRelation::Ancestor);
        assert_eq!(path_relation(&child, &root), PathRelation::Descendant);
        assert_eq!(
            path_relation(&root, &temp.path().join("other")),
            PathRelation::Unrelated
        );
    }

    #[test]
    fn compares_windows_like_paths_case_insensitively() {
        assert!(paths_overlap(
            Path::new(r"C:\Users\Alice\AppData\Local\Rebecca"),
            Path::new(r"c:\users\alice\appdata\local\rebecca\cache"),
        ));
    }
}