luff 0.2.1

Print files with formatting
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
//! Clipboard buffering with size limits and safe markdown truncation

use crate::{
    cli::{OutputFormat, clipboard, estimation},
    config::Config,
    error::Result,
    printer,
    walker::WalkerItem,
};
use log::debug;

/// Result of buffering entries for clipboard operations
#[derive(Debug)]
#[must_use]
pub struct BufferResult {
    /// The accumulated formatted output
    pub output: String,
    /// Number of files successfully processed
    pub processed: usize,
    /// Number of files seen before stopping (may be less than the true total
    /// if buffering was aborted early due to a size limit).
    pub total_seen: usize,
    /// Whether the size limit was exceeded
    pub size_exceeded: bool,
}

/// Tracks whether the last entry left an open code fence.
///
/// Each markdown entry is self-contained (the printer opens and closes its
/// own fences), so this flag simply records whether the most recent
/// `format_entry_into` call completed successfully.  On truncation we use
/// the snapshot taken *before* the truncated entry to decide whether a
/// closing fence is needed.
#[derive(Debug, Clone, Copy)]
struct FenceGuard {
    /// `true` between the start of formatting an entry and successful completion.
    open: bool,
}

impl FenceGuard {
    /// Create a new guard with no open fence.
    const fn new() -> Self {
        Self { open: false }
    }

    /// Mark that we are about to format an entry (fence may be opened).
    const fn mark_open(&mut self) {
        self.open = true;
    }

    /// Mark that formatting completed (fence was closed by the printer).
    const fn mark_closed(&mut self) {
        self.open = false;
    }

    /// If a fence was left open at this snapshot, append a closing fence
    /// to `output` so the markdown remains valid.
    fn close_if_needed(self, output: &mut String) {
        if self.open {
            output.push_str("\n```\n\n");
        }
    }
}

/// Buffer entries with accurate size limit checking for clipboard mode.
///
/// This function handles **Markdown** format. Tree format is handled
/// separately by [`buffer_tree_entries`] because tree rendering is
/// inherently a batch operation (all paths must be collected before the
/// hierarchy can be rendered).
///
/// `estimated_files` is a **hint** used only for pre-allocation. The actual
/// total is counted as entries are consumed, so callers that cannot predict the
/// count (e.g. directory walks) can safely pass `0`.
///
/// When the size limit is reached, buffering stops immediately. The returned
/// `total_seen` reflects only the entries consumed from the iterator up to
/// that point — the true total may be higher. This is deliberate: draining
/// the remaining iterator would be wasteful (each `.next()` on a directory
/// walker performs real filesystem I/O).
///
/// # Errors
///
/// Returns an error if file reading or formatting fails.
fn buffer_entries_with_limit<I>(
    entries: I,
    printer_opts: &crate::printer::PrinterOptions,
    max_bytes: usize,
    estimated_files: usize,
) -> Result<BufferResult>
where
    I: IntoIterator<Item = WalkerItem>,
{
    let estimated_size = estimation::estimate_output_size(estimated_files);
    let initial_capacity = if max_bytes > 0 {
        estimated_size.min(max_bytes)
    } else {
        estimated_size
    };

    debug!(
        "Pre-allocating {initial_capacity} bytes (estimate from {estimated_files} files, effective max: {max_bytes})"
    );

    let mut output = String::with_capacity(initial_capacity);
    let mut processed = 0usize;
    let mut total_seen = 0usize;
    let mut size_exceeded = false;
    let mut fence = FenceGuard::new();

    for item in entries {
        let entry = match item {
            WalkerItem::Entry(e) => e,
            WalkerItem::Error(e) => {
                // Log walker errors so operators can diagnose traversal
                // problems even in clipboard mode.
                debug!("Skipping walker error in clipboard mode: {e}");
                continue;
            }
        };

        // Count every non-error entry we attempt, regardless of outcome
        total_seen += 1;

        // Check size BEFORE formatting to avoid wasted work
        if max_bytes > 0 && output.len() >= max_bytes {
            debug!(
                "Clipboard size limit reached before formatting {}: {} >= {} bytes",
                entry.relative_path.display(),
                output.len(),
                max_bytes
            );
            size_exceeded = true;
            break;
        }

        // Track size before formatting this entry (safe truncation point)
        let size_before = output.len();

        // Snapshot fence state at the last safe truncation point so we
        // can restore it if we need to roll back this entry.
        let fence_at_safe_point = fence;

        // We are about to format an entry which may open a code fence.
        fence.mark_open();

        let formatted = printer::MarkdownPrinter::format_entry_into(
            &entry,
            &mut output,
            &printer_opts.patterns,
            printer_opts.skip_patterns,
        )?;

        if formatted {
            fence.mark_closed();
        }

        // Only count and check size if entry was actually formatted
        if !formatted {
            // Roll back any partial output that the formatter may have
            // written before deciding to skip this entry (e.g. a code-fence
            // header for a file that turned out to be binary).
            output.truncate(size_before);

            // Restore the fence state to the pre-entry snapshot so that
            // `close_if_needed` remains consistent with the actual buffer.
            fence = fence_at_safe_point;
            continue;
        }

        // Check actual bytes added after formatting
        let size_after = output.len();
        let bytes_added = size_after.saturating_sub(size_before);

        debug!(
            "Formatted {}: {bytes_added} bytes (total now: {size_after})",
            entry.relative_path.display(),
        );

        // If we exceeded limit, truncate to the safe point and stop
        if max_bytes > 0 && size_after > max_bytes {
            debug!(
                "Size limit exceeded after formatting {}: {size_after} > {max_bytes} bytes",
                entry.relative_path.display(),
            );

            // Truncate to safe point (before this entry)
            output.truncate(size_before);

            // Restore fence state to the pre-entry snapshot and close
            // any block that was open at that point.  In practice each
            // entry is self-contained so `open` is `false` here,
            // but restoring the snapshot makes this correct by construction
            // rather than by coincidence.
            fence = fence_at_safe_point;
            fence.close_if_needed(&mut output);

            size_exceeded = true;
            break;
        }

        processed += 1;
    }

    debug!(
        "Buffering complete: {} bytes, {processed}/{total_seen} files processed, size_exceeded: {size_exceeded}",
        output.len()
    );

    Ok(BufferResult {
        output,
        processed,
        total_seen,
        size_exceeded,
    })
}

/// Buffer tree entries for clipboard mode.
///
/// Tree rendering is inherently a batch operation: all paths must be
/// collected before the directory hierarchy can be drawn. This function
/// collects entries into a [`printer::TreePrinter`], renders the tree,
/// and then checks the size limit against the final output.
///
/// Because the size can only be checked after rendering, the limit acts
/// as a gate on clipboard copy rather than a truncation point. If the
/// rendered output exceeds `max_bytes`, the result is returned with
/// `size_exceeded = true` and an empty output buffer (a half-rendered
/// tree is not useful).
///
/// # Errors
///
/// Returns an error if entry processing or tree rendering fails.
fn buffer_tree_entries<I>(
    entries: I,
    printer_opts: &crate::printer::PrinterOptions,
    max_bytes: usize,
    max_files: usize,
) -> Result<BufferResult>
where
    I: IntoIterator<Item = WalkerItem>,
{
    let mut tree_printer = printer::TreePrinter::with_max_entries(max_files);
    let mut processed = 0usize;
    let mut total_seen = 0usize;

    for item in entries {
        let entry = match item {
            WalkerItem::Entry(e) => e,
            WalkerItem::Error(e) => {
                debug!("Skipping walker error in clipboard mode: {e}");
                continue;
            }
        };

        total_seen += 1;
        tree_printer.add_entry(entry.path)?;
        processed += 1;
    }

    // Render the complete tree into a byte buffer
    let mut buf = Vec::new();
    tree_printer.write_tree(&mut buf, &printer_opts.root)?;

    let output = String::from_utf8(buf).map_err(|e| {
        crate::error::Error::Io(std::io::Error::new(
            std::io::ErrorKind::InvalidData,
            format!("Tree output contained invalid UTF-8: {e}"),
        ))
    })?;

    let size_exceeded = max_bytes > 0 && output.len() > max_bytes;

    if size_exceeded {
        debug!(
            "Tree output ({} bytes) exceeds clipboard limit ({max_bytes} bytes)",
            output.len()
        );
        // Tree output cannot be meaningfully truncated, so return empty.
        // The caller will warn the user and skip clipboard copy.
        Ok(BufferResult {
            output: String::new(),
            processed: 0,
            total_seen,
            size_exceeded: true,
        })
    } else {
        debug!(
            "Tree buffering complete: {} bytes, {processed} files",
            output.len()
        );
        Ok(BufferResult {
            output,
            processed,
            total_seen,
            size_exceeded: false,
        })
    }
}

/// Handle clipboard mode buffering and output
///
/// # Errors
///
/// Returns an error if buffering or output operations fail.
///
/// `BrokenPipe` on stdout is **not** treated as an error: the clipboard is the
/// primary output target in this mode, so we log the broken pipe and continue
/// to the clipboard copy. This handles `luff --clip | head` correctly.
pub fn handle_clipboard_mode<I>(
    walker: I,
    estimated_files: usize,
    printer_opts: &crate::printer::PrinterOptions,
    config: &Config,
    output_mode: crate::cli::OutputMode,
) -> Result<()>
where
    I: IntoIterator<Item = WalkerItem>,
{
    let max_clipboard_bytes = config.max_clipboard_bytes();

    // Early warning: check if estimated size significantly exceeds limit.
    // Only useful when the caller can provide a meaningful estimate
    // (e.g. file-list mode). For directory walks, estimated_files may be 0.
    if estimated_files > 0 {
        let estimated = estimation::estimate_output_size(estimated_files);
        if max_clipboard_bytes > 0 && estimated > max_clipboard_bytes.saturating_mul(2) {
            eprintln!(
                "\n⚠ Warning: Estimated output size ({estimated} bytes) exceeds clipboard limit ({max_clipboard_bytes} bytes)"
            );
            eprintln!("  Consider increasing --max-clipboard-mb or using streaming mode");
        }
    }

    let result = match printer_opts.format {
        OutputFormat::Markdown => {
            buffer_entries_with_limit(walker, printer_opts, max_clipboard_bytes, estimated_files)?
        }
        OutputFormat::Tree => buffer_tree_entries(
            walker,
            printer_opts,
            max_clipboard_bytes,
            config.max_files(),
        )?,
    };

    if result.size_exceeded {
        eprintln!("\n⚠ Clipboard size limit exceeded ({max_clipboard_bytes} bytes)");
        eprintln!(
            "  Processed {processed} of {seen}+ files before limit was reached",
            processed = result.processed,
            seen = result.total_seen,
        );
        eprintln!("  Output has been truncated");
        eprintln!("  Consider using --max-clipboard-mb to increase the limit");
        eprintln!("  or remove --clip to stream output directly\n");
    }

    // Write to stdout first (if enabled).
    // BrokenPipe is non-fatal here: the clipboard is the primary target
    // in this mode, so we must not abort before attempting the copy.
    if output_mode.should_show_stdout() {
        use std::io::{self, Write};
        let stdout = io::stdout();
        let mut handle = stdout.lock();
        match handle.write_all(result.output.as_bytes()) {
            Ok(()) => {}
            Err(e) if e.kind() == io::ErrorKind::BrokenPipe => {
                log::debug!("Stdout broken pipe in clipboard mode; continuing to clipboard copy");
            }
            Err(e) => return Err(e.into()),
        }
    }

    // Attempt clipboard copy only if we didn't exceed size limit
    if result.size_exceeded {
        eprintln!("  Skipping clipboard copy due to size limit");
    } else if let Err(e) = clipboard::copy_to_clipboard(&result.output) {
        eprintln!("✗ Clipboard error: {e}");
    } else {
        eprintln!("✓ Copied to clipboard ({} bytes)", result.output.len());
    }

    Ok(())
}

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

    #[test]
    fn test_fence_guard_lifecycle() {
        // New guard starts closed
        let mut guard = FenceGuard::new();
        assert!(!guard.open);

        // Opening sets the flag
        guard.mark_open();
        assert!(guard.open);

        // Double-open is idempotent
        guard.mark_open();
        assert!(guard.open);

        // Closing clears it
        guard.mark_closed();
        assert!(!guard.open);
    }

    #[test]
    fn test_fence_guard_close_if_needed() {
        // No-op when closed
        let guard = FenceGuard::new();
        let mut output = String::from("existing");
        let len_before = output.len();
        guard.close_if_needed(&mut output);
        assert_eq!(output.len(), len_before);

        // Appends closing fence when open
        let mut guard = FenceGuard::new();
        guard.mark_open();
        let mut output = String::new();
        guard.close_if_needed(&mut output);
        assert_eq!(output, "\n```\n\n");
    }

    #[test]
    fn test_fence_guard_snapshot_independence() {
        // FenceGuard is Copy, so snapshots are independent of later mutations
        let mut guard = FenceGuard::new();
        guard.mark_open();

        let snapshot = guard; // Copy
        guard.mark_closed();

        assert!(
            snapshot.open,
            "Snapshot should retain state at time of copy"
        );
        assert!(!guard.open, "Original should reflect later mutation");
    }

    #[test]
    fn test_buffer_result_creation() {
        let result = BufferResult {
            output: "test".to_string(),
            processed: 10,
            total_seen: 15,
            size_exceeded: true,
        };

        assert_eq!(result.output, "test");
        assert_eq!(result.processed, 10);
        assert_eq!(result.total_seen, 15);
        assert!(result.size_exceeded);
    }
}