handoff-mcp-server 0.24.6 → 0.25.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/Cargo.lock +29 -3
- package/Cargo.toml +4 -2
- package/README.md +23 -9
- package/package.json +1 -1
- package/skills/handoff-docs/SKILL.md +181 -9
- package/src/context/injection.rs +55 -18
- package/src/context/mod.rs +1 -1
- package/src/mcp/handlers/auto_schedule.rs +1 -13
- package/src/mcp/handlers/capacity.rs +1 -14
- package/src/mcp/handlers/config.rs +13 -0
- package/src/mcp/handlers/docs.rs +832 -76
- package/src/mcp/handlers/docs_query.rs +4 -4
- package/src/mcp/handlers/memory.rs +88 -13
- package/src/mcp/handlers/mod.rs +1 -0
- package/src/mcp/handlers/task_checklist.rs +17 -5
- package/src/mcp/tools.rs +28 -7
- package/src/storage/config.rs +217 -5
- package/src/storage/docs/frontmatter.rs +509 -0
- package/src/storage/docs/mod.rs +346 -108
- package/src/storage/docs/model.rs +186 -15
- package/src/storage/memory/mod.rs +1 -0
- package/src/storage/memory/model.rs +38 -7
package/src/storage/docs/mod.rs
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
//! Document management: splitting a single authored Markdown body into
|
|
2
2
|
//! in-memory sections, and persisting documents to `.handoff/docs/` as a
|
|
3
|
-
//!
|
|
4
|
-
//!
|
|
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).
|
|
5
7
|
//!
|
|
6
8
|
//! Layout:
|
|
7
9
|
//!
|
|
8
10
|
//! ```text
|
|
9
11
|
//! .handoff/docs/
|
|
10
|
-
//! _doc.<slug>.
|
|
11
|
-
//! _doc.<slug>.md # full document body (pure Markdown, never split into files)
|
|
12
|
+
//! _doc.<slug>.md # YAML frontmatter (metadata) + full document body
|
|
12
13
|
//! injected/
|
|
13
14
|
//! <session-id>.json # per-session "already injected" sidecar
|
|
14
15
|
//! ```
|
|
@@ -16,10 +17,26 @@
|
|
|
16
17
|
//! `slug` is a human-readable, caller-supplied name (`[a-z0-9-]`, max
|
|
17
18
|
//! [`model::MAX_SLUG_LEN`] chars) used purely for file naming so `ls
|
|
18
19
|
//! .handoff/docs/` is self-describing. The stable `id` (timestamp-based)
|
|
19
|
-
//! stays inside the
|
|
20
|
+
//! stays inside the frontmatter for family-tree/task-link references;
|
|
20
21
|
//! [`find_doc_by_id`] resolves an `id` back to its document when the slug
|
|
21
22
|
//! isn't known by the caller.
|
|
22
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
|
+
//!
|
|
23
40
|
//! See `wiki/130-document-management.md` §3-4 for the full storage
|
|
24
41
|
//! architecture and data model.
|
|
25
42
|
//!
|
|
@@ -28,6 +45,7 @@
|
|
|
28
45
|
//! projects created before this feature shipped are unaffected until they
|
|
29
46
|
//! first call `doc_save`.
|
|
30
47
|
|
|
48
|
+
pub mod frontmatter;
|
|
31
49
|
pub mod model;
|
|
32
50
|
pub mod reassemble;
|
|
33
51
|
pub mod split;
|
|
@@ -37,7 +55,8 @@ use std::path::{Path, PathBuf};
|
|
|
37
55
|
use anyhow::{bail, Context, Result};
|
|
38
56
|
|
|
39
57
|
pub use model::{
|
|
40
|
-
CodeRef, DocMetadata, DocRelation, DocSource, SectionIndex,
|
|
58
|
+
CodeRef, DocMetadata, DocRelation, DocSource, SectionIndex, SubItem, Verification,
|
|
59
|
+
VerificationItem,
|
|
41
60
|
};
|
|
42
61
|
|
|
43
62
|
/// Path to the `docs/` directory inside a `.handoff/` dir.
|
|
@@ -76,6 +95,8 @@ pub fn validate_slug(slug: &str) -> Result<()> {
|
|
|
76
95
|
Ok(())
|
|
77
96
|
}
|
|
78
97
|
|
|
98
|
+
/// Legacy JSON sidecar path (`_doc.<slug>.json`). Only used by the
|
|
99
|
+
/// migration path (t123.3) — new writes never create this file.
|
|
79
100
|
fn doc_meta_path(handoff_dir: &Path, slug: &str) -> PathBuf {
|
|
80
101
|
docs_dir(handoff_dir).join(format!("_doc.{slug}.json"))
|
|
81
102
|
}
|
|
@@ -84,38 +105,66 @@ fn doc_body_path(handoff_dir: &Path, slug: &str) -> PathBuf {
|
|
|
84
105
|
docs_dir(handoff_dir).join(format!("_doc.{slug}.md"))
|
|
85
106
|
}
|
|
86
107
|
|
|
87
|
-
/// Write a document's metadata
|
|
88
|
-
/// `docs/` lazily.
|
|
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.
|
|
89
117
|
pub fn write_doc(handoff_dir: &Path, doc: &DocMetadata) -> Result<PathBuf> {
|
|
90
118
|
ensure_docs_dir(handoff_dir)?;
|
|
91
|
-
let path =
|
|
92
|
-
let
|
|
93
|
-
|
|
94
|
-
.with_context(|| format!("Failed to write document: {}", path.display()))?;
|
|
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)?;
|
|
95
122
|
Ok(path)
|
|
96
123
|
}
|
|
97
124
|
|
|
98
125
|
/// Write a document's full body to `_doc.<slug>.md` atomically, creating
|
|
99
126
|
/// `docs/` lazily. `body` is written exactly as given — no re-rendering —
|
|
100
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.
|
|
101
136
|
pub fn write_doc_body(handoff_dir: &Path, slug: &str, body: &str) -> Result<PathBuf> {
|
|
102
137
|
ensure_docs_dir(handoff_dir)?;
|
|
103
138
|
let path = doc_body_path(handoff_dir, slug);
|
|
104
|
-
|
|
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())
|
|
105
148
|
.with_context(|| format!("Failed to write document body: {}", path.display()))?;
|
|
106
149
|
Ok(path)
|
|
107
150
|
}
|
|
108
151
|
|
|
109
|
-
/// Read a document's full body from `_doc.<slug>.md
|
|
110
|
-
/// when the file does not exist.
|
|
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.
|
|
111
156
|
pub fn read_doc_body(handoff_dir: &Path, slug: &str) -> Result<Option<String>> {
|
|
112
157
|
let path = doc_body_path(handoff_dir, slug);
|
|
113
|
-
match
|
|
114
|
-
Ok(
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
Err(e).
|
|
118
|
-
|
|
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),
|
|
119
168
|
}
|
|
120
169
|
}
|
|
121
170
|
|
|
@@ -131,38 +180,125 @@ pub fn delete_doc_body(handoff_dir: &Path, slug: &str) -> Result<bool> {
|
|
|
131
180
|
Ok(true)
|
|
132
181
|
}
|
|
133
182
|
|
|
134
|
-
///
|
|
135
|
-
/// file
|
|
136
|
-
///
|
|
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).
|
|
137
249
|
///
|
|
138
|
-
///
|
|
139
|
-
///
|
|
140
|
-
///
|
|
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.
|
|
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.
|
|
149
253
|
pub fn read_doc(handoff_dir: &Path, slug: &str) -> Result<Option<DocMetadata>> {
|
|
150
|
-
let
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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);
|
|
155
290
|
}
|
|
291
|
+
doc.content_hash = lexsim::content_hash(body);
|
|
156
292
|
}
|
|
157
293
|
|
|
158
|
-
/// Read every document in `docs
|
|
159
|
-
///
|
|
160
|
-
///
|
|
161
|
-
///
|
|
162
|
-
///
|
|
163
|
-
///
|
|
164
|
-
///
|
|
165
|
-
///
|
|
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).
|
|
166
302
|
pub fn read_all_docs(handoff_dir: &Path) -> Result<Vec<DocMetadata>> {
|
|
167
303
|
let dir = docs_dir(handoff_dir);
|
|
168
304
|
if !dir.exists() {
|
|
@@ -182,17 +318,23 @@ pub fn read_all_docs(handoff_dir: &Path) -> Result<Vec<DocMetadata>> {
|
|
|
182
318
|
continue;
|
|
183
319
|
}
|
|
184
320
|
let name = entry.file_name().to_string_lossy().to_string();
|
|
185
|
-
if !name.starts_with("_doc.") || !name.ends_with(".
|
|
321
|
+
if !name.starts_with("_doc.") || !name.ends_with(".md") {
|
|
186
322
|
continue;
|
|
187
323
|
}
|
|
188
|
-
let
|
|
189
|
-
|
|
190
|
-
|
|
324
|
+
let Some(slug) = name
|
|
325
|
+
.strip_prefix("_doc.")
|
|
326
|
+
.and_then(|s| s.strip_suffix(".md"))
|
|
327
|
+
else {
|
|
328
|
+
continue;
|
|
191
329
|
};
|
|
192
|
-
|
|
193
|
-
docs.push(doc)
|
|
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(_) => {}
|
|
194
337
|
}
|
|
195
|
-
// Unparseable file: skip silently (lenient read, mirrors memory).
|
|
196
338
|
}
|
|
197
339
|
Ok(docs)
|
|
198
340
|
}
|
|
@@ -235,17 +377,35 @@ pub fn batch_resolve_docs(
|
|
|
235
377
|
.collect())
|
|
236
378
|
}
|
|
237
379
|
|
|
238
|
-
/// Delete a document's metadata
|
|
239
|
-
///
|
|
240
|
-
///
|
|
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.
|
|
241
390
|
pub fn delete_doc(handoff_dir: &Path, slug: &str) -> Result<bool> {
|
|
242
|
-
let
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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)
|
|
249
409
|
}
|
|
250
410
|
|
|
251
411
|
#[cfg(test)]
|
|
@@ -306,26 +466,13 @@ mod tests {
|
|
|
306
466
|
doc.task_ids = vec!["T-79".to_string()];
|
|
307
467
|
doc.has_bom = true;
|
|
308
468
|
doc.line_ending = "crlf".to_string();
|
|
309
|
-
doc.source.frontmatter = Some("title: Foo\n".to_string());
|
|
310
|
-
doc.sections = vec![
|
|
311
|
-
SectionIndex {
|
|
312
|
-
seq: 0,
|
|
313
|
-
heading: String::new(),
|
|
314
|
-
level: 0,
|
|
315
|
-
byte_offset: 0,
|
|
316
|
-
byte_length: 10,
|
|
317
|
-
content_hash: "hash0".to_string(),
|
|
318
|
-
},
|
|
319
|
-
SectionIndex {
|
|
320
|
-
seq: 1,
|
|
321
|
-
heading: "アーキテクチャ".to_string(),
|
|
322
|
-
level: 1,
|
|
323
|
-
byte_offset: 10,
|
|
324
|
-
byte_length: 20,
|
|
325
|
-
content_hash: "hash1".to_string(),
|
|
326
|
-
},
|
|
327
|
-
];
|
|
328
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();
|
|
329
476
|
write_doc(&h, &doc).unwrap();
|
|
330
477
|
|
|
331
478
|
let back = read_doc(&h, "session-loop-verification")
|
|
@@ -338,21 +485,24 @@ mod tests {
|
|
|
338
485
|
assert_eq!(back.tags, doc.tags);
|
|
339
486
|
assert_eq!(back.scope_paths, doc.scope_paths);
|
|
340
487
|
assert_eq!(back.task_ids, doc.task_ids);
|
|
341
|
-
assert_eq!(
|
|
488
|
+
assert_eq!(
|
|
489
|
+
back.sections.len(),
|
|
490
|
+
2,
|
|
491
|
+
"sections recomputed on read: {:?}",
|
|
492
|
+
back.sections
|
|
493
|
+
);
|
|
342
494
|
assert_eq!(back.sections[1].heading, "アーキテクチャ");
|
|
343
|
-
assert_eq!(back.sections[1].byte_offset, 10);
|
|
344
495
|
assert_eq!(back.auto_inject, "auto");
|
|
345
496
|
assert!(back.parent_id.is_none());
|
|
346
497
|
assert!(back.has_bom, "has_bom must round-trip through write/read");
|
|
347
498
|
assert_eq!(back.line_ending, "crlf");
|
|
348
|
-
assert_eq!(
|
|
349
|
-
back.source.frontmatter.as_deref(),
|
|
350
|
-
Some("title: Foo\n"),
|
|
351
|
-
"source.frontmatter must round-trip through write/read"
|
|
352
|
-
);
|
|
353
499
|
|
|
354
|
-
//
|
|
500
|
+
// Exactly one file on disk for this document (single-file
|
|
501
|
+
// frontmatter format — no JSON sidecar).
|
|
355
502
|
assert!(docs_dir(&h)
|
|
503
|
+
.join("_doc.session-loop-verification.md")
|
|
504
|
+
.exists());
|
|
505
|
+
assert!(!docs_dir(&h)
|
|
356
506
|
.join("_doc.session-loop-verification.json")
|
|
357
507
|
.exists());
|
|
358
508
|
}
|
|
@@ -377,27 +527,24 @@ mod tests {
|
|
|
377
527
|
let tmp = TempDir::new().unwrap();
|
|
378
528
|
let h = handoff(&tmp);
|
|
379
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.
|
|
380
533
|
std::fs::write(docs_dir(&h).join("_doc.doc-bad.json"), b"{not json").unwrap();
|
|
381
|
-
// A body file sitting alongside must never be mistaken for metadata.
|
|
382
|
-
write_doc_body(&h, "doc-good", "body text").unwrap();
|
|
383
534
|
|
|
384
535
|
let all = read_all_docs(&h).unwrap();
|
|
385
|
-
assert_eq!(all.len(), 1, "
|
|
536
|
+
assert_eq!(all.len(), 1, "lone json-only file ignored");
|
|
386
537
|
assert_eq!(all[0].id, "doc-good");
|
|
387
538
|
}
|
|
388
539
|
|
|
389
|
-
///
|
|
390
|
-
///
|
|
391
|
-
///
|
|
392
|
-
///
|
|
393
|
-
///
|
|
394
|
-
///
|
|
395
|
-
/// section states no real v4 documents exist outside dev test data), not
|
|
396
|
-
/// a graceful migration path — asserting it here so a regression toward
|
|
397
|
-
/// "v4 docs silently vanish" is caught, and so the behavior stays
|
|
398
|
-
/// documented in code rather than only in review notes.
|
|
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.
|
|
399
546
|
#[test]
|
|
400
|
-
fn
|
|
547
|
+
fn read_all_docs_ignores_lone_legacy_json_with_no_paired_md() {
|
|
401
548
|
let tmp = TempDir::new().unwrap();
|
|
402
549
|
let h = handoff(&tmp);
|
|
403
550
|
write_doc(&h, &sample_doc("doc-good", "doc-good")).unwrap();
|
|
@@ -421,17 +568,108 @@ mod tests {
|
|
|
421
568
|
|
|
422
569
|
assert!(
|
|
423
570
|
read_doc(&h, "old-spec").unwrap().is_none(),
|
|
424
|
-
"a
|
|
571
|
+
"a lone json sidecar with no paired .md body is not readable/migratable"
|
|
425
572
|
);
|
|
426
573
|
let all = read_all_docs(&h).unwrap();
|
|
427
574
|
assert_eq!(
|
|
428
575
|
all.len(),
|
|
429
576
|
1,
|
|
430
|
-
"the
|
|
577
|
+
"the lone json file must be silently skipped, not surfaced as an error or a warning"
|
|
431
578
|
);
|
|
432
579
|
assert_eq!(all[0].id, "doc-good");
|
|
433
580
|
}
|
|
434
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
|
+
|
|
435
673
|
#[test]
|
|
436
674
|
fn find_doc_by_id_scans_all_docs() {
|
|
437
675
|
let tmp = TempDir::new().unwrap();
|