luff 0.2.1

Print files with formatting
Documentation
//! CLI orchestration and execution logic

use crate::{
    cli::{Args, Commands, buffer::handle_clipboard_mode},
    config::Config,
    error::Result,
    printer,
    walker::WalkerItem,
};
use crossbeam_channel::{Receiver, bounded};
use std::io::{self, Write};
use std::thread;

/// Capacity of the bounded channel between the walker and the writer thread.
///
/// This controls backpressure: when the writer is slow (e.g. stdout is a pipe
/// to a pager), the walker blocks after producing this many messages ahead.
/// 16 keeps latency low while avoiding per-entry synchronization overhead.
const WRITER_CHANNEL_CAPACITY: usize = 16;

/// Message sent to the printer actor thread
enum PrintMessage {
    /// A pre-formatted block of text (e.g. a markdown code block)
    Block(String),
    /// A raw entry for the Tree printer to accumulate
    TreeEntry(crate::walker::WalkerEntry),
}

/// Run the CLI application with parsed arguments
///
/// # Errors
///
/// Returns an error if:
/// - Configuration cannot be loaded from arguments
/// - File list processing fails (invalid paths, permission denied)
/// - Directory walking fails (IO errors, git root not found)
/// - Output formatting fails (UTF-8 conversion, clipboard errors)
/// - Memory limits are exceeded during buffering
///
/// All errors are wrapped with diagnostic context via `miette`.
pub fn run(args: &Args) -> Result<()> {
    // Handle schema generation subcommand (early exit)
    if let Some(Commands::Schema { output }) = args.command() {
        return crate::cli::schema::generate_schema(output.as_ref());
    }

    let config = Config::from_args(args)?;
    let output_mode = args.output_mode();

    log::debug!("Starting luff v{}", crate::VERSION);
    log::debug!("Output mode: {output_mode:?}");

    // Warn on likely user mistake: -S without -c is a no-op since
    // OutputMode::Stdout always shows stdout. The flag is only
    // meaningful when combined with --clip.
    if args.suppress_stdout() && !args.use_clipboard() {
        log::warn!(
            "--suppress-stdout (-S) has no effect without --clip (-c); \
             output will still be written to stdout"
        );
    }

    // Determine mode of operation
    if let Some(files) = args.files() {
        let estimated = files.len();
        let walker = crate::walker::Walker::from_file_list(&files, &config)?;
        dispatch_output(walker, estimated, &config, output_mode)?;
    } else {
        log::debug!("Starting directory walk from: {}", config.root().display());
        let walker = crate::walker::Walker::from_dir(&config)?;
        let estimated_files = walker.size_hint().1.unwrap_or(0);
        dispatch_output(walker, estimated_files, &config, output_mode)?;
    }

    Ok(())
}

/// Dispatch walker output to the appropriate output sink.
///
/// This is the single dispatch point for both file-list and directory-walk
/// modes. It selects between clipboard buffering and streaming based on
/// the output mode.
fn dispatch_output<I>(
    walker: I,
    estimated_files: usize,
    config: &Config,
    output_mode: crate::cli::OutputMode,
) -> Result<()>
where
    I: IntoIterator<Item = WalkerItem>,
{
    let printer_opts = config.printer_options();

    if output_mode.should_buffer() {
        handle_clipboard_mode(walker, estimated_files, &printer_opts, config, output_mode)
    } else {
        // In non-buffered mode, should_show_stdout() is always true
        // (OutputMode::Stdout). Stream directly.
        debug_assert!(
            output_mode.should_show_stdout(),
            "Non-buffered mode must show stdout; \
             OutputMode::Stdout always returns true from should_show_stdout()"
        );
        stream_to_stdout(walker, &printer_opts, config.max_files())
    }
}

/// The inner write loop for the printer actor thread.
///
/// Separated from [`spawn_writer_thread`] so that `BrokenPipe` can be caught
/// at the thread boundary without cluttering the main loop with matches.
fn write_output(
    rx: Receiver<PrintMessage>,
    root: &std::path::Path,
    max_files: usize,
) -> Result<()> {
    let stdout = io::stdout();
    let mut handle = io::BufWriter::new(stdout.lock());
    let mut tree_printer: Option<printer::TreePrinter> = None;

    for msg in rx {
        match msg {
            PrintMessage::Block(s) => {
                handle.write_all(s.as_bytes())?;
            }
            PrintMessage::TreeEntry(entry) => {
                tree_printer
                    .get_or_insert_with(|| printer::TreePrinter::with_max_entries(max_files))
                    .add_entry(entry.path)?;
            }
        }
    }

    // If we accumulated tree entries, render and print the tree now
    if let Some(tp) = tree_printer {
        tp.write_tree(&mut handle, root)?;
    }

    handle.flush()?;
    Ok(())
}

/// Spawn the printer actor thread.
///
/// This thread handles all writes to stdout to avoid lock contention and
/// ensure atomic output. It consumes messages from the provided receiver.
///
/// `BrokenPipe` errors (e.g. `luff | head`) are caught at the thread
/// boundary and converted to `Ok(())`. This causes the channel to close,
/// the main thread's `send` fails, and the pipeline shuts down cleanly
/// without printing spurious error messages.
///
/// The `TreePrinter` is lazily initialized on first `TreeEntry` message,
/// so markdown-only runs avoid the allocation entirely.
fn spawn_writer_thread(
    rx: Receiver<PrintMessage>,
    root: std::path::PathBuf,
    max_files: usize,
) -> thread::JoinHandle<Result<()>> {
    thread::spawn(move || match write_output(rx, &root, max_files) {
        Ok(()) => Ok(()),
        Err(crate::error::Error::Io(ref e)) if e.kind() == io::ErrorKind::BrokenPipe => {
            log::debug!("Writer thread: stdout broken pipe (reader closed)");
            Ok(())
        }
        Err(e) => Err(e),
    })
}

/// Join on the writer thread, propagating panics instead of swallowing them.
///
/// A panic in the writer thread indicates a bug (not a recoverable IO error),
/// so we resume the unwind rather than silently dropping it.
fn join_writer(handle: thread::JoinHandle<Result<()>>) -> Result<()> {
    match handle.join() {
        Ok(result) => result,
        Err(panic_payload) => std::panic::resume_unwind(panic_payload),
    }
}

/// Stream walker output through the printer actor to stdout.
///
/// This is the common streaming path used by both file-list and
/// directory-walk modes. The bounded channel provides backpressure
/// if the writer thread falls behind.
fn stream_to_stdout<I>(
    walker: I,
    printer_opts: &printer::PrinterOptions,
    max_files: usize,
) -> Result<()>
where
    I: IntoIterator<Item = WalkerItem>,
{
    let (tx, rx) = bounded::<PrintMessage>(WRITER_CHANNEL_CAPACITY);

    let writer_handle = spawn_writer_thread(rx, printer_opts.root.clone(), max_files);

    for item in walker {
        match item {
            WalkerItem::Entry(entry) => match printer_opts.format {
                crate::cli::OutputFormat::Markdown => {
                    if let Some(formatted) = printer::MarkdownPrinter::format_entry(
                        &entry,
                        &printer_opts.patterns,
                        printer_opts.skip_patterns,
                    )? {
                        if tx.send(PrintMessage::Block(formatted)).is_err() {
                            break; // Writer thread died
                        }
                    }
                }
                crate::cli::OutputFormat::Tree => {
                    if tx.send(PrintMessage::TreeEntry(entry)).is_err() {
                        break; // Writer thread died
                    }
                }
            },
            WalkerItem::Error(e) => {
                eprintln!("⚠ Walker error: {e}");
            }
        }
    }

    // Drop sender to signal EOF to writer thread
    drop(tx);

    join_writer(writer_handle)
}