1use serde::{Deserialize, Serialize};
48
49use crate::anchor::{AnchorGrain, AnchorHashStability, prepared_content_hash};
50use crate::entity::Entity;
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
54#[serde(rename_all = "kebab-case")]
55pub enum Touchpoint {
56 PreparedForm,
59 DeliveryUnits,
62}
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66pub struct Preparation {
67 pub id: &'static str,
69 pub touchpoint: Touchpoint,
71 pub grains: &'static [AnchorGrain],
75 pub description: &'static str,
77}
78
79pub const ENTITY_LOAD_BEARING: &str = "entity-load-bearing";
84
85pub const DATED_ENTRIES: &str = "dated-entries";
101
102pub const WHOLE_FILE_UNIT: &str = "whole";
106
107pub const CODE_MAP: &str = "code-map";
126
127pub const REGISTRY: &[Preparation] = &[
130 Preparation {
131 id: ENTITY_LOAD_BEARING,
132 touchpoint: Touchpoint::PreparedForm,
133 grains: &[AnchorGrain::Entity],
134 description: "an entity's prepared form is the stable serialization of its type's \
135 load-bearing sections (explicitly declared, else the required sections, \
136 else every section) — notes-only edits keep dependents' anchors resolving",
137 },
138 Preparation {
139 id: DATED_ENTRIES,
140 touchpoint: Touchpoint::DeliveryUnits,
141 grains: &[AnchorGrain::Span],
142 description: "a file is a sequence of entries opening with an ISO date or date-time; \
143 each entry is one delivery unit `<path>#<stamp>`, and a source's units \
144 deliver in stamp order, identical on every pass — a chronological corpus \
145 (logs, transcripts, journals, mail threads) is never shuffled",
146 },
147 Preparation {
148 id: CODE_MAP,
149 touchpoint: Touchpoint::PreparedForm,
150 grains: &[AnchorGrain::File, AnchorGrain::Span, AnchorGrain::Tree],
151 description: "a scoped code file's prepared form is its interface digest (imports, \
152 exports, declarations and their signatures; comments, formatting and \
153 bodies invisible), and a tree's is the digest of every scoped file under \
154 it — an anchor drifts when an interface changes and stays quiet when \
155 only an implementation does",
156 },
157];
158
159pub fn registry() -> &'static [Preparation] {
161 REGISTRY
162}
163
164pub fn lookup(id: &str) -> Option<&'static Preparation> {
166 REGISTRY.iter().find(|p| p.id == id)
167}
168
169pub fn is_registered(id: &str) -> bool {
171 lookup(id).is_some()
172}
173
174pub fn registered_identifiers() -> Vec<&'static str> {
177 REGISTRY.iter().map(|p| p.id).collect()
178}
179
180pub fn delivery_preparation(declared: Option<&str>) -> Option<&'static Preparation> {
184 lookup(declared?).filter(|p| p.touchpoint == Touchpoint::DeliveryUnits)
185}
186
187pub fn applies_to_namespace(preparation: &Preparation, anchor_namespace: &str) -> bool {
194 preparation
195 .grains
196 .iter()
197 .any(|g| g.supported_by_namespace(anchor_namespace))
198}
199
200pub fn default_hash_stability(grain: AnchorGrain) -> AnchorHashStability {
209 match grain {
210 AnchorGrain::Url => AnchorHashStability::Unstable,
211 AnchorGrain::Span | AnchorGrain::File | AnchorGrain::Tree | AnchorGrain::Entity => {
212 AnchorHashStability::Stable
213 }
214 }
215}
216
217pub fn url_prepared_hash(content: &[u8]) -> String {
223 prepared_content_hash(content)
224}
225
226pub fn supplied_content_hash(grain: AnchorGrain, content: &[u8]) -> Option<String> {
234 match grain {
235 AnchorGrain::Span | AnchorGrain::File => Some(prepared_content_hash(content)),
236 AnchorGrain::Url => Some(url_prepared_hash(content)),
237 AnchorGrain::Tree | AnchorGrain::Entity => None,
238 }
239}
240
241#[derive(Debug, Clone, PartialEq, Eq)]
247pub enum PathPrepared {
248 Hash(String),
250 NoHash,
253 UnitAbsent,
256}
257
258pub fn path_prepared_hash(
269 preparation: Option<&str>,
270 artifact: &str,
271 grain: AnchorGrain,
272 bytes: &[u8],
273) -> PathPrepared {
274 let (path, locator) = split_unit_id(artifact);
275 match (preparation, grain) {
276 (_, AnchorGrain::Url | AnchorGrain::Entity | AnchorGrain::Tree) => PathPrepared::NoHash,
277 (Some(DATED_ENTRIES), AnchorGrain::Span) if locator.is_some() => {
278 let text = String::from_utf8_lossy(bytes);
279 match unitize(DATED_ENTRIES, &text)
280 .and_then(|units| units.into_iter().find(|u| Some(u.key.as_str()) == locator))
281 {
282 Some(unit) => PathPrepared::Hash(unit.hash),
283 None => PathPrepared::UnitAbsent,
284 }
285 }
286 (Some(CODE_MAP), AnchorGrain::File | AnchorGrain::Span) => {
287 let text = String::from_utf8_lossy(bytes);
288 PathPrepared::Hash(prepared_content_hash(
289 code_map_digest(path, &text).as_bytes(),
290 ))
291 }
292 (_, AnchorGrain::File | AnchorGrain::Span) => {
293 PathPrepared::Hash(prepared_content_hash(bytes))
294 }
295 }
296}
297
298pub fn code_map_tree_digest(files: &[(String, String)]) -> String {
303 let mut rows: Vec<(&str, &str)> = files
304 .iter()
305 .map(|(path, text)| (path.as_str(), text.as_str()))
306 .collect();
307 rows.sort();
308 rows.iter()
309 .map(|(path, text)| {
310 format!(
311 "{} {path}",
312 prepared_content_hash(code_map_digest(path, text).as_bytes())
313 )
314 })
315 .collect::<Vec<_>>()
316 .join("\n")
317}
318
319pub fn code_map_digest(path: &str, text: &str) -> String {
321 match family_of(path) {
322 Family::Text => text.to_string(),
323 Family::Json => serde_json::from_str::<serde_json::Value>(text)
324 .map(|v| v.to_string())
325 .unwrap_or_else(|_| text.to_string()),
326 Family::Vue => {
327 declaration_lines(&strip_c_comments(&vue_script_blocks(text)), Family::CLike)
328 }
329 Family::CLike => declaration_lines(&strip_c_comments(text), Family::CLike),
330 Family::Rust => declaration_lines(&strip_c_comments(text), Family::Rust),
331 Family::Python => declaration_lines(&strip_python_comments(text), Family::Python),
332 }
333}
334
335#[derive(Debug, Clone, Copy, PartialEq, Eq)]
336enum Family {
337 CLike,
338 Rust,
341 Python,
342 Json,
343 Vue,
344 Text,
345}
346
347fn family_of(path: &str) -> Family {
348 let name = path.rsplit('/').next().unwrap_or(path);
349 let ext = match name.rsplit_once('.') {
350 Some((_, ext)) => ext.to_ascii_lowercase(),
351 None => return Family::Text,
352 };
353 match ext.as_str() {
354 "rs" => Family::Rust,
355 "js" | "mjs" | "cjs" | "jsx" | "ts" | "tsx" | "mts" | "cts" | "go" | "java" | "kt"
356 | "kts" | "swift" | "cs" | "c" | "h" | "cc" | "cpp" | "hpp" | "m" | "mm" | "php"
357 | "dart" | "scala" => Family::CLike,
358 "py" | "pyi" => Family::Python,
359 "json" => Family::Json,
360 "vue" | "svelte" => Family::Vue,
361 _ => Family::Text,
362 }
363}
364
365fn vue_script_blocks(text: &str) -> String {
368 let lower = text.to_ascii_lowercase();
369 let mut out = String::new();
370 let mut from = 0;
371 while let Some(open) = lower[from..].find("<script") {
372 let open = from + open;
373 let Some(tag_end) = lower[open..].find('>') else {
374 break;
375 };
376 let body_start = open + tag_end + 1;
377 let Some(close) = lower[body_start..].find("</script") else {
378 out.push_str(&text[body_start..]);
379 break;
380 };
381 out.push_str(&text[body_start..body_start + close]);
382 out.push('\n');
383 from = body_start + close + 8;
384 }
385 out
386}
387
388fn strip_c_comments(text: &str) -> String {
392 let mut out = String::with_capacity(text.len());
393 let mut chars = text.chars().peekable();
394 let mut in_str: Option<char> = None;
395 let mut escape = false;
396 while let Some(c) = chars.next() {
397 if let Some(q) = in_str {
398 out.push(c);
399 if escape {
400 escape = false;
401 } else if c == '\\' {
402 escape = true;
403 } else if c == q || (c == '\n' && q != '`') {
404 in_str = None;
405 }
406 continue;
407 }
408 match c {
409 '"' | '\'' | '`' => {
410 in_str = Some(c);
411 out.push(c);
412 }
413 '/' => match chars.peek() {
414 Some('/') => {
415 for n in chars.by_ref() {
416 if n == '\n' {
417 out.push('\n');
418 break;
419 }
420 }
421 }
422 Some('*') => {
423 chars.next();
424 let mut prev = '\0';
425 for n in chars.by_ref() {
426 if n == '\n' {
427 out.push('\n');
428 }
429 if prev == '*' && n == '/' {
430 break;
431 }
432 prev = n;
433 }
434 }
435 _ => out.push(c),
436 },
437 _ => out.push(c),
438 }
439 }
440 out
441}
442
443fn strip_python_comments(text: &str) -> String {
446 let mut out = String::with_capacity(text.len());
447 let bytes: Vec<char> = text.chars().collect();
448 let mut i = 0;
449 let mut in_str: Option<char> = None;
450 let mut triple: Option<char> = None;
451 while i < bytes.len() {
452 let c = bytes[i];
453 if let Some(q) = triple {
454 if c == q && i + 2 < bytes.len() && bytes[i + 1] == q && bytes[i + 2] == q {
455 triple = None;
456 i += 3;
457 continue;
458 }
459 if c == '\n' {
460 out.push('\n');
461 }
462 i += 1;
463 continue;
464 }
465 if let Some(q) = in_str {
466 out.push(c);
467 if c == '\\' && i + 1 < bytes.len() {
468 out.push(bytes[i + 1]);
469 i += 2;
470 continue;
471 }
472 if c == q || c == '\n' {
473 in_str = None;
474 }
475 i += 1;
476 continue;
477 }
478 match c {
479 '"' | '\'' => {
480 if i + 2 < bytes.len() && bytes[i + 1] == c && bytes[i + 2] == c {
481 triple = Some(c);
482 i += 3;
483 continue;
484 }
485 in_str = Some(c);
486 out.push(c);
487 }
488 '#' => {
489 while i < bytes.len() && bytes[i] != '\n' {
490 i += 1;
491 }
492 continue;
493 }
494 _ => out.push(c),
495 }
496 i += 1;
497 }
498 out
499}
500
501const C_LIKE_TOP_LEVEL: &[&str] = &[
502 "import ",
503 "export ",
504 "module.exports",
505 "exports.",
506 "function ",
507 "async function ",
508 "class ",
509 "interface ",
510 "type ",
511 "enum ",
512 "declare ",
513 "const ",
514 "let ",
515 "var ",
516 "pub ",
517 "fn ",
518 "struct ",
519 "trait ",
520 "impl ",
521 "impl<",
522 "mod ",
523 "use ",
524 "static ",
525 "macro_rules!",
526 "package ",
527 "func ",
528 "namespace ",
529 "using ",
530 "#include",
531 "#[",
532 "@",
533 "public ",
534 "private ",
535 "protected ",
536 "abstract ",
537 "final ",
538 "override ",
539 "typedef ",
540 "extern ",
541 "template",
542 "def ",
543];
544
545const C_LIKE_MEMBER: &[&str] = &[
546 "pub ",
547 "fn ",
548 "public ",
549 "private ",
550 "protected ",
551 "static ",
552 "abstract ",
553 "override ",
554 "readonly ",
555 "async ",
556 "get ",
557 "set ",
558 "constructor",
559 "#[",
560 "@",
561];
562
563fn method_re() -> &'static regex::Regex {
566 static METHOD: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
567 METHOD.get_or_init(|| {
568 regex::Regex::new(r"^(?:(?:async|static|get|set|public|private|protected|override)\s+)*[A-Za-z_$][\w$]*\s*(?:<[^>]*>)?\s*\(").unwrap()
569 })
570}
571
572fn property_re() -> &'static regex::Regex {
576 static PROPERTY: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
577 PROPERTY.get_or_init(|| {
578 regex::Regex::new(r#"^(?:readonly\s+)?(?:['"]?)[A-Za-z_$][\w$]*(?:['"]?)\??\s*:"#).unwrap()
579 })
580}
581
582fn typed_member_re() -> &'static regex::Regex {
587 static TYPED_MEMBER: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
588 TYPED_MEMBER.get_or_init(|| {
589 regex::Regex::new(
590 r"^(?:readonly\s+)?[A-Za-z_$][\w$]*\??\s*(?:<[^>]*>)?\s*\(.*\)\s*:\s*[^{;]+[;,]?$",
591 )
592 .unwrap()
593 })
594}
595
596fn typed_member(line: &str) -> bool {
597 typed_member_re().is_match(line) && !line.contains("? ")
598}
599
600fn enum_member_re() -> &'static regex::Regex {
602 static ENUM_MEMBER: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
603 ENUM_MEMBER
604 .get_or_init(|| regex::Regex::new(r"^[A-Z][A-Za-z0-9_]*(?:\s*=\s*[^,]+)?,?$").unwrap())
605}
606
607fn member_signature_only(line: &str, depth: i32) -> bool {
612 depth >= 1
613 && !C_LIKE_MEMBER.iter().any(|p| line.starts_with(p))
614 && !property_re().is_match(line)
615 && !typed_member(line)
616 && method_re().is_match(line)
617}
618
619fn c_like_keeps(line: &str, depth: i32, next_opens_body: bool, properties: bool) -> bool {
620 let method = method_re();
621 let property = property_re();
622 let signature_shaped = |line: &str| {
626 method.is_match(line)
627 && !line.starts_with("if ")
628 && !line.starts_with("for ")
629 && !line.starts_with("while ")
630 && !line.starts_with("switch ")
631 && !line.starts_with("return ")
632 && !line.starts_with("catch ")
633 && (line.ends_with('{')
634 || line.ends_with('(')
635 || line.ends_with(',')
636 || (line.ends_with(')') && next_opens_body))
637 };
638 match depth {
639 0 => C_LIKE_TOP_LEVEL.iter().any(|p| line.starts_with(p)),
640 1 => {
641 C_LIKE_MEMBER.iter().any(|p| line.starts_with(p))
642 || signature_shaped(line)
643 || (properties && (property.is_match(line) || typed_member(line)))
644 || enum_member_re().is_match(line)
645 }
646 2 => signature_shaped(line),
647 _ => false,
648 }
649}
650
651fn python_keeps(line: &str, indent: usize) -> bool {
652 static CONSTANT: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
653 let constant = CONSTANT
654 .get_or_init(|| regex::Regex::new(r"^(?:[A-Z_][A-Z0-9_]*|__all__)\s*[:=]").unwrap());
655 let decl = line.starts_with("def ")
656 || line.starts_with("async def ")
657 || line.starts_with("class ")
658 || line.starts_with('@');
659 if indent == 0 {
660 decl || line.starts_with("import ") || line.starts_with("from ") || constant.is_match(line)
661 } else {
662 indent <= 4 && decl
663 }
664}
665
666fn python_constant_cut(line: &str) -> Option<usize> {
669 static CONSTANT: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
670 let constant = CONSTANT.get_or_init(|| {
671 regex::Regex::new(r"^(?:[A-Z_][A-Z0-9_]*|__all__)(?:\s*:\s*[^=]+?)?\s*=").unwrap()
672 });
673 constant.find(line).map(|m| m.end())
674}
675
676fn paren_balance(s: &str) -> i32 {
677 s.chars()
678 .map(|c| match c {
679 '(' => 1,
680 ')' => -1,
681 _ => 0,
682 })
683 .sum()
684}
685
686fn brace_delta(s: &str) -> i32 {
687 s.chars()
688 .map(|c| match c {
689 '{' => 1,
690 '}' => -1,
691 _ => 0,
692 })
693 .sum()
694}
695
696fn cut_at_body(sig: &str, family: Family) -> String {
700 match family {
701 Family::Python => sig.trim_end_matches(':').trim_end().to_string(),
702 _ if sig.starts_with("import ")
703 || sig.starts_with("use ")
704 || sig.starts_with("export {")
705 || sig.starts_with("export type {")
706 || sig.starts_with("export * ") =>
707 {
708 sig.trim_end().to_string()
709 }
710 _ => {
711 let mut depth = 0i32;
715 let mut cut = sig.len();
716 let bytes = sig.as_bytes();
717 for (i, c) in sig.char_indices() {
718 match c {
719 '(' | '[' => depth += 1,
720 ')' | ']' => depth -= 1,
721 '{' if depth <= 0 => {
722 cut = i;
723 break;
724 }
725 '=' if depth <= 0 && bytes.get(i + 1) == Some(&b'>') => {
726 let rest = sig[i + 2..].trim_start();
727 if !rest.starts_with('{') {
728 cut = i + 2;
729 break;
730 }
731 }
732 _ => {}
733 }
734 }
735 sig[..cut].trim_end().to_string()
736 }
737 }
738}
739
740fn normalize_signature(sig: &str) -> String {
746 let collapsed: Vec<&str> = sig
747 .trim()
748 .trim_end_matches(';')
749 .split_whitespace()
750 .collect();
751 let joined = collapsed.join(" ").replace('"', "'");
752 let is_punct = |c: char| "()[]{},;:=<>|&?!-+*/.".contains(c);
753 let mut out = String::with_capacity(joined.len());
754 let chars: Vec<char> = joined.chars().collect();
755 for (i, &c) in chars.iter().enumerate() {
756 if c == ' ' {
757 let before = chars[..i].iter().rev().find(|x| **x != ' ').copied();
758 let after = chars[i + 1..].iter().find(|x| **x != ' ').copied();
759 if before.is_some_and(is_punct) || after.is_some_and(is_punct) {
760 continue;
761 }
762 }
763 out.push(c);
764 }
765 let out = out
768 .replace(",)", ")")
769 .replace(",]", "]")
770 .replace(",}", "}")
771 .replace(",>", ">");
772 let out = out.trim_end_matches(',').to_string();
773 let out = out.replace("=|", "=");
775 let out = out.trim_end_matches('{').trim_end().to_string();
777 static QUOTED_KEY: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
781 static ARROW_PARENS: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
782 let quoted_key =
783 QUOTED_KEY.get_or_init(|| regex::Regex::new(r"^'([A-Za-z_$][\w$]*)':").unwrap());
784 let arrow_parens =
785 ARROW_PARENS.get_or_init(|| regex::Regex::new(r"\(([A-Za-z_$][\w$]*)\)=>").unwrap());
786 let out = quoted_key.replace(&out, "$1:").into_owned();
787 let out = arrow_parens.replace_all(&out, "$1=>").into_owned();
788 static SCALAR_PROPERTY: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
791 let scalar = SCALAR_PROPERTY
792 .get_or_init(|| regex::Regex::new(r#"^([A-Za-z_$][\w$]*:)(?:'|`|-?\d)"#).unwrap());
793 let out = match scalar.captures(&out) {
794 Some(caps) => caps[1].to_string(),
795 None => out,
796 };
797 if out.starts_with("from ") || out.starts_with("import ") {
798 return out
799 .replace('(', " ")
800 .replace(')', "")
801 .split_whitespace()
802 .collect::<Vec<_>>()
803 .join(" ");
804 }
805 out
806}
807
808fn cut_value_binding(line: &str) -> Option<String> {
814 static BINDING: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
815 static DEFAULT: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
816 let binding = BINDING.get_or_init(|| {
817 regex::Regex::new(
818 r"^((?:(?:export\s+)?(?:pub(?:\([^)]*\))?\s+)?(?:(?:static|readonly|private|public|protected|declare|override|const|let|var)\s+)*(?:[A-Za-z_$][\w$]*|\{[^}]*\}|\[[^\]]*\])(?:\s*:\s*[^=]+?)?|(?:module\.)?exports(?:\.[A-Za-z_$][\w$]*)?)\s*=)\s*(.*)$",
819 )
820 .unwrap()
821 });
822 let default =
823 DEFAULT.get_or_init(|| regex::Regex::new(r"^(export\s+default)\s+(.*)$").unwrap());
824 let function_like = |value: &str| {
825 static ARROW: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
826 let arrow = ARROW
827 .get_or_init(|| regex::Regex::new(r"^(?:async\s+)?[A-Za-z_$][\w$]*\s*=>").unwrap());
828 value.starts_with('(')
829 || value.starts_with("async ")
830 || value.starts_with("async(")
831 || value.starts_with("function")
832 || value.starts_with("class")
833 || arrow.is_match(value)
834 };
835 if let Some(caps) = binding.captures(line) {
836 let value = caps[2].trim();
837 if value.starts_with('>') {
840 return None;
841 }
842 let module_export = caps[1].starts_with("exports") || caps[1].starts_with("module.exports");
845 if function_like(value) || (module_export && value.starts_with('{')) {
848 return None;
849 }
850 return Some(caps[1].to_string());
851 }
852 if let Some(caps) = default.captures(line) {
853 let value = caps[2].trim();
854 if value.is_empty() || value.starts_with('{') || function_like(value) {
855 return None;
856 }
857 return Some(caps[1].to_string());
858 }
859 None
860}
861
862fn angle_balance(s: &str) -> i32 {
863 let s = s.replace("->", " ").replace("=>", " ");
865 s.chars()
866 .map(|c| match c {
867 '<' => 1,
868 '>' => -1,
869 _ => 0,
870 })
871 .sum::<i32>()
872 .max(0)
873}
874
875fn bracket_balance(s: &str) -> i32 {
876 s.chars()
877 .map(|c| match c {
878 '[' => 1,
879 ']' => -1,
880 _ => 0,
881 })
882 .sum()
883}
884
885fn opens_destructure(line: &str) -> bool {
888 static DESTRUCTURE: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
889 let re = DESTRUCTURE
890 .get_or_init(|| regex::Regex::new(r"^(?:export\s+)?(?:const|let|var)\s+[\{\[]").unwrap());
891 re.is_match(line) && brace_delta(line) + bracket_balance(line) > 0
892}
893
894fn is_list_declaration(sig: &str) -> bool {
897 sig.starts_with("import ")
898 || sig.starts_with("use ")
899 || sig.starts_with("export {")
900 || sig.starts_with("export type {")
901}
902
903fn declaration_lines(stripped: &str, family: Family) -> String {
904 let lines: Vec<&str> = stripped.lines().collect();
905 let mut out: Vec<String> = Vec::new();
906 let mut depth: i32 = 0;
907 let mut body_skip: Option<(i32, i32, i32)> = None;
911 let mut i = 0;
912 while i < lines.len() {
913 let raw = lines[i];
914 let line = raw.trim();
915 if line.is_empty() {
916 i += 1;
917 continue;
918 }
919 if let Some((braces, brackets, parens)) = body_skip {
920 let braces = braces + brace_delta(raw);
921 let brackets = brackets + bracket_balance(raw);
922 let parens = parens + paren_balance(raw);
923 depth += brace_delta(raw);
924 body_skip = if braces <= 0 && brackets <= 0 && parens <= 0 {
925 None
926 } else {
927 Some((braces, brackets, parens))
928 };
929 i += 1;
930 continue;
931 }
932 let indent = raw.len() - raw.trim_start().len();
933 let next_opens_body = lines[i + 1..]
934 .iter()
935 .map(|l| l.trim())
936 .find(|l| !l.is_empty())
937 .is_some_and(|l| l.starts_with('{'));
938 let keep = match family {
939 Family::Python => python_keeps(line, indent),
940 _ => c_like_keeps(line, depth, next_opens_body, family != Family::Rust),
941 };
942 if keep
943 && family == Family::Python
944 && indent == 0
945 && let Some(eq) = python_constant_cut(line)
946 {
947 out.push(normalize_signature(&line[..eq]));
949 i += 1;
950 continue;
951 }
952 let mut binding_end = i;
956 let destructured = if keep && family != Family::Python && opens_destructure(line) {
957 let mut joined = line.to_string();
958 while brace_delta(&joined) + bracket_balance(&joined) > 0
959 && binding_end + 1 < lines.len()
960 && binding_end - i < 60
961 {
962 binding_end += 1;
963 if lines[binding_end].trim().is_empty() {
964 continue;
965 }
966 joined.push(' ');
967 joined.push_str(lines[binding_end].trim());
968 }
969 Some(joined)
970 } else {
971 None
972 };
973 let binding_line = destructured.as_deref().unwrap_or(line);
974 if keep
975 && family != Family::Python
976 && !(depth >= 1 && enum_member_re().is_match(line))
977 && let Some(cut) = cut_value_binding(binding_line)
978 {
979 out.push(normalize_signature(&cut));
984 let span = &lines[i..=binding_end];
985 let mut opened = (
986 span.iter().map(|l| brace_delta(l)).sum::<i32>(),
987 span.iter().map(|l| bracket_balance(l)).sum::<i32>(),
988 span.iter().map(|l| paren_balance(l)).sum::<i32>(),
989 );
990 depth += opened.0;
991 i = binding_end + 1;
992 if binding_line.trim_end().ends_with('=') {
993 while i < lines.len() && lines[i].trim().is_empty() {
996 i += 1;
997 }
998 if i < lines.len() {
999 let v = lines[i];
1000 opened = (
1001 opened.0 + brace_delta(v),
1002 opened.1 + bracket_balance(v),
1003 opened.2 + paren_balance(v),
1004 );
1005 depth += brace_delta(v);
1006 i += 1;
1007 }
1008 }
1009 if opened.0 > 0 || opened.1 > 0 || opened.2 > 0 {
1010 body_skip = Some(opened);
1011 }
1012 continue;
1013 }
1014 if keep {
1015 let mut sig = line.to_string();
1020 let mut j = i;
1021 let c_like = family != Family::Python;
1022 let next_is_operator_led = |k: usize| {
1026 c_like
1027 && lines[k + 1..]
1028 .iter()
1029 .map(|l| l.trim())
1030 .find(|l| !l.is_empty())
1031 .is_some_and(|l| {
1032 l.starts_with('|')
1033 || l.starts_with('&')
1034 || l.starts_with('?')
1035 || l.starts_with(':')
1036 || l.starts_with('.')
1037 || l.starts_with('+')
1038 })
1039 };
1040 let ends_open = |s: &str| {
1046 let t = s.trim_end();
1047 c_like && (t.ends_with('=') || t.ends_with(':'))
1048 };
1049 while (!sig.trim_end().ends_with('{') || is_list_declaration(&sig))
1050 && (paren_balance(&sig) > 0
1051 || bracket_balance(&sig) > 0
1052 || (c_like && angle_balance(&sig) > 0)
1053 || (is_list_declaration(&sig) && brace_delta(&sig) > 0)
1054 || ends_open(&sig)
1055 || next_is_operator_led(j))
1056 && j + 1 < lines.len()
1057 && j - i < 60
1058 {
1059 j += 1;
1060 if lines[j].trim().is_empty() {
1061 continue;
1062 }
1063 sig.push(' ');
1064 sig.push_str(lines[j].trim());
1065 }
1066 let opens_body = sig.trim_end().ends_with('{')
1072 || sig.contains("=>")
1073 || lines[j + 1..]
1074 .iter()
1075 .map(|l| l.trim())
1076 .find(|l| !l.is_empty())
1077 .is_some_and(|l| l.starts_with('{'));
1078 if c_like && member_signature_only(&sig, depth) && !opens_body {
1079 for l in &lines[i..=j] {
1080 depth += brace_delta(l);
1081 }
1082 i = j + 1;
1083 continue;
1084 }
1085 out.push(normalize_signature(&cut_at_body(&sig, family)));
1086 if c_like {
1087 for l in &lines[i..=j] {
1088 depth += brace_delta(l);
1089 }
1090 }
1091 i = j + 1;
1092 } else {
1093 if family != Family::Python {
1094 depth += brace_delta(raw);
1095 }
1096 i += 1;
1097 }
1098 }
1099 out.join("\n")
1100}
1101
1102pub fn load_bearing_sections(
1111 type_def: &memstead_schema::types::TypeDefinition,
1112) -> Vec<&memstead_schema::types::SectionDef> {
1113 let explicit: Vec<_> = type_def
1114 .sections
1115 .iter()
1116 .filter(|s| s.load_bearing == Some(true))
1117 .collect();
1118 if !explicit.is_empty() {
1119 return explicit;
1120 }
1121 let required: Vec<_> = type_def
1122 .sections
1123 .iter()
1124 .filter(|s| s.required && s.load_bearing != Some(false))
1125 .collect();
1126 if !required.is_empty() {
1127 return required;
1128 }
1129 type_def.sections.iter().collect()
1130}
1131
1132pub fn entity_load_bearing_form(
1143 entity: &Entity,
1144 type_def: Option<&memstead_schema::types::TypeDefinition>,
1145) -> String {
1146 fn push(out: &mut String, key: &str, content: &str) {
1147 out.push_str("## ");
1148 out.push_str(key);
1149 out.push_str("\n\n");
1150 out.push_str(content.trim());
1151 out.push_str("\n\n");
1152 }
1153 let mut out = String::new();
1154 match type_def {
1155 Some(td) => {
1156 for section in load_bearing_sections(td) {
1157 if let Some(content) = entity.sections.get(§ion.key) {
1158 push(&mut out, §ion.key, content);
1159 }
1160 }
1161 }
1162 None => {
1163 for (key, content) in &entity.sections {
1164 push(&mut out, key, content);
1165 }
1166 }
1167 }
1168 out
1169}
1170
1171pub fn entity_prepared_hash(
1180 entity: &Entity,
1181 type_def: Option<&memstead_schema::types::TypeDefinition>,
1182 preparation: Option<&str>,
1183) -> Option<String> {
1184 let form = match preparation {
1185 None => crate::render::render_entity_markdown(entity, None),
1186 Some(ENTITY_LOAD_BEARING) => entity_load_bearing_form(entity, type_def),
1187 Some(_) => return None,
1188 };
1189 Some(prepared_content_hash(form.as_bytes()))
1190}
1191
1192#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1198pub struct DeliveryUnit {
1199 pub key: String,
1202 pub order_key: String,
1205 pub start_line: usize,
1207 pub end_line: usize,
1209 pub hash: String,
1212}
1213
1214#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1216#[serde(rename_all = "lowercase")]
1217pub enum UnitChange {
1218 Added,
1220 Modified,
1222 Deleted,
1224}
1225
1226pub fn unit_id(path: &str, key: &str) -> String {
1228 format!("{path}#{key}")
1229}
1230
1231pub fn split_unit_id(id: &str) -> (&str, Option<&str>) {
1234 match id.find('#') {
1235 Some(cut) => (&id[..cut], Some(&id[cut + 1..])),
1236 None => (id, None),
1237 }
1238}
1239
1240pub fn unitize(preparation: &str, content: &str) -> Option<Vec<DeliveryUnit>> {
1244 match preparation {
1245 DATED_ENTRIES => Some(dated_entries(content)),
1246 _ => None,
1247 }
1248}
1249
1250pub fn unit_text(content: &str, unit: &DeliveryUnit) -> String {
1252 content
1253 .lines()
1254 .skip(unit.start_line.saturating_sub(1))
1255 .take(unit.end_line + 1 - unit.start_line.max(1))
1256 .collect::<Vec<_>>()
1257 .join("\n")
1258}
1259
1260pub fn diff_units(
1266 before: &[DeliveryUnit],
1267 after: &[DeliveryUnit],
1268) -> Vec<(DeliveryUnit, UnitChange)> {
1269 let old: std::collections::BTreeMap<&str, &DeliveryUnit> =
1270 before.iter().map(|u| (u.key.as_str(), u)).collect();
1271 let new: std::collections::BTreeMap<&str, &DeliveryUnit> =
1272 after.iter().map(|u| (u.key.as_str(), u)).collect();
1273 let mut out = Vec::new();
1274 for u in after {
1275 match old.get(u.key.as_str()) {
1276 None => out.push((u.clone(), UnitChange::Added)),
1277 Some(prev) if prev.hash != u.hash => out.push((u.clone(), UnitChange::Modified)),
1278 Some(_) => {}
1279 }
1280 }
1281 for u in before {
1282 if !new.contains_key(u.key.as_str()) {
1283 out.push((u.clone(), UnitChange::Deleted));
1284 }
1285 }
1286 out
1287}
1288
1289fn dated_entries(content: &str) -> Vec<DeliveryUnit> {
1290 let lines: Vec<&str> = content.lines().collect();
1291 let starts: Vec<(usize, String)> = lines
1292 .iter()
1293 .enumerate()
1294 .filter_map(|(i, line)| leading_stamp(line).map(|stamp| (i, stamp)))
1295 .collect();
1296 if starts.is_empty() {
1297 return vec![DeliveryUnit {
1298 key: WHOLE_FILE_UNIT.to_string(),
1299 order_key: String::new(),
1300 start_line: 1,
1301 end_line: lines.len().max(1),
1302 hash: prepared_content_hash(content.as_bytes()),
1303 }];
1304 }
1305 let mut seen: std::collections::BTreeMap<String, usize> = std::collections::BTreeMap::new();
1306 let mut units = Vec::with_capacity(starts.len());
1307 for (n, (start, stamp)) in starts.iter().enumerate() {
1308 let from = if n == 0 { 0 } else { *start };
1311 let to = starts.get(n + 1).map_or(lines.len(), |(next, _)| *next);
1312 let text = lines[from..to].join("\n");
1313 let count = seen
1314 .entry(stamp.clone())
1315 .and_modify(|c| *c += 1)
1316 .or_insert(1);
1317 let key = if *count == 1 {
1318 stamp.clone()
1319 } else {
1320 format!("{stamp}.{count}")
1321 };
1322 units.push(DeliveryUnit {
1323 key,
1324 order_key: stamp.clone(),
1325 start_line: from + 1,
1326 end_line: to,
1327 hash: prepared_content_hash(text.as_bytes()),
1328 });
1329 }
1330 units
1331}
1332
1333fn leading_stamp(line: &str) -> Option<String> {
1337 static STAMP: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
1338 let re = STAMP.get_or_init(|| {
1339 regex::Regex::new(
1340 r"^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2}))?(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?)?\b",
1341 )
1342 .expect("the stamp regex compiles")
1343 });
1344 let s = line.trim_start_matches(|c: char| {
1345 c.is_whitespace() || matches!(c, '#' | '-' | '*' | '>' | '[' | '(' | '|' | '`' | '+')
1346 });
1347 let caps = re.captures(s)?;
1348 let num = |i: usize| -> u32 {
1349 caps.get(i)
1350 .map(|m| m.as_str().parse().unwrap_or(0))
1351 .unwrap_or(0)
1352 };
1353 let (y, mo, d, h, mi, sec) = (num(1), num(2), num(3), num(4), num(5), num(6));
1354 let days_in_month = match mo {
1355 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
1356 4 | 6 | 9 | 11 => 30,
1357 2 => 29,
1358 _ => return None,
1359 };
1360 if !(1..=days_in_month).contains(&d) || h > 23 || mi > 59 || sec > 59 {
1361 return None;
1362 }
1363 Some(format!("{y:04}-{mo:02}-{d:02}T{h:02}:{mi:02}:{sec:02}"))
1364}
1365
1366#[cfg(test)]
1367mod tests {
1368 use super::*;
1369 use crate::entity::EntityId;
1370 use indexmap::IndexMap;
1371 use memstead_schema::types::{SectionDef, TypeDefinition};
1372
1373 fn section(key: &str, required: bool, load_bearing: Option<bool>) -> SectionDef {
1374 let mut v = serde_json::json!({
1375 "key": key, "heading": key, "required": required, "search_weight": 1.0
1376 });
1377 if let Some(lb) = load_bearing {
1378 v["load_bearing"] = serde_json::json!(lb);
1379 }
1380 serde_json::from_value(v).unwrap()
1381 }
1382
1383 fn type_with(sections: Vec<SectionDef>) -> TypeDefinition {
1386 let schemas = memstead_schema::builtins::load_builtin_schemas().unwrap();
1387 let base = schemas
1388 .iter()
1389 .find_map(|s| s.get_type("assertion"))
1390 .expect("a builtin schema declares `assertion`");
1391 let mut td = (*base).clone();
1392 td.sections = sections;
1393 td
1394 }
1395
1396 fn entity(sections: &[(&str, &str)]) -> Entity {
1397 let mut map = IndexMap::new();
1398 for (k, v) in sections {
1399 map.insert(k.to_string(), v.to_string());
1400 }
1401 Entity {
1402 id: EntityId::canonical("m--e"),
1403 title: "E".into(),
1404 entity_type: "t".into(),
1405 mem: "m".into(),
1406 file_path: "e.md".into(),
1407 metadata: IndexMap::new(),
1408 sections: map,
1409 relationships: Vec::new(),
1410 content_hash: "h".into(),
1411 stub: false,
1412 stub_kind: None,
1413 heading_spans: Default::default(),
1414 raw_section_headings: Vec::new(),
1415 }
1416 }
1417
1418 #[test]
1419 fn registry_knows_its_three_flavours_and_nothing_else() {
1420 assert!(is_registered(ENTITY_LOAD_BEARING));
1421 assert!(is_registered(DATED_ENTRIES));
1422 assert!(is_registered(CODE_MAP));
1423 assert!(!is_registered("pdf-to-markdown"));
1424 assert!(!is_registered(""));
1425 assert_eq!(
1426 registered_identifiers(),
1427 vec![ENTITY_LOAD_BEARING, DATED_ENTRIES, CODE_MAP]
1428 );
1429 let c = lookup(CODE_MAP).unwrap();
1430 assert_eq!(c.touchpoint, Touchpoint::PreparedForm);
1431 assert!(applies_to_namespace(c, "path"));
1432 assert!(applies_to_namespace(c, "path+commit"));
1433 assert!(!applies_to_namespace(c, "entity"));
1434 assert!(!applies_to_namespace(c, "url"));
1435 assert!(delivery_preparation(Some(CODE_MAP)).is_none());
1436 let p = lookup(ENTITY_LOAD_BEARING).unwrap();
1437 assert_eq!(p.touchpoint, Touchpoint::PreparedForm);
1438 assert!(applies_to_namespace(p, "entity"));
1439 assert!(!applies_to_namespace(p, "path"));
1440 assert!(!applies_to_namespace(p, "url"));
1441 let d = lookup(DATED_ENTRIES).unwrap();
1442 assert_eq!(d.touchpoint, Touchpoint::DeliveryUnits);
1443 assert!(applies_to_namespace(d, "path"));
1444 assert!(applies_to_namespace(d, "path+commit"));
1445 assert!(!applies_to_namespace(d, "entity"));
1446 assert!(!applies_to_namespace(d, "url"));
1447 assert_eq!(
1449 delivery_preparation(Some(DATED_ENTRIES)).map(|p| p.id),
1450 Some(DATED_ENTRIES)
1451 );
1452 assert!(delivery_preparation(Some(ENTITY_LOAD_BEARING)).is_none());
1453 assert!(delivery_preparation(Some("pdf-to-markdown")).is_none());
1454 assert!(delivery_preparation(None).is_none());
1455 assert!(unitize(ENTITY_LOAD_BEARING, "x").is_none());
1456 assert!(unitize("pdf-to-markdown", "x").is_none());
1457 }
1458
1459 const LOG: &str = "# Ops log\n\nPreamble text.\n\n## 2026-08-24 10:05 boot\nline a\n\n\
1460 - 2026-08-24T10:05:00Z boot again\nline b\n2026-08-25 shutdown\nline c\n";
1461
1462 #[test]
1466 fn dated_entries_unitize_deterministically() {
1467 let units = unitize(DATED_ENTRIES, LOG).unwrap();
1468 let keys: Vec<&str> = units.iter().map(|u| u.key.as_str()).collect();
1469 assert_eq!(
1470 keys,
1471 vec![
1472 "2026-08-24T10:05:00",
1473 "2026-08-24T10:05:00.2",
1474 "2026-08-25T00:00:00"
1475 ]
1476 );
1477 assert_eq!(
1478 units[0].start_line, 1,
1479 "the preamble folds into the first unit"
1480 );
1481 assert_eq!((units[0].end_line, units[1].start_line), (7, 8));
1482 assert_eq!(units[2].end_line, 11);
1483 assert_eq!(units[1].order_key, "2026-08-24T10:05:00");
1484 assert!(unit_text(LOG, &units[2]).starts_with("2026-08-25 shutdown"));
1485 assert_eq!(
1486 units[2].hash,
1487 prepared_content_hash(unit_text(LOG, &units[2]).as_bytes())
1488 );
1489
1490 let whole = unitize(DATED_ENTRIES, "no stamps here\njust prose\n").unwrap();
1491 assert_eq!(whole.len(), 1);
1492 assert_eq!(whole[0].key, WHOLE_FILE_UNIT);
1493 assert_eq!(whole[0].order_key, "");
1494
1495 assert_eq!(
1496 leading_stamp("[2026-02-30] bad day"),
1497 None,
1498 "day out of range"
1499 );
1500 assert_eq!(
1501 leading_stamp("2026-08-24T25:00 x"),
1502 None,
1503 "hour out of range"
1504 );
1505 assert_eq!(leading_stamp("v2026-08-24"), None, "not at the line start");
1506 assert_eq!(leading_stamp("2026-08-2400"), None, "digits run on");
1507 assert_eq!(
1508 leading_stamp("> **2026-08-24T10:05:00.250+02:00** note").as_deref(),
1509 Some("2026-08-24T10:05:00")
1510 );
1511 assert_eq!(
1512 unit_id("logs/ops.md", "2026-08-25T00:00:00"),
1513 "logs/ops.md#2026-08-25T00:00:00"
1514 );
1515 assert_eq!(
1516 split_unit_id("logs/ops.md#2026-08-25T00:00:00"),
1517 ("logs/ops.md", Some("2026-08-25T00:00:00"))
1518 );
1519 assert_eq!(split_unit_id("logs/ops.md"), ("logs/ops.md", None));
1520 }
1521
1522 const JS: &str = "// Auth module\nimport axios from 'axios'\nimport { t } from '@/i18n'\n\n/* block\n comment */\nconst RETRIES = 3\n\nexport default {\n name: 'Auth',\n props: ['user'],\n data() {\n return { token: null, busy: false }\n },\n methods: {\n async login(user, password) {\n // body\n const r = await axios.post('/login', { user, password })\n return r.data\n },\n logout() {\n this.token = null\n }\n }\n}\n\nexport function helper(a, b) {\n return a + b\n}\n\nexport const LIMIT = { max: 10 }\n";
1523
1524 #[test]
1529 fn code_map_digest_sees_interfaces_not_bodies() {
1530 let digest = code_map_digest("src/auth.js", JS);
1531 assert_eq!(
1532 digest,
1533 "import axios from 'axios'\nimport{t}from '@/i18n'\nconst RETRIES=\n\
1534 export default\nname:\nprops:['user']\ndata()\nmethods:\n\
1535 async login(user,password)\nlogout()\nexport function helper(a,b)\n\
1536 export const LIMIT="
1537 );
1538 let value_forms = [
1543 "export const base = cfg.API ? cfg.API : 'x'\n",
1544 "export const base = cfg.API\n ? cfg.API\n : 'x'\n",
1545 "export const base =\n 'a' +\n 'b'\n",
1546 "export const base = new Client({\n region: 'eu',\n retries: 3,\n})\n",
1547 "export const base = axios\n .create(cfg)\n .interceptors\n",
1548 ];
1549 let cut: Vec<String> = value_forms
1550 .iter()
1551 .map(|t| code_map_digest("cfg.js", t))
1552 .collect();
1553 assert!(cut.iter().all(|d| d == "export const base="), "{cut:?}");
1554 assert_eq!(
1555 code_map_digest(
1556 "s.js",
1557 "const store = new Vuex.Store({\n state: { n: 1 },\n mutations: {\n inc(s) { s.n += 1 }\n }\n})\n"
1558 ),
1559 code_map_digest(
1560 "s.js",
1561 "const store = new Vuex.Store({\n state: { n: 1 },\n mutations: {\n inc(s) { s.n += 2 }\n }\n})\n"
1562 )
1563 );
1564 assert_eq!(
1565 code_map_digest("d.js", "export default new Vuetify({\n theme: 'x',\n})\n"),
1566 "export default"
1567 );
1568 assert_eq!(
1569 code_map_digest("f.js", "const f = x => x.id\n"),
1570 code_map_digest("f.js", "const f = (x) => x.id\n")
1571 );
1572 assert_eq!(
1573 code_map_digest("f.js", "const f = (x) => x.id\n"),
1574 "const f=x=>"
1575 );
1576 assert_eq!(
1577 code_map_digest(
1578 "f.js",
1579 "export const g = async (a, b) => {\n return a\n}\n"
1580 ),
1581 "export const g=async(a,b)=>"
1582 );
1583 let knr = "class S {\n login(user, password) {\n return 1\n }\n logout() {\n }\n}\n";
1585 let allman = "class S\n{\n login(user, password)\n {\n return 1\n }\n logout()\n {\n }\n}\n";
1586 assert_eq!(
1587 code_map_digest("s.js", knr),
1588 "class S\nlogin(user,password)\nlogout()"
1589 );
1590 assert_eq!(
1591 code_map_digest("s.js", allman),
1592 code_map_digest("s.js", knr)
1593 );
1594 let same = |a: &str, b: &str, why: &str| {
1600 assert_eq!(
1601 code_map_digest("w.js", a),
1602 code_map_digest("w.js", b),
1603 "{why}"
1604 );
1605 };
1606 same(
1607 "export const pick = state => state.items.filter(i => i.active).map(i => i.id)\n",
1608 "export const pick = state =>\n state.items\n .filter(i => i.active)\n .map(i => i.id)\n",
1609 "arrow expression body wrapped",
1610 );
1611 assert_eq!(
1612 code_map_digest("w.js", "export const pick = (state) => state.items\n"),
1613 "export const pick=state=>"
1614 );
1615 same(
1616 "export default {\n select: state => state.items.filter(i => i.active),\n}\n",
1617 "export default {\n select: state =>\n state.items.filter(i => i.active),\n}\n",
1618 "property arrow body wrapped",
1619 );
1620 same(
1621 "module.exports = {\n validate: (v) => {\n return v\n },\n}\n",
1622 "module.exports = {\n validate: v => {\n return v\n },\n}\n",
1623 "arrowParens on a property arrow",
1624 );
1625 assert_eq!(
1626 code_map_digest(
1627 "w.js",
1628 "module.exports = {\n validate: v => {\n return v\n },\n}\n"
1629 ),
1630 "module.exports=\nvalidate:v=>"
1631 );
1632 same(
1633 "export function setup(app) {\n registerPlugin(app, options, extra)\n}\n",
1634 "export function setup(app) {\n registerPlugin(\n app,\n options,\n extra\n )\n}\n",
1635 "wrapped call statement in a function body",
1636 );
1637 assert_eq!(
1638 code_map_digest(
1639 "w.js",
1640 "export function setup(app) {\n registerPlugin(\n app,\n options,\n extra\n )\n}\n"
1641 ),
1642 "export function setup(app)"
1643 );
1644 same(
1645 "class S {\n run() {\n helper(a, b, c)\n }\n}\n",
1646 "class S {\n run() {\n helper(\n a,\n b,\n c\n )\n }\n}\n",
1647 "wrapped call in a class method body",
1648 );
1649 assert_eq!(
1650 code_map_digest(
1651 "s.rs",
1652 "impl S {\n pub fn run(&self) {\n helper(\n a,\n b,\n )\n }\n}\n"
1653 ),
1654 code_map_digest(
1655 "s.rs",
1656 "impl S {\n pub fn run(&self) {\n helper(a, b)\n }\n}\n"
1657 )
1658 );
1659 same(
1660 "exports.base = cfg.API ? cfg.API : 'http://localhost'\n",
1661 "exports.base = cfg.API\n ? cfg.API\n : 'http://localhost'\n",
1662 "exports ternary wrapped",
1663 );
1664 same(
1665 "module.exports = mongoose.model('User', schema).plugin(paginate)\n",
1666 "module.exports = mongoose\n .model('User', schema)\n .plugin(paginate)\n",
1667 "module.exports chain wrapped",
1668 );
1669 assert_eq!(
1670 code_map_digest(
1671 "w.js",
1672 "exports.TIMEOUT = compute(\n settings,\n defaults\n)\n"
1673 ),
1674 "exports.TIMEOUT="
1675 );
1676 assert_eq!(
1677 code_map_digest(
1678 "t.ts",
1679 "export type Mode = 'discovery' | 'sync' | 'verify'\n"
1680 ),
1681 code_map_digest(
1682 "t.ts",
1683 "export type Mode =\n | 'discovery'\n | 'sync'\n | 'verify'\n"
1684 )
1685 );
1686 assert_ne!(
1687 code_map_digest("t.ts", "export type Mode = 'discovery' | 'sync'\n"),
1688 code_map_digest(
1689 "t.ts",
1690 "export type Mode = 'discovery' | 'sync' | 'verify'\n"
1691 ),
1692 "a union member is interface"
1693 );
1694 assert_eq!(
1695 code_map_digest(
1696 "g.rs",
1697 "pub fn all(&self) -> Result<Vec<String>, Error> {\n todo!()\n}\n"
1698 ),
1699 code_map_digest(
1700 "g.rs",
1701 "pub fn all(\n &self,\n) -> Result<\n Vec<String>,\n Error,\n> {\n todo!()\n}\n"
1702 )
1703 );
1704 assert_eq!(
1709 code_map_digest(
1710 "c.rs",
1711 "pub const DESCRIPTION: &str =\n \"a long description\";\n"
1712 ),
1713 code_map_digest(
1714 "c.rs",
1715 "pub const DESCRIPTION: &str = \"a long description\";\n"
1716 )
1717 );
1718 assert_eq!(
1719 code_map_digest("c.rs", "pub const DESCRIPTION: &str = \"x\";\n"),
1720 "pub const DESCRIPTION:&str="
1721 );
1722 assert_eq!(
1723 code_map_digest(
1724 "f.rs",
1725 "pub struct H {\n pub handler:\n Box<dyn Fn(&str) -> Result<(), Error> + Send>,\n}\n"
1726 ),
1727 code_map_digest(
1728 "f.rs",
1729 "pub struct H {\n pub handler: Box<dyn Fn(&str) -> Result<(), Error> + Send>,\n}\n"
1730 )
1731 );
1732 assert_eq!(
1733 code_map_digest(
1734 "t.rs",
1735 "pub type Handler =\n Box<dyn Fn(&str) -> Result<(), Error>>;\n"
1736 ),
1737 code_map_digest(
1738 "t.rs",
1739 "pub type Handler = Box<dyn Fn(&str) -> Result<(), Error>>;\n"
1740 )
1741 );
1742 assert!(code_map_digest("t.rs", "pub type Handler =\n Box<X>;\n").contains("Box<X>"));
1743 same(
1744 "class Api {\n static url = 'a' + 'b';\n private readonly base = x || 'y';\n}\n",
1745 "class Api {\n static url =\n 'a' +\n 'b';\n private readonly base =\n x || 'y';\n}\n",
1746 "class fields wrapped after =",
1747 );
1748 assert_eq!(
1749 code_map_digest("w.js", "class Api {\n static url = 'a';\n}\n"),
1750 "class Api\nstatic url="
1751 );
1752 same(
1753 "export default {\n message: 'a' + 'b',\n data() {\n return {}\n },\n}\n",
1754 "export default {\n message:\n 'a' +\n 'b',\n data() {\n return {}\n },\n}\n",
1755 "bare key wrapped away from its value",
1756 );
1757 assert!(
1758 code_map_digest(
1759 "w.js",
1760 "export default {\n message:\n 'a' +\n 'b',\n}\n"
1761 )
1762 .contains("message:")
1763 );
1764 same(
1765 "it('logs in', async () => {\n const r = await login()\n expect(r).toBe(1)\n})\n",
1766 "it('logs in', async () => {\n const r = await login();\n expect(r).toBe(2);\n});\n",
1767 "a callback body is body",
1768 );
1769 same(
1770 "export function setup(app) {\n setTimeout(() => {\n app.start(1)\n }, 10)\n}\n",
1771 "export function setup(app) {\n setTimeout(() => {\n app.start(2)\n }, 10)\n}\n",
1772 "a callback body inside a function body",
1773 );
1774 same(
1775 "export default {\n created() {\n setTimeout(() => {\n this.a = 1\n }, 5)\n },\n}\n",
1776 "export default {\n created() {\n setTimeout(() => {\n this.a = 2\n }, 5)\n },\n}\n",
1777 "a callback body inside a member body",
1778 );
1779 assert_eq!(
1780 code_map_digest(
1781 "r.js",
1782 "export const routes = [\n { path: '/', meta: { auth: true } },\n { path: '/x' },\n]\n"
1783 ),
1784 "export const routes="
1785 );
1786 let api = "export interface Api {\n name: string\n load(id: string): Promise<void>\n}\n";
1788 assert_eq!(
1789 code_map_digest("a.ts", api),
1790 "export interface Api\nname:string\nload(id:string):Promise<void>"
1791 );
1792 assert_ne!(
1793 code_map_digest("a.ts", api),
1794 code_map_digest("a.ts", &api.replace("name: string", "name: number"))
1795 );
1796 assert_ne!(
1797 code_map_digest("a.ts", api),
1798 code_map_digest(
1799 "a.ts",
1800 &api.replace("load(id: string)", "load(id: string, force: boolean)")
1801 )
1802 );
1803 let color = "export enum Color {\n Red,\n Green = 2,\n}\n";
1804 assert_eq!(
1805 code_map_digest("e.ts", color),
1806 "export enum Color\nRed\nGreen=2"
1807 );
1808 assert_ne!(
1809 code_map_digest("e.ts", color),
1810 code_map_digest("e.ts", &color.replace("Green = 2,", "Green = 2,\n Blue,"))
1811 );
1812 assert_eq!(
1813 code_map_digest("q.js", "const { a, b } = require('./x')\n"),
1814 "const{a,b}="
1815 );
1816 assert_ne!(
1817 code_map_digest("q.js", "const { a, b } = require('./x')\n"),
1818 code_map_digest("q.js", "const { a, c } = require('./x')\n")
1819 );
1820 let wrapped_api = "export interface Api {\n name: string\n load(\n id: string,\n force: boolean,\n ): Promise<void>\n}\n";
1823 assert_eq!(
1824 code_map_digest("a.ts", wrapped_api),
1825 code_map_digest(
1826 "a.ts",
1827 "export interface Api {\n name: string\n load(id: string, force: boolean): Promise<void>\n}\n"
1828 )
1829 );
1830 assert_ne!(
1831 code_map_digest("a.ts", wrapped_api),
1832 code_map_digest(
1833 "a.ts",
1834 &wrapped_api.replace(
1835 "force: boolean,\n",
1836 "force: boolean,\n options: LoadOptions,\n"
1837 )
1838 )
1839 );
1840 let wrapped_require = "const {\n a,\n b,\n} = require('./x')\n";
1841 assert_eq!(code_map_digest("q.js", wrapped_require), "const{a,b}=");
1842 assert_ne!(
1843 code_map_digest("q.js", wrapped_require),
1844 code_map_digest("q.js", &wrapped_require.replace(" b,\n", " c,\n"))
1845 );
1846 assert_eq!(
1847 code_map_digest("q.js", "export const [\n first,\n second,\n] = pair()\n"),
1848 "export const[first,second]="
1849 );
1850 assert_eq!(
1851 code_map_digest(
1852 "o.js",
1853 "export default {\n 'name': 'X',\n props: ['a'],\n}\n"
1854 ),
1855 code_map_digest(
1856 "o.js",
1857 "export default {\n name: 'X',\n props: ['a'],\n}\n"
1858 )
1859 );
1860 let h = |text: &str| prepared_content_hash(code_map_digest("src/auth.js", text).as_bytes());
1861 let base = h(JS);
1862 assert_eq!(h(&JS.replace("// body", "// rewritten comment")), base);
1864 assert_eq!(h(&JS.replace(" return a + b", " return a+b")), base);
1865 assert_eq!(h(&JS.replace("/login", "/session")), base);
1866 assert_eq!(h(&JS.replace("return r.data", "return r.data.user")), base);
1867 assert_eq!(
1868 h(&JS.replace("max: 10", "max: 20")),
1869 base,
1870 "a value is body"
1871 );
1872 assert_eq!(
1876 h(&JS.replace("login(user, password)", "login(user,password)")),
1877 base
1878 );
1879 assert_eq!(
1880 h(&JS.replace(
1881 "login(user, password)",
1882 "login(\n user,\n password\n )"
1883 )),
1884 base
1885 );
1886 assert_eq!(
1887 h(&JS.replace("import axios from 'axios'", "import axios from \"axios\";")),
1888 base
1889 );
1890 assert_eq!(
1891 h(&JS.replace("helper(a, b)", "helper (a /* first */, b)")),
1892 base
1893 );
1894 assert_eq!(
1895 h(&JS.replace("export const LIMIT = {", "export const LIMIT={")),
1896 base
1897 );
1898 assert_eq!(
1902 h(&JS.replace(
1903 "import { t } from '@/i18n'",
1904 "import {\n t,\n} from '@/i18n'"
1905 )),
1906 base
1907 );
1908 assert_eq!(h(&JS.replace("props: ['user'],", "props: ['user']")), base);
1909 assert_eq!(
1910 h(&JS.replace("props: ['user'],", "props: [\n 'user',\n ],")),
1911 base
1912 );
1913 assert_eq!(
1914 h(&JS.replace(
1915 "login(user, password)",
1916 "login(\n user,\n password,\n )"
1917 )),
1918 base
1919 );
1920 assert_eq!(
1921 h(&JS.replace("name: 'Auth',", "name: 'Login',")),
1922 base,
1923 "a scalar value is body"
1924 );
1925 assert_ne!(
1927 h(&JS.replace(
1928 "import { t } from '@/i18n'",
1929 "import {\n t,\n n,\n} from '@/i18n'"
1930 )),
1931 base
1932 );
1933 assert_eq!(
1934 code_map_digest("x.js", "export {\n a,\n b,\n} from './x'\n"),
1935 code_map_digest("x.js", "export { a, b } from './x'\n")
1936 );
1937 assert_ne!(
1939 h(&JS.replace("login(user, password)", "login(user, password, remember)")),
1940 base
1941 );
1942 assert_ne!(
1943 h(&JS.replace("export function helper", "function helper")),
1944 base
1945 );
1946 assert_ne!(h(&JS.replace("import axios from 'axios'\n", "")), base);
1947 assert_ne!(
1948 h(&JS.replace("props: ['user']", "props: ['user', 'tenant']")),
1949 base
1950 );
1951 let vue = format!(
1954 "<template>\n <div @click=\"login\">{{{{ t('hi') }}}}</div>\n</template>\n\n<script>\n{JS}</script>\n\n<style scoped>\n.a {{ color: red }}\n</style>\n"
1955 );
1956 assert_eq!(code_map_digest("src/Auth.vue", &vue), digest);
1957 assert_eq!(
1958 code_map_digest("src/Auth.vue", &vue.replace("color: red", "color: blue")),
1959 digest
1960 );
1961 assert_eq!(
1963 code_map_digest("README.md", "# hi\n\ntext\n"),
1964 "# hi\n\ntext\n"
1965 );
1966 assert_eq!(
1967 code_map_digest(
1968 "package.json",
1969 "{\n \"name\": \"x\",\n \"version\": \"1\"\n}\n"
1970 ),
1971 code_map_digest("package.json", "{\"name\":\"x\",\"version\":\"1\"}")
1972 );
1973 }
1974
1975 const PY: &str = "# -*- coding: utf-8 -*-\nimport os\nfrom typing import List\n\nTIMEOUT = 30 # seconds\n\n\
1976 def load(path: str, *, strict: bool = False) -> List[str]:\n \"\"\"Docstring.\"\"\"\n with open(path) as f:\n return f.readlines()\n\n\
1977 class Loader:\n retries = 3\n\n @property\n def name(self):\n return 'x'\n\n def run(self,\n arg):\n def inner():\n pass\n return arg\n";
1978
1979 #[test]
1980 fn code_map_digest_python_and_rust() {
1981 assert_eq!(
1982 code_map_digest("pivot.py", PY),
1983 "import os\nfrom typing import List\nTIMEOUT=\n\
1984 def load(path:str,*,strict:bool=False)->List[str]\nclass Loader\n\
1985 @property\ndef name(self)\ndef run(self,arg)"
1986 );
1987 assert_eq!(
1988 code_map_digest(
1989 "pivot.py",
1990 &PY.replace(
1991 "def run(self,\n arg):",
1992 "def run(\n self,\n arg,\n ):"
1993 )
1994 ),
1995 code_map_digest("pivot.py", PY),
1996 "a formatter's trailing comma in a wrapped def is invisible"
1997 );
1998 assert_eq!(
1999 code_map_digest("i.py", "from typing import (\n Dict,\n List,\n)\n"),
2000 code_map_digest("i.py", "from typing import Dict, List\n"),
2001 "black's parenthesized import list is formatting"
2002 );
2003 let h = |t: &str| prepared_content_hash(code_map_digest("pivot.py", t).as_bytes());
2004 assert_eq!(
2005 h(PY),
2006 h(&PY.replace("return f.readlines()", "return list(f)"))
2007 );
2008 assert_eq!(h(PY), h(&PY.replace("Docstring.", "Another docstring.")));
2009 assert_ne!(
2010 h(PY),
2011 h(&PY.replace("def run(self,", "def run(self, extra,"))
2012 );
2013
2014 let rs = "//! Module docs\nuse std::fmt;\n\n/// A thing.\n#[derive(Debug)]\npub struct Thing {\n pub id: u32,\n secret: String,\n}\n\nimpl Thing {\n pub fn new(id: u32) -> Self {\n Self { id, secret: String::new() }\n }\n fn hidden(&self) {}\n}\n";
2015 assert_eq!(
2016 code_map_digest("src/thing.rs", rs),
2017 "use std::fmt\n#[derive(Debug)]\npub struct Thing\npub id:u32\nimpl Thing\n\
2018 pub fn new(id:u32)->Self\nfn hidden(&self)"
2019 );
2020 }
2021
2022 #[test]
2026 fn code_map_tree_digest_and_path_rule() {
2027 let files = vec![
2028 ("src/b.js".to_string(), "export const B = 1\n".to_string()),
2029 ("src/a.js".to_string(), JS.to_string()),
2030 ];
2031 let base = code_map_tree_digest(&files);
2032 assert!(base.starts_with(&format!(
2033 "{} src/a.js\n",
2034 prepared_content_hash(code_map_digest("src/a.js", JS).as_bytes())
2035 )));
2036 let body_edit = vec![
2037 files[0].clone(),
2038 ("src/a.js".to_string(), JS.replace("/login", "/session")),
2039 ];
2040 assert_eq!(
2041 code_map_tree_digest(&body_edit),
2042 base,
2043 "a body edit leaves the tree map"
2044 );
2045 let sig_edit = vec![
2046 files[0].clone(),
2047 (
2048 "src/a.js".to_string(),
2049 JS.replace("logout()", "logout(everywhere)"),
2050 ),
2051 ];
2052 assert_ne!(code_map_tree_digest(&sig_edit), base);
2053 let mut joined = files.clone();
2054 joined.push(("src/c.js".to_string(), "export const C = 1\n".to_string()));
2055 assert_ne!(code_map_tree_digest(&joined), base);
2056
2057 let digest_hash = prepared_content_hash(code_map_digest("src/a.js", JS).as_bytes());
2058 assert_eq!(
2059 path_prepared_hash(Some(CODE_MAP), "src/a.js", AnchorGrain::File, JS.as_bytes()),
2060 PathPrepared::Hash(digest_hash.clone())
2061 );
2062 assert_eq!(
2063 path_prepared_hash(
2064 Some(CODE_MAP),
2065 "src/a.js#L1-L3",
2066 AnchorGrain::Span,
2067 JS.as_bytes()
2068 ),
2069 PathPrepared::Hash(digest_hash)
2070 );
2071 assert_eq!(
2072 path_prepared_hash(None, "src/a.js", AnchorGrain::File, JS.as_bytes()),
2073 PathPrepared::Hash(prepared_content_hash(JS.as_bytes())),
2074 "no preparation: the bytes, byte-for-byte as before"
2075 );
2076 assert_eq!(
2077 path_prepared_hash(Some(CODE_MAP), "src", AnchorGrain::Tree, b""),
2078 PathPrepared::NoHash,
2079 "a tree needs enumeration; the caller supplies it"
2080 );
2081 assert_eq!(
2082 path_prepared_hash(None, "src", AnchorGrain::Tree, b""),
2083 PathPrepared::NoHash
2084 );
2085 let log = "2026-08-24 one\nbody\n2026-08-25 two\nbody\n";
2086 assert!(matches!(
2087 path_prepared_hash(
2088 Some(DATED_ENTRIES),
2089 "log.md#2026-08-25T00:00:00",
2090 AnchorGrain::Span,
2091 log.as_bytes()
2092 ),
2093 PathPrepared::Hash(_)
2094 ));
2095 assert_eq!(
2096 path_prepared_hash(
2097 Some(DATED_ENTRIES),
2098 "log.md#2026-08-26T00:00:00",
2099 AnchorGrain::Span,
2100 log.as_bytes()
2101 ),
2102 PathPrepared::UnitAbsent
2103 );
2104 assert_eq!(
2105 path_prepared_hash(
2106 Some(DATED_ENTRIES),
2107 "log.md",
2108 AnchorGrain::File,
2109 log.as_bytes()
2110 ),
2111 PathPrepared::Hash(prepared_content_hash(log.as_bytes()))
2112 );
2113 }
2114
2115 #[test]
2123 #[ignore]
2124 fn measure_code_map_over_corpus() {
2125 use crate::pipeline::{MediumType, PatternEntry, PatternMode, Source};
2126 let Ok(root) = std::env::var("MEMSTEAD_CODE_MAP_CORPUS") else {
2127 eprintln!("MEMSTEAD_CODE_MAP_CORPUS unset; nothing measured");
2128 return;
2129 };
2130 let root = std::path::PathBuf::from(root);
2131 let split = |v: &str| -> Vec<String> {
2132 v.split(',')
2133 .map(str::trim)
2134 .filter(|s| !s.is_empty())
2135 .map(String::from)
2136 .collect()
2137 };
2138 let allows = split(&std::env::var("MEMSTEAD_CODE_MAP_ALLOW").unwrap_or_default());
2139 let denies = split(&std::env::var("MEMSTEAD_CODE_MAP_DENY").unwrap_or_default());
2140 let commits: usize = std::env::var("MEMSTEAD_CODE_MAP_COMMITS")
2141 .ok()
2142 .and_then(|v| v.parse().ok())
2143 .unwrap_or(200);
2144 let mut scope: Vec<PatternEntry> = allows
2145 .iter()
2146 .map(|p| PatternEntry {
2147 path: p.clone(),
2148 mode: PatternMode::Allow,
2149 })
2150 .collect();
2151 scope.extend(denies.iter().map(|p| PatternEntry {
2152 path: p.clone(),
2153 mode: PatternMode::Deny,
2154 }));
2155 let source = Source {
2156 name: "corpus".into(),
2157 medium_type: MediumType::Codebase,
2158 pointer: String::new(),
2159 change_detection: Some("git".into()),
2160 scope,
2161 engagement: None,
2162 preparation: Some(CODE_MAP.into()),
2163 };
2164 let files = crate::ingest::cursor::enumerate_facet_files(&source, &[], &root);
2165 let mut by_family: std::collections::BTreeMap<&str, (usize, usize, usize, usize, usize)> =
2166 std::collections::BTreeMap::new();
2167 for f in &files {
2168 let Ok(bytes) = std::fs::read(root.join(f)) else {
2169 continue;
2170 };
2171 let text = String::from_utf8_lossy(&bytes);
2172 let digest = code_map_digest(f, &text);
2173 let ext = f.rsplit('.').next().unwrap_or("");
2174 let fam = match family_of(f) {
2175 Family::CLike => {
2176 if ext == "py" {
2177 "py"
2178 } else {
2179 "js"
2180 }
2181 }
2182 Family::Rust => "rust",
2183 Family::Vue => "vue",
2184 Family::Python => "py",
2185 Family::Json => "json",
2186 Family::Text => "other",
2187 };
2188 let e = by_family.entry(fam).or_default();
2189 e.0 += 1;
2190 e.1 += text.len();
2191 e.2 += digest.len();
2192 e.3 += crate::chunking::estimate_tokens(&text);
2193 e.4 += crate::chunking::estimate_tokens(&digest);
2194 }
2195 let (mut n, mut rb, mut db, mut rt, mut dt) = (0, 0, 0, 0, 0);
2196 eprintln!(
2197 "| family | files | raw bytes | digest bytes | raw tokens | digest tokens | digest/raw |"
2198 );
2199 eprintln!("| --- | --- | --- | --- | --- | --- | --- |");
2200 for (fam, (c, b1, b2, t1, t2)) in &by_family {
2201 eprintln!(
2202 "| {fam} | {c} | {b1} | {b2} | {t1} | {t2} | {:.1}% |",
2203 100.0 * *b2 as f64 / (*b1).max(1) as f64
2204 );
2205 n += c;
2206 rb += b1;
2207 db += b2;
2208 rt += t1;
2209 dt += t2;
2210 }
2211 eprintln!(
2212 "| total | {n} | {rb} | {db} | {rt} | {dt} | {:.1}% |",
2213 100.0 * db as f64 / rb.max(1) as f64
2214 );
2215
2216 let git = |args: &[&str]| -> String {
2219 let out = std::process::Command::new("git")
2220 .args(args)
2221 .current_dir(&root)
2222 .output()
2223 .expect("git");
2224 String::from_utf8_lossy(&out.stdout).into_owned()
2225 };
2226 let mut builder = globset::GlobSetBuilder::new();
2227 for a in &allows {
2228 builder.add(globset::Glob::new(a).unwrap());
2229 }
2230 let allow_set = builder.build().unwrap();
2231 let mut dbuilder = globset::GlobSetBuilder::new();
2232 for d in &denies {
2233 dbuilder.add(globset::Glob::new(d).unwrap());
2234 }
2235 let deny_set = dbuilder.build().unwrap();
2236 let shas: Vec<String> = git(&["log", "--format=%H", "-n", &commits.to_string(), "--", "."])
2237 .lines()
2238 .map(String::from)
2239 .collect();
2240 let (mut commits_seen, mut commits_touching, mut commits_interface) =
2241 (0usize, 0usize, 0usize);
2242 let (mut files_changed, mut files_interface, mut files_body_only) =
2243 (0usize, 0usize, 0usize);
2244 for sha in &shas {
2245 commits_seen += 1;
2246 let parent = format!("{sha}~1");
2247 let names = git(&["diff", "--name-only", &parent, sha]);
2248 let mut touched = false;
2249 let mut iface = false;
2250 for f in names.lines() {
2251 if !allow_set.is_match(f) || deny_set.is_match(f) {
2252 continue;
2253 }
2254 let old = git(&["show", &format!("{parent}:{f}")]);
2255 let new = git(&["show", &format!("{sha}:{f}")]);
2256 if old.is_empty() || new.is_empty() {
2257 continue;
2258 }
2259 if prepared_content_hash(old.as_bytes()) == prepared_content_hash(new.as_bytes()) {
2260 continue;
2261 }
2262 touched = true;
2263 files_changed += 1;
2264 if prepared_content_hash(code_map_digest(f, &old).as_bytes())
2265 != prepared_content_hash(code_map_digest(f, &new).as_bytes())
2266 {
2267 files_interface += 1;
2268 iface = true;
2269 } else {
2270 files_body_only += 1;
2271 }
2272 }
2273 if touched {
2274 commits_touching += 1;
2275 }
2276 if iface {
2277 commits_interface += 1;
2278 }
2279 }
2280 eprintln!();
2281 eprintln!(
2282 "history: {commits_seen} commits inspected, {commits_touching} touched a scoped file's content, {commits_interface} of those changed an interface"
2283 );
2284 eprintln!(
2285 "files: {files_changed} scoped file changes, {files_interface} interface changes, {files_body_only} body-only ({:.1}% of file changes would not drift a code-map anchor)",
2286 100.0 * files_body_only as f64 / files_changed.max(1) as f64
2287 );
2288 }
2289
2290 #[test]
2294 fn unit_keys_survive_growth_and_diff_delivers_only_what_changed() {
2295 let before = unitize(DATED_ENTRIES, LOG).unwrap();
2296 let grown = format!("{LOG}2026-08-26 09:00 restart\nline d\n");
2297 let after = unitize(DATED_ENTRIES, &grown).unwrap();
2298 assert_eq!(
2299 &after[..3],
2300 &before[..],
2301 "existing units are byte-identical"
2302 );
2303 let delta = diff_units(&before, &after);
2304 assert_eq!(delta.len(), 1);
2305 assert_eq!(delta[0].0.key, "2026-08-26T09:00:00");
2306 assert_eq!(delta[0].1, UnitChange::Added);
2307
2308 let edited = LOG.replace("line c", "line c, revised");
2309 let delta = diff_units(&before, &unitize(DATED_ENTRIES, &edited).unwrap());
2310 assert_eq!(
2311 delta
2312 .iter()
2313 .map(|(u, c)| (u.key.as_str(), *c))
2314 .collect::<Vec<_>>(),
2315 vec![("2026-08-25T00:00:00", UnitChange::Modified)]
2316 );
2317
2318 let shrunk = LOG.replace("2026-08-25 shutdown\nline c\n", "");
2319 let delta = diff_units(&before, &unitize(DATED_ENTRIES, &shrunk).unwrap());
2320 assert_eq!(
2321 delta
2322 .iter()
2323 .map(|(u, c)| (u.key.as_str(), *c))
2324 .collect::<Vec<_>>(),
2325 vec![("2026-08-25T00:00:00", UnitChange::Deleted)]
2326 );
2327 assert!(diff_units(&before, &before).is_empty());
2328 }
2329
2330 #[test]
2331 fn url_defaults_unstable_every_other_grain_stable() {
2332 assert_eq!(
2333 default_hash_stability(AnchorGrain::Url),
2334 AnchorHashStability::Unstable
2335 );
2336 for g in [
2337 AnchorGrain::Span,
2338 AnchorGrain::File,
2339 AnchorGrain::Tree,
2340 AnchorGrain::Entity,
2341 ] {
2342 assert_eq!(default_hash_stability(g), AnchorHashStability::Stable);
2343 }
2344 }
2345
2346 #[test]
2350 fn url_prepared_form_is_the_shared_canonicalization() {
2351 let a = url_prepared_hash(b"<p>hello</p>\n");
2352 assert_eq!(a, prepared_content_hash(b"<p>hello</p>\n"));
2353 assert_eq!(a, url_prepared_hash(b"\xEF\xBB\xBF<p>hello</p>\r\n\r\n"));
2354 assert_ne!(a, url_prepared_hash(b"<p>hello!</p>\n"));
2355 assert_eq!(
2356 supplied_content_hash(AnchorGrain::Url, b"<p>hello</p>").as_deref(),
2357 Some(a.as_str())
2358 );
2359 assert!(supplied_content_hash(AnchorGrain::File, b"x").is_some());
2360 assert!(supplied_content_hash(AnchorGrain::Span, b"x").is_some());
2361 assert!(supplied_content_hash(AnchorGrain::Tree, b"x").is_none());
2362 assert!(supplied_content_hash(AnchorGrain::Entity, b"x").is_none());
2363 }
2364
2365 #[test]
2366 fn load_bearing_resolves_explicit_then_required_then_all() {
2367 let explicit = type_with(vec![
2368 section("claim", true, Some(true)),
2369 section("evidence", true, Some(false)),
2370 section("notes", false, None),
2371 ]);
2372 let keys: Vec<_> = load_bearing_sections(&explicit)
2373 .iter()
2374 .map(|s| s.key.as_str())
2375 .collect();
2376 assert_eq!(keys, vec!["claim"]);
2377
2378 let required = type_with(vec![
2379 section("claim", true, None),
2380 section("evidence", true, Some(false)),
2381 section("notes", false, None),
2382 ]);
2383 let keys: Vec<_> = load_bearing_sections(&required)
2384 .iter()
2385 .map(|s| s.key.as_str())
2386 .collect();
2387 assert_eq!(
2388 keys,
2389 vec!["claim"],
2390 "a required section opted out is excluded"
2391 );
2392
2393 let none = type_with(vec![section("a", false, None), section("b", false, None)]);
2394 let keys: Vec<_> = load_bearing_sections(&none)
2395 .iter()
2396 .map(|s| s.key.as_str())
2397 .collect();
2398 assert_eq!(keys, vec!["a", "b"], "no declaration at all: every section");
2399 }
2400
2401 #[test]
2404 fn notes_edit_keeps_the_hash_load_bearing_edit_breaks_it() {
2405 let td = type_with(vec![
2406 section("decision", true, None),
2407 section("notes", false, None),
2408 ]);
2409 let base = entity(&[("decision", "We ship."), ("notes", "first draft")]);
2410 let notes_edit = entity(&[("decision", "We ship."), ("notes", "first draft, revised")]);
2411 let claim_edit = entity(&[("decision", "We do not ship."), ("notes", "first draft")]);
2412 let h = |e: &Entity| entity_prepared_hash(e, Some(&td), Some(ENTITY_LOAD_BEARING)).unwrap();
2413 assert_eq!(h(&base), h(¬es_edit));
2414 assert_ne!(h(&base), h(&claim_edit));
2415
2416 let d = |e: &Entity| entity_prepared_hash(e, Some(&td), None).unwrap();
2419 assert_ne!(d(&base), d(¬es_edit));
2420 assert_eq!(
2421 d(&base),
2422 prepared_content_hash(crate::render::render_entity_markdown(&base, None).as_bytes())
2423 );
2424
2425 assert!(entity_prepared_hash(&base, Some(&td), Some("pdf-to-markdown")).is_none());
2427 }
2428
2429 #[test]
2432 fn form_is_keyed_and_trimmed() {
2433 let td = type_with(vec![
2434 section("claim", true, None),
2435 section("evidence", true, None),
2436 ]);
2437 let a = entity(&[("claim", "x"), ("evidence", "y")]);
2438 let b = entity(&[("claim", "y"), ("evidence", "x")]);
2439 let c = entity(&[("claim", "x \n\n"), ("evidence", "\n y")]);
2440 let form = |e: &Entity| entity_load_bearing_form(e, Some(&td));
2441 assert_ne!(form(&a), form(&b));
2442 assert_eq!(form(&a), form(&c));
2443 assert_eq!(form(&a), "## claim\n\nx\n\n## evidence\n\ny\n\n");
2444 assert_eq!(
2446 entity_load_bearing_form(&entity(&[("z", "1"), ("a", "2")]), None),
2447 "## z\n\n1\n\n## a\n\n2\n\n"
2448 );
2449 }
2450}