trusty-common 0.39.0

Shared utilities and provider-agnostic streaming chat (ChatProvider, OllamaProvider, OpenRouter, tool-use) for trusty-* projects
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
//! Unit tests for the trusty-memory client module.
//!
//! Why: live endpoints are covered by the trusty-memory daemon suite;
//! these tests cover URL helpers and all JSON-projection functions
//! without requiring a running daemon.
//! What: unit tests for `normalize_url`, `resolve_memory_url`,
//! `MemoryClient` construction, and all `parse_*` / `creator_label`
//! functions.
//! Test: this file is the test coverage.

#[cfg(test)]
#[allow(clippy::module_inception)]
mod tests {
    use super::super::client::MemoryClient;
    use super::super::parsers::{
        creator_label, parse_drawers, parse_dream_stats, parse_memory_details, parse_memory_event,
        parse_palace_detail, parse_palaces, parse_recall_hits,
    };
    use super::super::types::{DEFAULT_MEMORY_URL, normalize_url, resolve_memory_url};
    use super::super::types::{
        DRAWER_SNIPPET_FALLBACK_MAX, DreamStats, MemoryEvent, NO_CREATOR_LABEL,
    };

    #[test]
    fn default_memory_url_is_local() {
        assert!(DEFAULT_MEMORY_URL.starts_with("http://127.0.0.1"));
    }

    #[test]
    fn normalize_url_adds_scheme() {
        assert_eq!(normalize_url("127.0.0.1:7070"), "http://127.0.0.1:7070");
        assert_eq!(
            normalize_url("http://127.0.0.1:7070"),
            "http://127.0.0.1:7070"
        );
    }

    #[test]
    fn memory_client_stores_base_url() {
        let client = MemoryClient::new("http://127.0.0.1:7070");
        assert_eq!(client.base_url(), "http://127.0.0.1:7070");
    }

    #[test]
    fn memory_client_repoints() {
        let mut client = MemoryClient::new("http://127.0.0.1:7070");
        client.set_base_url("http://127.0.0.1:8080");
        assert_eq!(client.base_url(), "http://127.0.0.1:8080");
    }

    #[test]
    fn resolve_memory_url_returns_http_url() {
        let url = resolve_memory_url();
        assert!(url.starts_with("http://") || url.starts_with("https://"));
    }

    #[test]
    fn palace_list_accepts_array_and_object_shapes() {
        // Bare-array shape.
        let arr = serde_json::json!([
            {"id": "p1", "name": "default", "vector_count": 8400},
            {"id": "p2", "name": "work", "vectors": 0},
        ]);
        let rows = parse_palaces(&arr);
        assert_eq!(rows.len(), 2);
        assert_eq!(rows[0].id, "p1");
        assert_eq!(rows[0].vector_count, 8400);
        // The `vectors` alias is honoured.
        assert_eq!(rows[1].name, "work");

        // Object-wrapped shape.
        let obj = serde_json::json!({
            "palaces": [{"id": "p3", "name": "notes", "total_vectors": 12}],
        });
        let rows = parse_palaces(&obj);
        assert_eq!(rows.len(), 1);
        assert_eq!(rows[0].vector_count, 12);

        // An unexpected shape yields no rows rather than panicking.
        assert!(parse_palaces(&serde_json::json!("nonsense")).is_empty());
    }

    /// Why (issue #4682): since #4640 the bulk list route returns `cached:
    /// false` plus all-zero counts for any palace whose handle is not
    /// resident — 2,180 of 2,183 rows on a live daemon. Projecting those zeros
    /// as measurements is what made the /ui header read `0 drawers` above a
    /// "Drawers (1)" list.
    /// What: asserts an uncached row is flagged `counts_unknown` and every
    /// accessor returns `None`, while a `cached: true` row keeps real counts.
    /// Test: this test.
    #[test]
    fn parse_palaces_marks_uncached_rows_unknown() {
        let raw = serde_json::json!([
            {"id": "cold", "name": "cold", "vector_count": 0, "drawer_count": 0,
             "kg_triple_count": 0, "node_count": 0, "edge_count": 0, "cached": false},
            {"id": "warm", "name": "warm", "vector_count": 912, "drawer_count": 38,
             "kg_triple_count": 122, "node_count": 9, "edge_count": 8, "cached": true},
        ]);
        let rows = parse_palaces(&raw);
        assert_eq!(rows.len(), 2);

        let cold = &rows[0];
        assert!(cold.counts_unknown, "cached:false marks the counts unknown");
        assert_eq!(cold.vectors(), None, "a placeholder 0 must not read as 0");
        assert_eq!(cold.drawers(), None);
        assert_eq!(cold.kg_triples(), None);
        assert_eq!(cold.nodes(), None);
        assert_eq!(cold.edges(), None);

        let warm = &rows[1];
        assert!(!warm.counts_unknown);
        assert_eq!(warm.vectors(), Some(912));
        assert_eq!(warm.drawers(), Some(38));
        assert_eq!(warm.kg_triples(), Some(122));
    }

    /// Why (issue #4682): `cached` only exists on daemons carrying #4640. An
    /// older daemon opened every palace, so its counts are authoritative —
    /// defaulting the absent flag to `false` would make a current client print
    /// `—` for every palace against it.
    /// What: asserts a payload with no `cached` key keeps its counts, and that
    /// a genuinely empty *cached* palace still reads as a known `0` rather
    /// than unknown.
    /// Test: this test.
    #[test]
    fn parse_palaces_trusts_counts_when_cached_flag_absent() {
        let legacy = serde_json::json!([{"id": "p1", "name": "p1", "vector_count": 8400}]);
        let rows = parse_palaces(&legacy);
        assert!(!rows[0].counts_unknown, "absent flag != not loaded");
        assert_eq!(rows[0].vectors(), Some(8400));

        let empty_but_loaded =
            serde_json::json!([{"id": "p2", "name": "p2", "vector_count": 0, "cached": true}]);
        let rows = parse_palaces(&empty_but_loaded);
        assert_eq!(
            rows[0].vectors(),
            Some(0),
            "an empty loaded palace is a known zero, not unknown"
        );
    }

    /// Why (issue #4682): the CLI's single-id path must read the route that
    /// opens the palace; this pins the projection it depends on.
    /// What: asserts a single palace object projects to a row with live counts.
    /// Test: this test.
    #[test]
    fn parse_palace_detail_reads_live_counts() {
        let raw = serde_json::json!({
            "id": "t-tmpugxp9v", "name": "t-tmpugxp9v",
            "drawer_count": 1, "vector_count": 1, "kg_triple_count": 8,
            "node_count": 9, "edge_count": 8, "cached": true,
        });
        let row = parse_palace_detail(&raw).expect("single palace object projects");
        assert_eq!(row.id, "t-tmpugxp9v");
        assert_eq!(row.vectors(), Some(1));
        assert_eq!(row.drawers(), Some(1));
        assert_eq!(row.kg_triples(), Some(8));
    }

    /// Why (issue #4682): a non-object 2xx body must surface as an error, not
    /// as a row of silent zeros the CLI would print as fact.
    /// What: asserts arrays, strings, and null all yield `None`.
    /// Test: this test.
    #[test]
    fn parse_palace_detail_rejects_non_object() {
        assert!(parse_palace_detail(&serde_json::json!([])).is_none());
        assert!(parse_palace_detail(&serde_json::json!("nonsense")).is_none());
        assert!(parse_palace_detail(&serde_json::Value::Null).is_none());
    }

    /// Why (issue #4682): the single-palace route is the whole point of the
    /// fix; asserting the URL keeps a future refactor from quietly pointing it
    /// back at the bulk list.
    /// What: asserts the built URL is `<base>/api/v1/palaces/<id>`.
    /// Test: this test.
    #[test]
    fn fetch_palace_url_is_the_single_palace_route() {
        let client = MemoryClient::new("http://127.0.0.1:7070");
        assert_eq!(
            client.palace_url("t-tmpugxp9v"),
            "http://127.0.0.1:7070/api/v1/palaces/t-tmpugxp9v"
        );
    }

    #[test]
    fn parse_recall_hits_projects_fields() {
        // The recall endpoint returns a bare array; each hit projects
        // palace_id, a one-line snippet, and the score.
        let raw = serde_json::json!([
            {
                "palace_id": "default",
                "content": "JWT middleware added to auth flow\nmore detail",
                "score": 0.83,
            },
            {
                "palace_id": "work",
                "content": "  single line  ",
                "score": 0.5,
            },
        ]);
        let hits = parse_recall_hits(&raw);
        assert_eq!(hits.len(), 2);
        assert_eq!(hits[0].palace_id, "default");
        assert_eq!(hits[0].snippet, "JWT middleware added to auth flow");
        assert!((hits[0].score - 0.83).abs() < 1e-6);
        assert_eq!(hits[1].snippet, "single line");
        // A non-array payload yields no hits.
        assert!(parse_recall_hits(&serde_json::json!({})).is_empty());
    }

    #[test]
    fn parse_dream_stats_reads_counts() {
        let raw = serde_json::json!({
            "merged": 3, "pruned": 1, "compacted": 0,
            "closets_updated": 5, "duration_ms": 42,
        });
        assert_eq!(
            parse_dream_stats(&raw),
            DreamStats {
                merged: 3,
                pruned: 1,
                compacted: 0,
            }
        );
        // Absent fields default to zero.
        assert_eq!(
            parse_dream_stats(&serde_json::json!({})),
            DreamStats::default()
        );
    }

    #[test]
    fn parse_memory_event_maps_type_tag() {
        assert_eq!(
            parse_memory_event(&serde_json::json!({
                "type": "palace_created", "id": "p1", "name": "notes",
            })),
            Some(MemoryEvent::PalaceCreated {
                name: "notes".into(),
            })
        );
        // drawer_added with a content preview round-trips the preview.
        assert_eq!(
            parse_memory_event(&serde_json::json!({
                "type": "drawer_added",
                "palace_id": "default",
                "drawer_count": 14,
                "content_preview": "How the migration system handles…",
            })),
            Some(MemoryEvent::DrawerAdded {
                palace_id: "default".into(),
                drawer_count: 14,
                content_preview: "How the migration system handles…".into(),
            })
        );
        // Older daemons omit `content_preview`; the field defaults to empty.
        assert_eq!(
            parse_memory_event(&serde_json::json!({
                "type": "drawer_added", "palace_id": "default", "drawer_count": 14,
            })),
            Some(MemoryEvent::DrawerAdded {
                palace_id: "default".into(),
                drawer_count: 14,
                content_preview: String::new(),
            })
        );
        assert_eq!(
            parse_memory_event(&serde_json::json!({
                "type": "dream_completed", "merged": 3, "pruned": 1, "compacted": 0,
            })),
            Some(MemoryEvent::DreamCompleted {
                merged: 3,
                pruned: 1,
                compacted: 0,
            })
        );
        // Housekeeping and unmodelled frames are dropped.
        assert!(parse_memory_event(&serde_json::json!({"type": "connected"})).is_none());
        assert!(parse_memory_event(&serde_json::json!({"type": "lag", "skipped": 2})).is_none());
        assert!(parse_memory_event(&serde_json::json!({"no": "type"})).is_none());
    }

    #[test]
    fn parse_drawers_projects_fields() {
        // Bare array shape — the daemon's current response. Row 0
        // carries an explicit `snippet`; row 1 only has `content` (the
        // fallback path); row 2 carries neither.
        let raw = serde_json::json!([
            {
                "id": "11111111-1111-1111-1111-111111111111",
                "created_at": "2026-05-20T12:34:56Z",
                "tags": ["msg:from=cto", "user-tag"],
                "content": "ignored when snippet is present",
                "snippet": "JWT middleware added",
            },
            {
                "id": "22222222-2222-2222-2222-222222222222",
                "created_at": "2026-05-19T08:00:00Z",
                "tags": ["creator:client=mpm", "creator:source=http"],
                "content": "Plain content for the legacy fallback path",
            },
            {
                "id": "33333333-3333-3333-3333-333333333333",
                "created_at": "bad-timestamp",
                "tags": [],
            },
        ]);
        let drawers = parse_drawers(&raw);
        assert_eq!(drawers.len(), 3);
        assert_eq!(drawers[0].id, "11111111-1111-1111-1111-111111111111");
        assert_eq!(drawers[0].creator, "msg:from=cto");
        assert_eq!(drawers[0].tags.len(), 2);
        assert!(drawers[0].created_at.is_some());
        // Issue #202: explicit snippet wins over content.
        assert_eq!(drawers[0].snippet.as_deref(), Some("JWT middleware added"));

        assert_eq!(drawers[1].creator, "creator:client=mpm");
        // Issue #202: fall back to truncating `content` when snippet is absent.
        assert_eq!(
            drawers[1].snippet.as_deref(),
            Some("Plain content for the legacy fallback path"),
        );

        // Malformed timestamp drops to None; missing creator tag → em-dash;
        // no snippet and no content → snippet is None.
        assert!(drawers[2].created_at.is_none());
        assert_eq!(drawers[2].creator, NO_CREATOR_LABEL);
        assert!(drawers[2].snippet.is_none());

        // Object-wrapped shape.
        let obj = serde_json::json!({
            "drawers": [{"id": "abc", "tags": []}],
        });
        let drawers = parse_drawers(&obj);
        assert_eq!(drawers.len(), 1);
        assert_eq!(drawers[0].id, "abc");

        // Unexpected shape yields an empty list.
        assert!(parse_drawers(&serde_json::json!("nope")).is_empty());

        // An explicit `null` snippet (daemon returned `Value::Null`) also
        // yields `None` — neither the snippet nor the absent content
        // fields fill it in.
        let null_snippet = serde_json::json!([{
            "id": "44444444-4444-4444-4444-444444444444",
            "snippet": serde_json::Value::Null,
            "tags": [],
        }]);
        let drawers = parse_drawers(&null_snippet);
        assert!(drawers[0].snippet.is_none());

        // Long content gets truncated by the client fallback.
        let long_content = "x".repeat(200);
        let long = serde_json::json!([{
            "id": "55555555-5555-5555-5555-555555555555",
            "content": long_content,
            "tags": [],
        }]);
        let drawers = parse_drawers(&long);
        let snippet = drawers[0].snippet.as_deref().expect("fallback snippet");
        assert_eq!(snippet.chars().count(), DRAWER_SNIPPET_FALLBACK_MAX);
        assert!(
            snippet.ends_with('…'),
            "long fallback snippet must be truncated with ellipsis",
        );
    }

    /// Why (issue #215): the detail modal must see the full `content`
    /// field on every drawer; the row-oriented `parse_drawers` projection
    /// deliberately omits it, so `parse_memory_details` is the channel.
    /// What: feeds a bare array and an object-wrapped array of drawer
    /// payloads through the projection and asserts each row keeps its
    /// full body, tag list, and timestamp.
    /// Test: itself.
    #[test]
    fn parse_memory_details_projects_full_content() {
        let raw = serde_json::json!([
            {
                "id": "11111111-1111-1111-1111-111111111111",
                "created_at": "2026-05-20T12:34:56Z",
                "tags": ["msg:from=cto"],
                "content": "Full memory body the modal renders verbatim.",
            },
            {
                "id": "22222222-2222-2222-2222-222222222222",
                "created_at": "bad-timestamp",
                "tags": [],
                "content": "",
            },
        ]);
        let details = parse_memory_details(&raw);
        assert_eq!(details.len(), 2);
        assert_eq!(details[0].id, "11111111-1111-1111-1111-111111111111");
        assert_eq!(
            details[0].content,
            "Full memory body the modal renders verbatim."
        );
        assert_eq!(details[0].tags, vec!["msg:from=cto".to_string()]);
        assert!(details[0].created_at.is_some());

        // Empty content / bad timestamp degrade to safe defaults instead of
        // dropping the row.
        assert!(details[1].created_at.is_none());
        assert!(details[1].content.is_empty());

        // Object-wrapped shape.
        let obj = serde_json::json!({
            "drawers": [{"id": "abc", "content": "wrapped", "tags": []}],
        });
        let details = parse_memory_details(&obj);
        assert_eq!(details.len(), 1);
        assert_eq!(details[0].content, "wrapped");

        // Unexpected shape yields an empty list.
        assert!(parse_memory_details(&serde_json::json!("nope")).is_empty());
    }

    #[test]
    fn creator_label_picks_first_match() {
        // First matching tag wins, in the tag list's order.
        let label = creator_label(&[
            "user-tag".into(),
            "msg:from=cto".into(),
            "creator:client=mpm".into(),
        ]);
        assert_eq!(label, "msg:from=cto");

        // `tag:creator:` legacy prefix is recognised.
        let label = creator_label(&["tag:creator:client=mpm".into()]);
        assert_eq!(label, "tag:creator:client=mpm");

        // `creator:` alone (HTTP attribution) is recognised.
        let label = creator_label(&["creator:source=http".into()]);
        assert_eq!(label, "creator:source=http");

        // No recognised tags → em-dash placeholder.
        assert_eq!(
            creator_label(&["user-tag".into(), "kind:note".into()]),
            NO_CREATOR_LABEL,
        );
        assert_eq!(creator_label(&[]), NO_CREATOR_LABEL);
    }
}