handoff-mcp-server 0.26.0 → 0.28.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,835 +0,0 @@
1
- //! Document management: splitting a single authored Markdown body into
2
- //! in-memory sections, and persisting documents to `.handoff/docs/` as a
3
- //! single frontmatter+body Markdown file per document (frontmatter
4
- //! migration, t123.1-t123.3 — supersedes the earlier 2-file
5
- //! `_doc.<slug>.json` + `_doc.<slug>.md` pair, wiki/130-document-management.md
6
- //! §3.1).
7
- //!
8
- //! Layout:
9
- //!
10
- //! ```text
11
- //! .handoff/docs/
12
- //! _doc.<slug>.md # YAML frontmatter (metadata) + full document body
13
- //! injected/
14
- //! <session-id>.json # per-session "already injected" sidecar
15
- //! ```
16
- //!
17
- //! `slug` is a human-readable, caller-supplied name (`[a-z0-9-]`, max
18
- //! [`model::MAX_SLUG_LEN`] chars) used purely for file naming so `ls
19
- //! .handoff/docs/` is self-describing. The stable `id` (timestamp-based)
20
- //! stays inside the frontmatter for family-tree/task-link references;
21
- //! [`find_doc_by_id`] resolves an `id` back to its document when the slug
22
- //! isn't known by the caller.
23
- //!
24
- //! `sections[]` is never persisted — [`read_doc`]/[`read_all_docs`] always
25
- //! recompute it fresh from the body via [`split::split`] +
26
- //! [`split::compute_sections`], so a manual edit to the `.md` file can never
27
- //! leave a stale byte-offset index on disk (t123.2). `content_hash` is
28
- //! likewise recomputed on every read (not trusted from frontmatter) so drift
29
- //! detection (`doc_reassemble`, verification staleness) still works after a
30
- //! manual edit.
31
- //!
32
- //! **Migration**: a `_doc.<slug>.json` file next to `_doc.<slug>.md`
33
- //! indicates the old 2-file format. [`read_doc`]/[`read_all_docs`]
34
- //! transparently migrate it in place on first access (t123.3): the JSON
35
- //! metadata is folded into a frontmatter block prepended to the `.md` body,
36
- //! the `.json` file is deleted, and the migration is logged to stderr (this
37
- //! is a stdio-based MCP server, so stdout must stay clean JSON-RPC-only).
38
- //! Callers never need to know whether a document was migrated.
39
- //!
40
- //! See `wiki/130-document-management.md` §3-4 for the full storage
41
- //! architecture and data model.
42
- //!
43
- //! All writes go through [`crate::storage::atomic_write`] and `docs/` is
44
- //! created lazily on first write (mirrors `src/storage/memory/mod.rs`), so
45
- //! projects created before this feature shipped are unaffected until they
46
- //! first call `doc_save`.
47
-
48
- pub mod frontmatter;
49
- pub mod model;
50
- pub mod reassemble;
51
- pub mod split;
52
-
53
- use std::path::{Path, PathBuf};
54
-
55
- use anyhow::{bail, Context, Result};
56
-
57
- pub use model::{
58
- CodeRef, DocMetadata, DocRelation, DocSource, SectionIndex, SubItem, Verification,
59
- VerificationItem,
60
- };
61
-
62
- /// Path to the `docs/` directory inside a `.handoff/` dir.
63
- pub fn docs_dir(handoff_dir: &Path) -> PathBuf {
64
- handoff_dir.join("docs")
65
- }
66
-
67
- /// Ensure `docs/` exists, creating it lazily. Mirrors
68
- /// `memory::ensure_memory_dir` so projects initialized before this feature
69
- /// shipped never had a `docs/` dir until the first `doc_save`.
70
- pub fn ensure_docs_dir(handoff_dir: &Path) -> Result<PathBuf> {
71
- let dir = docs_dir(handoff_dir);
72
- std::fs::create_dir_all(&dir)
73
- .with_context(|| format!("Failed to create docs dir: {}", dir.display()))?;
74
- Ok(dir)
75
- }
76
-
77
- /// Validates a `slug`: only `[a-z0-9-]`, length 1..=[`model::MAX_SLUG_LEN`].
78
- /// Used by `doc_save` to reject a bad slug before any file is written.
79
- pub fn validate_slug(slug: &str) -> Result<()> {
80
- if slug.is_empty() {
81
- bail!("slug must not be empty");
82
- }
83
- if slug.len() > model::MAX_SLUG_LEN {
84
- bail!(
85
- "slug '{slug}' exceeds max length of {} characters",
86
- model::MAX_SLUG_LEN
87
- );
88
- }
89
- if !slug
90
- .bytes()
91
- .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
92
- {
93
- bail!("slug '{slug}' must contain only lowercase letters, digits, and hyphens ([a-z0-9-])");
94
- }
95
- Ok(())
96
- }
97
-
98
- /// Legacy JSON sidecar path (`_doc.<slug>.json`). Only used by the
99
- /// migration path (t123.3) — new writes never create this file.
100
- fn doc_meta_path(handoff_dir: &Path, slug: &str) -> PathBuf {
101
- docs_dir(handoff_dir).join(format!("_doc.{slug}.json"))
102
- }
103
-
104
- fn doc_body_path(handoff_dir: &Path, slug: &str) -> PathBuf {
105
- docs_dir(handoff_dir).join(format!("_doc.{slug}.md"))
106
- }
107
-
108
- /// Write a document's metadata as YAML frontmatter into `_doc.<slug>.md`,
109
- /// atomically, creating `docs/` lazily. Preserves whatever body currently
110
- /// exists on disk for this slug (callers that also change the body must
111
- /// call [`write_doc_body`] first — this is `doc_save`'s existing write
112
- /// order). A brand new document with no body on disk yet is written with an
113
- /// empty body.
114
- ///
115
- /// `doc.sections` is never persisted (t123.2) regardless of what it holds
116
- /// in memory when this is called.
117
- pub fn write_doc(handoff_dir: &Path, doc: &DocMetadata) -> Result<PathBuf> {
118
- ensure_docs_dir(handoff_dir)?;
119
- let path = doc_body_path(handoff_dir, &doc.slug);
120
- let body = read_doc_body(handoff_dir, &doc.slug)?.unwrap_or_default();
121
- frontmatter::write_frontmatter_doc(&path, doc, &body)?;
122
- Ok(path)
123
- }
124
-
125
- /// Write a document's full body to `_doc.<slug>.md` atomically, creating
126
- /// `docs/` lazily. `body` is written exactly as given — no re-rendering —
127
- /// so it can be read back byte-identical via [`read_doc_body`].
128
- ///
129
- /// This preserves whatever frontmatter already exists on disk for this
130
- /// slug (or writes no frontmatter at all for a brand-new file — the
131
- /// subsequent [`write_doc`] call in `doc_save`'s write order fills it in).
132
- /// Writing only the body without ever following up with [`write_doc`]
133
- /// would leave a frontmatter-less `.md` file, which reads back as "no
134
- /// frontmatter" (migration-signal territory) rather than a valid document —
135
- /// callers must always pair this with a `write_doc` call.
136
- pub fn write_doc_body(handoff_dir: &Path, slug: &str, body: &str) -> Result<PathBuf> {
137
- ensure_docs_dir(handoff_dir)?;
138
- let path = doc_body_path(handoff_dir, slug);
139
- let existing_doc = frontmatter::read_frontmatter_doc(&path, slug)?.map(|(doc, _)| doc);
140
- let content = match existing_doc {
141
- Some(doc) => {
142
- let fm_yaml = frontmatter::serialize_frontmatter(&doc)?;
143
- format!("---\n{fm_yaml}---\n{body}")
144
- }
145
- None => body.to_string(),
146
- };
147
- crate::storage::atomic_write(&path, content.as_bytes())
148
- .with_context(|| format!("Failed to write document body: {}", path.display()))?;
149
- Ok(path)
150
- }
151
-
152
- /// Read a document's full body from `_doc.<slug>.md` — the part *after* the
153
- /// YAML frontmatter block. Returns `Ok(None)` when the file does not exist.
154
- /// A file with no frontmatter (old-format body-only file, or a plain `.md`
155
- /// dropped in by hand) returns its entire content as the body.
156
- pub fn read_doc_body(handoff_dir: &Path, slug: &str) -> Result<Option<String>> {
157
- let path = doc_body_path(handoff_dir, slug);
158
- match frontmatter::read_frontmatter_doc(&path, slug) {
159
- Ok(Some((_, body))) => Ok(Some(body)),
160
- Ok(None) => match std::fs::read_to_string(&path) {
161
- Ok(content) => Ok(Some(content)),
162
- Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
163
- Err(e) => {
164
- Err(e).with_context(|| format!("Failed to read document body: {}", path.display()))
165
- }
166
- },
167
- Err(e) => Err(e),
168
- }
169
- }
170
-
171
- /// Delete a document's body file (`_doc.<slug>.md`) by exact slug. Returns
172
- /// `Ok(false)` when the file does not exist.
173
- pub fn delete_doc_body(handoff_dir: &Path, slug: &str) -> Result<bool> {
174
- let path = doc_body_path(handoff_dir, slug);
175
- if !path.exists() {
176
- return Ok(false);
177
- }
178
- std::fs::remove_file(&path)
179
- .with_context(|| format!("Failed to delete document body: {}", path.display()))?;
180
- Ok(true)
181
- }
182
-
183
- /// Migrates an old-format document (`_doc.<slug>.json` + `_doc.<slug>.md`
184
- /// body-only file) to the new single-file frontmatter format, in place
185
- /// (t123.3): reads the JSON metadata, reads the existing (frontmatter-less)
186
- /// body, writes a new `_doc.<slug>.md` with the metadata folded into a
187
- /// YAML frontmatter block prepended to that body, then deletes the `.json`
188
- /// sidecar. Logs the migration to stderr. Returns the migrated
189
- /// [`DocMetadata`] (with `sections` still empty — the caller computes those
190
- /// fresh, same as any other read).
191
- fn migrate_legacy_doc(handoff_dir: &Path, slug: &str) -> Result<DocMetadata> {
192
- let json_path = doc_meta_path(handoff_dir, slug);
193
- let json_content = std::fs::read_to_string(&json_path).with_context(|| {
194
- format!(
195
- "Failed to read legacy document metadata: {}",
196
- json_path.display()
197
- )
198
- })?;
199
- let mut doc: DocMetadata = serde_json::from_str(&json_content).with_context(|| {
200
- format!(
201
- "Failed to parse legacy document metadata: {}",
202
- json_path.display()
203
- )
204
- })?;
205
- // sections/version are storage-layer bookkeeping the frontmatter format
206
- // no longer persists (t123.2) — clear here so the migrated file starts
207
- // clean, matching what any other `write_doc` call would produce.
208
- doc.sections = Vec::new();
209
-
210
- let body_path = doc_body_path(handoff_dir, slug);
211
- let body = std::fs::read_to_string(&body_path).with_context(|| {
212
- format!(
213
- "Failed to read legacy document body: {}",
214
- body_path.display()
215
- )
216
- })?;
217
-
218
- frontmatter::write_frontmatter_doc(&body_path, &doc, &body)?;
219
- std::fs::remove_file(&json_path).with_context(|| {
220
- format!(
221
- "Failed to delete legacy document metadata after migration: {}",
222
- json_path.display()
223
- )
224
- })?;
225
-
226
- eprintln!(
227
- "handoff-mcp: migrated document '{slug}' (id={}) from JSON+MD sidecar format to \
228
- frontmatter MD",
229
- doc.id
230
- );
231
-
232
- Ok(doc)
233
- }
234
-
235
- /// Read one document by exact slug: parses YAML frontmatter from
236
- /// `_doc.<slug>.md`, transparently migrating an old-format
237
- /// `_doc.<slug>.json` + `_doc.<slug>.md` pair in place first if that's what
238
- /// is on disk (t123.3). Always recomputes `sections[]` fresh from the body
239
- /// (t123.2) and `content_hash` from the body's current bytes (drift
240
- /// detection stays correct after a manual edit) before returning.
241
- ///
242
- /// Returns `Ok(None)` when:
243
- /// - neither `_doc.<slug>.md` nor `_doc.<slug>.json` exists, or
244
- /// - `_doc.<slug>.md` exists with no frontmatter and no `.json` sidecar
245
- /// (a body-only leftover from a partially-completed migration, or a
246
- /// plain `.md` file dropped in by hand — logged as a warning, not
247
- /// silently ignored, since it's ambiguous whether this was ever meant to
248
- /// be a handoff document).
249
- ///
250
- /// Returns `Err` when a `.md` file has a `---` fence but the enclosed YAML
251
- /// fails to parse (corrupt frontmatter — a genuine error, not a migration
252
- /// signal), or when a legacy JSON sidecar exists but fails to parse/migrate.
253
- pub fn read_doc(handoff_dir: &Path, slug: &str) -> Result<Option<DocMetadata>> {
254
- let body_path = doc_body_path(handoff_dir, slug);
255
- let json_path = doc_meta_path(handoff_dir, slug);
256
-
257
- let parsed = frontmatter::read_frontmatter_doc(&body_path, slug)?;
258
- let (mut doc, body) = match parsed {
259
- Some((doc, body)) => (doc, body),
260
- None => {
261
- if !body_path.exists() {
262
- return Ok(None);
263
- }
264
- if !json_path.exists() {
265
- eprintln!(
266
- "handoff-mcp: document body file '{}' has no YAML frontmatter and no \
267
- legacy JSON sidecar to migrate from — skipping",
268
- body_path.display()
269
- );
270
- return Ok(None);
271
- }
272
- let migrated = migrate_legacy_doc(handoff_dir, slug)?;
273
- let body = read_doc_body(handoff_dir, slug)?.unwrap_or_default();
274
- (migrated, body)
275
- }
276
- };
277
-
278
- recompute_sections_and_hash(&mut doc, &body);
279
- Ok(Some(doc))
280
- }
281
-
282
- /// Recomputes `doc.sections` and `doc.content_hash` from `body` (t123.2):
283
- /// sections are never trusted from frontmatter (always empty there), and
284
- /// content_hash is recomputed rather than trusted so drift detection
285
- /// (`doc_reassemble`, verification staleness) reflects the body's actual
286
- /// current bytes even after a manual out-of-band edit.
287
- fn recompute_sections_and_hash(doc: &mut DocMetadata, body: &str) {
288
- if let Ok(split_doc) = split::split(body, doc.split_level) {
289
- doc.sections = split::compute_sections(&split_doc);
290
- }
291
- doc.content_hash = lexsim::content_hash(body);
292
- }
293
-
294
- /// Read every document in `docs/`: every `_doc.*.md` file (parsed via
295
- /// [`read_doc`], which transparently migrates any paired legacy `.json`
296
- /// sidecar first — t123.3). The `injected/` subdirectory is ignored. A
297
- /// `.md` file that fails to parse under [`read_doc`] (corrupt frontmatter,
298
- /// or a body-only leftover with no `.json` to migrate from) is skipped
299
- /// silently/with a warning respectively, same policy as [`read_doc`] itself
300
- /// applies per-file. Returns an empty vec when `docs/` does not exist
301
- /// (uninitialized / feature-untouched projects).
302
- pub fn read_all_docs(handoff_dir: &Path) -> Result<Vec<DocMetadata>> {
303
- let dir = docs_dir(handoff_dir);
304
- if !dir.exists() {
305
- return Ok(Vec::new());
306
- }
307
-
308
- let mut entries: Vec<_> = std::fs::read_dir(&dir)
309
- .with_context(|| format!("Failed to read docs dir: {}", dir.display()))?
310
- .filter_map(|e| e.ok())
311
- .collect();
312
- entries.sort_by_key(|e| e.file_name());
313
-
314
- let mut docs = Vec::new();
315
- for entry in entries {
316
- let path = entry.path();
317
- if !path.is_file() {
318
- continue;
319
- }
320
- let name = entry.file_name().to_string_lossy().to_string();
321
- if !name.starts_with("_doc.") || !name.ends_with(".md") {
322
- continue;
323
- }
324
- let Some(slug) = name
325
- .strip_prefix("_doc.")
326
- .and_then(|s| s.strip_suffix(".md"))
327
- else {
328
- continue;
329
- };
330
- match read_doc(handoff_dir, slug) {
331
- Ok(Some(doc)) => docs.push(doc),
332
- Ok(None) => {}
333
- // Corrupt frontmatter / failed migration: skip silently
334
- // (lenient read, mirrors memory) rather than failing the whole
335
- // listing over one bad file.
336
- Err(_) => {}
337
- }
338
- }
339
- Ok(docs)
340
- }
341
-
342
- /// Find a document by its stable `id` (not its file-naming `slug`), by
343
- /// scanning every `_doc.*.json` in `docs/`. Used for backward-compat
344
- /// lookups where a caller only has the `id` (e.g. family-tree
345
- /// `parent_id`/`related[].id`, task-link reverse lookups) and not the slug.
346
- /// Returns `Ok(None)` if no document with that `id` exists.
347
- pub fn find_doc_by_id(handoff_dir: &Path, doc_id: &str) -> Result<Option<DocMetadata>> {
348
- let docs = read_all_docs(handoff_dir)?;
349
- Ok(docs.into_iter().find(|d| d.id == doc_id))
350
- }
351
-
352
- /// Resolve every `link_type == "doc"` entry in `task_links` to its
353
- /// [`DocMetadata`], in one pass over `docs/` (via [`read_all_docs`]) rather
354
- /// than one [`find_doc_by_id`] scan per link. `task_links[].target` holds the
355
- /// document's stable `id` (see `crate::storage::tasks::sync_doc_task_links`),
356
- /// so lookup is by `id`, not `slug`. Links whose target doesn't resolve to an
357
- /// existing document (stale/dangling link) are silently skipped — callers
358
- /// that need to detect that should compare the input link count against the
359
- /// output length themselves.
360
- pub fn batch_resolve_docs(
361
- handoff_dir: &Path,
362
- task_links: &[crate::storage::tasks::TaskLink],
363
- ) -> Result<Vec<DocMetadata>> {
364
- let doc_ids: Vec<&str> = task_links
365
- .iter()
366
- .filter(|l| l.link_type == "doc")
367
- .map(|l| l.target.as_str())
368
- .collect();
369
- if doc_ids.is_empty() {
370
- return Ok(Vec::new());
371
- }
372
-
373
- let all_docs = read_all_docs(handoff_dir)?;
374
- Ok(doc_ids
375
- .iter()
376
- .filter_map(|id| all_docs.iter().find(|d| &d.id == id).cloned())
377
- .collect())
378
- }
379
-
380
- /// Delete a document's metadata by exact slug. In the single-file
381
- /// frontmatter format, metadata and body live in the same
382
- /// `_doc.<slug>.md` file, so this is equivalent to [`delete_doc_body`] —
383
- /// kept as a separate function (rather than folding callers onto one) so
384
- /// existing call sites that call both (`doc_delete`'s "delete body, then
385
- /// delete metadata" order) keep working unchanged: the second call is a
386
- /// no-op `Ok(false)` once the first has removed the file. Also removes a
387
- /// leftover legacy `_doc.<slug>.json` sidecar, if one still exists
388
- /// (e.g. a document deleted mid-migration). Returns `Ok(false)` when
389
- /// neither file existed.
390
- pub fn delete_doc(handoff_dir: &Path, slug: &str) -> Result<bool> {
391
- let md_path = doc_body_path(handoff_dir, slug);
392
- let json_path = doc_meta_path(handoff_dir, slug);
393
- let mut deleted = false;
394
- if md_path.exists() {
395
- std::fs::remove_file(&md_path)
396
- .with_context(|| format!("Failed to delete document: {}", md_path.display()))?;
397
- deleted = true;
398
- }
399
- if json_path.exists() {
400
- std::fs::remove_file(&json_path).with_context(|| {
401
- format!(
402
- "Failed to delete legacy document metadata: {}",
403
- json_path.display()
404
- )
405
- })?;
406
- deleted = true;
407
- }
408
- Ok(deleted)
409
- }
410
-
411
- #[cfg(test)]
412
- mod tests {
413
- use super::*;
414
- use tempfile::TempDir;
415
-
416
- fn handoff(tmp: &TempDir) -> PathBuf {
417
- let dir = tmp.path().join(".handoff");
418
- std::fs::create_dir_all(&dir).unwrap();
419
- dir
420
- }
421
-
422
- fn sample_doc(id: &str, slug: &str) -> DocMetadata {
423
- DocMetadata::new(
424
- id.to_string(),
425
- slug.to_string(),
426
- "Session Loop Verification".to_string(),
427
- "spec".to_string(),
428
- "2026-07-11T14:30:00Z".to_string(),
429
- )
430
- }
431
-
432
- #[test]
433
- fn validate_slug_accepts_lowercase_digits_hyphen() {
434
- assert!(validate_slug("doc-management-spec").is_ok());
435
- assert!(validate_slug("a").is_ok());
436
- assert!(validate_slug("a1-b2").is_ok());
437
- }
438
-
439
- #[test]
440
- fn validate_slug_rejects_empty() {
441
- assert!(validate_slug("").is_err());
442
- }
443
-
444
- #[test]
445
- fn validate_slug_rejects_uppercase_and_underscore_and_space() {
446
- assert!(validate_slug("Doc-Spec").is_err());
447
- assert!(validate_slug("doc_spec").is_err());
448
- assert!(validate_slug("doc spec").is_err());
449
- }
450
-
451
- #[test]
452
- fn validate_slug_rejects_over_max_length() {
453
- let too_long = "a".repeat(model::MAX_SLUG_LEN + 1);
454
- assert!(validate_slug(&too_long).is_err());
455
- let exactly_max = "a".repeat(model::MAX_SLUG_LEN);
456
- assert!(validate_slug(&exactly_max).is_ok());
457
- }
458
-
459
- #[test]
460
- fn write_then_read_doc_roundtrip() {
461
- let tmp = TempDir::new().unwrap();
462
- let h = handoff(&tmp);
463
- let mut doc = sample_doc("doc-1", "session-loop-verification");
464
- doc.tags = vec!["session-loop".to_string(), "verification".to_string()];
465
- doc.scope_paths = vec!["src/mcp/handlers/".to_string()];
466
- doc.task_ids = vec!["T-79".to_string()];
467
- doc.has_bom = true;
468
- doc.line_ending = "crlf".to_string();
469
-
470
- // v6 (frontmatter migration): sections are never persisted — they
471
- // are recomputed on every read from the body written via
472
- // `write_doc_body`, so a real body with a heading is needed here to
473
- // exercise that recomputation instead of hand-setting `sections`.
474
- let body = "Preamble.\r\n\r\n## アーキテクチャ\r\nSection body.\r\n";
475
- write_doc_body(&h, "session-loop-verification", body).unwrap();
476
- write_doc(&h, &doc).unwrap();
477
-
478
- let back = read_doc(&h, "session-loop-verification")
479
- .unwrap()
480
- .expect("doc must exist");
481
- assert_eq!(back.id, "doc-1");
482
- assert_eq!(back.slug, "session-loop-verification");
483
- assert_eq!(back.title, "Session Loop Verification");
484
- assert_eq!(back.doc_type, "spec");
485
- assert_eq!(back.tags, doc.tags);
486
- assert_eq!(back.scope_paths, doc.scope_paths);
487
- assert_eq!(back.task_ids, doc.task_ids);
488
- assert_eq!(
489
- back.sections.len(),
490
- 2,
491
- "sections recomputed on read: {:?}",
492
- back.sections
493
- );
494
- assert_eq!(back.sections[1].heading, "アーキテクチャ");
495
- assert_eq!(back.auto_inject, "auto");
496
- assert!(back.parent_id.is_none());
497
- assert!(back.has_bom, "has_bom must round-trip through write/read");
498
- assert_eq!(back.line_ending, "crlf");
499
-
500
- // Exactly one file on disk for this document (single-file
501
- // frontmatter format — no JSON sidecar).
502
- assert!(docs_dir(&h)
503
- .join("_doc.session-loop-verification.md")
504
- .exists());
505
- assert!(!docs_dir(&h)
506
- .join("_doc.session-loop-verification.json")
507
- .exists());
508
- }
509
-
510
- #[test]
511
- fn read_doc_missing_is_none() {
512
- let tmp = TempDir::new().unwrap();
513
- let h = handoff(&tmp);
514
- assert!(read_doc(&h, "doc-nope").unwrap().is_none());
515
- }
516
-
517
- #[test]
518
- fn read_all_docs_missing_dir_is_empty() {
519
- let tmp = TempDir::new().unwrap();
520
- let h = tmp.path().join(".handoff");
521
- std::fs::create_dir_all(&h).unwrap();
522
- assert!(read_all_docs(&h).unwrap().is_empty());
523
- }
524
-
525
- #[test]
526
- fn read_all_docs_skips_corrupt_and_ignores_body_files() {
527
- let tmp = TempDir::new().unwrap();
528
- let h = handoff(&tmp);
529
- write_doc(&h, &sample_doc("doc-good", "doc-good")).unwrap();
530
- // A lone legacy `.json` sidecar with no paired `.md` body is not a
531
- // migratable document (read_all_docs only iterates `.md` files) —
532
- // it must simply be ignored, not crash the scan.
533
- std::fs::write(docs_dir(&h).join("_doc.doc-bad.json"), b"{not json").unwrap();
534
-
535
- let all = read_all_docs(&h).unwrap();
536
- assert_eq!(all.len(), 1, "lone json-only file ignored");
537
- assert_eq!(all[0].id, "doc-good");
538
- }
539
-
540
- /// A lone legacy `_doc.*.json` file with no paired `_doc.*.md` body
541
- /// cannot be migrated (t123.3's migration reads both halves) — it is
542
- /// simply invisible to `read_all_docs`/`read_doc`, same as any other
543
- /// non-`.md` file in `docs/`. This is distinct from the "real" migration
544
- /// path exercised by [`read_doc_migrates_legacy_json_md_pair_in_place`],
545
- /// which requires both files to be present.
546
- #[test]
547
- fn read_all_docs_ignores_lone_legacy_json_with_no_paired_md() {
548
- let tmp = TempDir::new().unwrap();
549
- let h = handoff(&tmp);
550
- write_doc(&h, &sample_doc("doc-good", "doc-good")).unwrap();
551
-
552
- let real_v4_json = serde_json::json!({
553
- "version": 1,
554
- "id": "doc-v4",
555
- "title": "Old Spec",
556
- "doc_type": "spec",
557
- "created_at": "2026-01-01T00:00:00Z",
558
- "updated_at": "2026-01-01T00:00:00Z",
559
- "fragments": [
560
- { "seq": 0, "heading": "", "level": 0 }
561
- ],
562
- });
563
- std::fs::write(
564
- docs_dir(&h).join("_doc.old-spec.json"),
565
- serde_json::to_vec(&real_v4_json).unwrap(),
566
- )
567
- .unwrap();
568
-
569
- assert!(
570
- read_doc(&h, "old-spec").unwrap().is_none(),
571
- "a lone json sidecar with no paired .md body is not readable/migratable"
572
- );
573
- let all = read_all_docs(&h).unwrap();
574
- assert_eq!(
575
- all.len(),
576
- 1,
577
- "the lone json file must be silently skipped, not surfaced as an error or a warning"
578
- );
579
- assert_eq!(all[0].id, "doc-good");
580
- }
581
-
582
- /// t123.3: a genuine old-format `_doc.<slug>.json` + `_doc.<slug>.md`
583
- /// pair is transparently migrated in place on first `read_doc` access —
584
- /// the JSON metadata is folded into a YAML frontmatter block prepended
585
- /// to the existing body, and the `.json` sidecar is deleted.
586
- #[test]
587
- fn read_doc_migrates_legacy_json_md_pair_in_place() {
588
- let tmp = TempDir::new().unwrap();
589
- let h = handoff(&tmp);
590
- ensure_docs_dir(&h).unwrap();
591
- let slug = "legacy-doc";
592
- let legacy_json = serde_json::json!({
593
- "version": 2,
594
- "id": "doc-legacy-1",
595
- "slug": slug,
596
- "title": "Legacy Doc",
597
- "doc_type": "spec",
598
- "tags": ["old-format"],
599
- "created_at": "2026-01-01T00:00:00Z",
600
- "updated_at": "2026-01-02T00:00:00Z",
601
- "content_hash": "stale-hash-will-be-recomputed",
602
- });
603
- std::fs::write(
604
- docs_dir(&h).join(format!("_doc.{slug}.json")),
605
- serde_json::to_vec(&legacy_json).unwrap(),
606
- )
607
- .unwrap();
608
- let body = "# Legacy Doc\n\n## Old Section\n\nBody text.\n";
609
- std::fs::write(docs_dir(&h).join(format!("_doc.{slug}.md")), body).unwrap();
610
-
611
- let migrated = read_doc(&h, slug).unwrap().expect("must migrate and read");
612
- assert_eq!(migrated.id, "doc-legacy-1");
613
- assert_eq!(migrated.title, "Legacy Doc");
614
- assert_eq!(migrated.tags, vec!["old-format".to_string()]);
615
- assert_eq!(
616
- migrated.sections.len(),
617
- 3,
618
- "sections recomputed fresh from body post-migration (seq0 preamble + H1 + H2): {:?}",
619
- migrated.sections
620
- );
621
- assert_eq!(migrated.content_hash, lexsim::content_hash(body));
622
-
623
- // The .json sidecar must be gone; the .md file must now carry
624
- // frontmatter (starts with "---\n").
625
- assert!(!docs_dir(&h).join(format!("_doc.{slug}.json")).exists());
626
- let new_content =
627
- std::fs::read_to_string(docs_dir(&h).join(format!("_doc.{slug}.md"))).unwrap();
628
- assert!(new_content.starts_with("---\n"));
629
-
630
- // Re-reading must be stable (idempotent) and not re-migrate.
631
- let reread = read_doc(&h, slug).unwrap().expect("must still read");
632
- assert_eq!(reread.id, migrated.id);
633
- assert_eq!(reread.sections.len(), 3);
634
- }
635
-
636
- /// The migration path is also exercised transparently through
637
- /// `read_all_docs`, so a directory with a mix of already-migrated and
638
- /// legacy documents surfaces every document once, in the new format.
639
- #[test]
640
- fn read_all_docs_migrates_legacy_pairs_transparently() {
641
- let tmp = TempDir::new().unwrap();
642
- let h = handoff(&tmp);
643
- write_doc(&h, &sample_doc("doc-new", "already-new")).unwrap();
644
- write_doc_body(&h, "already-new", "# New\n\nBody.\n").unwrap();
645
-
646
- let legacy_json = serde_json::json!({
647
- "version": 2,
648
- "id": "doc-legacy-2",
649
- "slug": "legacy-two",
650
- "title": "Legacy Two",
651
- "doc_type": "note",
652
- "created_at": "2026-01-01T00:00:00Z",
653
- "updated_at": "2026-01-01T00:00:00Z",
654
- });
655
- std::fs::write(
656
- docs_dir(&h).join("_doc.legacy-two.json"),
657
- serde_json::to_vec(&legacy_json).unwrap(),
658
- )
659
- .unwrap();
660
- std::fs::write(
661
- docs_dir(&h).join("_doc.legacy-two.md"),
662
- "# Legacy Two\n\nBody.\n",
663
- )
664
- .unwrap();
665
-
666
- let all = read_all_docs(&h).unwrap();
667
- assert_eq!(all.len(), 2);
668
- assert!(all.iter().any(|d| d.id == "doc-new"));
669
- assert!(all.iter().any(|d| d.id == "doc-legacy-2"));
670
- assert!(!docs_dir(&h).join("_doc.legacy-two.json").exists());
671
- }
672
-
673
- #[test]
674
- fn find_doc_by_id_scans_all_docs() {
675
- let tmp = TempDir::new().unwrap();
676
- let h = handoff(&tmp);
677
- write_doc(&h, &sample_doc("doc-1", "human-readable-slug")).unwrap();
678
-
679
- let found = find_doc_by_id(&h, "doc-1")
680
- .unwrap()
681
- .expect("doc must be found by id");
682
- assert_eq!(found.slug, "human-readable-slug");
683
-
684
- assert!(find_doc_by_id(&h, "doc-nope").unwrap().is_none());
685
- }
686
-
687
- #[test]
688
- fn delete_doc_removes_file_and_is_idempotent() {
689
- let tmp = TempDir::new().unwrap();
690
- let h = handoff(&tmp);
691
- write_doc(&h, &sample_doc("doc-1", "doc-1")).unwrap();
692
- assert!(delete_doc(&h, "doc-1").unwrap());
693
- assert!(read_doc(&h, "doc-1").unwrap().is_none());
694
- assert!(!delete_doc(&h, "doc-1").unwrap());
695
- }
696
-
697
- #[test]
698
- fn lazy_dir_creation_on_first_doc_write() {
699
- let tmp = TempDir::new().unwrap();
700
- let h = tmp.path().join(".handoff");
701
- std::fs::create_dir_all(&h).unwrap();
702
- assert!(!docs_dir(&h).exists());
703
- write_doc(&h, &sample_doc("doc-1", "doc-1")).unwrap();
704
- assert!(docs_dir(&h).exists());
705
- }
706
-
707
- #[test]
708
- fn write_then_read_doc_body_roundtrip() {
709
- let tmp = TempDir::new().unwrap();
710
- let h = handoff(&tmp);
711
- write_doc_body(&h, "my-slug", "# Title\n\nBody text.\n").unwrap();
712
-
713
- let back = read_doc_body(&h, "my-slug").unwrap().expect("body exists");
714
- assert_eq!(back, "# Title\n\nBody text.\n");
715
- assert!(docs_dir(&h).join("_doc.my-slug.md").exists());
716
- }
717
-
718
- #[test]
719
- fn read_doc_body_missing_is_none() {
720
- let tmp = TempDir::new().unwrap();
721
- let h = handoff(&tmp);
722
- assert!(read_doc_body(&h, "nope").unwrap().is_none());
723
- }
724
-
725
- #[test]
726
- fn delete_doc_body_removes_file_and_is_idempotent() {
727
- let tmp = TempDir::new().unwrap();
728
- let h = handoff(&tmp);
729
- write_doc_body(&h, "my-slug", "body").unwrap();
730
- assert!(delete_doc_body(&h, "my-slug").unwrap());
731
- assert!(read_doc_body(&h, "my-slug").unwrap().is_none());
732
- assert!(!delete_doc_body(&h, "my-slug").unwrap());
733
- }
734
-
735
- #[test]
736
- fn lazy_dir_creation_on_first_doc_body_write() {
737
- let tmp = TempDir::new().unwrap();
738
- let h = tmp.path().join(".handoff");
739
- std::fs::create_dir_all(&h).unwrap();
740
- assert!(!docs_dir(&h).exists());
741
- write_doc_body(&h, "my-slug", "body").unwrap();
742
- assert!(docs_dir(&h).exists());
743
- }
744
-
745
- #[test]
746
- fn batch_resolve_docs_resolves_doc_links_by_id() {
747
- use crate::storage::tasks::TaskLink;
748
-
749
- let tmp = TempDir::new().unwrap();
750
- let h = handoff(&tmp);
751
- write_doc(&h, &sample_doc("doc-1", "doc-one")).unwrap();
752
- write_doc(&h, &sample_doc("doc-2", "doc-two")).unwrap();
753
-
754
- let links = vec![
755
- TaskLink {
756
- target: "doc-1".to_string(),
757
- link_type: "doc".to_string(),
758
- label: None,
759
- },
760
- TaskLink {
761
- target: "doc-2".to_string(),
762
- link_type: "doc".to_string(),
763
- label: None,
764
- },
765
- ];
766
-
767
- let resolved = batch_resolve_docs(&h, &links).unwrap();
768
- assert_eq!(resolved.len(), 2);
769
- let ids: Vec<&str> = resolved.iter().map(|d| d.id.as_str()).collect();
770
- assert!(ids.contains(&"doc-1"));
771
- assert!(ids.contains(&"doc-2"));
772
- }
773
-
774
- #[test]
775
- fn batch_resolve_docs_ignores_non_doc_link_types() {
776
- use crate::storage::tasks::TaskLink;
777
-
778
- let tmp = TempDir::new().unwrap();
779
- let h = handoff(&tmp);
780
- write_doc(&h, &sample_doc("doc-1", "doc-one")).unwrap();
781
-
782
- let links = vec![
783
- TaskLink {
784
- target: "doc-1".to_string(),
785
- link_type: "doc".to_string(),
786
- label: None,
787
- },
788
- TaskLink {
789
- target: "https://example.com".to_string(),
790
- link_type: "url".to_string(),
791
- label: None,
792
- },
793
- ];
794
-
795
- let resolved = batch_resolve_docs(&h, &links).unwrap();
796
- assert_eq!(resolved.len(), 1);
797
- assert_eq!(resolved[0].id, "doc-1");
798
- }
799
-
800
- #[test]
801
- fn batch_resolve_docs_skips_dangling_links() {
802
- use crate::storage::tasks::TaskLink;
803
-
804
- let tmp = TempDir::new().unwrap();
805
- let h = handoff(&tmp);
806
- write_doc(&h, &sample_doc("doc-1", "doc-one")).unwrap();
807
-
808
- let links = vec![
809
- TaskLink {
810
- target: "doc-1".to_string(),
811
- link_type: "doc".to_string(),
812
- label: None,
813
- },
814
- TaskLink {
815
- target: "doc-missing".to_string(),
816
- link_type: "doc".to_string(),
817
- label: None,
818
- },
819
- ];
820
-
821
- let resolved = batch_resolve_docs(&h, &links).unwrap();
822
- assert_eq!(resolved.len(), 1);
823
- assert_eq!(resolved[0].id, "doc-1");
824
- }
825
-
826
- #[test]
827
- fn batch_resolve_docs_empty_links_is_empty_without_reading_docs_dir() {
828
- let tmp = TempDir::new().unwrap();
829
- let h = tmp.path().join(".handoff");
830
- std::fs::create_dir_all(&h).unwrap();
831
- // docs/ dir does not exist at all — must not error.
832
- assert!(!docs_dir(&h).exists());
833
- assert!(batch_resolve_docs(&h, &[]).unwrap().is_empty());
834
- }
835
- }