1use std::path::{Path, PathBuf};
13
14use serde::{Deserialize, Serialize};
15
16#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ts_rs::TS)]
17#[ts(export, export_to = "../../../frontend/types/rpc/")]
18pub struct BrowserProfile {
19 pub id: String,
22 pub name: String,
23 pub color: String,
25 #[serde(default)]
26 #[ts(type = "number")]
27 pub created_at: i64,
28 #[serde(default)]
31 pub agents_allowed: bool,
32}
33
34const PROFILES_FILE_NAME: &str = "browser-profiles.json";
35pub const MAX_PROFILES: usize = 20;
36pub const MAX_NAME_CHARS: usize = 40;
37
38pub const PALETTE: [&str; 8] = ["#3b82f6", "#22c55e", "#f59e0b", "#ef4444", "#a855f7", "#14b8a6", "#ec4899", "#64748b"];
40
41pub fn profiles_file_path() -> Option<PathBuf> {
42 agentmux_common::DataPaths::from_env().map(|p| p.shared_dir.join(PROFILES_FILE_NAME))
43}
44
45pub fn read_profiles(path: &Path) -> Result<Vec<BrowserProfile>, String> {
49 if !path.exists() {
50 return Ok(Vec::new());
51 }
52 let content = std::fs::read_to_string(path).map_err(|e| format!("read browser profiles: {e}"))?;
53 if content.trim().is_empty() {
54 return Ok(Vec::new());
55 }
56 serde_json::from_str(&content).map_err(|e| format!("parse browser profiles: {e}"))
57}
58
59pub fn write_profiles(path: &Path, profiles: &[BrowserProfile]) -> Result<(), String> {
62 if let Some(parent) = path.parent() {
63 std::fs::create_dir_all(parent).map_err(|e| format!("mkdir: {e}"))?;
64 }
65 let json = serde_json::to_string_pretty(profiles).map_err(|e| format!("serialize browser profiles: {e}"))?;
66 let tmp = path.with_file_name(format!(".{PROFILES_FILE_NAME}.{}.tmp", uuid::Uuid::new_v4()));
67 std::fs::write(&tmp, &json).map_err(|e| format!("write browser profiles: {e}"))?;
68 std::fs::rename(&tmp, path).map_err(|e| {
69 let _ = std::fs::remove_file(&tmp);
70 format!("rename browser profiles: {e}")
71 })
72}
73
74pub fn is_profile_id(id: &str) -> bool {
76 (1..=64).contains(&id.len()) && id.bytes().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == b'-')
77}
78
79fn check_name(profiles: &[BrowserProfile], name: &str, except: Option<&str>) -> Result<String, String> {
80 let name = name.trim();
81 if name.is_empty() {
82 return Err("a profile needs a name".to_string());
83 }
84 if name.chars().count() > MAX_NAME_CHARS {
85 return Err(format!("a profile's name is at most {MAX_NAME_CHARS} characters"));
86 }
87 if ["personal", "incognito"].contains(&name.to_lowercase().as_str()) {
88 return Err(format!("{name:?} is taken: pick another name"));
89 }
90 if profiles.iter().any(|p| Some(p.id.as_str()) != except && p.name.eq_ignore_ascii_case(name)) {
91 return Err(format!("there is already a profile called {name:?}"));
92 }
93 Ok(name.to_string())
94}
95
96fn check_color(color: &str) -> Result<String, String> {
97 let c = color.trim().to_lowercase();
98 let hex = c.strip_prefix('#').unwrap_or("");
99 if hex.len() == 6 && hex.bytes().all(|b| b.is_ascii_hexdigit()) {
100 Ok(c)
101 } else {
102 Err(format!("{color:?} isn't a colour (#rrggbb)"))
103 }
104}
105
106pub fn add(profiles: &mut Vec<BrowserProfile>, name: &str, color: Option<&str>, now_ms: i64) -> Result<BrowserProfile, String> {
109 if profiles.len() >= MAX_PROFILES {
110 return Err(format!("at most {MAX_PROFILES} profiles"));
111 }
112 let name = check_name(profiles, name, None)?;
113 let color = match color {
114 Some(c) => check_color(c)?,
115 None => PALETTE[profiles.len() % PALETTE.len()].to_string(),
116 };
117 let id = format!("p-{}", &uuid::Uuid::new_v4().simple().to_string()[..12]);
118 let profile = BrowserProfile { id, name, color, created_at: now_ms, agents_allowed: false };
119 profiles.push(profile.clone());
120 Ok(profile)
121}
122
123pub fn update(
125 profiles: &mut [BrowserProfile],
126 id: &str,
127 name: Option<&str>,
128 color: Option<&str>,
129 agents_allowed: Option<bool>,
130) -> Result<(), String> {
131 let name = name.map(|n| check_name(profiles, n, Some(id))).transpose()?;
132 let color = color.map(check_color).transpose()?;
133 let p = profiles.iter_mut().find(|p| p.id == id).ok_or_else(|| format!("no profile {id:?}"))?;
134 if let Some(n) = name {
135 p.name = n;
136 }
137 if let Some(c) = color {
138 p.color = c;
139 }
140 if let Some(a) = agents_allowed {
141 p.agents_allowed = a;
142 }
143 Ok(())
144}
145
146pub fn remove(profiles: &mut Vec<BrowserProfile>, id: &str) -> Result<(), String> {
148 let before = profiles.len();
149 profiles.retain(|p| p.id != id);
150 if profiles.len() == before {
151 return Err(format!("no profile {id:?}"));
152 }
153 Ok(())
154}
155
156#[cfg(test)]
157mod tests {
158 use super::*;
159
160 #[test]
161 fn profiles_are_added_named_and_coloured() {
162 let mut ps = Vec::new();
163 let work = add(&mut ps, " Work ", None, 1).unwrap();
164 assert_eq!(work.name, "Work");
165 assert_eq!(work.color, PALETTE[0]);
166 assert!(is_profile_id(&work.id));
167 let client = add(&mut ps, "Client X", Some("#ABCDEF"), 2).unwrap();
168 assert_eq!(client.color, "#abcdef");
169 assert_ne!(work.id, client.id);
170 }
171
172 #[test]
173 fn bad_names_and_colours_are_refused() {
174 let mut ps = Vec::new();
175 add(&mut ps, "Work", None, 1).unwrap();
176 for bad in ["", " ", "work", "Personal", "incognito", &"x".repeat(MAX_NAME_CHARS + 1)] {
177 assert!(add(&mut ps, bad, None, 2).is_err(), "{bad:?}");
178 }
179 assert!(add(&mut ps, "Home", Some("blue"), 2).is_err());
180 assert!(add(&mut ps, "Home", Some("#12345"), 2).is_err());
181 }
182
183 #[test]
184 fn renaming_keeps_its_own_name_free_and_others_taken() {
185 let mut ps = Vec::new();
186 let a = add(&mut ps, "A", None, 1).unwrap();
187 add(&mut ps, "B", None, 1).unwrap();
188 update(&mut ps, &a.id, Some("a"), None, None).unwrap();
189 assert!(update(&mut ps, &a.id, Some("B"), None, None).is_err());
190 update(&mut ps, &a.id, None, Some("#000000"), None).unwrap();
191 assert_eq!(ps[0].color, "#000000");
192 assert!(update(&mut ps, "p-none", Some("C"), None, None).is_err());
193 }
194
195 #[test]
196 fn removing_and_the_cap() {
197 let mut ps = Vec::new();
198 for i in 0..MAX_PROFILES {
199 add(&mut ps, &format!("P{i}"), None, 1).unwrap();
200 }
201 assert!(add(&mut ps, "One more", None, 1).is_err());
202 let id = ps[0].id.clone();
203 remove(&mut ps, &id).unwrap();
204 assert!(remove(&mut ps, &id).is_err());
205 assert_eq!(ps.len(), MAX_PROFILES - 1);
206 }
207
208 #[test]
209 fn the_file_round_trips_and_a_missing_one_is_empty() {
210 let dir = tempfile::tempdir().unwrap();
211 let path = dir.path().join(PROFILES_FILE_NAME);
212 assert!(read_profiles(&path).unwrap().is_empty());
213 let mut ps = Vec::new();
214 add(&mut ps, "Work", None, 5).unwrap();
215 write_profiles(&path, &ps).unwrap();
216 assert_eq!(read_profiles(&path).unwrap(), ps);
217 std::fs::write(&path, "{not json").unwrap();
218 assert!(read_profiles(&path).is_err());
219 }
220}