handoff-mcp-server 0.19.1 → 0.22.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 +52 -11
- package/Cargo.toml +2 -1
- package/README.md +140 -4
- package/package.json +5 -2
- package/scripts/sync-plugin-skills.sh +23 -1
- package/scripts/sync-workflow-inline.sh +138 -0
- package/skills/handoff/SKILL.md +61 -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/bulk_update.rs +30 -2
- package/src/mcp/handlers/docs.rs +1043 -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 +120 -13
- package/src/mcp/handlers/memory.rs +33 -53
- package/src/mcp/handlers/mod.rs +13 -0
- package/src/mcp/handlers/update_task.rs +22 -4
- package/src/mcp/tools.rs +185 -13
- 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 +197 -5
|
@@ -0,0 +1,1335 @@
|
|
|
1
|
+
//! MCP handlers for document context injection and bulk import — P1-6c
|
|
2
|
+
//! (t96.3): `handoff_doc_query`, `handoff_doc_analyze`, `handoff_doc_import`.
|
|
3
|
+
//!
|
|
4
|
+
//! `doc_query` mirrors `memory_query`'s ranking + per-session diff-injection
|
|
5
|
+
//! pattern (`crate::context::injection`), but injects at **fragment**
|
|
6
|
+
//! granularity with a staged `full`/`outline` payload depending on fragment
|
|
7
|
+
//! size (wiki/130-document-management.md §5.7, §7.1). `doc_analyze` /
|
|
8
|
+
//! `doc_import` implement the read-only-scan -> AI-review -> bulk-write
|
|
9
|
+
//! pattern used by `handoff_import_context` for tasks
|
|
10
|
+
//! (wiki/130-document-management.md §6.1), applied to Markdown documents.
|
|
11
|
+
//!
|
|
12
|
+
//! See `wiki/130-document-management.md` §5.7, §6.1 for the full spec.
|
|
13
|
+
|
|
14
|
+
use std::collections::BTreeMap;
|
|
15
|
+
use std::path::{Path, PathBuf};
|
|
16
|
+
|
|
17
|
+
use anyhow::{bail, Result};
|
|
18
|
+
use serde::{Deserialize, Serialize};
|
|
19
|
+
use serde_json::{json, Value};
|
|
20
|
+
|
|
21
|
+
use super::resolve_project_dir;
|
|
22
|
+
use crate::context::doc_corpus_cache;
|
|
23
|
+
use crate::context::injection::{filter_already_injected, rank_by_bm25_and_scope, RankConfig};
|
|
24
|
+
use crate::storage::docs::reassemble::extract_section;
|
|
25
|
+
use crate::storage::docs::split::{compute_sections, split, DEFAULT_SPLIT_LEVEL};
|
|
26
|
+
use crate::storage::docs::{
|
|
27
|
+
docs_dir, ensure_docs_dir, read_all_docs, read_doc, read_doc_body, validate_slug, write_doc,
|
|
28
|
+
write_doc_body, DocMetadata,
|
|
29
|
+
};
|
|
30
|
+
use crate::storage::ensure_handoff_exists;
|
|
31
|
+
use crate::storage::tasks::sync_doc_task_links;
|
|
32
|
+
|
|
33
|
+
/// Bonus added to a fragment's BM25 score when its parent document's
|
|
34
|
+
/// `scope_paths` prefix-matches one of the query's `file_paths`. Mirrors
|
|
35
|
+
/// `memory.rs`/`docs.rs`'s `SCOPE_PATH_BONUS`.
|
|
36
|
+
const SCOPE_PATH_BONUS: f64 = 2.0;
|
|
37
|
+
|
|
38
|
+
/// Extra bonus added when a fragment's parent document is linked to the
|
|
39
|
+
/// query's `task_id` (spec §5.7 ranking signal #1, "highest weight").
|
|
40
|
+
/// Deliberately larger than [`SCOPE_PATH_BONUS`] so a task-linked document
|
|
41
|
+
/// reliably outranks a merely scope-matching one.
|
|
42
|
+
const TASK_AFFINITY_BONUS: f64 = 5.0;
|
|
43
|
+
|
|
44
|
+
/// Default relevance floor for `doc_query`. Zero fragments are dropped purely
|
|
45
|
+
/// on score — the session-diff + `limit` truncation is what keeps noise down,
|
|
46
|
+
/// mirroring `doc_list`'s `DOC_QUERY_MIN_SCORE` rather than
|
|
47
|
+
/// `memory_query`'s hook-tuned floor (documents are explicitly authored/
|
|
48
|
+
/// imported, not free-form auto-captured notes).
|
|
49
|
+
const DOC_QUERY_MIN_SCORE: f64 = 0.0;
|
|
50
|
+
|
|
51
|
+
/// Default number of fragments `doc_query` returns per call when the caller
|
|
52
|
+
/// does not pass `limit`.
|
|
53
|
+
const DEFAULT_DOC_QUERY_LIMIT: usize = 5;
|
|
54
|
+
|
|
55
|
+
/// Fragment body token count at/below which `doc_query` injects the fragment
|
|
56
|
+
/// **full** (metadata + entire body); above this it injects **outline**
|
|
57
|
+
/// (metadata + heading only). Spec §7.1 default: 300.
|
|
58
|
+
const DOC_INLINE_THRESHOLD_TOKENS: usize = 300;
|
|
59
|
+
|
|
60
|
+
fn new_doc_id() -> String {
|
|
61
|
+
format!("doc-{}", chrono::Utc::now().format("%Y%m%d-%H%M%S-%6f"))
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
fn to_json(v: &Value) -> String {
|
|
65
|
+
serde_json::to_string_pretty(v).unwrap_or_else(|_| v.to_string())
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/// Read a `&[String]` from a JSON string-array argument (missing -> empty).
|
|
69
|
+
fn string_array(arguments: &Value, key: &str) -> Vec<String> {
|
|
70
|
+
arguments
|
|
71
|
+
.get(key)
|
|
72
|
+
.and_then(|v| v.as_array())
|
|
73
|
+
.map(|arr| {
|
|
74
|
+
arr.iter()
|
|
75
|
+
.filter_map(|v| v.as_str())
|
|
76
|
+
.map(str::to_string)
|
|
77
|
+
.collect()
|
|
78
|
+
})
|
|
79
|
+
.unwrap_or_default()
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ---------------------------------------------------------------------
|
|
83
|
+
// handoff_doc_query
|
|
84
|
+
// ---------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
/// One fragment-level "already injected" sidecar
|
|
87
|
+
/// (`.handoff/docs/injected/<session>.json`), keyed by `"<doc_id>#<seq>"` ->
|
|
88
|
+
/// injected `content_hash`. Deliberately fragment-scoped (not document-scoped
|
|
89
|
+
/// like memory's) since `doc_query` injects at fragment granularity — editing
|
|
90
|
+
/// one fragment must not suppress re-injection of its unrelated siblings.
|
|
91
|
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
92
|
+
struct DocInjectedSet {
|
|
93
|
+
#[serde(default)]
|
|
94
|
+
session_id: String,
|
|
95
|
+
#[serde(default)]
|
|
96
|
+
updated_at: String,
|
|
97
|
+
#[serde(default)]
|
|
98
|
+
injected: BTreeMap<String, String>,
|
|
99
|
+
/// Documents explicitly suppressed via `suppress_doc_ids` +
|
|
100
|
+
/// `suppress_until_changed: true` (spec §7.2.2): key = doc_id, value =
|
|
101
|
+
/// the document's `content_hash` at the moment it was suppressed.
|
|
102
|
+
/// Document-scoped (not fragment-scoped like `injected`) since the
|
|
103
|
+
/// caller suppresses a whole document; the suppression is lifted for
|
|
104
|
+
/// the whole document as soon as *any* of its fragments changes the
|
|
105
|
+
/// document-level `content_hash`.
|
|
106
|
+
#[serde(default)]
|
|
107
|
+
suppressed: BTreeMap<String, String>,
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
impl DocInjectedSet {
|
|
111
|
+
fn new(session_id: String, now: String) -> Self {
|
|
112
|
+
DocInjectedSet {
|
|
113
|
+
session_id,
|
|
114
|
+
updated_at: now,
|
|
115
|
+
injected: BTreeMap::new(),
|
|
116
|
+
suppressed: BTreeMap::new(),
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
fn key(doc_id: &str, seq: usize) -> String {
|
|
121
|
+
format!("{doc_id}#{seq}")
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
fn already_injected(&self, doc_id: &str, seq: usize, content_hash: &str) -> bool {
|
|
125
|
+
self.injected
|
|
126
|
+
.get(&Self::key(doc_id, seq))
|
|
127
|
+
.map(String::as_str)
|
|
128
|
+
== Some(content_hash)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
fn mark(&mut self, doc_id: &str, seq: usize, content_hash: &str) {
|
|
132
|
+
self.injected
|
|
133
|
+
.insert(Self::key(doc_id, seq), content_hash.to_string());
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/// True when `doc_id` was suppressed at exactly its current
|
|
137
|
+
/// `content_hash` — i.e. it hasn't changed since suppression, so it
|
|
138
|
+
/// stays suppressed.
|
|
139
|
+
fn is_suppressed(&self, doc_id: &str, content_hash: &str) -> bool {
|
|
140
|
+
self.suppressed.get(doc_id).map(String::as_str) == Some(content_hash)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
fn suppress(&mut self, doc_id: &str, content_hash: &str) {
|
|
144
|
+
self.suppressed
|
|
145
|
+
.insert(doc_id.to_string(), content_hash.to_string());
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
fn docs_injected_dir(handoff: &Path) -> PathBuf {
|
|
150
|
+
docs_dir(handoff).join("injected")
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/// Sanitize a session id into a safe single-path-component filename stem,
|
|
154
|
+
/// mirroring `crate::storage::memory::injected`'s scheme (readable prefix +
|
|
155
|
+
/// a hash of the raw id, so distinct ids never collide and no path
|
|
156
|
+
/// separator/`..` can escape `injected/`).
|
|
157
|
+
fn sanitize_session_id(session_id: &str) -> String {
|
|
158
|
+
let mut out = String::with_capacity(session_id.len());
|
|
159
|
+
for ch in session_id.chars() {
|
|
160
|
+
if ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-') {
|
|
161
|
+
out.push(ch);
|
|
162
|
+
} else if ch == '.' {
|
|
163
|
+
if !out.is_empty() {
|
|
164
|
+
out.push('.');
|
|
165
|
+
}
|
|
166
|
+
} else {
|
|
167
|
+
out.push('_');
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
let trimmed = out.trim_end_matches('.');
|
|
171
|
+
let prefix: String = trimmed.chars().take(96).collect();
|
|
172
|
+
let prefix = if prefix.is_empty() { "anon" } else { &prefix };
|
|
173
|
+
format!("{prefix}-{}", lexsim::fnv1a_hex(session_id.as_bytes()))
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
fn docs_injected_path(handoff: &Path, session_id: &str) -> PathBuf {
|
|
177
|
+
docs_injected_dir(handoff).join(format!("{}.json", sanitize_session_id(session_id)))
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
fn read_docs_injected_set(handoff: &Path, session_id: &str, now: &str) -> DocInjectedSet {
|
|
181
|
+
let path = docs_injected_path(handoff, session_id);
|
|
182
|
+
match std::fs::read_to_string(&path) {
|
|
183
|
+
Ok(content) => serde_json::from_str::<DocInjectedSet>(&content)
|
|
184
|
+
.unwrap_or_else(|_| DocInjectedSet::new(session_id.to_string(), now.to_string())),
|
|
185
|
+
Err(_) => DocInjectedSet::new(session_id.to_string(), now.to_string()),
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
fn write_docs_injected_set(handoff: &Path, set: &DocInjectedSet) -> Result<()> {
|
|
190
|
+
std::fs::create_dir_all(docs_injected_dir(handoff))?;
|
|
191
|
+
let path = docs_injected_path(handoff, &set.session_id);
|
|
192
|
+
let content = serde_json::to_string_pretty(set)?;
|
|
193
|
+
crate::storage::atomic_write(&path, content.as_bytes())?;
|
|
194
|
+
Ok(())
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/// One section candidate flattened out of every document's section manifest,
|
|
198
|
+
/// used to build the BM25 corpus and carry the parent doc's ranking-relevant
|
|
199
|
+
/// fields (scope_paths/task_ids) alongside each section. `body` is the
|
|
200
|
+
/// byte-sliced section text (v5: extracted in-memory from `_doc.<slug>.md`
|
|
201
|
+
/// via `sections[].byte_offset`/`byte_length`, not read from a separate
|
|
202
|
+
/// fragment file).
|
|
203
|
+
struct SectionCandidate<'a> {
|
|
204
|
+
doc: &'a DocMetadata,
|
|
205
|
+
seq: usize,
|
|
206
|
+
heading: String,
|
|
207
|
+
body: String,
|
|
208
|
+
content_hash: String,
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/// `handoff_doc_query` — inject document fragments relevant to the current
|
|
212
|
+
/// prompt/file/task, staged `full` (body) or `outline` (heading only)
|
|
213
|
+
/// depending on fragment size (spec §5.7, §7.1).
|
|
214
|
+
pub fn handle_doc_query(arguments: &Value) -> Result<String> {
|
|
215
|
+
let project_dir = resolve_project_dir(arguments)?;
|
|
216
|
+
let handoff = ensure_handoff_exists(&project_dir)?;
|
|
217
|
+
|
|
218
|
+
let text = arguments
|
|
219
|
+
.get("text")
|
|
220
|
+
.and_then(|v| v.as_str())
|
|
221
|
+
.unwrap_or("")
|
|
222
|
+
.to_string();
|
|
223
|
+
let file_paths = string_array(arguments, "file_paths");
|
|
224
|
+
let task_id = arguments
|
|
225
|
+
.get("task_id")
|
|
226
|
+
.and_then(|v| v.as_str())
|
|
227
|
+
.map(str::trim)
|
|
228
|
+
.filter(|s| !s.is_empty());
|
|
229
|
+
let session_id = arguments
|
|
230
|
+
.get("session_id")
|
|
231
|
+
.and_then(|v| v.as_str())
|
|
232
|
+
.map(str::trim)
|
|
233
|
+
.filter(|s| !s.is_empty());
|
|
234
|
+
let limit = arguments
|
|
235
|
+
.get("limit")
|
|
236
|
+
.and_then(|v| v.as_u64())
|
|
237
|
+
.map(|n| n as usize)
|
|
238
|
+
.filter(|n| *n > 0)
|
|
239
|
+
.unwrap_or(DEFAULT_DOC_QUERY_LIMIT);
|
|
240
|
+
let mark_injected = arguments
|
|
241
|
+
.get("mark_injected")
|
|
242
|
+
.and_then(|v| v.as_bool())
|
|
243
|
+
.unwrap_or(true);
|
|
244
|
+
let suppress_doc_ids = string_array(arguments, "suppress_doc_ids");
|
|
245
|
+
let suppress_until_changed = arguments
|
|
246
|
+
.get("suppress_until_changed")
|
|
247
|
+
.and_then(|v| v.as_bool())
|
|
248
|
+
.unwrap_or(false);
|
|
249
|
+
|
|
250
|
+
let docs = read_all_docs(&handoff)?;
|
|
251
|
+
if docs.is_empty() {
|
|
252
|
+
return Ok(to_json(&json!({ "documents": [], "injected_count": 0 })));
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Documents suppressed for this call: explicit `suppress_doc_ids`, plus
|
|
256
|
+
// (with a session_id) anything the sidecar remembers as suppressed at
|
|
257
|
+
// its still-current content_hash (spec §7.2.2 "session-scoped temporary
|
|
258
|
+
// suppression, until content_hash changes").
|
|
259
|
+
let now = chrono::Utc::now().to_rfc3339();
|
|
260
|
+
let injected_set = session_id.map(|sid| read_docs_injected_set(&handoff, sid, &now));
|
|
261
|
+
let is_doc_suppressed = |doc: &DocMetadata| -> bool {
|
|
262
|
+
if suppress_doc_ids.iter().any(|id| id == &doc.id) {
|
|
263
|
+
return true;
|
|
264
|
+
}
|
|
265
|
+
match &injected_set {
|
|
266
|
+
Some(set) => set.is_suppressed(&doc.id, &doc.content_hash),
|
|
267
|
+
None => false,
|
|
268
|
+
}
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
let mut candidates: Vec<SectionCandidate> = Vec::new();
|
|
272
|
+
for doc in &docs {
|
|
273
|
+
if is_doc_suppressed(doc) {
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
let Some(body) = read_doc_body(&handoff, &doc.slug)? else {
|
|
277
|
+
continue;
|
|
278
|
+
};
|
|
279
|
+
for section in &doc.sections {
|
|
280
|
+
// Best-effort ranking pass over every document: if this one
|
|
281
|
+
// section's recorded byte range has drifted from the body
|
|
282
|
+
// currently on disk (out-of-band edit), skip just that section
|
|
283
|
+
// rather than failing the whole `doc_query` call for every
|
|
284
|
+
// other unaffected document.
|
|
285
|
+
let Ok(section_body) = extract_section(&body, section) else {
|
|
286
|
+
continue;
|
|
287
|
+
};
|
|
288
|
+
candidates.push(SectionCandidate {
|
|
289
|
+
doc,
|
|
290
|
+
seq: section.seq,
|
|
291
|
+
heading: section.heading.clone(),
|
|
292
|
+
body: section_body.to_string(),
|
|
293
|
+
content_hash: section.content_hash.clone(),
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
if candidates.is_empty() {
|
|
298
|
+
persist_suppressed_doc_ids(
|
|
299
|
+
&handoff,
|
|
300
|
+
session_id,
|
|
301
|
+
&docs,
|
|
302
|
+
&suppress_doc_ids,
|
|
303
|
+
suppress_until_changed,
|
|
304
|
+
&now,
|
|
305
|
+
)?;
|
|
306
|
+
return Ok(to_json(&json!({ "documents": [], "injected_count": 0 })));
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Index text per section: heading + body (title/tags folded in so a
|
|
310
|
+
// query for the doc's title still surfaces its sections).
|
|
311
|
+
let doc_texts: Vec<String> = candidates
|
|
312
|
+
.iter()
|
|
313
|
+
.map(|c| {
|
|
314
|
+
let mut t = c.doc.title.clone();
|
|
315
|
+
t.push(' ');
|
|
316
|
+
t.push_str(&c.doc.tags.join(" "));
|
|
317
|
+
t.push(' ');
|
|
318
|
+
if !c.heading.is_empty() {
|
|
319
|
+
t.push_str(&c.heading);
|
|
320
|
+
t.push(' ');
|
|
321
|
+
}
|
|
322
|
+
t.push_str(&c.body);
|
|
323
|
+
t
|
|
324
|
+
})
|
|
325
|
+
.collect();
|
|
326
|
+
|
|
327
|
+
// `lexsim::Corpus` is not `Clone`, so ranking happens while the cache's
|
|
328
|
+
// mutex guard (and thus a live `&Corpus` borrow) is held. The MCP server
|
|
329
|
+
// is single-threaded stdio (see `crate::context` module docs), so this
|
|
330
|
+
// is never contended in practice.
|
|
331
|
+
let mut cache = doc_corpus_cache()
|
|
332
|
+
.lock()
|
|
333
|
+
.map_err(|_| anyhow::anyhow!("doc corpus cache mutex poisoned"))?;
|
|
334
|
+
let corpus = cache.get_or_build_corpus(&doc_texts);
|
|
335
|
+
|
|
336
|
+
let mut query_tokens = lexsim::tokenize(&text);
|
|
337
|
+
for p in &file_paths {
|
|
338
|
+
query_tokens.extend(lexsim::tokenize(&basename(p)));
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
let scope_paths: Vec<Vec<String>> = candidates
|
|
342
|
+
.iter()
|
|
343
|
+
.map(|c| c.doc.scope_paths.clone())
|
|
344
|
+
.collect();
|
|
345
|
+
let rank_config = RankConfig {
|
|
346
|
+
min_score: DOC_QUERY_MIN_SCORE,
|
|
347
|
+
scope_path_bonus: SCOPE_PATH_BONUS,
|
|
348
|
+
limit: candidates.len(),
|
|
349
|
+
};
|
|
350
|
+
let mut ranked = rank_by_bm25_and_scope(
|
|
351
|
+
corpus,
|
|
352
|
+
&query_tokens,
|
|
353
|
+
&scope_paths,
|
|
354
|
+
&file_paths,
|
|
355
|
+
&rank_config,
|
|
356
|
+
);
|
|
357
|
+
drop(cache);
|
|
358
|
+
|
|
359
|
+
// Task-affinity bonus (spec §5.7 signal #1, highest weight): applied
|
|
360
|
+
// after the shared ranker since it is doc_query-specific.
|
|
361
|
+
if let Some(tid) = task_id {
|
|
362
|
+
for item in &mut ranked {
|
|
363
|
+
if candidates[item.index].doc.task_ids.iter().any(|t| t == tid) {
|
|
364
|
+
item.score += TASK_AFFINITY_BONUS;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
ranked.sort_by(|a, b| {
|
|
368
|
+
b.score
|
|
369
|
+
.partial_cmp(&a.score)
|
|
370
|
+
.unwrap_or(std::cmp::Ordering::Equal)
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
let already_injected = |i: usize| match &injected_set {
|
|
375
|
+
Some(set) => {
|
|
376
|
+
let c = &candidates[i];
|
|
377
|
+
set.already_injected(&c.doc.id, c.seq, &c.content_hash)
|
|
378
|
+
}
|
|
379
|
+
None => false,
|
|
380
|
+
};
|
|
381
|
+
let fresh = filter_already_injected(ranked, already_injected, limit);
|
|
382
|
+
|
|
383
|
+
let out: Vec<Value> = fresh
|
|
384
|
+
.iter()
|
|
385
|
+
.map(|item| {
|
|
386
|
+
let c = &candidates[item.index];
|
|
387
|
+
let tokens = lexsim::estimate_tokens(&c.body);
|
|
388
|
+
let depth = if tokens <= DOC_INLINE_THRESHOLD_TOKENS {
|
|
389
|
+
"full"
|
|
390
|
+
} else {
|
|
391
|
+
"outline"
|
|
392
|
+
};
|
|
393
|
+
let mut entry = json!({
|
|
394
|
+
"doc_id": c.doc.id,
|
|
395
|
+
"title": c.doc.title,
|
|
396
|
+
"doc_type": c.doc.doc_type,
|
|
397
|
+
"fragment_seq": c.seq,
|
|
398
|
+
"heading": c.heading,
|
|
399
|
+
"task_ids": c.doc.task_ids,
|
|
400
|
+
"depth": depth,
|
|
401
|
+
"tokens": tokens,
|
|
402
|
+
"score": round2(item.score),
|
|
403
|
+
});
|
|
404
|
+
if depth == "full" {
|
|
405
|
+
entry["body"] = json!(c.body);
|
|
406
|
+
} else {
|
|
407
|
+
// outline: heading only, plus the sibling table of contents
|
|
408
|
+
// so the AI can pick a seq to fetch via doc_get(format="section").
|
|
409
|
+
entry["outline"] = json!(c
|
|
410
|
+
.doc
|
|
411
|
+
.sections
|
|
412
|
+
.iter()
|
|
413
|
+
.map(|s| json!({ "seq": s.seq, "heading": s.heading, "level": s.level }))
|
|
414
|
+
.collect::<Vec<_>>());
|
|
415
|
+
}
|
|
416
|
+
entry
|
|
417
|
+
})
|
|
418
|
+
.collect();
|
|
419
|
+
|
|
420
|
+
if mark_injected && !fresh.is_empty() {
|
|
421
|
+
if let Some(sid) = session_id {
|
|
422
|
+
let mut set = read_docs_injected_set(&handoff, sid, &now);
|
|
423
|
+
set.updated_at = now.clone();
|
|
424
|
+
for item in &fresh {
|
|
425
|
+
let c = &candidates[item.index];
|
|
426
|
+
set.mark(&c.doc.id, c.seq, &c.content_hash);
|
|
427
|
+
}
|
|
428
|
+
write_docs_injected_set(&handoff, &set)?;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
persist_suppressed_doc_ids(
|
|
432
|
+
&handoff,
|
|
433
|
+
session_id,
|
|
434
|
+
&docs,
|
|
435
|
+
&suppress_doc_ids,
|
|
436
|
+
suppress_until_changed,
|
|
437
|
+
&now,
|
|
438
|
+
)?;
|
|
439
|
+
|
|
440
|
+
Ok(to_json(&json!({
|
|
441
|
+
"documents": out,
|
|
442
|
+
"injected_count": out.len(),
|
|
443
|
+
})))
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/// Record `suppress_doc_ids` in the session's `injected/` sidecar as
|
|
447
|
+
/// "suppressed at this content_hash" (spec §7.2.2), when
|
|
448
|
+
/// `suppress_until_changed` is requested. A no-op when there's no
|
|
449
|
+
/// `session_id`, no `suppress_doc_ids`, or `suppress_until_changed` is
|
|
450
|
+
/// false — called from both `handle_doc_query`'s early-out (candidates
|
|
451
|
+
/// empty, e.g. every candidate got suppressed) and its normal return path,
|
|
452
|
+
/// so the suppression sticks either way.
|
|
453
|
+
fn persist_suppressed_doc_ids(
|
|
454
|
+
handoff: &Path,
|
|
455
|
+
session_id: Option<&str>,
|
|
456
|
+
docs: &[DocMetadata],
|
|
457
|
+
suppress_doc_ids: &[String],
|
|
458
|
+
suppress_until_changed: bool,
|
|
459
|
+
now: &str,
|
|
460
|
+
) -> Result<()> {
|
|
461
|
+
if !suppress_until_changed || suppress_doc_ids.is_empty() {
|
|
462
|
+
return Ok(());
|
|
463
|
+
}
|
|
464
|
+
let Some(sid) = session_id else {
|
|
465
|
+
return Ok(());
|
|
466
|
+
};
|
|
467
|
+
let mut set = read_docs_injected_set(handoff, sid, now);
|
|
468
|
+
set.updated_at = now.to_string();
|
|
469
|
+
for doc in docs {
|
|
470
|
+
if suppress_doc_ids.iter().any(|id| id == &doc.id) {
|
|
471
|
+
set.suppress(&doc.id, &doc.content_hash);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
write_docs_injected_set(handoff, &set)?;
|
|
475
|
+
Ok(())
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
fn basename(p: &str) -> String {
|
|
479
|
+
p.rsplit(['/', '\\']).next().unwrap_or(p).to_string()
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
fn round2(x: f64) -> f64 {
|
|
483
|
+
(x * 100.0).round() / 100.0
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// ---------------------------------------------------------------------
|
|
487
|
+
// handoff_doc_analyze
|
|
488
|
+
// ---------------------------------------------------------------------
|
|
489
|
+
|
|
490
|
+
/// Regex-free scan of `body` for `[text](target)` Markdown links.
|
|
491
|
+
fn extract_markdown_links(body: &str) -> Vec<(String, String)> {
|
|
492
|
+
let mut links = Vec::new();
|
|
493
|
+
let bytes = body.as_bytes();
|
|
494
|
+
let mut i = 0;
|
|
495
|
+
while i < bytes.len() {
|
|
496
|
+
if bytes[i] == b'[' {
|
|
497
|
+
if let Some(close_bracket) = body[i + 1..].find(']') {
|
|
498
|
+
let close_bracket = i + 1 + close_bracket;
|
|
499
|
+
if body.as_bytes().get(close_bracket + 1) == Some(&b'(') {
|
|
500
|
+
if let Some(close_paren) = body[close_bracket + 2..].find(')') {
|
|
501
|
+
let close_paren = close_bracket + 2 + close_paren;
|
|
502
|
+
let text = body[i + 1..close_bracket].to_string();
|
|
503
|
+
let target = body[close_bracket + 2..close_paren].to_string();
|
|
504
|
+
links.push((text, target));
|
|
505
|
+
i = close_paren + 1;
|
|
506
|
+
continue;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
i += 1;
|
|
512
|
+
}
|
|
513
|
+
links
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/// Detect a `doc_type` from a title/body via keyword scan (spec §6.1 table).
|
|
517
|
+
fn detect_doc_type(title: &str, body: &str) -> String {
|
|
518
|
+
let hay = format!("{title} {body}").to_lowercase();
|
|
519
|
+
let has = |needles: &[&str]| needles.iter().any(|n| hay.contains(n));
|
|
520
|
+
if has(&["要求", "requirement"]) {
|
|
521
|
+
"spec".to_string()
|
|
522
|
+
} else if has(&["設計", "design"]) {
|
|
523
|
+
"design".to_string()
|
|
524
|
+
} else if has(&["テスト", "test"]) {
|
|
525
|
+
"test-spec".to_string()
|
|
526
|
+
} else if has(&["adr", "決定"]) {
|
|
527
|
+
"adr".to_string()
|
|
528
|
+
} else if has(&["ガイド", "guide"]) {
|
|
529
|
+
"guide".to_string()
|
|
530
|
+
} else {
|
|
531
|
+
"note".to_string()
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/// Detect tags from frontmatter (if present) and heading tokens.
|
|
536
|
+
fn detect_tags(frontmatter: Option<&str>, headings: &[String]) -> Vec<String> {
|
|
537
|
+
let mut tags = Vec::new();
|
|
538
|
+
if let Some(fm) = frontmatter {
|
|
539
|
+
for line in fm.lines() {
|
|
540
|
+
if let Some(rest) = line.trim_start().strip_prefix("tags:") {
|
|
541
|
+
let rest = rest.trim();
|
|
542
|
+
let list = rest.trim_start_matches('[').trim_end_matches(']');
|
|
543
|
+
for part in list.split(',') {
|
|
544
|
+
let t = part.trim().trim_matches('"').trim_matches('\'');
|
|
545
|
+
if !t.is_empty() {
|
|
546
|
+
tags.push(t.to_string());
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
for h in headings {
|
|
553
|
+
for tok in lexsim::tokenize(h) {
|
|
554
|
+
// `tokenize` also emits internal cross-language character n-grams
|
|
555
|
+
// (marker-prefixed) alongside real word tokens — useful for BM25
|
|
556
|
+
// matching, but not for a human-facing tag list.
|
|
557
|
+
if tok.len() > 1 && !lexsim::is_cl_ngram(&tok) && !tags.contains(&tok) {
|
|
558
|
+
tags.push(tok);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
tags
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/// Detect candidate `scope_paths` from inline-code / fenced-code file paths
|
|
566
|
+
/// (must contain `/` and end in a recognizable extension).
|
|
567
|
+
fn detect_scope_paths(body: &str) -> Vec<String> {
|
|
568
|
+
const EXTENSIONS: &[&str] = &[
|
|
569
|
+
".rs", ".ts", ".tsx", ".js", ".jsx", ".toml", ".json", ".md", ".py", ".go",
|
|
570
|
+
];
|
|
571
|
+
let mut found = Vec::new();
|
|
572
|
+
let mut token = String::new();
|
|
573
|
+
let flush = |token: &mut String, found: &mut Vec<String>| {
|
|
574
|
+
if token.contains('/') && EXTENSIONS.iter().any(|e| token.ends_with(e)) {
|
|
575
|
+
let cleaned = token.trim_matches(|c: char| {
|
|
576
|
+
!c.is_ascii_alphanumeric() && c != '/' && c != '.' && c != '_' && c != '-'
|
|
577
|
+
});
|
|
578
|
+
if !cleaned.is_empty() && !found.contains(&cleaned.to_string()) {
|
|
579
|
+
found.push(cleaned.to_string());
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
token.clear();
|
|
583
|
+
};
|
|
584
|
+
for ch in body.chars() {
|
|
585
|
+
if ch.is_whitespace() || matches!(ch, '`' | '(' | ')' | '[' | ']' | ',') {
|
|
586
|
+
flush(&mut token, &mut found);
|
|
587
|
+
} else {
|
|
588
|
+
token.push(ch);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
flush(&mut token, &mut found);
|
|
592
|
+
found
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/// One file's automatic analysis, ready either for direct import or for
|
|
596
|
+
/// AI review (`needs_review`) when its confidence is low or it has
|
|
597
|
+
/// unresolvable signals.
|
|
598
|
+
struct AnalyzedFile {
|
|
599
|
+
file: String,
|
|
600
|
+
title: String,
|
|
601
|
+
body: String,
|
|
602
|
+
doc_type: String,
|
|
603
|
+
tags: Vec<String>,
|
|
604
|
+
scope_paths: Vec<String>,
|
|
605
|
+
links: Vec<(String, String)>,
|
|
606
|
+
parent_dir: Option<String>,
|
|
607
|
+
index_text: String,
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/// Collect every heading (`#`..`######`) in `body`, in document order, paired
|
|
611
|
+
/// with its text (without the leading `#` markers).
|
|
612
|
+
fn extract_headings(body: &str) -> Vec<String> {
|
|
613
|
+
body.lines()
|
|
614
|
+
.filter_map(|line| {
|
|
615
|
+
let trimmed = line.trim_start();
|
|
616
|
+
if trimmed.starts_with('#') {
|
|
617
|
+
let text = trimmed.trim_start_matches('#').trim();
|
|
618
|
+
if !text.is_empty() {
|
|
619
|
+
return Some(text.to_string());
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
None
|
|
623
|
+
})
|
|
624
|
+
.collect()
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
fn analyze_one_file(root: &Path, path: &Path) -> Result<AnalyzedFile> {
|
|
628
|
+
let body = std::fs::read_to_string(path)
|
|
629
|
+
.map_err(|e| anyhow::anyhow!("Failed to read {}: {e}", path.display()))?;
|
|
630
|
+
let headings = extract_headings(&body);
|
|
631
|
+
let title = headings.first().cloned().unwrap_or_else(|| file_stem(path));
|
|
632
|
+
|
|
633
|
+
let split_doc = split(&body, DEFAULT_SPLIT_LEVEL).ok();
|
|
634
|
+
let frontmatter = split_doc.as_ref().and_then(|d| d.frontmatter);
|
|
635
|
+
|
|
636
|
+
let doc_type = detect_doc_type(&title, &body);
|
|
637
|
+
let tags = detect_tags(frontmatter, &headings);
|
|
638
|
+
let scope_paths = detect_scope_paths(&body);
|
|
639
|
+
let links = extract_markdown_links(&body);
|
|
640
|
+
|
|
641
|
+
let rel = path.strip_prefix(root).unwrap_or(path);
|
|
642
|
+
let file = rel.to_string_lossy().replace('\\', "/");
|
|
643
|
+
let parent_dir = rel
|
|
644
|
+
.parent()
|
|
645
|
+
.filter(|p| !p.as_os_str().is_empty())
|
|
646
|
+
.map(|p| format!("{}/", p.to_string_lossy().replace('\\', "/")));
|
|
647
|
+
|
|
648
|
+
let index_text = format!("{title} {}", tags.join(" "));
|
|
649
|
+
|
|
650
|
+
Ok(AnalyzedFile {
|
|
651
|
+
file,
|
|
652
|
+
title,
|
|
653
|
+
body,
|
|
654
|
+
doc_type,
|
|
655
|
+
tags,
|
|
656
|
+
scope_paths,
|
|
657
|
+
links,
|
|
658
|
+
parent_dir,
|
|
659
|
+
index_text,
|
|
660
|
+
})
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
fn file_stem(path: &Path) -> String {
|
|
664
|
+
path.file_stem()
|
|
665
|
+
.map(|s| s.to_string_lossy().to_string())
|
|
666
|
+
.unwrap_or_else(|| path.to_string_lossy().to_string())
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/// Collect every `*.md` file under `path` (or just `path` itself if it is a
|
|
670
|
+
/// file). `recursive=false` limits a directory scan to its immediate
|
|
671
|
+
/// children.
|
|
672
|
+
fn collect_markdown_files(path: &Path, recursive: bool) -> Result<Vec<PathBuf>> {
|
|
673
|
+
if path.is_file() {
|
|
674
|
+
return Ok(vec![path.to_path_buf()]);
|
|
675
|
+
}
|
|
676
|
+
if !path.is_dir() {
|
|
677
|
+
anyhow::bail!("Path not found: {}", path.display());
|
|
678
|
+
}
|
|
679
|
+
let mut files = Vec::new();
|
|
680
|
+
let mut stack = vec![path.to_path_buf()];
|
|
681
|
+
while let Some(dir) = stack.pop() {
|
|
682
|
+
for entry in std::fs::read_dir(&dir)
|
|
683
|
+
.map_err(|e| anyhow::anyhow!("Failed to read dir {}: {e}", dir.display()))?
|
|
684
|
+
{
|
|
685
|
+
let entry = entry?;
|
|
686
|
+
let p = entry.path();
|
|
687
|
+
if p.is_dir() {
|
|
688
|
+
if recursive {
|
|
689
|
+
stack.push(p);
|
|
690
|
+
}
|
|
691
|
+
} else if p.extension().and_then(|e| e.to_str()) == Some("md") {
|
|
692
|
+
files.push(p);
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
files.sort();
|
|
697
|
+
Ok(files)
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/// `handoff_doc_analyze` — read-only scan of a file or directory, producing
|
|
701
|
+
/// a conditioning report (spec §6.1 step 1). Never writes anything.
|
|
702
|
+
pub fn handle_doc_analyze(arguments: &Value) -> Result<String> {
|
|
703
|
+
let project_dir = resolve_project_dir(arguments)?;
|
|
704
|
+
let raw_path = arguments
|
|
705
|
+
.get("path")
|
|
706
|
+
.and_then(|v| v.as_str())
|
|
707
|
+
.ok_or_else(|| anyhow::anyhow!("'path' is required"))?;
|
|
708
|
+
let recursive = arguments
|
|
709
|
+
.get("recursive")
|
|
710
|
+
.and_then(|v| v.as_bool())
|
|
711
|
+
.unwrap_or(true);
|
|
712
|
+
let flatten = arguments
|
|
713
|
+
.get("flatten")
|
|
714
|
+
.and_then(|v| v.as_bool())
|
|
715
|
+
.unwrap_or(false);
|
|
716
|
+
|
|
717
|
+
let scan_root = project_dir.join(raw_path);
|
|
718
|
+
let scan_root = std::fs::canonicalize(&scan_root)
|
|
719
|
+
.map_err(|e| anyhow::anyhow!("Invalid path '{raw_path}': {e}"))?;
|
|
720
|
+
if !scan_root.starts_with(&project_dir) {
|
|
721
|
+
anyhow::bail!("path '{}' resolves outside the project directory", raw_path);
|
|
722
|
+
}
|
|
723
|
+
// The root used for relative-path reporting: the scan target's parent
|
|
724
|
+
// when it's a single file, or the directory itself.
|
|
725
|
+
let report_root = if scan_root.is_file() {
|
|
726
|
+
scan_root
|
|
727
|
+
.parent()
|
|
728
|
+
.map(Path::to_path_buf)
|
|
729
|
+
.unwrap_or_else(|| scan_root.clone())
|
|
730
|
+
} else {
|
|
731
|
+
scan_root.clone()
|
|
732
|
+
};
|
|
733
|
+
|
|
734
|
+
let files = collect_markdown_files(&scan_root, recursive)?;
|
|
735
|
+
let mut analyzed = Vec::with_capacity(files.len());
|
|
736
|
+
for f in &files {
|
|
737
|
+
analyzed.push(analyze_one_file(&report_root, f)?);
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
// Link analysis: classify each link as internal (matches another
|
|
741
|
+
// scanned file's path or heading), external (URL), or broken. Headings
|
|
742
|
+
// are collected once across every scanned file (not just the linking
|
|
743
|
+
// file) so a link can target a heading in a sibling document.
|
|
744
|
+
let known_files: Vec<&str> = analyzed.iter().map(|a| a.file.as_str()).collect();
|
|
745
|
+
let known_headings: Vec<String> = analyzed
|
|
746
|
+
.iter()
|
|
747
|
+
.flat_map(|a| extract_headings(&a.body))
|
|
748
|
+
.collect();
|
|
749
|
+
|
|
750
|
+
let mut auto_resolved = Vec::new();
|
|
751
|
+
let mut needs_review = Vec::new();
|
|
752
|
+
|
|
753
|
+
for a in &analyzed {
|
|
754
|
+
let mut broken_links = Vec::new();
|
|
755
|
+
for (text, target) in &a.links {
|
|
756
|
+
if target.starts_with("http://") || target.starts_with("https://") {
|
|
757
|
+
continue; // external — not reported as an issue
|
|
758
|
+
}
|
|
759
|
+
let target_path = target.split('#').next().unwrap_or(target);
|
|
760
|
+
let is_internal = target_path.is_empty()
|
|
761
|
+
|| known_files
|
|
762
|
+
.iter()
|
|
763
|
+
.any(|f| f.ends_with(target_path) || target_path.ends_with(f))
|
|
764
|
+
|| known_headings.iter().any(|h| target.contains(h.as_str()));
|
|
765
|
+
if !is_internal {
|
|
766
|
+
broken_links.push((text.clone(), target.clone()));
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
for (text, target) in &broken_links {
|
|
770
|
+
needs_review.push(json!({
|
|
771
|
+
"file": a.file,
|
|
772
|
+
"issue": "broken_link",
|
|
773
|
+
"detail": format!("Link '{text}' -> '{target}' does not match any scanned file or heading"),
|
|
774
|
+
"suggestion": { "action": "link_to" },
|
|
775
|
+
"context": format!("link text: '{text}'"),
|
|
776
|
+
}));
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
let confidence = if broken_links.is_empty() { 0.9 } else { 0.5 };
|
|
780
|
+
auto_resolved.push(json!({
|
|
781
|
+
"file": a.file,
|
|
782
|
+
"title": a.title,
|
|
783
|
+
"doc_type": a.doc_type,
|
|
784
|
+
"tags": a.tags,
|
|
785
|
+
"scope_paths": a.scope_paths,
|
|
786
|
+
"confidence": confidence,
|
|
787
|
+
"suggested_slug": slugify(&a.title),
|
|
788
|
+
}));
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
// Near-duplicate detection: pairwise Jaccard similarity over index text.
|
|
792
|
+
for i in 0..analyzed.len() {
|
|
793
|
+
for j in (i + 1)..analyzed.len() {
|
|
794
|
+
let score = lexsim::jaccard(&analyzed[i].index_text, &analyzed[j].index_text);
|
|
795
|
+
if score >= 0.7 {
|
|
796
|
+
needs_review.push(json!({
|
|
797
|
+
"file": analyzed[i].file,
|
|
798
|
+
"issue": "near_duplicate",
|
|
799
|
+
"detail": format!(
|
|
800
|
+
"{} and {} have similarity {:.2}",
|
|
801
|
+
analyzed[i].file, analyzed[j].file, score
|
|
802
|
+
),
|
|
803
|
+
"suggestion": { "action": "merge_or_reference" },
|
|
804
|
+
}));
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
let mut proposed_tree = serde_json::Map::new();
|
|
810
|
+
if !flatten {
|
|
811
|
+
let mut by_parent: BTreeMap<String, Vec<String>> = BTreeMap::new();
|
|
812
|
+
for a in &analyzed {
|
|
813
|
+
if let Some(parent) = &a.parent_dir {
|
|
814
|
+
by_parent
|
|
815
|
+
.entry(parent.clone())
|
|
816
|
+
.or_default()
|
|
817
|
+
.push(a.file.clone());
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
for (parent, children) in by_parent {
|
|
821
|
+
proposed_tree.insert(parent, json!({ "children": children, "doc_type": "note" }));
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
Ok(to_json(&json!({
|
|
826
|
+
"files_scanned": analyzed.len(),
|
|
827
|
+
"auto_resolved": auto_resolved,
|
|
828
|
+
"needs_review": needs_review,
|
|
829
|
+
"proposed_tree": Value::Object(proposed_tree),
|
|
830
|
+
})))
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
// ---------------------------------------------------------------------
|
|
834
|
+
// handoff_doc_import
|
|
835
|
+
// ---------------------------------------------------------------------
|
|
836
|
+
|
|
837
|
+
/// `handoff_doc_import` — bulk-write an analyzed payload (spec §6.1 step 3).
|
|
838
|
+
/// Applies `overrides`, splits + persists every file as a document, wires up
|
|
839
|
+
/// `proposed_tree` parent/child relationships, links `task_ids` to every
|
|
840
|
+
/// imported document, and bumps the doc corpus cache generation.
|
|
841
|
+
pub fn handle_doc_import(arguments: &Value) -> Result<String> {
|
|
842
|
+
let project_dir = resolve_project_dir(arguments)?;
|
|
843
|
+
let handoff = ensure_handoff_exists(&project_dir)?;
|
|
844
|
+
ensure_docs_dir(&handoff)?;
|
|
845
|
+
|
|
846
|
+
let analyzed = arguments
|
|
847
|
+
.get("analyzed")
|
|
848
|
+
.ok_or_else(|| anyhow::anyhow!("'analyzed' is required"))?;
|
|
849
|
+
let auto_resolved = analyzed
|
|
850
|
+
.get("auto_resolved")
|
|
851
|
+
.and_then(|v| v.as_array())
|
|
852
|
+
.cloned()
|
|
853
|
+
.unwrap_or_default();
|
|
854
|
+
if auto_resolved.is_empty() {
|
|
855
|
+
anyhow::bail!("'analyzed.auto_resolved' must contain at least one file entry");
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
let overrides: BTreeMap<String, Value> = arguments
|
|
859
|
+
.get("overrides")
|
|
860
|
+
.and_then(|v| v.as_array())
|
|
861
|
+
.map(|arr| {
|
|
862
|
+
arr.iter()
|
|
863
|
+
.filter_map(|o| {
|
|
864
|
+
o.get("file")
|
|
865
|
+
.and_then(|f| f.as_str())
|
|
866
|
+
.map(|f| (f.to_string(), o.clone()))
|
|
867
|
+
})
|
|
868
|
+
.collect()
|
|
869
|
+
})
|
|
870
|
+
.unwrap_or_default();
|
|
871
|
+
|
|
872
|
+
let task_ids = string_array(arguments, "task_ids");
|
|
873
|
+
|
|
874
|
+
let now = chrono::Utc::now().to_rfc3339();
|
|
875
|
+
let mut warnings: Vec<String> = Vec::new();
|
|
876
|
+
let mut imported_docs: Vec<Value> = Vec::new();
|
|
877
|
+
let mut file_to_doc_id: BTreeMap<String, String> = BTreeMap::new();
|
|
878
|
+
// Slugs claimed so far by this import batch, seeded with every slug
|
|
879
|
+
// already on disk — `unique_slug` disambiguates against both.
|
|
880
|
+
let mut used_slugs: std::collections::HashSet<String> = read_all_docs(&handoff)?
|
|
881
|
+
.into_iter()
|
|
882
|
+
.map(|d| d.slug)
|
|
883
|
+
.collect();
|
|
884
|
+
|
|
885
|
+
// Pass 1: validate every entry has a resolvable body before writing
|
|
886
|
+
// anything (mirrors handoff_import_context's validate-then-write
|
|
887
|
+
// pattern — a rejection mid-batch must not leave a half-written tree).
|
|
888
|
+
for entry in &auto_resolved {
|
|
889
|
+
let file = entry.get("file").and_then(|v| v.as_str()).ok_or_else(|| {
|
|
890
|
+
anyhow::anyhow!("Each 'analyzed.auto_resolved' entry requires 'file'")
|
|
891
|
+
})?;
|
|
892
|
+
if entry.get("body").and_then(|v| v.as_str()).is_none() {
|
|
893
|
+
anyhow::bail!(
|
|
894
|
+
"'analyzed.auto_resolved' entry for '{file}' is missing 'body' \
|
|
895
|
+
(doc_import writes from the payload; it does not re-read the filesystem)"
|
|
896
|
+
);
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
// Pass 2: write every document. `file`/`body` are re-extracted with the
|
|
901
|
+
// same `ok_or_else` shape as pass 1 (never `unwrap()`) even though pass 1
|
|
902
|
+
// already rejected any entry missing either — belt-and-suspenders against
|
|
903
|
+
// the two passes ever drifting apart.
|
|
904
|
+
for entry in &auto_resolved {
|
|
905
|
+
let file = entry
|
|
906
|
+
.get("file")
|
|
907
|
+
.and_then(|v| v.as_str())
|
|
908
|
+
.ok_or_else(|| anyhow::anyhow!("Each 'analyzed.auto_resolved' entry requires 'file'"))?
|
|
909
|
+
.to_string();
|
|
910
|
+
let body = entry.get("body").and_then(|v| v.as_str()).ok_or_else(|| {
|
|
911
|
+
anyhow::anyhow!("'analyzed.auto_resolved' entry for '{file}' is missing 'body'")
|
|
912
|
+
})?;
|
|
913
|
+
let override_entry = overrides.get(&file);
|
|
914
|
+
|
|
915
|
+
let title = override_entry
|
|
916
|
+
.and_then(|o| o.get("title"))
|
|
917
|
+
.and_then(|v| v.as_str())
|
|
918
|
+
.or_else(|| entry.get("title").and_then(|v| v.as_str()))
|
|
919
|
+
.unwrap_or(&file)
|
|
920
|
+
.to_string();
|
|
921
|
+
let doc_type = override_entry
|
|
922
|
+
.and_then(|o| o.get("doc_type"))
|
|
923
|
+
.and_then(|v| v.as_str())
|
|
924
|
+
.or_else(|| entry.get("doc_type").and_then(|v| v.as_str()))
|
|
925
|
+
.unwrap_or("note")
|
|
926
|
+
.to_string();
|
|
927
|
+
let tags: Vec<String> = override_entry
|
|
928
|
+
.and_then(|o| o.get("tags"))
|
|
929
|
+
.or_else(|| entry.get("tags"))
|
|
930
|
+
.map(string_array_value)
|
|
931
|
+
.unwrap_or_default();
|
|
932
|
+
let scope_paths: Vec<String> = override_entry
|
|
933
|
+
.and_then(|o| o.get("scope_paths"))
|
|
934
|
+
.or_else(|| entry.get("scope_paths"))
|
|
935
|
+
.map(string_array_value)
|
|
936
|
+
.unwrap_or_default();
|
|
937
|
+
|
|
938
|
+
let slug_override = override_entry
|
|
939
|
+
.and_then(|o| o.get("slug"))
|
|
940
|
+
.and_then(|v| v.as_str())
|
|
941
|
+
.or_else(|| entry.get("suggested_slug").and_then(|v| v.as_str()));
|
|
942
|
+
let slug = unique_slug(&handoff, &mut used_slugs, slug_override, &title, &file)?;
|
|
943
|
+
|
|
944
|
+
let split_doc = split(body, DEFAULT_SPLIT_LEVEL)?;
|
|
945
|
+
let id = new_doc_id();
|
|
946
|
+
|
|
947
|
+
let mut doc = DocMetadata::new(
|
|
948
|
+
id.clone(),
|
|
949
|
+
slug.clone(),
|
|
950
|
+
title.clone(),
|
|
951
|
+
doc_type.clone(),
|
|
952
|
+
now.clone(),
|
|
953
|
+
);
|
|
954
|
+
doc.tags = tags;
|
|
955
|
+
doc.scope_paths = scope_paths;
|
|
956
|
+
doc.source.origin = "imported".to_string();
|
|
957
|
+
doc.source.original_path = Some(file.clone());
|
|
958
|
+
doc.has_bom = split_doc.has_bom;
|
|
959
|
+
doc.line_ending = split_doc.line_ending.to_string();
|
|
960
|
+
doc.source.frontmatter = split_doc.frontmatter.map(str::to_string);
|
|
961
|
+
doc.source.frontmatter_trailing_eol = split_doc.frontmatter_trailing_eol;
|
|
962
|
+
|
|
963
|
+
let body_after_strip: String = split_doc.fragments.iter().map(|f| f.body).collect();
|
|
964
|
+
write_doc_body(&handoff, &slug, &body_after_strip)?;
|
|
965
|
+
doc.sections = compute_sections(&split_doc);
|
|
966
|
+
doc.content_hash = lexsim::content_hash(&body_after_strip);
|
|
967
|
+
doc.source.canonical_hash = Some(doc.content_hash.clone());
|
|
968
|
+
doc.task_ids = task_ids.clone();
|
|
969
|
+
|
|
970
|
+
write_doc(&handoff, &doc)?;
|
|
971
|
+
|
|
972
|
+
file_to_doc_id.insert(file.clone(), id.clone());
|
|
973
|
+
imported_docs.push(json!({
|
|
974
|
+
"doc_id": id,
|
|
975
|
+
"slug": doc.slug,
|
|
976
|
+
"title": doc.title,
|
|
977
|
+
"section_count": doc.sections.len(),
|
|
978
|
+
}));
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
// Pass 3: apply proposed_tree parent/child relationships (best-effort —
|
|
982
|
+
// an unresolvable parent directory entry is reported, not fatal).
|
|
983
|
+
if let Some(tree) = analyzed.get("proposed_tree").and_then(|v| v.as_object()) {
|
|
984
|
+
for (parent_key, node) in tree {
|
|
985
|
+
let children = node
|
|
986
|
+
.get("children")
|
|
987
|
+
.and_then(|v| v.as_array())
|
|
988
|
+
.cloned()
|
|
989
|
+
.unwrap_or_default();
|
|
990
|
+
let child_doc_ids: Vec<String> = children
|
|
991
|
+
.iter()
|
|
992
|
+
.filter_map(|c| c.as_str())
|
|
993
|
+
.filter_map(|f| file_to_doc_id.get(f).cloned())
|
|
994
|
+
.collect();
|
|
995
|
+
if child_doc_ids.is_empty() {
|
|
996
|
+
continue;
|
|
997
|
+
}
|
|
998
|
+
// Synthesize a parent document for the directory grouping when
|
|
999
|
+
// one doesn't already exist among the imported files.
|
|
1000
|
+
let parent_id = new_doc_id();
|
|
1001
|
+
let parent_title = parent_key.trim_end_matches('/').to_string();
|
|
1002
|
+
let parent_slug = unique_slug(
|
|
1003
|
+
&handoff,
|
|
1004
|
+
&mut used_slugs,
|
|
1005
|
+
None,
|
|
1006
|
+
&parent_title,
|
|
1007
|
+
&parent_title,
|
|
1008
|
+
)?;
|
|
1009
|
+
let mut parent_doc = DocMetadata::new(
|
|
1010
|
+
parent_id.clone(),
|
|
1011
|
+
parent_slug,
|
|
1012
|
+
parent_title,
|
|
1013
|
+
node.get("doc_type")
|
|
1014
|
+
.and_then(|v| v.as_str())
|
|
1015
|
+
.unwrap_or("note")
|
|
1016
|
+
.to_string(),
|
|
1017
|
+
now.clone(),
|
|
1018
|
+
);
|
|
1019
|
+
parent_doc.source.origin = "imported".to_string();
|
|
1020
|
+
parent_doc.children = child_doc_ids.clone();
|
|
1021
|
+
write_doc(&handoff, &parent_doc)?;
|
|
1022
|
+
|
|
1023
|
+
for child_id in &child_doc_ids {
|
|
1024
|
+
if let Ok(Some(mut child)) =
|
|
1025
|
+
crate::storage::docs::find_doc_by_id(&handoff, child_id)
|
|
1026
|
+
{
|
|
1027
|
+
child.parent_id = Some(parent_id.clone());
|
|
1028
|
+
write_doc(&handoff, &child)?;
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
imported_docs.push(json!({
|
|
1032
|
+
"doc_id": parent_id,
|
|
1033
|
+
"slug": parent_doc.slug,
|
|
1034
|
+
"title": parent_doc.title,
|
|
1035
|
+
"section_count": 0,
|
|
1036
|
+
}));
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
// Pass 4: link every imported document (leaves + synthesized parents) to
|
|
1041
|
+
// task_ids, if any.
|
|
1042
|
+
if !task_ids.is_empty() {
|
|
1043
|
+
let tasks_dir = handoff.join("tasks");
|
|
1044
|
+
for doc_val in &imported_docs {
|
|
1045
|
+
let doc_id = doc_val["doc_id"].as_str().unwrap_or_default();
|
|
1046
|
+
let title = doc_val["title"].as_str().unwrap_or_default();
|
|
1047
|
+
let report = sync_doc_task_links(&tasks_dir, doc_id, title, &task_ids, &[])?;
|
|
1048
|
+
if !report.unresolved.is_empty() {
|
|
1049
|
+
warnings.push(format!(
|
|
1050
|
+
"Could not resolve task id(s) for linking doc {doc_id}: {}",
|
|
1051
|
+
report.unresolved.join(", ")
|
|
1052
|
+
));
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
// Invalidate the doc corpus cache so the next doc_query sees the import.
|
|
1058
|
+
{
|
|
1059
|
+
let mut cache = doc_corpus_cache()
|
|
1060
|
+
.lock()
|
|
1061
|
+
.map_err(|_| anyhow::anyhow!("doc corpus cache mutex poisoned"))?;
|
|
1062
|
+
cache.increment_generation();
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
Ok(to_json(&json!({
|
|
1066
|
+
"imported_count": imported_docs.len(),
|
|
1067
|
+
"documents": imported_docs,
|
|
1068
|
+
"warnings": warnings,
|
|
1069
|
+
})))
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
fn string_array_value(v: &Value) -> Vec<String> {
|
|
1073
|
+
v.as_array()
|
|
1074
|
+
.map(|arr| {
|
|
1075
|
+
arr.iter()
|
|
1076
|
+
.filter_map(|v| v.as_str())
|
|
1077
|
+
.map(str::to_string)
|
|
1078
|
+
.collect()
|
|
1079
|
+
})
|
|
1080
|
+
.unwrap_or_default()
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
/// Derives a candidate slug (`[a-z0-9-]`, max
|
|
1084
|
+
/// [`crate::storage::docs::model::MAX_SLUG_LEN`]) from `text` by
|
|
1085
|
+
/// lowercasing, replacing any run of non-`[a-z0-9]` characters with a single
|
|
1086
|
+
/// hyphen, and trimming leading/trailing hyphens. Falls back to `"doc"` if
|
|
1087
|
+
/// the result would otherwise be empty (e.g. `text` is entirely
|
|
1088
|
+
/// non-ASCII/punctuation), so [`unique_slug`] always has a non-empty base to
|
|
1089
|
+
/// disambiguate.
|
|
1090
|
+
fn slugify(text: &str) -> String {
|
|
1091
|
+
let mut out = String::with_capacity(text.len());
|
|
1092
|
+
let mut last_was_hyphen = true; // suppress a leading hyphen
|
|
1093
|
+
for ch in text.chars() {
|
|
1094
|
+
if ch.is_ascii_alphanumeric() {
|
|
1095
|
+
out.push(ch.to_ascii_lowercase());
|
|
1096
|
+
last_was_hyphen = false;
|
|
1097
|
+
} else if !last_was_hyphen {
|
|
1098
|
+
out.push('-');
|
|
1099
|
+
last_was_hyphen = true;
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
let trimmed = out.trim_end_matches('-');
|
|
1103
|
+
let truncated: String = trimmed
|
|
1104
|
+
.chars()
|
|
1105
|
+
.take(crate::storage::docs::model::MAX_SLUG_LEN)
|
|
1106
|
+
.collect();
|
|
1107
|
+
let truncated = truncated.trim_end_matches('-');
|
|
1108
|
+
if truncated.is_empty() {
|
|
1109
|
+
"doc".to_string()
|
|
1110
|
+
} else {
|
|
1111
|
+
truncated.to_string()
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
/// Picks a slug for a `doc_import` entry: an explicit override/suggestion if
|
|
1116
|
+
/// given (still slugified/validated), else derived from `title`, falling
|
|
1117
|
+
/// back to `file` when the title slugifies to nothing usable. Disambiguates
|
|
1118
|
+
/// against `used_slugs` (every slug already on disk plus every slug already
|
|
1119
|
+
/// claimed earlier in this same import batch) by appending `-2`, `-3`, …
|
|
1120
|
+
/// Registers the chosen slug into `used_slugs` before returning it.
|
|
1121
|
+
fn unique_slug(
|
|
1122
|
+
handoff: &Path,
|
|
1123
|
+
used_slugs: &mut std::collections::HashSet<String>,
|
|
1124
|
+
preferred: Option<&str>,
|
|
1125
|
+
title: &str,
|
|
1126
|
+
file: &str,
|
|
1127
|
+
) -> Result<String> {
|
|
1128
|
+
let base = match preferred {
|
|
1129
|
+
Some(p) => slugify(p),
|
|
1130
|
+
None => {
|
|
1131
|
+
let from_title = slugify(title);
|
|
1132
|
+
if from_title == "doc" {
|
|
1133
|
+
slugify(file)
|
|
1134
|
+
} else {
|
|
1135
|
+
from_title
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
};
|
|
1139
|
+
validate_slug(&base)?;
|
|
1140
|
+
|
|
1141
|
+
if !used_slugs.contains(&base) && read_doc(handoff, &base)?.is_none() {
|
|
1142
|
+
used_slugs.insert(base.clone());
|
|
1143
|
+
return Ok(base);
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
// Reserve room for the "-N" disambiguation suffix up front: truncate
|
|
1147
|
+
// `base` so every candidate `format!("{truncated}-{n}")` fits within
|
|
1148
|
+
// `MAX_SLUG_LEN`, even for the largest `n` we're willing to try. Without
|
|
1149
|
+
// this, a `base` already at `MAX_SLUG_LEN` chars makes every candidate
|
|
1150
|
+
// exceed the limit and previously caused an unbounded loop (never
|
|
1151
|
+
// returning, never erroring — a live CPU-pinning bug found in review).
|
|
1152
|
+
const MAX_ATTEMPTS: usize = 999;
|
|
1153
|
+
let max_suffix_len = format!("-{MAX_ATTEMPTS}").len();
|
|
1154
|
+
let max_base_len = crate::storage::docs::model::MAX_SLUG_LEN - max_suffix_len;
|
|
1155
|
+
let truncated_base = if base.len() > max_base_len {
|
|
1156
|
+
&base[..max_base_len]
|
|
1157
|
+
} else {
|
|
1158
|
+
&base[..]
|
|
1159
|
+
};
|
|
1160
|
+
|
|
1161
|
+
for n in 2..=MAX_ATTEMPTS {
|
|
1162
|
+
let candidate = format!("{truncated_base}-{n}");
|
|
1163
|
+
if !used_slugs.contains(&candidate) && read_doc(handoff, &candidate)?.is_none() {
|
|
1164
|
+
used_slugs.insert(candidate.clone());
|
|
1165
|
+
return Ok(candidate);
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
bail!(
|
|
1169
|
+
"could not find a unique slug for base '{base}' after {MAX_ATTEMPTS} attempts; \
|
|
1170
|
+
pick a more specific slug override"
|
|
1171
|
+
)
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
#[cfg(test)]
|
|
1175
|
+
mod tests {
|
|
1176
|
+
use super::*;
|
|
1177
|
+
use tempfile::TempDir;
|
|
1178
|
+
|
|
1179
|
+
/// Regression test for a MAJOR bug found in review: when `base` is
|
|
1180
|
+
/// already `MAX_SLUG_LEN` chars long and taken, every disambiguation
|
|
1181
|
+
/// candidate `format!("{base}-{n}")` is longer than `MAX_SLUG_LEN`, so
|
|
1182
|
+
/// the old `for n in 2..` loop's length guard rejected every candidate
|
|
1183
|
+
/// and looped forever (never reaching `unreachable!()`), spinning a CPU
|
|
1184
|
+
/// core for the life of the process. `unique_slug` must instead reserve
|
|
1185
|
+
/// suffix room by truncating `base` and return a real `Err` if
|
|
1186
|
+
/// disambiguation is exhausted, never hang.
|
|
1187
|
+
#[test]
|
|
1188
|
+
fn unique_slug_disambiguates_when_base_is_at_max_length() {
|
|
1189
|
+
let tmp = TempDir::new().unwrap();
|
|
1190
|
+
let handoff = tmp.path().join(".handoff");
|
|
1191
|
+
std::fs::create_dir_all(handoff.join("docs")).unwrap();
|
|
1192
|
+
|
|
1193
|
+
let base = "a".repeat(crate::storage::docs::model::MAX_SLUG_LEN);
|
|
1194
|
+
let mut used_slugs: std::collections::HashSet<String> = std::collections::HashSet::new();
|
|
1195
|
+
used_slugs.insert(base.clone());
|
|
1196
|
+
|
|
1197
|
+
let slug = unique_slug(&handoff, &mut used_slugs, Some(&base), "Title", "file.md")
|
|
1198
|
+
.expect("must disambiguate instead of hanging or erroring");
|
|
1199
|
+
assert!(slug.len() <= crate::storage::docs::model::MAX_SLUG_LEN);
|
|
1200
|
+
assert_ne!(slug, base);
|
|
1201
|
+
assert!(used_slugs.contains(&slug));
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
/// When every disambiguation slot is already taken, `unique_slug` must
|
|
1205
|
+
/// return a real `Err` promptly rather than looping forever or panicking
|
|
1206
|
+
/// via `unreachable!()`.
|
|
1207
|
+
#[test]
|
|
1208
|
+
fn unique_slug_errors_instead_of_hanging_when_exhausted() {
|
|
1209
|
+
let tmp = TempDir::new().unwrap();
|
|
1210
|
+
let handoff = tmp.path().join(".handoff");
|
|
1211
|
+
std::fs::create_dir_all(handoff.join("docs")).unwrap();
|
|
1212
|
+
|
|
1213
|
+
let base = "x".repeat(crate::storage::docs::model::MAX_SLUG_LEN);
|
|
1214
|
+
let mut used_slugs: std::collections::HashSet<String> = std::collections::HashSet::new();
|
|
1215
|
+
used_slugs.insert(base.clone());
|
|
1216
|
+
// Pre-claim every disambiguated candidate the truncated-base scheme
|
|
1217
|
+
// could produce, forcing exhaustion.
|
|
1218
|
+
let max_suffix_len = "-999".len();
|
|
1219
|
+
let max_base_len = crate::storage::docs::model::MAX_SLUG_LEN - max_suffix_len;
|
|
1220
|
+
let truncated = &base[..max_base_len];
|
|
1221
|
+
for n in 2..=999 {
|
|
1222
|
+
used_slugs.insert(format!("{truncated}-{n}"));
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
let result = unique_slug(&handoff, &mut used_slugs, Some(&base), "Title", "file.md");
|
|
1226
|
+
assert!(
|
|
1227
|
+
result.is_err(),
|
|
1228
|
+
"expected Err on exhaustion, got {result:?}"
|
|
1229
|
+
);
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
#[test]
|
|
1233
|
+
fn extract_markdown_links_finds_pairs() {
|
|
1234
|
+
let body = "See [the guide](./guide.md) and [ext](https://example.com).";
|
|
1235
|
+
let links = extract_markdown_links(body);
|
|
1236
|
+
assert_eq!(links.len(), 2);
|
|
1237
|
+
assert_eq!(
|
|
1238
|
+
links[0],
|
|
1239
|
+
("the guide".to_string(), "./guide.md".to_string())
|
|
1240
|
+
);
|
|
1241
|
+
assert_eq!(
|
|
1242
|
+
links[1],
|
|
1243
|
+
("ext".to_string(), "https://example.com".to_string())
|
|
1244
|
+
);
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
#[test]
|
|
1248
|
+
fn extract_markdown_links_ignores_unmatched_brackets() {
|
|
1249
|
+
let body = "An array literal [1, 2, 3] is not a link.";
|
|
1250
|
+
assert!(extract_markdown_links(body).is_empty());
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
#[test]
|
|
1254
|
+
fn detect_doc_type_keyword_scan() {
|
|
1255
|
+
assert_eq!(detect_doc_type("要求仕様書", ""), "spec");
|
|
1256
|
+
assert_eq!(detect_doc_type("Design Doc", ""), "design");
|
|
1257
|
+
assert_eq!(detect_doc_type("Test Plan", ""), "test-spec");
|
|
1258
|
+
assert_eq!(detect_doc_type("ADR-001", ""), "adr");
|
|
1259
|
+
assert_eq!(detect_doc_type("Setup Guide", ""), "guide");
|
|
1260
|
+
assert_eq!(detect_doc_type("Random notes", ""), "note");
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
#[test]
|
|
1264
|
+
fn detect_tags_from_frontmatter_and_headings() {
|
|
1265
|
+
let fm = "title: Foo\ntags: [alpha, beta]\n";
|
|
1266
|
+
let headings = vec!["Session Loop".to_string()];
|
|
1267
|
+
let tags = detect_tags(Some(fm), &headings);
|
|
1268
|
+
assert!(tags.contains(&"alpha".to_string()));
|
|
1269
|
+
assert!(tags.contains(&"beta".to_string()));
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
/// `lexsim::tokenize` emits internal cross-language character n-grams
|
|
1273
|
+
/// (marker-prefixed, `is_cl_ngram() == true`) alongside real word tokens
|
|
1274
|
+
/// — the doc comment on `is_cl_ngram` says they are "useful for matching
|
|
1275
|
+
/// but not for human-facing output". `detect_tags` produces a
|
|
1276
|
+
/// human-facing `tags` list, so it must filter them out.
|
|
1277
|
+
#[test]
|
|
1278
|
+
fn detect_tags_excludes_internal_cl_ngram_tokens() {
|
|
1279
|
+
let headings = vec!["Real Binary".to_string()];
|
|
1280
|
+
let tags = detect_tags(None, &headings);
|
|
1281
|
+
assert!(
|
|
1282
|
+
tags.iter().all(|t| !lexsim::is_cl_ngram(t)),
|
|
1283
|
+
"detect_tags must not leak internal CL-CnG tokens into human-facing tags: {tags:?}"
|
|
1284
|
+
);
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
#[test]
|
|
1288
|
+
fn detect_scope_paths_finds_code_paths() {
|
|
1289
|
+
let body = "See `src/mcp/handlers/docs.rs` and also plain text.";
|
|
1290
|
+
let paths = detect_scope_paths(body);
|
|
1291
|
+
assert!(paths.iter().any(|p| p.contains("src/mcp/handlers/docs.rs")));
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
#[test]
|
|
1295
|
+
fn detect_scope_paths_ignores_plain_words() {
|
|
1296
|
+
let body = "Just some words without any paths here.";
|
|
1297
|
+
assert!(detect_scope_paths(body).is_empty());
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
#[test]
|
|
1301
|
+
fn doc_injected_set_already_injected_tracks_per_fragment() {
|
|
1302
|
+
let mut set = DocInjectedSet::new("s".to_string(), "now".to_string());
|
|
1303
|
+
set.mark("doc-1", 0, "hashA");
|
|
1304
|
+
assert!(set.already_injected("doc-1", 0, "hashA"));
|
|
1305
|
+
assert!(!set.already_injected("doc-1", 0, "hashB"));
|
|
1306
|
+
assert!(!set.already_injected("doc-1", 1, "hashA"));
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
#[test]
|
|
1310
|
+
fn doc_injected_set_is_suppressed_tracks_content_hash() {
|
|
1311
|
+
let mut set = DocInjectedSet::new("s".to_string(), "now".to_string());
|
|
1312
|
+
set.suppress("doc-1", "hashA");
|
|
1313
|
+
assert!(set.is_suppressed("doc-1", "hashA"));
|
|
1314
|
+
// Content changed since suppression -> no longer suppressed.
|
|
1315
|
+
assert!(!set.is_suppressed("doc-1", "hashB"));
|
|
1316
|
+
// A different, never-suppressed doc is unaffected.
|
|
1317
|
+
assert!(!set.is_suppressed("doc-2", "hashA"));
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
#[test]
|
|
1321
|
+
fn sanitize_session_id_blocks_traversal() {
|
|
1322
|
+
// Path separators are neutralized so the result is always a single
|
|
1323
|
+
// flat filename component. The readable prefix may still contain
|
|
1324
|
+
// dots (mirrors `storage::memory::injected::sanitize_session_id`),
|
|
1325
|
+
// but the mandatory hash suffix guarantees the full stem is never
|
|
1326
|
+
// exactly ".." or "." and never starts with a dot (not hidden).
|
|
1327
|
+
for evil in ["../../etc/passwd", "a/b\\c", "..", "../", "foo/../bar"] {
|
|
1328
|
+
let s = sanitize_session_id(evil);
|
|
1329
|
+
assert!(!s.contains('/'), "{evil:?} -> {s:?} still has /");
|
|
1330
|
+
assert!(!s.contains('\\'), "{evil:?} -> {s:?} still has \\");
|
|
1331
|
+
assert_ne!(s, "..", "{evil:?} -> {s:?} is a parent ref");
|
|
1332
|
+
assert!(!s.starts_with('.'), "{evil:?} -> {s:?} is hidden");
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
}
|