Skip to content
139 linesCodeBlameRaw
1//! Usernames as their owners wrote them. Every person in an answer, the
2//! REST API's and MCP's alike (an issue's `author`, a member, a
3//! collaborator, whoever `whoami` is), carries `display_username` beside
4//! `username`: `username` stays the lowercased key, stable for anything
5//! that matches on it, and `display_username` is the case its owner chose
6//! (`Ana`), the same as `username` when they chose none.
7//!
8//! The services keep the lowercased name; this fills the chosen case in on
9//! the way out, with one call to identity per answer.
10
11use std::collections::{BTreeSet, HashMap};
12
13use g1t_contracts::identity::DisplayUsernamesArgs;
14use serde_json::Value;
15
16use crate::operations::Services;
17
18/// The most people one answer looks up; the rest show `username` as it is.
19const MOST: usize = 200;
20
21/// Whether `object` is a person (or another principal) named by `username`,
22/// rather than a set of credentials that happens to carry one.
23fn names_someone(object: &serde_json::Map<String, Value>) -> bool {
24 matches!(object.get("username"), Some(Value::String(name)) if !name.is_empty())
25 && !object.contains_key("password")
26}
27
28/// The lowercased usernames in `value`, each once, at most [`MOST`].
29pub fn usernames_in(value: &Value) -> Vec<String> {
30 fn walk(value: &Value, out: &mut BTreeSet<String>) {
31 match value {
32 Value::Object(object) => {
33 if names_someone(object)
34 && let Some(Value::String(name)) = object.get("username")
35 {
36 out.insert(name.to_lowercase());
37 }
38 object.values().for_each(|value| walk(value, out));
39 }
40 Value::Array(items) => items.iter().for_each(|value| walk(value, out)),
41 _ => {}
42 }
43 }
44 let mut out = BTreeSet::new();
45 walk(value, &mut out);
46 out.into_iter().take(MOST).collect()
47}
48
49/// Gives every person in `value` a `display_username`: the case from
50/// `chosen` (by lowercased username), one they already carry when it is
51/// the same name, or else `username` itself.
52pub fn fill(value: &mut Value, chosen: &HashMap<String, String>) {
53 match value {
54 Value::Object(object) => {
55 if names_someone(object) {
56 let username = object["username"].as_str().unwrap_or_default().to_owned();
57 // A service's own `displayUsername` (a profile's) becomes this.
58 let theirs = object.remove("displayUsername");
59 let carried = object
60 .get("display_username")
61 .or(theirs.as_ref())
62 .and_then(Value::as_str)
63 .filter(|display| display.eq_ignore_ascii_case(&username))
64 .map(str::to_owned);
65 let shown = chosen
66 .get(&username.to_lowercase())
67 .cloned()
68 .or(carried)
69 .unwrap_or(username);
70 object.insert("display_username".to_owned(), Value::String(shown));
71 }
72 object.values_mut().for_each(|value| fill(value, chosen));
73 }
74 Value::Array(items) => items.iter_mut().for_each(|value| fill(value, chosen)),
75 _ => {}
76 }
77}
78
79/// `value` with each person's chosen case filled in. When identity does
80/// not answer, the people are still given `display_username`, as their
81/// `username`: an answer is never held up for it.
82pub async fn name_people(services: &Services, mut value: Value) -> Value {
83 let usernames = usernames_in(&value);
84 if usernames.is_empty() {
85 return value;
86 }
87 let chosen: HashMap<String, String> =
88 g1t_kit::call(&services.identity, "display_usernames", &DisplayUsernamesArgs { usernames })
89 .await
90 .unwrap_or_default();
91 fill(&mut value, &chosen);
92 value
93}
94
95#[cfg(test)]
96mod tests {
97 use super::*;
98 use serde_json::json;
99
100 #[test]
101 fn every_person_in_an_answer_is_found_once() {
102 let answer = json!({
103 "issue": { "author": { "id": "usr_1", "username": "ana" }, "assignees": [{ "username": "bo" }, { "username": "ana" }] },
104 "git": { "username": "ana", "password": "your g1t access token" },
105 "count": 3,
106 });
107 assert_eq!(usernames_in(&answer), vec!["ana".to_owned(), "bo".to_owned()]);
108 }
109
110 #[test]
111 fn people_get_the_case_they_chose_or_their_username() {
112 let mut answer = json!({
113 "author": { "id": "usr_1", "username": "ana" },
114 "members": [{ "username": "bo" }, { "username": "cy", "display_username": "Cy" }],
115 "git": { "username": "ana", "password": "your g1t access token" },
116 });
117 let chosen = HashMap::from([("ana".to_owned(), "Ana".to_owned())]);
118 fill(&mut answer, &chosen);
119 assert_eq!(answer["author"]["username"], "ana");
120 assert_eq!(answer["author"]["display_username"], "Ana");
121 assert_eq!(answer["members"][0]["display_username"], "bo");
122 assert_eq!(answer["members"][1]["display_username"], "Cy");
123 assert!(answer["git"].get("display_username").is_none());
124 }
125
126 #[test]
127 fn a_carried_case_of_another_name_is_not_kept() {
128 let mut answer = json!({ "username": "ana", "display_username": "Bob" });
129 fill(&mut answer, &HashMap::new());
130 assert_eq!(answer["display_username"], "ana");
131 }
132
133 #[test]
134 fn a_profiles_own_spelling_is_folded_in() {
135 let mut answer = json!({ "username": "ana", "displayUsername": "Ana" });
136 fill(&mut answer, &HashMap::new());
137 assert_eq!(answer, json!({ "username": "ana", "display_username": "Ana" }));
138 }
139}