• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

FlorianNAdam / nirion / 29698351807

19 Jul 2026 06:09PM UTC coverage: 92.572% (-0.06%) from 92.63%
29698351807

push

github

web-flow
feat(tui): adapt width of rendered status to terminal size (#61)

30 of 35 new or added lines in 3 files covered. (85.71%)

3 existing lines in 1 file now uncovered.

5857 of 6327 relevant lines covered (92.57%)

14.68 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

67.65
/nirion-bin/src/progress_render.rs
1
use nirion_lib::{
2
    context::NirionContext,
3
    docker::ProjectStatus,
4
    events::{ComposeEvent, ProcessEvent},
5
    projects::Projects,
6
};
7
use nirion_tui_lib::{
8
    color::{Colorize, GREY},
9
    line_renderer::LineRenderer,
10
    spinner::Spinner,
11
    status::{Status, StatusEntry},
12
    terminal::{HiddenCursorGuard, terminal_width},
13
};
14
use std::collections::BTreeMap;
15

16
use crate::status_display::{project_state_icon, project_status_segments};
17

18
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19
pub enum ProgressPresentation {
20
    Progress,
21
    Plain,
22
    Hidden,
23
}
24

25
fn empty_status() -> ProjectStatus {
2✔
26
    ProjectStatus {
27
        services: BTreeMap::new(),
2✔
28
    }
29
}
30

31
fn create_status(
3✔
32
    spinner: Option<&Spinner>,
33
    selected: &[String],
34
    running: &BTreeMap<String, bool>,
35
    statuses: &BTreeMap<String, ProjectStatus>,
36
    projects: &Projects,
37
) -> Status {
38
    let mut entries = Vec::new();
6✔
39

40
    for name in selected {
9✔
41
        let project_status = statuses
6✔
42
            .get(name)
6✔
43
            .cloned()
44
            .unwrap_or_else(empty_status);
3✔
45
        let project = &projects[name];
6✔
46

47
        let icon = if let Some(spinner) = spinner
6✔
48
            && *running.get(name).unwrap_or(&false)
×
49
        {
50
            spinner.get().yellow().to_string()
×
51
        } else {
52
            project_state_icon(&project_status.project_state())
6✔
53
        };
54

55
        let prefix = format!("{icon} {name}");
9✔
56

57
        let progressing = project_status.progressing();
9✔
58
        let num_services = project.services.len();
9✔
59
        let mut segments = project_status_segments(&project_status);
9✔
60

61
        for _ in 0..(num_services.saturating_sub(segments.len())) {
17✔
62
            segments.push(GREY);
5✔
63
        }
64

65
        let suffix = format!("({progressing}/{num_services})    ");
9✔
66

67
        entries.push(StatusEntry {
9✔
68
            prefix,
6✔
69
            segments,
3✔
70
            suffix,
3✔
71
        });
72
    }
73

74
    Status::new(entries)
6✔
75
}
76

77
pub(crate) trait ProgressRenderer {
78
    fn needs_status_during_compose(&self) -> bool {
18✔
79
        false
18✔
80
    }
81

82
    fn start(
9✔
83
        &mut self,
84
        _context: &NirionContext,
85
        _selected: &[String],
86
        _running: &BTreeMap<String, bool>,
87
        _statuses: &BTreeMap<String, ProjectStatus>,
88
    ) -> anyhow::Result<()> {
89
        Ok(())
9✔
90
    }
91

92
    fn compose_event(
4✔
93
        &mut self,
94
        _event: &ComposeEvent,
95
    ) -> anyhow::Result<()> {
96
        Ok(())
4✔
97
    }
98

99
    fn tick(
42✔
100
        &mut self,
101
        _context: &NirionContext,
102
        _selected: &[String],
103
        _running: &BTreeMap<String, bool>,
104
        _statuses: &BTreeMap<String, ProjectStatus>,
105
    ) -> anyhow::Result<()> {
106
        Ok(())
42✔
107
    }
108

109
    fn finish(
9✔
110
        &mut self,
111
        _context: &NirionContext,
112
        _selected: &[String],
113
        _running: &BTreeMap<String, bool>,
114
        _statuses: &BTreeMap<String, ProjectStatus>,
115
    ) -> anyhow::Result<()> {
116
        Ok(())
9✔
117
    }
118
}
119

120
impl<T> ProgressRenderer for Box<T>
121
where
122
    T: ProgressRenderer + ?Sized,
123
{
124
    fn needs_status_during_compose(&self) -> bool {
9✔
125
        (**self).needs_status_during_compose()
18✔
126
    }
127

128
    fn start(
9✔
129
        &mut self,
130
        context: &NirionContext,
131
        selected: &[String],
132
        running: &BTreeMap<String, bool>,
133
        statuses: &BTreeMap<String, ProjectStatus>,
134
    ) -> anyhow::Result<()> {
135
        (**self).start(context, selected, running, statuses)
54✔
136
    }
137

138
    fn compose_event(
33✔
139
        &mut self,
140
        event: &ComposeEvent,
141
    ) -> anyhow::Result<()> {
142
        (**self).compose_event(event)
99✔
143
    }
144

145
    fn tick(
42✔
146
        &mut self,
147
        context: &NirionContext,
148
        selected: &[String],
149
        running: &BTreeMap<String, bool>,
150
        statuses: &BTreeMap<String, ProjectStatus>,
151
    ) -> anyhow::Result<()> {
152
        (**self).tick(context, selected, running, statuses)
252✔
153
    }
154

155
    fn finish(
9✔
156
        &mut self,
157
        context: &NirionContext,
158
        selected: &[String],
159
        running: &BTreeMap<String, bool>,
160
        statuses: &BTreeMap<String, ProjectStatus>,
161
    ) -> anyhow::Result<()> {
162
        (**self).finish(context, selected, running, statuses)
54✔
163
    }
164
}
165

166
pub(crate) struct StatusProgressRenderer {
167
    spinner: Option<Spinner>,
168
    lines: LineRenderer,
169
    cursor: Option<HiddenCursorGuard>,
170
}
171

172
impl StatusProgressRenderer {
173
    pub(crate) fn with_spinner() -> Self {
1✔
174
        Self {
175
            spinner: Some(Spinner::default()),
2✔
176
            lines: LineRenderer::default(),
1✔
177
            cursor: None,
178
        }
179
    }
180

181
    pub(crate) fn without_spinner() -> Self {
1✔
182
        Self {
183
            spinner: None,
184
            lines: LineRenderer::default(),
1✔
185
            cursor: None,
186
        }
187
    }
188

189
    fn spinner(&self) -> Option<&Spinner> {
×
190
        self.spinner.as_ref()
×
191
    }
192
}
193

194
impl ProgressRenderer for StatusProgressRenderer {
195
    fn needs_status_during_compose(&self) -> bool {
2✔
196
        self.spinner.is_some()
4✔
197
    }
198

199
    fn start(
×
200
        &mut self,
201
        context: &NirionContext,
202
        selected: &[String],
203
        running: &BTreeMap<String, bool>,
204
        statuses: &BTreeMap<String, ProjectStatus>,
205
    ) -> anyhow::Result<()> {
206
        self.cursor = Some(HiddenCursorGuard::hide()?);
×
207
        let progress = create_status(
208
            self.spinner(),
×
209
            selected,
×
210
            running,
×
211
            statuses,
×
212
            &context.projects,
×
213
        )
NEW
214
        .render(terminal_width());
×
UNCOV
215
        self.lines.start(&progress)
×
216
    }
217

218
    fn tick(
×
219
        &mut self,
220
        context: &NirionContext,
221
        selected: &[String],
222
        running: &BTreeMap<String, bool>,
223
        statuses: &BTreeMap<String, ProjectStatus>,
224
    ) -> anyhow::Result<()> {
225
        let progress = create_status(
226
            self.spinner(),
×
227
            selected,
×
228
            running,
×
229
            statuses,
×
230
            &context.projects,
×
231
        )
NEW
232
        .render(terminal_width());
×
UNCOV
233
        self.lines.render(&progress)
×
234
    }
235

236
    fn finish(
×
237
        &mut self,
238
        context: &NirionContext,
239
        selected: &[String],
240
        running: &BTreeMap<String, bool>,
241
        statuses: &BTreeMap<String, ProjectStatus>,
242
    ) -> anyhow::Result<()> {
243
        let progress = create_status(
244
            self.spinner(),
×
245
            selected,
×
246
            running,
×
247
            statuses,
×
248
            &context.projects,
×
249
        )
NEW
250
        .render(terminal_width());
×
UNCOV
251
        self.lines.finish(&progress)
×
252
    }
253
}
254

255
struct PlainRenderer;
256

257
impl ProgressRenderer for PlainRenderer {
258
    fn compose_event(
29✔
259
        &mut self,
260
        event: &ComposeEvent,
261
    ) -> anyhow::Result<()> {
262
        render_compose_event(event);
58✔
263
        Ok(())
29✔
264
    }
265
}
266

267
struct HiddenRenderer;
268

269
impl ProgressRenderer for HiddenRenderer {}
270

271
pub(crate) fn progress_renderer(
9✔
272
    presentation: ProgressPresentation
273
) -> Box<dyn ProgressRenderer> {
274
    match presentation {
9✔
275
        ProgressPresentation::Progress => {
276
            Box::new(StatusProgressRenderer::with_spinner())
×
277
        }
278
        ProgressPresentation::Plain => Box::new(PlainRenderer),
8✔
279
        ProgressPresentation::Hidden => Box::new(HiddenRenderer),
1✔
280
    }
281
}
282

283
fn render_compose_event(event: &ComposeEvent) {
29✔
284
    match event {
29✔
285
        ComposeEvent::ProjectStarted { project } => {
10✔
286
            println!("[{}]", project.as_str().cyan());
15✔
287
        }
288
        ComposeEvent::Process { event, .. } => render_process_event(event),
72✔
289
        ComposeEvent::ProjectFailed { project, error } => {
×
290
            eprintln!("Project '{}' failed: {}", project, error);
×
291
            println!();
×
292
        }
293
    }
294
}
295

296
fn render_process_event(event: &ProcessEvent) {
24✔
297
    match event {
24✔
298
        ProcessEvent::StdoutLine(line) => println!("{}", line),
24✔
299
        ProcessEvent::StderrLine(line) => {
8✔
300
            if !line.contains("the attribute `version` is obsolete") {
16✔
301
                eprintln!("{}", line);
8✔
302
            }
303
        }
304
        ProcessEvent::Exited(_) => {}
8✔
305
    }
306
}
307

308
#[cfg(test)]
309
mod tests {
310
    use super::*;
311
    use nirion_lib::docker::{Port, ServiceState, ServiceStatus};
312

313
    fn projects() -> Projects {
314
        serde_json::from_str(
315
            r#"
316
{
317
  "app": {
318
    "name": "app",
319
    "dockerCompose": "compose.yml",
320
    "services": {
321
      "web": {"image": "nginx", "healthcheck": true, "restart": null},
322
      "db": {"image": "postgres", "healthcheck": true, "restart": null}
323
    }
324
  }
325
}
326
"#,
327
        )
328
        .unwrap()
329
    }
330

331
    fn service_status(
332
        service: &str,
333
        state: ServiceState,
334
    ) -> ServiceStatus {
335
        ServiceStatus {
336
            id: format!("{service}-id"),
337
            service: service.to_string(),
338
            container_name: service.to_string(),
339
            image: "image".to_string(),
340
            state,
341
            health: None,
342
            exit_code: None,
343
            running_for: None,
344
            status: None,
345
            ports: Vec::<Port>::new(),
346
            networks: Vec::new(),
347
        }
348
    }
349

350
    #[test]
351
    fn create_status_uses_empty_status_when_project_has_no_status() {
352
        let projects = projects();
353
        let selected = vec!["app".to_string()];
354
        let running = BTreeMap::new();
355
        let statuses = BTreeMap::new();
356

357
        let status =
358
            create_status(None, &selected, &running, &statuses, &projects);
359

360
        assert_eq!(status.entries.len(), 1);
361
        assert_eq!(status.entries[0].segments, vec![GREY, GREY]);
362
        assert_eq!(status.entries[0].suffix, "(0/2)    ");
363
    }
364

365
    #[test]
366
    fn create_status_pads_missing_service_segments() {
367
        let projects = projects();
368
        let selected = vec!["app".to_string()];
369
        let running = BTreeMap::from([("app".to_string(), false)]);
370
        let statuses = BTreeMap::from([(
371
            "app".to_string(),
372
            ProjectStatus {
373
                services: BTreeMap::from([(
374
                    "web".to_string(),
375
                    service_status("web", ServiceState::Healthy),
376
                )]),
377
            },
378
        )]);
379

380
        let status =
381
            create_status(None, &selected, &running, &statuses, &projects);
382

383
        assert_eq!(status.entries.len(), 1);
384
        assert_eq!(status.entries[0].segments.len(), 2);
385
        assert_eq!(
386
            status.entries[0].segments[0],
387
            project_status_segments(&statuses["app"])[0]
388
        );
389
        assert_eq!(status.entries[0].segments[1], GREY);
390
        assert_eq!(status.entries[0].suffix, "(1/2)    ");
391
    }
392

393
    #[test]
394
    fn status_progress_renderer_needs_status_when_using_spinner() {
395
        assert!(
396
            StatusProgressRenderer::with_spinner()
397
                .needs_status_during_compose()
398
        );
399
    }
400

401
    #[test]
402
    fn status_progress_renderer_without_spinner_is_status_only() {
403
        assert!(
404
            !StatusProgressRenderer::without_spinner()
405
                .needs_status_during_compose()
406
        );
407
    }
408

409
    #[test]
410
    fn create_status_formats_status_without_spinner() {
411
        let projects = projects();
412
        let selected = vec!["app".to_string()];
413
        let running = BTreeMap::new();
414
        let statuses = BTreeMap::new();
415

416
        let output =
417
            create_status(None, &selected, &running, &statuses, &projects)
418
                .render(80);
419

420
        assert!(output.contains("app"));
421
        assert!(output.contains("(0/2)"));
422
    }
423
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc