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