zoe_app_primitives/
qr.rs

1use qrcode::QrCode;
2use qrcode::render::unicode;
3use serde::Serialize;
4
5/// Error types for QR code operations
6#[derive(Debug, thiserror::Error)]
7pub enum QrError {
8    #[error("Serialization failed: {0}")]
9    Serialization(#[from] postcard::Error),
10
11    #[error("QR code generation failed: {0}")]
12    QrGeneration(#[from] qrcode::types::QrError),
13}
14
15/// Result type for QR code operations
16pub type QrResult<T> = Result<T, QrError>;
17
18/// QR code generation options
19#[derive(Debug, Clone)]
20pub struct QrOptions {
21    /// Title to display above the QR code
22    pub title: String,
23
24    /// Subtitle lines to display below the title
25    pub subtitle_lines: Vec<String>,
26
27    /// Footer message to display below the QR code
28    pub footer: String,
29
30    /// Width of the display border (in characters)
31    pub border_width: usize,
32}
33
34impl Default for QrOptions {
35    fn default() -> Self {
36        Self {
37            title: "QR CODE".to_string(),
38            subtitle_lines: Vec::new(),
39            footer: "Scan to connect".to_string(),
40            border_width: 60,
41        }
42    }
43}
44
45impl QrOptions {
46    /// Create new QR options with a title
47    pub fn new(title: impl Into<String>) -> Self {
48        Self {
49            title: title.into(),
50            ..Default::default()
51        }
52    }
53
54    /// Add a subtitle line
55    pub fn with_subtitle(mut self, subtitle: impl Into<String>) -> Self {
56        self.subtitle_lines.push(subtitle.into());
57        self
58    }
59
60    /// Set the footer message
61    pub fn with_footer(mut self, footer: impl Into<String>) -> Self {
62        self.footer = footer.into();
63        self
64    }
65
66    /// Set the border width
67    pub fn with_border_width(mut self, width: usize) -> Self {
68        self.border_width = width;
69        self
70    }
71}
72
73/// Generate binary data from any postcard-serializable data for QR code encoding
74///
75/// This function serializes the data using postcard and returns the binary data
76/// directly for QR code generation.
77///
78/// # Arguments
79/// * `data` - The data to encode (must implement Serialize)
80///
81/// # Returns
82/// * `QrResult<Vec<u8>>` - The binary data that can be used to generate the QR code
83///
84/// # Examples
85/// ```
86/// use zoe_app_primitives::qr::generate_qr_data;
87/// use serde::Serialize;
88///
89/// #[derive(Serialize)]
90/// struct MyData {
91///     message: String,
92/// }
93///
94/// let data = MyData { message: "Hello, World!".to_string() };
95/// let qr_data = generate_qr_data(&data).unwrap();
96/// ```
97pub fn generate_qr_data<T: Serialize>(data: &T) -> QrResult<Vec<u8>> {
98    // Serialize the data using postcard - return binary data directly
99    let serialized = postcard::to_stdvec(data)?;
100    Ok(serialized)
101}
102
103/// Generate a QR code and return the visual representation as a string from structured data
104///
105/// This function creates a QR code from structured data using postcard serialization
106/// and returns it as a string that can be printed to the console.
107///
108/// # Arguments
109/// * `data` - The data to encode (must implement Serialize)
110/// * `options` - Display options for the QR code
111///
112/// # Returns
113/// * `QrResult<String>` - The QR code as a printable string
114///
115/// # Examples
116/// ```
117/// use zoe_app_primitives::qr::{generate_qr_string, QrOptions};
118/// use serde::Serialize;
119///
120/// #[derive(Serialize)]
121/// struct MyData {
122///     message: String,
123/// }
124///
125/// let data = MyData { message: "Hello, World!".to_string() };
126/// let options = QrOptions::new("My QR Code").with_footer("Scan me!");
127/// let qr_string = generate_qr_string(&data, &options).unwrap();
128/// println!("{}", qr_string);
129/// ```
130pub fn generate_qr_string<T: Serialize>(data: &T, options: &QrOptions) -> QrResult<String> {
131    let qr_data = generate_qr_data(data)?;
132    let qr_code = QrCode::new(&qr_data[..])?;
133
134    let image = qr_code
135        .render::<unicode::Dense1x2>()
136        .dark_color(unicode::Dense1x2::Light)
137        .light_color(unicode::Dense1x2::Dark)
138        .build();
139
140    let mut result = String::new();
141    let border_char = "─";
142    let border = border_char.repeat(options.border_width);
143
144    // Top border
145    result.push_str(&format!("┌{border}┐\n"));
146
147    // Title
148    result.push_str(&format!(
149        "│{:^width$}│\n",
150        options.title,
151        width = options.border_width
152    ));
153
154    // Subtitle lines
155    if !options.subtitle_lines.is_empty() {
156        result.push_str(&format!("├{border}┤\n"));
157        for subtitle in &options.subtitle_lines {
158            result.push_str(&format!(
159                "│{:^width$}│\n",
160                subtitle,
161                width = options.border_width
162            ));
163        }
164    }
165
166    // Separator before QR code
167    result.push_str(&format!("├{border}┤\n"));
168
169    // QR code
170    for line in image.lines() {
171        // Calculate the visual width of the QR line (Unicode characters may have different widths)
172        let line_visual_width = line.chars().count();
173
174        // Calculate padding needed to center the QR code within the border
175        let total_padding = options.border_width.saturating_sub(line_visual_width);
176        let left_padding = total_padding / 2;
177        let right_padding = total_padding - left_padding;
178
179        // Create the padded line with proper spacing
180        let padded_line = format!(
181            "{}{}{}",
182            " ".repeat(left_padding),
183            line,
184            " ".repeat(right_padding)
185        );
186
187        result.push_str(&format!("│{padded_line}│\n"));
188    }
189
190    // Separator after QR code
191    result.push_str(&format!("├{border}┤\n"));
192
193    // Footer
194    result.push_str(&format!(
195        "│{:^width$}│\n",
196        options.footer,
197        width = options.border_width
198    ));
199
200    // Bottom border
201    result.push_str(&format!("└{border}┘"));
202
203    Ok(result)
204}
205
206/// Generate a QR code from plain text and return the visual representation as a string
207///
208/// This function creates a QR code from plain text (like URLs) without serialization
209/// and returns it as a string that can be printed to the console.
210///
211/// # Arguments
212/// * `text` - The plain text to encode in the QR code
213/// * `options` - Display options for the QR code
214///
215/// # Returns
216/// * `QrResult<String>` - The QR code as a printable string
217///
218/// # Examples
219/// ```
220/// use zoe_app_primitives::qr::{generate_qr_string_from_text, QrOptions};
221///
222/// let url = "https://signal.org/#eu_EQIlw-O0NmftmVoqUlKZiwlqcTMG0ybgChE8XQtjn2WSHw";
223/// let options = QrOptions::new("📱 SIGNAL LINKING").with_footer("Scan with Signal app");
224/// let qr_string = generate_qr_string_from_text(url, &options).unwrap();
225/// println!("{}", qr_string);
226/// ```
227pub fn generate_qr_string_from_text(text: &str, options: &QrOptions) -> QrResult<String> {
228    let qr_code = QrCode::new(text)?;
229
230    let image = qr_code
231        .render::<unicode::Dense1x2>()
232        .dark_color(unicode::Dense1x2::Light)
233        .light_color(unicode::Dense1x2::Dark)
234        .build();
235
236    let mut result = String::new();
237    let border_char = "─";
238    let border = border_char.repeat(options.border_width);
239
240    // Top border
241    result.push_str(&format!("┌{border}┐\n"));
242
243    // Title
244    result.push_str(&format!(
245        "│{:^width$}│\n",
246        options.title,
247        width = options.border_width
248    ));
249
250    // Subtitle lines
251    if !options.subtitle_lines.is_empty() {
252        result.push_str(&format!("├{border}┤\n"));
253        for subtitle in &options.subtitle_lines {
254            result.push_str(&format!(
255                "│{:^width$}│\n",
256                subtitle,
257                width = options.border_width
258            ));
259        }
260    }
261
262    // Separator before QR code
263    result.push_str(&format!("├{border}┤\n"));
264
265    // QR code
266    for line in image.lines() {
267        // Calculate the visual width of the QR line (Unicode characters may have different widths)
268        let line_visual_width = line.chars().count();
269
270        // Calculate padding needed to center the QR code within the border
271        let total_padding = options.border_width.saturating_sub(line_visual_width);
272        let left_padding = total_padding / 2;
273        let right_padding = total_padding - left_padding;
274
275        // Create the padded line with proper spacing
276        let padded_line = format!(
277            "{}{}{}",
278            " ".repeat(left_padding),
279            line,
280            " ".repeat(right_padding)
281        );
282
283        result.push_str(&format!("│{padded_line}│\n"));
284    }
285
286    // Separator after QR code
287    result.push_str(&format!("├{border}┤\n"));
288
289    // Footer
290    result.push_str(&format!(
291        "│{:^width$}│\n",
292        options.footer,
293        width = options.border_width
294    ));
295
296    // Bottom border
297    result.push_str(&format!("└{border}┘"));
298
299    Ok(result)
300}
301
302/// Display a QR code to stdout from structured data
303///
304/// This function generates a QR code from structured data using postcard serialization.
305///
306/// # Arguments
307/// * `data` - The data to encode (must implement Serialize)
308/// * `options` - Display options for the QR code
309///
310/// # Returns
311/// * `QrResult<()>` - Success or error
312///
313/// # Examples
314/// ```
315/// use zoe_app_primitives::qr::{display_qr_code, QrOptions};
316/// use serde::Serialize;
317///
318/// #[derive(Serialize)]
319/// struct MyData {
320///     message: String,
321/// }
322///
323/// let data = MyData { message: "Hello, World!".to_string() };
324/// let options = QrOptions::new("My QR Code").with_footer("Scan me!");
325/// display_qr_code(&data, &options).unwrap();
326/// ```
327pub fn display_qr_code<T: Serialize>(data: &T, options: &QrOptions) -> QrResult<()> {
328    let qr_string = generate_qr_string(data, options)?;
329    println!("{qr_string}");
330    Ok(())
331}
332
333/// Display a QR code to stdout from a plain string
334///
335/// This function generates a QR code from a plain string (like URLs) without serialization.
336/// Use this for simple text data like URLs, connection strings, etc.
337///
338/// # Arguments
339/// * `text` - The plain text to encode in the QR code
340/// * `options` - Display options for the QR code
341///
342/// # Returns
343/// * `QrResult<()>` - Success or error
344///
345/// # Examples
346/// ```
347/// use zoe_app_primitives::qr::{display_qr_code_from_string, QrOptions};
348///
349/// let url = "https://signal.org/#eu_EQIlw-O0NmftmVoqUlKZiwlqcTMG0ybgChE8XQtjn2WSHw";
350/// let options = QrOptions::new("📱 SIGNAL LINKING")
351///     .with_subtitle("Scan with Signal mobile app")
352///     .with_footer("Link expires in 10 minutes");
353/// display_qr_code_from_string(url, &options).unwrap();
354/// ```
355pub fn display_qr_code_from_string(text: &str, options: &QrOptions) -> QrResult<()> {
356    let qr_string = generate_qr_string_from_text(text, options)?;
357    println!("{qr_string}");
358    Ok(())
359}
360
361#[cfg(test)]
362mod tests {
363    use super::*;
364    use serde::{Deserialize, Serialize};
365
366    #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
367    struct TestData {
368        message: String,
369        number: u32,
370    }
371
372    #[test]
373    fn test_generate_qr_data() {
374        let data = TestData {
375            message: "Hello, World!".to_string(),
376            number: 42,
377        };
378
379        let qr_data = generate_qr_data(&data).unwrap();
380
381        // Verify we can decode the binary data back directly
382        let decoded_data: TestData = postcard::from_bytes(&qr_data).unwrap();
383
384        assert_eq!(data, decoded_data);
385    }
386
387    #[test]
388    fn test_generate_qr_string() {
389        let data = TestData {
390            message: "Test".to_string(),
391            number: 123,
392        };
393
394        let options = QrOptions::new("Test QR Code")
395            .with_subtitle("Subtitle line")
396            .with_footer("Test footer");
397
398        let qr_string = generate_qr_string(&data, &options).unwrap();
399
400        // Verify the string contains expected elements
401        assert!(qr_string.contains("Test QR Code"));
402        assert!(qr_string.contains("Subtitle line"));
403        assert!(qr_string.contains("Test footer"));
404        assert!(qr_string.contains("┌"));
405        assert!(qr_string.contains("└"));
406    }
407
408    #[test]
409    fn test_qr_options_builder() {
410        let options = QrOptions::new("My Title")
411            .with_subtitle("Line 1")
412            .with_subtitle("Line 2")
413            .with_footer("My Footer")
414            .with_border_width(80);
415
416        assert_eq!(options.title, "My Title");
417        assert_eq!(options.subtitle_lines, vec!["Line 1", "Line 2"]);
418        assert_eq!(options.footer, "My Footer");
419        assert_eq!(options.border_width, 80);
420    }
421
422    #[test]
423    fn test_qr_options_default() {
424        let options = QrOptions::default();
425
426        assert_eq!(options.title, "QR CODE");
427        assert!(options.subtitle_lines.is_empty());
428        assert_eq!(options.footer, "Scan to connect");
429        assert_eq!(options.border_width, 60);
430    }
431    #[test]
432    fn test_empty_data_serialization() {
433        #[derive(Serialize, Deserialize)]
434        struct EmptyData;
435
436        let data = EmptyData;
437        let qr_data = generate_qr_data(&data).unwrap();
438
439        // Empty structs in postcard serialize to empty bytes
440        // This is correct behavior - empty data should produce empty binary data
441        assert_eq!(qr_data, Vec::<u8>::new());
442
443        // Verify we can still decode empty data back
444        let _decoded_data: EmptyData = postcard::from_bytes(&qr_data).unwrap();
445    }
446
447    #[test]
448    fn test_large_data_serialization() {
449        let data = TestData {
450            message: "A".repeat(1000), // Large string
451            number: u32::MAX,
452        };
453
454        let qr_data = generate_qr_data(&data).unwrap();
455
456        // Verify we can still decode large data directly from binary
457        let decoded_data: TestData = postcard::from_bytes(&qr_data).unwrap();
458
459        assert_eq!(data, decoded_data);
460    }
461
462    #[test]
463    fn test_qr_string_formatting() {
464        let data = TestData {
465            message: "Format test".to_string(),
466            number: 789,
467        };
468
469        let options = QrOptions::new("📱 TITLE")
470            .with_subtitle("Address: 192.168.1.1:8080")
471            .with_subtitle("Key: abc123...")
472            .with_footer("Scan with app to connect")
473            .with_border_width(50);
474
475        let qr_string = generate_qr_string(&data, &options).unwrap();
476
477        // Check that all components are present and properly formatted
478        let lines: Vec<&str> = qr_string.lines().collect();
479
480        // Should have top border
481        assert!(lines[0].starts_with("┌"));
482        assert!(lines[0].ends_with("┐"));
483
484        // Should have title
485        assert!(lines[1].contains("📱 TITLE"));
486
487        // Should have subtitles
488        assert!(
489            lines
490                .iter()
491                .any(|line| line.contains("Address: 192.168.1.1:8080"))
492        );
493        assert!(lines.iter().any(|line| line.contains("Key: abc123...")));
494
495        // Should have footer
496        assert!(
497            lines
498                .iter()
499                .any(|line| line.contains("Scan with app to connect"))
500        );
501
502        // Should have bottom border
503        let last_line = lines.last().unwrap();
504        assert!(last_line.starts_with("└"));
505        assert!(last_line.ends_with("┘"));
506    }
507
508    #[test]
509    fn test_qr_line_formatting_consistency() {
510        let data = TestData {
511            message: "Test".to_string(),
512            number: 42,
513        };
514
515        let options = QrOptions::new("TEST").with_border_width(60);
516
517        let qr_string = generate_qr_string(&data, &options).unwrap();
518        let lines: Vec<&str> = qr_string.lines().collect();
519
520        for (i, line) in lines.iter().enumerate() {
521            // Each line should start and end with border characters
522            assert!(
523                line.starts_with('│')
524                    || line.starts_with('┌')
525                    || line.starts_with('├')
526                    || line.starts_with('└'),
527                "Line {i} should start with border character: '{line}'"
528            );
529            assert!(
530                line.ends_with('│')
531                    || line.ends_with('┐')
532                    || line.ends_with('┤')
533                    || line.ends_with('┘'),
534                "Line {i} should end with border character: '{line}'"
535            );
536
537            // Check that QR code lines are properly formatted
538            if line.starts_with('│') && !line.contains("TEST") && !line.contains("Scan to connect")
539            {
540                // This should be a QR code line - verify it's properly padded
541                // Extract content by removing the first and last characters (the │ borders)
542                let chars: Vec<char> = line.chars().collect();
543                if chars.len() >= 2 {
544                    let content: String = chars[1..chars.len() - 1].iter().collect();
545                    assert_eq!(
546                        content.chars().count(),
547                        options.border_width,
548                        "QR line {} content should be exactly {} characters: '{}'",
549                        i,
550                        options.border_width,
551                        content
552                    );
553                }
554            }
555        }
556    }
557
558    #[test]
559    fn test_qr_centering_with_different_border_widths() {
560        let data = TestData {
561            message: "Centering test".to_string(),
562            number: 999,
563        };
564
565        // Test with various border widths
566        for border_width in [40, 50, 60, 70, 80] {
567            let options = QrOptions::new("CENTERING TEST").with_border_width(border_width);
568
569            let qr_string = generate_qr_string(&data, &options).unwrap();
570            let lines: Vec<&str> = qr_string.lines().collect();
571
572            // Find QR code lines and verify they're properly centered
573            for line in &lines {
574                if line.starts_with('│')
575                    && !line.contains("CENTERING TEST")
576                    && !line.contains("Scan to connect")
577                {
578                    let chars: Vec<char> = line.chars().collect();
579                    if chars.len() >= 2 {
580                        let content: String = chars[1..chars.len() - 1].iter().collect();
581                        assert_eq!(
582                            content.chars().count(),
583                            border_width,
584                            "Content width should match border_width {border_width} for line: '{content}'"
585                        );
586
587                        // Verify the QR code is centered by checking that leading and trailing spaces are balanced
588                        let trimmed = content.trim();
589                        if !trimmed.is_empty() {
590                            let leading_spaces = content.len() - content.trim_start().len();
591                            let trailing_spaces = content.len() - content.trim_end().len();
592                            // Allow for ±1 difference due to odd/even width differences
593                            assert!(
594                                (leading_spaces as i32 - trailing_spaces as i32).abs() <= 1,
595                                "QR code should be centered: leading={leading_spaces}, trailing={trailing_spaces}, content='{content}'"
596                            );
597                        }
598                    }
599                }
600            }
601        }
602    }
603
604    #[test]
605    fn test_qr_visual_output_sample() {
606        let data = TestData {
607            message: "Visual test".to_string(),
608            number: 12345,
609        };
610
611        let options = QrOptions::new("📡 ZOE RELAY SERVER")
612            .with_subtitle("Bind: 127.0.0.1:13908")
613            .with_subtitle("Key: 75cbe0f409466428...")
614            .with_footer("Scan with Zoe client to connect")
615            .with_border_width(60);
616
617        let qr_string = generate_qr_string(&data, &options).unwrap();
618
619        // Print the QR code for visual inspection during development
620        // This helps ensure the output looks correct
621        println!("Generated QR code:");
622        println!("{qr_string}");
623
624        // Verify structure
625        let lines: Vec<&str> = qr_string.lines().collect();
626        assert!(!lines.is_empty(), "QR string should have content");
627
628        // Should contain all expected text elements
629        let full_text = qr_string;
630        assert!(full_text.contains("📡 ZOE RELAY SERVER"));
631        assert!(full_text.contains("Bind: 127.0.0.1:13908"));
632        assert!(full_text.contains("Key: 75cbe0f409466428..."));
633        assert!(full_text.contains("Scan with Zoe client to connect"));
634
635        // Should have proper border structure
636        assert!(full_text.contains("┌"));
637        assert!(full_text.contains("└"));
638        assert!(full_text.contains("├"));
639        assert!(full_text.contains("┤"));
640    }
641
642    #[test]
643    fn test_generate_qr_string_from_text() {
644        let url = "https://signal.org/#eu_EQIlw-O0NmftmVoqUlKZiwlqcTMG0ybgChE8XQtjn2WSHw";
645        let options = QrOptions::new("📱 SIGNAL LINKING")
646            .with_subtitle("Scan with Signal mobile app")
647            .with_footer("Link expires in 10 minutes");
648
649        let qr_string = generate_qr_string_from_text(url, &options).unwrap();
650
651        // Verify the string contains expected elements
652        assert!(qr_string.contains("📱 SIGNAL LINKING"));
653        assert!(qr_string.contains("Scan with Signal mobile app"));
654        assert!(qr_string.contains("Link expires in 10 minutes"));
655        assert!(qr_string.contains("┌"));
656        assert!(qr_string.contains("└"));
657    }
658
659    #[test]
660    fn test_display_qr_code_from_string() {
661        let url = "https://example.com/test";
662        let options = QrOptions::new("TEST QR")
663            .with_subtitle("Test subtitle")
664            .with_footer("Test footer");
665
666        // This should not panic and should work correctly
667        let result = display_qr_code_from_string(url, &options);
668        assert!(result.is_ok());
669    }
670
671    #[test]
672    fn test_qr_string_from_text_formatting() {
673        let text = "Simple test text";
674        let options = QrOptions::new("📱 TEXT QR")
675            .with_subtitle("Line 1")
676            .with_subtitle("Line 2")
677            .with_footer("Footer text")
678            .with_border_width(50);
679
680        let qr_string = generate_qr_string_from_text(text, &options).unwrap();
681        let lines: Vec<&str> = qr_string.lines().collect();
682
683        // Should have top border
684        assert!(lines[0].starts_with("┌"));
685        assert!(lines[0].ends_with("┐"));
686
687        // Should have title
688        assert!(lines[1].contains("📱 TEXT QR"));
689
690        // Should have subtitles
691        assert!(lines.iter().any(|line| line.contains("Line 1")));
692        assert!(lines.iter().any(|line| line.contains("Line 2")));
693
694        // Should have footer
695        assert!(lines.iter().any(|line| line.contains("Footer text")));
696
697        // Should have bottom border
698        let last_line = lines.last().unwrap();
699        assert!(last_line.starts_with("└"));
700        assert!(last_line.ends_with("┘"));
701    }
702}