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

organicveggie / saurron / 24701438181

21 Apr 2026 02:49AM UTC coverage: 37.85% (-6.0%) from 43.855%
24701438181

push

github

web-flow
Merge pull request #12 from organicveggie/registry-client-passwd

Add client for container registries

100 of 321 new or added lines in 4 files covered. (31.15%)

1 existing line in 1 file now uncovered.

257 of 679 relevant lines covered (37.85%)

244.28 hits per line

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

57.34
/src/docker.rs
1
use std::collections::{HashMap, HashSet};
2
use std::path::{Path, PathBuf};
3

4
use anyhow::{Context, Result, bail};
5
use bollard::API_DEFAULT_VERSION;
6
use bollard::Docker;
7

8
use crate::config::DockerConfig;
9

10
pub struct DockerClient {
11
    inner: Docker,
12
}
13

14
#[derive(Debug, PartialEq)]
15
enum ConnectionType {
16
    Socket,
17
    Http,
18
    Https,
19
}
20

21
fn connection_type(host: &str, tls_verify: bool) -> ConnectionType {
7✔
22
    if host.starts_with("unix://") || host.starts_with("npipe://") || host.starts_with('/') {
36✔
23
        ConnectionType::Socket
3✔
24
    } else if tls_verify || host.starts_with("https://") {
10✔
25
        ConnectionType::Https
2✔
26
    } else {
27
        ConnectionType::Http
2✔
28
    }
29
}
30

31
fn parse_api_version(version: &str) -> Result<bollard::ClientVersion> {
4✔
32
    let v = version.trim_start_matches('v');
12✔
33
    let (major_str, minor_str) = v
8✔
34
        .split_once('.')
35
        .context("API version must be in 'major.minor' format")?;
36
    let major_version = major_str
4✔
37
        .parse::<usize>()
38
        .context("invalid API version major component")?;
39
    let minor_version = minor_str
4✔
40
        .parse::<usize>()
41
        .context("invalid API version minor component")?;
42
    Ok(bollard::ClientVersion {
2✔
43
        major_version,
2✔
44
        minor_version,
2✔
45
    })
46
}
47

48
// ── Container state ──────────────────────────────────────────────────────────
49

50
#[derive(Debug, Clone, PartialEq)]
51
pub enum ContainerState {
52
    Created,
53
    Restarting,
54
    Running,
55
    Removing,
56
    Paused,
57
    Exited,
58
    Dead,
59
    Unknown(String),
60
}
61

62
impl ContainerState {
63
    pub fn from_str(s: &str) -> Self {
8✔
64
        match s {
8✔
65
            "created" => Self::Created,
9✔
66
            "restarting" => Self::Restarting,
8✔
67
            "running" => Self::Running,
7✔
68
            "removing" => Self::Removing,
6✔
69
            "paused" => Self::Paused,
5✔
70
            "exited" => Self::Exited,
4✔
71
            "dead" => Self::Dead,
3✔
72
            other => Self::Unknown(other.to_string()),
2✔
73
        }
74
    }
75
}
76

77
impl std::fmt::Display for ContainerState {
78
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3✔
79
        let s = match self {
6✔
80
            Self::Created => "created",
×
81
            Self::Restarting => "restarting",
×
82
            Self::Running => "running",
1✔
83
            Self::Removing => "removing",
×
84
            Self::Paused => "paused",
×
85
            Self::Exited => "exited",
1✔
86
            Self::Dead => "dead",
×
87
            Self::Unknown(s) => s,
2✔
88
        };
89
        f.write_str(s)
9✔
90
    }
91
}
92

93
// ── ContainerInfo ─────────────────────────────────────────────────────────────
94

95
#[derive(Debug, Clone)]
96
pub struct ContainerInfo {
97
    pub id: String,
98
    pub name: String,
99
    pub image: String,
100
    pub image_id: String,
101
    pub state: ContainerState,
102
    pub labels: HashMap<String, String>,
103
}
104

105
impl ContainerInfo {
106
    pub fn saurron_labels(&self) -> SaurronLabels {
23✔
107
        SaurronLabels::from_labels(&self.labels)
46✔
108
    }
109
}
110

111
// ── Saurron per-container labels ──────────────────────────────────────────────
112

113
const LABEL_ENABLE: &str = "saurron.enable";
114
const LABEL_SCOPE: &str = "saurron.scope";
115
const LABEL_DEPENDS_ON: &str = "saurron.depends-on";
116
const LABEL_IMAGE_TAG: &str = "saurron.image-tag";
117
const LABEL_SEMVER_PRE_RELEASE: &str = "saurron.semver-pre-release";
118
const LABEL_NON_SEMVER_STRATEGY: &str = "saurron.non-semver-strategy";
119

120
#[derive(Debug, Clone, Default, PartialEq)]
121
pub struct SaurronLabels {
122
    pub enable: Option<bool>,
123
    pub scope: Option<String>,
124
    pub depends_on: Vec<String>,
125
    pub image_tag: Option<String>,
126
    /// Include pre-release versions when selecting the latest SemVer tag.
127
    pub semver_pre_release: Option<bool>,
128
    /// Override non-semver tag strategy: `"digest"` (default) or `"skip"`.
129
    pub non_semver_strategy: Option<String>,
130
}
131

132
impl SaurronLabels {
133
    pub fn from_labels(labels: &HashMap<String, String>) -> Self {
46✔
134
        Self {
135
            enable: labels.get(LABEL_ENABLE).and_then(|v| parse_bool_label(v)),
222✔
136
            scope: labels.get(LABEL_SCOPE).filter(|v| !v.is_empty()).cloned(),
234✔
137
            depends_on: labels
46✔
138
                .get(LABEL_DEPENDS_ON)
139
                .map(|v| parse_depends_on(v))
140
                .unwrap_or_default(),
141
            image_tag: labels
46✔
142
                .get(LABEL_IMAGE_TAG)
143
                .filter(|v| !v.is_empty())
144
                .cloned(),
145
            semver_pre_release: labels
46✔
146
                .get(LABEL_SEMVER_PRE_RELEASE)
147
                .and_then(|v| parse_bool_label(v)),
148
            non_semver_strategy: labels
46✔
149
                .get(LABEL_NON_SEMVER_STRATEGY)
150
                .filter(|v| !v.is_empty())
151
                .cloned(),
152
        }
153
    }
154
}
155

156
fn parse_bool_label(value: &str) -> Option<bool> {
22✔
157
    match value.trim().to_ascii_lowercase().as_str() {
22✔
158
        "true" => Some(true),
33✔
159
        "false" => Some(false),
18✔
160
        _ => None,
4✔
161
    }
162
}
163

164
fn parse_depends_on(value: &str) -> Vec<String> {
4✔
165
    value
4✔
166
        .split(',')
167
        .map(|s| s.trim().to_string())
26✔
168
        .filter(|s| !s.is_empty())
26✔
169
        .collect()
170
}
171

172
// ── Container selection ───────────────────────────────────────────────────────
173

174
pub struct ContainerSelector {
175
    label_enable: bool,
176
    global_takes_precedence: bool,
177
    disabled_names: HashSet<String>,
178
    allowed_names: Option<HashSet<String>>,
179
    include_restarting: bool,
180
    revive_stopped: bool,
181
}
182

183
impl ContainerSelector {
184
    pub fn new(
25✔
185
        label_enable: bool,
186
        global_takes_precedence: bool,
187
        disable_containers: &[String],
188
        containers: &[String],
189
        include_restarting: bool,
190
        revive_stopped: bool,
191
    ) -> Self {
192
        Self {
193
            label_enable,
194
            global_takes_precedence,
195
            disabled_names: disable_containers.iter().cloned().collect(),
100✔
196
            allowed_names: if containers.is_empty() {
50✔
197
                None
198
            } else {
199
                Some(containers.iter().cloned().collect())
200
            },
201
            include_restarting,
202
            revive_stopped,
203
        }
204
    }
205

206
    /// Returns the Docker container state strings to pass as list filters.
207
    pub fn state_filter(&self) -> Vec<&'static str> {
4✔
208
        let mut states = vec!["running"];
8✔
209
        if self.include_restarting {
6✔
210
            states.push("restarting");
4✔
211
        }
212
        if self.revive_stopped {
6✔
213
            states.push("exited");
8✔
214
            states.push("created");
4✔
215
        }
216
        states
4✔
217
    }
218

219
    /// Returns true if this container should be included in the update cycle.
220
    pub fn is_selected(&self, container: &ContainerInfo) -> bool {
28✔
221
        if let Some(ref allowed) = self.allowed_names {
37✔
222
            if !allowed.contains(&container.name) {
18✔
223
                return false;
2✔
224
            }
225
        }
226

227
        if self.disabled_names.contains(&container.name) {
78✔
228
            return false;
4✔
229
        }
230

231
        let labels = container.saurron_labels();
66✔
232

233
        if self.label_enable {
22✔
234
            // Opt-in: only containers explicitly enabled via label
235
            matches!(labels.enable, Some(true))
12✔
236
        } else if self.global_takes_precedence {
14✔
237
            // Opt-out with global precedence: per-container disable label is ignored
238
            true
2✔
239
        } else {
240
            // Opt-out: include unless the container opts out via label
241
            !matches!(labels.enable, Some(false))
22✔
242
        }
243
    }
244

245
    pub fn select(&self, containers: &[ContainerInfo]) -> Vec<ContainerInfo> {
4✔
246
        containers
4✔
247
            .iter()
248
            .filter(|c| self.is_selected(c))
31✔
249
            .cloned()
250
            .collect()
251
    }
252
}
253

254
// ── Local image info ──────────────────────────────────────────────────────────
255

256
/// Canonical name and manifest digest of a locally-present image.
257
#[derive(Debug, Default, PartialEq)]
258
pub struct LocalImageInfo {
259
    /// First `RepoTags` entry, e.g. `"postgres:15"`.
260
    pub name: Option<String>,
261
    /// Manifest digest from first `RepoDigests` entry (part after `@`),
262
    /// e.g. `"sha256:6eed15406dbba206cb1260528a3354d80d2522cab068cb9ad7a1ede5ac90e6f6"`.
263
    pub digest: Option<String>,
264
}
265

266
fn local_image_info_from_inspect(inspect: &bollard::models::ImageInspect) -> LocalImageInfo {
4✔
267
    let name = inspect
8✔
268
        .repo_tags
4✔
269
        .as_deref()
270
        .and_then(|tags| tags.first())
10✔
271
        .cloned();
272
    let digest = inspect
8✔
273
        .repo_digests
4✔
274
        .as_deref()
275
        .and_then(|digests| digests.first())
10✔
276
        .and_then(|rd| rd.split_once('@').map(|(_, d)| d.to_string()));
16✔
277
    LocalImageInfo { name, digest }
278
}
279

280
// ── Bollard summary → ContainerInfo ──────────────────────────────────────────
281

282
fn summary_to_info(s: bollard::models::ContainerSummary) -> Option<ContainerInfo> {
×
283
    Some(ContainerInfo {
284
        id: s.id?,
×
285
        name: s
×
286
            .names
×
287
            .as_deref()
×
288
            .and_then(|v| v.first())
×
289
            .map(|n| n.trim_start_matches('/').to_string())
×
290
            .unwrap_or_default(),
×
291
        image: s.image.unwrap_or_default(),
×
292
        image_id: s.image_id.unwrap_or_default(),
×
293
        state: ContainerState::from_str(s.state.as_deref().unwrap_or("unknown")),
×
294
        labels: s.labels.unwrap_or_default(),
×
295
    })
296
}
297

298
// ── Docker client ─────────────────────────────────────────────────────────────
299

300
impl DockerClient {
301
    pub fn connect(config: &DockerConfig) -> Result<Self> {
×
302
        let api_version = config
×
303
            .api_version
×
304
            .as_deref()
305
            .map(parse_api_version)
×
306
            .transpose()?;
307
        let version = api_version.as_ref().unwrap_or(API_DEFAULT_VERSION);
×
308

309
        let inner = match connection_type(&config.host, config.tls_verify) {
×
310
            ConnectionType::Socket => Docker::connect_with_socket(&config.host, 120, version)
×
311
                .context("failed to connect to Docker via Unix socket")?,
312

313
            ConnectionType::Http => Docker::connect_with_http(&config.host, 120, version)
×
314
                .context("failed to connect to Docker via HTTP")?,
315

316
            ConnectionType::Https => {
317
                let ca = config
×
318
                    .tls_ca_cert
×
319
                    .as_deref()
320
                    .context("--tls-ca-cert is required when --tlsverify is set")?;
321

322
                let (key_path, cert_path): (PathBuf, PathBuf) =
×
323
                    match (&config.tls_key, &config.tls_cert) {
×
324
                        (Some(k), Some(c)) => (PathBuf::from(k), PathBuf::from(c)),
×
325
                        (None, None) => {
326
                            // Server-only TLS: bollard resolver returns None → no client auth
327
                            (PathBuf::new(), PathBuf::new())
×
328
                        }
329
                        _ => bail!("--tls-cert and --tls-key must be provided together"),
×
330
                    };
331

332
                Docker::connect_with_ssl(
333
                    &config.host,
×
334
                    &key_path,
×
335
                    &cert_path,
×
336
                    Path::new(ca),
×
337
                    120,
338
                    version,
×
339
                )
340
                .context("failed to connect to Docker via TLS")?
341
            }
342
        };
343

344
        Ok(Self { inner })
×
345
    }
346

347
    pub async fn ping(&self) -> Result<()> {
×
348
        self.inner
×
349
            .ping()
350
            .await
×
351
            .context("Docker daemon ping failed")?;
352
        Ok(())
×
353
    }
354

355
    pub async fn list_containers(
×
356
        &self,
357
        selector: &ContainerSelector,
358
    ) -> Result<Vec<ContainerInfo>> {
359
        use bollard::container::ListContainersOptions;
360
        let status_filter: Vec<String> = selector
×
361
            .state_filter()
362
            .iter()
363
            .map(|s| s.to_string())
×
364
            .collect();
365
        let mut filters = HashMap::new();
×
366
        filters.insert("status".to_string(), status_filter);
×
367
        let opts = ListContainersOptions {
368
            all: true,
369
            filters,
370
            ..Default::default()
371
        };
372
        let summaries = self
×
373
            .inner
×
374
            .list_containers(Some(opts))
×
375
            .await
×
376
            .context("failed to list containers")?;
377
        Ok(summaries.into_iter().filter_map(summary_to_info).collect())
×
378
    }
379

380
    pub fn select_containers(
×
381
        &self,
382
        containers: &[ContainerInfo],
383
        selector: &ContainerSelector,
384
    ) -> Vec<ContainerInfo> {
385
        selector.select(containers)
×
386
    }
387

388
    /// Inspects a local image (by name or sha256 ID) and returns its canonical
389
    /// name and manifest digest.
390
    ///
391
    /// `name` is taken from the first `RepoTags` entry (e.g. `"postgres:15"`).
392
    /// `digest` is taken from the first `RepoDigests` entry after the `@`
393
    /// separator (e.g. `"sha256:6eed15..."`).
394
    ///
395
    /// Either field may be `None` for dangling or locally-built images.
NEW
396
    pub async fn get_local_image_info(&self, image: &str) -> Result<LocalImageInfo> {
×
NEW
397
        let inspect = self
×
NEW
398
            .inner
×
NEW
399
            .inspect_image(image)
×
NEW
400
            .await
×
NEW
401
            .with_context(|| format!("failed to inspect image '{image}'"))?;
×
402

NEW
403
        Ok(local_image_info_from_inspect(&inspect))
×
404
    }
405
}
406

407
#[cfg(test)]
408
mod tests {
409
    use super::*;
410

411
    #[test]
412
    fn unix_socket_by_scheme() {
413
        assert_eq!(
414
            connection_type("unix:///var/run/docker.sock", false),
415
            ConnectionType::Socket
416
        );
417
    }
418

419
    #[test]
420
    fn unix_socket_by_path() {
421
        assert_eq!(
422
            connection_type("/var/run/docker.sock", false),
423
            ConnectionType::Socket
424
        );
425
    }
426

427
    #[test]
428
    fn npipe_is_socket() {
429
        assert_eq!(
430
            connection_type("npipe:////./pipe/docker_engine", false),
431
            ConnectionType::Socket
432
        );
433
    }
434

435
    #[test]
436
    fn tcp_without_tls_is_http() {
437
        assert_eq!(
438
            connection_type("tcp://localhost:2375", false),
439
            ConnectionType::Http
440
        );
441
    }
442

443
    #[test]
444
    fn http_scheme_is_http() {
445
        assert_eq!(
446
            connection_type("http://localhost:2375", false),
447
            ConnectionType::Http
448
        );
449
    }
450

451
    #[test]
452
    fn tls_verify_flag_forces_https() {
453
        assert_eq!(
454
            connection_type("tcp://localhost:2376", true),
455
            ConnectionType::Https
456
        );
457
    }
458

459
    #[test]
460
    fn https_scheme_selects_https() {
461
        assert_eq!(
462
            connection_type("https://localhost:2376", false),
463
            ConnectionType::Https
464
        );
465
    }
466

467
    #[test]
468
    fn parse_api_version_standard() {
469
        let v = parse_api_version("1.44").unwrap();
470
        assert_eq!(v.major_version, 1);
471
        assert_eq!(v.minor_version, 44);
472
    }
473

474
    #[test]
475
    fn parse_api_version_with_v_prefix() {
476
        let v = parse_api_version("v1.44").unwrap();
477
        assert_eq!(v.major_version, 1);
478
        assert_eq!(v.minor_version, 44);
479
    }
480

481
    #[test]
482
    fn parse_api_version_invalid_format() {
483
        assert!(parse_api_version("not-a-version").is_err());
484
    }
485

486
    #[test]
487
    fn parse_api_version_missing_minor() {
488
        assert!(parse_api_version("1").is_err());
489
    }
490

491
    // ── ContainerState ────────────────────────────────────────────────────────
492

493
    #[test]
494
    fn container_state_known_variants() {
495
        assert_eq!(ContainerState::from_str("created"), ContainerState::Created);
496
        assert_eq!(
497
            ContainerState::from_str("restarting"),
498
            ContainerState::Restarting
499
        );
500
        assert_eq!(ContainerState::from_str("running"), ContainerState::Running);
501
        assert_eq!(
502
            ContainerState::from_str("removing"),
503
            ContainerState::Removing
504
        );
505
        assert_eq!(ContainerState::from_str("paused"), ContainerState::Paused);
506
        assert_eq!(ContainerState::from_str("exited"), ContainerState::Exited);
507
        assert_eq!(ContainerState::from_str("dead"), ContainerState::Dead);
508
    }
509

510
    #[test]
511
    fn container_state_unknown_preserved() {
512
        assert_eq!(
513
            ContainerState::from_str("something-new"),
514
            ContainerState::Unknown("something-new".to_string())
515
        );
516
    }
517

518
    #[test]
519
    fn container_state_display() {
520
        assert_eq!(ContainerState::Running.to_string(), "running");
521
        assert_eq!(ContainerState::Exited.to_string(), "exited");
522
        assert_eq!(
523
            ContainerState::Unknown("weird".to_string()).to_string(),
524
            "weird"
525
        );
526
    }
527

528
    // ── SaurronLabels ─────────────────────────────────────────────────────────
529

530
    fn labels(pairs: &[(&str, &str)]) -> HashMap<String, String> {
531
        pairs
532
            .iter()
533
            .map(|(k, v)| (k.to_string(), v.to_string()))
534
            .collect()
535
    }
536

537
    #[test]
538
    fn saurron_labels_empty_map_gives_defaults() {
539
        assert_eq!(
540
            SaurronLabels::from_labels(&HashMap::new()),
541
            SaurronLabels::default()
542
        );
543
    }
544

545
    #[test]
546
    fn saurron_labels_enable_true() {
547
        let l = SaurronLabels::from_labels(&labels(&[("saurron.enable", "true")]));
548
        assert_eq!(l.enable, Some(true));
549
    }
550

551
    #[test]
552
    fn saurron_labels_enable_false() {
553
        let l = SaurronLabels::from_labels(&labels(&[("saurron.enable", "false")]));
554
        assert_eq!(l.enable, Some(false));
555
    }
556

557
    #[test]
558
    fn saurron_labels_enable_case_insensitive() {
559
        let l = SaurronLabels::from_labels(&labels(&[("saurron.enable", "TRUE")]));
560
        assert_eq!(l.enable, Some(true));
561
        let l = SaurronLabels::from_labels(&labels(&[("saurron.enable", "False")]));
562
        assert_eq!(l.enable, Some(false));
563
    }
564

565
    #[test]
566
    fn saurron_labels_enable_invalid_value_is_none() {
567
        let l = SaurronLabels::from_labels(&labels(&[("saurron.enable", "yes")]));
568
        assert_eq!(l.enable, None);
569
        let l = SaurronLabels::from_labels(&labels(&[("saurron.enable", "1")]));
570
        assert_eq!(l.enable, None);
571
        let l = SaurronLabels::from_labels(&labels(&[("saurron.enable", "")]));
572
        assert_eq!(l.enable, None);
573
    }
574

575
    #[test]
576
    fn saurron_labels_scope() {
577
        let l = SaurronLabels::from_labels(&labels(&[("saurron.scope", "production")]));
578
        assert_eq!(l.scope, Some("production".to_string()));
579
    }
580

581
    #[test]
582
    fn saurron_labels_scope_empty_is_none() {
583
        let l = SaurronLabels::from_labels(&labels(&[("saurron.scope", "")]));
584
        assert_eq!(l.scope, None);
585
    }
586

587
    #[test]
588
    fn saurron_labels_depends_on_multiple() {
589
        let l = SaurronLabels::from_labels(&labels(&[("saurron.depends-on", "db,redis")]));
590
        assert_eq!(l.depends_on, vec!["db", "redis"]);
591
    }
592

593
    #[test]
594
    fn saurron_labels_depends_on_trims_whitespace() {
595
        let l = SaurronLabels::from_labels(&labels(&[("saurron.depends-on", "db, redis , cache")]));
596
        assert_eq!(l.depends_on, vec!["db", "redis", "cache"]);
597
    }
598

599
    #[test]
600
    fn saurron_labels_depends_on_empty_string_gives_empty_vec() {
601
        let l = SaurronLabels::from_labels(&labels(&[("saurron.depends-on", "")]));
602
        assert_eq!(l.depends_on, Vec::<String>::new());
603
    }
604

605
    #[test]
606
    fn saurron_labels_depends_on_sparse_commas_filtered() {
607
        let l = SaurronLabels::from_labels(&labels(&[("saurron.depends-on", ",db,,redis,")]));
608
        assert_eq!(l.depends_on, vec!["db", "redis"]);
609
    }
610

611
    #[test]
612
    fn saurron_labels_image_tag() {
613
        let l = SaurronLabels::from_labels(&labels(&[("saurron.image-tag", "v1.2.3")]));
614
        assert_eq!(l.image_tag, Some("v1.2.3".to_string()));
615
    }
616

617
    #[test]
618
    fn saurron_labels_image_tag_empty_is_none() {
619
        let l = SaurronLabels::from_labels(&labels(&[("saurron.image-tag", "")]));
620
        assert_eq!(l.image_tag, None);
621
    }
622

623
    #[test]
624
    fn saurron_labels_semver_pre_release_true() {
625
        let l = SaurronLabels::from_labels(&labels(&[("saurron.semver-pre-release", "true")]));
626
        assert_eq!(l.semver_pre_release, Some(true));
627
    }
628

629
    #[test]
630
    fn saurron_labels_semver_pre_release_false() {
631
        let l = SaurronLabels::from_labels(&labels(&[("saurron.semver-pre-release", "false")]));
632
        assert_eq!(l.semver_pre_release, Some(false));
633
    }
634

635
    #[test]
636
    fn saurron_labels_semver_pre_release_invalid_is_none() {
637
        let l = SaurronLabels::from_labels(&labels(&[("saurron.semver-pre-release", "yes")]));
638
        assert_eq!(l.semver_pre_release, None);
639
    }
640

641
    #[test]
642
    fn saurron_labels_non_semver_strategy_skip() {
643
        let l = SaurronLabels::from_labels(&labels(&[("saurron.non-semver-strategy", "skip")]));
644
        assert_eq!(l.non_semver_strategy, Some("skip".to_string()));
645
    }
646

647
    #[test]
648
    fn saurron_labels_non_semver_strategy_digest() {
649
        let l = SaurronLabels::from_labels(&labels(&[("saurron.non-semver-strategy", "digest")]));
650
        assert_eq!(l.non_semver_strategy, Some("digest".to_string()));
651
    }
652

653
    #[test]
654
    fn saurron_labels_non_semver_strategy_empty_is_none() {
655
        let l = SaurronLabels::from_labels(&labels(&[("saurron.non-semver-strategy", "")]));
656
        assert_eq!(l.non_semver_strategy, None);
657
    }
658

659
    #[test]
660
    fn saurron_labels_unknown_saurron_labels_ignored() {
661
        let l = SaurronLabels::from_labels(&labels(&[
662
            ("saurron.enable", "true"),
663
            ("saurron.future-feature", "somevalue"),
664
            ("com.example.app", "myapp"),
665
        ]));
666
        assert_eq!(l.enable, Some(true));
667
        assert_eq!(l.scope, None);
668
    }
669

670
    #[test]
671
    fn container_info_saurron_labels_convenience() {
672
        let info = ContainerInfo {
673
            id: "abc123".to_string(),
674
            name: "mycontainer".to_string(),
675
            image: "nginx:latest".to_string(),
676
            image_id: "sha256:abc".to_string(),
677
            state: ContainerState::Running,
678
            labels: labels(&[("saurron.enable", "true"), ("saurron.image-tag", "stable")]),
679
        };
680
        let sl = info.saurron_labels();
681
        assert_eq!(sl.enable, Some(true));
682
        assert_eq!(sl.image_tag, Some("stable".to_string()));
683
    }
684

685
    // ── local_image_info_from_inspect ─────────────────────────────────────────
686

687
    fn make_inspect(
688
        repo_tags: Option<Vec<&str>>,
689
        repo_digests: Option<Vec<&str>>,
690
    ) -> bollard::models::ImageInspect {
691
        bollard::models::ImageInspect {
692
            repo_tags: repo_tags.map(|v| v.into_iter().map(String::from).collect()),
693
            repo_digests: repo_digests.map(|v| v.into_iter().map(String::from).collect()),
694
            ..Default::default()
695
        }
696
    }
697

698
    #[test]
699
    fn image_info_name_from_first_repo_tag() {
700
        let inspect = make_inspect(
701
            Some(vec!["postgres:15", "postgres:latest"]),
702
            Some(vec![
703
                "postgres@sha256:6eed15406dbba206cb1260528a3354d80d2522cab068cb9ad7a1ede5ac90e6f6",
704
            ]),
705
        );
706
        let info = local_image_info_from_inspect(&inspect);
707
        assert_eq!(info.name, Some("postgres:15".to_string()));
708
        assert_eq!(
709
            info.digest,
710
            Some(
711
                "sha256:6eed15406dbba206cb1260528a3354d80d2522cab068cb9ad7a1ede5ac90e6f6"
712
                    .to_string()
713
            )
714
        );
715
    }
716

717
    #[test]
718
    fn image_info_empty_repo_tags_gives_none_name() {
719
        let inspect = make_inspect(Some(vec![]), Some(vec!["postgres@sha256:abc"]));
720
        let info = local_image_info_from_inspect(&inspect);
721
        assert_eq!(info.name, None);
722
        assert_eq!(info.digest, Some("sha256:abc".to_string()));
723
    }
724

725
    #[test]
726
    fn image_info_none_fields_give_none() {
727
        let inspect = make_inspect(None, None);
728
        assert_eq!(
729
            local_image_info_from_inspect(&inspect),
730
            LocalImageInfo::default()
731
        );
732
    }
733

734
    #[test]
735
    fn image_info_digest_extracted_after_at_sign() {
736
        let inspect = make_inspect(
737
            Some(vec!["nginx:latest"]),
738
            Some(vec!["nginx@sha256:deadbeef"]),
739
        );
740
        let info = local_image_info_from_inspect(&inspect);
741
        assert_eq!(info.digest, Some("sha256:deadbeef".to_string()));
742
    }
743

744
    // ── ContainerSelector ─────────────────────────────────────────────────────
745

746
    fn make_container(name: &str, state: ContainerState, ls: &[(&str, &str)]) -> ContainerInfo {
747
        ContainerInfo {
748
            id: format!("{name}_id"),
749
            name: name.to_string(),
750
            image: format!("{name}:latest"),
751
            image_id: "sha256:abc".to_string(),
752
            state,
753
            labels: labels(ls),
754
        }
755
    }
756

757
    fn running(name: &str, ls: &[(&str, &str)]) -> ContainerInfo {
758
        make_container(name, ContainerState::Running, ls)
759
    }
760

761
    fn opt_out() -> ContainerSelector {
762
        ContainerSelector::new(false, false, &[], &[], false, false)
763
    }
764

765
    fn opt_in() -> ContainerSelector {
766
        ContainerSelector::new(true, false, &[], &[], false, false)
767
    }
768

769
    // State filter
770

771
    #[test]
772
    fn state_filter_default_is_running_only() {
773
        assert_eq!(opt_out().state_filter(), vec!["running"]);
774
    }
775

776
    #[test]
777
    fn state_filter_include_restarting() {
778
        let sel = ContainerSelector::new(false, false, &[], &[], true, false);
779
        assert_eq!(sel.state_filter(), vec!["running", "restarting"]);
780
    }
781

782
    #[test]
783
    fn state_filter_revive_stopped() {
784
        let sel = ContainerSelector::new(false, false, &[], &[], false, true);
785
        assert_eq!(sel.state_filter(), vec!["running", "exited", "created"]);
786
    }
787

788
    #[test]
789
    fn state_filter_both_flags() {
790
        let sel = ContainerSelector::new(false, false, &[], &[], true, true);
791
        assert_eq!(
792
            sel.state_filter(),
793
            vec!["running", "restarting", "exited", "created"]
794
        );
795
    }
796

797
    // Opt-out selection
798

799
    #[test]
800
    fn opt_out_no_labels_included() {
801
        assert!(opt_out().is_selected(&running("app", &[])));
802
    }
803

804
    #[test]
805
    fn opt_out_enable_true_included() {
806
        assert!(opt_out().is_selected(&running("app", &[("saurron.enable", "true")])));
807
    }
808

809
    #[test]
810
    fn opt_out_enable_false_excluded() {
811
        assert!(!opt_out().is_selected(&running("app", &[("saurron.enable", "false")])));
812
    }
813

814
    // Opt-in selection
815

816
    #[test]
817
    fn opt_in_no_labels_excluded() {
818
        assert!(!opt_in().is_selected(&running("app", &[])));
819
    }
820

821
    #[test]
822
    fn opt_in_enable_true_included() {
823
        assert!(opt_in().is_selected(&running("app", &[("saurron.enable", "true")])));
824
    }
825

826
    #[test]
827
    fn opt_in_enable_false_excluded() {
828
        assert!(!opt_in().is_selected(&running("app", &[("saurron.enable", "false")])));
829
    }
830

831
    // disable_containers
832

833
    #[test]
834
    fn disabled_name_excluded_in_opt_out() {
835
        let sel = ContainerSelector::new(false, false, &["app".to_string()], &[], false, false);
836
        assert!(!sel.is_selected(&running("app", &[("saurron.enable", "true")])));
837
    }
838

839
    #[test]
840
    fn disabled_name_excluded_in_opt_in() {
841
        let sel = ContainerSelector::new(true, false, &["app".to_string()], &[], false, false);
842
        assert!(!sel.is_selected(&running("app", &[("saurron.enable", "true")])));
843
    }
844

845
    #[test]
846
    fn non_disabled_name_unaffected() {
847
        let sel = ContainerSelector::new(false, false, &["other".to_string()], &[], false, false);
848
        assert!(sel.is_selected(&running("app", &[])));
849
    }
850

851
    // global_takes_precedence
852

853
    #[test]
854
    fn global_precedence_overrides_per_container_disable() {
855
        let sel = ContainerSelector::new(false, true, &[], &[], false, false);
856
        assert!(sel.is_selected(&running("app", &[("saurron.enable", "false")])));
857
    }
858

859
    #[test]
860
    fn global_precedence_disable_containers_still_excluded() {
861
        let sel = ContainerSelector::new(false, true, &["app".to_string()], &[], false, false);
862
        assert!(!sel.is_selected(&running("app", &[])));
863
    }
864

865
    #[test]
866
    fn global_precedence_no_label_still_included() {
867
        let sel = ContainerSelector::new(false, true, &[], &[], false, false);
868
        assert!(sel.is_selected(&running("app", &[])));
869
    }
870

871
    // select()
872

873
    #[test]
874
    fn select_returns_only_matching_containers() {
875
        let containers = vec![
876
            running("enabled", &[("saurron.enable", "true")]),
877
            running("unlabelled", &[]),
878
            running("disabled", &[("saurron.enable", "false")]),
879
        ];
880
        let result = opt_out().select(&containers);
881
        assert_eq!(result.len(), 2);
882
        assert!(result.iter().any(|c| c.name == "enabled"));
883
        assert!(result.iter().any(|c| c.name == "unlabelled"));
884
    }
885

886
    #[test]
887
    fn select_empty_input_returns_empty() {
888
        assert!(opt_out().select(&[]).is_empty());
889
    }
890

891
    #[test]
892
    fn select_opt_in_filters_to_enabled_only() {
893
        let containers = vec![
894
            running("a", &[("saurron.enable", "true")]),
895
            running("b", &[]),
896
            running("c", &[("saurron.enable", "true")]),
897
        ];
898
        let result = opt_in().select(&containers);
899
        assert_eq!(result.len(), 2);
900
        assert!(result.iter().all(|c| c.name == "a" || c.name == "c"));
901
    }
902

903
    // allowed_names (--containers)
904

905
    #[test]
906
    fn allowed_names_empty_slice_means_no_restriction() {
907
        let sel = ContainerSelector::new(false, false, &[], &[], false, false);
908
        assert!(sel.allowed_names.is_none());
909
        assert!(sel.is_selected(&running("any", &[])));
910
    }
911

912
    #[test]
913
    fn allowed_names_matching_container_included() {
914
        let sel = ContainerSelector::new(
915
            false,
916
            false,
917
            &[],
918
            &["foo".to_string(), "bar".to_string()],
919
            false,
920
            false,
921
        );
922
        assert!(sel.is_selected(&running("foo", &[])));
923
        assert!(sel.is_selected(&running("bar", &[])));
924
    }
925

926
    #[test]
927
    fn allowed_names_non_matching_container_excluded() {
928
        let sel = ContainerSelector::new(false, false, &[], &["foo".to_string()], false, false);
929
        assert!(!sel.is_selected(&running("other", &[])));
930
    }
931

932
    #[test]
933
    fn allowed_names_disable_containers_still_excludes() {
934
        let sel = ContainerSelector::new(
935
            false,
936
            false,
937
            &["foo".to_string()],
938
            &["foo".to_string()],
939
            false,
940
            false,
941
        );
942
        // foo is in allowed_names but also in disabled_names — disabled wins
943
        assert!(!sel.is_selected(&running("foo", &[])));
944
    }
945

946
    #[test]
947
    fn allowed_names_with_label_enable_still_requires_label() {
948
        let sel = ContainerSelector::new(true, false, &[], &["foo".to_string()], false, false);
949
        // foo is in allowed_names but label_enable mode requires saurron.enable=true
950
        assert!(!sel.is_selected(&running("foo", &[])));
951
        assert!(sel.is_selected(&running("foo", &[("saurron.enable", "true")])));
952
    }
953

954
    #[test]
955
    fn allowed_names_select_filters_to_listed_containers() {
956
        let containers = vec![
957
            running("foo", &[]),
958
            running("bar", &[]),
959
            running("baz", &[]),
960
        ];
961
        let sel = ContainerSelector::new(
962
            false,
963
            false,
964
            &[],
965
            &["foo".to_string(), "baz".to_string()],
966
            false,
967
            false,
968
        );
969
        let result = sel.select(&containers);
970
        assert_eq!(result.len(), 2);
971
        assert!(result.iter().any(|c| c.name == "foo"));
972
        assert!(result.iter().any(|c| c.name == "baz"));
973
        assert!(!result.iter().any(|c| c.name == "bar"));
974
    }
975
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc