1use ratatui::Frame;
11use ratatui::layout::Rect;
12use ratatui::style::{Color, Style};
13use ratatui::text::{Line, Span};
14use ratatui::widgets::Paragraph;
15
16use crate::search::{StructuredFilter, number_query};
17
18#[derive(Debug, Clone, Default)]
20pub struct FilterRow {
21 pub query: Option<String>,
23 pub structured: StructuredFilter,
25}
26
27impl FilterRow {
28 pub fn describe(&self) -> Option<String> {
33 let mut segments = Vec::new();
34 if let Some(q) = self.query.as_deref().filter(|q| !q.is_empty()) {
35 segments.push(match number_query(q) {
36 Some(n) => format!("#{n}"),
37 None => format!("`{q}`"),
38 });
39 }
40 let criteria = self.structured.to_string();
41 if criteria != "(no active criteria)" {
42 segments.push(format!("({criteria})"));
43 }
44 if segments.is_empty() {
45 None
46 } else {
47 Some(format!("Filters: {}", segments.join(" ")))
48 }
49 }
50
51 pub fn render(&self, f: &mut Frame, area: Rect) {
52 if area.height == 0 || area.width == 0 {
53 return;
54 }
55 let text = self
56 .describe()
57 .unwrap_or_else(|| "Filters: (none)".to_string());
58 let para = Paragraph::new(Line::from(Span::styled(
59 text,
60 Style::default().fg(Color::Cyan),
61 )));
62 f.render_widget(para, area);
63 }
64}
65
66#[cfg(test)]
67mod tests {
68 use super::*;
69 use ratatui::Terminal;
70 use ratatui::backend::TestBackend;
71
72 fn render_row(row: &FilterRow, w: u16, h: u16) -> String {
73 let backend = TestBackend::new(w, h);
74 let mut terminal = Terminal::new(backend).unwrap();
75 terminal.draw(|f| row.render(f, f.area())).unwrap();
76 let buffer = terminal.backend().buffer();
77 buffer.content.iter().map(|c| c.symbol()).collect()
78 }
79
80 #[test]
81 fn describe_query_only() {
82 let row = FilterRow {
83 query: Some("target".into()),
84 structured: StructuredFilter::default(),
85 };
86 assert_eq!(row.describe().as_deref(), Some("Filters: `target`"));
87 }
88
89 #[test]
90 fn describe_numeric_query_renders_issue_reference() {
91 let row = FilterRow {
92 query: Some("55".into()),
93 structured: StructuredFilter::default(),
94 };
95 assert_eq!(row.describe().as_deref(), Some("Filters: #55"));
96 }
97
98 #[test]
99 fn describe_criteria_only_shows_criteria() {
100 let row = FilterRow {
101 query: None,
102 structured: StructuredFilter {
103 status: Some("todo".to_string()),
104 ..Default::default()
105 },
106 };
107 assert_eq!(row.describe().as_deref(), Some("Filters: (status:todo)"));
108 }
109
110 #[test]
111 fn describe_new_fields_in_display_order() {
112 let row = FilterRow {
113 query: None,
114 structured: StructuredFilter {
115 cycle: Some("Cycle 1".to_string()),
116 milestone: Some("Backlog".to_string()),
117 reviewer: Some("alice".to_string()),
118 start_date: Some("2026-09-01".to_string()),
119 due_date: Some("2026-09-10".to_string()),
120 overdue: Some(true),
121 ..Default::default()
122 },
123 };
124 assert_eq!(
125 row.describe().as_deref(),
126 Some(
127 "Filters: (cycle:Cycle 1, milestone:Backlog, reviewer:alice, start_date:2026-09-01, due_date:2026-09-10, overdue:true)"
128 )
129 );
130 }
131
132 #[test]
133 fn describe_query_and_criteria_both() {
134 let row = FilterRow {
135 query: Some("55".into()),
136 structured: StructuredFilter {
137 status: Some("todo".to_string()),
138 ..Default::default()
139 },
140 };
141 assert_eq!(
142 row.describe().as_deref(),
143 Some("Filters: #55 (status:todo)")
144 );
145 }
146
147 #[test]
148 fn describe_empty_layers_is_none() {
149 let row = FilterRow {
150 query: None,
151 structured: StructuredFilter::default(),
152 };
153 assert_eq!(row.describe(), None);
154 }
155
156 #[test]
157 fn render_shows_the_described_filters() {
158 let row = FilterRow {
159 query: Some("target".into()),
160 structured: StructuredFilter::default(),
161 };
162 let content = render_row(&row, 80, 1);
163 assert!(content.contains("`target`"), "got {content:?}");
164 }
165
166 #[test]
167 fn render_smoke_tiny_and_tall() {
168 let _ = render_row(
170 &FilterRow {
171 query: Some("55".into()),
172 structured: StructuredFilter::default(),
173 },
174 4,
175 1,
176 );
177 let row = FilterRow::default();
178 let content = render_row(&row, 80, 1);
179 assert!(content.starts_with("Filters:"), "got {content:?}");
180 }
181}