haz-cli 0.2.0

Command-line task runner: tracks task DAGs across a workspace of projects, executes them with caching.
Documentation
//! Gitignore and hidden-entry classification for the `haz init`
//! tree view.
//!
//! Independent of the TUI toolkit: pure `ignore`-crate and path
//! logic, so the classification rules are unit-testable without a
//! terminal. Consumed by the tree-reading paths in
//! [`crate::commands::init::tui::tree_io`].

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

use ignore::gitignore::{Gitignore, GitignoreBuilder};

use crate::commands::init::model::HiddenKind;

/// Combined gitignore matcher used to classify directories as
/// `HiddenKind::Gitignored` at read time.
///
/// Honours the cwd's local `.gitignore` plus git's global
/// excludes (`core.excludesFile`, typically
/// `~/.config/git/ignore`). Sub-directory `.gitignore` files are
/// NOT cascaded for the v1 of this feature: the common case
/// (excludes declared at the repo root: `node_modules/`,
/// `target/`, etc.) is covered, and deeper cascading would
/// require either a pre-walk of the whole tree or per-directory
/// matcher rebuilds, both of which cost more than this feature
/// pays back.
#[derive(Debug, Clone)]
pub struct GitignoreMatcher {
    local: Gitignore,
    global: Gitignore,
}

impl GitignoreMatcher {
    /// Construct a matcher rooted at `cwd_host`. A missing
    /// `.gitignore` is not an error; the matcher then matches
    /// only entries the global gitignore would also match (if
    /// any).
    #[must_use]
    pub fn new(cwd_host: &Path) -> Self {
        let mut builder = GitignoreBuilder::new(cwd_host);
        // The builder's `add` returns `Option<Error>`; we ignore
        // a missing file (the common case) and any malformed
        // ignore file (would produce a typed error if needed,
        // but for now we degrade gracefully to an empty
        // matcher).
        let _ = builder.add(cwd_host.join(".gitignore"));
        let local = builder.build().unwrap_or_else(|_| Gitignore::empty());
        let (global, _) = Gitignore::global();
        Self { local, global }
    }

    /// Whether the (host-side) `path` is treated as ignored.
    /// `is_dir` mirrors `gitignore`'s directory-vs-file
    /// distinction so `dir/`-style patterns match correctly.
    #[must_use]
    pub fn is_ignored(&self, host_path: &Path, is_dir: bool) -> bool {
        self.local.matched(host_path, is_dir).is_ignore()
            || self.global.matched(host_path, is_dir).is_ignore()
    }
}

/// Classify a directory entry by how it would be hidden.
///
/// `Gitignored` wins over `Dotfile` when both apply, so the
/// warning-colour styling reaches the user. Pure helper; the
/// caller decides whether the entry is suppressed or shown.
pub(super) fn classify_hidden(
    name: &OsString,
    host_path: &Path,
    gitignore: &GitignoreMatcher,
) -> HiddenKind {
    if gitignore.is_ignored(host_path, true) {
        return HiddenKind::Gitignored;
    }
    if is_hidden_name(name) {
        return HiddenKind::Dotfile;
    }
    HiddenKind::Visible
}

fn is_hidden_name(name: &OsString) -> bool {
    let bytes = name.as_encoded_bytes();
    bytes.first().copied() == Some(b'.')
}

/// Join a workspace-absolute path (rooted at `/`) onto the host
/// cwd root, yielding the host-side path for filesystem reads.
pub(super) fn host_join(cwd_host_root: &Path, workspace_absolute: &Path) -> PathBuf {
    let stripped = workspace_absolute
        .strip_prefix("/")
        .unwrap_or(workspace_absolute);
    if stripped.as_os_str().is_empty() {
        cwd_host_root.to_path_buf()
    } else {
        cwd_host_root.join(stripped)
    }
}