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