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

FlorianNAdam / nirion / 29658361877

18 Jul 2026 07:36PM UTC coverage: 92.391% (-0.3%) from 92.714%
29658361877

push

github

web-flow
refactor(tui): fully replace crossterm with console (#53)

55 of 86 new or added lines in 7 files covered. (63.95%)

5173 of 5599 relevant lines covered (92.39%)

13.47 hits per line

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

70.3
/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,
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 { entries }
75
}
76

77
fn render_progress(
1✔
78
    spinner: Option<&Spinner>,
79
    selected: &[String],
80
    running: &BTreeMap<String, bool>,
81
    statuses: &BTreeMap<String, ProjectStatus>,
82
    projects: &Projects,
83
) -> String {
84
    let status = create_status(spinner, selected, running, statuses, projects);
7✔
85
    status.render()
2✔
86
}
87

88
pub(crate) trait ProgressRenderer {
89
    fn needs_status_during_compose(&self) -> bool {
18✔
90
        false
18✔
91
    }
92

93
    fn start(
9✔
94
        &mut self,
95
        _context: &NirionContext,
96
        _selected: &[String],
97
        _running: &BTreeMap<String, bool>,
98
        _statuses: &BTreeMap<String, ProjectStatus>,
99
    ) -> anyhow::Result<()> {
100
        Ok(())
9✔
101
    }
102

103
    fn compose_event(
4✔
104
        &mut self,
105
        _event: &ComposeEvent,
106
    ) -> anyhow::Result<()> {
107
        Ok(())
4✔
108
    }
109

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

120
    fn finish(
9✔
121
        &mut self,
122
        _context: &NirionContext,
123
        _selected: &[String],
124
        _running: &BTreeMap<String, bool>,
125
        _statuses: &BTreeMap<String, ProjectStatus>,
126
    ) -> anyhow::Result<()> {
127
        Ok(())
9✔
128
    }
129
}
130

131
impl<T> ProgressRenderer for Box<T>
132
where
133
    T: ProgressRenderer + ?Sized,
134
{
135
    fn needs_status_during_compose(&self) -> bool {
9✔
136
        (**self).needs_status_during_compose()
18✔
137
    }
138

139
    fn start(
9✔
140
        &mut self,
141
        context: &NirionContext,
142
        selected: &[String],
143
        running: &BTreeMap<String, bool>,
144
        statuses: &BTreeMap<String, ProjectStatus>,
145
    ) -> anyhow::Result<()> {
146
        (**self).start(context, selected, running, statuses)
54✔
147
    }
148

149
    fn compose_event(
33✔
150
        &mut self,
151
        event: &ComposeEvent,
152
    ) -> anyhow::Result<()> {
153
        (**self).compose_event(event)
99✔
154
    }
155

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

166
    fn finish(
9✔
167
        &mut self,
168
        context: &NirionContext,
169
        selected: &[String],
170
        running: &BTreeMap<String, bool>,
171
        statuses: &BTreeMap<String, ProjectStatus>,
172
    ) -> anyhow::Result<()> {
173
        (**self).finish(context, selected, running, statuses)
54✔
174
    }
175
}
176

177
pub(crate) struct StatusProgressRenderer {
178
    spinner: Option<Spinner>,
179
    lines: LineRenderer,
180
    cursor: Option<HiddenCursorGuard>,
181
}
182

183
impl StatusProgressRenderer {
184
    pub(crate) fn with_spinner() -> Self {
1✔
185
        Self {
186
            spinner: Some(Spinner::default()),
2✔
187
            lines: LineRenderer::default(),
1✔
188
            cursor: None,
189
        }
190
    }
191

192
    pub(crate) fn without_spinner() -> Self {
1✔
193
        Self {
194
            spinner: None,
195
            lines: LineRenderer::default(),
1✔
196
            cursor: None,
197
        }
198
    }
199

200
    fn spinner(&self) -> Option<&Spinner> {
×
201
        self.spinner.as_ref()
×
202
    }
203
}
204

205
impl ProgressRenderer for StatusProgressRenderer {
206
    fn needs_status_during_compose(&self) -> bool {
2✔
207
        self.spinner.is_some()
4✔
208
    }
209

210
    fn start(
×
211
        &mut self,
212
        context: &NirionContext,
213
        selected: &[String],
214
        running: &BTreeMap<String, bool>,
215
        statuses: &BTreeMap<String, ProjectStatus>,
216
    ) -> anyhow::Result<()> {
NEW
217
        self.cursor = Some(HiddenCursorGuard::hide()?);
×
218
        let progress = render_progress(
219
            self.spinner(),
×
220
            selected,
×
221
            running,
×
222
            statuses,
×
223
            &context.projects,
×
224
        );
225
        self.lines.start(&progress)
×
226
    }
227

228
    fn tick(
×
229
        &mut self,
230
        context: &NirionContext,
231
        selected: &[String],
232
        running: &BTreeMap<String, bool>,
233
        statuses: &BTreeMap<String, ProjectStatus>,
234
    ) -> anyhow::Result<()> {
235
        let progress = render_progress(
236
            self.spinner(),
×
237
            selected,
×
238
            running,
×
239
            statuses,
×
240
            &context.projects,
×
241
        );
242
        self.lines.render(&progress)
×
243
    }
244

245
    fn finish(
×
246
        &mut self,
247
        context: &NirionContext,
248
        selected: &[String],
249
        running: &BTreeMap<String, bool>,
250
        statuses: &BTreeMap<String, ProjectStatus>,
251
    ) -> anyhow::Result<()> {
252
        let progress = render_progress(
253
            self.spinner(),
×
254
            selected,
×
255
            running,
×
256
            statuses,
×
257
            &context.projects,
×
258
        );
259
        self.lines.finish(&progress)
×
260
    }
261
}
262

263
struct PlainRenderer;
264

265
impl ProgressRenderer for PlainRenderer {
266
    fn compose_event(
29✔
267
        &mut self,
268
        event: &ComposeEvent,
269
    ) -> anyhow::Result<()> {
270
        render_compose_event(event);
58✔
271
        Ok(())
29✔
272
    }
273
}
274

275
struct HiddenRenderer;
276

277
impl ProgressRenderer for HiddenRenderer {}
278

279
pub(crate) fn progress_renderer(
9✔
280
    presentation: ProgressPresentation
281
) -> Box<dyn ProgressRenderer> {
282
    match presentation {
9✔
283
        ProgressPresentation::Progress => {
284
            Box::new(StatusProgressRenderer::with_spinner())
×
285
        }
286
        ProgressPresentation::Plain => Box::new(PlainRenderer),
8✔
287
        ProgressPresentation::Hidden => Box::new(HiddenRenderer),
1✔
288
    }
289
}
290

291
fn render_compose_event(event: &ComposeEvent) {
29✔
292
    match event {
29✔
293
        ComposeEvent::ProjectStarted { project } => {
10✔
294
            println!("[{}]", project.as_str().cyan());
15✔
295
        }
296
        ComposeEvent::Process { event, .. } => render_process_event(event),
72✔
297
        ComposeEvent::ProjectFailed { project, error } => {
×
298
            eprintln!("Project '{}' failed: {}", project, error);
×
299
            println!();
×
300
        }
301
    }
302
}
303

304
fn render_process_event(event: &ProcessEvent) {
24✔
305
    match event {
24✔
306
        ProcessEvent::StdoutLine(line) => println!("{}", line),
24✔
307
        ProcessEvent::StderrLine(line) => {
8✔
308
            if !line.contains("the attribute `version` is obsolete") {
16✔
309
                eprintln!("{}", line);
8✔
310
            }
311
        }
312
        ProcessEvent::Exited(_) => {}
8✔
313
    }
314
}
315

316
#[cfg(test)]
317
mod tests {
318
    use super::*;
319
    use nirion_lib::docker::{Port, ServiceState, ServiceStatus};
320

321
    fn projects() -> Projects {
322
        serde_json::from_str(
323
            r#"
324
{
325
  "app": {
326
    "name": "app",
327
    "dockerCompose": "compose.yml",
328
    "services": {
329
      "web": {"image": "nginx", "healthcheck": true, "restart": null},
330
      "db": {"image": "postgres", "healthcheck": true, "restart": null}
331
    }
332
  }
333
}
334
"#,
335
        )
336
        .unwrap()
337
    }
338

339
    fn service_status(
340
        service: &str,
341
        state: ServiceState,
342
    ) -> ServiceStatus {
343
        ServiceStatus {
344
            id: format!("{service}-id"),
345
            service: service.to_string(),
346
            container_name: service.to_string(),
347
            image: "image".to_string(),
348
            state,
349
            health: None,
350
            exit_code: None,
351
            running_for: None,
352
            status: None,
353
            ports: Vec::<Port>::new(),
354
            networks: Vec::new(),
355
        }
356
    }
357

358
    #[test]
359
    fn create_status_uses_empty_status_when_project_has_no_status() {
360
        let projects = projects();
361
        let selected = vec!["app".to_string()];
362
        let running = BTreeMap::new();
363
        let statuses = BTreeMap::new();
364

365
        let status =
366
            create_status(None, &selected, &running, &statuses, &projects);
367

368
        assert_eq!(status.entries.len(), 1);
369
        assert_eq!(status.entries[0].segments, vec![GREY, GREY]);
370
        assert_eq!(status.entries[0].suffix, "(0/2)    ");
371
    }
372

373
    #[test]
374
    fn create_status_pads_missing_service_segments() {
375
        let projects = projects();
376
        let selected = vec!["app".to_string()];
377
        let running = BTreeMap::from([("app".to_string(), false)]);
378
        let statuses = BTreeMap::from([(
379
            "app".to_string(),
380
            ProjectStatus {
381
                services: BTreeMap::from([(
382
                    "web".to_string(),
383
                    service_status("web", ServiceState::Healthy),
384
                )]),
385
            },
386
        )]);
387

388
        let status =
389
            create_status(None, &selected, &running, &statuses, &projects);
390

391
        assert_eq!(status.entries.len(), 1);
392
        assert_eq!(status.entries[0].segments.len(), 2);
393
        assert_eq!(
394
            status.entries[0].segments[0],
395
            project_status_segments(&statuses["app"])[0]
396
        );
397
        assert_eq!(status.entries[0].segments[1], GREY);
398
        assert_eq!(status.entries[0].suffix, "(1/2)    ");
399
    }
400

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

409
    #[test]
410
    fn status_progress_renderer_without_spinner_is_status_only() {
411
        assert!(
412
            !StatusProgressRenderer::without_spinner()
413
                .needs_status_during_compose()
414
        );
415
    }
416

417
    #[test]
418
    fn render_progress_formats_status_without_spinner() {
419
        let projects = projects();
420
        let selected = vec!["app".to_string()];
421
        let running = BTreeMap::new();
422
        let statuses = BTreeMap::new();
423

424
        let output =
425
            render_progress(None, &selected, &running, &statuses, &projects);
426

427
        assert!(output.contains("app"));
428
        assert!(output.contains("(0/2)"));
429
    }
430
}
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