handoff-mcp-server 0.20.0 → 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.
@@ -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
+ }