haz-cli 0.2.0

Command-line task runner: tracks task DAGs across a workspace of projects, executes them with caching.
Documentation
//! Filesystem reads for the `haz init` tree view.
//!
//! Loads and reloads directory children from the host filesystem,
//! applying the hidden-entry classification from
//! [`crate::commands::init::tui::gitignore`], and locates visible
//! focus paths after a reload. The only TUI-toolkit-independent IO
//! layer of the init module; the dispatcher returns signals that
//! the driver turns into calls here.

use std::fs;
use std::path::Path;

use miette::{IntoDiagnostic, Result, WrapErr};

use crate::commands::init::model::{HiddenKind, Node};
use crate::commands::init::tui::TuiState;
use crate::commands::init::tui::gitignore::{GitignoreMatcher, classify_hidden, host_join};

pub(super) fn load_children_for_focused(state: &mut TuiState) -> Result<()> {
    let focused_path = state.tree.focused().path.clone();
    let host_path = host_join(&state.cwd_host, &focused_path);
    let children = read_dir_children(
        &host_path,
        &focused_path,
        &state.gitignore,
        state.hidden_visible,
    )?;
    state.tree.populate_focused_children(children);
    Ok(())
}

/// Re-read every currently-loaded directory in the tree against
/// the new `hidden_visible` value. New entries appear with
/// default state; entries previously present retain their
/// `state`, `expanded`, `children_loaded`, and `children`
/// (matched by basename). Entries no longer in the new read are
/// dropped. After reloading, focus is restored to the
/// previously-focused workspace path when that node is still
/// visible, otherwise it falls back to the root.
pub(super) fn reload_tree_for_visibility(state: &mut TuiState) -> Result<()> {
    let focused_workspace_path = state.tree.focused().path.clone();
    let cwd_host = state.cwd_host.clone();
    let show_hidden = state.hidden_visible;
    reload_loaded_subtree(
        state.tree.root_mut(),
        &cwd_host,
        &state.gitignore,
        show_hidden,
    )?;
    let new_path =
        locate_visible_path(state.tree.root(), &focused_workspace_path).unwrap_or_default();
    state.tree.set_focus_path(new_path);
    Ok(())
}

/// Recursive reload helper. Only descends into directories
/// previously loaded (`children_loaded == true`); never reads a
/// directory the user has not yet expanded.
fn reload_loaded_subtree(
    node: &mut Node,
    cwd_host: &Path,
    gitignore: &GitignoreMatcher,
    show_hidden: bool,
) -> Result<()> {
    if !node.children_loaded {
        return Ok(());
    }
    let host_path = host_join(cwd_host, &node.path);
    let mut new_children = read_dir_children(&host_path, &node.path, gitignore, show_hidden)?;

    let mut old_by_name: std::collections::HashMap<std::ffi::OsString, Node> =
        std::collections::HashMap::new();
    for child in std::mem::take(&mut node.children) {
        old_by_name.insert(child.name.clone(), child);
    }

    for new_child in &mut new_children {
        if let Some(old) = old_by_name.remove(&new_child.name) {
            new_child.state = old.state;
            new_child.expanded = old.expanded;
            new_child.children_loaded = old.children_loaded;
            new_child.children = old.children;
            reload_loaded_subtree(new_child, cwd_host, gitignore, show_hidden)?;
        }
    }
    node.children = new_children;
    Ok(())
}

/// Locate the focus path (list of child indices) leading to the
/// node whose workspace-absolute path equals `target`. Returns
/// `None` if no such node is visible (collapsed ancestor or
/// removed by the reload).
fn locate_visible_path(root: &Node, target: &Path) -> Option<Vec<usize>> {
    let mut path: Vec<usize> = Vec::new();
    if locate_visible_path_rec(root, target, &mut path) {
        Some(path)
    } else {
        None
    }
}

fn locate_visible_path_rec(node: &Node, target: &Path, path: &mut Vec<usize>) -> bool {
    if node.path == target {
        return true;
    }
    if !node.expanded {
        return false;
    }
    for (i, child) in node.children.iter().enumerate() {
        path.push(i);
        if locate_visible_path_rec(child, target, path) {
            return true;
        }
        path.pop();
    }
    false
}

pub(super) fn read_dir_children(
    host_dir: &Path,
    workspace_dir: &Path,
    gitignore: &GitignoreMatcher,
    show_hidden: bool,
) -> Result<Vec<Node>> {
    let mut entries: Vec<Node> = Vec::new();
    let read = fs::read_dir(host_dir)
        .into_diagnostic()
        .wrap_err_with(|| format!("failed to read directory `{}`", host_dir.display()))?;
    for entry in read {
        let entry = entry
            .into_diagnostic()
            .wrap_err_with(|| format!("failed to read entry under `{}`", host_dir.display()))?;
        let file_type = entry
            .file_type()
            .into_diagnostic()
            .wrap_err_with(|| format!("failed to stat `{}`", entry.path().display()))?;
        if !file_type.is_dir() {
            continue;
        }
        let name = entry.file_name();
        let kind = classify_hidden(&name, &entry.path(), gitignore);
        if kind != HiddenKind::Visible && !show_hidden {
            continue;
        }
        let mut child_workspace = workspace_dir.to_path_buf();
        child_workspace.push(&name);
        let mut node = Node::new(name, child_workspace);
        node.hidden_kind = kind;
        entries.push(node);
    }
    entries.sort_by(|a, b| a.name.cmp(&b.name));
    Ok(entries)
}

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

    use tempfile::TempDir;

    use crate::commands::init::model::{HiddenKind, Node};
    use crate::commands::init::tui::gitignore::GitignoreMatcher;

    use super::read_dir_children;

    fn names_of(nodes: &[Node]) -> Vec<String> {
        nodes
            .iter()
            .map(|n| n.name.to_string_lossy().into_owned())
            .collect()
    }

    fn kind_of(nodes: &[Node], name: &str) -> Option<HiddenKind> {
        nodes
            .iter()
            .find(|n| n.name.as_os_str() == std::ffi::OsStr::new(name))
            .map(|n| n.hidden_kind)
    }

    fn write_gitignore(dir: &Path, body: &str) {
        fs::write(dir.join(".gitignore"), body).unwrap();
    }

    #[test]
    fn read_dir_children_skips_dotfile_and_gitignored_when_show_hidden_false() {
        let tmp = TempDir::new().unwrap();
        fs::create_dir(tmp.path().join("src")).unwrap();
        fs::create_dir(tmp.path().join(".git")).unwrap();
        fs::create_dir(tmp.path().join("node_modules")).unwrap();
        write_gitignore(tmp.path(), "node_modules/\n");
        let matcher = GitignoreMatcher::new(tmp.path());
        let children = read_dir_children(tmp.path(), Path::new("/"), &matcher, false).unwrap();
        assert_eq!(names_of(&children), vec!["src".to_owned()]);
    }

    #[test]
    fn read_dir_children_includes_hidden_with_kinds_when_show_hidden_true() {
        let tmp = TempDir::new().unwrap();
        fs::create_dir(tmp.path().join("src")).unwrap();
        fs::create_dir(tmp.path().join(".git")).unwrap();
        fs::create_dir(tmp.path().join("node_modules")).unwrap();
        write_gitignore(tmp.path(), "node_modules/\n");
        let matcher = GitignoreMatcher::new(tmp.path());
        let children = read_dir_children(tmp.path(), Path::new("/"), &matcher, true).unwrap();
        // All three present, alphabetically sorted by basename.
        assert_eq!(
            names_of(&children),
            vec![
                ".git".to_owned(),
                "node_modules".to_owned(),
                "src".to_owned()
            ],
        );
        assert_eq!(kind_of(&children, ".git"), Some(HiddenKind::Dotfile));
        assert_eq!(
            kind_of(&children, "node_modules"),
            Some(HiddenKind::Gitignored),
        );
        assert_eq!(kind_of(&children, "src"), Some(HiddenKind::Visible));
    }

    #[test]
    fn read_dir_children_classifies_dotfile_dir_that_is_also_gitignored_as_gitignored() {
        // Gitignored wins when both apply, so the warning channel
        // reaches the user.
        let tmp = TempDir::new().unwrap();
        fs::create_dir(tmp.path().join(".cache")).unwrap();
        write_gitignore(tmp.path(), ".cache/\n");
        let matcher = GitignoreMatcher::new(tmp.path());
        let children = read_dir_children(tmp.path(), Path::new("/"), &matcher, true).unwrap();
        assert_eq!(kind_of(&children, ".cache"), Some(HiddenKind::Gitignored));
    }

    #[test]
    fn read_dir_children_with_no_gitignore_file_still_skips_dotfiles() {
        let tmp = TempDir::new().unwrap();
        fs::create_dir(tmp.path().join("lib")).unwrap();
        fs::create_dir(tmp.path().join(".hidden")).unwrap();
        // No .gitignore on disk; the matcher degrades to "match
        // nothing" via Gitignore::empty.
        let matcher = GitignoreMatcher::new(tmp.path());
        let children = read_dir_children(tmp.path(), Path::new("/"), &matcher, false).unwrap();
        assert_eq!(names_of(&children), vec!["lib".to_owned()]);
    }

    #[test]
    fn read_dir_children_ignores_regular_files() {
        // Files are never offered to the user: only directories
        // carry selection state.
        let tmp = TempDir::new().unwrap();
        fs::create_dir(tmp.path().join("lib")).unwrap();
        fs::write(tmp.path().join("README.md"), "").unwrap();
        let matcher = GitignoreMatcher::new(tmp.path());
        let children = read_dir_children(tmp.path(), Path::new("/"), &matcher, true).unwrap();
        assert_eq!(names_of(&children), vec!["lib".to_owned()]);
    }
}