tooka 1.1.1

A fast, rule-based CLI tool for organizing your files
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
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
use crate::core::sorter::MatchResult;
use chrono::Local;
use pdf_writer::{Chunk, Content, Name, Pdf, Rect, Ref, Str};
use std::{collections::BTreeMap, path::Path};

// Page dimensions and basic layout
const PAGE_WIDTH: f32 = 595.0;
const PAGE_HEIGHT: f32 = 842.0;
const MARGIN_X: f32 = 20.0;
const MARGIN_TOP: f32 = 40.0;

// Font sizes
const FONT_SIZE: f32 = 12.0;
const TITLE_FONT_SIZE: f32 = 20.0;
const FOOTER_FONT_SIZE: f32 = FONT_SIZE - 1.0;
const TIMESTAMP_FONT_SIZE: f32 = FONT_SIZE - 1.0;
const ACTION_FONT_SIZE: f32 = FONT_SIZE + 0.5;

// Header and footer positioning
const TITLE_POS_X: f32 = MARGIN_X;
const TITLE_POS_Y: f32 = PAGE_HEIGHT - MARGIN_TOP;
const TIME_POS_X: f32 = PAGE_WIDTH - 110.0;
const TIME_POS_Y: f32 = PAGE_HEIGHT - 15.0;
const FOOTER_Y: f32 = 8.0;
const PAGE_NUMBER_X_OFFSET: f32 = 20.0;

// Content layout constants
const CONTENT_START_OFFSET: f32 = 50.0;
const MIN_Y_OFFSET: f32 = 40.0;
const RULE_BEFORE_SPACING: f32 = 15.0; // Space before rule title when following content
const RULE_AFTER_SPACING: f32 = 20.0; // Space after rule title before next content
const RULE_FONT_SIZE_OFFSET: f32 = 2.0;
const TOTAL_CHANGES_Y_OFFSET: f32 = 15.0;

// Box and content styling
const BOX_PADDING: f32 = 20.0;
const BOX_TOP_PADDING: f32 = 20.0;
const BOX_BOTTOM_SPACING: f32 = 10.0;
const BOX_CORNER_RADIUS: f32 = 4.0;
const BEZIER_CONTROL_RATIO: f32 = 0.552; // Ratio for circular arc approximation with cubic bezier
const CONTENT_INDENT: f32 = 10.0;
const TEXT_SECTION_SPACING: f32 = 20.0;
const PATH_SECTION_SPACING: f32 = 15.0;
const LINE_HEIGHT: f32 = 12.0;
const CONTENT_BASE_HEIGHT: f32 = 30.0;

// Text positioning and wrapping
const MAX_PATH_LENGTH: f32 = PAGE_WIDTH - 2.0 * MARGIN_X - 105.0;
const APPROX_CHAR_WIDTH: f32 = 6.0;
const FROM_TO_INDENT: f32 = 15.0;
const PATH_VALUE_INDENT: f32 = 50.0;
const PATH_CONTINUATION_INDENT: f32 = 35.0;
const MAX_PATH_LINES: usize = 3;

/// PDF generator that manages state and rendering for creating reports
struct PDFGenerator {
    pdf: Pdf,
    alloc: Ref,
    page_tree_id: Ref,
    font_name: Name<'static>,
    font_id: Ref,
    page_ids: Vec<Ref>,
    content: Content,
    extg_states: Vec<(String, Ref)>,
    page_number: i32,
    y: f32,
    first_page: bool,
    last_rule_id: Option<String>,
    total_results: usize,
}

impl PDFGenerator {
    fn new(total_results: usize) -> Self {
        let mut alloc = Ref::new(1);
        let mut pdf = Pdf::new();

        let (font_name, font_id) = Self::init_fonts(&mut pdf, &mut alloc);
        let page_tree_id = alloc.bump();

        Self {
            pdf,
            alloc,
            page_tree_id,
            font_name,
            font_id,
            page_ids: vec![],
            content: Content::new(),
            extg_states: vec![],
            page_number: 1,
            y: PAGE_HEIGHT - MARGIN_TOP - CONTENT_START_OFFSET,
            first_page: true,
            last_rule_id: None,
            total_results,
        }
    }

    fn generate(mut self, path: &Path, results: &[MatchResult]) -> Result<(), anyhow::Error> {
        let flat_entries = Self::prepare_entries(results);
        self.render_pages(&flat_entries);
        self.finalize();

        std::fs::write(path, self.pdf.finish())?;
        Ok(())
    }

    fn init_fonts(pdf: &mut Pdf, alloc: &mut Ref) -> (Name<'static>, Ref) {
        let font_id = alloc.bump();
        pdf.type1_font(font_id).base_font(Name(b"Helvetica"));
        (Name(b"Helvetica"), font_id)
    }

    fn prepare_entries(results: &[MatchResult]) -> Vec<(Option<String>, Option<&MatchResult>)> {
        let mut grouped: BTreeMap<String, Vec<&MatchResult>> = BTreeMap::new();
        for result in results {
            grouped
                .entry(result.matched_rule_id.clone())
                .or_default()
                .push(result);
        }
        for entries in grouped.values_mut() {
            entries.sort_by(|a, b| a.file_name.cmp(&b.file_name));
        }

        let mut flat_entries = vec![];
        for (rule_id, entries) in grouped {
            flat_entries.push((Some(rule_id), None));
            for entry in entries {
                flat_entries.push((None, Some(entry)));
            }
        }
        flat_entries
    }

    fn render_pages(&mut self, flat_entries: &[(Option<String>, Option<&MatchResult>)]) {
        let secondary = Chunk::new();
        let min_y = MARGIN_TOP + MIN_Y_OFFSET;
        let mut first_rule = true;

        for (rule_id_opt, entry_opt) in flat_entries {
            if let Some(rule_id) = rule_id_opt {
                // Add spacing before rule title if it's not the first rule and not at top of page
                if !first_rule && self.y < PAGE_HEIGHT - MARGIN_TOP - CONTENT_START_OFFSET {
                    self.y -= RULE_BEFORE_SPACING;
                }

                self.page_break_if_needed(min_y + RULE_AFTER_SPACING);

                self.write_text(
                    &format!("> Rule: {rule_id}"),
                    FONT_SIZE + RULE_FONT_SIZE_OFFSET,
                    MARGIN_X,
                    self.y,
                );
                self.y -= RULE_AFTER_SPACING; // Reduced spacing after rule title
                self.last_rule_id = Some(rule_id.clone());
                first_rule = false;
            }

            if let Some(entry) = entry_opt {
                // Calculate needed space for this entry (considering path wrapping)
                let from_lines =
                    PDFGenerator::format_path_with_wrapping(&entry.current_path, MAX_PATH_LENGTH);
                let to_lines =
                    PDFGenerator::format_path_with_wrapping(&entry.new_path, MAX_PATH_LENGTH);
                let total_lines = from_lines.len() + to_lines.len();
                #[allow(clippy::cast_precision_loss)]
                let content_height = CONTENT_BASE_HEIGHT + (total_lines as f32 * LINE_HEIGHT); // Header + path lines
                let box_height = content_height + BOX_PADDING; // Add padding

                self.page_break_if_needed(min_y + box_height);

                if self.y == PAGE_HEIGHT - MARGIN_TOP - CONTENT_START_OFFSET {
                    if let Some(rule_id) = &self.last_rule_id {
                        self.write_text(
                            &format!("> Rule: {rule_id}"),
                            FONT_SIZE + RULE_FONT_SIZE_OFFSET,
                            MARGIN_X,
                            self.y,
                        );
                        self.y -= RULE_AFTER_SPACING; // Use consistent spacing after rule
                    }
                }

                let state_name = format!("G_{}_{}", entry.matched_rule_id, entry.file_name);
                self.content.set_parameters(Name(state_name.as_bytes()));

                // Calculate box position (box goes from bottom to top in PDF coordinates)
                let box_bottom = self.y - box_height;
                let box_top = self.y;

                // Draw the background box
                self.draw_colored_box(
                    MARGIN_X,
                    box_bottom,
                    PAGE_WIDTH - 2.0 * MARGIN_X,
                    box_height,
                    (0.9, 0.9, 0.9),
                );

                // Draw content with proper positioning (text starts from top of box with padding)
                let text_start_y = box_top - BOX_TOP_PADDING; // Start points from top of box
                self.draw_match_result_block(entry, text_start_y);

                self.y = box_bottom - BOX_BOTTOM_SPACING; // Move position down past the box plus spacing
                let state_id = self.alloc.bump();
                self.extg_states.push((state_name, state_id));
            }
        }

        if !self.extg_states.is_empty() {
            self.finish_page();
        }

        self.pdf.extend(&secondary);
    }

    fn page_break_if_needed(&mut self, min_y: f32) {
        if self.y < min_y {
            self.finish_page();
            self.page_number += 1;
            self.y = PAGE_HEIGHT - MARGIN_TOP - CONTENT_START_OFFSET;
            self.content = Content::new();
            self.extg_states = vec![];
            self.first_page = false;
        }
    }

    fn finish_page(&mut self) {
        let page_id = self.alloc.bump();
        self.page_ids.push(page_id);

        // Draw header and footer before creating the page
        if self.first_page {
            self.draw_header();
        }
        self.draw_footer();

        // Create the content stream and write it to the PDF
        let content_id = self.alloc.bump();
        let content_data = std::mem::replace(&mut self.content, Content::new()).finish();
        self.pdf.stream(content_id, &content_data);

        // Now create the page and set its properties
        let mut page = self.pdf.page(page_id);
        page.media_box(Rect::new(0.0, 0.0, PAGE_WIDTH, PAGE_HEIGHT));
        page.parent(self.page_tree_id);
        page.contents(content_id);

        // Add font resources
        let mut resources = page.resources();
        resources.fonts().pair(self.font_name, self.font_id);

        // Add graphics state resources if any
        if !self.extg_states.is_empty() {
            resources.ext_g_states().pairs(
                self.extg_states
                    .iter()
                    .map(|(n, id): &(String, Ref)| (Name(n.as_bytes()), *id)),
            );
        }
    }

    fn finalize(&mut self) {
        self.pdf
            .pages(self.page_tree_id)
            .kids(self.page_ids.iter().copied())
            .count(i32::try_from(self.page_ids.len()).unwrap_or(0));
        self.pdf.catalog(self.alloc.bump()).pages(self.page_tree_id);
    }

    fn write_text(&mut self, text: &str, font_size: f32, x: f32, y: f32) {
        self.content.begin_text();
        self.content.next_line(x, y);
        self.content.set_font(self.font_name, font_size);
        self.content.show(Str(text.as_bytes()));
        self.content.end_text();
    }

    fn draw_footer(&mut self) {
        self.write_text(
            &format!("Page {}", self.page_number),
            FOOTER_FONT_SIZE,
            PAGE_WIDTH / 2.0 - PAGE_NUMBER_X_OFFSET,
            FOOTER_Y,
        );
    }

    fn draw_header(&mut self) {
        self.write_text("Tooka Report", TITLE_FONT_SIZE, TITLE_POS_X, TITLE_POS_Y);

        let timestamp = Local::now().format("%Y-%m-%d %H:%M:%S").to_string();
        self.write_text(&timestamp, TIMESTAMP_FONT_SIZE, TIME_POS_X, TIME_POS_Y);

        self.write_text(
            &format!("Total changes: {}", self.total_results),
            FONT_SIZE,
            TITLE_POS_X,
            TITLE_POS_Y - TOTAL_CHANGES_Y_OFFSET,
        );
    }

    fn draw_match_result_block(&mut self, result: &MatchResult, y_start: f32) {
        let from_path =
            PDFGenerator::format_path_with_wrapping(&result.current_path, MAX_PATH_LENGTH);
        let to_path = PDFGenerator::format_path_with_wrapping(&result.new_path, MAX_PATH_LENGTH);

        // Set colors based on action
        let color = match result.action.as_str() {
            "move" => (0.2, 0.4, 0.8),    // Blue-ish
            "copy" => (0.2, 0.7, 0.3),    // Green-ish
            "delete" => (0.85, 0.3, 0.3), // Red-ish
            "rename" => (0.8, 0.6, 0.2),  // Orange-ish
            "execute" => (0.5, 0.2, 0.7), // Purple-ish
            "skip" => (0.6, 0.6, 0.6),    // Grey
            _ => (0.0, 0.0, 0.0),         // Default to black
        };

        self.content.set_fill_rgb(color.0, color.1, color.2);

        // Start with action header at the top of the content area
        let mut current_y = y_start;
        self.write_text(
            &format!("[{}] - {}", result.action, result.file_name),
            ACTION_FONT_SIZE,
            MARGIN_X + CONTENT_INDENT,
            current_y,
        );

        self.content.set_fill_rgb(0.0, 0.0, 0.0); // Reset fill color to black

        // Move down for the paths section
        current_y -= TEXT_SECTION_SPACING;

        // Draw "From:" path
        self.write_text("From:", FONT_SIZE, MARGIN_X + FROM_TO_INDENT, current_y);
        for (i, line) in from_path.iter().enumerate() {
            let x_offset = if i == 0 {
                PATH_VALUE_INDENT
            } else {
                PATH_CONTINUATION_INDENT
            }; // Indent continuation lines
            self.write_text(line, FONT_SIZE, MARGIN_X + x_offset, current_y);
            if i < from_path.len() - 1 {
                current_y -= LINE_HEIGHT; // Move to next line for wrapped text
            }
        }

        current_y -= PATH_SECTION_SPACING; // Space between from and to

        // Draw "To:" path
        self.write_text("To:", FONT_SIZE, MARGIN_X + FROM_TO_INDENT, current_y);
        for (i, line) in to_path.iter().enumerate() {
            let x_offset = if i == 0 {
                PATH_VALUE_INDENT
            } else {
                PATH_CONTINUATION_INDENT
            }; // Indent continuation lines (align with "To:")
            self.write_text(line, FONT_SIZE, MARGIN_X + x_offset, current_y);
            if i < to_path.len() - 1 {
                current_y -= LINE_HEIGHT; // Move to next line for wrapped text
            }
        }
    }

    /// Format a path with intelligent wrapping/truncation
    fn format_path_with_wrapping(path: &Path, max_width: f32) -> Vec<String> {
        let full_path = path.display().to_string();
        let approx_char_width = APPROX_CHAR_WIDTH; // Approximate character width in points for font size 12
        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
        let max_chars_per_line = (max_width / approx_char_width) as usize;

        if full_path.len() <= max_chars_per_line {
            return vec![full_path];
        }

        // Try to break at path separators first
        let mut lines = Vec::new();
        let mut current_line = String::new();

        // Split by path separators and try to fit segments
        let parts: Vec<&str> = full_path.split('/').collect();

        for (i, part) in parts.iter().enumerate() {
            let separator = if i == 0 { "" } else { "/" };
            let potential_addition = format!("{separator}{part}");

            // If adding this part would exceed the line length
            if current_line.len() + potential_addition.len() > max_chars_per_line {
                // If current line is not empty, save it
                if !current_line.is_empty() {
                    lines.push(current_line.clone());
                    current_line.clear();
                }

                // If this single part is too long, truncate it
                if part.len() > max_chars_per_line {
                    let truncated = if part.len() > max_chars_per_line - 3 {
                        format!("{}...", &part[..max_chars_per_line - 3])
                    } else {
                        (*part).to_string()
                    };
                    lines.push(format!("{separator}{truncated}"));
                } else {
                    current_line = potential_addition;
                }
            } else {
                current_line.push_str(&potential_addition);
            }
        }

        // Add the last line if it's not empty
        if !current_line.is_empty() {
            lines.push(current_line);
        }

        // Ensure we don't have too many lines (max lines for readability)
        if lines.len() > MAX_PATH_LINES {
            lines.truncate(MAX_PATH_LINES - 1);
            lines.push("... (path continues)".to_string());
        }

        // If still empty, return a fallback
        if lines.is_empty() {
            lines.push(truncate_path(path, max_width));
        }

        lines
    }

    fn draw_colored_box(
        &mut self,
        x: f32,
        y: f32,
        width: f32,
        height: f32,
        color: (f32, f32, f32),
    ) {
        self.content.set_fill_rgb(color.0, color.1, color.2);

        // Draw rounded rectangle using path operations
        let radius = BOX_CORNER_RADIUS; // Corner radius

        // Start from top-left corner (after the radius)
        self.content.move_to(x + radius, y + height);

        // Top edge
        self.content.line_to(x + width - radius, y + height);
        // Top-right corner (using cubic bezier curve)
        self.content.cubic_to(
            x + width - radius + radius * BEZIER_CONTROL_RATIO,
            y + height,
            x + width,
            y + height - radius + radius * BEZIER_CONTROL_RATIO,
            x + width,
            y + height - radius,
        );

        // Right edge
        self.content.line_to(x + width, y + radius);
        // Bottom-right corner
        self.content.cubic_to(
            x + width,
            y + radius - radius * BEZIER_CONTROL_RATIO,
            x + width - radius + radius * BEZIER_CONTROL_RATIO,
            y,
            x + width - radius,
            y,
        );

        // Bottom edge
        self.content.line_to(x + radius, y);
        // Bottom-left corner
        self.content.cubic_to(
            x + radius - radius * BEZIER_CONTROL_RATIO,
            y,
            x,
            y + radius - radius * BEZIER_CONTROL_RATIO,
            x,
            y + radius,
        );

        // Left edge
        self.content.line_to(x, y + height - radius);
        // Top-left corner
        self.content.cubic_to(
            x,
            y + height - radius + radius * BEZIER_CONTROL_RATIO,
            x + radius - radius * BEZIER_CONTROL_RATIO,
            y + height,
            x + radius,
            y + height,
        );

        self.content.fill_even_odd();
        self.content.set_fill_rgb(0.0, 0.0, 0.0); // Reset fill color to black
    }
}

pub(crate) fn generate_pdf(path: &Path, results: &[MatchResult]) -> Result<(), anyhow::Error> {
    let generator = PDFGenerator::new(results.len());
    generator.generate(path, results)
}

fn truncate_path(path: &Path, max_len: f32) -> String {
    let full = path.display().to_string();
    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
    let max_len_usize = max_len as usize;

    if full.len() <= max_len_usize {
        return full;
    }

    // Try to get parent and filename
    if let (Some(parent), Some(file_name)) = (path.parent(), path.file_name()) {
        let parent_str = parent.file_name().or(Some(parent.as_os_str())).map_or_else(
            || parent.display().to_string(),
            |s| s.to_string_lossy().to_string(),
        );

        return format!(
            "[truncated].../{parent_str}/{}",
            file_name.to_string_lossy()
        );
    }

    // Fallback: just truncate the string with ellipsis
    if full.len() > max_len_usize {
        format!("{}...", &full[..max_len_usize - 3])
    } else {
        full
    }
}