Skip to main content

auths_core/pairing/
qr.rs

1//! QR code generation for pairing tokens.
2
3use qrcode::QrCode;
4use qrcode::render::unicode;
5
6use super::PairingError;
7use auths_pairing_protocol::PairingToken;
8
9/// QR code rendering options.
10#[derive(Debug, Clone)]
11pub struct QrOptions {
12    /// Use compact rendering (half-block characters).
13    pub compact: bool,
14    /// Quiet zone (border) size in modules.
15    pub quiet_zone: u32,
16    /// Invert colors (light on dark).
17    pub invert: bool,
18}
19
20impl Default for QrOptions {
21    fn default() -> Self {
22        Self {
23            compact: true,
24            quiet_zone: 1,
25            invert: false,
26        }
27    }
28}
29
30/// Render a pairing token as a QR code string for terminal display.
31pub fn render_qr(token: &PairingToken, options: &QrOptions) -> Result<String, PairingError> {
32    let uri = token.to_uri();
33    render_qr_from_data(&uri, options)
34}
35
36/// Render arbitrary data as a QR code string for terminal display.
37pub fn render_qr_from_data(data: &str, options: &QrOptions) -> Result<String, PairingError> {
38    let code =
39        QrCode::new(data.as_bytes()).map_err(|e| PairingError::QrCodeFailed(e.to_string()))?;
40
41    let qr_string = if options.compact {
42        // Use half-block characters for compact rendering
43        let (dark, light) = if options.invert {
44            (unicode::Dense1x2::Light, unicode::Dense1x2::Dark)
45        } else {
46            (unicode::Dense1x2::Dark, unicode::Dense1x2::Light)
47        };
48
49        code.render::<unicode::Dense1x2>()
50            .dark_color(dark)
51            .light_color(light)
52            .quiet_zone(options.quiet_zone > 0)
53            .build()
54    } else {
55        // Use full-block characters
56        let (dark, light) = if options.invert {
57            (' ', '\u{2588}')
58        } else {
59            ('\u{2588}', ' ')
60        };
61
62        code.render::<char>()
63            .dark_color(dark)
64            .light_color(light)
65            .quiet_zone(options.quiet_zone > 0)
66            .build()
67    };
68
69    Ok(qr_string)
70}
71
72/// Format a pairing QR code with header text for terminal display.
73///
74/// Returns the complete formatted output as a String. The caller is
75/// responsible for printing it.
76pub fn format_pairing_qr(token: &PairingToken) -> Result<String, PairingError> {
77    let options = QrOptions::default();
78    let qr = render_qr(token, &options)?;
79
80    let mut output = String::new();
81    output.push('\n');
82    output.push_str("Scan this QR code with your other device:\n");
83    output.push('\n');
84    output.push_str(&qr);
85    output.push('\n');
86    output.push('\n');
87    output.push_str(&format!(
88        "Or enter this code manually: {}\n",
89        token.short_code
90    ));
91    output.push('\n');
92    output.push_str(&format!("Controller: {}\n", token.controller_did));
93    if !token.capabilities.is_empty() {
94        output.push_str(&format!(
95            "Capabilities: {}\n",
96            token
97                .capabilities
98                .iter()
99                .map(|c| c.as_str())
100                .collect::<Vec<_>>()
101                .join(", ")
102        ));
103    }
104    output.push_str(&format!(
105        "Expires: {}\n",
106        token.expires_at.format("%H:%M:%S")
107    ));
108    output.push('\n');
109
110    Ok(output)
111}
112
113#[cfg(test)]
114#[allow(clippy::disallowed_methods)]
115mod tests {
116    use super::*;
117
118    fn make_token() -> PairingToken {
119        PairingToken::generate(
120            chrono::Utc::now(),
121            "did:keri:test123".to_string(),
122            "http://localhost:3000".to_string(),
123            vec![auths_keri::Capability::sign_commit()],
124        )
125        .unwrap()
126        .token
127    }
128
129    #[test]
130    fn test_render_qr() {
131        let token = make_token();
132        let options = QrOptions::default();
133
134        let qr = render_qr(&token, &options).unwrap();
135        assert!(!qr.is_empty());
136        // Should contain unicode block characters
137        assert!(qr.contains('\u{2580}') || qr.contains('\u{2584}') || qr.contains('\u{2588}'));
138    }
139
140    #[test]
141    fn test_render_qr_inverted() {
142        let token = make_token();
143        let options = QrOptions {
144            invert: true,
145            ..Default::default()
146        };
147
148        let qr = render_qr(&token, &options).unwrap();
149        assert!(!qr.is_empty());
150    }
151
152    #[test]
153    fn test_render_qr_non_compact() {
154        let token = make_token();
155        let options = QrOptions {
156            compact: false,
157            ..Default::default()
158        };
159
160        let qr = render_qr(&token, &options).unwrap();
161        assert!(!qr.is_empty());
162    }
163}