handoff-mcp-server 0.26.0 → 0.27.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.
Files changed (72) hide show
  1. package/README.md +31 -4
  2. package/bin/handoff-mcp.js +56 -13
  3. package/bin/resolve-binary.js +122 -0
  4. package/package.json +14 -9
  5. package/Cargo.lock +0 -686
  6. package/Cargo.toml +0 -30
  7. package/scripts/cargo-env.sh +0 -29
  8. package/scripts/handoff-memory-hook.py +0 -208
  9. package/scripts/install-local.sh +0 -109
  10. package/scripts/postinstall.js +0 -50
  11. package/scripts/sync-plugin-skills.sh +0 -35
  12. package/scripts/sync-plugin-version.sh +0 -85
  13. package/scripts/sync-workflow-inline.sh +0 -138
  14. package/src/cli.rs +0 -551
  15. package/src/context/injection.rs +0 -276
  16. package/src/context/mod.rs +0 -129
  17. package/src/lib.rs +0 -5
  18. package/src/main.rs +0 -157
  19. package/src/mcp/handlers/assignees.rs +0 -254
  20. package/src/mcp/handlers/auto_schedule.rs +0 -489
  21. package/src/mcp/handlers/bulk_update.rs +0 -155
  22. package/src/mcp/handlers/calendar.rs +0 -196
  23. package/src/mcp/handlers/capacity.rs +0 -318
  24. package/src/mcp/handlers/check_criterion.rs +0 -70
  25. package/src/mcp/handlers/config.rs +0 -402
  26. package/src/mcp/handlers/config_crud.rs +0 -183
  27. package/src/mcp/handlers/dashboard.rs +0 -214
  28. package/src/mcp/handlers/docs.rs +0 -2288
  29. package/src/mcp/handlers/docs_query.rs +0 -1335
  30. package/src/mcp/handlers/fork_session.rs +0 -91
  31. package/src/mcp/handlers/get_session.rs +0 -48
  32. package/src/mcp/handlers/get_task.rs +0 -53
  33. package/src/mcp/handlers/import_context.rs +0 -470
  34. package/src/mcp/handlers/init.rs +0 -28
  35. package/src/mcp/handlers/list_sessions.rs +0 -187
  36. package/src/mcp/handlers/list_tasks.rs +0 -308
  37. package/src/mcp/handlers/load_context.rs +0 -361
  38. package/src/mcp/handlers/log_time.rs +0 -67
  39. package/src/mcp/handlers/memory.rs +0 -961
  40. package/src/mcp/handlers/merge_sessions.rs +0 -103
  41. package/src/mcp/handlers/metrics.rs +0 -196
  42. package/src/mcp/handlers/milestones.rs +0 -102
  43. package/src/mcp/handlers/mod.rs +0 -140
  44. package/src/mcp/handlers/refer.rs +0 -307
  45. package/src/mcp/handlers/referrals.rs +0 -74
  46. package/src/mcp/handlers/save_context.rs +0 -354
  47. package/src/mcp/handlers/task_checklist.rs +0 -507
  48. package/src/mcp/handlers/timer.rs +0 -529
  49. package/src/mcp/handlers/update_session.rs +0 -197
  50. package/src/mcp/handlers/update_task.rs +0 -452
  51. package/src/mcp/mod.rs +0 -6
  52. package/src/mcp/protocol.rs +0 -41
  53. package/src/mcp/resources.rs +0 -57
  54. package/src/mcp/router.rs +0 -154
  55. package/src/mcp/tools.rs +0 -1522
  56. package/src/mcp/types.rs +0 -108
  57. package/src/setup.rs +0 -1212
  58. package/src/storage/config.rs +0 -578
  59. package/src/storage/docs/frontmatter.rs +0 -509
  60. package/src/storage/docs/mod.rs +0 -835
  61. package/src/storage/docs/model.rs +0 -708
  62. package/src/storage/docs/reassemble.rs +0 -167
  63. package/src/storage/docs/split.rs +0 -377
  64. package/src/storage/git.rs +0 -47
  65. package/src/storage/memory/injected.rs +0 -340
  66. package/src/storage/memory/mod.rs +0 -236
  67. package/src/storage/memory/model.rs +0 -127
  68. package/src/storage/mod.rs +0 -96
  69. package/src/storage/referrals.rs +0 -248
  70. package/src/storage/sessions.rs +0 -859
  71. package/src/storage/tasks.rs +0 -957
  72. package/templates/claude-md-section.md +0 -12
@@ -1,509 +0,0 @@
1
- //! YAML frontmatter <-> [`DocMetadata`] serialize/deserialize (frontmatter
2
- //! migration, t123.1) plus single-file read/write helpers for
3
- //! `_doc.<slug>.md` (frontmatter + body, replacing the old
4
- //! `_doc.<slug>.json` + `_doc.<slug>.md` pair).
5
- //!
6
- //! `sections[]` is deliberately never part of the frontmatter shape: byte
7
- //! offsets are computed fresh from the body on every read (t123.2), so they
8
- //! can never go stale after a manual edit. `version` (schema version) and
9
- //! `slug` (derived from the filename) are likewise omitted from the
10
- //! frontmatter body — both are storage-layer bookkeeping, not document
11
- //! content.
12
-
13
- use std::collections::HashMap;
14
- use std::path::Path;
15
-
16
- use anyhow::{Context, Result};
17
- use serde::{Deserialize, Serialize};
18
- use serde_json::Value;
19
-
20
- use super::model::{DocMetadata, DocRelation, DocSource, Verification, DOC_SCHEMA_VERSION};
21
-
22
- /// YAML-facing mirror of [`DocMetadata`], minus `version`, `slug`, and
23
- /// `sections` (see module docs), plus alias support on the handful of
24
- /// fields the frontmatter spec defines common aliases for. This is a
25
- /// separate type from `DocMetadata` (rather than reusing it directly with
26
- /// `#[serde(skip)]`) so YAML aliases don't leak into the JSON-era shape any
27
- /// other code still round-trips through `serde_json`.
28
- #[derive(Debug, Clone, Serialize, Deserialize)]
29
- struct FrontmatterDoc {
30
- id: String,
31
- #[serde(alias = "name")]
32
- title: String,
33
- doc_type: String,
34
- #[serde(default)]
35
- tags: Vec<String>,
36
- #[serde(default)]
37
- scope_paths: Vec<String>,
38
- #[serde(default)]
39
- parent_id: Option<String>,
40
- #[serde(default)]
41
- children: Vec<String>,
42
- #[serde(default)]
43
- related: Vec<DocRelation>,
44
- #[serde(default = "default_auto_inject")]
45
- auto_inject: String,
46
- #[serde(default)]
47
- task_ids: Vec<String>,
48
- #[serde(default)]
49
- source: FrontmatterSource,
50
- #[serde(default)]
51
- has_bom: bool,
52
- #[serde(default = "default_line_ending")]
53
- line_ending: String,
54
- #[serde(default = "default_split_level")]
55
- split_level: u8,
56
- #[serde(alias = "date", alias = "created", alias = "publishDate")]
57
- created_at: String,
58
- #[serde(alias = "lastmod", alias = "modified", alias = "last_update")]
59
- updated_at: String,
60
- #[serde(default)]
61
- content_hash: String,
62
- #[serde(default, skip_serializing_if = "Option::is_none")]
63
- verification: Option<Verification>,
64
- /// `description` accepts several common frontmatter aliases on read;
65
- /// write always emits the canonical `description` key. Not a field on
66
- /// `DocMetadata` itself (no storage-layer concept of "description" yet)
67
- /// — captured here purely so a value under any alias round-trips into
68
- /// `extra["description"]` instead of being silently dropped.
69
- #[serde(
70
- default,
71
- alias = "excerpt",
72
- alias = "summary",
73
- alias = "abstract",
74
- skip_serializing_if = "Option::is_none"
75
- )]
76
- description: Option<String>,
77
-
78
- /// Every YAML key not covered by a named field above, preserved for
79
- /// round-trip fidelity.
80
- #[serde(flatten)]
81
- extra: HashMap<String, Value>,
82
- }
83
-
84
- fn default_auto_inject() -> String {
85
- "auto".to_string()
86
- }
87
-
88
- fn default_line_ending() -> String {
89
- "lf".to_string()
90
- }
91
-
92
- fn default_split_level() -> u8 {
93
- super::split::DEFAULT_SPLIT_LEVEL
94
- }
95
-
96
- /// `source:` sub-block in frontmatter. Deliberately excludes
97
- /// `DocSource::frontmatter`/`frontmatter_trailing_eol` (see module docs
98
- /// header and the `t123.1` design note: these two fields existed only to
99
- /// stash a stripped *user* frontmatter block under the old 2-file format;
100
- /// in the new format the frontmatter itself IS the metadata, so there's
101
- /// nothing left to stash).
102
- #[derive(Debug, Clone, Default, Serialize, Deserialize)]
103
- struct FrontmatterSource {
104
- #[serde(default)]
105
- origin: String,
106
- #[serde(default, skip_serializing_if = "Option::is_none")]
107
- original_path: Option<String>,
108
- #[serde(default, skip_serializing_if = "Option::is_none")]
109
- canonical_hash: Option<String>,
110
- }
111
-
112
- impl From<&DocMetadata> for FrontmatterDoc {
113
- fn from(doc: &DocMetadata) -> Self {
114
- let mut extra = doc.extra.clone();
115
- let description = extra
116
- .remove("description")
117
- .and_then(|v| v.as_str().map(str::to_string));
118
- FrontmatterDoc {
119
- id: doc.id.clone(),
120
- title: doc.title.clone(),
121
- doc_type: doc.doc_type.clone(),
122
- tags: doc.tags.clone(),
123
- scope_paths: doc.scope_paths.clone(),
124
- parent_id: doc.parent_id.clone(),
125
- children: doc.children.clone(),
126
- related: doc.related.clone(),
127
- auto_inject: doc.auto_inject.clone(),
128
- task_ids: doc.task_ids.clone(),
129
- source: FrontmatterSource {
130
- origin: doc.source.origin.clone(),
131
- original_path: doc.source.original_path.clone(),
132
- canonical_hash: doc.source.canonical_hash.clone(),
133
- },
134
- has_bom: doc.has_bom,
135
- line_ending: doc.line_ending.clone(),
136
- split_level: doc.split_level,
137
- created_at: doc.created_at.clone(),
138
- updated_at: doc.updated_at.clone(),
139
- content_hash: doc.content_hash.clone(),
140
- verification: doc.verification.clone(),
141
- description,
142
- extra,
143
- }
144
- }
145
- }
146
-
147
- impl FrontmatterDoc {
148
- /// Converts back into a [`DocMetadata`], filling in the storage-layer
149
- /// fields (`version`, `slug`, `sections`) that don't live in
150
- /// frontmatter. `slug` is derived by the caller from the filename;
151
- /// `sections` is always computed fresh by [`super::split::compute_sections`]
152
- /// after this call.
153
- fn into_doc_metadata(mut self, slug: String) -> DocMetadata {
154
- if let Some(description) = self.description.take() {
155
- self.extra
156
- .insert("description".to_string(), Value::String(description));
157
- }
158
- DocMetadata {
159
- version: DOC_SCHEMA_VERSION,
160
- id: self.id,
161
- slug,
162
- title: self.title,
163
- doc_type: self.doc_type,
164
- tags: self.tags,
165
- scope_paths: self.scope_paths,
166
- parent_id: self.parent_id,
167
- children: self.children,
168
- related: self.related,
169
- auto_inject: self.auto_inject,
170
- task_ids: self.task_ids,
171
- source: DocSource {
172
- origin: self.source.origin,
173
- original_path: self.source.original_path,
174
- canonical_hash: self.source.canonical_hash,
175
- frontmatter: None,
176
- frontmatter_trailing_eol: true,
177
- },
178
- has_bom: self.has_bom,
179
- line_ending: self.line_ending,
180
- split_level: self.split_level,
181
- sections: Vec::new(),
182
- created_at: self.created_at,
183
- updated_at: self.updated_at,
184
- content_hash: self.content_hash,
185
- verification: self.verification,
186
- extra: self.extra,
187
- }
188
- }
189
- }
190
-
191
- /// Converts `doc` into a YAML frontmatter string, **without** the enclosing
192
- /// `---` fences (callers wrap it — see [`write_frontmatter_doc`]). Never
193
- /// includes `sections[]`, `version`, or `slug` (see module docs).
194
- pub fn serialize_frontmatter(doc: &DocMetadata) -> Result<String> {
195
- let fm = FrontmatterDoc::from(doc);
196
- serde_yaml::to_string(&fm).context("Failed to serialize document frontmatter")
197
- }
198
-
199
- /// Parses a YAML frontmatter block (the text between the `---` fences,
200
- /// exclusive) back into a [`DocMetadata`]. `slug` is supplied by the caller
201
- /// (derived from the filename, not stored in frontmatter itself).
202
- pub fn deserialize_frontmatter(yaml_str: &str, slug: &str) -> Result<DocMetadata> {
203
- let fm: FrontmatterDoc =
204
- serde_yaml::from_str(yaml_str).context("Failed to parse document frontmatter as YAML")?;
205
- Ok(fm.into_doc_metadata(slug.to_string()))
206
- }
207
-
208
- /// Splits a `_doc.<slug>.md` file's raw content into `(frontmatter_yaml,
209
- /// body)`. Returns `None` for the frontmatter half when the content doesn't
210
- /// start with a `---` fence (old-format body-only file, or a document that
211
- /// somehow lost its frontmatter).
212
- fn split_frontmatter_and_body(content: &str) -> (Option<&str>, &str) {
213
- let Some(after_open) = content.strip_prefix("---\n") else {
214
- return (None, content);
215
- };
216
- // Find the closing fence: a line that is exactly "---" on its own line.
217
- let mut search_from = 0usize;
218
- loop {
219
- let Some(rel_idx) = after_open[search_from..].find("\n---") else {
220
- return (None, content);
221
- };
222
- let idx = search_from + rel_idx;
223
- // `idx` points at the '\n' right before "---". The fence itself
224
- // starts at idx+1.
225
- let fence_start = idx + 1;
226
- let after_fence = &after_open[fence_start + 3..];
227
- // The closing fence line must end the line here: either end of
228
- // string, '\n', or '\r\n'.
229
- if after_fence.is_empty() {
230
- return (Some(&after_open[..idx]), "");
231
- }
232
- if let Some(rest) = after_fence.strip_prefix('\n') {
233
- return (Some(&after_open[..idx]), rest);
234
- }
235
- if let Some(rest) = after_fence.strip_prefix("\r\n") {
236
- return (Some(&after_open[..idx]), rest);
237
- }
238
- // Not actually a fence line (e.g. "----" or "--- foo") — keep
239
- // searching past it.
240
- search_from = fence_start + 3;
241
- }
242
- }
243
-
244
- /// Reads a `_doc.<slug>.md` file and splits it into `(metadata, body)`.
245
- /// Returns `Ok(None)` when the file does not exist. Returns `Ok(Some((doc,
246
- /// body)))` with `doc.sections` empty — callers must compute sections
247
- /// on-demand from `body` (t123.2; see `super::read_doc`).
248
- ///
249
- /// Returns `Err` when the file exists, starts with a `---` fence, but the
250
- /// enclosed YAML fails to parse (corrupt frontmatter) — this is distinct
251
- /// from "no frontmatter at all" (which is a migration signal handled by the
252
- /// caller, not an error here).
253
- pub fn read_frontmatter_doc(path: &Path, slug: &str) -> Result<Option<(DocMetadata, String)>> {
254
- let content = match std::fs::read_to_string(path) {
255
- Ok(c) => c,
256
- Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
257
- Err(e) => {
258
- return Err(e).with_context(|| format!("Failed to read document: {}", path.display()))
259
- }
260
- };
261
- let (Some(fm_yaml), body) = split_frontmatter_and_body(&content) else {
262
- return Ok(None);
263
- };
264
- let doc = deserialize_frontmatter(fm_yaml, slug)?;
265
- Ok(Some((doc, body.to_string())))
266
- }
267
-
268
- /// Writes a single `_doc.<slug>.md` file: YAML frontmatter (fenced by
269
- /// `---`) followed by `body` verbatim. `doc.sections` is never
270
- /// serialized (see module docs) regardless of what it currently holds.
271
- pub fn write_frontmatter_doc(path: &Path, doc: &DocMetadata, body: &str) -> Result<()> {
272
- let fm_yaml = serialize_frontmatter(doc)?;
273
- let content = format!("---\n{fm_yaml}---\n{body}");
274
- crate::storage::atomic_write(path, content.as_bytes())
275
- .with_context(|| format!("Failed to write document: {}", path.display()))
276
- }
277
-
278
- #[cfg(test)]
279
- mod tests {
280
- use super::*;
281
- use crate::storage::docs::model::{CodeRef, VerificationItem};
282
-
283
- fn sample_doc() -> DocMetadata {
284
- let mut doc = DocMetadata::new(
285
- "doc-20260718-120000-123456".to_string(),
286
- "my-slug".to_string(),
287
- "Document Title".to_string(),
288
- "spec".to_string(),
289
- "2026-07-18T12:00:00Z".to_string(),
290
- );
291
- doc.tags = vec!["auth".to_string(), "security".to_string()];
292
- doc.scope_paths = vec!["src/auth/".to_string()];
293
- doc.task_ids = vec!["t42".to_string(), "t43".to_string()];
294
- doc.parent_id = Some("doc-20260710-000000-000001".to_string());
295
- doc.related = vec![DocRelation {
296
- id: "doc-other".to_string(),
297
- rel: "supersedes".to_string(),
298
- }];
299
- doc.source.origin = "authored".to_string();
300
- doc.source.canonical_hash = Some("abc123".to_string());
301
- doc.content_hash = "def456".to_string();
302
- doc.updated_at = "2026-07-18T15:30:00Z".to_string();
303
- doc
304
- }
305
-
306
- #[test]
307
- fn serialize_frontmatter_roundtrips_core_fields() {
308
- let doc = sample_doc();
309
- let yaml = serialize_frontmatter(&doc).unwrap();
310
- let back = deserialize_frontmatter(&yaml, &doc.slug).unwrap();
311
-
312
- assert_eq!(back.id, doc.id);
313
- assert_eq!(back.slug, doc.slug);
314
- assert_eq!(back.title, doc.title);
315
- assert_eq!(back.doc_type, doc.doc_type);
316
- assert_eq!(back.tags, doc.tags);
317
- assert_eq!(back.scope_paths, doc.scope_paths);
318
- assert_eq!(back.task_ids, doc.task_ids);
319
- assert_eq!(back.parent_id, doc.parent_id);
320
- assert_eq!(back.related, doc.related);
321
- assert_eq!(back.source.origin, doc.source.origin);
322
- assert_eq!(back.source.canonical_hash, doc.source.canonical_hash);
323
- assert_eq!(back.content_hash, doc.content_hash);
324
- assert_eq!(back.created_at, doc.created_at);
325
- assert_eq!(back.updated_at, doc.updated_at);
326
- }
327
-
328
- #[test]
329
- fn serialize_frontmatter_never_includes_sections_version_or_slug() {
330
- let mut doc = sample_doc();
331
- doc.sections = vec![super::super::model::SectionIndex {
332
- seq: 0,
333
- heading: String::new(),
334
- level: 0,
335
- byte_offset: 0,
336
- byte_length: 10,
337
- content_hash: "h".to_string(),
338
- }];
339
- let yaml = serialize_frontmatter(&doc).unwrap();
340
- assert!(
341
- !yaml.contains("sections"),
342
- "sections[] must never be written to frontmatter: {yaml}"
343
- );
344
- assert!(
345
- !yaml.contains("version:"),
346
- "schema version must not be in frontmatter: {yaml}"
347
- );
348
- assert!(
349
- !yaml.lines().any(|l| l.starts_with("slug:")),
350
- "slug must not be in frontmatter (derived from filename): {yaml}"
351
- );
352
- }
353
-
354
- #[test]
355
- fn verification_round_trips_through_yaml_frontmatter() {
356
- let mut doc = sample_doc();
357
- doc.verification = Some(Verification {
358
- status: "in_progress".to_string(),
359
- created_at: "2026-07-18T12:00:00Z".to_string(),
360
- updated_at: "2026-07-18T12:00:00Z".to_string(),
361
- items: vec![VerificationItem {
362
- fragment_seq: Some(1),
363
- heading: "Section Title".to_string(),
364
- status: "verified".to_string(),
365
- impl_refs: vec![CodeRef {
366
- path: "src/foo.rs".to_string(),
367
- lines: Some("10-50".to_string()),
368
- label: None,
369
- }],
370
- test_refs: vec![CodeRef {
371
- path: "tests/foo.rs".to_string(),
372
- lines: None,
373
- label: None,
374
- }],
375
- reviewer: None,
376
- verified_at: Some("2026-07-18T12:00:00Z".to_string()),
377
- notes: String::new(),
378
- content_hash_at_verify: Some("abc123".to_string()),
379
- category: "section".to_string(),
380
- sub_items: Vec::new(),
381
- label: None,
382
- }],
383
- });
384
-
385
- let yaml = serialize_frontmatter(&doc).unwrap();
386
- let back = deserialize_frontmatter(&yaml, &doc.slug).unwrap();
387
- let v = back.verification.expect("verification must round-trip");
388
- assert_eq!(v.status, "in_progress");
389
- assert_eq!(v.items.len(), 1);
390
- assert_eq!(v.items[0].fragment_seq, Some(1));
391
- assert_eq!(v.items[0].impl_refs[0].path, "src/foo.rs");
392
- assert_eq!(v.items[0].impl_refs[0].lines.as_deref(), Some("10-50"));
393
- assert_eq!(v.items[0].test_refs[0].path, "tests/foo.rs");
394
- assert_eq!(v.items[0].content_hash_at_verify.as_deref(), Some("abc123"));
395
- }
396
-
397
- #[test]
398
- fn deserialize_frontmatter_accepts_documented_aliases() {
399
- let yaml = "id: doc-1\n\
400
- title: T\n\
401
- doc_type: note\n\
402
- date: 2026-01-01T00:00:00Z\n\
403
- lastmod: 2026-01-02T00:00:00Z\n\
404
- excerpt: A short summary\n";
405
- let doc = deserialize_frontmatter(yaml, "slug-1").unwrap();
406
- assert_eq!(doc.created_at, "2026-01-01T00:00:00Z");
407
- assert_eq!(doc.updated_at, "2026-01-02T00:00:00Z");
408
- assert_eq!(
409
- doc.extra.get("description").and_then(|v| v.as_str()),
410
- Some("A short summary")
411
- );
412
- }
413
-
414
- #[test]
415
- fn deserialize_frontmatter_preserves_unknown_keys_in_extra() {
416
- let yaml = "id: doc-1\n\
417
- title: T\n\
418
- doc_type: note\n\
419
- created_at: 2026-01-01T00:00:00Z\n\
420
- updated_at: 2026-01-01T00:00:00Z\n\
421
- custom_field: hello\n\
422
- another: 42\n";
423
- let doc = deserialize_frontmatter(yaml, "slug-1").unwrap();
424
- assert_eq!(
425
- doc.extra.get("custom_field").and_then(|v| v.as_str()),
426
- Some("hello")
427
- );
428
- assert_eq!(doc.extra.get("another").and_then(|v| v.as_i64()), Some(42));
429
- }
430
-
431
- #[test]
432
- fn extra_fields_round_trip_through_write_then_read() {
433
- let mut doc = sample_doc();
434
- doc.extra.insert(
435
- "custom_field".to_string(),
436
- Value::String("hello".to_string()),
437
- );
438
- let yaml = serialize_frontmatter(&doc).unwrap();
439
- assert!(yaml.contains("custom_field"));
440
- let back = deserialize_frontmatter(&yaml, &doc.slug).unwrap();
441
- assert_eq!(
442
- back.extra.get("custom_field").and_then(|v| v.as_str()),
443
- Some("hello")
444
- );
445
- }
446
-
447
- #[test]
448
- fn write_then_read_frontmatter_doc_roundtrip() {
449
- let tmp = tempfile::TempDir::new().unwrap();
450
- let path = tmp.path().join("_doc.my-slug.md");
451
- let doc = sample_doc();
452
- let body = "# Document Title\n\n## Section 1\n\nBody text.\n";
453
-
454
- write_frontmatter_doc(&path, &doc, body).unwrap();
455
- let (back_doc, back_body) = read_frontmatter_doc(&path, &doc.slug)
456
- .unwrap()
457
- .expect("file must exist");
458
-
459
- assert_eq!(back_doc.id, doc.id);
460
- assert_eq!(back_doc.title, doc.title);
461
- assert_eq!(back_body, body);
462
- assert!(
463
- back_doc.sections.is_empty(),
464
- "sections must not be persisted/parsed from frontmatter"
465
- );
466
- }
467
-
468
- #[test]
469
- fn read_frontmatter_doc_missing_file_is_none() {
470
- let tmp = tempfile::TempDir::new().unwrap();
471
- let path = tmp.path().join("_doc.nope.md");
472
- assert!(read_frontmatter_doc(&path, "nope").unwrap().is_none());
473
- }
474
-
475
- #[test]
476
- fn read_frontmatter_doc_without_frontmatter_is_none() {
477
- let tmp = tempfile::TempDir::new().unwrap();
478
- let path = tmp.path().join("_doc.old-format.md");
479
- std::fs::write(&path, "# Just a plain body\n\nNo frontmatter here.\n").unwrap();
480
- assert!(
481
- read_frontmatter_doc(&path, "old-format").unwrap().is_none(),
482
- "a body-only file (no leading '---' fence) must be treated as \
483
- 'no frontmatter', not an error — the caller decides whether \
484
- that's an old-format migration or a genuine error case"
485
- );
486
- }
487
-
488
- #[test]
489
- fn read_frontmatter_doc_corrupt_yaml_is_error() {
490
- let tmp = tempfile::TempDir::new().unwrap();
491
- let path = tmp.path().join("_doc.corrupt.md");
492
- std::fs::write(&path, "---\nid: [unterminated\n---\nbody\n").unwrap();
493
- assert!(read_frontmatter_doc(&path, "corrupt").is_err());
494
- }
495
-
496
- #[test]
497
- fn write_frontmatter_doc_body_survives_headings_that_look_like_yaml() {
498
- let tmp = tempfile::TempDir::new().unwrap();
499
- let path = tmp.path().join("_doc.tricky.md");
500
- let doc = sample_doc();
501
- // Body containing a line that looks like a closing fence prefix
502
- // ("---") must not confuse the frontmatter/body split.
503
- let body = "# Title\n\n---\n\nA horizontal rule inside the body.\n";
504
-
505
- write_frontmatter_doc(&path, &doc, body).unwrap();
506
- let (_, back_body) = read_frontmatter_doc(&path, &doc.slug).unwrap().unwrap();
507
- assert_eq!(back_body, body);
508
- }
509
- }