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 };
490 handle.add_drawer(drawer);
491 }
492 // The handle is `Arc<PalaceHandle>` and the registry caches it; drop
493 // ours so the service can re-open from cache.
494 drop(handle);
495
496 let service = MemoryService::new(state.clone());
497
498 // Page 1 (newest two) under created_desc — expects idx:0 then idx:1.
499 let page1 = service
500 .list_drawers(
501 "paging-test",
502 ListDrawersQuery {
503 limit: Some(2),
504 offset: Some(0),
505 sort: Some("created_desc".into()),
506 ..Default::default()
507 },
508 )
509 .await
510 .expect("page 1");
511 let arr = page1.as_array().expect("array");
512 assert_eq!(arr.len(), 2, "page 1 must have 2 rows");
513 assert_eq!(arr[0]["content"].as_str(), Some("drawer-0"));
514 assert_eq!(arr[1]["content"].as_str(), Some("drawer-1"));
515
516 // Page 2 — expects idx:2 then idx:3.
517 let page2 = service
518 .list_drawers(
519 "paging-test",
520 ListDrawersQuery {
521 limit: Some(2),
522 offset: Some(2),
523 sort: Some("created_desc".into()),
524 ..Default::default()
525 },
526 )
527 .await
528 .expect("page 2");
529 let arr = page2.as_array().expect("array");
530 assert_eq!(arr.len(), 2, "page 2 must have 2 rows");
531 assert_eq!(arr[0]["content"].as_str(), Some("drawer-2"));
532 assert_eq!(arr[1]["content"].as_str(), Some("drawer-3"));
533
534 // Page 3 — expects idx:4 alone.
535 let page3 = service
536 .list_drawers(
537 "paging-test",
538 ListDrawersQuery {
539 limit: Some(2),
540 offset: Some(4),
541 sort: Some("created_desc".into()),
542 ..Default::default()
543 },
544 )
545 .await
546 .expect("page 3");
547 let arr = page3.as_array().expect("array");
548 assert_eq!(arr.len(), 1, "page 3 (tail) must have 1 row");
549 assert_eq!(arr[0]["content"].as_str(), Some("drawer-4"));
550
551 // Importance-desc default — first row is the highest-importance
552 // drawer (idx:1 had importance 0.9), confirming we did not break
553 // the legacy callers.
554 let legacy = service
555 .list_drawers(
556 "paging-test",
557 ListDrawersQuery {
558 limit: Some(1),
559 ..Default::default()
560 },
561 )
562 .await
563 .expect("legacy");
564 let arr = legacy.as_array().expect("array");
565 assert_eq!(arr.len(), 1);
566 assert_eq!(
567 arr[0]["content"].as_str(),
568 Some("drawer-1"),
569 "importance default should surface drawer with importance 0.9 first",
570 );
571
572 // Issue #202: every row carries an enriched `snippet` field
573 // derived from the drawer body so the TUI activity panel can
574 // render a glanceable summary without re-parsing.
575 assert_eq!(
576 arr[0]["snippet"].as_str(),
577 Some("drawer-1"),
578 "snippet must be populated for non-empty drawer content",
579 );
580 }
581
582 /// Why: issue #202 — the snippet helper must collapse whitespace,
583 /// trim, and cap at [`DRAWER_SNIPPET_MAX_CHARS`] with a trailing `…`
584 /// when the body overflows, matching the SSE preview's shape but at
585 /// a tighter width.
586 /// What: feeds a multiline / whitespace-heavy body and asserts both
587 /// the truncation and the collapse rule.
588 /// Test: itself.
589 #[test]
590 fn drawer_snippet_truncates_long_content() {
591 // Short content round-trips verbatim.
592 assert_eq!(drawer_snippet("hello world"), "hello world");
593
594 // Whitespace is collapsed.
595 assert_eq!(
596 drawer_snippet("first line\n\nsecond\tline third"),
597 "first line second line third",
598 );
599
600 // Padding is trimmed.
601 assert_eq!(drawer_snippet(" padded "), "padded");
602
603 // A body longer than the cap is truncated and ends with `…`.
604 let long = "a".repeat(200);
605 let snippet = drawer_snippet(&long);
606 assert_eq!(snippet.chars().count(), DRAWER_SNIPPET_MAX_CHARS);
607 assert!(
608 snippet.ends_with('…'),
609 "long body must be truncated with ellipsis",
610 );
611
612 // A body sized exactly at the cap is preserved verbatim.
613 let exact = "a".repeat(DRAWER_SNIPPET_MAX_CHARS);
614 assert_eq!(drawer_snippet(&exact), exact);
615 }
616
617 /// Why: empty / whitespace-only bodies must produce an empty
618 /// snippet so the `list_drawers` shaper can omit the `snippet`
619 /// field (rendered as `null` on the wire) instead of an empty
620 /// string. The TUI relies on this distinction to skip the snippet
621 /// column entirely when the body has no usable preview.
622 /// What: feeds empty and whitespace-only strings.
623 /// Test: itself.
624 #[test]
625 fn drawer_snippet_handles_empty_content() {
626 assert_eq!(drawer_snippet(""), "");
627 assert_eq!(drawer_snippet(" \n\t "), "");
628 }
629}