Skip to main content

aperture_cli/
logging.rs

1//! Request and response logging utilities with automatic secret redaction.
2//!
3//! This module provides logging capabilities for HTTP requests and responses,
4//! with built-in automatic redaction of sensitive information including:
5//! - Authorization headers
6//! - API keys in query parameters
7//! - Values matching configured `x-aperture-secret` environment variables
8
9use crate::cache::models::CachedSpec;
10use crate::config::models::GlobalConfig;
11use tracing::{debug, info, trace};
12
13/// Minimum length for a secret to be redacted in body content.
14/// Shorter secrets might cause false positives in legitimate content.
15const MIN_SECRET_LENGTH_FOR_BODY_REDACTION: usize = 8;
16
17/// Context containing resolved secret values for dynamic redaction.
18///
19/// This struct collects actual secret values from environment variables
20/// referenced by `x-aperture-secret` extensions and config-based secrets,
21/// allowing them to be redacted from logs wherever they appear.
22#[derive(Debug, Default, Clone)]
23pub struct SecretContext {
24    /// Resolved secret values that should be redacted
25    secrets: Vec<String>,
26}
27
28/// Collects non-empty secret values from spec's security schemes.
29fn collect_secrets_from_spec(spec: &CachedSpec, secrets: &mut Vec<String>) {
30    for scheme in spec.security_schemes.values() {
31        let Some(ref aperture_secret) = scheme.aperture_secret else {
32            continue;
33        };
34        let Ok(value) = std::env::var(&aperture_secret.name) else {
35            continue;
36        };
37        if !value.is_empty() {
38            secrets.push(value);
39        }
40    }
41}
42
43/// Collects non-empty secret values from config-based secrets.
44fn collect_secrets_from_config(
45    global_config: Option<&GlobalConfig>,
46    api_name: &str,
47    secrets: &mut Vec<String>,
48) {
49    let Some(config) = global_config else {
50        return;
51    };
52    let Some(api_config) = config.api_configs.get(api_name) else {
53        return;
54    };
55    for secret in api_config.secrets.values() {
56        let Ok(value) = std::env::var(&secret.name) else {
57            continue;
58        };
59        if !value.is_empty() {
60            secrets.push(value);
61        }
62    }
63}
64
65impl SecretContext {
66    /// Creates an empty `SecretContext` with no secrets to redact.
67    #[must_use]
68    pub fn empty() -> Self {
69        Self::default()
70    }
71
72    /// Creates a `SecretContext` by collecting secrets from the spec and config.
73    ///
74    /// This resolves environment variables referenced by:
75    /// 1. `x-aperture-secret` extensions in the `OpenAPI` spec's security schemes
76    /// 2. Config-based secrets in the global configuration
77    ///
78    /// # Arguments
79    /// * `spec` - The cached API specification containing security schemes
80    /// * `api_name` - The name of the API (used to look up config-based secrets)
81    /// * `global_config` - Optional global configuration with config-based secrets
82    #[must_use]
83    pub fn from_spec_and_config(
84        spec: &CachedSpec,
85        api_name: &str,
86        global_config: Option<&GlobalConfig>,
87    ) -> Self {
88        let mut secrets = Vec::new();
89
90        // Collect secrets from x-aperture-secret extensions in security schemes
91        collect_secrets_from_spec(spec, &mut secrets);
92
93        // Collect secrets from config-based secrets
94        collect_secrets_from_config(global_config, api_name, &mut secrets);
95
96        // Remove duplicates while preserving order
97        secrets.sort();
98        secrets.dedup();
99
100        Self { secrets }
101    }
102
103    /// Checks if a value exactly matches any of the secrets.
104    #[must_use]
105    pub fn is_secret(&self, value: &str) -> bool {
106        self.secrets.iter().any(|s| s == value)
107    }
108
109    /// Redacts all occurrences of secrets in the given text.
110    ///
111    /// Only redacts secrets that are at least `MIN_SECRET_LENGTH_FOR_BODY_REDACTION`
112    /// characters long to avoid false positives with short values.
113    #[must_use]
114    pub fn redact_secrets_in_text(&self, text: &str) -> String {
115        let mut result = text.to_string();
116        for secret in &self.secrets {
117            if secret.len() >= MIN_SECRET_LENGTH_FOR_BODY_REDACTION {
118                result = result.replace(secret, "[REDACTED]");
119            }
120        }
121        result
122    }
123
124    /// Returns true if this context has any secrets to redact.
125    #[must_use]
126    pub const fn has_secrets(&self) -> bool {
127        !self.secrets.is_empty()
128    }
129}
130
131/// Returns the canonical status text for an HTTP status code
132#[must_use]
133const fn http_status_text(status: u16) -> &'static str {
134    match status {
135        // 2xx Success
136        200 => "OK",
137        201 => "Created",
138        202 => "Accepted",
139        204 => "No Content",
140        // 3xx Redirection
141        301 => "Moved Permanently",
142        302 => "Found",
143        304 => "Not Modified",
144        307 => "Temporary Redirect",
145        308 => "Permanent Redirect",
146        // 4xx Client Error
147        400 => "Bad Request",
148        401 => "Unauthorized",
149        403 => "Forbidden",
150        404 => "Not Found",
151        405 => "Method Not Allowed",
152        409 => "Conflict",
153        410 => "Gone",
154        422 => "Unprocessable Entity",
155        429 => "Too Many Requests",
156        // 5xx Server Error
157        500 => "Internal Server Error",
158        501 => "Not Implemented",
159        502 => "Bad Gateway",
160        503 => "Service Unavailable",
161        504 => "Gateway Timeout",
162        // Default fallback
163        _ => "",
164    }
165}
166
167/// Redacts sensitive values from strings
168#[must_use]
169pub fn redact_sensitive_value(value: &str) -> String {
170    if value.is_empty() {
171        value.to_string()
172    } else {
173        "[REDACTED]".to_string()
174    }
175}
176
177/// Checks if a header name should be redacted.
178///
179/// This is the single source of truth for sensitive header identification.
180/// Used by both logging and request building to ensure consistent redaction.
181#[must_use]
182pub fn should_redact_header(header_name: &str) -> bool {
183    let lower = header_name.to_lowercase();
184    matches!(
185        lower.as_str(),
186        // Standard authentication headers
187        "authorization"
188            | "proxy-authorization"
189            // API key variants
190            | "x-api-key"
191            | "x-api-token"
192            | "api-key"
193            | "api_key"
194            // Auth token variants
195            | "x-access-token"
196            | "x-auth-token"
197            | "x-secret-token"
198            // Generic sensitive headers
199            | "token"
200            | "secret"
201            | "password"
202            // Webhook secrets
203            | "x-webhook-secret"
204            // Session/cookie headers
205            | "cookie"
206            | "set-cookie"
207            // CSRF tokens
208            | "x-csrf-token"
209            | "x-xsrf-token"
210            // Cloud provider tokens
211            | "x-amz-security-token"
212            // Platform-specific tokens
213            | "private-token"
214    )
215}
216
217/// Checks if a query parameter name should be redacted
218#[must_use]
219fn should_redact_query_param(param_name: &str) -> bool {
220    let lower = param_name.to_lowercase();
221    matches!(
222        lower.as_str(),
223        // API key variants
224        "api_key"
225            | "apikey"
226            | "api-key"
227            | "key"
228            // Token variants
229            | "token"
230            | "access_token"
231            | "accesstoken"
232            | "auth_token"
233            | "authtoken"
234            | "bearer_token"
235            | "refresh_token"
236            // Secret variants
237            | "secret"
238            | "api_secret"
239            | "client_secret"
240            // Password variants
241            | "password"
242            | "passwd"
243            | "pwd"
244            // Signature variants
245            | "signature"
246            | "sig"
247            // Session IDs
248            | "session_id"
249            | "sessionid"
250            // Other common sensitive params
251            | "auth"
252            | "authorization"
253            | "credentials"
254    )
255}
256
257/// Redacts sensitive query parameters from a URL
258///
259/// Returns the URL with sensitive parameter values replaced with `[REDACTED]`.
260#[must_use]
261pub fn redact_url_query_params(url: &str) -> String {
262    // Find the query string start
263    let Some(query_start) = url.find('?') else {
264        return url.to_string();
265    };
266
267    let base_url = &url[..query_start];
268    let query_string = &url[query_start + 1..];
269
270    // Handle fragment if present
271    let (query_part, fragment) =
272        query_string
273            .find('#')
274            .map_or((query_string, None), |frag_start| {
275                (
276                    &query_string[..frag_start],
277                    Some(&query_string[frag_start..]),
278                )
279            });
280
281    // Process each query parameter
282    let redacted_params: Vec<String> = query_part
283        .split('&')
284        .map(|param| {
285            param.find('=').map_or_else(
286                || param.to_string(),
287                |eq_pos| {
288                    let name = &param[..eq_pos];
289                    if should_redact_query_param(name) {
290                        format!("{name}=[REDACTED]")
291                    } else {
292                        param.to_string()
293                    }
294                },
295            )
296        })
297        .collect();
298
299    let mut result = format!("{base_url}?{}", redacted_params.join("&"));
300    if let Some(frag) = fragment {
301        result.push_str(frag);
302    }
303    result
304}
305
306/// Logs an HTTP request with optional headers and body
307///
308/// # Arguments
309/// * `method` - HTTP method (GET, POST, etc.)
310/// * `url` - Request URL (sensitive query params will be redacted)
311/// * `headers` - Optional request headers (sensitive headers will be redacted)
312/// * `body` - Optional request body
313/// * `secret_ctx` - Optional context for dynamic secret redaction
314pub fn log_request(
315    method: &str,
316    url: &str,
317    headers: Option<&reqwest::header::HeaderMap>,
318    body: Option<&str>,
319    secret_ctx: Option<&SecretContext>,
320) {
321    // Redact sensitive query parameters from URL before logging
322    let redacted_url = redact_url_query_params(url);
323
324    // Log at info level: method, URL, and duration (duration added by caller)
325    info!(
326        target: "aperture::executor",
327        "→ {} {}",
328        method.to_uppercase(),
329        redacted_url
330    );
331
332    // Log headers at debug level
333    let Some(header_map) = headers else {
334        if let Some(body_content) = body {
335            let redacted_body = secret_ctx.map_or_else(
336                || body_content.to_string(),
337                |ctx| ctx.redact_secrets_in_text(body_content),
338            );
339            trace!(
340                target: "aperture::executor",
341                "Request body: {}",
342                redacted_body
343            );
344        }
345        return;
346    };
347
348    debug!(
349        target: "aperture::executor",
350        "Request headers:"
351    );
352    for (name, value) in header_map {
353        let header_str = name.as_str();
354        let raw_value = String::from_utf8_lossy(value.as_bytes()).to_string();
355        let display_value = redact_header_value(header_str, &raw_value, secret_ctx);
356        debug!(
357            target: "aperture::executor",
358            "  {}: {}",
359            header_str,
360            display_value
361        );
362    }
363
364    // Log body at trace level
365    if let Some(body_content) = body {
366        let redacted_body = secret_ctx.map_or_else(
367            || body_content.to_string(),
368            |ctx| ctx.redact_secrets_in_text(body_content),
369        );
370        trace!(
371            target: "aperture::executor",
372            "Request body: {}",
373            redacted_body
374        );
375    }
376}
377
378/// Redacts a header value based on static rules and dynamic secret context.
379fn redact_header_value(
380    header_name: &str,
381    value: &str,
382    secret_ctx: Option<&SecretContext>,
383) -> String {
384    // Always redact known sensitive headers
385    if should_redact_header(header_name) {
386        return "[REDACTED]".to_string();
387    }
388
389    // Check if the value matches a dynamic secret
390    let is_dynamic_secret = secret_ctx.is_some_and(|ctx| ctx.is_secret(value));
391    if is_dynamic_secret {
392        return "[REDACTED]".to_string();
393    }
394
395    value.to_string()
396}
397
398/// Logs an HTTP response with optional headers and body
399///
400/// # Arguments
401/// * `status` - HTTP status code
402/// * `duration_ms` - Request duration in milliseconds
403/// * `headers` - Optional response headers (sensitive headers will be redacted)
404/// * `body` - Optional response body
405/// * `max_body_len` - Maximum body length to log before truncation
406/// * `secret_ctx` - Optional context for dynamic secret redaction
407pub fn log_response(
408    status: u16,
409    duration_ms: u128,
410    headers: Option<&reqwest::header::HeaderMap>,
411    body: Option<&str>,
412    max_body_len: usize,
413    secret_ctx: Option<&SecretContext>,
414) {
415    // Log at info level: status and duration
416    let status_text = http_status_text(status);
417    info!(
418        target: "aperture::executor",
419        "← {} {} ({}ms)",
420        status,
421        status_text,
422        duration_ms
423    );
424
425    // Log headers at debug level
426    let Some(header_map) = headers else {
427        log_response_body(body, max_body_len, secret_ctx);
428        return;
429    };
430
431    debug!(
432        target: "aperture::executor",
433        "Response headers:"
434    );
435    for (name, value) in header_map {
436        let header_str = name.as_str();
437        let raw_value = String::from_utf8_lossy(value.as_bytes()).to_string();
438        let display_value = redact_header_value(header_str, &raw_value, secret_ctx);
439        debug!(
440            target: "aperture::executor",
441            "  {}: {}",
442            header_str,
443            display_value
444        );
445    }
446
447    // Log body at trace level with truncation
448    log_response_body(body, max_body_len, secret_ctx);
449}
450
451/// Truncates a string to at most `max_chars` characters, ensuring we don't
452/// split in the middle of a multi-byte UTF-8 character.
453fn truncate_string(s: &str, max_chars: usize) -> &str {
454    match s.char_indices().nth(max_chars) {
455        Some((byte_idx, _)) => &s[..byte_idx],
456        None => s, // String is shorter than max_chars
457    }
458}
459
460/// Helper function to log response body with truncation
461fn log_response_body(body: Option<&str>, max_body_len: usize, secret_ctx: Option<&SecretContext>) {
462    let Some(body_content) = body else {
463        return;
464    };
465
466    // Redact secrets in body before logging
467    let redacted_body = secret_ctx.map_or_else(
468        || body_content.to_string(),
469        |ctx| ctx.redact_secrets_in_text(body_content),
470    );
471
472    // Check character count, not byte length, for truncation
473    let char_count = redacted_body.chars().count();
474    if char_count > max_body_len {
475        let truncated = truncate_string(&redacted_body, max_body_len);
476        trace!(
477            target: "aperture::executor",
478            "Response body: {} (truncated at {} chars)",
479            truncated,
480            max_body_len
481        );
482    } else {
483        trace!(
484            target: "aperture::executor",
485            "Response body: {}",
486            redacted_body
487        );
488    }
489}
490
491/// Gets the maximum body length from `APERTURE_LOG_MAX_BODY` environment variable
492#[must_use]
493pub fn get_max_body_len() -> usize {
494    std::env::var("APERTURE_LOG_MAX_BODY")
495        .ok()
496        .and_then(|s| s.parse::<usize>().ok())
497        .unwrap_or(1000)
498}
499
500#[cfg(test)]
501mod tests {
502    use super::*;
503
504    #[test]
505    fn test_should_redact_header_authorization() {
506        assert!(should_redact_header("Authorization"));
507        assert!(should_redact_header("AUTHORIZATION"));
508        assert!(should_redact_header("authorization"));
509    }
510
511    #[test]
512    fn test_should_redact_header_api_key_variants() {
513        assert!(should_redact_header("X-API-Key"));
514        assert!(should_redact_header("X-Api-Key"));
515        assert!(should_redact_header("api-key"));
516        assert!(should_redact_header("API_KEY"));
517        assert!(should_redact_header("api_key"));
518    }
519
520    #[test]
521    fn test_should_redact_proxy_authorization() {
522        assert!(should_redact_header("Proxy-Authorization"));
523        assert!(should_redact_header("proxy-authorization"));
524    }
525
526    #[test]
527    fn test_should_redact_session_headers() {
528        assert!(should_redact_header("Cookie"));
529        assert!(should_redact_header("Set-Cookie"));
530        assert!(should_redact_header("cookie"));
531        assert!(should_redact_header("set-cookie"));
532    }
533
534    #[test]
535    fn test_should_redact_csrf_tokens() {
536        assert!(should_redact_header("X-CSRF-Token"));
537        assert!(should_redact_header("X-XSRF-Token"));
538        assert!(should_redact_header("x-csrf-token"));
539        assert!(should_redact_header("x-xsrf-token"));
540    }
541
542    #[test]
543    fn test_should_redact_cloud_tokens() {
544        assert!(should_redact_header("X-Amz-Security-Token"));
545        assert!(should_redact_header("x-amz-security-token"));
546        assert!(should_redact_header("Private-Token"));
547        assert!(should_redact_header("private-token"));
548    }
549
550    #[test]
551    fn test_should_not_redact_regular_header() {
552        assert!(!should_redact_header("Content-Type"));
553        assert!(!should_redact_header("User-Agent"));
554        assert!(!should_redact_header("Accept"));
555        assert!(!should_redact_header("Cache-Control"));
556        assert!(!should_redact_header("X-Request-Id"));
557    }
558
559    #[test]
560    fn test_redact_sensitive_value() {
561        assert_eq!(redact_sensitive_value("secret123"), "[REDACTED]");
562        assert_eq!(redact_sensitive_value(""), "");
563    }
564
565    // Note: Environment variable tests for get_max_body_len have been moved
566    // to logging_integration_tests.rs to avoid race conditions when tests
567    // run in parallel. Unit tests here should not depend on env vars.
568
569    #[test]
570    fn test_http_status_text() {
571        // Success codes
572        assert_eq!(http_status_text(200), "OK");
573        assert_eq!(http_status_text(201), "Created");
574        assert_eq!(http_status_text(204), "No Content");
575
576        // Client error codes
577        assert_eq!(http_status_text(400), "Bad Request");
578        assert_eq!(http_status_text(401), "Unauthorized");
579        assert_eq!(http_status_text(403), "Forbidden");
580        assert_eq!(http_status_text(404), "Not Found");
581        assert_eq!(http_status_text(429), "Too Many Requests");
582
583        // Server error codes
584        assert_eq!(http_status_text(500), "Internal Server Error");
585        assert_eq!(http_status_text(502), "Bad Gateway");
586        assert_eq!(http_status_text(503), "Service Unavailable");
587
588        // Unknown codes return empty string
589        assert_eq!(http_status_text(999), "");
590    }
591
592    #[test]
593    fn test_should_redact_query_param() {
594        // API key variants
595        assert!(should_redact_query_param("api_key"));
596        assert!(should_redact_query_param("apikey"));
597        assert!(should_redact_query_param("API_KEY"));
598        assert!(should_redact_query_param("key"));
599
600        // Token variants
601        assert!(should_redact_query_param("token"));
602        assert!(should_redact_query_param("access_token"));
603        assert!(should_redact_query_param("auth_token"));
604
605        // Secret variants
606        assert!(should_redact_query_param("secret"));
607        assert!(should_redact_query_param("client_secret"));
608
609        // Password variants
610        assert!(should_redact_query_param("password"));
611
612        // Non-sensitive params
613        assert!(!should_redact_query_param("page"));
614        assert!(!should_redact_query_param("limit"));
615        assert!(!should_redact_query_param("id"));
616        assert!(!should_redact_query_param("filter"));
617    }
618
619    #[test]
620    fn test_redact_url_query_params_with_api_key() {
621        let url = "https://api.example.com/users?api_key=secret123&page=1";
622        let redacted = redact_url_query_params(url);
623        assert_eq!(
624            redacted,
625            "https://api.example.com/users?api_key=[REDACTED]&page=1"
626        );
627    }
628
629    #[test]
630    fn test_redact_url_query_params_multiple_sensitive() {
631        let url = "https://api.example.com/auth?token=abc123&secret=xyz789&user=john";
632        let redacted = redact_url_query_params(url);
633        assert_eq!(
634            redacted,
635            "https://api.example.com/auth?token=[REDACTED]&secret=[REDACTED]&user=john"
636        );
637    }
638
639    #[test]
640    fn test_redact_url_query_params_no_query_string() {
641        let url = "https://api.example.com/users";
642        let redacted = redact_url_query_params(url);
643        assert_eq!(redacted, "https://api.example.com/users");
644    }
645
646    #[test]
647    fn test_redact_url_query_params_with_fragment() {
648        let url = "https://api.example.com/users?api_key=secret123#section";
649        let redacted = redact_url_query_params(url);
650        assert_eq!(
651            redacted,
652            "https://api.example.com/users?api_key=[REDACTED]#section"
653        );
654    }
655
656    #[test]
657    fn test_redact_url_query_params_empty_value() {
658        let url = "https://api.example.com/users?api_key=&page=1";
659        let redacted = redact_url_query_params(url);
660        assert_eq!(
661            redacted,
662            "https://api.example.com/users?api_key=[REDACTED]&page=1"
663        );
664    }
665
666    #[test]
667    fn test_redact_url_query_params_no_sensitive() {
668        let url = "https://api.example.com/users?page=1&limit=10";
669        let redacted = redact_url_query_params(url);
670        assert_eq!(redacted, "https://api.example.com/users?page=1&limit=10");
671    }
672
673    // SecretContext tests
674
675    #[test]
676    fn test_secret_context_empty() {
677        let ctx = SecretContext::empty();
678        assert!(!ctx.has_secrets());
679        assert!(!ctx.is_secret("any_value"));
680    }
681
682    #[test]
683    fn test_secret_context_is_secret() {
684        let mut ctx = SecretContext::empty();
685        ctx.secrets = vec!["my_secret_token".to_string()];
686
687        assert!(ctx.has_secrets());
688        assert!(ctx.is_secret("my_secret_token"));
689        assert!(!ctx.is_secret("other_value"));
690    }
691
692    #[test]
693    fn test_secret_context_redact_secrets_in_text() {
694        let mut ctx = SecretContext::empty();
695        ctx.secrets = vec!["secret123abc".to_string()]; // 12 chars, above minimum
696
697        let text = "The token is secret123abc and should be hidden";
698        let redacted = ctx.redact_secrets_in_text(text);
699        assert_eq!(redacted, "The token is [REDACTED] and should be hidden");
700    }
701
702    #[test]
703    fn test_secret_context_short_secrets_not_redacted_in_body() {
704        let mut ctx = SecretContext::empty();
705        ctx.secrets = vec!["short".to_string()]; // 5 chars, below minimum
706
707        let text = "This text contains short word";
708        let redacted = ctx.redact_secrets_in_text(text);
709        // Short secrets should not be redacted in body to avoid false positives
710        assert_eq!(redacted, "This text contains short word");
711    }
712
713    #[test]
714    fn test_secret_context_multiple_secrets() {
715        let mut ctx = SecretContext::empty();
716        ctx.secrets = vec![
717            "first_secret_value".to_string(),
718            "second_secret_val".to_string(),
719        ];
720
721        let text = "first_secret_value and second_secret_val are both here";
722        let redacted = ctx.redact_secrets_in_text(text);
723        assert_eq!(redacted, "[REDACTED] and [REDACTED] are both here");
724    }
725
726    #[test]
727    fn test_redact_header_value_known_header() {
728        // Known sensitive headers are always redacted regardless of context
729        let result = redact_header_value("Authorization", "Bearer token123", None);
730        assert_eq!(result, "[REDACTED]");
731    }
732
733    #[test]
734    fn test_redact_header_value_dynamic_secret() {
735        let mut ctx = SecretContext::empty();
736        ctx.secrets = vec!["my_api_key_12345".to_string()];
737
738        // Unknown header but value matches a dynamic secret
739        let result = redact_header_value("X-Custom-Header", "my_api_key_12345", Some(&ctx));
740        assert_eq!(result, "[REDACTED]");
741    }
742
743    #[test]
744    fn test_redact_header_value_no_match() {
745        let ctx = SecretContext::empty();
746
747        // Unknown header, no secret match
748        let result = redact_header_value("X-Custom-Header", "some_value", Some(&ctx));
749        assert_eq!(result, "some_value");
750    }
751
752    #[test]
753    fn test_truncate_string_ascii() {
754        let text = "Hello, World!";
755        assert_eq!(truncate_string(text, 5), "Hello");
756        assert_eq!(truncate_string(text, 100), "Hello, World!");
757        assert_eq!(truncate_string(text, 0), "");
758    }
759
760    #[test]
761    fn test_truncate_string_unicode() {
762        // Japanese text: "こんにちは世界" (7 characters, 21 bytes)
763        let text = "こんにちは世界";
764        assert_eq!(truncate_string(text, 3), "こんに");
765        assert_eq!(truncate_string(text, 7), "こんにちは世界");
766        assert_eq!(truncate_string(text, 100), "こんにちは世界");
767    }
768
769    #[test]
770    fn test_truncate_string_emoji() {
771        // Emoji can be multiple bytes
772        let text = "Hello 👋🌍!";
773        assert_eq!(truncate_string(text, 6), "Hello ");
774        assert_eq!(truncate_string(text, 7), "Hello 👋");
775        assert_eq!(truncate_string(text, 8), "Hello 👋🌍");
776    }
777}