1use qrcode::QrCode;
2use qrcode::render::unicode;
3use serde::Serialize;
4
5#[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
15pub type QrResult<T> = Result<T, QrError>;
17
18#[derive(Debug, Clone)]
20pub struct QrOptions {
21 pub title: String,
23
24 pub subtitle_lines: Vec<String>,
26
27 pub footer: String,
29
30 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 pub fn new(title: impl Into<String>) -> Self {
48 Self {
49 title: title.into(),
50 ..Default::default()
51 }
52 }
53
54 pub fn with_subtitle(mut self, subtitle: impl Into<String>) -> Self {
56 self.subtitle_lines.push(subtitle.into());
57 self
58 }
59
60 pub fn with_footer(mut self, footer: impl Into<String>) -> Self {
62 self.footer = footer.into();
63 self
64 }
65
66 pub fn with_border_width(mut self, width: usize) -> Self {
68 self.border_width = width;
69 self
70 }
71}
72
73pub fn generate_qr_data<T: Serialize>(data: &T) -> QrResult<Vec<u8>> {
98 let serialized = postcard::to_stdvec(data)?;
100 Ok(serialized)
101}
102
103pub 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 result.push_str(&format!("┌{border}┐\n"));
146
147 result.push_str(&format!(
149 "│{:^width$}│\n",
150 options.title,
151 width = options.border_width
152 ));
153
154 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 result.push_str(&format!("├{border}┤\n"));
168
169 for line in image.lines() {
171 let line_visual_width = line.chars().count();
173
174 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 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 result.push_str(&format!("├{border}┤\n"));
192
193 result.push_str(&format!(
195 "│{:^width$}│\n",
196 options.footer,
197 width = options.border_width
198 ));
199
200 result.push_str(&format!("└{border}┘"));
202
203 Ok(result)
204}
205
206pub 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 result.push_str(&format!("┌{border}┐\n"));
242
243 result.push_str(&format!(
245 "│{:^width$}│\n",
246 options.title,
247 width = options.border_width
248 ));
249
250 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 result.push_str(&format!("├{border}┤\n"));
264
265 for line in image.lines() {
267 let line_visual_width = line.chars().count();
269
270 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 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 result.push_str(&format!("├{border}┤\n"));
288
289 result.push_str(&format!(
291 "│{:^width$}│\n",
292 options.footer,
293 width = options.border_width
294 ));
295
296 result.push_str(&format!("└{border}┘"));
298
299 Ok(result)
300}
301
302pub 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
333pub 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 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 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 assert_eq!(qr_data, Vec::<u8>::new());
442
443 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), number: u32::MAX,
452 };
453
454 let qr_data = generate_qr_data(&data).unwrap();
455
456 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 let lines: Vec<&str> = qr_string.lines().collect();
479
480 assert!(lines[0].starts_with("┌"));
482 assert!(lines[0].ends_with("┐"));
483
484 assert!(lines[1].contains("📱 TITLE"));
486
487 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 assert!(
497 lines
498 .iter()
499 .any(|line| line.contains("Scan with app to connect"))
500 );
501
502 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 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 if line.starts_with('│') && !line.contains("TEST") && !line.contains("Scan to connect")
539 {
540 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 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 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 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 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 println!("Generated QR code:");
622 println!("{qr_string}");
623
624 let lines: Vec<&str> = qr_string.lines().collect();
626 assert!(!lines.is_empty(), "QR string should have content");
627
628 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 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 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 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 assert!(lines[0].starts_with("┌"));
685 assert!(lines[0].ends_with("┐"));
686
687 assert!(lines[1].contains("📱 TEXT QR"));
689
690 assert!(lines.iter().any(|line| line.contains("Line 1")));
692 assert!(lines.iter().any(|line| line.contains("Line 2")));
693
694 assert!(lines.iter().any(|line| line.contains("Footer text")));
696
697 let last_line = lines.last().unwrap();
699 assert!(last_line.starts_with("└"));
700 assert!(last_line.ends_with("┘"));
701 }
702}