luff 0.2.1

Print files with formatting
Documentation
//! Configuration options for the WASM processor.
//!
//! [`ProcessorOptions`] is a WASM-compatible subset of the full CLI config.
//! It contains no `PathBuf`, no filesystem references, and no `clap` derives.

use globset::{Glob, GlobSet, GlobSetBuilder};

use crate::format::OutputFormat;

/// WASM-safe processing options.
///
/// Construct via [`ProcessorOptionsBuilder`] for ergonomic setup,
/// or use [`Default::default`] for sensible defaults.
#[derive(Clone, Debug)]
#[non_exhaustive]
pub struct ProcessorOptions {
    /// Output format selection.
    pub(crate) output_format: OutputFormat,

    /// File extensions to ignore (without leading dot, e.g. `"lock"`).
    pub(crate) ignore_extensions: Vec<String>,

    /// Compiled glob patterns for path-based ignoring.
    pub(crate) ignore_globs: GlobSet,

    /// Raw glob pattern strings (kept for inspection and round-tripping).
    pub(crate) ignore_glob_strings: Vec<String>,

    /// Whether to include dotfiles (paths with components starting with `.`).
    pub(crate) include_dotfiles: bool,

    /// Maximum number of files to include in output.
    pub(crate) max_files: Option<usize>,

    /// Maximum output size in bytes (safety valve for memory).
    pub(crate) max_output_bytes: Option<usize>,

    /// Display label for the tree root.
    pub(crate) root_label: String,
}

impl Default for ProcessorOptions {
    fn default() -> Self {
        Self {
            output_format: OutputFormat::default(),
            ignore_extensions: Vec::new(),
            ignore_globs: GlobSet::empty(),
            ignore_glob_strings: Vec::new(),
            include_dotfiles: false,
            max_files: None,
            max_output_bytes: None,
            root_label: String::from("."),
        }
    }
}

impl ProcessorOptions {
    /// Returns a new [`ProcessorOptionsBuilder`].
    #[must_use]
    pub fn builder() -> ProcessorOptionsBuilder {
        ProcessorOptionsBuilder::default()
    }

    /// The selected output format.
    #[must_use]
    pub const fn output_format(&self) -> OutputFormat {
        self.output_format
    }

    /// The file extensions configured for ignoring (without leading dot).
    #[must_use]
    pub fn ignore_extensions(&self) -> &[String] {
        &self.ignore_extensions
    }

    /// The raw glob pattern strings configured for ignoring.
    ///
    /// Useful for inspecting or round-tripping the configured patterns.
    #[must_use]
    pub fn ignore_glob_strings(&self) -> &[String] {
        &self.ignore_glob_strings
    }

    /// Whether dotfiles are included in output.
    #[must_use]
    pub const fn include_dotfiles(&self) -> bool {
        self.include_dotfiles
    }

    /// The maximum number of files to include, if set.
    #[must_use]
    pub const fn max_files(&self) -> Option<usize> {
        self.max_files
    }

    /// The maximum output size in bytes, if set.
    #[must_use]
    pub const fn max_output_bytes(&self) -> Option<usize> {
        self.max_output_bytes
    }

    /// The display label for the tree root.
    #[must_use]
    pub fn root_label(&self) -> &str {
        &self.root_label
    }
}

/// Compiles a slice of glob pattern strings into a [`GlobSet`].
///
/// Short-circuits for the empty case (the default path) to avoid
/// allocating a [`GlobSetBuilder`] when no patterns are configured.
fn compile_globs(patterns: &[String]) -> super::Result<GlobSet> {
    if patterns.is_empty() {
        return Ok(GlobSet::empty());
    }
    let mut builder = GlobSetBuilder::new();
    for pattern in patterns {
        let _ = builder.add(Glob::new(pattern)?);
    }
    Ok(builder.build()?)
}

/// Builder for [`ProcessorOptions`].
///
/// All setters are infallible and return `Self` for fluent chaining.
/// Glob compilation is deferred to [`build`](Self::build), which is
/// the single fallible step.
///
/// # Example
///
/// ```
/// # #[cfg(feature = "wasm")]
/// # {
/// use luff::wasm::{ProcessorOptions, OutputFormat};
///
/// let opts = ProcessorOptions::builder()
///     .output_format(OutputFormat::Markdown)
///     .ignore_extensions(["lock", "min.js"])
///     .ignore_globs(["**/node_modules/**", "dist/**"])
///     .include_dotfiles(true)
///     .max_files(500)
///     .build()
///     .expect("valid glob patterns");
/// # }
/// ```
#[derive(Debug, Default)]
pub struct ProcessorOptionsBuilder {
    /// Output format selection.
    output_format: OutputFormat,
    /// File extensions to ignore.
    ignore_extensions: Vec<String>,
    /// Raw glob pattern strings (compiled at build time).
    ignore_glob_strings: Vec<String>,
    /// Whether to include dotfiles.
    include_dotfiles: bool,
    /// Maximum number of files to process.
    max_files: Option<usize>,
    /// Maximum output size in bytes.
    max_output_bytes: Option<usize>,
    /// Display label for the root of tree output.
    root_label: Option<String>,
}

impl ProcessorOptionsBuilder {
    /// Set the output format.
    #[must_use]
    pub const fn output_format(mut self, format: OutputFormat) -> Self {
        self.output_format = format;
        self
    }

    /// Set file extensions to ignore.
    ///
    /// Accepts any iterator of string-like items. Extensions should
    /// not include a leading dot.
    #[must_use]
    pub fn ignore_extensions(mut self, exts: impl IntoIterator<Item = impl Into<String>>) -> Self {
        self.ignore_extensions = exts.into_iter().map(Into::into).collect();
        self
    }

    /// Set glob patterns to ignore.
    ///
    /// Accepts any iterator of string-like items. Patterns are stored
    /// as strings and compiled when [`build`](Self::build) is called.
    /// Invalid patterns will cause `build` to return an error.
    #[must_use]
    pub fn ignore_globs(mut self, patterns: impl IntoIterator<Item = impl Into<String>>) -> Self {
        self.ignore_glob_strings = patterns.into_iter().map(Into::into).collect();
        self
    }

    /// Whether to include dotfiles in output.
    #[must_use]
    pub const fn include_dotfiles(mut self, include: bool) -> Self {
        self.include_dotfiles = include;
        self
    }

    /// Maximum number of files to process.
    #[must_use]
    pub const fn max_files(mut self, max: usize) -> Self {
        self.max_files = Some(max);
        self
    }

    /// Maximum output size in bytes.
    #[must_use]
    pub const fn max_output_bytes(mut self, max: usize) -> Self {
        self.max_output_bytes = Some(max);
        self
    }

    /// Display label for the root of tree output.
    #[must_use]
    pub fn root_label(mut self, label: impl Into<String>) -> Self {
        self.root_label = Some(label.into());
        self
    }

    /// Build the [`ProcessorOptions`], compiling glob patterns.
    ///
    /// # Errors
    ///
    /// Returns [`WasmError::PatternError`](super::WasmError::PatternError)
    /// if any glob pattern is invalid.
    pub fn build(self) -> super::Result<ProcessorOptions> {
        let ignore_globs = compile_globs(&self.ignore_glob_strings)?;
        Ok(ProcessorOptions {
            output_format: self.output_format,
            ignore_extensions: self.ignore_extensions,
            ignore_globs,
            ignore_glob_strings: self.ignore_glob_strings,
            include_dotfiles: self.include_dotfiles,
            max_files: self.max_files,
            max_output_bytes: self.max_output_bytes,
            root_label: self.root_label.unwrap_or_else(|| String::from(".")),
        })
    }
}

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

    #[test]
    fn default_options() {
        let opts = ProcessorOptions::default();
        assert_eq!(opts.output_format(), OutputFormat::Markdown);
        assert!(!opts.include_dotfiles());
        assert!(opts.max_files().is_none());
        assert!(opts.max_output_bytes().is_none());
        assert!(opts.ignore_extensions().is_empty());
        assert_eq!(opts.root_label(), ".");
    }

    #[test]
    fn builder_round_trip() {
        let opts = ProcessorOptions::builder()
            .output_format(OutputFormat::Tree)
            .ignore_extensions(["lock", "min.js"])
            .ignore_globs(["**/target/**"])
            .include_dotfiles(true)
            .max_files(100)
            .root_label("my-project")
            .build()
            .unwrap();

        assert_eq!(opts.output_format(), OutputFormat::Tree);
        assert_eq!(opts.ignore_extensions(), &["lock", "min.js"]);
        assert!(opts.include_dotfiles());
        assert_eq!(opts.max_files(), Some(100));
        assert_eq!(opts.root_label(), "my-project");
    }

    #[test]
    fn builder_accepts_slices_and_arrays() {
        // Arrays
        let opts = ProcessorOptions::builder()
            .ignore_extensions(["lock", "log"])
            .ignore_globs(["**/target/**"])
            .build()
            .unwrap();
        assert_eq!(opts.ignore_extensions(), &["lock", "log"]);

        // Vec
        let exts = vec!["lock".to_string(), "log".to_string()];
        let opts = ProcessorOptions::builder()
            .ignore_extensions(exts)
            .build()
            .unwrap();
        assert_eq!(opts.ignore_extensions(), &["lock", "log"]);

        // Slice via iter().cloned()
        let source = ["lock", "log"];
        let opts = ProcessorOptions::builder()
            .ignore_extensions(source.iter().copied())
            .build()
            .unwrap();
        assert_eq!(opts.ignore_extensions(), &["lock", "log"]);
    }

    #[test]
    fn glob_strings_round_trip() {
        let opts = ProcessorOptions::builder()
            .ignore_globs(["**/target/**", "dist/**"])
            .build()
            .unwrap();

        assert_eq!(opts.ignore_glob_strings(), &["**/target/**", "dist/**"]);
    }

    #[test]
    fn invalid_glob_rejected() {
        let result = ProcessorOptionsBuilder::default()
            .ignore_globs(["[invalid"])
            .build();
        assert!(result.is_err());
    }

    #[test]
    fn output_format_serde() {
        let json = serde_json::to_string(&OutputFormat::Tree).unwrap();
        assert_eq!(json, r#""tree""#);

        let parsed: OutputFormat = serde_json::from_str(r#""markdown""#).unwrap();
        assert_eq!(parsed, OutputFormat::Markdown);
    }

    #[test]
    fn builder_no_globs_is_infallible() {
        // A builder with no globs should always succeed.
        let opts = ProcessorOptions::builder()
            .output_format(OutputFormat::Markdown)
            .build()
            .unwrap();
        assert_eq!(opts.output_format(), OutputFormat::Markdown);
    }
}