1use chrono::{DateTime, Local, NaiveDate, NaiveDateTime, TimeZone as _};
2
3use crate::error::Result;
4use crate::fields::change::{Change, Op};
5use crate::fields::schema::{Kind, ProjectSchema, SchemaField};
6use crate::fields::{choices, closest, period, same, usage};
7
8#[derive(Clone, Debug, PartialEq)]
9pub enum ResolvedOp {
10 Set(Vec<String>),
11 Add(String),
12 Remove(String),
13 Clear,
14}
15
16#[derive(Clone, Debug, PartialEq)]
18pub struct Resolved {
19 pub field: SchemaField,
20 pub op: ResolvedOp,
21}
22
23pub fn resolve(
26 schema: &ProjectSchema,
27 changes: &[Change],
28 me: Option<&str>,
29 validate: bool,
30) -> Result<Vec<Resolved>> {
31 let mut resolved: Vec<Resolved> = Vec::new();
32 for change in changes {
33 let field = match schema.field(&change.name) {
34 Some(field) => field.clone(),
35 None if !validate => SchemaField::unknown(&change.name),
36 None => return Err(unknown_field(schema, &change.name)),
37 };
38 let both_relative = |other: &Resolved| {
39 matches!(
40 (&other.op, &change.op),
41 (
42 ResolvedOp::Add(_) | ResolvedOp::Remove(_),
43 Op::Add(_) | Op::Remove(_)
44 )
45 )
46 };
47 if resolved
48 .iter()
49 .any(|other| other.field.name == field.name && !both_relative(other))
50 {
51 return Err(usage(format!(
52 "-f {}: {} is changed twice",
53 change.name, field.name
54 )));
55 }
56 let op = match &change.op {
57 Op::Set(values) => {
58 if !field.multi && values.len() > 1 {
59 return Err(usage(format!(
60 "{} takes one value; it was given {}",
61 field.name,
62 values.len()
63 )));
64 }
65 ResolvedOp::Set(
66 values
67 .iter()
68 .map(|value| canonical(&field, value, me, validate))
69 .collect::<Result<_>>()?,
70 )
71 }
72 Op::Add(_) | Op::Remove(_) if validate && !field.multi => {
73 return Err(usage(format!(
74 "{} holds one value; set it with -f '{}=…'",
75 field.name, field.name
76 )));
77 }
78 Op::Add(value) => ResolvedOp::Add(canonical(&field, value, me, validate)?),
79 Op::Remove(value) => ResolvedOp::Remove(canonical(&field, value, me, validate)?),
80 Op::Clear => {
81 if validate && !field.can_be_empty {
82 return Err(usage(format!("{} cannot be empty", field.name)));
83 }
84 ResolvedOp::Clear
85 }
86 };
87 resolved.push(Resolved { field, op });
88 }
89 Ok(resolved)
90}
91
92pub fn missing_required<'a>(
94 schema: &'a ProjectSchema,
95 resolved: &[Resolved],
96) -> Vec<&'a SchemaField> {
97 schema
98 .fields
99 .iter()
100 .filter(|field| !field.can_be_empty && !field.has_default)
101 .filter(|field| !resolved.iter().any(|item| item.field.name == field.name))
102 .collect()
103}
104
105fn canonical(field: &SchemaField, value: &str, me: Option<&str>, validate: bool) -> Result<String> {
106 let name = &field.name;
107 match field.kind() {
108 Kind::Bundle | Kind::State => pick(field, value, validate),
109 Kind::User => {
110 let login = match me {
111 Some(me) if value.eq_ignore_ascii_case("me") => me,
112 _ => value,
113 };
114 if field.values.is_empty() {
115 Ok(login.to_string())
116 } else {
117 pick(field, login, validate)
118 }
119 }
120 Kind::Date => NaiveDate::parse_from_str(value, "%Y-%m-%d")
121 .map(|date| date.format("%Y-%m-%d").to_string())
122 .map_err(|_| usage(format!("{name}: '{value}' is not a date (YYYY-MM-DD)"))),
123 Kind::DateTime => date_time(value)
124 .map(|millis| millis.to_string())
125 .ok_or_else(|| {
126 usage(format!(
127 "{name}: '{value}' is not a date and time (YYYY-MM-DD HH:MM, or RFC 3339)"
128 ))
129 }),
130 Kind::Period => period(value).ok_or_else(|| {
131 usage(format!(
132 "{name}: '{value}' is not a period like 1w 2d 3h 30m"
133 ))
134 }),
135 Kind::Integer => value
136 .parse::<i64>()
137 .map(|number| number.to_string())
138 .map_err(|_| usage(format!("{name}: '{value}' is not a whole number"))),
139 Kind::Float => value
140 .parse::<f64>()
141 .map(|_| value.to_string())
142 .map_err(|_| usage(format!("{name}: '{value}' is not a number"))),
143 Kind::String | Kind::Text | Kind::Other => Ok(value.to_string()),
144 }
145}
146
147fn pick(field: &SchemaField, value: &str, validate: bool) -> Result<String> {
148 if !validate {
149 return Ok(value.to_string());
150 }
151 if let Some(allowed) = field.values.iter().find(|allowed| same(allowed, value)) {
152 return Ok(allowed.clone());
153 }
154 let mut message = format!("{}: '{value}' is not allowed", field.name);
155 if field.values.is_empty() {
156 message.push_str("; the field has no values to choose from");
157 } else {
158 message.push_str(&format!("; choose from {}", choices(&field.values)));
159 if let Some(close) = closest(value, field.values.iter().map(String::as_str)) {
160 message.push_str(&format!(" (did you mean '{close}'?)"));
161 }
162 }
163 Err(usage(message))
164}
165
166fn unknown_field(schema: &ProjectSchema, name: &str) -> miette::Report {
167 if schema.fields.is_empty() {
168 return usage(format!(
169 "{name}: the fields of {} could not be read; pass --no-validate to send the change as given",
170 schema.project
171 ));
172 }
173 let names: Vec<String> = schema
174 .fields
175 .iter()
176 .map(|field| field.name.clone())
177 .collect();
178 let mut message = format!(
179 "{} has no field '{name}'; its fields are {}",
180 schema.project,
181 choices(&names)
182 );
183 if let Some(close) = closest(name, names.iter().map(String::as_str)) {
184 message.push_str(&format!(" (did you mean '{close}'?)"));
185 }
186 usage(message)
187}
188
189fn date_time(value: &str) -> Option<i64> {
190 if let Ok(parsed) = DateTime::parse_from_rfc3339(value) {
191 return Some(parsed.timestamp_millis());
192 }
193 let naive = NaiveDateTime::parse_from_str(value, "%Y-%m-%d %H:%M").ok()?;
194 Local
195 .from_local_datetime(&naive)
196 .single()
197 .map(|local| local.timestamp_millis())
198}
199
200#[cfg(test)]
201mod tests {
202 use super::*;
203
204 fn field(
205 name: &str,
206 type_id: &str,
207 multi: bool,
208 can_be_empty: bool,
209 values: &[&str],
210 ) -> SchemaField {
211 SchemaField {
212 name: name.into(),
213 aliases: vec![],
214 type_id: type_id.into(),
215 multi,
216 can_be_empty,
217 has_default: false,
218 values: values.iter().map(|value| value.to_string()).collect(),
219 }
220 }
221
222 fn schema() -> ProjectSchema {
223 let mut priority = field(
224 "Priority",
225 "enum",
226 false,
227 false,
228 &["Normal", "Major", "Critical"],
229 );
230 priority.aliases = vec!["prio".into()];
231 priority.has_default = true;
232 ProjectSchema {
233 project: "WEB".into(),
234 fields: vec![
235 priority,
236 field(
237 "State",
238 "state",
239 false,
240 false,
241 &["Open", "In Progress", "Fixed"],
242 ),
243 field("Assignee", "user", false, true, &["jane", "bob"]),
244 field("Fix versions", "version", true, true, &["1.0", "1.1"]),
245 field("Due Date", "date", false, true, &[]),
246 field("Deadline", "date and time", false, true, &[]),
247 field("Estimation", "period", false, true, &[]),
248 field("Points", "integer", false, true, &[]),
249 field("Notes", "text", false, true, &[]),
250 field("Type", "enum", false, false, &["Bug", "Task"]),
251 ],
252 }
253 }
254
255 fn change(name: &str, op: Op) -> Change {
256 Change {
257 name: name.into(),
258 op,
259 }
260 }
261
262 fn set(name: &str, value: &str) -> Change {
263 change(name, Op::Set(vec![value.into()]))
264 }
265
266 fn error_text(changes: &[Change]) -> String {
267 format!(
268 "{}",
269 resolve(&schema(), changes, Some("jane"), true).unwrap_err()
270 )
271 }
272
273 #[test]
274 fn names_and_values_take_the_schema_spelling() {
275 let resolved = resolve(
276 &schema(),
277 &[set("prio", "major"), set("state", "in progress")],
278 None,
279 true,
280 )
281 .unwrap();
282 assert_eq!(resolved[0].field.name, "Priority");
283 assert_eq!(resolved[0].op, ResolvedOp::Set(vec!["Major".into()]));
284 assert_eq!(resolved[1].op, ResolvedOp::Set(vec!["In Progress".into()]));
285 }
286
287 #[test]
288 fn unknown_names_and_values_suggest_the_closest() {
289 let text = error_text(&[set("Priority", "Majr")]);
290 assert!(text.contains("did you mean 'Major'"), "{text}");
291 assert!(text.contains("Normal, Major, Critical"), "{text}");
292 let text = error_text(&[set("Priorty", "Major")]);
293 assert!(text.contains("did you mean 'Priority'"), "{text}");
294 }
295
296 #[test]
297 fn me_is_the_signed_in_user() {
298 let resolved = resolve(&schema(), &[set("Assignee", "ME")], Some("jane"), true).unwrap();
299 assert_eq!(resolved[0].op, ResolvedOp::Set(vec!["jane".into()]));
300 }
301
302 #[test]
303 fn multiplicity_and_emptiness_are_checked() {
304 assert!(
305 error_text(&[change(
306 "Priority",
307 Op::Set(vec!["Major".into(), "Normal".into()])
308 )])
309 .contains("takes one value")
310 );
311 assert!(
312 error_text(&[change("Priority", Op::Add("Major".into()))]).contains("holds one value")
313 );
314 assert!(error_text(&[change("Priority", Op::Clear)]).contains("cannot be empty"));
315 let resolved = resolve(
316 &schema(),
317 &[change(
318 "Fix versions",
319 Op::Set(vec!["1.0".into(), "1.1".into()]),
320 )],
321 None,
322 true,
323 )
324 .unwrap();
325 assert_eq!(
326 resolved[0].op,
327 ResolvedOp::Set(vec!["1.0".into(), "1.1".into()])
328 );
329 }
330
331 #[test]
332 fn typed_values_are_parsed() {
333 let resolved = resolve(
334 &schema(),
335 &[
336 set("Due Date", "2026-10-10"),
337 set("Deadline", "2026-10-10T12:00:00Z"),
338 set("Estimation", "1w 2d 30m"),
339 set("Points", "5"),
340 set("Notes", "free text"),
341 ],
342 None,
343 true,
344 )
345 .unwrap();
346 let values: Vec<ResolvedOp> = resolved.into_iter().map(|item| item.op).collect();
347 assert_eq!(
348 values,
349 vec![
350 ResolvedOp::Set(vec!["2026-10-10".into()]),
351 ResolvedOp::Set(vec!["1791633600000".into()]),
352 ResolvedOp::Set(vec!["1w 2d 30m".into()]),
353 ResolvedOp::Set(vec!["5".into()]),
354 ResolvedOp::Set(vec!["free text".into()]),
355 ]
356 );
357 assert!(error_text(&[set("Due Date", "10/10/2026")]).contains("YYYY-MM-DD"));
358 assert!(error_text(&[set("Estimation", "2d 1w")]).contains("period"));
359 assert!(error_text(&[set("Points", "five")]).contains("whole number"));
360 }
361
362 #[test]
363 fn local_date_times_use_the_local_zone() {
364 let resolved = resolve(
365 &schema(),
366 &[set("Deadline", "2026-10-10 12:00")],
367 None,
368 true,
369 )
370 .unwrap();
371 let expected = chrono::Local
372 .with_ymd_and_hms(2026, 10, 10, 12, 0, 0)
373 .single()
374 .unwrap()
375 .timestamp_millis()
376 .to_string();
377 assert_eq!(resolved[0].op, ResolvedOp::Set(vec![expected]));
378 }
379
380 #[test]
381 fn without_validation_anything_goes_through() {
382 let resolved = resolve(
383 &schema(),
384 &[set("Custom", "Value"), set("Priority", "whatever")],
385 None,
386 false,
387 )
388 .unwrap();
389 assert_eq!(resolved[0].field, SchemaField::unknown("Custom"));
390 assert_eq!(resolved[1].op, ResolvedOp::Set(vec!["whatever".into()]));
391 }
392
393 #[test]
394 fn a_field_named_twice_through_an_alias_is_an_error() {
395 assert!(
396 error_text(&[set("Priority", "Major"), set("prio", "Normal")])
397 .contains("changed twice")
398 );
399 }
400
401 #[test]
402 fn required_fields_without_defaults_are_missing() {
403 let schema = schema();
404 let missing: Vec<&str> = missing_required(&schema, &[])
405 .into_iter()
406 .map(|field| field.name.as_str())
407 .collect();
408 assert_eq!(missing, ["State", "Type"]);
409 let resolved = resolve(&schema, &[set("Type", "bug")], None, true).unwrap();
410 let missing: Vec<&str> = missing_required(&schema, &resolved)
411 .into_iter()
412 .map(|field| field.name.as_str())
413 .collect();
414 assert_eq!(missing, ["State"]);
415 }
416}