1use super::Ui;
2use super::theme::{header_cell, terminal_width};
3use comfy_table::{Cell, Color, ContentArrangement, Row, Table, presets::UTF8_FULL_CONDENSED};
4use std::io::Write;
5use winbrew_models::catalog::package::CatalogPackage;
6use winbrew_models::install::installed::{InstalledPackage, PackageStatus};
7use winbrew_models::shared::DeploymentKind;
8
9impl<W: Write> Ui<W> {
10 fn render_table(&mut self, table: Table) {
11 let _ = writeln!(self.out, "{table}");
12 let _ = self.out.flush();
13 }
14
15 pub fn display_packages(&mut self, packages: &[InstalledPackage]) {
16 if packages.is_empty() {
17 self.notice("No packages installed via winbrew.");
18 return;
19 }
20
21 self.render_section_label("installed packages");
22
23 let color = self.color_enabled;
24 let mut table = self.build_table([
25 header_cell("Name", color, Color::Green),
26 header_cell("Version", color, Color::Cyan),
27 header_cell("Deployment", color, Color::Magenta),
28 header_cell("Status", color, Color::DarkGrey),
29 header_cell("Installed At", color, Color::DarkGrey),
30 ]);
31
32 for pkg in packages {
33 table.add_row([
34 Cell::new(&pkg.name),
35 Cell::new(&pkg.version),
36 deployment_badge(pkg.deployment_kind, color),
37 status_badge(pkg.status, color),
38 Cell::new(&pkg.installed_at).fg(Color::DarkGrey),
39 ]);
40 }
41
42 self.render_table(table);
43 }
44
45 pub fn display_catalog_packages(&mut self, packages: &[CatalogPackage]) {
46 if packages.is_empty() {
47 self.notice("No catalog packages found.");
48 return;
49 }
50
51 self.render_section_label("catalog packages");
52
53 let color = self.color_enabled;
54 let mut table = self.build_table([
55 header_cell("Name", color, Color::Green),
56 header_cell("Version", color, Color::Cyan),
57 header_cell("Source", color, Color::Magenta),
58 header_cell("Package ID", color, Color::DarkGrey),
59 ]);
60 table.set_content_arrangement(ContentArrangement::Dynamic);
61
62 for pkg in packages {
63 let mut row = Row::new();
64 row.add_cell(Cell::new(&pkg.name));
65 row.add_cell(Cell::new(pkg.version.to_string()));
66 row.add_cell(source_cell(pkg.source, color));
67 row.add_cell(Cell::new(pkg.id.as_str()));
68 row.max_height(1);
69 table.add_row(row);
70 }
71
72 self.render_table(table);
73 }
74
75 pub fn display_key_values(&mut self, rows: &[(String, String)]) {
76 let color = self.color_enabled;
77 let mut table = self.build_table([
78 header_cell("Key", color, Color::Green),
79 header_cell("Value", color, Color::Cyan),
80 ]);
81
82 for (key, value) in rows {
83 table.add_row([Cell::new(key), Cell::new(value)]);
84 }
85
86 self.render_table(table);
87 }
88
89 fn build_table(&self, headers: impl IntoIterator<Item = Cell>) -> Table {
90 let mut table = Table::new();
91 table
92 .load_preset(UTF8_FULL_CONDENSED)
93 .set_truncation_indicator("…")
94 .set_header(headers)
95 .set_width(terminal_width());
96 table
97 }
98
99 fn render_section_label(&mut self, label: &str) {
100 if self.color_enabled {
101 let _ = writeln!(self.out, "\x1b[2;37m{label}\x1b[0m");
102 } else {
103 let _ = writeln!(self.out, "{label}");
104 }
105 }
106}
107
108fn status_badge(status: PackageStatus, color_enabled: bool) -> Cell {
109 let (label, color) = match status {
110 PackageStatus::Installing => ("[installing]", Color::DarkGrey),
111 PackageStatus::Ok => ("[installed]", Color::Green),
112 PackageStatus::Updating => ("[update available]", Color::Yellow),
113 PackageStatus::Failed => ("[broken]", Color::Red),
114 };
115
116 let cell = Cell::new(label);
117 if color_enabled { cell.fg(color) } else { cell }
118}
119
120fn deployment_badge(kind: DeploymentKind, color_enabled: bool) -> Cell {
121 let (label, color) = match kind {
122 DeploymentKind::Installed => ("[installed]", Color::Cyan),
123 DeploymentKind::Portable => ("[portable]", Color::Magenta),
124 };
125
126 let cell = Cell::new(label);
127 if color_enabled { cell.fg(color) } else { cell }
128}
129
130fn source_cell(source: impl AsRef<str>, color_enabled: bool) -> Cell {
131 let source = source.as_ref();
132 let color = match source.to_ascii_lowercase().as_str() {
133 "winget" => Color::Blue,
134 "scoop" => Color::Yellow,
135 _ => Color::DarkGrey,
136 };
137
138 let cell = Cell::new(source);
139 if color_enabled { cell.fg(color) } else { cell }
140}
141
142#[cfg(test)]
143mod tests {
144 use crate::{Ui, UiSettings};
145 use std::io::{Result as IoResult, Write};
146 use std::sync::{Arc, Mutex};
147 use winbrew_models::domains::catalog::CatalogPackage;
148 use winbrew_models::domains::install::{EngineKind, InstallerType};
149 use winbrew_models::domains::installed::{InstalledPackage, PackageStatus};
150 use winbrew_models::domains::shared::DeploymentKind;
151 use winbrew_models::domains::shared::Version;
152 use winbrew_testing::{CatalogPackageBuilderExt as _, catalog_package};
153
154 struct SharedBuffer {
155 bytes: Arc<Mutex<Vec<u8>>>,
156 }
157
158 impl SharedBuffer {
159 fn new(bytes: Arc<Mutex<Vec<u8>>>) -> Self {
160 Self { bytes }
161 }
162 }
163
164 impl Write for SharedBuffer {
165 fn write(&mut self, buffer: &[u8]) -> IoResult<usize> {
166 let mut bytes = self.bytes.lock().expect("buffer lock should be available");
167 bytes.extend_from_slice(buffer);
168 Ok(buffer.len())
169 }
170
171 fn flush(&mut self) -> IoResult<()> {
172 Ok(())
173 }
174 }
175
176 fn catalog_package_with_description(description: Option<&str>) -> CatalogPackage {
177 let package = catalog_package(
178 "scoop/main/Contoso.App".into(),
179 "Contoso App",
180 Version::parse("1.2.3").expect("version should parse"),
181 );
182
183 match description {
184 Some(description) => package.with_description(description),
185 None => package,
186 }
187 }
188
189 fn installed_package(status: PackageStatus) -> InstalledPackage {
190 InstalledPackage {
191 name: "Contoso App".to_string(),
192 version: "1.2.3".to_string(),
193 kind: InstallerType::Portable,
194 deployment_kind: DeploymentKind::Portable,
195 engine_kind: EngineKind::Portable,
196 engine_metadata: None,
197 install_dir: "C:\\Apps\\Contoso".to_string(),
198 dependencies: Vec::new(),
199 status,
200 installed_at: "2026-04-07T12:00:00Z".to_string(),
201 }
202 }
203
204 #[test]
205 fn display_catalog_packages_hides_descriptions() {
206 let shared_bytes = Arc::new(Mutex::new(Vec::new()));
207 let writer = SharedBuffer::new(Arc::clone(&shared_bytes));
208 let mut ui = Ui::with_writer(
209 writer,
210 UiSettings {
211 color_enabled: false,
212 default_yes: false,
213 },
214 );
215
216 let long_description = "This is a very long Scoop description that should be truncated so that it does not dominate the search table layout or wrap the row unexpectedly.";
217
218 ui.display_catalog_packages(&[catalog_package_with_description(Some(long_description))]);
219 ui.display_catalog_packages(&[
220 catalog_package_with_description(Some("Short description")),
221 catalog_package(
222 "winget/Fabrikam.Tool".into(),
223 "Fabrikam Tool",
224 Version::parse("2.0.0").expect("version should parse"),
225 ),
226 ]);
227 ui.display_catalog_packages(&[catalog_package_with_description(None)]);
228
229 let output = String::from_utf8(
230 shared_bytes
231 .lock()
232 .expect("buffer lock should be available")
233 .clone(),
234 )
235 .expect("rendered output should be valid UTF-8");
236
237 assert!(!output.contains(long_description));
238 assert!(!output.contains("No description available"));
239 assert!(output.contains("Contoso App"));
240 assert!(output.contains("winget/Fabrikam.Tool"));
241 assert!(output.contains("scoop/main/Contoso.App"));
242 assert!(output.contains("catalog packages"));
243 }
244
245 #[test]
246 fn display_packages_renders_status_badges_and_section_label() {
247 let shared_bytes = Arc::new(Mutex::new(Vec::new()));
248 let writer = SharedBuffer::new(Arc::clone(&shared_bytes));
249 let mut ui = Ui::with_writer(
250 writer,
251 UiSettings {
252 color_enabled: false,
253 default_yes: false,
254 },
255 );
256
257 ui.display_packages(&[
258 installed_package(PackageStatus::Ok),
259 installed_package(PackageStatus::Updating),
260 installed_package(PackageStatus::Failed),
261 installed_package(PackageStatus::Installing),
262 ]);
263
264 let output = String::from_utf8(
265 shared_bytes
266 .lock()
267 .expect("buffer lock should be available")
268 .clone(),
269 )
270 .expect("rendered output should be valid UTF-8");
271
272 assert!(output.contains("installed packages"));
273 assert!(output.contains("portable"));
274 assert!(output.contains("[installed]"));
275 assert!(output.contains("[update available]"));
276 assert!(output.contains("[broken]"));
277 assert!(output.contains("[installing]"));
278 }
279}