1use std::fmt;
8
9use thiserror::Error;
10
11use crate::v2::{
12 CommitMetrics, StatusPayloadV2, StatusSnapshotV2, SwapMetrics, MAX_DRIVE_ENTRIES,
13 MAX_DRIVE_NAME_BYTES, SCHEMA_VERSION_V2,
14};
15use crate::{LoadAverage, MemoryMetrics, MAX_SAMPLE_INTERVAL_MS};
16
17#[derive(Debug, Clone, PartialEq, Eq, Error)]
19#[error("{kind}")]
20pub struct ValidationViolationV2 {
21 pub kind: ViolationKindV2,
23 pub field: String,
25}
26
27impl ValidationViolationV2 {
28 fn new(kind: ViolationKindV2, field: impl Into<String>) -> Self {
29 Self {
30 kind,
31 field: field.into(),
32 }
33 }
34}
35
36#[derive(Debug, Clone, PartialEq, Eq)]
38pub enum ViolationKindV2 {
39 UnsupportedSchemaVersion { found: u16 },
41 ZeroNotAllowed,
43 SampleIntervalOutOfRange { max_ms: u64 },
45 PercentageNotFinite,
47 PercentageOutOfRange,
49 LoadValueOutOfRange,
51 UsedExceedsTotal,
53 AvailableExceedsTotal,
55 IowaitCapabilityMismatch,
57 LoadCapabilityMismatch,
59 SwapCapabilityMismatch,
61 CommitCapabilityMismatch,
63 EmptyDriveName,
65 DriveNameTooLong { max_bytes: usize },
67 TooManyDrives { max_entries: usize },
69}
70
71impl fmt::Display for ViolationKindV2 {
72 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
73 match self {
74 Self::UnsupportedSchemaVersion { found } => write!(
75 f,
76 "unsupported schema_version {found} (expected {SCHEMA_VERSION_V2})"
77 ),
78 Self::ZeroNotAllowed => f.write_str("value must be positive"),
79 Self::SampleIntervalOutOfRange { max_ms } => {
80 write!(f, "sample interval must be at most {max_ms} ms")
81 }
82 Self::PercentageNotFinite => f.write_str("percentage must be finite"),
83 Self::PercentageOutOfRange => f.write_str("percentage must be in 0.0..=100.0"),
84 Self::LoadValueOutOfRange => {
85 f.write_str("load average must be finite and non-negative")
86 }
87 Self::UsedExceedsTotal => f.write_str("used exceeds total/limit"),
88 Self::AvailableExceedsTotal => f.write_str("available exceeds total"),
89 Self::IowaitCapabilityMismatch => {
90 f.write_str("iowait_pct must be Some(_) iff cpu_iowait capability is true")
91 }
92 Self::LoadCapabilityMismatch => {
93 f.write_str("load must be Some(_) iff load_average capability is true")
94 }
95 Self::SwapCapabilityMismatch => {
96 f.write_str("swap must be Some(_) iff swap capability is true")
97 }
98 Self::CommitCapabilityMismatch => {
99 f.write_str("commit must be Some(_) iff memory_commit capability is true")
100 }
101 Self::EmptyDriveName => f.write_str("drive name must not be empty"),
102 Self::DriveNameTooLong { max_bytes } => {
103 write!(f, "drive name exceeds maximum length of {max_bytes} bytes")
104 }
105 Self::TooManyDrives { max_entries } => {
106 write!(
107 f,
108 "drive list exceeds maximum length of {max_entries} entries"
109 )
110 }
111 }
112 }
113}
114
115pub fn validate_v2(snap: &StatusSnapshotV2) -> Result<(), Vec<ValidationViolationV2>> {
119 let mut violations = Vec::new();
120
121 if snap.schema_version != SCHEMA_VERSION_V2 {
122 violations.push(ValidationViolationV2::new(
123 ViolationKindV2::UnsupportedSchemaVersion {
124 found: snap.schema_version,
125 },
126 "schema_version",
127 ));
128 }
129
130 if snap.observed_at_unix_ms == 0 {
131 violations.push(ValidationViolationV2::new(
132 ViolationKindV2::ZeroNotAllowed,
133 "observed_at_unix_ms",
134 ));
135 }
136 if snap.sample_interval_ms == 0 {
137 violations.push(ValidationViolationV2::new(
138 ViolationKindV2::ZeroNotAllowed,
139 "sample_interval_ms",
140 ));
141 } else if snap.sample_interval_ms > MAX_SAMPLE_INTERVAL_MS {
142 violations.push(ValidationViolationV2::new(
143 ViolationKindV2::SampleIntervalOutOfRange {
144 max_ms: MAX_SAMPLE_INTERVAL_MS,
145 },
146 "sample_interval_ms",
147 ));
148 }
149
150 validate_cpu_v2(&snap.cpu, snap.capabilities.cpu_iowait, &mut violations);
151 validate_load_v2(
152 snap.load.as_ref(),
153 snap.capabilities.load_average,
154 &mut violations,
155 );
156 validate_memory_v2(&snap.memory, &mut violations);
157 validate_swap_v2(snap.swap.as_ref(), snap.capabilities.swap, &mut violations);
158 validate_commit_v2(
159 snap.commit.as_ref(),
160 snap.capabilities.memory_commit,
161 &mut violations,
162 );
163
164 if violations.is_empty() {
165 Ok(())
166 } else {
167 Err(violations)
168 }
169}
170
171pub fn validate_payload_v2(payload: &StatusPayloadV2) -> Result<(), Vec<ValidationViolationV2>> {
173 let mut violations = match validate_v2(&payload.snapshot) {
174 Ok(()) => Vec::new(),
175 Err(violations) => violations,
176 };
177
178 if let Some(drives) = &payload.drives {
179 if drives.len() > MAX_DRIVE_ENTRIES {
180 violations.push(ValidationViolationV2::new(
181 ViolationKindV2::TooManyDrives {
182 max_entries: MAX_DRIVE_ENTRIES,
183 },
184 "drives",
185 ));
186 }
187 for (index, drive) in drives.iter().enumerate() {
188 let prefix = format!("drives[{index}]");
189 if drive.name.is_empty() {
190 violations.push(ValidationViolationV2::new(
191 ViolationKindV2::EmptyDriveName,
192 format!("{prefix}.name"),
193 ));
194 }
195 if drive.name.len() > MAX_DRIVE_NAME_BYTES {
196 violations.push(ValidationViolationV2::new(
197 ViolationKindV2::DriveNameTooLong {
198 max_bytes: MAX_DRIVE_NAME_BYTES,
199 },
200 format!("{prefix}.name"),
201 ));
202 }
203 if drive.total_bytes == 0 {
204 violations.push(ValidationViolationV2::new(
205 ViolationKindV2::ZeroNotAllowed,
206 format!("{prefix}.total_bytes"),
207 ));
208 }
209 if drive.used_bytes > drive.total_bytes {
210 violations.push(ValidationViolationV2::new(
211 ViolationKindV2::UsedExceedsTotal,
212 format!("{prefix}.used_bytes"),
213 ));
214 }
215 if drive
216 .available_bytes
217 .is_some_and(|available| available > drive.total_bytes)
218 {
219 violations.push(ValidationViolationV2::new(
220 ViolationKindV2::AvailableExceedsTotal,
221 format!("{prefix}.available_bytes"),
222 ));
223 }
224 }
225 }
226
227 if violations.is_empty() {
228 Ok(())
229 } else {
230 Err(violations)
231 }
232}
233
234fn validate_cpu_v2(
235 cpu: &crate::v2::CpuMetricsV2,
236 cpu_iowait: bool,
237 out: &mut Vec<ValidationViolationV2>,
238) {
239 if cpu.logical_cores == 0 {
240 out.push(ValidationViolationV2::new(
241 ViolationKindV2::ZeroNotAllowed,
242 "cpu.logical_cores",
243 ));
244 }
245 check_percentage_v2(cpu.usage_pct, "cpu.usage_pct", out);
246 match cpu.iowait_pct {
247 None => {
248 if cpu_iowait {
249 out.push(ValidationViolationV2::new(
250 ViolationKindV2::IowaitCapabilityMismatch,
251 "cpu.iowait_pct",
252 ));
253 }
254 }
255 Some(value) => {
256 if cpu_iowait {
257 check_percentage_v2(value, "cpu.iowait_pct", out);
258 } else {
259 out.push(ValidationViolationV2::new(
260 ViolationKindV2::IowaitCapabilityMismatch,
261 "cpu.iowait_pct",
262 ));
263 }
264 }
265 }
266}
267
268fn validate_load_v2(
269 load: Option<&LoadAverage>,
270 load_average_capable: bool,
271 out: &mut Vec<ValidationViolationV2>,
272) {
273 match load {
274 None => {
275 if load_average_capable {
276 out.push(ValidationViolationV2::new(
277 ViolationKindV2::LoadCapabilityMismatch,
278 "load",
279 ));
280 }
281 }
282 Some(l) => {
283 if load_average_capable {
284 check_load_v2(l.one, "load.one", out);
285 check_load_v2(l.five, "load.five", out);
286 check_load_v2(l.fifteen, "load.fifteen", out);
287 } else {
288 out.push(ValidationViolationV2::new(
289 ViolationKindV2::LoadCapabilityMismatch,
290 "load",
291 ));
292 }
293 }
294 }
295}
296
297fn check_load_v2(value: f32, field: &str, out: &mut Vec<ValidationViolationV2>) {
298 if !value.is_finite() || value < 0.0 {
299 out.push(ValidationViolationV2::new(
300 ViolationKindV2::LoadValueOutOfRange,
301 field,
302 ));
303 }
304}
305
306fn validate_memory_v2(memory: &MemoryMetrics, out: &mut Vec<ValidationViolationV2>) {
307 check_percentage_v2(memory.usage_pct, "memory.usage_pct", out);
308 if memory.used_bytes > memory.total_bytes {
309 out.push(ValidationViolationV2::new(
310 ViolationKindV2::UsedExceedsTotal,
311 "memory.used_bytes",
312 ));
313 }
314 if memory.total_bytes == 0 && memory.usage_pct != 0.0 {
315 out.push(ValidationViolationV2::new(
316 ViolationKindV2::PercentageOutOfRange,
317 "memory.usage_pct",
318 ));
319 }
320}
321
322fn validate_swap_v2(
323 swap: Option<&SwapMetrics>,
324 swap_capable: bool,
325 out: &mut Vec<ValidationViolationV2>,
326) {
327 match swap {
328 None => {
329 if swap_capable {
330 out.push(ValidationViolationV2::new(
331 ViolationKindV2::SwapCapabilityMismatch,
332 "swap",
333 ));
334 }
335 }
336 Some(s) => {
337 if swap_capable {
338 check_percentage_v2(s.usage_pct, "swap.usage_pct", out);
339 if s.used_bytes > s.total_bytes {
340 out.push(ValidationViolationV2::new(
341 ViolationKindV2::UsedExceedsTotal,
342 "swap.used_bytes",
343 ));
344 }
345 if s.total_bytes == 0 && s.usage_pct != 0.0 {
346 out.push(ValidationViolationV2::new(
347 ViolationKindV2::PercentageOutOfRange,
348 "swap.usage_pct",
349 ));
350 }
351 } else {
352 out.push(ValidationViolationV2::new(
353 ViolationKindV2::SwapCapabilityMismatch,
354 "swap",
355 ));
356 }
357 }
358 }
359}
360
361fn validate_commit_v2(
362 commit: Option<&CommitMetrics>,
363 commit_capable: bool,
364 out: &mut Vec<ValidationViolationV2>,
365) {
366 match commit {
367 None => {
368 if commit_capable {
369 out.push(ValidationViolationV2::new(
370 ViolationKindV2::CommitCapabilityMismatch,
371 "commit",
372 ));
373 }
374 }
375 Some(c) => {
376 if commit_capable {
377 check_percentage_v2(c.usage_pct, "commit.usage_pct", out);
378 if c.used_bytes > c.limit_bytes {
379 out.push(ValidationViolationV2::new(
380 ViolationKindV2::UsedExceedsTotal,
381 "commit.used_bytes",
382 ));
383 }
384 if c.limit_bytes == 0 {
385 out.push(ValidationViolationV2::new(
386 ViolationKindV2::ZeroNotAllowed,
387 "commit.limit_bytes",
388 ));
389 }
390 if c.limit_bytes == 0 && c.usage_pct != 0.0 {
391 out.push(ValidationViolationV2::new(
392 ViolationKindV2::PercentageOutOfRange,
393 "commit.usage_pct",
394 ));
395 }
396 } else {
397 out.push(ValidationViolationV2::new(
398 ViolationKindV2::CommitCapabilityMismatch,
399 "commit",
400 ));
401 }
402 }
403 }
404}
405
406fn check_percentage_v2(value: f32, field: &str, out: &mut Vec<ValidationViolationV2>) {
407 if !value.is_finite() {
408 out.push(ValidationViolationV2::new(
409 ViolationKindV2::PercentageNotFinite,
410 field,
411 ));
412 return;
413 }
414 if !(0.0..=100.0).contains(&value) {
415 out.push(ValidationViolationV2::new(
416 ViolationKindV2::PercentageOutOfRange,
417 field,
418 ));
419 }
420}
421
422#[cfg(test)]
423mod tests {
424 use super::*;
425 use crate::v2::{
426 CpuMetricsV2, DriveMetrics, MetricCapabilitiesV2, StatusPayloadV2, StatusSnapshotV2,
427 SwapMetrics, MAX_DRIVE_ENTRIES, MAX_DRIVE_NAME_BYTES, SCHEMA_VERSION_V2,
428 };
429 use crate::{LoadAverage, MemoryMetrics, SystemIdentity};
430
431 fn v2_identity() -> SystemIdentity {
432 SystemIdentity {
433 name: "test".into(),
434 hostname: "test.local".into(),
435 os_name: "linux".into(),
436 os_version: "1.0".into(),
437 kernel_name: "Linux".into(),
438 kernel_release: "6.0.0".into(),
439 architecture: "x86_64".into(),
440 }
441 }
442
443 fn valid_linux_v2() -> StatusSnapshotV2 {
444 StatusSnapshotV2 {
445 schema_version: SCHEMA_VERSION_V2,
446 observed_at_unix_ms: 1,
447 sample_interval_ms: 1000,
448 capabilities: MetricCapabilitiesV2 {
449 cpu_iowait: true,
450 load_average: true,
451 swap: true,
452 memory_commit: false,
453 },
454 system: v2_identity(),
455 cpu: CpuMetricsV2 {
456 logical_cores: 8,
457 usage_pct: 25.2,
458 iowait_pct: Some(0.4),
459 },
460 load: Some(LoadAverage {
461 one: 1.32,
462 five: 0.91,
463 fifteen: 0.62,
464 }),
465 memory: MemoryMetrics {
466 used_bytes: 5_900_000_000,
467 total_bytes: 15_600_000_000,
468 usage_pct: 37.8,
469 },
470 swap: Some(SwapMetrics {
471 used_bytes: 0,
472 total_bytes: 4_000_000_000,
473 usage_pct: 0.0,
474 }),
475 commit: None,
476 }
477 }
478
479 fn valid_payload(drives: Option<Vec<DriveMetrics>>) -> StatusPayloadV2 {
480 StatusPayloadV2 {
481 snapshot: valid_linux_v2(),
482 drives,
483 }
484 }
485
486 #[test]
487 fn valid_drive_payloads_include_unavailable_empty_and_populated_states() {
488 assert!(valid_payload(None).validate().is_ok());
489 assert!(valid_payload(Some(Vec::new())).validate().is_ok());
490 assert!(valid_payload(Some(vec![DriveMetrics {
491 name: "C:\\".into(),
492 used_bytes: 1,
493 total_bytes: 2,
494 available_bytes: None,
495 }]))
496 .validate()
497 .is_ok());
498 }
499
500 #[test]
501 fn drive_validation_reports_indexed_fields_and_bounds() {
502 let payload = valid_payload(Some(vec![DriveMetrics {
503 name: String::new(),
504 used_bytes: 3,
505 total_bytes: 2,
506 available_bytes: None,
507 }]));
508 let err = payload.validate().unwrap_err();
509 assert!(err.iter().any(|v| v.field == "drives[0].name"));
510 assert!(err.iter().any(|v| v.field == "drives[0].used_bytes"));
511
512 let too_long = valid_payload(Some(vec![DriveMetrics {
513 name: "x".repeat(MAX_DRIVE_NAME_BYTES + 1),
514 used_bytes: 0,
515 total_bytes: 1,
516 available_bytes: None,
517 }]));
518 assert!(too_long
519 .validate()
520 .unwrap_err()
521 .iter()
522 .any(|v| v.field == "drives[0].name"));
523
524 let too_many = valid_payload(Some(
525 (0..=MAX_DRIVE_ENTRIES)
526 .map(|index| DriveMetrics {
527 name: format!("/{index}"),
528 used_bytes: 0,
529 total_bytes: 1,
530 available_bytes: None,
531 })
532 .collect(),
533 ));
534 assert!(too_many
535 .validate()
536 .unwrap_err()
537 .iter()
538 .any(|v| v.field == "drives"));
539 }
540
541 #[test]
542 fn drive_names_accept_unicode_and_windows_roots() {
543 let payload = valid_payload(Some(vec![
544 DriveMetrics {
545 name: "データ /home".into(),
546 used_bytes: 1,
547 total_bytes: 2,
548 available_bytes: None,
549 },
550 DriveMetrics {
551 name: "C:\\".into(),
552 used_bytes: 1,
553 total_bytes: 2,
554 available_bytes: None,
555 },
556 ]));
557 payload.validate().unwrap();
558 }
559
560 #[test]
561 fn explicit_availability_is_optional_and_bounded_independently() {
562 let mut payload = valid_payload(Some(vec![DriveMetrics {
563 name: "/".into(),
564 used_bytes: 8,
565 total_bytes: 10,
566 available_bytes: Some(1),
567 }]));
568 payload.validate().unwrap();
569 payload.drives.as_mut().unwrap()[0].available_bytes = Some(11);
570 let error = payload.validate().unwrap_err();
571 assert!(error.iter().any(|violation| {
572 violation.kind == ViolationKindV2::AvailableExceedsTotal
573 && violation.field == "drives[0].available_bytes"
574 }));
575 }
576
577 #[test]
578 fn valid_linux_v2_passes() {
579 let snap = valid_linux_v2();
580 validate_v2(&snap).expect("linux v2 validates");
581 }
582
583 #[test]
584 fn valid_windows_v2_passes() {
585 let snap = StatusSnapshotV2 {
586 schema_version: SCHEMA_VERSION_V2,
587 observed_at_unix_ms: 1,
588 sample_interval_ms: 1000,
589 capabilities: MetricCapabilitiesV2 {
590 cpu_iowait: false,
591 load_average: false,
592 swap: false,
593 memory_commit: true,
594 },
595 system: v2_identity(),
596 cpu: CpuMetricsV2 {
597 logical_cores: 4,
598 usage_pct: 12.5,
599 iowait_pct: None,
600 },
601 load: None,
602 memory: MemoryMetrics {
603 used_bytes: 2_000_000_000,
604 total_bytes: 8_000_000_000,
605 usage_pct: 25.0,
606 },
607 swap: None,
608 commit: Some(crate::v2::CommitMetrics {
609 used_bytes: 3_000_000_000,
610 limit_bytes: 8_000_000_000,
611 usage_pct: 37.5,
612 }),
613 };
614 validate_v2(&snap).expect("windows v2 validates");
615 }
616
617 #[test]
618 fn valid_macos_v2_passes() {
619 let snap = StatusSnapshotV2 {
620 schema_version: SCHEMA_VERSION_V2,
621 observed_at_unix_ms: 1,
622 sample_interval_ms: 1000,
623 capabilities: MetricCapabilitiesV2 {
624 cpu_iowait: false,
625 load_average: true,
626 swap: false,
627 memory_commit: false,
628 },
629 system: v2_identity(),
630 cpu: CpuMetricsV2 {
631 logical_cores: 8,
632 usage_pct: 18.7,
633 iowait_pct: None,
634 },
635 load: Some(LoadAverage {
636 one: 2.10,
637 five: 1.85,
638 fifteen: 1.40,
639 }),
640 memory: MemoryMetrics {
641 used_bytes: 9_000_000_000,
642 total_bytes: 16_000_000_000,
643 usage_pct: 56.25,
644 },
645 swap: None,
646 commit: None,
647 };
648 validate_v2(&snap).expect("macos v2 validates");
649 }
650
651 #[test]
652 fn rejects_wrong_schema_version() {
653 let mut snap = valid_linux_v2();
654 snap.schema_version = 1;
655 let err = validate_v2(&snap).unwrap_err();
656 assert!(err.iter().any(|v| matches!(
657 v.kind,
658 ViolationKindV2::UnsupportedSchemaVersion { found: 1 }
659 )));
660 }
661
662 #[test]
663 fn rejects_zero_observed_at() {
664 let mut snap = valid_linux_v2();
665 snap.observed_at_unix_ms = 0;
666 let err = validate_v2(&snap).unwrap_err();
667 assert!(err.iter().any(|v| v.field == "observed_at_unix_ms"));
668 }
669
670 #[test]
671 fn rejects_zero_sample_interval() {
672 let mut snap = valid_linux_v2();
673 snap.sample_interval_ms = 0;
674 let err = validate_v2(&snap).unwrap_err();
675 assert!(err.iter().any(|v| v.field == "sample_interval_ms"));
676 }
677
678 #[test]
679 fn rejects_sample_interval_above_protocol_limit() {
680 let mut snap = valid_linux_v2();
681 snap.sample_interval_ms = MAX_SAMPLE_INTERVAL_MS + 1;
682 let err = validate_v2(&snap).unwrap_err();
683 assert!(err.iter().any(|violation| {
684 violation.field == "sample_interval_ms"
685 && matches!(
686 violation.kind,
687 ViolationKindV2::SampleIntervalOutOfRange { .. }
688 )
689 }));
690 }
691
692 #[test]
693 fn rejects_invalid_load_value_with_load_specific_violation() {
694 let mut snap = valid_linux_v2();
695 snap.load.as_mut().unwrap().one = -1.0;
696 let err = validate_v2(&snap).unwrap_err();
697 assert!(err.iter().any(|violation| {
698 violation.field == "load.one" && violation.kind == ViolationKindV2::LoadValueOutOfRange
699 }));
700 }
701
702 #[test]
703 fn rejects_nonzero_memory_usage_with_zero_total() {
704 let mut snap = valid_linux_v2();
705 snap.memory.total_bytes = 0;
706 snap.memory.usage_pct = 1.0;
707 let err = validate_v2(&snap).unwrap_err();
708 assert!(err.iter().any(|violation| {
709 violation.field == "memory.usage_pct"
710 && violation.kind == ViolationKindV2::PercentageOutOfRange
711 }));
712 }
713
714 #[test]
715 fn rejects_zero_commit_limit_and_nonzero_usage() {
716 let snap = StatusSnapshotV2 {
717 capabilities: MetricCapabilitiesV2 {
718 memory_commit: true,
719 ..valid_linux_v2().capabilities
720 },
721 commit: Some(crate::v2::CommitMetrics {
722 used_bytes: 0,
723 limit_bytes: 0,
724 usage_pct: 1.0,
725 }),
726 ..valid_linux_v2()
727 };
728 let err = validate_v2(&snap).unwrap_err();
729 assert!(err.iter().any(|violation| {
730 violation.field == "commit.limit_bytes"
731 && violation.kind == ViolationKindV2::ZeroNotAllowed
732 }));
733 assert!(err.iter().any(|violation| {
734 violation.field == "commit.usage_pct"
735 && violation.kind == ViolationKindV2::PercentageOutOfRange
736 }));
737 }
738
739 #[test]
740 fn rejects_zero_logical_cores() {
741 let mut snap = valid_linux_v2();
742 snap.cpu.logical_cores = 0;
743 let err = validate_v2(&snap).unwrap_err();
744 assert!(err.iter().any(|v| v.field == "cpu.logical_cores"));
745 }
746
747 #[test]
748 fn rejects_nan_cpu_usage() {
749 let mut snap = valid_linux_v2();
750 snap.cpu.usage_pct = f32::NAN;
751 let err = validate_v2(&snap).unwrap_err();
752 assert!(err.iter().any(|v| v.field == "cpu.usage_pct"));
753 }
754
755 #[test]
756 fn rejects_iowait_none_when_capability_true() {
757 let mut snap = valid_linux_v2();
758 snap.cpu.iowait_pct = None;
759 let err = validate_v2(&snap).unwrap_err();
760 assert!(err
761 .iter()
762 .any(|v| matches!(v.kind, ViolationKindV2::IowaitCapabilityMismatch)));
763 }
764
765 #[test]
766 fn rejects_iowait_some_when_capability_false() {
767 let mut snap = valid_linux_v2();
768 snap.capabilities.cpu_iowait = false;
769 snap.cpu.iowait_pct = Some(0.5);
770 let err = validate_v2(&snap).unwrap_err();
771 assert!(err
772 .iter()
773 .any(|v| matches!(v.kind, ViolationKindV2::IowaitCapabilityMismatch)));
774 }
775
776 #[test]
777 fn rejects_load_none_when_capability_true() {
778 let mut snap = valid_linux_v2();
779 snap.load = None;
780 let err = validate_v2(&snap).unwrap_err();
781 assert!(err
782 .iter()
783 .any(|v| matches!(v.kind, ViolationKindV2::LoadCapabilityMismatch)));
784 }
785
786 #[test]
787 fn rejects_load_some_when_capability_false() {
788 let mut snap = valid_linux_v2();
789 snap.capabilities.load_average = false;
790 let err = validate_v2(&snap).unwrap_err();
791 assert!(err
792 .iter()
793 .any(|v| matches!(v.kind, ViolationKindV2::LoadCapabilityMismatch)));
794 }
795
796 #[test]
797 fn rejects_swap_none_when_capability_true() {
798 let mut snap = valid_linux_v2();
799 snap.swap = None;
800 let err = validate_v2(&snap).unwrap_err();
801 assert!(err
802 .iter()
803 .any(|v| matches!(v.kind, ViolationKindV2::SwapCapabilityMismatch)));
804 }
805
806 #[test]
807 fn rejects_swap_some_when_capability_false() {
808 let mut snap = valid_linux_v2();
809 snap.capabilities.swap = false;
810 let err = validate_v2(&snap).unwrap_err();
811 assert!(err
812 .iter()
813 .any(|v| matches!(v.kind, ViolationKindV2::SwapCapabilityMismatch)));
814 }
815
816 #[test]
817 fn rejects_commit_none_when_capability_true() {
818 let mut snap = StatusSnapshotV2 {
819 capabilities: MetricCapabilitiesV2 {
820 memory_commit: true,
821 ..valid_linux_v2().capabilities
822 },
823 ..valid_linux_v2()
824 };
825 snap.commit = None;
826 let err = validate_v2(&snap).unwrap_err();
827 assert!(err
828 .iter()
829 .any(|v| matches!(v.kind, ViolationKindV2::CommitCapabilityMismatch)));
830 }
831
832 #[test]
833 fn rejects_commit_some_when_capability_false() {
834 let mut snap = valid_linux_v2();
835 snap.commit = Some(crate::v2::CommitMetrics {
836 used_bytes: 1_000_000_000,
837 limit_bytes: 4_000_000_000,
838 usage_pct: 25.0,
839 });
840 let err = validate_v2(&snap).unwrap_err();
841 assert!(err
842 .iter()
843 .any(|v| matches!(v.kind, ViolationKindV2::CommitCapabilityMismatch)));
844 }
845
846 #[test]
847 fn rejects_used_exceeds_limit_in_commit() {
848 let snap = StatusSnapshotV2 {
849 capabilities: MetricCapabilitiesV2 {
850 memory_commit: true,
851 ..valid_linux_v2().capabilities
852 },
853 commit: Some(crate::v2::CommitMetrics {
854 used_bytes: 9_000_000_000,
855 limit_bytes: 4_000_000_000,
856 usage_pct: 25.0,
857 }),
858 ..valid_linux_v2()
859 };
860 let err = validate_v2(&snap).unwrap_err();
861 assert!(err.iter().any(|v| v.field == "commit.used_bytes"));
862 }
863
864 #[test]
865 fn rejects_used_exceeds_total_in_swap() {
866 let mut snap = valid_linux_v2();
867 snap.swap = Some(SwapMetrics {
868 used_bytes: 5_000_000_000,
869 total_bytes: 4_000_000_000,
870 usage_pct: 25.0,
871 });
872 let err = validate_v2(&snap).unwrap_err();
873 assert!(err.iter().any(|v| v.field == "swap.used_bytes"));
874 }
875
876 #[test]
877 fn rejects_used_exceeds_total_in_memory() {
878 let mut snap = valid_linux_v2();
879 snap.memory.used_bytes = 20_000_000_000;
880 let err = validate_v2(&snap).unwrap_err();
881 assert!(err.iter().any(|v| v.field == "memory.used_bytes"));
882 }
883
884 #[test]
885 fn rejects_percentage_over_100() {
886 let mut snap = valid_linux_v2();
887 snap.cpu.usage_pct = 101.0;
888 let err = validate_v2(&snap).unwrap_err();
889 assert!(err.iter().any(|v| v.field == "cpu.usage_pct"));
890 }
891
892 #[test]
893 fn rejects_infinite_percentage() {
894 let mut snap = valid_linux_v2();
895 snap.cpu.usage_pct = f32::INFINITY;
896 let err = validate_v2(&snap).unwrap_err();
897 assert!(err.iter().any(|v| v.field == "cpu.usage_pct"));
898 }
899
900 #[test]
901 fn multiple_violations_all_reported() {
902 let mut snap = valid_linux_v2();
903 snap.schema_version = 99;
904 snap.observed_at_unix_ms = 0;
905 snap.cpu.logical_cores = 0;
906 let err = validate_v2(&snap).unwrap_err();
907 assert!(err.len() >= 3);
908 }
909}