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

FlorianNAdam / nirion / 29649950462

18 Jul 2026 03:20PM UTC coverage: 92.714% (+1.0%) from 91.761%
29649950462

push

github

web-flow
refactor(tui): render status updates with line diff (#52)

81 of 91 new or added lines in 4 files covered. (89.01%)

1 existing line in 1 file now uncovered.

5141 of 5545 relevant lines covered (92.71%)

13.03 hits per line

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

65.14
/nirion-bin/src/progress_render.rs
1
use crossterm::{
2
    cursor, execute,
3
    style::{Color, Stylize},
4
};
5
use nirion_lib::{
6
    context::NirionContext,
7
    docker::ProjectStatus,
8
    events::{ComposeEvent, ProcessEvent},
9
    projects::Projects,
10
};
11
use nirion_tui_lib::{
12
    line_renderer::LineRenderer,
13
    spinner::Spinner,
14
    status::{Status, StatusEntry},
15
};
16
use std::collections::BTreeMap;
17
use std::io::{Write, stdout};
18

19
use crate::status_display::{project_state_icon, project_status_segments};
20

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

28
struct CursorGuard;
29

30
impl CursorGuard {
31
    fn hide() -> anyhow::Result<Self> {
×
32
        let mut stdout = stdout();
×
33
        execute!(stdout, cursor::Hide)?;
×
34
        Ok(Self)
×
35
    }
36
}
37

38
impl Drop for CursorGuard {
39
    fn drop(&mut self) {
×
40
        let mut stdout = stdout();
×
41
        let _ = execute!(stdout, cursor::Show);
×
42
        let _ = stdout.flush();
×
43
    }
44
}
45

46
fn empty_status() -> ProjectStatus {
2✔
47
    ProjectStatus {
48
        services: BTreeMap::new(),
2✔
49
    }
50
}
51

52
fn create_status(
3✔
53
    spinner: Option<&Spinner>,
54
    selected: &[String],
55
    running: &BTreeMap<String, bool>,
56
    statuses: &BTreeMap<String, ProjectStatus>,
57
    projects: &Projects,
58
) -> Status {
59
    let mut entries = Vec::new();
6✔
60

61
    for name in selected {
9✔
62
        let project_status = statuses
6✔
63
            .get(name)
6✔
64
            .cloned()
65
            .unwrap_or_else(empty_status);
3✔
66
        let project = &projects[name];
6✔
67

68
        let icon = if let Some(spinner) = spinner
6✔
69
            && *running.get(name).unwrap_or(&false)
×
70
        {
71
            spinner.get().yellow().to_string()
×
72
        } else {
73
            project_state_icon(&project_status.project_state())
6✔
74
        };
75

76
        let prefix = format!("{icon} {name}");
9✔
77

78
        let progressing = project_status.progressing();
9✔
79
        let num_services = project.services.len();
9✔
80
        let mut segments = project_status_segments(&project_status);
9✔
81

82
        for _ in 0..(num_services.saturating_sub(segments.len())) {
17✔
83
            segments.push(Color::Grey);
10✔
84
        }
85

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

88
        entries.push(StatusEntry {
9✔
89
            prefix,
6✔
90
            segments,
3✔
91
            suffix,
3✔
92
        });
93
    }
94

95
    Status { entries }
96
}
97

98
fn render_progress(
1✔
99
    spinner: Option<&Spinner>,
100
    selected: &[String],
101
    running: &BTreeMap<String, bool>,
102
    statuses: &BTreeMap<String, ProjectStatus>,
103
    projects: &Projects,
104
) -> String {
105
    let status = create_status(spinner, selected, running, statuses, projects);
7✔
106
    status.render()
2✔
107
}
108

109
pub(crate) trait ProgressRenderer {
110
    fn needs_status_during_compose(&self) -> bool {
18✔
111
        false
18✔
112
    }
113

114
    fn start(
9✔
115
        &mut self,
116
        _context: &NirionContext,
117
        _selected: &[String],
118
        _running: &BTreeMap<String, bool>,
119
        _statuses: &BTreeMap<String, ProjectStatus>,
120
    ) -> anyhow::Result<()> {
121
        Ok(())
9✔
122
    }
123

124
    fn compose_event(
4✔
125
        &mut self,
126
        _event: &ComposeEvent,
127
    ) -> anyhow::Result<()> {
128
        Ok(())
4✔
129
    }
130

131
    fn tick(
42✔
132
        &mut self,
133
        _context: &NirionContext,
134
        _selected: &[String],
135
        _running: &BTreeMap<String, bool>,
136
        _statuses: &BTreeMap<String, ProjectStatus>,
137
    ) -> anyhow::Result<()> {
138
        Ok(())
42✔
139
    }
140

141
    fn finish(
9✔
142
        &mut self,
143
        _context: &NirionContext,
144
        _selected: &[String],
145
        _running: &BTreeMap<String, bool>,
146
        _statuses: &BTreeMap<String, ProjectStatus>,
147
    ) -> anyhow::Result<()> {
148
        Ok(())
9✔
149
    }
150
}
151

152
impl<T> ProgressRenderer for Box<T>
153
where
154
    T: ProgressRenderer + ?Sized,
155
{
156
    fn needs_status_during_compose(&self) -> bool {
9✔
157
        (**self).needs_status_during_compose()
18✔
158
    }
159

160
    fn start(
9✔
161
        &mut self,
162
        context: &NirionContext,
163
        selected: &[String],
164
        running: &BTreeMap<String, bool>,
165
        statuses: &BTreeMap<String, ProjectStatus>,
166
    ) -> anyhow::Result<()> {
167
        (**self).start(context, selected, running, statuses)
54✔
168
    }
169

170
    fn compose_event(
33✔
171
        &mut self,
172
        event: &ComposeEvent,
173
    ) -> anyhow::Result<()> {
174
        (**self).compose_event(event)
99✔
175
    }
176

177
    fn tick(
42✔
178
        &mut self,
179
        context: &NirionContext,
180
        selected: &[String],
181
        running: &BTreeMap<String, bool>,
182
        statuses: &BTreeMap<String, ProjectStatus>,
183
    ) -> anyhow::Result<()> {
184
        (**self).tick(context, selected, running, statuses)
252✔
185
    }
186

187
    fn finish(
9✔
188
        &mut self,
189
        context: &NirionContext,
190
        selected: &[String],
191
        running: &BTreeMap<String, bool>,
192
        statuses: &BTreeMap<String, ProjectStatus>,
193
    ) -> anyhow::Result<()> {
194
        (**self).finish(context, selected, running, statuses)
54✔
195
    }
196
}
197

198
pub(crate) struct StatusProgressRenderer {
199
    spinner: Option<Spinner>,
200
    lines: LineRenderer,
201
    cursor: Option<CursorGuard>,
202
}
203

204
impl StatusProgressRenderer {
205
    pub(crate) fn with_spinner() -> Self {
1✔
206
        Self {
207
            spinner: Some(Spinner::default()),
2✔
208
            lines: LineRenderer::default(),
1✔
209
            cursor: None,
210
        }
211
    }
212

213
    pub(crate) fn without_spinner() -> Self {
1✔
214
        Self {
215
            spinner: None,
216
            lines: LineRenderer::default(),
1✔
217
            cursor: None,
218
        }
219
    }
220

NEW
221
    fn spinner(&self) -> Option<&Spinner> {
×
NEW
222
        self.spinner.as_ref()
×
223
    }
224
}
225

226
impl ProgressRenderer for StatusProgressRenderer {
227
    fn needs_status_during_compose(&self) -> bool {
2✔
228
        self.spinner.is_some()
4✔
229
    }
230

UNCOV
231
    fn start(
×
232
        &mut self,
233
        context: &NirionContext,
234
        selected: &[String],
235
        running: &BTreeMap<String, bool>,
236
        statuses: &BTreeMap<String, ProjectStatus>,
237
    ) -> anyhow::Result<()> {
238
        self.cursor = Some(CursorGuard::hide()?);
×
239
        let progress = render_progress(
NEW
240
            self.spinner(),
×
241
            selected,
×
242
            running,
×
243
            statuses,
×
244
            &context.projects,
×
245
        );
NEW
246
        self.lines.start(&progress)
×
247
    }
248

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

266
    fn finish(
×
267
        &mut self,
268
        context: &NirionContext,
269
        selected: &[String],
270
        running: &BTreeMap<String, bool>,
271
        statuses: &BTreeMap<String, ProjectStatus>,
272
    ) -> anyhow::Result<()> {
273
        let progress = render_progress(
NEW
274
            self.spinner(),
×
275
            selected,
×
276
            running,
×
277
            statuses,
×
278
            &context.projects,
×
279
        );
NEW
280
        self.lines.finish(&progress)
×
281
    }
282
}
283

284
struct PlainRenderer;
285

286
impl ProgressRenderer for PlainRenderer {
287
    fn compose_event(
29✔
288
        &mut self,
289
        event: &ComposeEvent,
290
    ) -> anyhow::Result<()> {
291
        render_compose_event(event);
58✔
292
        Ok(())
29✔
293
    }
294
}
295

296
struct HiddenRenderer;
297

298
impl ProgressRenderer for HiddenRenderer {}
299

300
pub(crate) fn progress_renderer(
9✔
301
    presentation: ProgressPresentation
302
) -> Box<dyn ProgressRenderer> {
303
    match presentation {
9✔
304
        ProgressPresentation::Progress => {
NEW
305
            Box::new(StatusProgressRenderer::with_spinner())
×
306
        }
307
        ProgressPresentation::Plain => Box::new(PlainRenderer),
8✔
308
        ProgressPresentation::Hidden => Box::new(HiddenRenderer),
1✔
309
    }
310
}
311

312
fn render_compose_event(event: &ComposeEvent) {
29✔
313
    match event {
29✔
314
        ComposeEvent::ProjectStarted { project } => {
10✔
315
            println!("[{}]", project.as_str().cyan());
15✔
316
        }
317
        ComposeEvent::Process { event, .. } => render_process_event(event),
72✔
318
        ComposeEvent::ProjectFailed { project, error } => {
×
319
            eprintln!("Project '{}' failed: {}", project, error);
×
320
            println!();
×
321
        }
322
    }
323
}
324

325
fn render_process_event(event: &ProcessEvent) {
24✔
326
    match event {
24✔
327
        ProcessEvent::StdoutLine(line) => println!("{}", line),
24✔
328
        ProcessEvent::StderrLine(line) => {
8✔
329
            if !line.contains("the attribute `version` is obsolete") {
16✔
330
                eprintln!("{}", line);
8✔
331
            }
332
        }
333
        ProcessEvent::Exited(_) => {}
8✔
334
    }
335
}
336

337
#[cfg(test)]
338
mod tests {
339
    use super::*;
340
    use nirion_lib::docker::{Port, ServiceState, ServiceStatus};
341

342
    fn projects() -> Projects {
343
        serde_json::from_str(
344
            r#"
345
{
346
  "app": {
347
    "name": "app",
348
    "dockerCompose": "compose.yml",
349
    "services": {
350
      "web": {"image": "nginx", "healthcheck": true, "restart": null},
351
      "db": {"image": "postgres", "healthcheck": true, "restart": null}
352
    }
353
  }
354
}
355
"#,
356
        )
357
        .unwrap()
358
    }
359

360
    fn service_status(
361
        service: &str,
362
        state: ServiceState,
363
    ) -> ServiceStatus {
364
        ServiceStatus {
365
            id: format!("{service}-id"),
366
            service: service.to_string(),
367
            container_name: service.to_string(),
368
            image: "image".to_string(),
369
            state,
370
            health: None,
371
            exit_code: None,
372
            running_for: None,
373
            status: None,
374
            ports: Vec::<Port>::new(),
375
            networks: Vec::new(),
376
        }
377
    }
378

379
    #[test]
380
    fn create_status_uses_empty_status_when_project_has_no_status() {
381
        let projects = projects();
382
        let selected = vec!["app".to_string()];
383
        let running = BTreeMap::new();
384
        let statuses = BTreeMap::new();
385

386
        let status =
387
            create_status(None, &selected, &running, &statuses, &projects);
388

389
        assert_eq!(status.entries.len(), 1);
390
        assert_eq!(status.entries[0].segments, vec![Color::Grey, Color::Grey]);
391
        assert_eq!(status.entries[0].suffix, "(0/2)    ");
392
    }
393

394
    #[test]
395
    fn create_status_pads_missing_service_segments() {
396
        let projects = projects();
397
        let selected = vec!["app".to_string()];
398
        let running = BTreeMap::from([("app".to_string(), false)]);
399
        let statuses = BTreeMap::from([(
400
            "app".to_string(),
401
            ProjectStatus {
402
                services: BTreeMap::from([(
403
                    "web".to_string(),
404
                    service_status("web", ServiceState::Healthy),
405
                )]),
406
            },
407
        )]);
408

409
        let status =
410
            create_status(None, &selected, &running, &statuses, &projects);
411

412
        assert_eq!(status.entries.len(), 1);
413
        assert_eq!(status.entries[0].segments.len(), 2);
414
        assert_eq!(
415
            status.entries[0].segments[0],
416
            project_status_segments(&statuses["app"])[0]
417
        );
418
        assert_eq!(status.entries[0].segments[1], Color::Grey);
419
        assert_eq!(status.entries[0].suffix, "(1/2)    ");
420
    }
421

422
    #[test]
423
    fn status_progress_renderer_needs_status_when_using_spinner() {
424
        assert!(
425
            StatusProgressRenderer::with_spinner()
426
                .needs_status_during_compose()
427
        );
428
    }
429

430
    #[test]
431
    fn status_progress_renderer_without_spinner_is_status_only() {
432
        assert!(
433
            !StatusProgressRenderer::without_spinner()
434
                .needs_status_during_compose()
435
        );
436
    }
437

438
    #[test]
439
    fn render_progress_formats_status_without_spinner() {
440
        let projects = projects();
441
        let selected = vec!["app".to_string()];
442
        let running = BTreeMap::new();
443
        let statuses = BTreeMap::new();
444

445
        let output =
446
            render_progress(None, &selected, &running, &statuses, &projects);
447

448
        assert!(output.contains("app"));
449
        assert!(output.contains("(0/2)"));
450
    }
451
}
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