agentmux_srv/backend/wconfig/
loader.rs1use std::collections::HashMap;
7use std::path::PathBuf;
8
9use super::types::{ConfigError, FullConfigType, WidgetConfigType};
10
11pub fn build_default_config() -> FullConfigType {
18 let mut config = FullConfigType::default();
19
20 const WIDGETS_JSON: &str =
22 include_str!("../../config/widgets.json");
23
24 match serde_json::from_str::<HashMap<String, WidgetConfigType>>(WIDGETS_JSON) {
25 Ok(widgets) => {
26 validate_widget_configs(&widgets);
27 config.widgets = widgets;
28 }
29 Err(e) => {
30 eprintln!("wconfig: failed to parse embedded widgets.json: {}", e);
31 }
32 }
33
34 config
35}
36
37pub(crate) fn validate_widget_configs(widgets: &HashMap<String, WidgetConfigType>) {
42 for (key, widget) in widgets {
43 let has_blockdef = !widget.block_def.meta.is_empty();
44 let has_children = !widget.children.is_empty();
45 if has_blockdef && has_children {
46 eprintln!(
47 "wconfig: widget \"{}\" has both a blockdef and children — children (parent behavior) takes precedence",
48 key
49 );
50 } else if !has_blockdef && !has_children {
51 eprintln!(
52 "wconfig: widget \"{}\" has neither a blockdef nor children — it can't open a pane or expand a submenu",
53 key
54 );
55 }
56 }
57}
58
59pub fn read_config_file<T: serde::de::DeserializeOwned + Default>(
63 path: &PathBuf,
64) -> (T, Vec<ConfigError>) {
65 let mut errors = Vec::new();
66
67 let content = match std::fs::read_to_string(path) {
68 Ok(c) => c,
69 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return (T::default(), errors),
70 Err(e) => {
71 errors.push(ConfigError {
72 file: path.to_string_lossy().to_string(),
73 err: format!("cannot read file: {}", e),
74 });
75 return (T::default(), errors);
76 }
77 };
78
79 let stripped = json_comments::StripComments::new(content.as_bytes());
81 let mut json_bytes = Vec::new();
82 std::io::Read::read_to_end(&mut std::io::BufReader::new(stripped), &mut json_bytes)
83 .unwrap_or_default();
84
85 let json_str = strip_trailing_commas(&String::from_utf8_lossy(&json_bytes));
87 let clean: Result<T, _> = serde_json::from_str(&json_str);
88
89 match clean {
90 Ok(parsed) => (parsed, errors),
91 Err(e) => {
92 errors.push(ConfigError {
93 file: path.to_string_lossy().to_string(),
94 err: format!("JSON parse error: {}", e),
95 });
96 (T::default(), errors)
97 }
98 }
99}
100
101pub fn read_settings_raw_jsonc(path: &std::path::Path) -> serde_json::Map<String, serde_json::Value> {
104 if !path.exists() {
105 return serde_json::Map::new();
106 }
107 match std::fs::read_to_string(path) {
108 Ok(content) => parse_jsonc_to_map(&content),
109 Err(_) => serde_json::Map::new(),
110 }
111}
112
113pub fn parse_jsonc_to_map(content: &str) -> serde_json::Map<String, serde_json::Value> {
115 let stripped_comments = json_comments::StripComments::new(content.as_bytes());
116 let mut json_bytes = Vec::new();
117 std::io::Read::read_to_end(&mut std::io::BufReader::new(stripped_comments), &mut json_bytes)
118 .unwrap_or_default();
119 let json_str = strip_trailing_commas(&String::from_utf8_lossy(&json_bytes));
120 match serde_json::from_str::<serde_json::Value>(&json_str) {
121 Ok(serde_json::Value::Object(map)) => map,
122 _ => serde_json::Map::new(),
123 }
124}
125
126pub fn merge_into_template(
135 template: &str,
136 user_settings: &serde_json::Map<String, serde_json::Value>,
137) -> String {
138 if user_settings.is_empty() {
139 return template.to_string();
140 }
141
142 let mut remaining: std::collections::HashMap<&str, &serde_json::Value> =
143 user_settings.iter().map(|(k, v)| (k.as_str(), v)).collect();
144 let mut lines: Vec<String> = Vec::new();
145
146 for line in template.lines() {
147 if let Some(key) = extract_commented_setting_key(line) {
148 if let Some(value) = remaining.remove(key) {
149 let indent: String = line.chars().take_while(|c| c.is_whitespace()).collect();
151 let val_str = serde_json::to_string(value).unwrap_or_default();
152 lines.push(format!("{}\"{}\": {},", indent, key, val_str));
153 continue;
154 }
155 }
156 lines.push(line.to_string());
157 }
158
159 if !remaining.is_empty() {
161 if let Some(brace_pos) = lines.iter().rposition(|l| l.trim() == "}") {
163 let mut extra: Vec<String> = Vec::new();
164 extra.push(String::new());
165 extra.push(" // -- User Overrides --".to_string());
166 let mut sorted_keys: Vec<&&str> = remaining.keys().collect();
167 sorted_keys.sort();
168 for key in sorted_keys {
169 let value = remaining[*key];
170 let val_str = serde_json::to_string(value).unwrap_or_default();
171 extra.push(format!(" \"{}\": {},", key, val_str));
172 }
173 for (i, line) in extra.into_iter().enumerate() {
174 lines.insert(brace_pos + i, line);
175 }
176 }
177 }
178
179 let mut result = lines.join("\n");
180 if !result.ends_with('\n') {
182 result.push('\n');
183 }
184 result
185}
186
187fn extract_commented_setting_key(line: &str) -> Option<&str> {
191 let trimmed = line.trim_start();
192 let rest = trimmed.strip_prefix("//")?;
193 let rest = rest.trim_start();
194 let rest = rest.strip_prefix('"')?;
195 let end = rest.find('"')?;
196 Some(&rest[..end])
197}
198
199pub(super) fn strip_trailing_commas(input: &str) -> String {
200 let mut result = String::with_capacity(input.len());
201 let mut chars = input.chars().peekable();
202 let mut in_string = false;
203 let mut last_comma_pos: Option<usize> = None;
204
205 while let Some(ch) = chars.next() {
206 if in_string {
207 result.push(ch);
208 if ch == '\\' {
209 if let Some(&next) = chars.peek() {
210 result.push(next);
211 chars.next();
212 }
213 } else if ch == '"' {
214 in_string = false;
215 }
216 } else {
217 match ch {
218 '"' => {
219 in_string = true;
220 last_comma_pos = None;
221 result.push(ch);
222 }
223 ',' => {
224 last_comma_pos = Some(result.len());
225 result.push(ch);
226 }
227 '}' | ']' => {
228 if let Some(pos) = last_comma_pos {
229 result.replace_range(pos..pos + 1, " ");
230 }
231 last_comma_pos = None;
232 result.push(ch);
233 }
234 c if c.is_whitespace() => {
235 result.push(ch);
236 }
237 _ => {
238 last_comma_pos = None;
239 result.push(ch);
240 }
241 }
242 }
243 }
244 result
245}
246
247#[allow(dead_code)]
249pub fn expand_env_vars(s: &str) -> String {
250 let mut result = s.to_string();
251 let mut start = 0;
252
253 while let Some(idx) = result[start..].find("$ENV:") {
254 let abs_idx = start + idx;
255 let rest = &result[abs_idx + 5..];
256
257 let end = rest
259 .find(|c: char| !c.is_ascii_alphanumeric() && c != '_' && c != ':')
260 .unwrap_or(rest.len());
261
262 let var_spec = &rest[..end];
263
264 let (var_name, fallback) = if let Some(colon_idx) = var_spec.find(':') {
266 (&var_spec[..colon_idx], Some(&var_spec[colon_idx + 1..]))
267 } else {
268 (var_spec, None)
269 };
270
271 let value = std::env::var(var_name).unwrap_or_else(|_| {
272 fallback.unwrap_or("").to_string()
273 });
274
275 let full_pattern = format!("$ENV:{}", var_spec);
276 result = result.replacen(&full_pattern, &value, 1);
277 start = abs_idx + value.len();
278 }
279
280 result
281}
282
283#[cfg(test)]
284mod tests {
285 use super::*;
286
287 fn leaf_widget() -> WidgetConfigType {
288 WidgetConfigType {
289 block_def: super::super::types::BlockDef {
290 meta: std::collections::HashMap::from([("view".to_string(), serde_json::json!("browser"))]),
291 ..Default::default()
292 },
293 ..Default::default()
294 }
295 }
296
297 fn parent_widget(children: &[&str]) -> WidgetConfigType {
298 WidgetConfigType {
299 children: children.iter().map(|s| s.to_string()).collect(),
300 ..Default::default()
301 }
302 }
303
304 #[test]
313 fn test_widget_module_survives_parse_and_serialize() {
314 let raw = r#"{"label":"Hello","module":"hello/index.js","blockdef":{"meta":{"view":"ext:hello"}}}"#;
315 let widget: WidgetConfigType = serde_json::from_str(raw).expect("parses");
316 assert_eq!(widget.module, "hello/index.js");
317 let back = serde_json::to_value(&widget).expect("serializes");
318 assert_eq!(back["module"], "hello/index.js");
319 let plain = serde_json::to_value(leaf_widget()).expect("serializes");
321 assert!(plain.get("module").is_none());
322 }
323
324 #[test]
325 fn test_validate_widget_configs_does_not_panic() {
326 let widgets: HashMap<String, WidgetConfigType> = HashMap::from([
327 ("defwidget@leaf".to_string(), leaf_widget()),
328 ("defwidget@parent".to_string(), parent_widget(&["leaf"])),
329 ("defwidget@neither".to_string(), WidgetConfigType::default()),
330 (
331 "defwidget@both".to_string(),
332 WidgetConfigType {
333 children: vec!["leaf".to_string()],
334 ..leaf_widget()
335 },
336 ),
337 ]);
338 validate_widget_configs(&widgets);
339 }
340
341 #[test]
342 fn test_build_default_config_loads_messengers_group() {
343 let config = build_default_config();
344 let messengers = config
345 .widgets
346 .get("defwidget@messengers")
347 .expect("defwidget@messengers should be in the embedded widgets.json");
348 assert_eq!(
349 messengers.children,
350 vec!["discord", "slack", "telegram", "whatsapp", "teams"]
351 );
352 assert!(
353 messengers.block_def.meta.is_empty(),
354 "a parent widget shouldn't carry a blockdef"
355 );
356
357 for short_name in ["discord", "slack", "telegram", "whatsapp", "teams"] {
358 let key = format!("defwidget@{short_name}");
359 let child = config
360 .widgets
361 .get(&key)
362 .unwrap_or_else(|| panic!("{key} should still be in widgets.json"));
363 assert!(child.display_grouponly, "{key} should be display:grouponly now that it's grouped");
364 assert!(child.children.is_empty(), "{key} is a leaf, not itself a parent");
365 assert!(!child.block_def.meta.is_empty(), "{key} should still carry its own blockdef");
366 }
367 }
368
369 #[test]
370 fn a_widget_saved_with_the_old_display_hidden_name_still_reads_as_group_only() {
371 let old: WidgetConfigType = serde_json::from_str(r#"{ "display:hidden": true }"#).unwrap();
372 assert!(old.display_grouponly);
373 let new: WidgetConfigType = serde_json::from_str(r#"{ "display:grouponly": true }"#).unwrap();
374 assert!(new.display_grouponly);
375 assert_eq!(serde_json::to_value(&old).unwrap()["display:grouponly"], true);
377 }
378}