winbrew_ui/
table.rs

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}