1use std::fs;
2
3use hefesto_widgets::{Badge, BadgeAnchor, BadgeStack, PopupSize};
4use ratatui::style::Color;
5
6#[derive(serde::Deserialize)]
9struct BadgeInput {
10 text: String,
11 #[serde(default = "default_badge_color")]
12 color: String,
13 #[serde(default)]
14 anchor: Option<String>,
15 #[serde(default)]
16 layer: Option<u16>,
17 #[serde(default)]
18 width: Option<DimInput>,
19 #[serde(default)]
20 lines: Option<DimInput>,
21}
22
23fn default_badge_color() -> String {
24 "gray".to_string()
25}
26
27#[derive(serde::Deserialize)]
28#[serde(untagged)]
29enum DimInput {
30 Number(u16),
31 String(String),
32}
33
34impl BadgeInput {
35 fn to_badge_parts(&self) -> BadgeParts {
36 BadgeParts {
37 text: self.text.trim().to_string(),
38 color: parse_color(&self.color).unwrap_or(Color::Gray),
39 anchor: self.anchor.as_deref().and_then(parse_anchor),
40 layer: self.layer,
41 width: self.width.as_ref().map(dim_input_to_mode),
42 lines: self.lines.as_ref().map(dim_input_to_mode),
43 }
44 }
45}
46
47fn dim_input_to_mode(input: &DimInput) -> DimMode {
48 match input {
49 DimInput::Number(n) => DimMode::Fixed(*n),
50 DimInput::String(s) => parse_dim(s).unwrap_or(DimMode::Auto),
51 }
52}
53
54#[derive(clap::Args)]
57pub struct BadgeArgs {
58 #[arg(long = "badge", value_name = "FILE")]
60 pub badge_file: Option<String>,
61
62 #[arg(long, default_value = "0")]
64 pub badge_width: u16,
65 #[arg(long, default_value = "1")]
67 pub badge_lines: u16,
68
69 #[arg(long, default_value = "tl")]
71 pub badge_anchor: String,
72}
73
74impl BadgeArgs {
75 pub fn prepare_specs(&self) -> Vec<BadgeSpec> {
76 let file_path = match &self.badge_file {
77 Some(p) => p,
78 None => return vec![],
79 };
80
81 let content = match fs::read_to_string(file_path) {
82 Ok(c) => c,
83 Err(e) => {
84 eprintln!("{} badge: error al leer '{}': {}", crate::BIN_NAME, file_path, e);
85 std::process::exit(1);
86 }
87 };
88
89 let inputs: Vec<BadgeInput> = match serde_json::from_str(&content) {
90 Ok(v) => v,
91 Err(e) => {
92 eprintln!("{} badge: error al parsear JSON en '{}': {}", crate::BIN_NAME, file_path, e);
93 std::process::exit(1);
94 }
95 };
96
97 let global_anchor = parse_anchor(&self.badge_anchor).unwrap_or(BadgeAnchor::TopLeft);
98 let global_width = if self.badge_width > 0 { Some(DimMode::Fixed(self.badge_width)) } else { None };
99
100 inputs.iter()
101 .map(|input| {
102 let parts = input.to_badge_parts();
103 prepare_badge(parts, global_anchor, self.badge_lines, global_width)
104 })
105 .collect()
106 }
107}
108
109#[derive(Debug, Clone, Copy, PartialEq, Eq)]
112pub enum DimMode {
113 Auto,
114 Max(u16),
115 Fixed(u16),
116}
117
118pub struct BadgeParts {
119 pub text: String,
120 pub color: Color,
121 pub anchor: Option<BadgeAnchor>,
122 pub layer: Option<u16>,
123 pub width: Option<DimMode>,
124 pub lines: Option<DimMode>,
125}
126
127pub struct BadgeSpec {
128 pub display: String,
129 pub color: Color,
130 pub badge_w: u16,
131 pub lines: u16,
132 pub anchor: BadgeAnchor,
133 pub layer: u16,
134}
135
136impl BadgeSpec {
137 pub fn into_badge(&self) -> Badge<'_> {
138 let mut badge = if self.lines > 1 && self.display.contains('\n') {
139 let lines: Vec<ratatui::text::Line<'_>> =
140 self.display.split('\n').map(ratatui::text::Line::from).collect();
141 Badge::with_content(lines, self.color)
142 .width(PopupSize::Fixed(self.badge_w))
143 .height(PopupSize::Fixed(self.lines + 2))
144 .closable(false)
145 } else {
146 Badge::new(&self.display, self.color)
147 .width(PopupSize::Fixed(self.badge_w))
148 .height(PopupSize::Fixed(self.lines + 2))
149 .closable(false)
150 };
151 badge = badge.anchor(self.anchor).layer(self.layer);
152 badge
153 }
154}
155
156pub fn parse_color(s: &str) -> Option<Color> {
159 match s.to_lowercase().as_str() {
160 "green" => Some(Color::Green),
161 "red" => Some(Color::Red),
162 "yellow" => Some(Color::Yellow),
163 "blue" => Some(Color::Blue),
164 "cyan" => Some(Color::Cyan),
165 "magenta" => Some(Color::Magenta),
166 "white" => Some(Color::White),
167 "black" => Some(Color::Black),
168 "gray" | "grey" => Some(Color::Gray),
169 "darkgray" | "darkgrey" => Some(Color::DarkGray),
170 "lightred" => Some(Color::LightRed),
171 "lightgreen" => Some(Color::LightGreen),
172 "lightyellow" => Some(Color::LightYellow),
173 "lightblue" => Some(Color::LightBlue),
174 "lightmagenta" => Some(Color::LightMagenta),
175 "lightcyan" => Some(Color::LightCyan),
176 _ => None,
177 }
178}
179
180pub fn parse_anchor(s: &str) -> Option<BadgeAnchor> {
181 match s.to_lowercase().as_str() {
182 "tl" | "top-left" => Some(BadgeAnchor::TopLeft),
183 "t" | "top" => Some(BadgeAnchor::Top),
184 "tr" | "top-right" => Some(BadgeAnchor::TopRight),
185 "bl" | "bottom-left" => Some(BadgeAnchor::BottomLeft),
186 "b" | "bottom" => Some(BadgeAnchor::Bottom),
187 "br" | "bottom-right" => Some(BadgeAnchor::BottomRight),
188 "l" | "left" => Some(BadgeAnchor::Left),
189 "r" | "right" => Some(BadgeAnchor::Right),
190 _ => None,
191 }
192}
193
194pub fn parse_anchor_and_layer(s: &str) -> (Option<BadgeAnchor>, Option<u16>) {
195 if let Some((anchor_part, layer_part)) = s.split_once('@') {
196 (parse_anchor(anchor_part), layer_part.parse::<u16>().ok())
197 } else {
198 (parse_anchor(s), None)
199 }
200}
201
202fn parse_dim(s: &str) -> Option<DimMode> {
203 if s.is_empty() || s.eq_ignore_ascii_case("a") {
204 Some(DimMode::Auto)
205 } else if let Some(rest) = s.strip_suffix(['a', 'A']) {
206 rest.parse::<u16>().ok().map(DimMode::Max)
207 } else {
208 s.parse::<u16>().ok().map(DimMode::Fixed)
209 }
210}
211
212pub fn parse_dimensions(s: &str) -> (Option<DimMode>, Option<DimMode>) {
213 if s.is_empty() {
214 return (None, None);
215 }
216 if let Some((w, l)) = s.split_once('x') {
217 let w = if w.is_empty() { Some(DimMode::Auto) } else { parse_dim(w) };
218 let l = if l.is_empty() { Some(DimMode::Auto) } else { parse_dim(l) };
219 (w, l)
220 } else {
221 (parse_dim(s), None)
222 }
223}
224
225
226fn truncate_with_ellipsis(text: &str, max_len: usize) -> String {
229 if text.len() <= max_len {
230 return text.to_string();
231 }
232 let mut t: String = text.chars().take(max_len.saturating_sub(1)).collect();
233 t.push('…');
234 t
235}
236
237fn wrap_text(text: &str, content_w: usize, max_lines: u16) -> String {
238 let mut result = String::new();
239 let mut remaining = text;
240 for i in 0..max_lines as usize {
241 if remaining.is_empty() { break; }
242 let chunk_len = remaining.len().min(content_w);
243 let (chunk, rest) = remaining.split_at(chunk_len);
244 if i > 0 { result.push('\n'); }
245 if i == max_lines as usize - 1 && !rest.is_empty() {
246 let mut t: String = chunk.chars().take(content_w.saturating_sub(1)).collect();
247 t.push('…');
248 result.push_str(&t);
249 } else {
250 result.push_str(chunk);
251 }
252 remaining = rest;
253 }
254 result
255}
256
257fn resolve_width(dim: Option<DimMode>, global: Option<DimMode>, text_len: u16) -> u16 {
260 match dim.or(global).unwrap_or(DimMode::Auto) {
261 DimMode::Fixed(w) => w,
262 DimMode::Max(w) => (text_len + 2).min(w),
263 DimMode::Auto => text_len + 2,
264 }
265}
266
267fn resolve_lines(dim: Option<DimMode>, global_lines: u16) -> u16 {
268 match dim {
269 Some(DimMode::Fixed(n)) => n,
270 Some(DimMode::Max(n)) => n,
271 Some(DimMode::Auto) => global_lines,
272 None => global_lines,
273 }
274}
275
276pub fn prepare_badge(
277 parts: BadgeParts,
278 global_anchor: BadgeAnchor,
279 global_lines: u16,
280 global_width: Option<DimMode>,
281) -> BadgeSpec {
282 let text = parts.text.trim().to_string();
283 let anchor = parts.anchor.unwrap_or(global_anchor);
284 let layer = parts.layer.unwrap_or(0);
285 let lines = resolve_lines(parts.lines, global_lines);
286 let badge_w = resolve_width(parts.width, global_width, text.len() as u16);
287 let content_w = badge_w.saturating_sub(2) as usize;
288
289 let display = if text.len() <= content_w {
290 text
291 } else if lines > 1 {
292 wrap_text(&text, content_w, lines)
293 } else {
294 truncate_with_ellipsis(&text, content_w)
295 };
296
297 BadgeSpec { display, color: parts.color, badge_w, lines, anchor, layer }
298}
299
300pub fn build_badge_stack(specs: &[BadgeSpec]) -> BadgeStack<'_> {
301 let mut stack = BadgeStack::new();
302 for spec in specs {
303 stack = stack.push(spec.into_badge());
304 }
305 stack
306}
307
308#[cfg(test)]
311mod tests {
312 use super::*;
313
314 #[test]
317 fn parse_color_green() {
318 assert_eq!(parse_color("green"), Some(Color::Green));
319 }
320
321 #[test]
322 fn parse_color_case_insensitive() {
323 assert_eq!(parse_color("RED"), Some(Color::Red));
324 assert_eq!(parse_color("Cyan"), Some(Color::Cyan));
325 }
326
327 #[test]
328 fn parse_color_unknown_returns_none() {
329 assert_eq!(parse_color("pink"), None);
330 assert_eq!(parse_color(""), None);
331 }
332
333 #[test]
334 fn parse_color_gray_aliases() {
335 assert_eq!(parse_color("gray"), Some(Color::Gray));
336 assert_eq!(parse_color("grey"), Some(Color::Gray));
337 }
338
339 #[test]
342 fn parse_anchor_tl() {
343 assert_eq!(parse_anchor("tl"), Some(BadgeAnchor::TopLeft));
344 }
345
346 #[test]
347 fn parse_anchor_tr() {
348 assert_eq!(parse_anchor("tr"), Some(BadgeAnchor::TopRight));
349 }
350
351 #[test]
352 fn parse_anchor_bl() {
353 assert_eq!(parse_anchor("bl"), Some(BadgeAnchor::BottomLeft));
354 }
355
356 #[test]
357 fn parse_anchor_br() {
358 assert_eq!(parse_anchor("br"), Some(BadgeAnchor::BottomRight));
359 }
360
361 #[test]
362 fn parse_anchor_invalid() {
363 assert_eq!(parse_anchor("xyz"), None);
364 }
365
366 #[test]
369 fn parse_anchor_only() {
370 let (a, l) = parse_anchor_and_layer("tl");
371 assert_eq!(a, Some(BadgeAnchor::TopLeft));
372 assert!(l.is_none());
373 }
374
375 #[test]
376 fn parse_anchor_with_layer() {
377 let (a, l) = parse_anchor_and_layer("tr@1");
378 assert_eq!(a, Some(BadgeAnchor::TopRight));
379 assert_eq!(l, Some(1));
380 }
381
382 #[test]
383 fn parse_anchor_with_layer_two() {
384 let (a, l) = parse_anchor_and_layer("bl@2");
385 assert_eq!(a, Some(BadgeAnchor::BottomLeft));
386 assert_eq!(l, Some(2));
387 }
388
389 #[test]
390 fn parse_anchor_invalid_layer_defaults() {
391 let (a, l) = parse_anchor_and_layer("tr@abc");
392 assert_eq!(a, Some(BadgeAnchor::TopRight));
393 assert!(l.is_none());
394 }
395
396 #[test]
399 fn parse_dim_width_only() {
400 let (w, l) = parse_dimensions("20");
401 assert_eq!(w, Some(DimMode::Fixed(20)));
402 assert!(l.is_none());
403 }
404
405 #[test]
406 fn parse_dim_width_and_lines() {
407 let (w, l) = parse_dimensions("20x2");
408 assert_eq!(w, Some(DimMode::Fixed(20)));
409 assert_eq!(l, Some(DimMode::Fixed(2)));
410 }
411
412 #[test]
413 fn parse_dim_auto_up_to() {
414 let (w, l) = parse_dimensions("20a");
415 assert_eq!(w, Some(DimMode::Max(20)));
416 assert!(l.is_none());
417 }
418
419 #[test]
420 fn parse_dim_full_auto() {
421 let (w, l) = parse_dimensions("a");
422 assert_eq!(w, Some(DimMode::Auto));
423 assert!(l.is_none());
424 }
425
426 #[test]
427 fn parse_dim_both_auto_up_to() {
428 let (w, l) = parse_dimensions("20ax3a");
429 assert_eq!(w, Some(DimMode::Max(20)));
430 assert_eq!(l, Some(DimMode::Max(3)));
431 }
432
433 #[test]
436 fn badge_args_prepare_specs_empty_when_no_file() {
437 let args = BadgeArgs {
438 badge_file: None,
439 badge_width: 0,
440 badge_lines: 1,
441 badge_anchor: "tl".to_string(),
442 };
443 let specs = args.prepare_specs();
444 assert!(specs.is_empty());
445 }
446
447 #[test]
448 fn badge_args_prepare_specs_from_json() {
449 let dir = std::env::temp_dir().join("pandora_test_badge");
450 let _ = std::fs::create_dir_all(&dir);
451 let path = dir.join("badges.json");
452 std::fs::write(&path, r#"[
453 {"text": "prod", "color": "red", "anchor": "tr"},
454 {"text": "v2.1.0", "color": "blue", "anchor": "bl"}
455 ]"#).unwrap();
456
457 let args = BadgeArgs {
458 badge_file: Some(path.to_str().unwrap().to_string()),
459 badge_width: 0,
460 badge_lines: 1,
461 badge_anchor: "tr".to_string(),
462 };
463 let specs = args.prepare_specs();
464 assert_eq!(specs.len(), 2);
465 assert_eq!(specs[0].anchor, BadgeAnchor::TopRight);
466 assert_eq!(specs[0].layer, 0);
467 assert_eq!(specs[0].display, "prod");
468
469 let _ = std::fs::remove_dir_all(&dir);
470 }
471
472 #[test]
475 fn badge_input_minimal() {
476 let input: BadgeInput = serde_json::from_str(r#"{"text":"main"}"#).unwrap();
477 let parts = input.to_badge_parts();
478 assert_eq!(parts.text, "main");
479 assert_eq!(parts.color, Color::Gray);
480 assert!(parts.anchor.is_none());
481 assert!(parts.layer.is_none());
482 assert!(parts.width.is_none());
483 assert!(parts.lines.is_none());
484 }
485
486 #[test]
487 fn badge_input_all_fields() {
488 let input: BadgeInput = serde_json::from_str(
489 r#"{"text":"prod","color":"red","anchor":"tr","layer":2,"width":20,"lines":3}"#,
490 )
491 .unwrap();
492 let parts = input.to_badge_parts();
493 assert_eq!(parts.text, "prod");
494 assert_eq!(parts.color, Color::Red);
495 assert_eq!(parts.anchor, Some(BadgeAnchor::TopRight));
496 assert_eq!(parts.layer, Some(2));
497 assert_eq!(parts.width, Some(DimMode::Fixed(20)));
498 assert_eq!(parts.lines, Some(DimMode::Fixed(3)));
499 }
500
501 #[test]
502 fn badge_input_color_defaults_to_gray() {
503 let input: BadgeInput = serde_json::from_str(r#"{"text":"x"}"#).unwrap();
504 assert_eq!(input.color, "gray");
505 }
506
507 #[test]
508 fn badge_input_unknown_color_falls_back_to_gray() {
509 let input: BadgeInput = serde_json::from_str(r#"{"text":"x","color":"pink"}"#).unwrap();
510 assert_eq!(input.to_badge_parts().color, Color::Gray);
511 }
512
513 #[test]
514 fn badge_input_layers_separate_from_anchor() {
515 let input: BadgeInput =
516 serde_json::from_str(r#"{"text":"x","anchor":"tr","layer":1}"#).unwrap();
517 let parts = input.to_badge_parts();
518 assert_eq!(parts.anchor, Some(BadgeAnchor::TopRight));
519 assert_eq!(parts.layer, Some(1));
520 }
521
522 #[test]
525 fn dim_input_number_fixed() {
526 let d: DimInput = serde_json::from_str("20").unwrap();
527 assert_eq!(dim_input_to_mode(&d), DimMode::Fixed(20));
528 }
529
530 #[test]
531 fn dim_input_string_max() {
532 let d: DimInput = serde_json::from_str(r#""20a""#).unwrap();
533 assert_eq!(dim_input_to_mode(&d), DimMode::Max(20));
534 }
535
536 #[test]
537 fn dim_input_string_auto() {
538 let d: DimInput = serde_json::from_str(r#""auto""#).unwrap();
539 assert_eq!(dim_input_to_mode(&d), DimMode::Auto);
540 }
541
542 #[test]
543 fn truncate_fits_no_change() {
544 assert_eq!(truncate_with_ellipsis("hello", 10), "hello");
545 }
546
547 #[test]
548 fn truncate_adds_ellipsis() {
549 assert_eq!(truncate_with_ellipsis("hello world", 6), "hello…");
550 }
551
552 #[test]
555 fn wrap_text_single_line() {
556 assert_eq!(wrap_text("hello", 10, 1), "hello");
557 }
558
559 #[test]
560 fn wrap_text_multi_chunks() {
561 let result = wrap_text("hello world", 5, 3);
562 assert_eq!(result, "hello\n worl\nd");
563 }
564
565 #[test]
566 fn wrap_text_last_line_ellipsis() {
567 let result = wrap_text("hello world foo", 5, 2);
568 assert_eq!(result, "hello\n wor…");
569 }
570
571 fn badge_parts(text: &str, color: Color, width: Option<DimMode>) -> BadgeParts {
574 BadgeParts { text: text.to_string(), color, anchor: None, layer: None, width, lines: None }
575 }
576
577 #[test]
578 fn prepare_badge_auto_width() {
579 let spec = prepare_badge(badge_parts("prod", Color::Red, None), BadgeAnchor::TopLeft, 1, None);
580 assert_eq!(spec.display, "prod");
581 assert_eq!(spec.badge_w, 6);
582 assert_eq!(spec.layer, 0);
583 }
584
585 #[test]
586 fn prepare_badge_fixed_width_truncates() {
587 let spec = prepare_badge(badge_parts("hello world", Color::Red, Some(DimMode::Fixed(10))), BadgeAnchor::TopLeft, 1, None);
588 assert_eq!(spec.display, "hello w…");
589 assert_eq!(spec.badge_w, 10);
590 }
591
592 #[test]
593 fn prepare_badge_auto_up_to() {
594 let spec = prepare_badge(badge_parts("prod", Color::Red, Some(DimMode::Max(20))), BadgeAnchor::TopLeft, 1, None);
595 assert_eq!(spec.badge_w, 6);
596 }
597
598 #[test]
599 fn prepare_badge_trims_input() {
600 let trimmed = prepare_badge(
601 badge_parts(" spaced ", Color::Red, None),
602 BadgeAnchor::TopLeft, 1, None,
603 );
604 assert_eq!(trimmed.display, "spaced");
605 }
606
607 #[test]
608 fn prepare_badge_respects_layer() {
609 let parts = BadgeParts {
610 text: "main".to_string(),
611 color: Color::Red,
612 anchor: Some(BadgeAnchor::TopRight),
613 layer: Some(2),
614 width: None,
615 lines: None,
616 };
617 let spec = prepare_badge(parts, BadgeAnchor::TopLeft, 1, None);
618 assert_eq!(spec.anchor, BadgeAnchor::TopRight);
619 assert_eq!(spec.layer, 2);
620 }
621
622 #[test]
623 fn prepare_badge_inherits_global_anchor() {
624 let parts = BadgeParts {
625 text: "main".to_string(),
626 color: Color::Red,
627 anchor: None,
628 layer: None,
629 width: None,
630 lines: None,
631 };
632 let spec = prepare_badge(parts, BadgeAnchor::BottomRight, 1, None);
633 assert_eq!(spec.anchor, BadgeAnchor::BottomRight);
634 assert_eq!(spec.layer, 0);
635 }
636
637}
638
639