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.
- package/README.md +31 -4
- package/bin/handoff-mcp.js +56 -13
- package/bin/resolve-binary.js +122 -0
- package/package.json +14 -9
- package/Cargo.lock +0 -686
- package/Cargo.toml +0 -30
- package/scripts/cargo-env.sh +0 -29
- package/scripts/handoff-memory-hook.py +0 -208
- package/scripts/install-local.sh +0 -109
- package/scripts/postinstall.js +0 -50
- package/scripts/sync-plugin-skills.sh +0 -35
- package/scripts/sync-plugin-version.sh +0 -85
- package/scripts/sync-workflow-inline.sh +0 -138
- package/src/cli.rs +0 -551
- package/src/context/injection.rs +0 -276
- package/src/context/mod.rs +0 -129
- package/src/lib.rs +0 -5
- package/src/main.rs +0 -157
- package/src/mcp/handlers/assignees.rs +0 -254
- package/src/mcp/handlers/auto_schedule.rs +0 -489
- package/src/mcp/handlers/bulk_update.rs +0 -155
- package/src/mcp/handlers/calendar.rs +0 -196
- package/src/mcp/handlers/capacity.rs +0 -318
- package/src/mcp/handlers/check_criterion.rs +0 -70
- package/src/mcp/handlers/config.rs +0 -402
- package/src/mcp/handlers/config_crud.rs +0 -183
- package/src/mcp/handlers/dashboard.rs +0 -214
- package/src/mcp/handlers/docs.rs +0 -2288
- package/src/mcp/handlers/docs_query.rs +0 -1335
- package/src/mcp/handlers/fork_session.rs +0 -91
- package/src/mcp/handlers/get_session.rs +0 -48
- package/src/mcp/handlers/get_task.rs +0 -53
- package/src/mcp/handlers/import_context.rs +0 -470
- package/src/mcp/handlers/init.rs +0 -28
- package/src/mcp/handlers/list_sessions.rs +0 -187
- package/src/mcp/handlers/list_tasks.rs +0 -308
- package/src/mcp/handlers/load_context.rs +0 -361
- package/src/mcp/handlers/log_time.rs +0 -67
- package/src/mcp/handlers/memory.rs +0 -961
- package/src/mcp/handlers/merge_sessions.rs +0 -103
- package/src/mcp/handlers/metrics.rs +0 -196
- package/src/mcp/handlers/milestones.rs +0 -102
- package/src/mcp/handlers/mod.rs +0 -140
- package/src/mcp/handlers/refer.rs +0 -307
- package/src/mcp/handlers/referrals.rs +0 -74
- package/src/mcp/handlers/save_context.rs +0 -354
- package/src/mcp/handlers/task_checklist.rs +0 -507
- package/src/mcp/handlers/timer.rs +0 -529
- package/src/mcp/handlers/update_session.rs +0 -197
- package/src/mcp/handlers/update_task.rs +0 -452
- package/src/mcp/mod.rs +0 -6
- package/src/mcp/protocol.rs +0 -41
- package/src/mcp/resources.rs +0 -57
- package/src/mcp/router.rs +0 -154
- package/src/mcp/tools.rs +0 -1522
- package/src/mcp/types.rs +0 -108
- package/src/setup.rs +0 -1212
- package/src/storage/config.rs +0 -578
- package/src/storage/docs/frontmatter.rs +0 -509
- package/src/storage/docs/mod.rs +0 -835
- package/src/storage/docs/model.rs +0 -708
- package/src/storage/docs/reassemble.rs +0 -167
- package/src/storage/docs/split.rs +0 -377
- package/src/storage/git.rs +0 -47
- package/src/storage/memory/injected.rs +0 -340
- package/src/storage/memory/mod.rs +0 -236
- package/src/storage/memory/model.rs +0 -127
- package/src/storage/mod.rs +0 -96
- package/src/storage/referrals.rs +0 -248
- package/src/storage/sessions.rs +0 -859
- package/src/storage/tasks.rs +0 -957
- 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
|
-
}
|