stowe 0.4.2

where git chokes, stowe stows - versioned, deduped big and binary files, pushed to backups you can still play (mirror) or compact blob stores (S3)
//! `.stoweignore`: the paths stowe refuses to track.
//!
//! Media trees are full of things nobody wants versioned - `.DS_Store`,
//! `Thumbs.db`, `.thumbnails/` regenerated by a phone's gallery, editor scratch
//! files. Without a way to exclude them they get hashed, committed, pushed, and
//! (worst of all) reported as drift on every mirror that regenerates them.
//!
//! The file lives at the repo root, one pattern per line:
//!
//! ```text
//! # comments and blank lines are skipped
//! .DS_Store           # a bare name matches that file or folder anywhere
//! *.tmp               # `*` matches any run, `?` exactly one, within a segment
//! .thumbnails/        # a trailing slash matches directories only
//! Renders/proxies/    # a pattern with a slash is anchored at the repo root
//! ```
//!
//! Anything inside an ignored directory is ignored too. The rules apply to
//! every tree walk - the working tree *and* a mirror's - so junk a drive
//! recreates by itself never reads as drift. Naming a file explicitly
//! (`stowe add junk.tmp`) still stages it: an exact path you typed wins.

use std::path::Path;

/// A parsed `.stoweignore`.
pub struct Ignore {
    rules: Vec<Rule>,
}

struct Rule {
    /// The pattern split on `/`. Unanchored rules always hold exactly one.
    segs: Vec<String>,
    /// Pattern ended in `/`, so it matches directories only.
    dir_only: bool,
    /// Pattern held (or started with) a `/`, so it is matched against the whole
    /// repo-relative path rather than against a bare name.
    anchored: bool,
}

impl Ignore {
    /// Read `<root>/.stoweignore`. A missing or unreadable file means "ignore
    /// nothing", which is what every repo made before this feature existed
    /// wants.
    pub fn load(root: &Path) -> Ignore {
        let text = std::fs::read_to_string(root.join(".stoweignore")).unwrap_or_default();
        Ignore::parse(&text)
    }

    pub fn parse(text: &str) -> Ignore {
        let mut rules = Vec::new();
        for line in text.lines() {
            let line = line.trim();
            if line.is_empty() || line.starts_with('#') {
                continue;
            }
            let dir_only = line.ends_with('/');
            // A leading `/` only anchors; it is not part of the name to match.
            let body = line.trim_matches('/');
            if body.is_empty() {
                continue;
            }
            rules.push(Rule {
                anchored: body.contains('/') || line.starts_with('/'),
                segs: body.split('/').map(str::to_string).collect(),
                dir_only,
            });
        }
        Ignore { rules }
    }

    /// Is this repo-relative path excluded?
    ///
    /// Ancestors are checked as well as the path itself, so a file buried in an
    /// ignored directory is still ignored even when the caller walked into it
    /// instead of pruning at the top.
    pub fn is_ignored(&self, rel: &str, is_dir: bool) -> bool {
        if self.rules.is_empty() {
            return false;
        }
        if self.matches(rel, is_dir) {
            return true;
        }
        let mut start = 0;
        while let Some(pos) = rel[start..].find('/') {
            let end = start + pos;
            if self.matches(&rel[..end], true) {
                return true;
            }
            start = end + 1;
        }
        false
    }

    fn matches(&self, path: &str, is_dir: bool) -> bool {
        self.rules.iter().any(|r| r.matches(path, is_dir))
    }
}

impl Rule {
    fn matches(&self, path: &str, is_dir: bool) -> bool {
        if self.dir_only && !is_dir {
            return false;
        }
        if self.anchored {
            let segs: Vec<&str> = path.split('/').collect();
            segs.len() == self.segs.len()
                && segs.iter().zip(&self.segs).all(|(s, p)| wildcard(p, s))
        } else {
            wildcard(&self.segs[0], path.rsplit('/').next().unwrap_or(path))
        }
    }
}

/// Glob match inside a single path segment: `*` is any run of characters, `?`
/// is exactly one. Iterative with backtracking, so a pattern full of stars
/// can't blow the stack on a long name.
fn wildcard(pat: &str, text: &str) -> bool {
    let p: Vec<char> = pat.chars().collect();
    let t: Vec<char> = text.chars().collect();
    let (mut pi, mut ti) = (0usize, 0usize);
    // Where to resume from if the current `*` turns out to have matched too
    // little: `star` is that `*`, `mark` how much of the text it had taken.
    let (mut star, mut mark) = (usize::MAX, 0usize);
    while ti < t.len() {
        if pi < p.len() && (p[pi] == '?' || p[pi] == t[ti]) {
            pi += 1;
            ti += 1;
        } else if pi < p.len() && p[pi] == '*' {
            star = pi;
            mark = ti;
            pi += 1;
        } else if star != usize::MAX {
            pi = star + 1;
            mark += 1;
            ti = mark;
        } else {
            return false;
        }
    }
    while pi < p.len() && p[pi] == '*' {
        pi += 1;
    }
    pi == p.len()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn nothing_is_ignored_without_a_file() {
        let ig = Ignore::parse("");
        assert!(!ig.is_ignored("Photos/a.NEF", false));
    }

    #[test]
    fn a_bare_name_matches_at_any_depth() {
        let ig = Ignore::parse(".DS_Store");
        assert!(ig.is_ignored(".DS_Store", false));
        assert!(ig.is_ignored("Photos/2019/.DS_Store", false));
        assert!(!ig.is_ignored("Photos/DS_Store.NEF", false));
    }

    #[test]
    fn wildcards_stay_inside_one_segment() {
        let ig = Ignore::parse("*.tmp");
        assert!(ig.is_ignored("Renders/scratch.tmp", false));
        assert!(!ig.is_ignored("scratch.tmp.exr", false));
        assert!(wildcard("a?c", "abc"));
        assert!(!wildcard("a?c", "ac"));
        assert!(wildcard("*", "anything"));
    }

    #[test]
    fn a_trailing_slash_matches_directories_only() {
        let ig = Ignore::parse("cache/");
        assert!(ig.is_ignored("Photos/cache", true));
        assert!(!ig.is_ignored("Photos/cache", false));
        // ...and everything inside one.
        assert!(ig.is_ignored("Photos/cache/thumb.jpg", false));
    }

    #[test]
    fn a_slash_anchors_the_pattern_to_the_root() {
        let ig = Ignore::parse("Renders/proxies/");
        assert!(ig.is_ignored("Renders/proxies/shot.mov", false));
        // Same name, different place: not anchored there, so it stays.
        assert!(!ig.is_ignored("Video/Renders/proxies/shot.mov", false));
    }

    #[test]
    fn comments_and_blank_lines_are_skipped() {
        let ig = Ignore::parse("# junk\n\n  *.tmp  \n");
        assert!(ig.is_ignored("a.tmp", false));
        assert!(!ig.is_ignored("# junk", false));
    }
}