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}