Skip to main content

trusty_memory/service/
helpers.rs

1//! Free helper functions + user-config loading for the trusty-memory service
2//! layer.
3//!
4//! Why: thin no-IO transforms (preview/snippet/recall-entry JSON), palace-stat
5//! aggregation, palace-info enrichment, gaps-cache refresh, and user-config
6//! loading are shared by the HTTP handlers, the chat dispatcher, and the
7//! `MemoryService` core (split out of the former monolithic `service.rs`,
8//! issue #607).
9//! What: the free helpers + `LoadedUserConfig`/`load_user_config` +
10//! `service_result_to_anyhow`, moved verbatim. The service-layer unit tests
11//! live here too (1500-SLOC test cap applies).
12//! Test: `drawer_*`, `recall_entry_*`, and `list_drawers_*` in `service::tests`.
13
14use crate::AppState;
15use anyhow::{anyhow, Context, Result};
16use serde::Deserialize;
17use serde_json::{json, Value};
18use std::collections::HashSet;
19use std::sync::Arc;
20use trusty_common::memory_core::palace::{Palace, PalaceId};
21use trusty_common::memory_core::retrieval::RecallResult;
22use trusty_common::memory_core::PalaceHandle;
23use uuid::Uuid;
24
25use super::types::{PalaceInfo, ServiceResult};
26
27#[cfg(test)]
28use super::core::MemoryService;
29#[cfg(test)]
30use super::types::ListDrawersQuery;
31
32// ---------------------------------------------------------------------------
33// Free helper functions kept module-public so `web.rs` and `chat.rs` can use
34// them without going through the `MemoryService` wrapper. Each is a thin
35// transform (no IO, no global state).
36// ---------------------------------------------------------------------------
37
38/// Maximum characters retained in a drawer's content preview.
39pub const DRAWER_PREVIEW_MAX_CHARS: usize = 80;
40
41/// Maximum characters retained in a drawer-row snippet (issue #202).
42///
43/// Why: the TUI activity panel renders the snippet inline at the end of a
44/// narrow row (`<id> <ts> <creator>  <snippet>`); 60 chars is short
45/// enough to keep the row readable while still showing the key phrase
46/// of most drawers.
47/// What: 60 characters; the trailing `…` from [`drawer_snippet`] counts
48/// against this budget.
49/// Test: `drawer_snippet_truncates_long_content`.
50pub const DRAWER_SNIPPET_MAX_CHARS: usize = 60;
51
52/// Build a single-line preview of drawer content for SSE events.
53///
54/// Why: the activity feed should show *what* was just stored; multiline /
55/// whitespace-heavy bodies otherwise blow out the log row.
56/// What: collapses whitespace, trims, truncates to
57/// [`DRAWER_PREVIEW_MAX_CHARS`] with `…` when cut.
58/// Test: `drawer_preview_collapses_whitespace_and_truncates`.
59pub fn drawer_content_preview(content: &str) -> String {
60    let normalised: String = content.split_whitespace().collect::<Vec<_>>().join(" ");
61    if normalised.chars().count() <= DRAWER_PREVIEW_MAX_CHARS {
62        normalised
63    } else {
64        let kept: String = normalised
65            .chars()
66            .take(DRAWER_PREVIEW_MAX_CHARS.saturating_sub(1))
67            .collect();
68        format!("{kept}…")
69    }
70}
71
72/// Build a short snippet from a drawer's content for the TUI activity panel
73/// row (issue #202).
74///
75/// Why: the activity panel renders one row per drawer at narrow column
76/// width; a 60-char whitespace-collapsed snippet is long enough to convey
77/// the gist but short enough to fit inline with the id / timestamp /
78/// creator columns. Re-using the preview's whitespace-collapse rule keeps
79/// SSE and `/drawers` snippets visually consistent.
80/// What: collapses whitespace, trims, truncates to
81/// [`DRAWER_SNIPPET_MAX_CHARS`] (60) with a trailing `…` when cut.
82/// Returns the empty string for empty / whitespace-only content so the
83/// caller can omit the `snippet` field entirely.
84/// Test: `drawer_snippet_truncates_long_content`,
85/// `drawer_snippet_handles_empty_content`.
86pub fn drawer_snippet(content: &str) -> String {
87    let normalised: String = content.split_whitespace().collect::<Vec<_>>().join(" ");
88    if normalised.chars().count() <= DRAWER_SNIPPET_MAX_CHARS {
89        normalised
90    } else {
91        let kept: String = normalised
92            .chars()
93            .take(DRAWER_SNIPPET_MAX_CHARS.saturating_sub(1))
94            .collect();
95        format!("{kept}…")
96    }
97}
98
99/// Flatten a [`RecallResult`] into a single JSON object with the drawer's
100/// fields hoisted to the top level (issue #69 shape).
101///
102/// Why: clients look for `content`/`tags`/`importance` at the top level of an
103/// entry; nesting under `"drawer"` made recall appear empty.
104/// What: serialises the drawer then inserts `score`/`layer`.
105/// Test: `recall_entry_json_hoists_drawer_fields`.
106pub fn recall_entry_json(r: RecallResult) -> Value {
107    let mut obj = match serde_json::to_value(&r.drawer) {
108        Ok(Value::Object(map)) => map,
109        _ => serde_json::Map::new(),
110    };
111    obj.insert("score".to_string(), json!(r.score));
112    obj.insert("layer".to_string(), json!(r.layer));
113    Value::Object(obj)
114}
115
116/// Reserved-prefix predicate for "system" palaces hidden from user listings.
117///
118/// Why: Issue #185 — the `/health` round-trip writes probe drawers into a
119/// dedicated `__health_probe__` palace. That palace exists on disk but must
120/// never appear in the admin UI, TUI, chat-tool palace roster, or any other
121/// user-facing surface. Centralising the predicate here keeps the convention
122/// (any palace id starting with `__`) in one place so future system palaces
123/// inherit the same hidden-from-users behaviour automatically.
124/// What: Returns `true` iff `id.as_str()` starts with the double-underscore
125/// prefix. Pure function over the id — no I/O, no allocation.
126/// Test: covered indirectly by `health_probe_palace_is_invisible` in
127/// `web::tests` (drives a full `/health` round-trip and asserts the probe
128/// palace does not appear in `MemoryService::list_palaces`).
129pub(crate) fn is_reserved_system_palace(id: &PalaceId) -> bool {
130    id.as_str().starts_with("__")
131}
132
133/// Aggregate counts summed across one or more palaces.
134///
135/// Why (issue #228): both `status()` (the `/api/v1/status` endpoint) and
136/// `aggregate_status_event()` (the SSE `StatusChanged` payload) sum the same
137/// three numbers across every persisted palace. The original implementation
138/// inlined the same `for p in palaces` loop in both methods. Sharing a
139/// single helper eliminates the byte-for-byte duplicate and makes future
140/// changes (e.g. adding a `total_vectors_orphaned` field) land in one place.
141/// What: saturating sums of `drawers.read().len()`, `vector_store.index_size()`,
142/// and `kg.count_active_triples()` across the supplied palace ids.
143/// Test: indirectly via `status_endpoint_returns_payload` and any SSE test
144/// that observes `StatusChanged`.
145pub(crate) struct PalaceStats {
146    pub total_drawers: usize,
147    pub total_vectors: usize,
148    pub total_kg_triples: usize,
149}
150
151/// Sum drawer / vector / KG-triple counts across `ids`, skipping palaces that
152/// cannot be opened.
153///
154/// Why (issue #228): centralises the previously-duplicated loop from
155/// `status()` and `aggregate_status_event()`. Callers pass an iterator of
156/// `PalaceId` so the helper works for both the on-disk view (used by
157/// `status()`) and the in-memory registry view (used by
158/// `aggregate_status_event()` on the SSE hot path).
159/// What: for each id, calls `registry.open_palace` (cheap when the handle is
160/// already cached, slow only on first-ever open) and accumulates the three
161/// counts via `saturating_add` so overflow is impossible. Palaces that fail
162/// to open are silently skipped — one bad palace must not blank the
163/// dashboard.
164/// Test: indirectly through `status_endpoint_returns_payload`.
165pub(crate) fn collect_palace_stats<'a, I>(state: &AppState, ids: I) -> PalaceStats
166where
167    I: IntoIterator<Item = &'a PalaceId>,
168{
169    let (mut total_drawers, mut total_vectors, mut total_kg_triples): (usize, usize, usize) =
170        (0, 0, 0);
171    for id in ids {
172        if let Ok(handle) = state.registry.open_palace(&state.data_root, id) {
173            total_drawers = total_drawers.saturating_add(handle.drawers.read().len());
174            total_vectors = total_vectors.saturating_add(handle.vector_store.index_size());
175            total_kg_triples = total_kg_triples.saturating_add(handle.kg.count_active_triples());
176        }
177    }
178    PalaceStats {
179        total_drawers,
180        total_vectors,
181        total_kg_triples,
182    }
183}
184
185/// Build a `PalaceInfo` from a `Palace` row plus an optional opened handle.
186///
187/// Why: both `list_palaces` and `get_palace` need the same enriched shape;
188/// the helper avoids field-set drift between them.
189/// What: reads drawer/vector/triple counts, distinct rooms, max
190/// `created_at`, KG node/edge/community counts, and the `is_compacting` flag.
191/// Test: `palace_list_includes_richer_counts`, `palace_list_includes_graph_counts`.
192pub fn palace_info_from(palace: &Palace, handle: Option<&Arc<PalaceHandle>>) -> PalaceInfo {
193    let (
194        drawer_count,
195        vector_count,
196        kg_triple_count,
197        wing_count,
198        last_write_at,
199        node_count,
200        edge_count,
201        community_count,
202        is_compacting,
203    ) = if let Some(h) = handle {
204        let drawers = h.drawers.read();
205        let distinct_rooms: HashSet<Uuid> = drawers.iter().map(|d| d.room_id).collect();
206        let last_write = drawers.iter().map(|d| d.created_at).max();
207        (
208            drawers.len(),
209            h.vector_store.index_size(),
210            h.kg.count_active_triples(),
211            distinct_rooms.len(),
212            last_write,
213            h.kg.node_count() as u64,
214            h.kg.edge_count() as u64,
215            h.kg.community_count() as u64,
216            h.is_compacting(),
217        )
218    } else {
219        (0, 0, 0, 0, None, 0, 0, 0, false)
220    };
221    PalaceInfo {
222        id: palace.id.0.clone(),
223        name: palace.name.clone(),
224        description: palace.description.clone(),
225        drawer_count,
226        vector_count,
227        kg_triple_count,
228        wing_count,
229        created_at: palace.created_at,
230        last_write_at,
231        node_count,
232        edge_count,
233        community_count,
234        is_compacting,
235    }
236}
237
238/// Recompute the gaps for `handle` and write them to the registry cache.
239///
240/// Why: the dream-run path needs this post-cycle bookkeeping; pulling it out
241/// of `web.rs` keeps the dream code on one side of the wall.
242/// What: calls `knowledge_gaps()`, optionally enriches via
243/// `enrich_gap_exploration`, stores on `state.registry`. Logs gap count.
244/// Test: indirectly via `kg_gaps_endpoint_returns_cached_gaps`.
245pub async fn refresh_gaps_cache(state: &AppState, handle: &Arc<PalaceHandle>) {
246    let mut gaps = handle.kg.knowledge_gaps();
247    if let Ok(api_key) = std::env::var("OPENROUTER_API_KEY") {
248        if !api_key.is_empty() {
249            for gap in gaps.iter_mut() {
250                if let Some(enriched) = enrich_gap_exploration(&api_key, gap).await {
251                    gap.suggested_exploration = enriched;
252                }
253            }
254        }
255    }
256    let gap_count = gaps.len();
257    state.registry.set_gaps(handle.id.clone(), gaps);
258    tracing::debug!(palace = %handle.id, gaps = gap_count, "community gaps updated");
259}
260
261/// Ask OpenRouter for a focused exploration question for a single gap.
262///
263/// Why: see `refresh_gaps_cache`.
264/// What: builds a short user prompt, calls `openrouter_chat`, returns the
265/// trimmed completion (or `None` on any failure).
266/// Test: network-dependent — not unit-tested.
267pub async fn enrich_gap_exploration(
268    api_key: &str,
269    gap: &trusty_common::memory_core::community::KnowledgeGap,
270) -> Option<String> {
271    let preview: Vec<&str> = gap.entities.iter().take(5).map(String::as_str).collect();
272    if preview.is_empty() {
273        return None;
274    }
275    let entities = preview.join(", ");
276    let user = format!(
277        "Given these related entities from a knowledge graph: {entities}. \
278         Suggest one specific research question (single sentence, under 25 words) \
279         that would help fill gaps in this knowledge cluster. Return only the question."
280    );
281    let messages = vec![trusty_common::ChatMessage {
282        role: "user".to_string(),
283        content: user,
284        tool_call_id: None,
285        tool_calls: None,
286    }];
287    #[allow(deprecated)]
288    let res = trusty_common::openrouter_chat(api_key, "openai/gpt-4o-mini", messages).await;
289    match res {
290        Ok(text) => {
291            let trimmed = text.trim().to_string();
292            if trimmed.is_empty() {
293                None
294            } else {
295                Some(trimmed)
296            }
297        }
298        Err(e) => {
299            tracing::debug!("openrouter gap enrichment failed (using template): {e:#}");
300            None
301        }
302    }
303}
304
305// ---------------------------------------------------------------------------
306// User config — moved from `web.rs` so chat and HTTP both load it cheaply.
307// ---------------------------------------------------------------------------
308
309/// Minimal mirror of the user-config schema.
310#[derive(Deserialize, Default, Clone)]
311struct UserConfigMin {
312    #[serde(default)]
313    openrouter: OpenRouterMin,
314    #[serde(default)]
315    local_model: LocalModelMin,
316}
317
318#[derive(Deserialize, Default, Clone)]
319struct OpenRouterMin {
320    #[serde(default)]
321    api_key: String,
322    #[serde(default)]
323    model: String,
324}
325
326#[derive(Deserialize, Clone)]
327struct LocalModelMin {
328    #[serde(default = "default_local_enabled")]
329    enabled: bool,
330    #[serde(default = "default_local_base_url")]
331    base_url: String,
332    #[serde(default = "default_local_model")]
333    model: String,
334}
335
336fn default_local_enabled() -> bool {
337    true
338}
339fn default_local_base_url() -> String {
340    "http://localhost:11434".to_string()
341}
342fn default_local_model() -> String {
343    "llama3.2".to_string()
344}
345
346impl Default for LocalModelMin {
347    fn default() -> Self {
348        Self {
349            enabled: default_local_enabled(),
350            base_url: default_local_base_url(),
351            model: default_local_model(),
352        }
353    }
354}
355
356/// Loaded user config (mirrors the public `LoadedUserConfig` from `web.rs`).
357#[derive(Clone)]
358pub struct LoadedUserConfig {
359    pub openrouter_api_key: String,
360    pub openrouter_model: String,
361    pub local_model: trusty_common::LocalModelConfig,
362}
363
364impl Default for LoadedUserConfig {
365    fn default() -> Self {
366        Self {
367            openrouter_api_key: String::new(),
368            openrouter_model: "anthropic/claude-3-5-sonnet".to_string(),
369            local_model: trusty_common::LocalModelConfig::default(),
370        }
371    }
372}
373
374/// Read the user's `~/.trusty-memory/config.toml`, falling back to defaults.
375///
376/// Why: shared between HTTP config endpoint, chat tool dispatch, and
377/// provider auto-detection.
378/// What: returns `Some(LoadedUserConfig)` even when the file is missing
379/// (so callers see defaults consistently); `None` only when the home
380/// directory itself can't be resolved.
381/// Test: indirectly via `config_endpoint_returns_payload`.
382pub fn load_user_config() -> Option<LoadedUserConfig> {
383    let home = dirs::home_dir()?;
384    let path = home.join(".trusty-memory").join("config.toml");
385    if !path.exists() {
386        return Some(LoadedUserConfig::default());
387    }
388    let raw = std::fs::read_to_string(&path).ok()?;
389    let parsed: UserConfigMin = toml::from_str(&raw).unwrap_or_default();
390    let model = if parsed.openrouter.model.is_empty() {
391        "anthropic/claude-3-5-sonnet".to_string()
392    } else {
393        parsed.openrouter.model
394    };
395    Some(LoadedUserConfig {
396        openrouter_api_key: parsed.openrouter.api_key,
397        openrouter_model: model,
398        local_model: trusty_common::LocalModelConfig {
399            enabled: parsed.local_model.enabled,
400            base_url: parsed.local_model.base_url,
401            model: parsed.local_model.model,
402        },
403    })
404}
405
406// ---------------------------------------------------------------------------
407// Convenience helpers for callers that want `anyhow::Result<Value>` shape.
408// ---------------------------------------------------------------------------
409
410/// Convert a `ServiceResult<T>` into `anyhow::Result<Value>` using a serializer.
411///
412/// Why: the chat tool dispatcher needs uniform `Result<Value>` returns to
413/// shove into the LLM's `role: "tool"` message.
414/// What: serialises `T` to JSON; on `Err`, returns the message as an
415/// `anyhow::Error`. The HTTP layer does *not* go through this — it preserves
416/// the `ServiceError` variant for status-code mapping.
417/// Test: trivial wrapper; covered indirectly by the chat tests.
418pub fn service_result_to_anyhow<T: serde::Serialize>(r: ServiceResult<T>) -> Result<Value> {
419    match r {
420        Ok(v) => serde_json::to_value(v).context("serialize service result"),
421        Err(e) => Err(anyhow!("{e}")),
422    }
423}
424
425#[cfg(test)]
426mod tests {
427    use super::*;
428    use chrono::{Duration as ChronoDuration, Utc};
429    use trusty_common::memory_core::palace::{Drawer, Palace};
430
431    fn test_state() -> AppState {
432        let tmp = tempfile::tempdir().expect("tempdir");
433        let root = tmp.path().to_path_buf();
434        // Leak the TempDir guard so the directory survives the test body.
435        std::mem::forget(tmp);
436        AppState::new(root)
437    }
438
439    /// Issue #184 — `sort=created_desc` paginates newest-first and the
440    /// importance default is preserved.
441    ///
442    /// Why: the TUI activity panel needs a stable creation-date ordering with
443    /// offset pagination; the legacy importance-desc default must keep
444    /// working for other callers (e.g. chat tool `list_drawers`).
445    /// What: provisions a fresh palace, drops five drawers in with
446    /// monotonically older `created_at` and shuffled importance, then drives
447    /// `MemoryService::list_drawers` with two pages of `limit=2` and asserts
448    /// the order is newest-first across both pages. Re-runs the same call
449    /// with `sort` unset and confirms the order changes (importance-based).
450    /// Test: this test.
451    #[tokio::test]
452    async fn list_drawers_creates_desc_paginates() {
453        let state = test_state();
454        // Provision a fresh palace via the registry.
455        let palace = Palace {
456            id: PalaceId::new("paging-test"),
457            name: "paging-test".to_string(),
458            description: None,
459            created_at: Utc::now(),
460            data_dir: state.data_root.join("paging-test"),
461        };
462        state
463            .registry
464            .create_palace(&state.data_root, palace)
465            .expect("create_palace");
466
467        // Open the handle and seed five drawers with staggered timestamps and
468        // shuffled importance.
469        let handle = state
470            .registry
471            .open_palace(&state.data_root, &PalaceId::new("paging-test"))
472            .expect("open_palace");
473        let room_id = Uuid::nil();
474        let now = Utc::now();
475        // Index 0 is newest; index 4 is oldest.
476        for (i, importance) in [0.1f32, 0.9, 0.3, 0.7, 0.5].iter().enumerate() {
477            let drawer = Drawer {
478                id: Uuid::new_v4(),
479                room_id,
480                content: format!("drawer-{i}"),
481                importance: *importance,
482                source_file: None,
483                created_at: now - ChronoDuration::seconds(i as i64),
484                tags: vec![format!("idx:{i}")],
485                last_accessed_at: None,
486                access_count: 0,
487                drawer_type: Default::default(),
488                expires_at: None,
489                completed_at: None,
490            };
491            handle.add_drawer(drawer);
492        }
493        // The handle is `Arc<PalaceHandle>` and the registry caches it; drop
494        // ours so the service can re-open from cache.
495        drop(handle);
496
497        let service = MemoryService::new(state.clone());
498
499        // Page 1 (newest two) under created_desc — expects idx:0 then idx:1.
500        let page1 = service
501            .list_drawers(
502                "paging-test",
503                ListDrawersQuery {
504                    limit: Some(2),
505                    offset: Some(0),
506                    sort: Some("created_desc".into()),
507                    ..Default::default()
508                },
509            )
510            .await
511            .expect("page 1");
512        let arr = page1.as_array().expect("array");
513        assert_eq!(arr.len(), 2, "page 1 must have 2 rows");
514        assert_eq!(arr[0]["content"].as_str(), Some("drawer-0"));
515        assert_eq!(arr[1]["content"].as_str(), Some("drawer-1"));
516
517        // Page 2 — expects idx:2 then idx:3.
518        let page2 = service
519            .list_drawers(
520                "paging-test",
521                ListDrawersQuery {
522                    limit: Some(2),
523                    offset: Some(2),
524                    sort: Some("created_desc".into()),
525                    ..Default::default()
526                },
527            )
528            .await
529            .expect("page 2");
530        let arr = page2.as_array().expect("array");
531        assert_eq!(arr.len(), 2, "page 2 must have 2 rows");
532        assert_eq!(arr[0]["content"].as_str(), Some("drawer-2"));
533        assert_eq!(arr[1]["content"].as_str(), Some("drawer-3"));
534
535        // Page 3 — expects idx:4 alone.
536        let page3 = service
537            .list_drawers(
538                "paging-test",
539                ListDrawersQuery {
540                    limit: Some(2),
541                    offset: Some(4),
542                    sort: Some("created_desc".into()),
543                    ..Default::default()
544                },
545            )
546            .await
547            .expect("page 3");
548        let arr = page3.as_array().expect("array");
549        assert_eq!(arr.len(), 1, "page 3 (tail) must have 1 row");
550        assert_eq!(arr[0]["content"].as_str(), Some("drawer-4"));
551
552        // Importance-desc default — first row is the highest-importance
553        // drawer (idx:1 had importance 0.9), confirming we did not break
554        // the legacy callers.
555        let legacy = service
556            .list_drawers(
557                "paging-test",
558                ListDrawersQuery {
559                    limit: Some(1),
560                    ..Default::default()
561                },
562            )
563            .await
564            .expect("legacy");
565        let arr = legacy.as_array().expect("array");
566        assert_eq!(arr.len(), 1);
567        assert_eq!(
568            arr[0]["content"].as_str(),
569            Some("drawer-1"),
570            "importance default should surface drawer with importance 0.9 first",
571        );
572
573        // Issue #202: every row carries an enriched `snippet` field
574        // derived from the drawer body so the TUI activity panel can
575        // render a glanceable summary without re-parsing.
576        assert_eq!(
577            arr[0]["snippet"].as_str(),
578            Some("drawer-1"),
579            "snippet must be populated for non-empty drawer content",
580        );
581    }
582
583    /// Why: issue #202 — the snippet helper must collapse whitespace,
584    /// trim, and cap at [`DRAWER_SNIPPET_MAX_CHARS`] with a trailing `…`
585    /// when the body overflows, matching the SSE preview's shape but at
586    /// a tighter width.
587    /// What: feeds a multiline / whitespace-heavy body and asserts both
588    /// the truncation and the collapse rule.
589    /// Test: itself.
590    #[test]
591    fn drawer_snippet_truncates_long_content() {
592        // Short content round-trips verbatim.
593        assert_eq!(drawer_snippet("hello world"), "hello world");
594
595        // Whitespace is collapsed.
596        assert_eq!(
597            drawer_snippet("first line\n\nsecond\tline   third"),
598            "first line second line third",
599        );
600
601        // Padding is trimmed.
602        assert_eq!(drawer_snippet("   padded   "), "padded");
603
604        // A body longer than the cap is truncated and ends with `…`.
605        let long = "a".repeat(200);
606        let snippet = drawer_snippet(&long);
607        assert_eq!(snippet.chars().count(), DRAWER_SNIPPET_MAX_CHARS);
608        assert!(
609            snippet.ends_with('…'),
610            "long body must be truncated with ellipsis",
611        );
612
613        // A body sized exactly at the cap is preserved verbatim.
614        let exact = "a".repeat(DRAWER_SNIPPET_MAX_CHARS);
615        assert_eq!(drawer_snippet(&exact), exact);
616    }
617
618    /// Why: empty / whitespace-only bodies must produce an empty
619    /// snippet so the `list_drawers` shaper can omit the `snippet`
620    /// field (rendered as `null` on the wire) instead of an empty
621    /// string. The TUI relies on this distinction to skip the snippet
622    /// column entirely when the body has no usable preview.
623    /// What: feeds empty and whitespace-only strings.
624    /// Test: itself.
625    #[test]
626    fn drawer_snippet_handles_empty_content() {
627        assert_eq!(drawer_snippet(""), "");
628        assert_eq!(drawer_snippet("   \n\t  "), "");
629    }
630}