handoff-mcp-server 0.19.1 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,537 @@
1
+ //! Data model for documents (wiki/130-document-management.md v5 rearchitecture).
2
+ //! One `DocMetadata` per `_doc.<slug>.json`, paired with its full body at
3
+ //! `_doc.<slug>.md` (see `super` module docs). Sections are computed
4
+ //! in-memory (byte offsets into the body) rather than split into physical
5
+ //! fragment files.
6
+
7
+ use serde::{Deserialize, Serialize};
8
+
9
+ /// Current document schema version. Bump when `DocMetadata` changes shape in
10
+ /// a way that needs migration handling on read.
11
+ pub const DOC_SCHEMA_VERSION: u32 = 2;
12
+
13
+ /// Valid `doc_type` values (spec §4.1, extensible via `config.toml`
14
+ /// `settings.doc_types.types` — this list is the storage-layer default set,
15
+ /// not an enforced enum, so a project-configured custom type still
16
+ /// round-trips through `serde` even if it is not in this list).
17
+ pub const VALID_DOC_TYPES: &[&str] = &["spec", "design", "adr", "guide", "note"];
18
+
19
+ /// Valid `auto_inject` values (spec §7.2.1).
20
+ pub const VALID_AUTO_INJECT: &[&str] = &["auto", "full", "outline", "none"];
21
+
22
+ /// Valid `related[].rel` relationship kinds (spec §4.3).
23
+ pub const VALID_RELATIONS: &[&str] = &[
24
+ "supersedes",
25
+ "references",
26
+ "implements",
27
+ "extends",
28
+ "conflicts",
29
+ ];
30
+
31
+ /// A document: the family-tree node and section manifest persisted at
32
+ /// `_doc.<slug>.json` (spec §4.1, v5). The full Markdown body lives
33
+ /// unsplit at `_doc.<slug>.md`.
34
+ #[derive(Debug, Clone, Serialize, Deserialize)]
35
+ pub struct DocMetadata {
36
+ /// Schema version (= [`DOC_SCHEMA_VERSION`]).
37
+ pub version: u32,
38
+ /// Stable id: `doc-YYYYMMDD-HHMMSS-NNNNNN`. Kept internally for
39
+ /// family-tree/task-link references; file naming uses `slug` instead.
40
+ pub id: String,
41
+ /// Human-readable file-naming slug (`[a-z0-9-]`, max 60 chars). Required
42
+ /// on creation; used to build `_doc.<slug>.json` / `_doc.<slug>.md`.
43
+ pub slug: String,
44
+ pub title: String,
45
+ /// One of [`VALID_DOC_TYPES`] by convention (spec: `spec | design | adr |
46
+ /// guide | note`), not enforced here — validation belongs to the
47
+ /// `doc_save` handler (t96) so a project-configured custom type can still
48
+ /// be persisted.
49
+ pub doc_type: String,
50
+ #[serde(default)]
51
+ pub tags: Vec<String>,
52
+ #[serde(default)]
53
+ pub scope_paths: Vec<String>,
54
+
55
+ /// Family tree: `None` = root document.
56
+ #[serde(default)]
57
+ pub parent_id: Option<String>,
58
+ /// Ordered child document ids.
59
+ #[serde(default)]
60
+ pub children: Vec<String>,
61
+ /// Sibling/relative relationships (semantic, not structural).
62
+ #[serde(default)]
63
+ pub related: Vec<DocRelation>,
64
+
65
+ /// Auto-injection control (spec §7.2.1): `"auto"` | `"full"` |
66
+ /// `"outline"` | `"none"`. Defaults to `"auto"`.
67
+ #[serde(default = "default_auto_inject")]
68
+ pub auto_inject: String,
69
+
70
+ /// Task ids this document is linked to (bidirectional — the task side
71
+ /// mirrors this via `TaskLink { link_type: "doc" }`, synced by
72
+ /// `crate::storage::tasks::sync_doc_task_links`).
73
+ #[serde(default)]
74
+ pub task_ids: Vec<String>,
75
+
76
+ /// Source tracking for reversibility (spec §4.1 / §8).
77
+ #[serde(default)]
78
+ pub source: DocSource,
79
+
80
+ /// `true` when the authored body started with a UTF-8 BOM (spec §5.1
81
+ /// scope rule 6). Computed by [`super::split::split`] and persisted so
82
+ /// callers can restore it losslessly. Defaults to `false` for
83
+ /// documents written before this field existed.
84
+ #[serde(default)]
85
+ pub has_bom: bool,
86
+ /// `"lf"` or `"crlf"` (spec §5.1 scope rule 6), detected by
87
+ /// [`super::split::split`]. Defaults to `"lf"` for backward compat with
88
+ /// documents written before this field existed.
89
+ #[serde(default = "default_line_ending")]
90
+ pub line_ending: String,
91
+
92
+ /// Section manifest, in `seq` order (v5: replaces the old `fragments`
93
+ /// physical-file manifest — `sections` are in-memory byte-offset
94
+ /// indexes into `_doc.<slug>.md`, not separate files). Old on-disk
95
+ /// documents that still have a `fragments` key deserialize via the
96
+ /// `alias` below for backward compat.
97
+ #[serde(default, alias = "fragments")]
98
+ pub sections: Vec<SectionIndex>,
99
+
100
+ pub created_at: String,
101
+ pub updated_at: String,
102
+
103
+ /// FNV-1a hash of the full document body. Used to detect drift after
104
+ /// direct `.md` edits (spec §8.2).
105
+ #[serde(default)]
106
+ pub content_hash: String,
107
+
108
+ /// Verification matrix (wiki/140-verification-matrix.md §3.1). `None` =
109
+ /// matrix not yet generated. Managed exclusively through the
110
+ /// `handoff_doc_verify` tool — `doc_save` never touches this field, so
111
+ /// existing on-disk documents without it deserialize to `None` via
112
+ /// `#[serde(default)]`.
113
+ #[serde(default, skip_serializing_if = "Option::is_none")]
114
+ pub verification: Option<Verification>,
115
+ }
116
+
117
+ fn default_auto_inject() -> String {
118
+ "auto".to_string()
119
+ }
120
+
121
+ fn default_line_ending() -> String {
122
+ "lf".to_string()
123
+ }
124
+
125
+ /// Maximum allowed length of a `slug` (spec §3.1 v5 proposal).
126
+ pub const MAX_SLUG_LEN: usize = 60;
127
+
128
+ impl DocMetadata {
129
+ /// Build a fresh document with empty family-tree/section fields and
130
+ /// `auto_inject: "auto"`. `now` is an RFC3339 timestamp supplied by the
131
+ /// caller (keeps this module clock-free and testable, mirroring
132
+ /// `MemoryEntry::new`).
133
+ pub fn new(id: String, slug: String, title: String, doc_type: String, now: String) -> Self {
134
+ DocMetadata {
135
+ version: DOC_SCHEMA_VERSION,
136
+ id,
137
+ slug,
138
+ title,
139
+ doc_type,
140
+ tags: Vec::new(),
141
+ scope_paths: Vec::new(),
142
+ parent_id: None,
143
+ children: Vec::new(),
144
+ related: Vec::new(),
145
+ auto_inject: default_auto_inject(),
146
+ task_ids: Vec::new(),
147
+ source: DocSource::default(),
148
+ has_bom: false,
149
+ line_ending: default_line_ending(),
150
+ sections: Vec::new(),
151
+ created_at: now.clone(),
152
+ updated_at: now,
153
+ content_hash: String::new(),
154
+ verification: None,
155
+ }
156
+ }
157
+ }
158
+
159
+ /// A sibling/relative relationship to another document (spec §4.3).
160
+ #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
161
+ pub struct DocRelation {
162
+ pub id: String,
163
+ /// One of [`VALID_RELATIONS`].
164
+ pub rel: String,
165
+ }
166
+
167
+ /// Source tracking for a document, used to support the reversibility
168
+ /// guarantee (spec §4.1 / §8).
169
+ #[derive(Debug, Clone, Serialize, Deserialize)]
170
+ pub struct DocSource {
171
+ /// `"authored"` | `"imported"` | `"split"`. Empty string when unset
172
+ /// (fresh documents created directly via `doc_save` default to
173
+ /// `"authored"` at the handler level).
174
+ #[serde(default)]
175
+ pub origin: String,
176
+ /// Original file path when imported from `wiki/` or `tmp/`.
177
+ #[serde(default, skip_serializing_if = "Option::is_none")]
178
+ pub original_path: Option<String>,
179
+ /// Canonical-form hash used to detect drift on reassembly.
180
+ #[serde(default, skip_serializing_if = "Option::is_none")]
181
+ pub canonical_hash: Option<String>,
182
+ /// Raw YAML frontmatter block (spec §5.1 scope rule 6), extracted by
183
+ /// [`super::split::split`] and excluded from section seq-0. `None` when
184
+ /// the document has no frontmatter.
185
+ #[serde(default, skip_serializing_if = "Option::is_none")]
186
+ pub frontmatter: Option<String>,
187
+ /// `true` if a line ending immediately followed the closing `---` fence
188
+ /// in the originally authored body (see
189
+ /// [`super::split::SplitDocument::frontmatter_trailing_eol`]).
190
+ /// Meaningless when `frontmatter` is `None`. Defaults to `true` so
191
+ /// documents persisted before this field existed keep the pre-fix
192
+ /// reassembly behavior (always re-adding the eol) rather than silently
193
+ /// dropping a byte they never reported not having.
194
+ #[serde(default = "default_frontmatter_trailing_eol")]
195
+ pub frontmatter_trailing_eol: bool,
196
+ }
197
+
198
+ fn default_frontmatter_trailing_eol() -> bool {
199
+ true
200
+ }
201
+
202
+ impl Default for DocSource {
203
+ /// Matches the per-field `#[serde(default = ...)]` values above, so a
204
+ /// document missing the whole `source` key (oldest on-disk schema) and
205
+ /// one missing only `frontmatter_trailing_eol` (this field's own
206
+ /// addition) deserialize identically — both keep the pre-fix reassembly
207
+ /// behavior of always re-adding the eol after the frontmatter fence.
208
+ fn default() -> Self {
209
+ DocSource {
210
+ origin: String::new(),
211
+ original_path: None,
212
+ canonical_hash: None,
213
+ frontmatter: None,
214
+ frontmatter_trailing_eol: default_frontmatter_trailing_eol(),
215
+ }
216
+ }
217
+ }
218
+
219
+ /// One entry in a document's section manifest (`DocMetadata::sections`),
220
+ /// v5 (spec §3.1): an in-memory byte-offset index into `_doc.<slug>.md`,
221
+ /// replacing the v4 `FragmentSummary` (which paired with physical
222
+ /// `_frag.*` files) and the old `FragmentMetadata` sidecar entirely.
223
+ #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
224
+ pub struct SectionIndex {
225
+ /// 0-based position in the document. seq 0 is always the preamble.
226
+ pub seq: usize,
227
+ /// Heading text (without `#` markers), empty string for the seq-0
228
+ /// preamble when the document has no heading before it.
229
+ pub heading: String,
230
+ /// ATX heading level (1-6), or 0 for the seq-0 preamble.
231
+ pub level: u8,
232
+ /// Byte offset of this section within the document body (the file at
233
+ /// `_doc.<slug>.md`, after BOM/frontmatter stripping).
234
+ pub byte_offset: usize,
235
+ /// Byte length of this section's body.
236
+ pub byte_length: usize,
237
+ /// FNV-1a hash of this section's body slice.
238
+ pub content_hash: String,
239
+ }
240
+
241
+ /// Verification matrix for a document (wiki/140-verification-matrix.md §3.1).
242
+ /// Persisted inside `DocMetadata::verification`.
243
+ #[derive(Debug, Clone, Serialize, Deserialize)]
244
+ pub struct Verification {
245
+ /// Overall status: "pending" | "in_review" | "verified".
246
+ pub status: String,
247
+ pub created_at: String,
248
+ pub updated_at: String,
249
+ /// One item per tracked fragment.
250
+ pub items: Vec<VerificationItem>,
251
+ }
252
+
253
+ /// One row in the verification matrix — tracks review state of a single
254
+ /// spec fragment.
255
+ #[derive(Debug, Clone, Serialize, Deserialize)]
256
+ pub struct VerificationItem {
257
+ pub fragment_seq: usize,
258
+ pub heading: String,
259
+ /// "pending" | "skipped" | "verified".
260
+ pub status: String,
261
+ #[serde(default)]
262
+ pub impl_refs: Vec<CodeRef>,
263
+ #[serde(default)]
264
+ pub test_refs: Vec<CodeRef>,
265
+ #[serde(default, skip_serializing_if = "Option::is_none")]
266
+ pub reviewer: Option<String>,
267
+ #[serde(default, skip_serializing_if = "Option::is_none")]
268
+ pub verified_at: Option<String>,
269
+ #[serde(default)]
270
+ pub notes: String,
271
+ /// Fragment content_hash at the time of verification. If the fragment's
272
+ /// current hash differs, this item's review is stale and should be
273
+ /// flagged (`doc_verify_status`).
274
+ #[serde(default, skip_serializing_if = "Option::is_none")]
275
+ pub content_hash_at_verify: Option<String>,
276
+ }
277
+
278
+ /// A reference to a source code location.
279
+ #[derive(Debug, Clone, Serialize, Deserialize)]
280
+ pub struct CodeRef {
281
+ pub path: String,
282
+ #[serde(default, skip_serializing_if = "Option::is_none")]
283
+ pub lines: Option<String>,
284
+ #[serde(default, skip_serializing_if = "Option::is_none")]
285
+ pub label: Option<String>,
286
+ }
287
+
288
+ #[cfg(test)]
289
+ mod tests {
290
+ use super::*;
291
+
292
+ fn new_doc() -> DocMetadata {
293
+ DocMetadata::new(
294
+ "doc-1".to_string(),
295
+ "my-slug".to_string(),
296
+ "Title".to_string(),
297
+ "spec".to_string(),
298
+ "2026-07-11T00:00:00Z".to_string(),
299
+ )
300
+ }
301
+
302
+ #[test]
303
+ fn doc_metadata_new_defaults() {
304
+ let doc = new_doc();
305
+ assert_eq!(doc.version, DOC_SCHEMA_VERSION);
306
+ assert_eq!(doc.slug, "my-slug");
307
+ assert_eq!(doc.auto_inject, "auto");
308
+ assert!(doc.parent_id.is_none());
309
+ assert!(doc.children.is_empty());
310
+ assert!(doc.sections.is_empty());
311
+ }
312
+
313
+ #[test]
314
+ fn section_index_holds_byte_offset_length_and_hash() {
315
+ let body = "## Heading\n\nBody\n";
316
+ let section = SectionIndex {
317
+ seq: 1,
318
+ heading: "Heading".to_string(),
319
+ level: 2,
320
+ byte_offset: 5,
321
+ byte_length: body.len(),
322
+ content_hash: lexsim::content_hash(body),
323
+ };
324
+ assert_eq!(section.byte_length, body.len());
325
+ assert_eq!(section.content_hash, lexsim::content_hash(body));
326
+ assert_eq!(section.byte_offset, 5);
327
+ }
328
+
329
+ #[test]
330
+ fn serde_roundtrip_doc_metadata() {
331
+ let mut doc = new_doc();
332
+ doc.related.push(DocRelation {
333
+ id: "doc-2".to_string(),
334
+ rel: "references".to_string(),
335
+ });
336
+ doc.source = DocSource {
337
+ origin: "authored".to_string(),
338
+ original_path: None,
339
+ canonical_hash: Some("abc123".to_string()),
340
+ frontmatter: None,
341
+ frontmatter_trailing_eol: true,
342
+ };
343
+
344
+ let json = serde_json::to_string(&doc).unwrap();
345
+ let back: DocMetadata = serde_json::from_str(&json).unwrap();
346
+ assert_eq!(back.related.len(), 1);
347
+ assert_eq!(back.related[0].rel, "references");
348
+ assert_eq!(back.source.canonical_hash.as_deref(), Some("abc123"));
349
+ assert_eq!(back.slug, "my-slug");
350
+ }
351
+
352
+ /// wiki/130-document-management.md §5.1: `split()` computes `has_bom`,
353
+ /// `line_ending`, and `frontmatter` for every authored document — these
354
+ /// must round-trip through storage so a BOM/CRLF/frontmatter document is
355
+ /// never silently corrupted by `doc_save` (t96).
356
+ #[test]
357
+ fn doc_metadata_persists_bom_line_ending_and_frontmatter() {
358
+ let mut doc = new_doc();
359
+ doc.has_bom = true;
360
+ doc.line_ending = "crlf".to_string();
361
+ doc.source.frontmatter = Some("title: Foo\n".to_string());
362
+
363
+ let json = serde_json::to_string(&doc).unwrap();
364
+ let back: DocMetadata = serde_json::from_str(&json).unwrap();
365
+ assert!(back.has_bom);
366
+ assert_eq!(back.line_ending, "crlf");
367
+ assert_eq!(back.source.frontmatter.as_deref(), Some("title: Foo\n"));
368
+ }
369
+
370
+ #[test]
371
+ fn doc_metadata_new_defaults_bom_and_line_ending() {
372
+ let doc = new_doc();
373
+ assert!(!doc.has_bom);
374
+ assert_eq!(doc.line_ending, "lf");
375
+ assert!(doc.source.frontmatter.is_none());
376
+ }
377
+
378
+ /// Old on-disk documents written before this field existed must still
379
+ /// deserialize (backward compat via `#[serde(default)]`).
380
+ #[test]
381
+ fn doc_metadata_deserializes_without_bom_line_ending_fields() {
382
+ let old_json = serde_json::json!({
383
+ "version": 1,
384
+ "id": "doc-1",
385
+ "slug": "doc-1",
386
+ "title": "Title",
387
+ "doc_type": "spec",
388
+ "created_at": "2026-07-11T00:00:00Z",
389
+ "updated_at": "2026-07-11T00:00:00Z",
390
+ });
391
+ let back: DocMetadata = serde_json::from_value(old_json).unwrap();
392
+ assert!(!back.has_bom);
393
+ assert_eq!(back.line_ending, "lf");
394
+ assert!(back.source.frontmatter.is_none());
395
+ assert!(
396
+ back.source.frontmatter_trailing_eol,
397
+ "pre-fix on-disk documents always had the eol re-added on reassembly; \
398
+ the default must preserve that behavior rather than silently drop a byte"
399
+ );
400
+ }
401
+
402
+ /// `#[serde(alias = "fragments")]` on `DocMetadata::sections` lets a
403
+ /// *new-shaped* payload (full `SectionIndex` fields: byte_offset,
404
+ /// byte_length, content_hash) round-trip whether it's keyed `sections`
405
+ /// or (legacy key name) `fragments`.
406
+ #[test]
407
+ fn doc_metadata_sections_field_accepts_fragments_alias_key() {
408
+ let via_alias_key = serde_json::json!({
409
+ "version": 2,
410
+ "id": "doc-1",
411
+ "slug": "doc-1",
412
+ "title": "Title",
413
+ "doc_type": "spec",
414
+ "created_at": "2026-07-11T00:00:00Z",
415
+ "updated_at": "2026-07-11T00:00:00Z",
416
+ "fragments": [
417
+ { "seq": 0, "heading": "", "level": 0, "byte_offset": 0, "byte_length": 10, "content_hash": "abc" }
418
+ ],
419
+ });
420
+ let back: DocMetadata = serde_json::from_value(via_alias_key).unwrap();
421
+ assert_eq!(back.sections.len(), 1);
422
+ assert_eq!(back.sections[0].byte_length, 10);
423
+ }
424
+
425
+ /// Caution (found in review): a **real** v4 on-disk document has no
426
+ /// `byte_offset`/`byte_length`/`content_hash` in its `fragments` entries
427
+ /// (v4's `FragmentSummary` shape was just `{seq, heading, level}`) *and*
428
+ /// has no `slug` field at all (`slug` is new in v5, required, with no
429
+ /// `#[serde(default)]`). Both gaps make a genuine v4 file fail to
430
+ /// deserialize as `DocMetadata` — the `alias = "fragments"` above only
431
+ /// helps if the *rest* of the v5 shape (crucially `slug` and full
432
+ /// `SectionIndex` fields) is already present. This is **not** a
433
+ /// migration path: `storage::docs::read_doc`/`read_all_docs` treat a
434
+ /// failed parse as "skip silently" (same policy as any corrupt file),
435
+ /// so a real v4 document would vanish from `doc_get`/`doc_list`/
436
+ /// `doc_query` with no warning. Deliberately out of scope per
437
+ /// wiki/130-document-management.md's migration section (no real v4
438
+ /// documents exist outside dev test data) — documented here so the gap
439
+ /// isn't mistaken for a safety net if that assumption ever changes.
440
+ #[test]
441
+ fn doc_metadata_rejects_real_v4_shape_missing_slug_and_byte_fields() {
442
+ let real_v4_shape = serde_json::json!({
443
+ "version": 1,
444
+ "id": "doc-1",
445
+ "title": "Title",
446
+ "doc_type": "spec",
447
+ "created_at": "2026-07-11T00:00:00Z",
448
+ "updated_at": "2026-07-11T00:00:00Z",
449
+ "fragments": [
450
+ { "seq": 0, "heading": "", "level": 0 }
451
+ ],
452
+ });
453
+ let result: Result<DocMetadata, _> = serde_json::from_value(real_v4_shape);
454
+ assert!(
455
+ result.is_err(),
456
+ "a real v4 document (no slug, no byte_offset/byte_length/content_hash) \
457
+ must fail to deserialize under the v5 schema, not silently succeed \
458
+ with data loss (empty sections) — verifying this fails loudly here so \
459
+ read_doc's lenient Ok(None) fallback is a deliberate, documented \
460
+ trade-off rather than an invisible one"
461
+ );
462
+ }
463
+
464
+ /// wiki/140-verification-matrix.md §3.4: existing on-disk documents
465
+ /// without a `verification` key must deserialize with `verification:
466
+ /// None` — `doc_save` and the pre-verification-matrix on-disk schema
467
+ /// must be unaffected by this addition.
468
+ #[test]
469
+ fn doc_metadata_new_defaults_verification_to_none() {
470
+ let doc = new_doc();
471
+ assert!(doc.verification.is_none());
472
+ }
473
+
474
+ #[test]
475
+ fn doc_metadata_deserializes_without_verification_field() {
476
+ let old_json = serde_json::json!({
477
+ "version": 2,
478
+ "id": "doc-1",
479
+ "slug": "doc-1",
480
+ "title": "Title",
481
+ "doc_type": "spec",
482
+ "created_at": "2026-07-11T00:00:00Z",
483
+ "updated_at": "2026-07-11T00:00:00Z",
484
+ });
485
+ let back: DocMetadata = serde_json::from_value(old_json).unwrap();
486
+ assert!(back.verification.is_none());
487
+ }
488
+
489
+ #[test]
490
+ fn verification_round_trips_through_doc_metadata() {
491
+ let mut doc = new_doc();
492
+ doc.verification = Some(Verification {
493
+ status: "in_review".to_string(),
494
+ created_at: "2026-07-11T10:00:00Z".to_string(),
495
+ updated_at: "2026-07-11T14:30:00Z".to_string(),
496
+ items: vec![VerificationItem {
497
+ fragment_seq: 2,
498
+ heading: "1. 課題".to_string(),
499
+ status: "verified".to_string(),
500
+ impl_refs: vec![CodeRef {
501
+ path: "src/storage/docs/mod.rs".to_string(),
502
+ lines: Some("42-180".to_string()),
503
+ label: Some("DocStore".to_string()),
504
+ }],
505
+ test_refs: vec![CodeRef {
506
+ path: "tests/doc_save.rs".to_string(),
507
+ lines: None,
508
+ label: Some("doc_save roundtrip".to_string()),
509
+ }],
510
+ reviewer: Some("ai".to_string()),
511
+ verified_at: Some("2026-07-11T14:30:00Z".to_string()),
512
+ notes: String::new(),
513
+ content_hash_at_verify: Some("abc123".to_string()),
514
+ }],
515
+ });
516
+
517
+ let json = serde_json::to_string(&doc).unwrap();
518
+ let back: DocMetadata = serde_json::from_str(&json).unwrap();
519
+ let v = back.verification.expect("verification must round-trip");
520
+ assert_eq!(v.status, "in_review");
521
+ assert_eq!(v.items.len(), 1);
522
+ assert_eq!(v.items[0].fragment_seq, 2);
523
+ assert_eq!(v.items[0].impl_refs[0].path, "src/storage/docs/mod.rs");
524
+ assert_eq!(
525
+ v.items[0].test_refs[0].label.as_deref(),
526
+ Some("doc_save roundtrip")
527
+ );
528
+ }
529
+
530
+ #[test]
531
+ fn valid_constants_contain_spec_values() {
532
+ assert!(VALID_DOC_TYPES.contains(&"spec"));
533
+ assert!(VALID_DOC_TYPES.contains(&"note"));
534
+ assert!(VALID_AUTO_INJECT.contains(&"outline"));
535
+ assert!(VALID_RELATIONS.contains(&"supersedes"));
536
+ }
537
+ }