morphir_core/ir/layout/stems.rs
1//! `stemFor`: the file stem a name is given inside a module directory, truncated when it would
2//! not fit the tree's path budget.
3//!
4//! Mirrors `IR/src/layout/stems.ts:stemFor` in `ecosystem/morphir-typescript`; see
5//! `.dev/docs/superpowers/maps/2026-09-17-reference-tree-layout-map.md` section 2.2 for the
6//! worked example this is pinned against.
7
8use crate::ir::{Diagnostic, DiagnosticCode, DiagnosticStage};
9use crate::naming::{self, Name};
10
11/// `"__"` plus eight hex digits: what a truncated stem's hash suffix costs.
12const HASH_SUFFIX_LEN: u32 = 10;
13
14/// The stem `stem_for` chose for a name, and whether it had to be truncated to fit.
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct StemResult {
17 pub stem: String,
18 pub truncated: bool,
19}
20
21/// The file stem `name` is given under a module directory whose physical path already carries
22/// `physical_prefix` (the root, the module's directory, and the trailing `/`) and will carry
23/// `suffix` (the node kind and the profile's extension) after it.
24///
25/// The whole physical path — root included, version slot included, extension included — is what
26/// is measured against `path_budget`, in characters, inclusive: a path exactly at the budget
27/// fits. When it does not, the stem is shortened to `path_budget - physical_prefix.len() -
28/// suffix.len() - 10` characters of the escaped stem (stripping a trailing `-` or `_` the cut
29/// exposed, without re-extending afterwards — the reference computes a fixed cut rather than the
30/// longest prefix that fits) followed by `__` and the first eight hex digits of the SHA-256 of
31/// the *full, untruncated* escaped stem. A budget too small to hold even one kept character is
32/// `invalid_distribution_shape`, cursor `/`, rather than an overrun path.
33pub fn stem_for(
34 name: &Name,
35 physical_prefix: &str,
36 suffix: &str,
37 path_budget: u32,
38) -> Result<StemResult, Diagnostic> {
39 let escaped = naming::file_stem(name);
40
41 let prefix_len = char_len(physical_prefix);
42 let escaped_len = char_len(&escaped);
43 let suffix_len = char_len(suffix);
44
45 if prefix_len + escaped_len + suffix_len <= path_budget {
46 return Ok(StemResult {
47 stem: escaped,
48 truncated: false,
49 });
50 }
51
52 let budget_error = || {
53 Diagnostic::new(
54 DiagnosticCode::InvalidDistributionShape,
55 DiagnosticStage::Semantic,
56 "/",
57 format!("path budget {path_budget} cannot fit {physical_prefix}{escaped}{suffix}"),
58 )
59 };
60
61 let keep = i64::from(path_budget)
62 - i64::from(prefix_len)
63 - i64::from(suffix_len)
64 - i64::from(HASH_SUFFIX_LEN);
65 if keep < 1 {
66 return Err(budget_error());
67 }
68
69 // `truncate_stem`'s `available` is `keep + 10`; its own floor, `MIN_TRUNCATED_STEM_BUDGET`
70 // (11), is exactly what `keep >= 1` guarantees here, so this is not expected to refuse — but
71 // a caller finding it refuse anyway is a real disagreement between the two budgets, not
72 // something to paper over with an `unwrap`.
73 let available = keep as u64 + u64::from(HASH_SUFFIX_LEN);
74 let available = usize::try_from(available).map_err(|_| budget_error())?;
75
76 match naming::truncate_stem(&escaped, available) {
77 Some(stem) => Ok(StemResult {
78 stem,
79 truncated: true,
80 }),
81 None => Err(budget_error()),
82 }
83}
84
85/// A budget is a count of characters, not bytes: the escaped-stem grammar is ASCII, but a
86/// caller's prefix or suffix is not guaranteed to be.
87fn char_len(text: &str) -> u32 {
88 u32::try_from(text.chars().count()).unwrap_or(u32::MAX)
89}