1use std::collections::BTreeMap;
39use std::time::Duration;
40
41use serde::{Deserialize, Serialize};
42
43#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq)]
48#[serde(default)]
49pub struct ConfigScope {
50 #[serde(skip_serializing_if = "Option::is_none")]
53 pub max_local_concurrent: Option<std::num::NonZeroU32>,
54 #[serde(skip_serializing_if = "Option::is_none")]
55 pub target_version: Option<String>,
56 #[serde(skip_serializing_if = "Option::is_none")]
63 pub target_version_jitter: Option<String>,
64 #[serde(skip_serializing_if = "Option::is_none")]
65 pub heartbeat_interval: Option<String>,
66 #[serde(skip_serializing_if = "Option::is_none")]
72 pub host_perf_interval: Option<String>,
73 #[serde(skip_serializing_if = "Option::is_none")]
81 pub process_perf_enabled: Option<bool>,
82 #[serde(skip_serializing_if = "Option::is_none")]
89 pub process_perf_expires_at: Option<chrono::DateTime<chrono::Utc>>,
90 #[serde(skip_serializing_if = "Option::is_none")]
95 pub process_perf_top_n: Option<u32>,
96 #[serde(skip_serializing_if = "Option::is_none")]
106 pub client_display_name: Option<String>,
107}
108
109impl ConfigScope {
110 pub fn is_empty(&self) -> bool {
111 self.max_local_concurrent.is_none()
112 && self.target_version.is_none()
113 && self.target_version_jitter.is_none()
114 && self.heartbeat_interval.is_none()
115 && self.host_perf_interval.is_none()
116 && self.process_perf_enabled.is_none()
117 && self.process_perf_expires_at.is_none()
118 && self.process_perf_top_n.is_none()
119 && self.client_display_name.is_none()
120 }
121}
122
123#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
130pub struct EffectiveConfig {
131 #[serde(default)]
133 pub max_local_concurrent: Option<std::num::NonZeroU32>,
134 pub target_version: Option<String>,
135 pub target_version_jitter: String,
136 pub heartbeat_interval: String,
137 pub host_perf_interval: String,
138 pub process_perf_enabled: bool,
140 pub process_perf_expires_at: Option<chrono::DateTime<chrono::Utc>>,
142 pub process_perf_top_n: u32,
144 pub client_display_name: Option<String>,
150}
151
152impl EffectiveConfig {
153 pub fn builtin_defaults() -> Self {
155 Self {
156 max_local_concurrent: None,
157 target_version: None,
158 target_version_jitter: "10m".to_string(),
170 heartbeat_interval: "30s".to_string(),
171 host_perf_interval: "60s".to_string(),
177 process_perf_enabled: false,
182 process_perf_expires_at: None,
183 process_perf_top_n: 20,
184 client_display_name: None,
188 }
189 }
190
191 pub fn process_perf_active_at(&self, now: chrono::DateTime<chrono::Utc>) -> bool {
196 if !self.process_perf_enabled {
197 return false;
198 }
199 match self.process_perf_expires_at {
200 None => true,
201 Some(deadline) => now < deadline,
202 }
203 }
204
205 pub fn heartbeat_duration(&self) -> Duration {
209 humantime::parse_duration(&self.heartbeat_interval).unwrap_or(Duration::from_secs(30))
210 }
211
212 pub fn host_perf_duration(&self) -> Duration {
215 humantime::parse_duration(&self.host_perf_interval).unwrap_or(Duration::from_secs(60))
216 }
217
218 pub fn target_version_jitter_duration(&self) -> Duration {
227 humantime::parse_duration(&self.target_version_jitter)
228 .unwrap_or(Duration::from_secs(10 * 60))
229 }
230}
231
232impl Default for EffectiveConfig {
233 fn default() -> Self {
234 Self::builtin_defaults()
235 }
236}
237
238#[derive(Debug, Clone, PartialEq, Eq)]
243pub enum ResolutionWarning {
244 MultiGroupConflict {
245 field: &'static str,
246 groups: Vec<String>,
250 },
251}
252
253pub fn resolve(
270 global: Option<&ConfigScope>,
271 group_scopes: &BTreeMap<String, ConfigScope>,
272 pc_scope: Option<&ConfigScope>,
273 my_groups: &[String],
274) -> (EffectiveConfig, Vec<ResolutionWarning>) {
275 let mut out = EffectiveConfig::builtin_defaults();
276 let mut warnings = Vec::new();
277
278 if let Some(g) = global {
279 apply_scope(&mut out, g);
280 }
281
282 let mut sorted_groups: Vec<&str> = my_groups.iter().map(String::as_str).collect();
285 sorted_groups.sort();
286 sorted_groups.dedup();
287
288 let mut setters: BTreeMap<&'static str, Vec<String>> = BTreeMap::new();
291 for g in &sorted_groups {
292 let Some(scope) = group_scopes.get(*g) else {
293 continue;
294 };
295 if scope.max_local_concurrent.is_some() {
296 setters
297 .entry("max_local_concurrent")
298 .or_default()
299 .push(g.to_string());
300 }
301 if scope.target_version.is_some() {
302 setters
303 .entry("target_version")
304 .or_default()
305 .push(g.to_string());
306 }
307 if scope.target_version_jitter.is_some() {
308 setters
309 .entry("target_version_jitter")
310 .or_default()
311 .push(g.to_string());
312 }
313 if scope.heartbeat_interval.is_some() {
314 setters
315 .entry("heartbeat_interval")
316 .or_default()
317 .push(g.to_string());
318 }
319 if scope.host_perf_interval.is_some() {
320 setters
321 .entry("host_perf_interval")
322 .or_default()
323 .push(g.to_string());
324 }
325 if scope.process_perf_enabled.is_some() {
326 setters
327 .entry("process_perf_enabled")
328 .or_default()
329 .push(g.to_string());
330 }
331 if scope.process_perf_expires_at.is_some() {
332 setters
333 .entry("process_perf_expires_at")
334 .or_default()
335 .push(g.to_string());
336 }
337 if scope.process_perf_top_n.is_some() {
338 setters
339 .entry("process_perf_top_n")
340 .or_default()
341 .push(g.to_string());
342 }
343 if scope.client_display_name.is_some() {
344 setters
345 .entry("client_display_name")
346 .or_default()
347 .push(g.to_string());
348 }
349 }
350 for (field, groups) in setters {
351 if groups.len() > 1 {
352 warnings.push(ResolutionWarning::MultiGroupConflict { field, groups });
353 }
354 }
355
356 for g in &sorted_groups {
358 if let Some(scope) = group_scopes.get(*g) {
359 apply_scope(&mut out, scope);
360 }
361 }
362
363 if let Some(p) = pc_scope {
364 apply_scope(&mut out, p);
365 }
366
367 (out, warnings)
368}
369
370fn apply_scope(out: &mut EffectiveConfig, s: &ConfigScope) {
371 if let Some(v) = s.max_local_concurrent {
372 out.max_local_concurrent = Some(v);
373 }
374 if let Some(v) = &s.target_version {
375 out.target_version = Some(v.clone());
376 }
377 if let Some(v) = &s.target_version_jitter {
378 out.target_version_jitter = v.clone();
379 }
380 if let Some(v) = &s.heartbeat_interval {
381 out.heartbeat_interval = v.clone();
382 }
383 if let Some(v) = &s.host_perf_interval {
384 out.host_perf_interval = v.clone();
385 }
386 if let Some(v) = s.process_perf_enabled {
387 out.process_perf_enabled = v;
388 }
389 if let Some(v) = s.process_perf_expires_at {
390 out.process_perf_expires_at = Some(v);
391 }
392 if let Some(v) = s.process_perf_top_n {
393 out.process_perf_top_n = v;
394 }
395 if let Some(v) = &s.client_display_name {
396 out.client_display_name = Some(v.clone());
397 }
398}
399
400#[cfg(test)]
401mod tests {
402 use super::*;
403
404 #[test]
405 fn local_limit_inherits_and_rejects_zero() {
406 let global: ConfigScope = serde_json::from_str(r#"{"max_local_concurrent":4}"#).unwrap();
407 let pc: ConfigScope = serde_json::from_str(r#"{"max_local_concurrent":2}"#).unwrap();
408 assert!(!pc.is_empty());
409 assert!(serde_json::from_str::<ConfigScope>(r#"{"max_local_concurrent":0}"#).is_err());
410 let (inherited, _) = resolve(Some(&global), &BTreeMap::new(), None, &[]);
411 assert_eq!(inherited.max_local_concurrent.unwrap().get(), 4);
412 let (overridden, _) = resolve(Some(&global), &BTreeMap::new(), Some(&pc), &[]);
413 assert_eq!(overridden.max_local_concurrent.unwrap().get(), 2);
414 assert!(EffectiveConfig::default().max_local_concurrent.is_none());
415 }
416 fn scope() -> ConfigScope {
417 ConfigScope::default()
418 }
419
420 #[test]
421 fn empty_stack_gives_builtin_defaults() {
422 let (eff, warns) = resolve(None, &BTreeMap::new(), None, &[]);
423 assert_eq!(eff, EffectiveConfig::builtin_defaults());
424 assert!(warns.is_empty());
425 }
426
427 #[test]
428 fn client_display_name_unset_resolves_to_none() {
429 let (eff, _) = resolve(None, &BTreeMap::new(), None, &[]);
432 assert!(eff.client_display_name.is_none());
433 }
434
435 #[test]
436 fn client_display_name_layers_global_then_pc() {
437 let global = ConfigScope {
438 client_display_name: Some("端末管理支援ツール".into()),
439 ..scope()
440 };
441 let (eff, _) = resolve(Some(&global), &BTreeMap::new(), None, &[]);
442 assert_eq!(
443 eff.client_display_name.as_deref(),
444 Some("端末管理支援ツール")
445 );
446
447 let pc = ConfigScope {
450 client_display_name: Some("PC専用名".into()),
451 ..scope()
452 };
453 let (eff, _) = resolve(Some(&global), &BTreeMap::new(), Some(&pc), &[]);
454 assert_eq!(eff.client_display_name.as_deref(), Some("PC専用名"));
455 }
456
457 #[test]
458 fn client_display_name_multi_group_conflict_warns() {
459 let mut groups = BTreeMap::new();
460 groups.insert(
461 "site-a".into(),
462 ConfigScope {
463 client_display_name: Some("A社ツール".into()),
464 ..scope()
465 },
466 );
467 groups.insert(
468 "site-b".into(),
469 ConfigScope {
470 client_display_name: Some("B社ツール".into()),
471 ..scope()
472 },
473 );
474 let (eff, warns) = resolve(None, &groups, None, &["site-a".into(), "site-b".into()]);
475 assert_eq!(eff.client_display_name.as_deref(), Some("B社ツール"));
477 assert_eq!(warns.len(), 1);
478 match &warns[0] {
479 ResolutionWarning::MultiGroupConflict { field, .. } => {
480 assert_eq!(*field, "client_display_name");
481 }
482 }
483 }
484
485 #[test]
486 fn global_only() {
487 let g = ConfigScope {
488 heartbeat_interval: Some("60s".into()),
489 ..scope()
490 };
491 let (eff, _) = resolve(Some(&g), &BTreeMap::new(), None, &[]);
492 assert_eq!(eff.heartbeat_interval, "60s");
493 assert_eq!(eff.target_version_jitter, "10m");
496 assert!(eff.target_version.is_none());
497 }
498
499 #[test]
500 fn group_overrides_global() {
501 let global = ConfigScope {
502 heartbeat_interval: Some("30s".into()),
503 ..scope()
504 };
505 let mut groups = BTreeMap::new();
506 groups.insert(
507 "canary".into(),
508 ConfigScope {
509 heartbeat_interval: Some("5s".into()),
510 ..scope()
511 },
512 );
513 let (eff, warns) = resolve(Some(&global), &groups, None, &["canary".into()]);
514 assert_eq!(eff.heartbeat_interval, "5s");
515 assert!(warns.is_empty());
516 }
517
518 #[test]
519 fn pc_overrides_group() {
520 let mut groups = BTreeMap::new();
521 groups.insert(
522 "wave1".into(),
523 ConfigScope {
524 heartbeat_interval: Some("30s".into()),
525 ..scope()
526 },
527 );
528 let pc = ConfigScope {
529 heartbeat_interval: Some("5s".into()),
530 ..scope()
531 };
532 let (eff, _) = resolve(None, &groups, Some(&pc), &["wave1".into()]);
533 assert_eq!(eff.heartbeat_interval, "5s");
534 }
535
536 #[test]
537 fn pc_overrides_global_when_no_group_match() {
538 let global = ConfigScope {
539 heartbeat_interval: Some("30s".into()),
540 ..scope()
541 };
542 let pc = ConfigScope {
543 heartbeat_interval: Some("5s".into()),
544 ..scope()
545 };
546 let (eff, _) = resolve(Some(&global), &BTreeMap::new(), Some(&pc), &[]);
547 assert_eq!(eff.heartbeat_interval, "5s");
548 }
549
550 #[test]
551 fn partial_override_only_changes_named_fields() {
552 let global = ConfigScope {
553 target_version_jitter: Some("30m".into()),
554 heartbeat_interval: Some("30s".into()),
555 ..scope()
556 };
557 let pc = ConfigScope {
558 heartbeat_interval: Some("15s".into()),
559 ..scope()
561 };
562 let (eff, _) = resolve(Some(&global), &BTreeMap::new(), Some(&pc), &[]);
563 assert_eq!(eff.target_version_jitter, "30m"); assert_eq!(eff.heartbeat_interval, "15s"); }
566
567 #[test]
568 fn multi_group_conflict_emits_warning() {
569 let mut groups = BTreeMap::new();
570 groups.insert(
571 "wave1".into(),
572 ConfigScope {
573 heartbeat_interval: Some("5s".into()),
574 ..scope()
575 },
576 );
577 groups.insert(
578 "dept-eng".into(),
579 ConfigScope {
580 heartbeat_interval: Some("60s".into()),
581 ..scope()
582 },
583 );
584 let (eff, warns) = resolve(None, &groups, None, &["wave1".into(), "dept-eng".into()]);
585 assert_eq!(eff.heartbeat_interval, "5s");
587 assert_eq!(warns.len(), 1);
588 match &warns[0] {
589 ResolutionWarning::MultiGroupConflict { field, groups } => {
590 assert_eq!(*field, "heartbeat_interval");
591 assert_eq!(groups, &vec!["dept-eng".to_string(), "wave1".to_string()]);
592 }
593 }
594 }
595
596 #[test]
597 fn group_alphabetical_last_wins_no_conflict_when_only_one_sets() {
598 let mut groups = BTreeMap::new();
599 groups.insert(
600 "wave1".into(),
601 ConfigScope {
602 heartbeat_interval: Some("5s".into()),
603 ..scope()
604 },
605 );
606 groups.insert(
607 "dept-eng".into(),
608 ConfigScope {
609 target_version_jitter: Some("15m".into()),
611 ..scope()
612 },
613 );
614 let (eff, warns) = resolve(None, &groups, None, &["wave1".into(), "dept-eng".into()]);
615 assert_eq!(eff.heartbeat_interval, "5s");
616 assert_eq!(eff.target_version_jitter, "15m");
617 assert!(warns.is_empty());
618 }
619
620 #[test]
621 fn unknown_group_is_silently_ignored() {
622 let mut groups = BTreeMap::new();
626 groups.insert(
627 "canary".into(),
628 ConfigScope {
629 heartbeat_interval: Some("5s".into()),
630 ..scope()
631 },
632 );
633 let (eff, warns) = resolve(
634 None,
635 &groups,
636 None,
637 &["canary".into(), "ghost-group".into()],
638 );
639 assert_eq!(eff.heartbeat_interval, "5s");
640 assert!(warns.is_empty());
641 }
642
643 #[test]
644 fn group_scope_not_applied_when_pc_not_in_group() {
645 let mut groups = BTreeMap::new();
646 groups.insert(
647 "canary".into(),
648 ConfigScope {
649 target_version: Some("0.3.0".into()),
650 ..scope()
651 },
652 );
653 let (eff, _) = resolve(None, &groups, None, &["dept-eng".into()]);
654 assert!(eff.target_version.is_none());
656 }
657
658 #[test]
659 fn duplicate_group_names_dedup_silently() {
660 let mut groups = BTreeMap::new();
661 groups.insert(
662 "wave1".into(),
663 ConfigScope {
664 heartbeat_interval: Some("5s".into()),
665 ..scope()
666 },
667 );
668 let (eff, warns) = resolve(None, &groups, None, &["wave1".into(), "wave1".into()]);
671 assert_eq!(eff.heartbeat_interval, "5s");
672 assert!(warns.is_empty());
673 }
674
675 #[test]
676 fn config_scope_serde_round_trip() {
677 let s = ConfigScope {
678 target_version: Some("0.3.0".into()),
679 heartbeat_interval: Some("15s".into()),
680 ..scope()
681 };
682 let json = serde_json::to_string(&s).unwrap();
683 assert_eq!(
685 json,
686 r#"{"target_version":"0.3.0","heartbeat_interval":"15s"}"#
687 );
688 let back: ConfigScope = serde_json::from_str(&json).unwrap();
689 assert_eq!(back, s);
690 }
691
692 #[test]
693 fn empty_config_scope_round_trips_as_empty_json() {
694 let s = ConfigScope::default();
695 assert!(s.is_empty());
696 let json = serde_json::to_string(&s).unwrap();
697 assert_eq!(json, "{}");
698 let back: ConfigScope = serde_json::from_str(&json).unwrap();
699 assert_eq!(back, s);
700 }
701
702 #[test]
703 fn deserialize_tolerates_unknown_fields_for_forward_compat() {
704 let json =
711 r#"{"target_version":"0.3.0","inventory_interval":"24h","future_knob":"future_value"}"#;
712 let s: ConfigScope = serde_json::from_str(json).unwrap();
713 assert_eq!(s.target_version.as_deref(), Some("0.3.0"));
714 }
715
716 #[test]
717 fn pc_does_not_override_other_pcs() {
718 let mut groups = BTreeMap::new();
724 groups.insert(
725 "wave1".into(),
726 ConfigScope {
727 heartbeat_interval: Some("30s".into()),
728 ..scope()
729 },
730 );
731 let pc = ConfigScope {
732 heartbeat_interval: Some("5s".into()),
733 ..scope()
734 };
735 let (eff, _) = resolve(None, &groups, Some(&pc), &["wave1".into()]);
736 assert_eq!(eff.heartbeat_interval, "5s");
737 }
738
739 #[test]
740 fn malformed_jitter_falls_back_to_safe_default_not_zero() {
741 let eff = EffectiveConfig {
746 target_version_jitter: "not-a-duration".into(),
747 ..EffectiveConfig::builtin_defaults()
748 };
749 assert_eq!(
750 eff.target_version_jitter_duration(),
751 Duration::from_secs(10 * 60),
752 );
753 let zero = EffectiveConfig {
755 target_version_jitter: "0s".into(),
756 ..EffectiveConfig::builtin_defaults()
757 };
758 assert_eq!(zero.target_version_jitter_duration(), Duration::ZERO);
759 }
760}