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(end) = find_frontmatter_end(&chunks[idx]) {
125 format!("---\n{merged_fm}\n---{}", &chunks[idx][end..])
126 } else {
127 format!("---\n{merged_fm}\n---\n\n{}", chunks[idx])
128 }
129 } else {
130 format!("---\n{merged_fm}\n---\n\n{}", chunks[idx])
131 };
132
133 Ok(result)
134}
135
136fn extract_frontmatter_lines(markdown: &str) -> Vec<(String, String)> {
145 let Some(end) = find_frontmatter_end(markdown) else {
146 return Vec::new();
147 };
148 let inner_end = end - 4;
152 let inner = &markdown[4..inner_end];
153 inner
154 .lines()
155 .filter_map(|line| {
156 let trimmed = line.trim_end();
157 if trimmed.is_empty() {
158 return None;
159 }
160 let colon = trimmed.find(':')?;
161 let key = trimmed[..colon].trim().to_string();
162 let value = trimmed[colon + 1..].trim_start().to_string();
163 if key.is_empty() {
164 None
165 } else {
166 Some((key, value))
167 }
168 })
169 .collect()
170}
171
172fn merge_chunk_frontmatter(
180 original: &[(String, String)],
181 extra_fm: &[(&str, &str)],
182 idx: usize,
183 total: usize,
184 truncated: bool,
185) -> String {
186 use indexmap::IndexMap;
187 const CHUNK_WALK_KEYS: &[&str] = &["_truncated", "_chunk", "_total_chunks"];
188
189 let mut keyed: IndexMap<String, String> = IndexMap::new();
190 for (k, v) in original {
191 if CHUNK_WALK_KEYS.contains(&k.as_str()) {
192 continue; }
194 keyed.insert(k.clone(), v.clone());
195 }
196 for (k, v) in extra_fm {
197 if CHUNK_WALK_KEYS.contains(k) {
198 continue;
199 }
200 keyed.insert((*k).to_string(), (*v).to_string());
201 }
202 if truncated {
203 keyed.insert("_truncated".to_string(), "true".to_string());
204 }
205 keyed.insert("_chunk".to_string(), format!("{idx} of {total}"));
206 keyed.insert("_total_chunks".to_string(), total.to_string());
207
208 keyed
209 .iter()
210 .map(|(k, v)| format!("{k}: {v}"))
211 .collect::<Vec<_>>()
212 .join("\n")
213}
214
215fn inject_chunk_frontmatter(markdown: &str, idx: usize, total: usize, truncated: bool) -> String {
221 let chunk_line = format!("_chunk: {idx} of {total}");
222 let total_line = format!("_total_chunks: {total}");
223 let truncated_line = if truncated { "_truncated: true\n" } else { "" };
224 match find_frontmatter_end(markdown) {
225 Some(end) => {
226 let inner_end = end - 4;
230 let inner = markdown[4..inner_end].trim_end_matches('\n');
231 let separator = if inner.is_empty() { "" } else { "\n" };
232 format!(
233 "---\n{inner}{separator}{truncated_line}{chunk_line}\n{total_line}\n---{}",
234 &markdown[end..]
235 )
236 }
237 None => format!("---\n{truncated_line}{chunk_line}\n{total_line}\n---\n\n{markdown}"),
238 }
239}
240
241fn find_frontmatter_end(text: &str) -> Option<usize> {
243 if !text.starts_with("---\n") {
244 return None;
245 }
246 text[4..].find("\n---").map(|pos| pos + 4 + 4) }
249
250#[cfg(test)]
251mod tests {
252 use super::*;
253
254 #[test]
255 fn estimate_tokens_basic() {
256 assert_eq!(estimate_tokens("hello world!"), 3); }
258
259 #[test]
263 fn floor_chunk_budget_raises_tiny_budgets_only() {
264 assert_eq!(floor_chunk_budget(5), MIN_TRANSPORT_CHUNK_BUDGET);
265 assert_eq!(floor_chunk_budget(0), MIN_TRANSPORT_CHUNK_BUDGET);
266 assert_eq!(floor_chunk_budget(25_000), 25_000);
267 let small_body = "line one\nline two\nline three\n";
270 assert!(chunk_markdown(small_body, floor_chunk_budget(5)).is_none());
271 assert!(chunk_markdown(small_body, 5).unwrap().len() > 1);
272 }
273
274 #[test]
275 fn chunk_small_content_returns_none() {
276 let text = "short";
277 assert!(chunk_markdown(text, 100).is_none());
278 }
279
280 #[test]
281 fn chunk_splits_at_newline_boundaries() {
282 let text = "line1\nline2\nline3\nline4\nline5\n";
283 let chunks = chunk_markdown(text, 2).unwrap();
285 assert!(chunks.len() > 1);
286 for chunk in &chunks[..chunks.len() - 1] {
288 assert!(chunk.ends_with('\n') || !chunk.contains('\n'));
289 }
290 }
291
292 #[test]
293 fn apply_chunking_no_split_needed_injects_chunk_metadata() {
294 let md = "---\n_hash: abc\n---\n\n# Title\n\nContent";
295 let result = apply_chunking(md, 10000, None, &[]).unwrap();
296 assert!(
297 result.contains("_hash: abc"),
298 "preserves existing frontmatter key"
299 );
300 assert!(result.contains("_chunk: 1 of 1"), "got: {result}");
301 assert!(result.contains("_total_chunks: 1"), "got: {result}");
302 assert!(result.ends_with("# Title\n\nContent"), "preserves body");
303 }
304
305 #[test]
306 fn apply_chunking_invalid_chunk_returns_error() {
307 let md = "a\nb\n".repeat(100);
308 let result = apply_chunking(&md, 1, Some(999), &[]);
309 assert!(result.is_err());
310 }
311
312 #[test]
313 fn apply_chunking_out_of_range_errors_even_when_no_split_needed() {
314 let md = "---\n_hash: x\n---\n\n# Small\n";
318 let result = apply_chunking(md, 10000, Some(99), &[]);
319 assert!(result.is_err(), "out-of-range request must fail");
320 }
321
322 #[test]
323 fn apply_chunking_no_frontmatter_prepends_one() {
324 let md = "# Bare\n\nNo frontmatter here.";
325 let result = apply_chunking(md, 10000, None, &[]).unwrap();
326 assert!(result.starts_with("---\n_chunk: 1 of 1\n_total_chunks: 1\n---"));
327 assert!(result.contains("# Bare"));
328 }
329
330 #[test]
335 fn apply_chunking_preserves_entity_frontmatter_on_chunk_1() {
336 let mut md = String::from(
340 "---\n\
341 _hash: abc123\n\
342 type: spec\n\
343 level: M0\n\
344 stability: stable\n\
345 created_date: 2026-01-01\n\
346 last_modified: 2026-05-17\n\
347 _tokens: 9999\n\
348 ---\n\n\
349 # Title\n\n\
350 ",
351 );
352 for i in 0..200 {
353 md.push_str(&format!(
354 "body line {i} with enough content to span chunks\n"
355 ));
356 }
357
358 let result = apply_chunking(
359 &md,
360 100,
361 Some(1),
362 &[("_hash", "fresh-hash"), ("_mem_schema", "default@1.0.0")],
363 )
364 .unwrap();
365 for key in [
366 "type:",
367 "level:",
368 "stability:",
369 "created_date:",
370 "last_modified:",
371 ] {
372 assert!(
373 result.contains(key),
374 "chunk 1 must carry the entity-level `{key}` frontmatter key — got:\n{result}",
375 );
376 }
377 assert!(
379 result.contains("_hash: fresh-hash"),
380 "extra_fm must override the original frontmatter's `_hash`",
381 );
382 assert!(
383 result.contains("_mem_schema: default@1.0.0"),
384 "extra_fm key must be present",
385 );
386 assert!(result.contains("_truncated: true"));
388 assert!(result.contains("_chunk: 1 of "));
389 }
390
391 #[test]
393 fn apply_chunking_preserves_entity_frontmatter_on_later_chunks() {
394 let mut md = String::from(
395 "---\n\
396 _hash: abc123\n\
397 type: memo\n\
398 level: M1\n\
399 created_date: 2026-01-01\n\
400 _tokens_unfiltered_body: 5000\n\
401 ---\n\n\
402 # Title\n\n\
403 ",
404 );
405 for i in 0..300 {
406 md.push_str(&format!(
407 "body line {i}: long enough content for spread chunking\n"
408 ));
409 }
410
411 let chunk_1 = apply_chunking(&md, 100, Some(1), &[("_hash", "h")]).unwrap();
414 let total_chunks: usize = chunk_1
417 .lines()
418 .find_map(|l| l.strip_prefix("_total_chunks: "))
419 .and_then(|s| s.parse().ok())
420 .unwrap_or_else(|| panic!("chunk 1 must declare _total_chunks: {chunk_1}"));
421 assert!(total_chunks >= 3, "test fixture must produce ≥3 chunks");
422
423 for chunk_idx in 2..=total_chunks {
424 let chunk = apply_chunking(&md, 100, Some(chunk_idx), &[("_hash", "h")]).unwrap();
425 for key in ["type: memo", "level: M1", "created_date: 2026-01-01"] {
426 assert!(
427 chunk.contains(key),
428 "chunk {chunk_idx} must carry `{key}` in its frontmatter — got:\n{chunk}",
429 );
430 }
431 }
432 }
433
434 #[test]
438 fn apply_chunking_caller_supplied_wins_on_collision() {
439 let mut md = String::from(
440 "---\n\
441 _hash: stale-from-prior-write\n\
442 type: spec\n\
443 ---\n\n",
444 );
445 for i in 0..200 {
446 md.push_str(&format!("line {i}: filler to force multi-chunk emission\n"));
447 }
448
449 let chunk_1 =
450 apply_chunking(&md, 100, Some(1), &[("_hash", "post-mutation-hash")]).unwrap();
451 assert!(chunk_1.contains("_hash: post-mutation-hash"));
452 assert!(
453 !chunk_1.contains("_hash: stale-from-prior-write"),
454 "stale hash must not survive the merge",
455 );
456 }
457
458 #[test]
467 fn chunk_tiny_budgets_em_dash() {
468 for body in ["—text", "te—xt", "text—", "—a—b—c—", " — — —"] {
471 for budget in 0..=2 {
472 let _ = chunk_markdown(body, budget); }
474 }
475 }
476
477 #[test]
478 fn chunk_tiny_budgets_cjk() {
479 for body in ["日本語", "日本語テスト", "abc日本語def", "日a本b語c"] {
482 for budget in 0..=4 {
483 let _ = chunk_markdown(body, budget);
484 }
485 }
486 }
487
488 #[test]
489 fn chunk_tiny_budgets_emoji_vs() {
490 let heart_vs = "\u{2764}\u{FE0F}";
493 let body = format!("{heart_vs}{heart_vs}{heart_vs}text{heart_vs}{heart_vs}");
494 for budget in 0..=5 {
495 let _ = chunk_markdown(&body, budget);
496 }
497 }
498
499 #[test]
500 fn chunk_markdown_budget_zero_emits_single_chunk() {
501 let chunks = chunk_markdown("any non-trivial body", 0).expect("non-empty body chunks");
504 assert_eq!(chunks.len(), 1);
505 assert_eq!(chunks[0], "any non-trivial body");
506 }
507
508 #[test]
509 fn apply_chunking_tiny_budgets_with_em_dash() {
510 let md = "---\n_hash: x\n---\n\n# Title — with em-dash\n\nMore body — even more.";
514 for budget in 0..=2 {
515 let result = apply_chunking(md, budget, None, &[]);
516 assert!(
517 result.is_ok(),
518 "budget {budget} must not panic or error: {result:?}"
519 );
520 }
521 }
522
523 #[test]
524 fn chunk_markdown_byte_identical_for_budget_ten_plus() {
525 let body = "line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\n".repeat(20);
529 for budget in [10, 25, 50, 100, 250, 1000] {
530 let chunks = chunk_markdown(&body, budget).unwrap_or_else(|| vec![body.clone()]);
531 let rejoined = chunks.join("\n");
535 assert!(
536 rejoined == body || rejoined == body.trim_end_matches('\n'),
537 "budget={budget}: chunk roundtrip must equal source"
538 );
539 }
540 }
541
542 #[test]
545 fn apply_chunking_cross_chunk_frontmatter_consistency() {
546 let mut md = String::from(
547 "---\n\
548 _hash: abc\n\
549 type: decision\n\
550 level: M2\n\
551 stability: stable\n\
552 ---\n\n",
553 );
554 for i in 0..300 {
555 md.push_str(&format!("line {i}: filler\n"));
556 }
557
558 let chunk_1 = apply_chunking(&md, 100, Some(1), &[]).unwrap();
559 let chunk_2 = apply_chunking(&md, 100, Some(2), &[]).unwrap();
560 for key in ["type: decision", "level: M2", "stability: stable"] {
561 assert!(chunk_1.contains(key), "chunk_1 missing `{key}`");
562 assert!(chunk_2.contains(key), "chunk_2 missing `{key}`");
563 }
564 }
565}