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

FlorianNAdam / nirion / 29645316854

18 Jul 2026 12:54PM UTC coverage: 91.761% (+0.2%) from 91.607%
29645316854

push

github

web-flow
refactor(cli): refactor monitor (#51)

107 of 171 new or added lines in 12 files covered. (62.57%)

1 existing line in 1 file now uncovered.

5045 of 5498 relevant lines covered (91.76%)

12.93 hits per line

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

49.59
/nirion-bin/src/progress_render.rs
1
use crossterm::{
2
    cursor::{self, MoveUp},
3
    execute,
4
    style::{Color, Stylize},
5
};
6
use nirion_lib::{
7
    context::NirionContext,
8
    docker::ProjectStatus,
9
    events::{ComposeEvent, ProcessEvent},
10
    projects::Projects,
11
};
12
use nirion_tui_lib::{
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 {
1✔
47
    ProjectStatus {
48
        services: BTreeMap::new(),
1✔
49
    }
50
}
51

52
fn create_status(
2✔
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();
4✔
60

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

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

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

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

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

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

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

95
    Status { entries }
96
}
97

98
fn print_progress(
×
99
    selected: &[String],
100
    spinner: Option<&Spinner>,
101
    running: &BTreeMap<String, bool>,
102
    statuses: &BTreeMap<String, ProjectStatus>,
103
    projects: &Projects,
104
    move_up: bool,
105
) -> anyhow::Result<()> {
106
    let mut stdout = stdout();
×
107
    let status = create_status(spinner, selected, running, statuses, projects);
×
108
    status.print()?;
×
109

110
    if move_up {
×
111
        execute!(stdout, MoveUp((selected.len() * 2 + 1) as u16))?;
×
112
    }
113

114
    stdout.flush()?;
×
115
    Ok(())
×
116
}
117

118
pub(crate) trait ProgressRenderer {
119
    fn needs_status_during_compose(&self) -> bool {
18✔
120
        false
18✔
121
    }
122

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

133
    fn compose_event(
4✔
134
        &mut self,
135
        _event: &ComposeEvent,
136
    ) -> anyhow::Result<()> {
137
        Ok(())
4✔
138
    }
139

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

150
    fn finish(
9✔
151
        &mut self,
152
        _context: &NirionContext,
153
        _selected: &[String],
154
        _running: &BTreeMap<String, bool>,
155
        _statuses: &BTreeMap<String, ProjectStatus>,
156
    ) -> anyhow::Result<()> {
157
        Ok(())
9✔
158
    }
159
}
160

161
impl<T> ProgressRenderer for Box<T>
162
where
163
    T: ProgressRenderer + ?Sized,
164
{
165
    fn needs_status_during_compose(&self) -> bool {
9✔
166
        (**self).needs_status_during_compose()
18✔
167
    }
168

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

179
    fn compose_event(
33✔
180
        &mut self,
181
        event: &ComposeEvent,
182
    ) -> anyhow::Result<()> {
183
        (**self).compose_event(event)
99✔
184
    }
185

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

196
    fn finish(
9✔
197
        &mut self,
198
        context: &NirionContext,
199
        selected: &[String],
200
        running: &BTreeMap<String, bool>,
201
        statuses: &BTreeMap<String, ProjectStatus>,
202
    ) -> anyhow::Result<()> {
203
        (**self).finish(context, selected, running, statuses)
54✔
204
    }
205
}
206

207
#[derive(Default)]
208
struct ProgressStatusRenderer {
209
    spinner: Spinner,
210
    cursor: Option<CursorGuard>,
211
}
212

213
impl ProgressRenderer for ProgressStatusRenderer {
214
    fn needs_status_during_compose(&self) -> bool {
×
215
        true
×
216
    }
217

218
    fn start(
×
219
        &mut self,
220
        context: &NirionContext,
221
        selected: &[String],
222
        running: &BTreeMap<String, bool>,
223
        statuses: &BTreeMap<String, ProjectStatus>,
224
    ) -> anyhow::Result<()> {
225
        self.cursor = Some(CursorGuard::hide()?);
×
226
        print_progress(
227
            selected,
×
NEW
228
            Some(&self.spinner),
×
NEW
229
            running,
×
NEW
230
            statuses,
×
NEW
231
            &context.projects,
×
232
            true,
233
        )
234
    }
235

NEW
236
    fn tick(
×
237
        &mut self,
238
        context: &NirionContext,
239
        selected: &[String],
240
        running: &BTreeMap<String, bool>,
241
        statuses: &BTreeMap<String, ProjectStatus>,
242
    ) -> anyhow::Result<()> {
243
        print_progress(
NEW
244
            selected,
×
NEW
245
            Some(&self.spinner),
×
NEW
246
            running,
×
NEW
247
            statuses,
×
NEW
248
            &context.projects,
×
249
            true,
250
        )
251
    }
252

NEW
253
    fn finish(
×
254
        &mut self,
255
        context: &NirionContext,
256
        selected: &[String],
257
        running: &BTreeMap<String, bool>,
258
        statuses: &BTreeMap<String, ProjectStatus>,
259
    ) -> anyhow::Result<()> {
260
        print_progress(
NEW
261
            selected,
×
NEW
262
            Some(&self.spinner),
×
NEW
263
            running,
×
NEW
264
            statuses,
×
NEW
265
            &context.projects,
×
266
            false,
267
        )
268
    }
269
}
270

271
#[derive(Default)]
272
pub(crate) struct StaticStatusRenderer {
273
    cursor: Option<CursorGuard>,
274
}
275

276
impl ProgressRenderer for StaticStatusRenderer {
NEW
277
    fn start(
×
278
        &mut self,
279
        context: &NirionContext,
280
        selected: &[String],
281
        running: &BTreeMap<String, bool>,
282
        statuses: &BTreeMap<String, ProjectStatus>,
283
    ) -> anyhow::Result<()> {
NEW
284
        self.cursor = Some(CursorGuard::hide()?);
×
285
        print_progress(
NEW
286
            selected,
×
NEW
287
            None,
×
288
            running,
×
289
            statuses,
×
290
            &context.projects,
×
291
            true,
292
        )
293
    }
294

295
    fn tick(
×
296
        &mut self,
297
        context: &NirionContext,
298
        selected: &[String],
299
        running: &BTreeMap<String, bool>,
300
        statuses: &BTreeMap<String, ProjectStatus>,
301
    ) -> anyhow::Result<()> {
302
        print_progress(
303
            selected,
×
NEW
304
            None,
×
305
            running,
×
306
            statuses,
×
307
            &context.projects,
×
308
            true,
309
        )
310
    }
311

312
    fn finish(
×
313
        &mut self,
314
        context: &NirionContext,
315
        selected: &[String],
316
        running: &BTreeMap<String, bool>,
317
        statuses: &BTreeMap<String, ProjectStatus>,
318
    ) -> anyhow::Result<()> {
319
        print_progress(
320
            selected,
×
NEW
321
            None,
×
322
            running,
×
323
            statuses,
×
324
            &context.projects,
×
325
            false,
326
        )
327
    }
328
}
329

330
struct PlainRenderer;
331

332
impl ProgressRenderer for PlainRenderer {
333
    fn compose_event(
29✔
334
        &mut self,
335
        event: &ComposeEvent,
336
    ) -> anyhow::Result<()> {
337
        render_compose_event(event);
58✔
338
        Ok(())
29✔
339
    }
340
}
341

342
struct HiddenRenderer;
343

344
impl ProgressRenderer for HiddenRenderer {}
345

346
pub(crate) fn progress_renderer(
9✔
347
    presentation: ProgressPresentation
348
) -> Box<dyn ProgressRenderer> {
349
    match presentation {
9✔
350
        ProgressPresentation::Progress => {
NEW
351
            Box::<ProgressStatusRenderer>::default()
×
352
        }
353
        ProgressPresentation::Plain => Box::new(PlainRenderer),
8✔
354
        ProgressPresentation::Hidden => Box::new(HiddenRenderer),
1✔
355
    }
356
}
357

358
fn render_compose_event(event: &ComposeEvent) {
29✔
359
    match event {
29✔
360
        ComposeEvent::ProjectStarted { project } => {
10✔
361
            println!("[{}]", project.as_str().cyan());
15✔
362
        }
363
        ComposeEvent::Process { event, .. } => render_process_event(event),
72✔
364
        ComposeEvent::ProjectFailed { project, error } => {
×
365
            eprintln!("Project '{}' failed: {}", project, error);
×
366
            println!();
×
367
        }
368
    }
369
}
370

371
fn render_process_event(event: &ProcessEvent) {
24✔
372
    match event {
24✔
373
        ProcessEvent::StdoutLine(line) => println!("{}", line),
24✔
374
        ProcessEvent::StderrLine(line) => {
8✔
375
            if !line.contains("the attribute `version` is obsolete") {
16✔
376
                eprintln!("{}", line);
8✔
377
            }
378
        }
379
        ProcessEvent::Exited(_) => {}
8✔
380
    }
381
}
382

383
#[cfg(test)]
384
mod tests {
385
    use super::*;
386
    use nirion_lib::docker::{Port, ServiceState, ServiceStatus};
387

388
    fn projects() -> Projects {
389
        serde_json::from_str(
390
            r#"
391
{
392
  "app": {
393
    "name": "app",
394
    "dockerCompose": "compose.yml",
395
    "services": {
396
      "web": {"image": "nginx", "healthcheck": true, "restart": null},
397
      "db": {"image": "postgres", "healthcheck": true, "restart": null}
398
    }
399
  }
400
}
401
"#,
402
        )
403
        .unwrap()
404
    }
405

406
    fn service_status(
407
        service: &str,
408
        state: ServiceState,
409
    ) -> ServiceStatus {
410
        ServiceStatus {
411
            id: format!("{service}-id"),
412
            service: service.to_string(),
413
            container_name: service.to_string(),
414
            image: "image".to_string(),
415
            state,
416
            health: None,
417
            exit_code: None,
418
            running_for: None,
419
            status: None,
420
            ports: Vec::<Port>::new(),
421
            networks: Vec::new(),
422
        }
423
    }
424

425
    #[test]
426
    fn create_status_uses_empty_status_when_project_has_no_status() {
427
        let projects = projects();
428
        let selected = vec!["app".to_string()];
429
        let running = BTreeMap::new();
430
        let statuses = BTreeMap::new();
431

432
        let status =
433
            create_status(None, &selected, &running, &statuses, &projects);
434

435
        assert_eq!(status.entries.len(), 1);
436
        assert_eq!(status.entries[0].segments, vec![Color::Grey, Color::Grey]);
437
        assert_eq!(status.entries[0].suffix, "(0/2)    ");
438
    }
439

440
    #[test]
441
    fn create_status_pads_missing_service_segments() {
442
        let projects = projects();
443
        let selected = vec!["app".to_string()];
444
        let running = BTreeMap::from([("app".to_string(), false)]);
445
        let statuses = BTreeMap::from([(
446
            "app".to_string(),
447
            ProjectStatus {
448
                services: BTreeMap::from([(
449
                    "web".to_string(),
450
                    service_status("web", ServiceState::Healthy),
451
                )]),
452
            },
453
        )]);
454

455
        let status =
456
            create_status(None, &selected, &running, &statuses, &projects);
457

458
        assert_eq!(status.entries.len(), 1);
459
        assert_eq!(status.entries[0].segments.len(), 2);
460
        assert_eq!(
461
            status.entries[0].segments[0],
462
            project_status_segments(&statuses["app"])[0]
463
        );
464
        assert_eq!(status.entries[0].segments[1], Color::Grey);
465
        assert_eq!(status.entries[0].suffix, "(1/2)    ");
466
    }
467
}
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