readseek 0.4.33

structural source reader with stable line hashes
// SPDX-License-Identifier: LGPL-2.1-or-later
// Copyright (c) 2026 Jarkko Sakkinen

use crate::engine::flags::GitFlags;
use anyhow::{Context, Result, bail};
use std::collections::BTreeSet;
use std::fs;
use std::path::{Path, PathBuf};

pub(crate) fn command_paths(target: &Path, flags: GitFlags) -> Result<Vec<PathBuf>> {
    let metadata = fs::metadata(target).with_context(|| format!("stat {}", target.display()))?;
    if metadata.is_file() {
        return Ok(vec![target.to_path_buf()]);
    }
    if !metadata.is_dir() {
        bail!(
            "search target is not a file or directory: {}",
            target.display()
        );
    }

    if let Some(paths) = git_paths(target, flags, None)? {
        return Ok(paths);
    }

    if flags.has_any() {
        log::debug!(
            "ignoring Git file selection flags outside repository: {}",
            target.display()
        );
    }

    let mut paths = Vec::new();
    collect_search_paths(target, &mut paths)?;
    Ok(paths)
}

pub(crate) fn def_candidate_paths(
    target: &Path,
    flags: GitFlags,
    search_name: &str,
) -> Result<Vec<PathBuf>> {
    let filter = (!search_name.is_empty()).then_some(search_name.as_bytes());
    if let Some(paths) = git_paths(target, flags, filter)? {
        return Ok(paths);
    }

    command_paths(target, flags)
}

struct GitScope {
    repository: git2::Repository,
    workdir: PathBuf,
    output_root: PathBuf,
    scope: PathBuf,
}

fn resolve_git_scope(target: &Path, flags: GitFlags) -> Result<Option<GitScope>> {
    let original_target = target;
    let Ok(repository) = git2::Repository::discover(target) else {
        return Ok(None);
    };

    flags.validate()?;

    let workdir = repository
        .workdir()
        .context("Git repository has no work tree")?;
    let target = target
        .canonicalize()
        .with_context(|| format!("canonicalize {}", target.display()))?;
    let workdir = workdir
        .canonicalize()
        .with_context(|| format!("canonicalize {}", workdir.display()))?;
    let scope = target
        .strip_prefix(&workdir)
        .with_context(|| format!("{} is outside Git work tree", target.display()))?;
    let output_root = output_root_for_scope(original_target, scope)?;

    Ok(Some(GitScope {
        repository,
        workdir,
        output_root,
        scope: scope.to_path_buf(),
    }))
}

/// Collect the in-scope cached and untracked paths of the Git repository at
/// `target`, or `None` when `target` is not inside one.
///
/// When `name_filter` is `Some`, only files whose bytes contain the identifier
/// are kept; `None` keeps every selected path.
fn git_paths(
    target: &Path,
    flags: GitFlags,
    name_filter: Option<&[u8]>,
) -> Result<Option<Vec<PathBuf>>> {
    let Some(scope) = resolve_git_scope(target, flags)? else {
        return Ok(None);
    };
    let default_selection = !flags.has_any();
    let cached = flags.cached || default_selection;
    let others = flags.others || default_selection;

    let mut paths = BTreeSet::new();
    if cached {
        let index = scope.repository.index().context("read Git index")?;
        for entry in index.iter() {
            let relative = git_path(&entry.path)?;
            if !path_is_in_scope(&relative, &scope.scope) {
                continue;
            }
            let full_path = scope.workdir.join(&relative);
            if !full_path.is_file() {
                continue;
            }
            insert_if_matches(
                &full_path,
                scope.output_root.join(&relative),
                name_filter,
                &mut paths,
            );
        }
    }
    if others {
        let mut other_paths = BTreeSet::new();
        collect_other_paths(
            &scope.repository,
            &scope.workdir,
            &scope.output_root,
            &scope.scope,
            flags.ignored,
            &mut other_paths,
        )?;
        for path in other_paths {
            insert_if_matches(&path, path.clone(), name_filter, &mut paths);
        }
    }

    Ok(Some(paths.into_iter().collect()))
}

/// Insert `output_path` unless a `name_filter` is given and `read_path` does not
/// contain the identifier; an unreadable `read_path` is skipped.
fn insert_if_matches(
    read_path: &Path,
    output_path: PathBuf,
    name_filter: Option<&[u8]>,
    paths: &mut BTreeSet<PathBuf>,
) {
    let Some(name) = name_filter else {
        paths.insert(output_path);
        return;
    };
    let Ok(content) = fs::read(read_path) else {
        return;
    };
    if bytes_contain_identifier(&content, name) {
        paths.insert(output_path);
    }
}

pub(crate) fn bytes_contain_identifier(text: &[u8], identifier: &[u8]) -> bool {
    if identifier.is_empty() {
        return true;
    }

    identifier_spans(text, identifier).next().is_some()
}

/// Return byte offsets where `identifier` appears as a whole identifier.
pub(crate) fn identifier_spans<'a>(
    text: &'a [u8],
    identifier: &'a [u8],
) -> impl Iterator<Item = usize> + 'a {
    IdentifierSpans {
        text,
        identifier,
        inner: memchr::memmem::find_iter(text, identifier),
    }
}

struct IdentifierSpans<'a> {
    text: &'a [u8],
    identifier: &'a [u8],
    inner: memchr::memmem::FindIter<'a, 'a>,
}

impl Iterator for IdentifierSpans<'_> {
    type Item = usize;

    fn next(&mut self) -> Option<Self::Item> {
        if self.identifier.is_empty() {
            return None;
        }
        loop {
            let byte_index = self.inner.next()?;
            let before = byte_index.checked_sub(1).map(|i| self.text[i]);
            let after = self.text.get(byte_index + self.identifier.len()).copied();
            if !before.is_some_and(|b| b.is_ascii_alphanumeric() || b == b'_')
                && !after.is_some_and(|b| b.is_ascii_alphanumeric() || b == b'_')
            {
                return Some(byte_index);
            }
        }
    }
}

fn output_root_for_scope(target: &Path, scope: &Path) -> Result<PathBuf> {
    let mut output_root = target.to_path_buf();
    for _ in scope.components() {
        if !output_root.pop() {
            bail!("{} is outside Git work tree", target.display());
        }
    }
    Ok(output_root)
}

fn collect_other_paths(
    repository: &git2::Repository,
    workdir: &Path,
    output_root: &Path,
    scope: &Path,
    ignored: bool,
    paths: &mut BTreeSet<PathBuf>,
) -> Result<()> {
    let mut options = git2::StatusOptions::new();
    options.include_untracked(true).recurse_untracked_dirs(true);
    if ignored {
        options.include_ignored(true).recurse_ignored_dirs(true);
    }

    for entry in repository.statuses(Some(&mut options))?.iter() {
        let status = entry.status();
        let include = status.contains(git2::Status::WT_NEW)
            || (ignored && status.contains(git2::Status::IGNORED));
        if !include {
            continue;
        }

        let Some(relative) = entry.path().map(PathBuf::from) else {
            continue;
        };
        insert_scoped_file(workdir, output_root, scope, &relative, paths);
    }

    Ok(())
}

fn insert_scoped_file(
    workdir: &Path,
    output_root: &Path,
    scope: &Path,
    relative: &Path,
    paths: &mut BTreeSet<PathBuf>,
) {
    if !path_is_in_scope(relative, scope) {
        return;
    }

    let path = workdir.join(relative);
    if path.is_file() {
        paths.insert(output_root.join(relative));
    }
}

fn git_path(path: &[u8]) -> Result<PathBuf> {
    let path = std::str::from_utf8(path).context("Git index path is not UTF-8")?;
    Ok(PathBuf::from(path))
}

fn path_is_in_scope(path: &Path, scope: &Path) -> bool {
    scope.as_os_str().is_empty() || path.starts_with(scope)
}

fn collect_search_paths(directory: &Path, paths: &mut Vec<PathBuf>) -> Result<()> {
    let mut entries = fs::read_dir(directory)
        .with_context(|| format!("read directory {}", directory.display()))?
        .collect::<std::result::Result<Vec<_>, _>>()
        .with_context(|| format!("read directory entry from {}", directory.display()))?;
    entries.sort_by_key(std::fs::DirEntry::path);

    for entry in entries {
        let path = entry.path();
        let file_type = entry
            .file_type()
            .with_context(|| format!("read file type for {}", path.display()))?;
        let is_readseek = file_type.is_dir() && entry.file_name() == ".readseek";
        if is_readseek {
            continue;
        }
        if file_type.is_dir() {
            collect_search_paths(&path, paths)?;
        } else if file_type.is_file() {
            paths.push(path);
        }
    }

    Ok(())
}