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