handoff-mcp-server 0.20.0 → 0.22.1

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,478 @@
1
+ //! Document management: splitting a single authored Markdown body into
2
+ //! in-memory sections, and persisting documents to `.handoff/docs/` as a
3
+ //! 2-file slug-based pair (v5 rearchitecture,
4
+ //! wiki/130-document-management.md §3.1).
5
+ //!
6
+ //! Layout:
7
+ //!
8
+ //! ```text
9
+ //! .handoff/docs/
10
+ //! _doc.<slug>.json # document metadata (incl. `sections[]` byte-offset index)
11
+ //! _doc.<slug>.md # full document body (pure Markdown, never split into files)
12
+ //! injected/
13
+ //! <session-id>.json # per-session "already injected" sidecar
14
+ //! ```
15
+ //!
16
+ //! `slug` is a human-readable, caller-supplied name (`[a-z0-9-]`, max
17
+ //! [`model::MAX_SLUG_LEN`] chars) used purely for file naming so `ls
18
+ //! .handoff/docs/` is self-describing. The stable `id` (timestamp-based)
19
+ //! stays inside the metadata JSON for family-tree/task-link references;
20
+ //! [`find_doc_by_id`] resolves an `id` back to its document when the slug
21
+ //! isn't known by the caller.
22
+ //!
23
+ //! See `wiki/130-document-management.md` §3-4 for the full storage
24
+ //! architecture and data model.
25
+ //!
26
+ //! All writes go through [`crate::storage::atomic_write`] and `docs/` is
27
+ //! created lazily on first write (mirrors `src/storage/memory/mod.rs`), so
28
+ //! projects created before this feature shipped are unaffected until they
29
+ //! first call `doc_save`.
30
+
31
+ pub mod model;
32
+ pub mod reassemble;
33
+ pub mod split;
34
+
35
+ use std::path::{Path, PathBuf};
36
+
37
+ use anyhow::{bail, Context, Result};
38
+
39
+ pub use model::{
40
+ CodeRef, DocMetadata, DocRelation, DocSource, SectionIndex, Verification, VerificationItem,
41
+ };
42
+
43
+ /// Path to the `docs/` directory inside a `.handoff/` dir.
44
+ pub fn docs_dir(handoff_dir: &Path) -> PathBuf {
45
+ handoff_dir.join("docs")
46
+ }
47
+
48
+ /// Ensure `docs/` exists, creating it lazily. Mirrors
49
+ /// `memory::ensure_memory_dir` so projects initialized before this feature
50
+ /// shipped never had a `docs/` dir until the first `doc_save`.
51
+ pub fn ensure_docs_dir(handoff_dir: &Path) -> Result<PathBuf> {
52
+ let dir = docs_dir(handoff_dir);
53
+ std::fs::create_dir_all(&dir)
54
+ .with_context(|| format!("Failed to create docs dir: {}", dir.display()))?;
55
+ Ok(dir)
56
+ }
57
+
58
+ /// Validates a `slug`: only `[a-z0-9-]`, length 1..=[`model::MAX_SLUG_LEN`].
59
+ /// Used by `doc_save` to reject a bad slug before any file is written.
60
+ pub fn validate_slug(slug: &str) -> Result<()> {
61
+ if slug.is_empty() {
62
+ bail!("slug must not be empty");
63
+ }
64
+ if slug.len() > model::MAX_SLUG_LEN {
65
+ bail!(
66
+ "slug '{slug}' exceeds max length of {} characters",
67
+ model::MAX_SLUG_LEN
68
+ );
69
+ }
70
+ if !slug
71
+ .bytes()
72
+ .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
73
+ {
74
+ bail!("slug '{slug}' must contain only lowercase letters, digits, and hyphens ([a-z0-9-])");
75
+ }
76
+ Ok(())
77
+ }
78
+
79
+ fn doc_meta_path(handoff_dir: &Path, slug: &str) -> PathBuf {
80
+ docs_dir(handoff_dir).join(format!("_doc.{slug}.json"))
81
+ }
82
+
83
+ fn doc_body_path(handoff_dir: &Path, slug: &str) -> PathBuf {
84
+ docs_dir(handoff_dir).join(format!("_doc.{slug}.md"))
85
+ }
86
+
87
+ /// Write a document's metadata to `_doc.<slug>.json` atomically, creating
88
+ /// `docs/` lazily.
89
+ pub fn write_doc(handoff_dir: &Path, doc: &DocMetadata) -> Result<PathBuf> {
90
+ ensure_docs_dir(handoff_dir)?;
91
+ let path = doc_meta_path(handoff_dir, &doc.slug);
92
+ let content = serde_json::to_string_pretty(doc).context("Failed to serialize document")?;
93
+ crate::storage::atomic_write(&path, content.as_bytes())
94
+ .with_context(|| format!("Failed to write document: {}", path.display()))?;
95
+ Ok(path)
96
+ }
97
+
98
+ /// Write a document's full body to `_doc.<slug>.md` atomically, creating
99
+ /// `docs/` lazily. `body` is written exactly as given — no re-rendering —
100
+ /// so it can be read back byte-identical via [`read_doc_body`].
101
+ pub fn write_doc_body(handoff_dir: &Path, slug: &str, body: &str) -> Result<PathBuf> {
102
+ ensure_docs_dir(handoff_dir)?;
103
+ let path = doc_body_path(handoff_dir, slug);
104
+ crate::storage::atomic_write(&path, body.as_bytes())
105
+ .with_context(|| format!("Failed to write document body: {}", path.display()))?;
106
+ Ok(path)
107
+ }
108
+
109
+ /// Read a document's full body from `_doc.<slug>.md`. Returns `Ok(None)`
110
+ /// when the file does not exist.
111
+ pub fn read_doc_body(handoff_dir: &Path, slug: &str) -> Result<Option<String>> {
112
+ let path = doc_body_path(handoff_dir, slug);
113
+ match std::fs::read_to_string(&path) {
114
+ Ok(content) => Ok(Some(content)),
115
+ Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
116
+ Err(e) => {
117
+ Err(e).with_context(|| format!("Failed to read document body: {}", path.display()))
118
+ }
119
+ }
120
+ }
121
+
122
+ /// Delete a document's body file (`_doc.<slug>.md`) by exact slug. Returns
123
+ /// `Ok(false)` when the file does not exist.
124
+ pub fn delete_doc_body(handoff_dir: &Path, slug: &str) -> Result<bool> {
125
+ let path = doc_body_path(handoff_dir, slug);
126
+ if !path.exists() {
127
+ return Ok(false);
128
+ }
129
+ std::fs::remove_file(&path)
130
+ .with_context(|| format!("Failed to delete document body: {}", path.display()))?;
131
+ Ok(true)
132
+ }
133
+
134
+ /// Read one document's metadata by exact slug. Returns `Ok(None)` when the
135
+ /// file does not exist or fails to parse (lenient read, same policy as
136
+ /// memory).
137
+ ///
138
+ /// Caution: `slug` is a required (non-`#[serde(default)]`) field on
139
+ /// [`DocMetadata`], so this is **not** a backward-compat path for real v4
140
+ /// documents (which had no `slug` field and used per-fragment physical
141
+ /// files). A genuine v4 `_doc.*.json` fails to deserialize under the v5
142
+ /// schema and is silently treated as `Ok(None)` here — i.e. it would
143
+ /// disappear from `doc_get`/`doc_list`/`doc_query` with no warning, not be
144
+ /// gracefully migrated. This repo's own migration plan
145
+ /// (wiki/130-document-management.md, "移行" section) deliberately scoped v4
146
+ /// migration out because no real v4 documents exist outside dev test data;
147
+ /// if that assumption ever changes, a real migration path is needed before
148
+ /// pointing this binary at a directory with genuine v4 documents.
149
+ pub fn read_doc(handoff_dir: &Path, slug: &str) -> Result<Option<DocMetadata>> {
150
+ let path = doc_meta_path(handoff_dir, slug);
151
+ match std::fs::read_to_string(&path) {
152
+ Ok(content) => Ok(serde_json::from_str::<DocMetadata>(&content).ok()),
153
+ Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
154
+ Err(e) => Err(e).with_context(|| format!("Failed to read document: {}", path.display())),
155
+ }
156
+ }
157
+
158
+ /// Read every document in `docs/`, skipping any `_doc.*.json` file that
159
+ /// fails to parse. `.md` body files and the `injected/` subdirectory are
160
+ /// ignored. Returns an empty vec when `docs/` does not exist (uninitialized
161
+ /// / feature-untouched projects).
162
+ ///
163
+ /// See the caution on [`read_doc`]: a real v4 document (no `slug` field)
164
+ /// fails to parse under the v5 schema and is skipped here silently, same as
165
+ /// any other corrupt file — it is not migrated or surfaced as a warning.
166
+ pub fn read_all_docs(handoff_dir: &Path) -> Result<Vec<DocMetadata>> {
167
+ let dir = docs_dir(handoff_dir);
168
+ if !dir.exists() {
169
+ return Ok(Vec::new());
170
+ }
171
+
172
+ let mut entries: Vec<_> = std::fs::read_dir(&dir)
173
+ .with_context(|| format!("Failed to read docs dir: {}", dir.display()))?
174
+ .filter_map(|e| e.ok())
175
+ .collect();
176
+ entries.sort_by_key(|e| e.file_name());
177
+
178
+ let mut docs = Vec::new();
179
+ for entry in entries {
180
+ let path = entry.path();
181
+ if !path.is_file() {
182
+ continue;
183
+ }
184
+ let name = entry.file_name().to_string_lossy().to_string();
185
+ if !name.starts_with("_doc.") || !name.ends_with(".json") {
186
+ continue;
187
+ }
188
+ let content = match std::fs::read_to_string(&path) {
189
+ Ok(c) => c,
190
+ Err(_) => continue,
191
+ };
192
+ if let Ok(doc) = serde_json::from_str::<DocMetadata>(&content) {
193
+ docs.push(doc);
194
+ }
195
+ // Unparseable file: skip silently (lenient read, mirrors memory).
196
+ }
197
+ Ok(docs)
198
+ }
199
+
200
+ /// Find a document by its stable `id` (not its file-naming `slug`), by
201
+ /// scanning every `_doc.*.json` in `docs/`. Used for backward-compat
202
+ /// lookups where a caller only has the `id` (e.g. family-tree
203
+ /// `parent_id`/`related[].id`, task-link reverse lookups) and not the slug.
204
+ /// Returns `Ok(None)` if no document with that `id` exists.
205
+ pub fn find_doc_by_id(handoff_dir: &Path, doc_id: &str) -> Result<Option<DocMetadata>> {
206
+ let docs = read_all_docs(handoff_dir)?;
207
+ Ok(docs.into_iter().find(|d| d.id == doc_id))
208
+ }
209
+
210
+ /// Delete a document's metadata file by exact slug. Returns `Ok(false)` when
211
+ /// the file does not exist. Does not touch the document's body file —
212
+ /// callers that want a full delete should also call [`delete_doc_body`].
213
+ pub fn delete_doc(handoff_dir: &Path, slug: &str) -> Result<bool> {
214
+ let path = doc_meta_path(handoff_dir, slug);
215
+ if !path.exists() {
216
+ return Ok(false);
217
+ }
218
+ std::fs::remove_file(&path)
219
+ .with_context(|| format!("Failed to delete document: {}", path.display()))?;
220
+ Ok(true)
221
+ }
222
+
223
+ #[cfg(test)]
224
+ mod tests {
225
+ use super::*;
226
+ use tempfile::TempDir;
227
+
228
+ fn handoff(tmp: &TempDir) -> PathBuf {
229
+ let dir = tmp.path().join(".handoff");
230
+ std::fs::create_dir_all(&dir).unwrap();
231
+ dir
232
+ }
233
+
234
+ fn sample_doc(id: &str, slug: &str) -> DocMetadata {
235
+ DocMetadata::new(
236
+ id.to_string(),
237
+ slug.to_string(),
238
+ "Session Loop Verification".to_string(),
239
+ "spec".to_string(),
240
+ "2026-07-11T14:30:00Z".to_string(),
241
+ )
242
+ }
243
+
244
+ #[test]
245
+ fn validate_slug_accepts_lowercase_digits_hyphen() {
246
+ assert!(validate_slug("doc-management-spec").is_ok());
247
+ assert!(validate_slug("a").is_ok());
248
+ assert!(validate_slug("a1-b2").is_ok());
249
+ }
250
+
251
+ #[test]
252
+ fn validate_slug_rejects_empty() {
253
+ assert!(validate_slug("").is_err());
254
+ }
255
+
256
+ #[test]
257
+ fn validate_slug_rejects_uppercase_and_underscore_and_space() {
258
+ assert!(validate_slug("Doc-Spec").is_err());
259
+ assert!(validate_slug("doc_spec").is_err());
260
+ assert!(validate_slug("doc spec").is_err());
261
+ }
262
+
263
+ #[test]
264
+ fn validate_slug_rejects_over_max_length() {
265
+ let too_long = "a".repeat(model::MAX_SLUG_LEN + 1);
266
+ assert!(validate_slug(&too_long).is_err());
267
+ let exactly_max = "a".repeat(model::MAX_SLUG_LEN);
268
+ assert!(validate_slug(&exactly_max).is_ok());
269
+ }
270
+
271
+ #[test]
272
+ fn write_then_read_doc_roundtrip() {
273
+ let tmp = TempDir::new().unwrap();
274
+ let h = handoff(&tmp);
275
+ let mut doc = sample_doc("doc-1", "session-loop-verification");
276
+ doc.tags = vec!["session-loop".to_string(), "verification".to_string()];
277
+ doc.scope_paths = vec!["src/mcp/handlers/".to_string()];
278
+ doc.task_ids = vec!["T-79".to_string()];
279
+ doc.has_bom = true;
280
+ doc.line_ending = "crlf".to_string();
281
+ doc.source.frontmatter = Some("title: Foo\n".to_string());
282
+ doc.sections = vec![
283
+ SectionIndex {
284
+ seq: 0,
285
+ heading: String::new(),
286
+ level: 0,
287
+ byte_offset: 0,
288
+ byte_length: 10,
289
+ content_hash: "hash0".to_string(),
290
+ },
291
+ SectionIndex {
292
+ seq: 1,
293
+ heading: "アーキテクチャ".to_string(),
294
+ level: 1,
295
+ byte_offset: 10,
296
+ byte_length: 20,
297
+ content_hash: "hash1".to_string(),
298
+ },
299
+ ];
300
+
301
+ write_doc(&h, &doc).unwrap();
302
+
303
+ let back = read_doc(&h, "session-loop-verification")
304
+ .unwrap()
305
+ .expect("doc must exist");
306
+ assert_eq!(back.id, "doc-1");
307
+ assert_eq!(back.slug, "session-loop-verification");
308
+ assert_eq!(back.title, "Session Loop Verification");
309
+ assert_eq!(back.doc_type, "spec");
310
+ assert_eq!(back.tags, doc.tags);
311
+ assert_eq!(back.scope_paths, doc.scope_paths);
312
+ assert_eq!(back.task_ids, doc.task_ids);
313
+ assert_eq!(back.sections.len(), 2);
314
+ assert_eq!(back.sections[1].heading, "アーキテクチャ");
315
+ assert_eq!(back.sections[1].byte_offset, 10);
316
+ assert_eq!(back.auto_inject, "auto");
317
+ assert!(back.parent_id.is_none());
318
+ assert!(back.has_bom, "has_bom must round-trip through write/read");
319
+ assert_eq!(back.line_ending, "crlf");
320
+ assert_eq!(
321
+ back.source.frontmatter.as_deref(),
322
+ Some("title: Foo\n"),
323
+ "source.frontmatter must round-trip through write/read"
324
+ );
325
+
326
+ // File is named by slug, not by id.
327
+ assert!(docs_dir(&h)
328
+ .join("_doc.session-loop-verification.json")
329
+ .exists());
330
+ }
331
+
332
+ #[test]
333
+ fn read_doc_missing_is_none() {
334
+ let tmp = TempDir::new().unwrap();
335
+ let h = handoff(&tmp);
336
+ assert!(read_doc(&h, "doc-nope").unwrap().is_none());
337
+ }
338
+
339
+ #[test]
340
+ fn read_all_docs_missing_dir_is_empty() {
341
+ let tmp = TempDir::new().unwrap();
342
+ let h = tmp.path().join(".handoff");
343
+ std::fs::create_dir_all(&h).unwrap();
344
+ assert!(read_all_docs(&h).unwrap().is_empty());
345
+ }
346
+
347
+ #[test]
348
+ fn read_all_docs_skips_corrupt_and_ignores_body_files() {
349
+ let tmp = TempDir::new().unwrap();
350
+ let h = handoff(&tmp);
351
+ write_doc(&h, &sample_doc("doc-good", "doc-good")).unwrap();
352
+ std::fs::write(docs_dir(&h).join("_doc.doc-bad.json"), b"{not json").unwrap();
353
+ // A body file sitting alongside must never be mistaken for metadata.
354
+ write_doc_body(&h, "doc-good", "body text").unwrap();
355
+
356
+ let all = read_all_docs(&h).unwrap();
357
+ assert_eq!(all.len(), 1, "corrupt doc skipped, body file ignored");
358
+ assert_eq!(all[0].id, "doc-good");
359
+ }
360
+
361
+ /// Caution (found in review): a genuine v4 `_doc.*.json` file (no
362
+ /// `slug` field — `slug` is new, required, in v5; `fragments` entries
363
+ /// with no `byte_offset`/`byte_length`/`content_hash`) fails to
364
+ /// deserialize as `DocMetadata` and is treated exactly like a corrupt
365
+ /// file: skipped silently, with no warning. This is a deliberate,
366
+ /// documented trade-off (wiki/130-document-management.md's migration
367
+ /// section states no real v4 documents exist outside dev test data), not
368
+ /// a graceful migration path — asserting it here so a regression toward
369
+ /// "v4 docs silently vanish" is caught, and so the behavior stays
370
+ /// documented in code rather than only in review notes.
371
+ #[test]
372
+ fn read_all_docs_silently_skips_real_v4_file_missing_slug() {
373
+ let tmp = TempDir::new().unwrap();
374
+ let h = handoff(&tmp);
375
+ write_doc(&h, &sample_doc("doc-good", "doc-good")).unwrap();
376
+
377
+ let real_v4_json = serde_json::json!({
378
+ "version": 1,
379
+ "id": "doc-v4",
380
+ "title": "Old Spec",
381
+ "doc_type": "spec",
382
+ "created_at": "2026-01-01T00:00:00Z",
383
+ "updated_at": "2026-01-01T00:00:00Z",
384
+ "fragments": [
385
+ { "seq": 0, "heading": "", "level": 0 }
386
+ ],
387
+ });
388
+ std::fs::write(
389
+ docs_dir(&h).join("_doc.old-spec.json"),
390
+ serde_json::to_vec(&real_v4_json).unwrap(),
391
+ )
392
+ .unwrap();
393
+
394
+ assert!(
395
+ read_doc(&h, "old-spec").unwrap().is_none(),
396
+ "a real v4 doc (no slug field) must not be readable under the v5 schema"
397
+ );
398
+ let all = read_all_docs(&h).unwrap();
399
+ assert_eq!(
400
+ all.len(),
401
+ 1,
402
+ "the v4 doc must be silently skipped, not surfaced as an error or a warning"
403
+ );
404
+ assert_eq!(all[0].id, "doc-good");
405
+ }
406
+
407
+ #[test]
408
+ fn find_doc_by_id_scans_all_docs() {
409
+ let tmp = TempDir::new().unwrap();
410
+ let h = handoff(&tmp);
411
+ write_doc(&h, &sample_doc("doc-1", "human-readable-slug")).unwrap();
412
+
413
+ let found = find_doc_by_id(&h, "doc-1")
414
+ .unwrap()
415
+ .expect("doc must be found by id");
416
+ assert_eq!(found.slug, "human-readable-slug");
417
+
418
+ assert!(find_doc_by_id(&h, "doc-nope").unwrap().is_none());
419
+ }
420
+
421
+ #[test]
422
+ fn delete_doc_removes_file_and_is_idempotent() {
423
+ let tmp = TempDir::new().unwrap();
424
+ let h = handoff(&tmp);
425
+ write_doc(&h, &sample_doc("doc-1", "doc-1")).unwrap();
426
+ assert!(delete_doc(&h, "doc-1").unwrap());
427
+ assert!(read_doc(&h, "doc-1").unwrap().is_none());
428
+ assert!(!delete_doc(&h, "doc-1").unwrap());
429
+ }
430
+
431
+ #[test]
432
+ fn lazy_dir_creation_on_first_doc_write() {
433
+ let tmp = TempDir::new().unwrap();
434
+ let h = tmp.path().join(".handoff");
435
+ std::fs::create_dir_all(&h).unwrap();
436
+ assert!(!docs_dir(&h).exists());
437
+ write_doc(&h, &sample_doc("doc-1", "doc-1")).unwrap();
438
+ assert!(docs_dir(&h).exists());
439
+ }
440
+
441
+ #[test]
442
+ fn write_then_read_doc_body_roundtrip() {
443
+ let tmp = TempDir::new().unwrap();
444
+ let h = handoff(&tmp);
445
+ write_doc_body(&h, "my-slug", "# Title\n\nBody text.\n").unwrap();
446
+
447
+ let back = read_doc_body(&h, "my-slug").unwrap().expect("body exists");
448
+ assert_eq!(back, "# Title\n\nBody text.\n");
449
+ assert!(docs_dir(&h).join("_doc.my-slug.md").exists());
450
+ }
451
+
452
+ #[test]
453
+ fn read_doc_body_missing_is_none() {
454
+ let tmp = TempDir::new().unwrap();
455
+ let h = handoff(&tmp);
456
+ assert!(read_doc_body(&h, "nope").unwrap().is_none());
457
+ }
458
+
459
+ #[test]
460
+ fn delete_doc_body_removes_file_and_is_idempotent() {
461
+ let tmp = TempDir::new().unwrap();
462
+ let h = handoff(&tmp);
463
+ write_doc_body(&h, "my-slug", "body").unwrap();
464
+ assert!(delete_doc_body(&h, "my-slug").unwrap());
465
+ assert!(read_doc_body(&h, "my-slug").unwrap().is_none());
466
+ assert!(!delete_doc_body(&h, "my-slug").unwrap());
467
+ }
468
+
469
+ #[test]
470
+ fn lazy_dir_creation_on_first_doc_body_write() {
471
+ let tmp = TempDir::new().unwrap();
472
+ let h = tmp.path().join(".handoff");
473
+ std::fs::create_dir_all(&h).unwrap();
474
+ assert!(!docs_dir(&h).exists());
475
+ write_doc_body(&h, "my-slug", "body").unwrap();
476
+ assert!(docs_dir(&h).exists());
477
+ }
478
+ }