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.
- package/Cargo.lock +52 -11
- package/Cargo.toml +3 -2
- package/README.md +31 -2
- package/package.json +1 -1
- package/skills/handoff/SKILL.md +24 -0
- package/skills/handoff-docs/SKILL.md +233 -0
- package/skills/handoff-load/SKILL.md +10 -2
- package/skills/handoff-memory/SKILL.md +20 -0
- package/src/context/injection.rs +239 -0
- package/src/context/mod.rs +129 -0
- package/src/lib.rs +1 -0
- package/src/mcp/handlers/docs.rs +1492 -0
- package/src/mcp/handlers/docs_query.rs +1335 -0
- package/src/mcp/handlers/get_task.rs +9 -0
- package/src/mcp/handlers/import_context.rs +1 -0
- package/src/mcp/handlers/memory.rs +33 -53
- package/src/mcp/handlers/mod.rs +15 -0
- package/src/mcp/handlers/update_task.rs +2 -0
- package/src/mcp/tools.rs +195 -0
- package/src/storage/docs/mod.rs +478 -0
- package/src/storage/docs/model.rs +537 -0
- package/src/storage/docs/reassemble.rs +167 -0
- package/src/storage/docs/split.rs +377 -0
- package/src/storage/mod.rs +1 -0
- package/src/storage/tasks.rs +131 -0
|
@@ -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
|
+
}
|