1use anyhow::{Context, Result, bail};
9use serde::{Deserialize, Serialize};
10
11use crate::wire::ConfigScope;
12
13#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
17pub struct FieldValue {
18 pub value: String,
19}
20
21#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
26pub struct FieldUpdate {
27 pub scope: ConfigScope,
28 pub changed: bool,
29}
30
31pub fn parse_set_spec(spec: &str) -> Result<(&str, &str)> {
34 spec.split_once('=')
35 .ok_or_else(|| anyhow::anyhow!("expected <field>=<value>, got '{spec}'"))
36}
37
38pub fn apply_field(scope: &mut ConfigScope, field: &str, value: Option<&str>) -> Result<()> {
43 let parsed_duration = |field: &str, v: Option<&str>| -> Result<Option<String>> {
50 match v {
51 None => Ok(None),
52 Some(v) => {
53 humantime::parse_duration(v).with_context(|| {
54 format!("{field}: expected a humantime duration (e.g. 30s, 10m, 1h), got {v:?}")
55 })?;
56 Ok(Some(v.to_string()))
57 }
58 }
59 };
60 match field {
61 "max_local_concurrent" => {
62 scope.max_local_concurrent = value
63 .map(str::parse::<std::num::NonZeroU32>)
64 .transpose()
65 .context("max_local_concurrent: expected an integer >= 1")?;
66 }
67 "target_version" => scope.target_version = value.map(String::from),
68 "target_version_jitter" => {
69 scope.target_version_jitter = parsed_duration(field, value)?;
70 }
71 "heartbeat_interval" => scope.heartbeat_interval = parsed_duration(field, value)?,
72 "host_perf_interval" => scope.host_perf_interval = parsed_duration(field, value)?,
73 "process_perf_enabled" => {
74 scope.process_perf_enabled = match value {
75 None => None,
76 Some(v) => Some(v.parse::<bool>().with_context(|| {
77 format!("process_perf_enabled: expected true|false, got {v:?}")
78 })?),
79 };
80 }
81 "process_perf_expires_at" => {
82 scope.process_perf_expires_at = match value {
83 None => None,
84 Some(v) => Some(
85 chrono::DateTime::parse_from_rfc3339(v)
86 .with_context(|| {
87 format!(
88 "process_perf_expires_at: expected RFC3339 timestamp, got {v:?}"
89 )
90 })?
91 .with_timezone(&chrono::Utc),
92 ),
93 };
94 }
95 "process_perf_top_n" => {
96 scope.process_perf_top_n = match value {
97 None => None,
98 Some(v) => Some(v.parse::<u32>().with_context(|| {
99 format!("process_perf_top_n: expected positive integer, got {v:?}")
100 })?),
101 };
102 }
103 "client_display_name" => scope.client_display_name = value.map(String::from),
107 other => bail!(
108 "unknown field '{other}' — supported: max_local_concurrent, target_version, target_version_jitter, heartbeat_interval, host_perf_interval, process_perf_enabled, process_perf_expires_at, process_perf_top_n, client_display_name"
109 ),
110 }
111 Ok(())
112}
113
114#[cfg(test)]
115mod tests {
116 use super::*;
117
118 #[test]
119 fn local_limit_sets_clears_and_rejects_invalid_values() {
120 let mut s = ConfigScope::default();
121 apply_field(&mut s, "max_local_concurrent", Some("2")).unwrap();
122 assert_eq!(s.max_local_concurrent.unwrap().get(), 2);
123 for value in ["0", "-1", "1.5", "4294967296"] {
124 assert!(apply_field(&mut s, "max_local_concurrent", Some(value)).is_err());
125 }
126 apply_field(&mut s, "max_local_concurrent", None).unwrap();
127 assert!(s.max_local_concurrent.is_none());
128 }
129 #[test]
130 fn apply_field_sets_string() {
131 let mut s = ConfigScope::default();
132 apply_field(&mut s, "heartbeat_interval", Some("15s")).unwrap();
133 assert_eq!(s.heartbeat_interval.as_deref(), Some("15s"));
134 }
135
136 #[test]
137 fn apply_field_unset_clears_string() {
138 let mut s = ConfigScope {
139 heartbeat_interval: Some("15s".into()),
140 ..Default::default()
141 };
142 apply_field(&mut s, "heartbeat_interval", None).unwrap();
143 assert!(s.heartbeat_interval.is_none());
144 }
145
146 #[test]
147 fn apply_field_sets_and_clears_client_display_name() {
148 let mut s = ConfigScope::default();
149 apply_field(&mut s, "client_display_name", Some("端末管理支援ツール")).unwrap();
150 assert_eq!(s.client_display_name.as_deref(), Some("端末管理支援ツール"));
151 apply_field(&mut s, "client_display_name", None).unwrap();
152 assert!(s.client_display_name.is_none());
153 }
154
155 #[test]
156 fn apply_field_rejects_unknown() {
157 let mut s = ConfigScope::default();
158 let err = apply_field(&mut s, "nope", Some("x")).unwrap_err();
159 assert!(err.to_string().contains("unknown field"));
160 }
161
162 #[test]
163 fn apply_field_rejects_malformed_durations() {
164 let mut s = ConfigScope::default();
169 for field in [
170 "target_version_jitter",
171 "heartbeat_interval",
172 "host_perf_interval",
173 ] {
174 let err = apply_field(&mut s, field, Some("not-a-duration")).unwrap_err();
175 assert!(err.to_string().contains("humantime"), "{field}: {err:#}",);
176 }
177 apply_field(&mut s, "target_version_jitter", None).unwrap();
179 assert!(s.target_version_jitter.is_none());
180 }
181
182 #[test]
183 fn parse_set_spec_splits_on_first_equals() {
184 assert_eq!(
185 parse_set_spec("heartbeat_interval=15s").unwrap(),
186 ("heartbeat_interval", "15s")
187 );
188 assert_eq!(parse_set_spec("a=b=c").unwrap(), ("a", "b=c"));
189 assert_eq!(
190 parse_set_spec("client_display_name=").unwrap(),
191 ("client_display_name", "")
192 );
193 }
194
195 #[test]
196 fn parse_set_spec_rejects_missing_equals() {
197 let err = parse_set_spec("heartbeat_interval").unwrap_err();
198 assert_eq!(
199 err.to_string(),
200 "expected <field>=<value>, got 'heartbeat_interval'"
201 );
202 }
203}