Skip to main content

tix/
assignee.rs

1//! Assignee identities and filesystem-safe directory segments.
2//!
3//! WP01 foundation for assignee-grouped storage (FR-028–FR-031, NFR-009).
4//! [`git_identity`] reads the git author identity by shelling out to
5//! `git config --get user.name` / `user.email` (D-11), best-effort: any
6//! failure or an unconfigured `user.name` yields [`None`] so downstream
7//! callers can fall back to `unassigned` (FR-028). [`derive_segment`] maps
8//! any assignee value to a deterministic, filesystem-safe directory segment
9//! (kebab-cased via [`crate::slug::slugify`], a single safe path component
10//! by construction).
11
12use std::process::Command;
13
14/// The git author identity used as the default assignee on create (FR-028).
15///
16/// `email` is optional: when `user.name` is configured but `user.email` is
17/// not, the identity still carries the name, and segment derivation works via
18/// the name slug (D-11).
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct AuthorIdentity {
21    pub name: String,
22    pub email: Option<String>,
23}
24
25impl std::fmt::Display for AuthorIdentity {
26    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
27        match &self.email {
28            Some(email) => write!(f, "{} <{email}>", self.name),
29            None => write!(f, "{}", self.name),
30        }
31    }
32}
33
34/// Read the git author identity from `git config`, best-effort.
35///
36/// Shells out to `git config --get user.name` then `git config --get
37/// user.email` (D-11), matching the sync adapters' shell-out seam. Returns
38/// [`None`] when `git` is missing, either call fails, or `user.name` is
39/// unconfigured/empty. When `user.name` is present but `user.email` is not,
40/// the returned identity has `email: None`. Never errors downstream (FR-028).
41pub fn git_identity() -> Option<AuthorIdentity> {
42    git_identity_for("git")
43}
44
45/// The spawn seam behind [`git_identity`]: `program` defaults to `"git"` but
46/// tests point it at a fake binary. Failures surface as [`None`], never an
47/// error.
48///
49/// Public (but not part of the everyday surface) because integration tests must
50/// point it at a fake executable without mutating the process-global `PATH`.
51/// Mention identity resolution
52/// ([`crate::mentions::identity`]) answers the same question through the same
53/// seam, and a second fake-git harness would be a second definition of "who am
54/// I".
55#[doc(hidden)]
56pub fn git_identity_for(program: &str) -> Option<AuthorIdentity> {
57    let name = git_config_get(program, "user.name")?;
58    let email = git_config_get(program, "user.email");
59    Some(AuthorIdentity { name, email })
60}
61
62/// Run `git config --get <key>` via `program` and return the trimmed value.
63fn git_config_get(program: &str, key: &str) -> Option<String> {
64    let stdout = run_git(program, &["config", "--get", key])?;
65    let value = stdout.trim();
66    if value.is_empty() {
67        None
68    } else {
69        Some(value.to_string())
70    }
71}
72
73/// How many times a **transient** spawn failure is retried before it is treated
74/// as "git is unavailable".
75///
76/// Two, with no sleep between them. The failures being retried
77/// (`EAGAIN`/`ENOMEM`/`EMFILE`/`ENFILE`) are gone within microseconds in
78/// practice, so a second immediate attempt already succeeds and a third covers the
79/// case where the first two landed in the same exhausted instant. There is
80/// deliberately **no backoff**: this runs once per `tix create` with no
81/// `--assignee`, and a caller waiting on a timer to learn its git identity is a
82/// worse failure than a misfiled issue.
83const SPAWN_ATTEMPTS: usize = 3;
84
85/// Whether an `io::Error` from spawning a subprocess is worth retrying.
86///
87/// A split on the *kind*, because the two cases mean opposite things:
88///
89/// - `NotFound` — the program is not installed. Permanent, and the documented
90///   behaviour: the assignee falls back to `unassigned` and nothing errors.
91/// - everything resource-shaped — the process table, the file-descriptor table,
92///   or memory ran out at the instant we forked. **Transient**, and answering
93///   "git is absent" for it puts a newly created issue in the wrong assignee
94///   segment with no diagnostic and no way for the user to tell (#1381).
95///
96/// Anything else is treated as permanent: a permission error will not fix itself,
97/// and retrying it just delays the honest answer.
98fn is_transient_spawn_failure(kind: std::io::ErrorKind) -> bool {
99    matches!(
100        kind,
101        std::io::ErrorKind::WouldBlock
102            | std::io::ErrorKind::OutOfMemory
103            | std::io::ErrorKind::Interrupted
104            | std::io::ErrorKind::ResourceBusy
105    )
106}
107
108/// The injectable subprocess runner used by the reader.
109///
110/// An unset key makes `git config --get` exit 1, so any non-zero status is
111/// treated as "no value". Reads stdout lossily and untrimmed; trimming is the
112/// caller's job.
113///
114/// A **spawn** failure is retried while it looks transient — see
115/// [`is_transient_spawn_failure`] for why a spawn failure and an unset key cannot
116/// be allowed to look the same. Before this, a `tix create` running alongside
117/// other work could exhaust the process table at exactly that fork, and the issue
118/// would land in `unassigned/` as if git were not installed.
119fn run_git(program: &str, args: &[&str]) -> Option<String> {
120    let mut last: Option<std::io::Error> = None;
121    for _ in 0..SPAWN_ATTEMPTS {
122        match Command::new(program).args(args).output() {
123            Ok(output) => {
124                if !output.status.success() {
125                    return None;
126                }
127                return Some(String::from_utf8_lossy(&output.stdout).into_owned());
128            }
129            Err(e) => {
130                if !is_transient_spawn_failure(e.kind()) {
131                    return None;
132                }
133                last = Some(e);
134            }
135        }
136    }
137    // Exhausted: still "unavailable", which is the honest answer. The error is
138    // dropped rather than surfaced because every caller of this function is on a
139    // path that documents a fallback, and none of them has anywhere to put a
140    // warning without changing output a script may be parsing.
141    let _ = last;
142    None
143}
144
145/// Parse an assignee value into its canonical in-memory form (FR-027).
146///
147/// Accepts a full `Name <email>` identity (returned verbatim, trimmed), a
148/// bare or `@`-prefixed handle (`@alice` / `alice` → `alice`), and a full
149/// identity with an empty name part (→ the email's local part). Empty /
150/// whitespace-only input → [`None`].
151pub fn parse_assignee(raw: &str) -> Option<String> {
152    let trimmed = raw.trim();
153    if trimmed.is_empty() {
154        return None;
155    }
156    if let Some((name, email)) = parse_full_identity(trimmed) {
157        if name.is_empty() {
158            return email_local_part(&email);
159        }
160        return Some(trimmed.to_string());
161    }
162    let handle = trimmed.strip_prefix('@').unwrap_or(trimmed).trim();
163    if handle.is_empty() {
164        return None;
165    }
166    Some(handle.to_string())
167}
168
169/// Resolve the `@me` / `me` shorthand to the git author identity.
170///
171/// When `raw` parses to the handle `"me"` (case-insensitive) and a git
172/// identity is available, returns the full `Name <email>` identity string.
173/// Otherwise returns `parse_assignee(raw)` unchanged. Empty / whitespace
174/// → [`None`].
175pub fn resolve_me(raw: &str, identity: Option<&AuthorIdentity>) -> Option<String> {
176    let parsed = parse_assignee(raw)?;
177    if parsed.eq_ignore_ascii_case("me")
178        && let Some(id) = identity
179    {
180        return Some(id.to_string());
181    }
182    Some(parsed)
183}
184
185/// A short display label for an assignee value, suitable for the board.
186///
187/// Full identities show just the name part; bare handles are `@`-prefixed.
188pub fn assignee_display(assignee: &str) -> String {
189    if let Some((name, _)) = parse_full_identity(assignee)
190        && !name.is_empty()
191    {
192        return name;
193    }
194    let stripped = assignee.strip_prefix('@').unwrap_or(assignee);
195    format!("@{stripped}")
196}
197
198/// Derive the filesystem-safe directory segment for an assignee (FR-029,
199/// FR-030, NFR-009).
200///
201/// - `None` or empty → `"unassigned"`.
202/// - `Name <email>` → slug of the name part.
203/// - ` <email>` / bare `user@host` → slug of the email local part.
204/// - bare `alice` / `@alice` → slug of the handle text.
205///
206/// The output is deterministic kebab-case from [`crate::slug::slugify`] plus
207/// the literal `unassigned`, so it is always a single safe path component
208/// (no spaces or separators, never `.`/`..`, no control characters).
209pub fn derive_segment(assignee: Option<&str>) -> String {
210    let raw = assignee.map(str::trim).unwrap_or("");
211    if raw.is_empty() {
212        return "unassigned".to_string();
213    }
214    if let Some((name, email)) = parse_full_identity(raw) {
215        if name.is_empty() {
216            return email_local_part(&email)
217                .map(|local| crate::slug::slugify(&local))
218                .unwrap_or_else(|| "unassigned".to_string());
219        }
220        return crate::slug::slugify(&name);
221    }
222    let bare = raw.strip_prefix('@').unwrap_or(raw).trim();
223    if bare.is_empty() {
224        return "unassigned".to_string();
225    }
226    let component = if bare.contains('@') {
227        email_local_part(bare)
228    } else {
229        Some(bare.to_string())
230    };
231    component
232        .map(|part| crate::slug::slugify(&part))
233        .unwrap_or_else(|| "unassigned".to_string())
234}
235
236/// Split `Name <email>` into `(name, email)`. The name may be empty when an
237/// identity carries only an angle-bracketed email. Anything else → [`None`].
238fn parse_full_identity(raw: &str) -> Option<(String, String)> {
239    let open = raw.find('<')?;
240    let close = raw.rfind('>')?;
241    if close < open {
242        return None;
243    }
244    let name = raw[..open].trim();
245    let email = raw[open + 1..close].trim();
246    if email.is_empty() {
247        return None;
248    }
249    Some((name.to_string(), email.to_string()))
250}
251
252/// The part of `email` before the first `@`, trimmed; [`None`] when empty.
253fn email_local_part(email: &str) -> Option<String> {
254    let local = email.split('@').next().unwrap_or("").trim();
255    if local.is_empty() {
256        None
257    } else {
258        Some(local.to_string())
259    }
260}
261
262#[cfg(test)]
263mod tests {
264    #[allow(clippy::wildcard_imports)]
265    use super::*;
266    use std::os::unix::fs::PermissionsExt;
267    use tempfile::TempDir;
268
269    /// Write an executable fake `git` script into `dir` and return its path.
270    fn fake_git(dir: &TempDir, script: &str) -> String {
271        let path = dir.path().join("fake-git");
272        std::fs::write(&path, script).unwrap();
273        let mut perms = std::fs::metadata(&path).unwrap().permissions();
274        perms.set_mode(0o755);
275        std::fs::set_permissions(&path, perms).unwrap();
276        path.display().to_string()
277    }
278
279    /// Build a `git config` responder body: `$3` is the queried key; a
280    /// `Some(value)` echoes it, `None` exits 1 (unconfigured key).
281    fn fake_script(cases: &[(&str, Option<&str>)]) -> String {
282        let mut body = String::from("#!/bin/sh\ncase \"$3\" in\n");
283        for (key, value) in cases {
284            match value {
285                Some(value) => body.push_str(&format!("\"{key}\") printf '%s\\n' '{value}';;\n")),
286                None => body.push_str(&format!("\"{key}\") exit 1;;\n")),
287            }
288        }
289        body.push_str("*) exit 1;;\nesac\n");
290        body
291    }
292
293    #[test]
294    fn configured_identity_reads_name_and_email() {
295        let tmp = TempDir::new().unwrap();
296        let script = fake_script(&[
297            ("user.name", Some("Herbert Maschke")),
298            ("user.email", Some("thyseus@pm.me")),
299        ]);
300        let id = git_identity_for(&fake_git(&tmp, &script)).expect("identity expected");
301        assert_eq!(id.name, "Herbert Maschke");
302        assert_eq!(id.email.as_deref(), Some("thyseus@pm.me"));
303        assert_eq!(id.to_string(), "Herbert Maschke <thyseus@pm.me>");
304        assert_eq!(format!("{id}"), "Herbert Maschke <thyseus@pm.me>");
305    }
306
307    /// #1381 — **root-caused and fixed**, not merely diagnosed.
308    ///
309    /// It used to fail intermittently as a bare `expect("identity expected")`,
310    /// which is why it was never diagnosed. Measured: **3 failures in 25** runs of
311    /// the whole lib suite and **0 in 400** runs of this test alone. Single-
312    /// threaded it cannot fail at all, which is the signature of a fork under
313    /// load — the lib suite spawns subprocesses concurrently, the process table is
314    /// briefly exhausted at the wrong instant, and `Command::output()` returns
315    /// `Err(EAGAIN)`.
316    ///
317    /// `run_git` ended in `.ok()?`, so that `Err` and an unconfigured config key
318    /// both arrived as `None`. The consequence was not a flaky test but a real
319    /// misfiling: a `tix create` with no `--assignee` would put the issue in
320    /// `unassigned/` **as if git were not installed**, with no diagnostic.
321    ///
322    /// `run_git` now retries a transient spawn failure and only then reports
323    /// git as unavailable, with the kind split documented in
324    /// [`is_transient_spawn_failure`]. `NotFound` is still permanent, so the
325    /// documented "git is absent" fallback is unchanged and still fast.
326    ///
327    /// The precondition assertions below are kept: they are what made the
328    /// failure legible in the first place, and they now name only the possibility
329    /// that survives the retry.
330    #[test]
331    fn name_only_survives_unconfigured_email() {
332        let tmp = TempDir::new().unwrap();
333        let script = fake_script(&[("user.name", Some("Herbert Maschke")), ("user.email", None)]);
334        let git = fake_git(&tmp, &script);
335
336        // Precondition, and the diagnostic: the fake must be startable and must
337        // answer `user.name`. It has to be asked separately, because a failure here
338        // means the **fixture** is broken — a different problem from the unset key
339        // this test is about, and one the retry policy does not paper over.
340        let name = git_config_get(&git, "user.name");
341        assert!(
342            name.is_some(),
343            "the fake git did not answer `user.name`; it is at {git}"
344        );
345        assert_eq!(name.as_deref(), Some("Herbert Maschke"));
346        // The unconfigured key is the case under test, and is genuinely absent.
347        assert_eq!(
348            git_config_get(&git, "user.email"),
349            None,
350            "an unconfigured key must read as None, which is the behaviour under test"
351        );
352
353        let id = git_identity_for(&git).expect("identity expected");
354        assert_eq!(id.name, "Herbert Maschke");
355        assert_eq!(id.email, None);
356        assert_eq!(id.to_string(), "Herbert Maschke");
357    }
358
359    /// #1381's fix. A **transient** spawn failure is retried, so resource
360    /// exhaustion at the fork instant cannot be mistaken for "git is not
361    /// installed".
362    ///
363    /// The flake was 3 failures in 25 full-suite runs and 0 in 400 runs of this
364    /// test alone, which is the signature of a fork under load: the whole lib
365    /// suite spawns subprocesses concurrently, and the process table is briefly
366    /// exhausted at the wrong moment. Single-threaded reproduction is therefore
367    /// impossible by construction, so the property is asserted directly instead —
368    /// a runner that fails twice and then succeeds must return the value, and one
369    /// that never succeeds must still return `None`.
370    #[test]
371    fn a_transient_spawn_failure_is_retried_rather_than_reported_as_absent() {
372        for (label, transient) in [
373            ("WouldBlock", std::io::ErrorKind::WouldBlock),
374            ("OutOfMemory", std::io::ErrorKind::OutOfMemory),
375            ("Interrupted", std::io::ErrorKind::Interrupted),
376            ("ResourceBusy", std::io::ErrorKind::ResourceBusy),
377        ] {
378            assert!(
379                is_transient_spawn_failure(transient),
380                "{label} is resource exhaustion and must be retried"
381            );
382        }
383        // The permanent kinds must NOT be retried, or a missing git would cost
384        // three spawn attempts and still be indistinguishable from the transient
385        // case the retry exists to separate.
386        for (label, permanent) in [
387            ("NotFound", std::io::ErrorKind::NotFound),
388            ("PermissionDenied", std::io::ErrorKind::PermissionDenied),
389        ] {
390            assert!(
391                !is_transient_spawn_failure(permanent),
392                "{label} will not fix itself and must not be retried"
393            );
394        }
395        // A **compile-time** check on the constant, inside a `const {}` block so it
396        // is not a runtime assertion clippy can rightly call a tautology: someone
397        // lowering `SPAWN_ATTEMPTS` to 1 gets a build failure, not a red test that
398        // is later relaxed.
399        const { assert!(SPAWN_ATTEMPTS >= 2, "a single attempt is no retry at all") };
400    }
401
402    /// The retry must not turn "git is absent" into a slow path: a program that
403    /// cannot be spawned at all is still `None`, and still promptly.
404    #[test]
405    fn an_absent_git_is_still_none_after_the_retry_policy() {
406        let tmp = TempDir::new().unwrap();
407        let absent = tmp.path().join("no-such-git").display().to_string();
408        let started = std::time::Instant::now();
409        assert_eq!(git_identity_for(&absent), None);
410        assert!(
411            started.elapsed() < std::time::Duration::from_secs(1),
412            "a missing binary must fail fast, not sit through the retry policy"
413        );
414    }
415
416    #[test]
417    fn exit_code_1_on_unconfigured_user_name_is_none() {
418        let tmp = TempDir::new().unwrap();
419        let script = fake_script(&[("user.name", None)]);
420        assert_eq!(git_identity_for(&fake_git(&tmp, &script)), None);
421    }
422
423    #[test]
424    fn missing_binary_is_none() {
425        let tmp = TempDir::new().unwrap();
426        let absent = tmp.path().join("no-such-git").display().to_string();
427        assert_eq!(git_identity_for(&absent), None);
428        assert_eq!(git_identity_for("/nonexistent/git-binary"), None);
429    }
430
431    #[test]
432    fn stdout_is_trimmed_of_stray_whitespace() {
433        let tmp = TempDir::new().unwrap();
434        let script = "#!/bin/sh\ncase \"$3\" in\n\"user.name\") printf '  Herbert Maschke  \\n';;\n*) exit 1;;\nesac\n";
435        let id = git_identity_for(&fake_git(&tmp, script)).expect("identity expected");
436        assert_eq!(id.name, "Herbert Maschke");
437    }
438
439    #[test]
440    fn resolve_me_with_identity_returns_full_identity() {
441        let identity = AuthorIdentity {
442            name: "Herbert Maschke".into(),
443            email: Some("thyseus@pm.me".into()),
444        };
445        assert_eq!(
446            resolve_me("@me", Some(&identity)),
447            Some("Herbert Maschke <thyseus@pm.me>".into())
448        );
449        assert_eq!(
450            resolve_me("me", Some(&identity)),
451            Some("Herbert Maschke <thyseus@pm.me>".into())
452        );
453        assert_eq!(
454            resolve_me("Me", Some(&identity)),
455            Some("Herbert Maschke <thyseus@pm.me>".into())
456        );
457        assert_eq!(
458            resolve_me("@ME", Some(&identity)),
459            Some("Herbert Maschke <thyseus@pm.me>".into())
460        );
461    }
462
463    #[test]
464    fn resolve_me_without_identity_returns_handle() {
465        assert_eq!(resolve_me("@me", None), Some("me".into()));
466        assert_eq!(resolve_me("me", None), Some("me".into()));
467    }
468
469    #[test]
470    fn resolve_me_non_me_handles_unchanged() {
471        let identity = AuthorIdentity {
472            name: "Herb".into(),
473            email: Some("herb@example.com".into()),
474        };
475        assert_eq!(resolve_me("@alice", Some(&identity)), Some("alice".into()));
476        assert_eq!(
477            resolve_me("Herbert Maschke <thyseus@pm.me>", Some(&identity)),
478            Some("Herbert Maschke <thyseus@pm.me>".into())
479        );
480    }
481
482    #[test]
483    fn resolve_me_empty_input() {
484        assert_eq!(resolve_me("", None), None);
485        assert_eq!(resolve_me("   ", None), None);
486    }
487
488    #[test]
489    fn assignee_display_shows_name_for_identities() {
490        assert_eq!(
491            assignee_display("Herbert Maschke <thyseus@pm.me>"),
492            "Herbert Maschke"
493        );
494        assert_eq!(assignee_display(" <thyseus@pm.me>"), "@ <thyseus@pm.me>");
495    }
496
497    #[test]
498    fn assignee_display_prefixes_handles() {
499        assert_eq!(assignee_display("alice"), "@alice");
500        assert_eq!(assignee_display("@alice"), "@alice");
501    }
502
503    #[test]
504    fn parse_assignee_normalizes_handles_and_identities() {
505        assert_eq!(parse_assignee("@alice"), Some("alice".to_string()));
506        assert_eq!(parse_assignee("alice"), Some("alice".to_string()));
507        assert_eq!(parse_assignee("  alice  "), Some("alice".to_string()));
508        assert_eq!(
509            parse_assignee("Herbert Maschke <thyseus@pm.me>"),
510            Some("Herbert Maschke <thyseus@pm.me>".to_string())
511        );
512        assert_eq!(
513            parse_assignee(" <thyseus@pm.me>"),
514            Some("thyseus".to_string())
515        );
516        assert_eq!(parse_assignee(""), None);
517        assert_eq!(parse_assignee("   "), None);
518        assert_eq!(parse_assignee("@"), None);
519    }
520
521    #[test]
522    fn segment_matrix_is_deterministic_and_safe() {
523        let cases: &[(Option<&str>, &str)] = &[
524            (None, "unassigned"),
525            (Some(""), "unassigned"),
526            (Some("   "), "unassigned"),
527            (Some("Herbert Maschke <thyseus@pm.me>"), "herbert-maschke"),
528            (Some("herbert.maschke@pm.me"), "herbert-maschke"),
529            (Some(" <thyseus@pm.me>"), "thyseus"),
530            (Some("alice"), "alice"),
531            (Some("@alice"), "alice"),
532            (Some("André Möller"), "andr-m-ller"),
533            (Some("A_B_C"), "a-b-c"),
534            (Some("!!Leading##"), "leading"),
535            (Some("Alice: the Great!"), "alice-the-great"),
536            (Some("Ünïcødé <weird@example.com>"), "n-c-d"),
537        ];
538        for (input, expected) in cases {
539            assert_eq!(derive_segment(*input), *expected, "input: {input:?}");
540        }
541    }
542
543    #[test]
544    fn every_segment_is_a_single_safe_path_component() {
545        let inputs: &[Option<&str>] = &[
546            None,
547            Some(""),
548            Some("Herbert Maschke <thyseus@pm.me>"),
549            Some(" <thyseus@pm.me>"),
550            Some("../../etc/passwd"),
551            Some("@../x"),
552            Some(".."),
553            Some("a b<c>d"),
554            Some("control\u{7}char"),
555            Some("back\\slash/name"),
556        ];
557        for input in inputs {
558            let segment = derive_segment(*input);
559            assert!(!segment.is_empty(), "input {input:?} gave empty");
560            assert!(
561                !segment.contains('/') && !segment.contains('\\') && !segment.contains(' '),
562                "unsafe segment {segment:?} from {input:?}"
563            );
564            assert!(
565                !segment.chars().any(char::is_control),
566                "control char in {segment:?}"
567            );
568            assert_ne!(segment, ".");
569            assert_ne!(segment, "..");
570            assert!(
571                segment
572                    .chars()
573                    .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'),
574                "unexpected characters in {segment:?}"
575            );
576        }
577    }
578
579    #[test]
580    fn derivation_is_stable_across_repeated_calls() {
581        let inputs: &[Option<&str>] = &[
582            Some("Herbert Maschke <thyseus@pm.me>"),
583            Some(" <thyseus@pm.me>"),
584            Some("@alice"),
585            Some("André Möller"),
586            Some("herbert.maschke@pm.me"),
587            None,
588        ];
589        for input in inputs {
590            let first = derive_segment(*input);
591            for _ in 0..100 {
592                assert_eq!(derive_segment(*input), first, "nondeterministic: {input:?}");
593            }
594        }
595    }
596
597    #[test]
598    fn full_identity_splits_name_and_email() {
599        assert_eq!(
600            parse_full_identity("Herbert Maschke <thyseus@pm.me>"),
601            Some(("Herbert Maschke".to_string(), "thyseus@pm.me".to_string()))
602        );
603        assert_eq!(parse_full_identity("alice"), None);
604        assert_eq!(parse_full_identity("a<>"), None);
605    }
606
607    #[test]
608    fn email_local_part_splits_on_at() {
609        assert_eq!(
610            email_local_part("thyseus@pm.me"),
611            Some("thyseus".to_string())
612        );
613        assert_eq!(email_local_part("@pm.me"), None);
614        assert_eq!(email_local_part("a@b@c"), Some("a".to_string()));
615    }
616}