Skip to main content

yt/fields/
resolve.rs

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/// A change matched against the schema, with values in the form YouTrack expects.
17#[derive(Clone, Debug, PartialEq)]
18pub struct Resolved {
19    pub field: SchemaField,
20    pub op: ResolvedOp,
21}
22
23/// Matches `changes` against `schema`: names and aliases, allowed values, value formats and multiplicity.
24/// Without `validate`, names and values are taken as given; fields the schema lacks go out as commands.
25pub 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
92/// Fields a new issue must be given: not allowed to be empty, without a default, and not in `resolved`.
93pub 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}