Functional Weave
Code in Rust

text.mask

Mask all but the last n characters of a card, account or phone number, optionally keeping separators.

1.0.0 · published 2026-10-03 by charlie · Anterra

Pinned by 16 tests, run in TypeScript, Python and Rust.

What it does

Replaces every character except the last `visible` with `maskChar`: "4242424242424242" with 4 visible is "************4242".

## Separators

For example

  • mask(4242424242424242, 4, *, false) → ************4242 a card number shows its last four
  • mask(4242 4242 4242 4242, 4, *, true) → **** **** **** 4242 separators kept: the grouping survives and still four digits show
  • mask(4242 4242 4242 4242, 4, *, false) → ***************4242 separators not kept: spaces are masked and count as characters

The function

The same function in TypeScript, Python and Rust, pinned by the same tests. Pick your language; the choice follows you around the registry.

pub fn mask(value: &str, visible: i64, mask_char: &str, keep_separators: bool) -> String
valuestringthe text to mask
visibleinthow many characters to leave showing at the end, 0 or more
mask_charstringexactly one character, usually "*"
keep_separatorsboolleave spaces and hyphens in place and do not count them
returnsstringthe same number of characters as value

Your code names it in one line, in the file that uses it

fune!(text.mask@^1);  // then call mask(…)
impl/rust.rs · 49 lines · open · raw

Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.

use super::funejson::Value;  ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one

/// Only these two are separators: they are how card and sort-code numbers are
/// printed.
fn is_separator(ch: char) -> bool {
    ch == ' ' || ch == '-'
}

/// Mask all but the last `visible` characters of `value` with `mask_char`.
///
/// Characters are Unicode code points (`char`), so an emoji counts once and
/// the result has as many code points as the input.
///
/// # Panics
/// Panics if `visible` is negative or `mask_char` is not exactly one character.
pub fn mask(value: &str, visible: i64, mask_char: &str, keep_separators: bool) -> String {
    if visible < 0 {
        panic!("visible must be 0 or greater, received {}", visible);
    }
    let mut mask_chars = mask_char.chars();
    let replacement = match (mask_chars.next(), mask_chars.next()) {
        (Some(c), None) => c,
        _ => panic!("maskChar must be exactly one character, received \"{}\"", mask_char),
    };

    let chars: Vec<char> = value.chars().collect();
    let mut out: Vec<char> = vec![replacement; chars.len()];
    let mut shown: i64 = 0;
    // Walk from the end so "the last n" is counted without a second pass.
    for i in (0..chars.len()).rev() {
        let ch = chars[i];
        if keep_separators && is_separator(ch) {
            out[i] = ch;
        } else if shown < visible {
            out[i] = ch;
            shown += 1;
        }
    }
    out.into_iter().collect()
}

pub fn fune_vector(args: &[Value]) -> Value {
    Value::Str(mask(
        args[0].as_str(),
        args[1].as_i64(),
        args[2].as_str(),
        args[3].as_bool(),
    ))
}

Install

fune build

With that line in your source, in a Rust project (language rust in fune.project), fune build resolves it and nothing else, pins them in fune.lock, downloads only the Rust package of each, and builds the code above into your project’s .fune/build, one readable file per capability with a header linking back here. A crate’s build.rs runs it before every compile. Or pin a range in fune.project and build in one step:

fune add text.mask
Download for Rust text.mask-1.0.0-rust.fune · 7,272 bytes sha256 d757de7759c72076bdcd6bb6028b03731b5d503ac96b6282f0cebd9219e7ffc5

The manifest, vectors and README with only the Rust implementation. Install it without the registry with fune add ./text.mask-1.0.0-rust.fune, or fetch it from a terminal with fune pull text.mask@1.0.0:rust.

The whole function, every language, is one file too: text.mask-1.0.0.fune, 10,511 bytes, sha256 e1bbb7d0741d771c6ccf2ae30859e642453a9ee706208c1183714ba54a1668fa. It installs into a project of any language.

Customise it in your app

The seams this capability offers. Put a marker directly above a function of your own and fune build wires it into the built code; the package on the registry is not changed, the built file’s header lists it under CUSTOMISED, and fune hooks lists every hook in the project. How hooks work.

before — your function gets the arguments and returns them, changed or not, or throws to refuse the call.

// fune: before text.mask

after — your function gets the result and the arguments, and returns the final result.

// fune: after text.mask

replace — it requires no other capability, so there is no dependency to replace.

step — your function runs at a numbered point inside the function’s body, receives the in-scope values it names as parameters, and may return replacements. List the points with fune show text.mask --steps.

// fune: step text.mask after <n|label>

Tests

A version published now needs at least 8 tests for every function, and one that expects the error for each function that throws; the registry refuses it otherwise. fune verify --all runs each case in TypeScript, Python and Rust, and a project runs them again with fune verify. This page lists the cases; it does not run them. The exact JSON is vectors.json.

CaseArgumentsExpected
a card number shows its last four 4242424242424242, 4, *, false → ************4242
separators kept: the grouping survives and still four digits show 4242 4242 4242 4242, 4, *, true → **** **** **** 4242
separators not kept: spaces are masked and count as characters 4242 4242 4242 4242, 4, *, false → ***************4242
separators kept, the visible count skips over a separator 1234 5678, 5, *, true → ***4 5678
a hyphenated sort code with a different mask character 12-34-56, 2, #, true → ##-##-56
zero visible masks everything 12345678, 0, *, false → ********
visible equal to the length leaves the value alone 1234, 4, *, false → 1234
visible past the length leaves the value alone, unpadded 12, 4, *, false → 12
the empty string stays empty , 4, *, true →
dots are not separators: they are masked a.b.c, 1, *, true → ****c
Show the other 6 tests
CaseArgumentsExpected
accented letters count as one character each ÄÖÜß, 1, •, false → •••ß
an emoji is one character, not two UTF-16 units a😀b, 1, *, false → **b
an emoji is masked as a single mask character 😀😀x, 2, *, false → *😀x
a negative visible count is an error 1234, -1, *, false → error: visible must be 0 or greater
an empty mask character is an error 1234, 2, , false → error: maskChar must be exactly one character
a two-character mask would change the length, so it is an error 1234, 2, **, false → error: maskChar must be exactly one character

More from the author

With `keepSeparators` on, ASCII spaces and hyphens stay where they are and are not counted, so a card printed in groups keeps its shape and still shows its last four digits: "4242 4242 4242 4242" becomes "**** **** **** 4242", and the sort code "12-34-56" with 2 visible becomes "**-**-56". Only those two characters are separators; dots, slashes and brackets are masked like anything else, because they are just as often part of the secret.

With it off, separators are ordinary characters: they are masked and they count towards `visible`, so the output never reveals how a value was grouped.

## Counting

Characters are Unicode code points in all three languages, so an accented letter or an emoji counts once, and the result always has the same number of code points as the input. A letter written as a base letter plus a combining accent is two code points and is masked as two.

## Edge cases

- `visible` of 0 masks everything; `visible` at or above the length returns the value unchanged. Nothing is padded: a short value stays short. - A negative `visible`, or a `maskChar` that is not exactly one code point, is an error.

## What masking is not

Masking is for display. The digits you show must be ones you are allowed to show - PCI DSS permits at most the first six and last four of a card number - and the full value must not be sent to the page at all if it is not needed there. This function does not know what the value is.

Files

PathBytes
README.md1,599
impl/python.py1,561
impl/rust.rs1,595
impl/typescript.ts1,521
vectors.json2,075