1pub fn estimate_tokens(text: &str) -> usize {
5 text.chars().count() / 4
6}
7
8pub const MIN_TRANSPORT_CHUNK_BUDGET: usize = 4096;
22
23pub fn floor_chunk_budget(requested: usize) -> usize {
27 requested.max(MIN_TRANSPORT_CHUNK_BUDGET)
28}
29
30pub fn chunk_markdown(markdown: &str, budget: usize) -> Option<Vec<String>> {
34 let char_budget = budget * 4;
35 if markdown.len() <= char_budget {
36 return None;
37 }
38
39 let mut chunks = Vec::new();
40 let mut remaining = markdown;
41
42 while !remaining.is_empty() {
43 if remaining.len() <= char_budget {
44 chunks.push(remaining.to_string());
45 break;
46 }
47 let safe_budget = remaining.floor_char_boundary(char_budget);
50 if safe_budget == 0 {
57 chunks.push(remaining.to_string());
58 break;
59 }
60 let (split_at, advance) = match remaining[..safe_budget].rfind('\n').filter(|&i| i > 0) {
65 Some(i) => (i, i + 1),
66 None => (safe_budget, safe_budget),
67 };
68 chunks.push(remaining[..split_at].to_string());
69 remaining = &remaining[advance..];
70 }
71
72 Some(chunks)
73}
74
75pub fn apply_chunking(
85 markdown: &str,
86 budget: usize,
87 chunk: Option<usize>,
88 extra_fm: &[(&str, &str)],
89) -> Result<String, String> {
90 let chunks_opt = chunk_markdown(markdown, budget);
91 let total = chunks_opt.as_ref().map(|c| c.len()).unwrap_or(1);
92 let idx = chunk.unwrap_or(1).saturating_sub(1);
93 if idx >= total {
94 return Err(format!(
95 "Chunk {} does not exist. Content has {} chunk{s}.",
96 idx + 1,
97 total,
98 s = if total == 1 { "" } else { "s" },
99 ));
100 }
101
102 if chunks_opt.is_none() {
106 return Ok(inject_chunk_frontmatter(markdown, 1, 1, false));
107 }
108
109 let chunks = chunks_opt.unwrap();
110 let is_last = idx == total - 1;
111
112 let original_fm = extract_frontmatter_lines(markdown);
121 let merged_fm = merge_chunk_frontmatter(&original_fm, extra_fm, idx + 1, total, !is_last);
122
123 let result = if idx == 0 {
124 if let Some((_, body)) = frontmatter_parts(&chunks[idx]) {
128 format!("---\n{merged_fm}\n---\n{body}")
129 } else {
130 format!("---\n{merged_fm}\n---\n\n{}", chunks[idx])
131 }
132 } else {
133 format!("---\n{merged_fm}\n---\n\n{}", chunks[idx])
134 };
135
136 Ok(result)
137}
138
139fn extract_frontmatter_lines(markdown: &str) -> Vec<(String, String)> {
148 let Some((inner, _)) = frontmatter_parts(markdown) else {
149 return Vec::new();
150 };
151 inner
152 .lines()
153 .filter_map(|line| {
154 let trimmed = line.trim_end();
155 if trimmed.is_empty() {
156 return None;
157 }
158 let colon = trimmed.find(':')?;
159 let key = trimmed[..colon].trim().to_string();
160 let value = trimmed[colon + 1..].trim_start().to_string();
161 if key.is_empty() {
162 None
163 } else {
164 Some((key, value))
165 }
166 })
167 .collect()
168}
169
170fn merge_chunk_frontmatter(
178 original: &[(String, String)],
179 extra_fm: &[(&str, &str)],
180 idx: usize,
181 total: usize,
182 truncated: bool,
183) -> String {
184 use indexmap::IndexMap;
185 const CHUNK_WALK_KEYS: &[&str] = &["_truncated", "_chunk", "_total_chunks"];
186
187 let mut keyed: IndexMap<String, String> = IndexMap::new();
188 for (k, v) in original {
189 if CHUNK_WALK_KEYS.contains(&k.as_str()) {
190 continue; }
192 keyed.insert(k.clone(), v.clone());
193 }
194 for (k, v) in extra_fm {
195 if CHUNK_WALK_KEYS.contains(k) {
196 continue;
197 }
198 keyed.insert((*k).to_string(), (*v).to_string());
199 }
200 if truncated {
201 keyed.insert("_truncated".to_string(), "true".to_string());
202 }
203 keyed.insert("_chunk".to_string(), format!("{idx} of {total}"));
204 keyed.insert("_total_chunks".to_string(), total.to_string());
205
206 keyed
207 .iter()
208 .map(|(k, v)| format!("{k}: {v}"))
209 .collect::<Vec<_>>()
210 .join("\n")
211}
212
213fn inject_chunk_frontmatter(markdown: &str, idx: usize, total: usize, truncated: bool) -> String {
219 let chunk_line = format!("_chunk: {idx} of {total}");
220 let total_line = format!("_total_chunks: {total}");
221 let truncated_line = if truncated { "_truncated: true\n" } else { "" };
222 match frontmatter_parts(markdown) {
223 Some((meta, body)) => {
224 let inner = meta.trim_end_matches(['\n', '\r']);
225 let separator = if inner.is_empty() { "" } else { "\n" };
226 format!(
227 "---\n{inner}{separator}{truncated_line}{chunk_line}\n{total_line}\n---\n{body}"
228 )
229 }
230 None => format!("---\n{truncated_line}{chunk_line}\n{total_line}\n---\n\n{markdown}"),
231 }
232}
233
234fn frontmatter_parts(text: &str) -> Option<(&str, &str)> {
246 match crate::entity::parser::split_frontmatter_core(text) {
247 (_, crate::entity::parser::Frontmatter::Present { meta, body }) => Some((meta, body)),
248 _ => None,
249 }
250}
251
252#[cfg(test)]
253mod tests {
254 use super::*;
255
256 #[test]
257 fn estimate_tokens_basic() {
258 assert_eq!(estimate_tokens("hello world!"), 3); }
260
261 #[test]
265 fn floor_chunk_budget_raises_tiny_budgets_only() {
266 assert_eq!(floor_chunk_budget(5), MIN_TRANSPORT_CHUNK_BUDGET);
267 assert_eq!(floor_chunk_budget(0), MIN_TRANSPORT_CHUNK_BUDGET);
268 assert_eq!(floor_chunk_budget(25_000), 25_000);
269 let small_body = "line one\nline two\nline three\n";
272 assert!(chunk_markdown(small_body, floor_chunk_budget(5)).is_none());
273 assert!(chunk_markdown(small_body, 5).unwrap().len() > 1);
274 }
275
276 #[test]
277 fn chunk_small_content_returns_none() {
278 let text = "short";
279 assert!(chunk_markdown(text, 100).is_none());
280 }
281
282 #[test]
283 fn chunk_splits_at_newline_boundaries() {
284 let text = "line1\nline2\nline3\nline4\nline5\n";
285 let chunks = chunk_markdown(text, 2).unwrap();
287 assert!(chunks.len() > 1);
288 for chunk in &chunks[..chunks.len() - 1] {
290 assert!(chunk.ends_with('\n') || !chunk.contains('\n'));
291 }
292 }
293
294 #[test]
295 fn apply_chunking_no_split_needed_injects_chunk_metadata() {
296 let md = "---\n_hash: abc\n---\n\n# Title\n\nContent";
297 let result = apply_chunking(md, 10000, None, &[]).unwrap();
298 assert!(
299 result.contains("_hash: abc"),
300 "preserves existing frontmatter key"
301 );
302 assert!(result.contains("_chunk: 1 of 1"), "got: {result}");
303 assert!(result.contains("_total_chunks: 1"), "got: {result}");
304 assert!(result.ends_with("# Title\n\nContent"), "preserves body");
305 }
306
307 #[test]
308 fn apply_chunking_invalid_chunk_returns_error() {
309 let md = "a\nb\n".repeat(100);
310 let result = apply_chunking(&md, 1, Some(999), &[]);
311 assert!(result.is_err());
312 }
313
314 #[test]
315 fn apply_chunking_out_of_range_errors_even_when_no_split_needed() {
316 let md = "---\n_hash: x\n---\n\n# Small\n";
320 let result = apply_chunking(md, 10000, Some(99), &[]);
321 assert!(result.is_err(), "out-of-range request must fail");
322 }
323
324 #[test]
325 fn apply_chunking_no_frontmatter_prepends_one() {
326 let md = "# Bare\n\nNo frontmatter here.";
327 let result = apply_chunking(md, 10000, None, &[]).unwrap();
328 assert!(result.starts_with("---\n_chunk: 1 of 1\n_total_chunks: 1\n---"));
329 assert!(result.contains("# Bare"));
330 }
331
332 #[test]
337 fn apply_chunking_preserves_entity_frontmatter_on_chunk_1() {
338 let mut md = String::from(
342 "---\n\
343 _hash: abc123\n\
344 type: spec\n\
345 level: M0\n\
346 stability: stable\n\
347 created_date: 2026-01-01\n\
348 last_modified: 2026-05-17\n\
349 _tokens: 9999\n\
350 ---\n\n\
351 # Title\n\n\
352 ",
353 );
354 for i in 0..200 {
355 md.push_str(&format!(
356 "body line {i} with enough content to span chunks\n"
357 ));
358 }
359
360 let result = apply_chunking(
361 &md,
362 100,
363 Some(1),
364 &[("_hash", "fresh-hash"), ("_mem_schema", "default@1.0.0")],
365 )
366 .unwrap();
367 for key in [
368 "type:",
369 "level:",
370 "stability:",
371 "created_date:",
372 "last_modified:",
373 ] {
374 assert!(
375 result.contains(key),
376 "chunk 1 must carry the entity-level `{key}` frontmatter key — got:\n{result}",
377 );
378 }
379 assert!(
381 result.contains("_hash: fresh-hash"),
382 "extra_fm must override the original frontmatter's `_hash`",
383 );
384 assert!(
385 result.contains("_mem_schema: default@1.0.0"),
386 "extra_fm key must be present",
387 );
388 assert!(result.contains("_truncated: true"));
390 assert!(result.contains("_chunk: 1 of "));
391 }
392
393 #[test]
395 fn apply_chunking_preserves_entity_frontmatter_on_later_chunks() {
396 let mut md = String::from(
397 "---\n\
398 _hash: abc123\n\
399 type: memo\n\
400 level: M1\n\
401 created_date: 2026-01-01\n\
402 _tokens_unfiltered_body: 5000\n\
403 ---\n\n\
404 # Title\n\n\
405 ",
406 );
407 for i in 0..300 {
408 md.push_str(&format!(
409 "body line {i}: long enough content for spread chunking\n"
410 ));
411 }
412
413 let chunk_1 = apply_chunking(&md, 100, Some(1), &[("_hash", "h")]).unwrap();
416 let total_chunks: usize = chunk_1
419 .lines()
420 .find_map(|l| l.strip_prefix("_total_chunks: "))
421 .and_then(|s| s.parse().ok())
422 .unwrap_or_else(|| panic!("chunk 1 must declare _total_chunks: {chunk_1}"));
423 assert!(total_chunks >= 3, "test fixture must produce ≥3 chunks");
424
425 for chunk_idx in 2..=total_chunks {
426 let chunk = apply_chunking(&md, 100, Some(chunk_idx), &[("_hash", "h")]).unwrap();
427 for key in ["type: memo", "level: M1", "created_date: 2026-01-01"] {
428 assert!(
429 chunk.contains(key),
430 "chunk {chunk_idx} must carry `{key}` in its frontmatter — got:\n{chunk}",
431 );
432 }
433 }
434 }
435
436 #[test]
440 fn apply_chunking_caller_supplied_wins_on_collision() {
441 let mut md = String::from(
442 "---\n\
443 _hash: stale-from-prior-write\n\
444 type: spec\n\
445 ---\n\n",
446 );
447 for i in 0..200 {
448 md.push_str(&format!("line {i}: filler to force multi-chunk emission\n"));
449 }
450
451 let chunk_1 =
452 apply_chunking(&md, 100, Some(1), &[("_hash", "post-mutation-hash")]).unwrap();
453 assert!(chunk_1.contains("_hash: post-mutation-hash"));
454 assert!(
455 !chunk_1.contains("_hash: stale-from-prior-write"),
456 "stale hash must not survive the merge",
457 );
458 }
459
460 #[test]
469 fn chunk_tiny_budgets_em_dash() {
470 for body in ["—text", "te—xt", "text—", "—a—b—c—", " — — —"] {
473 for budget in 0..=2 {
474 let _ = chunk_markdown(body, budget); }
476 }
477 }
478
479 #[test]
480 fn chunk_tiny_budgets_cjk() {
481 for body in ["日本語", "日本語テスト", "abc日本語def", "日a本b語c"] {
484 for budget in 0..=4 {
485 let _ = chunk_markdown(body, budget);
486 }
487 }
488 }
489
490 #[test]
491 fn chunk_tiny_budgets_emoji_vs() {
492 let heart_vs = "\u{2764}\u{FE0F}";
495 let body = format!("{heart_vs}{heart_vs}{heart_vs}text{heart_vs}{heart_vs}");
496 for budget in 0..=5 {
497 let _ = chunk_markdown(&body, budget);
498 }
499 }
500
501 #[test]
502 fn chunk_markdown_budget_zero_emits_single_chunk() {
503 let chunks = chunk_markdown("any non-trivial body", 0).expect("non-empty body chunks");
506 assert_eq!(chunks.len(), 1);
507 assert_eq!(chunks[0], "any non-trivial body");
508 }
509
510 #[test]
511 fn apply_chunking_tiny_budgets_with_em_dash() {
512 let md = "---\n_hash: x\n---\n\n# Title — with em-dash\n\nMore body — even more.";
516 for budget in 0..=2 {
517 let result = apply_chunking(md, budget, None, &[]);
518 assert!(
519 result.is_ok(),
520 "budget {budget} must not panic or error: {result:?}"
521 );
522 }
523 }
524
525 #[test]
526 fn chunk_markdown_byte_identical_for_budget_ten_plus() {
527 let body = "line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\n".repeat(20);
531 for budget in [10, 25, 50, 100, 250, 1000] {
532 let chunks = chunk_markdown(&body, budget).unwrap_or_else(|| vec![body.clone()]);
533 let rejoined = chunks.join("\n");
537 assert!(
538 rejoined == body || rejoined == body.trim_end_matches('\n'),
539 "budget={budget}: chunk roundtrip must equal source"
540 );
541 }
542 }
543
544 #[test]
547 fn apply_chunking_cross_chunk_frontmatter_consistency() {
548 let mut md = String::from(
549 "---\n\
550 _hash: abc\n\
551 type: decision\n\
552 level: M2\n\
553 stability: stable\n\
554 ---\n\n",
555 );
556 for i in 0..300 {
557 md.push_str(&format!("line {i}: filler\n"));
558 }
559
560 let chunk_1 = apply_chunking(&md, 100, Some(1), &[]).unwrap();
561 let chunk_2 = apply_chunking(&md, 100, Some(2), &[]).unwrap();
562 for key in ["type: decision", "level: M2", "stability: stable"] {
563 assert!(chunk_1.contains(key), "chunk_1 missing `{key}`");
564 assert!(chunk_2.contains(key), "chunk_2 missing `{key}`");
565 }
566 }
567
568 #[test]
575 fn inject_preserves_carriage_return_frontmatter() {
576 let doc = "---\r\ntype: spec\r\nlevel: M0\r\n---\r\n\r\n# Title\r\n\r\nBody text.\r\n";
577 let out = inject_chunk_frontmatter(doc, 1, 1, false);
578 assert_eq!(
579 out.matches("---").count(),
580 2,
581 "exactly one frontmatter block, not a second prepended over the first:\n{out}"
582 );
583 assert!(
584 out.contains("type: spec") && out.contains("_chunk: 1 of 1"),
585 "entity keys and chunk-walk keys share the one block:\n{out}"
586 );
587 }
588}