toolconfig 2026.10.1

EditorConfig, extended to your tools: generate .editorconfig and native tool configuration from TOML or Pkl
//! Files and directories a compilation read, so watch mode and publication can detect changes.

use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};

use anyhow::{Context, Result};
use pklr::{EvalCapabilities, NativeCapabilities};
use sha2::{Digest, Sha256};

#[derive(Clone, Debug, Default)]
pub(crate) struct Inputs {
    pub(crate) files: BTreeMap<PathBuf, Option<String>>,
    pub(crate) directories: BTreeSet<PathBuf>,
    pub(crate) globs: BTreeMap<(PathBuf, String), Vec<PathBuf>>,
    pub(crate) excluded: BTreeSet<PathBuf>,
}

impl Inputs {
    pub(crate) fn paths(&self) -> impl Iterator<Item = &PathBuf> {
        self.files.keys()
    }

    pub(crate) fn directories(&self) -> impl Iterator<Item = &PathBuf> {
        self.directories.iter()
    }

    pub(crate) fn changed(&self) -> bool {
        let file = self
            .files
            .iter()
            .find(|(path, old)| match (old, fingerprint(path)) {
                (Some(old), Ok(current)) => old != &current,
                (None, Err(_)) => path.try_exists().unwrap_or(true),
                _ => true,
            });
        if let Some((path, _)) = file {
            trace(format_args!("input changed: {}", path.display()));
            return true;
        }
        self.globs.iter().any(|((base, pattern), expected)| {
            NativeCapabilities::new()
                .glob(base, pattern)
                .map_or(true, |paths| {
                    let paths = paths
                        .into_iter()
                        .filter(|path| !self.ignores(path))
                        .collect::<Vec<_>>();
                    let changed = &paths != expected;
                    if changed {
                        trace(format_args!(
                            "glob changed: {pattern} in {}",
                            base.display()
                        ));
                    }
                    changed
                })
        })
    }

    pub(crate) fn contains(&self, path: &Path) -> bool {
        self.files.contains_key(path)
            || path
                .canonicalize()
                .is_ok_and(|real| self.files.contains_key(&real))
    }

    pub(crate) fn ignores(&self, path: &Path) -> bool {
        self.excluded
            .iter()
            .any(|directory| within(path, directory))
    }

    /// Reads a file and records its fingerprint, or records that it is missing.
    pub(crate) fn inspect(&mut self, path: &Path) -> Result<Option<Vec<u8>>> {
        match std::fs::read(path) {
            Ok(bytes) => {
                self.files.insert(path.to_path_buf(), Some(digest(&bytes)));
                Ok(Some(bytes))
            }
            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
                self.files.insert(path.to_path_buf(), None);
                Ok(None)
            }
            Err(error) => Err(error).with_context(|| format!("cannot inspect {}", path.display())),
        }
    }

    pub(crate) fn extend(&mut self, other: Inputs) {
        self.files.extend(other.files);
        self.directories.extend(other.directories);
        self.globs.extend(other.globs);
        self.excluded.extend(other.excluded);
    }
}

/// With TOOLCONFIG_WATCH_TRACE set, reports why watch mode regenerates.
pub(crate) fn trace(message: std::fmt::Arguments<'_>) {
    if std::env::var_os("TOOLCONFIG_WATCH_TRACE").is_some() {
        eprintln!("toolconfig: trace: {message}");
    }
}

/// Whether `path` lies in `directory`, which is canonical. Watchers and evaluators may report
/// paths in another spelling, such as without Windows' `\\?\` prefix, so canonical forms are
/// compared too; a removed file is judged by its parent.
pub(crate) fn within(path: &Path, directory: &Path) -> bool {
    path.starts_with(directory)
        || path
            .canonicalize()
            .or_else(|_| {
                path.parent()
                    .ok_or(std::io::ErrorKind::NotFound)?
                    .canonicalize()
            })
            .is_ok_and(|real| real.starts_with(directory))
}

pub(crate) fn fingerprint(path: &Path) -> Result<String> {
    Ok(digest(&std::fs::read(path).with_context(|| {
        format!("cannot read {}", path.display())
    })?))
}

pub(crate) fn digest(bytes: &[u8]) -> String {
    Sha256::digest(bytes)
        .iter()
        .map(|byte| format!("{byte:02x}"))
        .collect()
}