agentmux_srv/server/agent_handlers/
account_label.rs1use std::collections::HashMap;
14
15use crate::backend::storage::store::{AgentIdentityLink, IdentityAccount};
16
17pub(super) const NO_AUTH: &str = "No auth";
18
19pub(super) fn names_by_id<'a>(
24 own: &'a [IdentityAccount],
25 mirror: &'a [IdentityAccount],
26) -> HashMap<&'a str, &'a str> {
27 let mut names: HashMap<&str, &str> = mirror.iter().map(|a| (a.id.as_str(), a.name.as_str())).collect();
28 names.extend(own.iter().map(|a| (a.id.as_str(), a.name.as_str())));
29 names
30}
31
32#[derive(Debug, PartialEq, Eq)]
35pub(super) struct AccountLine {
36 pub text: String,
37 pub unresolved: usize,
38}
39
40#[derive(Debug, Default, Clone, Copy)]
43pub(super) struct Degraded {
44 pub links: bool,
47 pub accounts: bool,
51}
52
53pub(super) fn account_line(
54 links: Option<&[&AgentIdentityLink]>,
55 names: &HashMap<&str, &str>,
56 degraded: Degraded,
57) -> AccountLine {
58 let links = links.unwrap_or(&[]);
59 if links.is_empty() {
60 let text = if degraded.links { String::new() } else { NO_AUTH.to_string() };
61 return AccountLine { text, unresolved: 0 };
62 }
63 let mut resolved: Vec<String> = links.iter().filter_map(|l| names.get(l.account_id.as_str()).map(|n| n.to_string())).collect();
64 let unresolved = links.len() - resolved.len();
65 resolved.sort();
66 resolved.dedup();
67 let text = match (resolved.is_empty(), degraded.accounts) {
68 (false, _) => resolved.join(", "),
69 (true, true) => String::new(),
71 (true, false) => NO_AUTH.to_string(),
72 };
73 AccountLine { text, unresolved: if degraded.accounts { 0 } else { unresolved } }
75}
76
77#[cfg(test)]
78mod tests {
79 use super::*;
80
81 fn acct(id: &str, name: &str) -> IdentityAccount {
82 IdentityAccount {
83 id: id.into(),
84 name: name.into(),
85 provider: "claude".into(),
86 kind: "oauth".into(),
87 display_name: String::new(),
88 secret_ref: crate::backend::storage::store::SecretRef::OAuthConfigDir { dir: "/x".into() },
89 context: serde_json::json!({}),
90 status: "valid".into(),
91 created_at: 0,
92 updated_at: 0,
93 }
94 }
95
96 const OK: Degraded = Degraded { links: false, accounts: false };
97
98 fn link(agent: &str, account: &str) -> AgentIdentityLink {
99 AgentIdentityLink { agent_id: agent.into(), account_id: account.into(), provider: "claude".into() }
100 }
101
102 #[test]
103 fn a_bound_agent_shows_its_accounts_sorted_and_deduplicated() {
104 let own = [acct("a1", "work"), acct("a2", "home")];
105 let names = names_by_id(&own, &[]);
106 let ls = [link("ag", "a1"), link("ag", "a2"), link("ag", "a1")];
107 let refs: Vec<&AgentIdentityLink> = ls.iter().collect();
108 assert_eq!(account_line(Some(&refs), &names, OK), AccountLine { text: "home, work".into(), unresolved: 0 });
109 }
110
111 #[test]
112 fn an_unbound_agent_says_no_auth() {
113 let names = names_by_id(&[], &[]);
114 assert_eq!(account_line(None, &names, OK).text, NO_AUTH);
115 assert_eq!(account_line(Some(&[]), &names, OK).text, NO_AUTH);
116 }
117
118 #[test]
119 fn a_link_to_an_account_that_is_gone_says_no_auth_and_is_counted() {
120 let names = names_by_id(&[], &[]);
121 let ls = [link("ag", "deleted")];
122 let refs: Vec<&AgentIdentityLink> = ls.iter().collect();
123 assert_eq!(account_line(Some(&refs), &names, OK), AccountLine { text: NO_AUTH.into(), unresolved: 1 });
124 }
125
126 #[test]
127 fn a_partly_resolvable_agent_shows_the_accounts_it_can_launch_with() {
128 let own = [acct("a1", "work")];
129 let names = names_by_id(&own, &[]);
130 let ls = [link("ag", "a1"), link("ag", "deleted")];
131 let refs: Vec<&AgentIdentityLink> = ls.iter().collect();
132 assert_eq!(account_line(Some(&refs), &names, OK), AccountLine { text: "work".into(), unresolved: 1 });
133 }
134
135 #[test]
136 fn the_global_mirror_resolves_what_this_channel_does_not_and_this_channel_wins() {
137 let own = [acct("a1", "own name")];
138 let mirror = [acct("a1", "mirror name"), acct("a2", "only in mirror")];
139 let names = names_by_id(&own, &mirror);
140 let ls = [link("ag", "a1"), link("ag", "a2")];
141 let refs: Vec<&AgentIdentityLink> = ls.iter().collect();
142 assert_eq!(account_line(Some(&refs), &names, OK).text, "only in mirror, own name");
143 }
144
145 #[test]
146 fn a_failed_links_lookup_claims_nothing() {
147 let names = names_by_id(&[], &[]);
148 let links_down = Degraded { links: true, accounts: false };
149 assert_eq!(account_line(None, &names, links_down).text, "", "unknown is not 'No auth'");
150 let own = [acct("a1", "work")];
152 let names = names_by_id(&own, &[]);
153 let ls = [link("ag", "a1")];
154 let refs: Vec<&AgentIdentityLink> = ls.iter().collect();
155 assert_eq!(account_line(Some(&refs), &names, links_down).text, "work");
156 }
157
158 #[test]
159 fn an_unreadable_accounts_store_makes_a_link_unknown_not_no_auth() {
160 let names = names_by_id(&[], &[]);
161 let ls = [link("ag", "perfectly-good")];
162 let refs: Vec<&AgentIdentityLink> = ls.iter().collect();
163 let accounts_down = Degraded { links: false, accounts: true };
164 assert_eq!(account_line(Some(&refs), &names, accounts_down), AccountLine { text: String::new(), unresolved: 0 });
166 assert_eq!(account_line(None, &names, accounts_down).text, NO_AUTH);
168 let own = [acct("perfectly-good", "work")];
170 let names = names_by_id(&own, &[]);
171 assert_eq!(account_line(Some(&refs), &names, accounts_down).text, "work");
172 }
173
174 #[test]
175 fn the_old_labels_are_gone() {
176 for old in ["(missing account)", "(ambient creds)", "(unknown account)"] {
177 assert!(!NO_AUTH.contains(old));
178 }
179 }
180}