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!("Search: {}", 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(|| "Search: (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 #[allow(clippy::wildcard_imports)]
69 use super::*;
70 use ratatui::Terminal;
71 use ratatui::backend::TestBackend;
72
73 fn render_row(row: &FilterRow, w: u16, h: u16) -> String {
74 let backend = TestBackend::new(w, h);
75 let mut terminal = Terminal::new(backend).unwrap();
76 terminal.draw(|f| row.render(f, f.area())).unwrap();
77 let buffer = terminal.backend().buffer();
78 buffer.content.iter().map(|c| c.symbol()).collect()
79 }
80
81 #[test]
82 fn describe_query_only() {
83 let row = FilterRow {
84 query: Some("target".into()),
85 structured: StructuredFilter::default(),
86 };
87 assert_eq!(row.describe().as_deref(), Some("Search: `target`"));
88 }
89
90 #[test]
91 fn describe_numeric_query_renders_issue_reference() {
92 let row = FilterRow {
93 query: Some("55".into()),
94 structured: StructuredFilter::default(),
95 };
96 assert_eq!(row.describe().as_deref(), Some("Search: #55"));
97 }
98
99 #[test]
100 fn describe_criteria_only_shows_criteria() {
101 let row = FilterRow {
102 query: None,
103 structured: StructuredFilter {
104 status: Some("todo".to_string()),
105 ..Default::default()
106 },
107 };
108 assert_eq!(row.describe().as_deref(), Some("Search: (status:todo)"));
109 }
110
111 #[test]
112 fn describe_new_fields_in_display_order() {
113 let row = FilterRow {
114 query: None,
115 structured: StructuredFilter {
116 cycle: Some("Cycle 1".to_string()),
117 milestone: Some("Backlog".to_string()),
118 reviewer: Some("alice".to_string()),
119 start_date: Some("2026-09-01".to_string()),
120 due_date: Some("2026-09-10".to_string()),
121 overdue: Some(true),
122 ..Default::default()
123 },
124 };
125 assert_eq!(
126 row.describe().as_deref(),
127 Some(
128 "Search: (cycle:Cycle 1, milestone:Backlog, reviewer:alice, start_date:2026-09-01, due_date:2026-09-10, overdue:true)"
129 )
130 );
131 }
132
133 #[test]
134 fn describe_query_and_criteria_both() {
135 let row = FilterRow {
136 query: Some("55".into()),
137 structured: StructuredFilter {
138 status: Some("todo".to_string()),
139 ..Default::default()
140 },
141 };
142 assert_eq!(row.describe().as_deref(), Some("Search: #55 (status:todo)"));
143 }
144
145 #[test]
146 fn describe_empty_layers_is_none() {
147 let row = FilterRow {
148 query: None,
149 structured: StructuredFilter::default(),
150 };
151 assert_eq!(row.describe(), None);
152 }
153
154 #[test]
155 fn render_shows_the_described_filters() {
156 let row = FilterRow {
157 query: Some("target".into()),
158 structured: StructuredFilter::default(),
159 };
160 let content = render_row(&row, 80, 1);
161 assert!(content.contains("`target`"), "got {content:?}");
162 }
163
164 #[test]
165 fn render_smoke_tiny_and_tall() {
166 let _ = render_row(
168 &FilterRow {
169 query: Some("55".into()),
170 structured: StructuredFilter::default(),
171 },
172 4,
173 1,
174 );
175 let row = FilterRow::default();
176 let content = render_row(&row, 80, 1);
177 assert!(content.starts_with("Search:"), "got {content:?}");
178 }
179}