Skip to main content

yt/fields/
mod.rs

1pub mod apply;
2pub mod cache;
3pub mod change;
4pub mod plan;
5pub mod resolve;
6pub mod schema;
7
8use crate::api::Client;
9use crate::error::{Result, YtError};
10use crate::fields::cache::SchemaCache;
11use crate::fields::change::{Change, Op};
12use crate::fields::resolve::Resolved;
13use crate::fields::schema::ProjectSchema;
14
15/// Case-insensitive comparison for names and values (Unicode, so `Stāvoklis` matches `stāvoklis`).
16pub fn same(a: &str, b: &str) -> bool {
17    a.to_lowercase() == b.to_lowercase()
18}
19
20/// Up to ten values, then how many more.
21pub fn choices(values: &[String]) -> String {
22    let mut text = values
23        .iter()
24        .take(10)
25        .map(String::as_str)
26        .collect::<Vec<_>>()
27        .join(", ");
28    if values.len() > 10 {
29        text.push_str(&format!(" and {} more", values.len() - 10));
30    }
31    text
32}
33
34/// The candidate closest to `value`, when it is close enough to be a likely typo.
35pub fn closest<'a>(value: &str, candidates: impl IntoIterator<Item = &'a str>) -> Option<&'a str> {
36    let value = value.to_lowercase();
37    candidates
38        .into_iter()
39        .map(|candidate| {
40            (
41                strsim::jaro_winkler(&value, &candidate.to_lowercase()),
42                candidate,
43            )
44        })
45        .filter(|(score, _)| *score > 0.8)
46        .max_by(|a, b| a.0.total_cmp(&b.0))
47        .map(|(_, candidate)| candidate)
48}
49
50/// `1w 2d 3h 30m`: any of these units, each once, in this order; `None` for anything else.
51pub fn period(value: &str) -> Option<String> {
52    let units = ['w', 'd', 'h', 'm'];
53    let mut next = 0;
54    let mut parts = Vec::new();
55    for token in value.split_whitespace() {
56        let (index, unit) = token.char_indices().last()?;
57        let number = &token[..index];
58        let position = units.iter().position(|candidate| *candidate == unit)?;
59        if position < next || number.is_empty() || !number.chars().all(|c| c.is_ascii_digit()) {
60            return None;
61        }
62        next = position + 1;
63        parts.push(token);
64    }
65    (!parts.is_empty()).then(|| parts.join(" "))
66}
67
68pub(crate) fn usage(message: impl Into<String>) -> miette::Report {
69    YtError::Usage {
70        message: message.into(),
71    }
72    .into()
73}
74
75/// Resolves changes for issues of `project` with the cached schema, refetching it once when a name or value
76/// is not in the cached copy (it may predate a change in YouTrack).
77pub async fn resolve_for(
78    client: &Client,
79    cache: &SchemaCache,
80    project: &str,
81    changes: &[Change],
82    validate: bool,
83) -> Result<(ProjectSchema, Vec<Resolved>)> {
84    let me = if mentions_me(changes) {
85        Some(crate::api::users::me(client).await?.login)
86    } else {
87        None
88    };
89    if let Some(schema) = cache.load(project)
90        && let Ok(resolved) = resolve::resolve(&schema, changes, me.as_deref(), validate)
91    {
92        return Ok((schema, resolved));
93    }
94    let schema = cache.refresh(client, project).await?;
95    let resolved = resolve::resolve(&schema, changes, me.as_deref(), validate)?;
96    Ok((schema, resolved))
97}
98
99fn mentions_me(changes: &[Change]) -> bool {
100    changes.iter().any(|change| match &change.op {
101        Op::Set(values) => values.iter().any(|value| value.eq_ignore_ascii_case("me")),
102        Op::Add(value) | Op::Remove(value) => value.eq_ignore_ascii_case("me"),
103        Op::Clear => false,
104    })
105}
106
107#[cfg(test)]
108mod tests {
109    use crate::api::test_client;
110    use crate::fields::cache::SchemaCache;
111    use crate::fields::change::{Change, Op};
112    use crate::fields::resolve::ResolvedOp;
113    use crate::fields::schema::{ProjectSchema, SchemaField};
114    use serde_json::json;
115    use std::time::Duration;
116    use wiremock::matchers::{method, path};
117    use wiremock::{Mock, MockServer, ResponseTemplate};
118
119    use super::*;
120
121    #[test]
122    fn names_match_case_insensitively_in_any_script() {
123        assert!(same("Stāvoklis", "STĀVOKLIS"));
124        assert!(!same("State", "Stage"));
125    }
126
127    #[test]
128    fn periods_take_each_unit_once_in_order() {
129        assert_eq!(period("1w 2d 3h 30m").as_deref(), Some("1w 2d 3h 30m"));
130        assert_eq!(period(" 90m ").as_deref(), Some("90m"));
131        assert_eq!(period("2d 1w"), None);
132        assert_eq!(period("90"), None);
133        assert_eq!(period(""), None);
134    }
135
136    #[test]
137    fn long_choice_lists_are_cut() {
138        let values: Vec<String> = (1..=12).map(|n| n.to_string()).collect();
139        assert_eq!(choices(&values), "1, 2, 3, 4, 5, 6, 7, 8, 9, 10 and 2 more");
140    }
141
142    #[test]
143    fn the_closest_candidate_is_suggested_only_when_close() {
144        assert_eq!(closest("Majr", ["Normal", "Major"]), Some("Major"));
145        assert_eq!(closest("xyz", ["Normal", "Major"]), None);
146    }
147
148    fn priority(values: &[&str]) -> ProjectSchema {
149        ProjectSchema {
150            project: "WEB".into(),
151            fields: vec![SchemaField {
152                name: "Priority".into(),
153                aliases: vec![],
154                type_id: "enum".into(),
155                multi: false,
156                can_be_empty: false,
157                has_default: true,
158                values: values.iter().map(|value| value.to_string()).collect(),
159            }],
160        }
161    }
162
163    fn server_fields(values: &[&str]) -> serde_json::Value {
164        let values: Vec<_> = values
165            .iter()
166            .map(|name| json!({"name": name, "archived": false}))
167            .collect();
168        json!([{"canBeEmpty": false, "defaultValues": [{"name": "Normal"}],
169                "field": {"name": "Priority", "fieldType": {"id": "enum[1]", "isMultiValue": false}},
170                "bundle": {"values": values}}])
171    }
172
173    fn set_priority(value: &str) -> Vec<Change> {
174        vec![Change {
175            name: "Priority".into(),
176            op: Op::Set(vec![value.into()]),
177        }]
178    }
179
180    #[tokio::test]
181    async fn a_fresh_cached_schema_needs_no_request() {
182        let server = MockServer::start().await;
183        Mock::given(method("GET"))
184            .respond_with(ResponseTemplate::new(500))
185            .expect(0)
186            .mount(&server)
187            .await;
188        let dir = tempfile::tempdir().unwrap();
189        let cache = SchemaCache::new(dir.path(), "acme", Duration::from_secs(60));
190        cache.store(&priority(&["Normal", "Major"]));
191        let (_, resolved) = resolve_for(
192            &test_client(&server.uri()),
193            &cache,
194            "WEB",
195            &set_priority("major"),
196            true,
197        )
198        .await
199        .unwrap();
200        assert_eq!(resolved[0].op, ResolvedOp::Set(vec!["Major".into()]));
201    }
202
203    #[tokio::test]
204    async fn a_value_missing_from_the_cache_refetches_once() {
205        let server = MockServer::start().await;
206        Mock::given(method("GET"))
207            .and(path("/api/admin/projects/WEB/customFields"))
208            .respond_with(
209                ResponseTemplate::new(200).set_body_json(server_fields(&["Normal", "Urgent"])),
210            )
211            .expect(1)
212            .mount(&server)
213            .await;
214        let dir = tempfile::tempdir().unwrap();
215        let cache = SchemaCache::new(dir.path(), "acme", Duration::from_secs(60));
216        cache.store(&priority(&["Normal"]));
217        let (schema, resolved) = resolve_for(
218            &test_client(&server.uri()),
219            &cache,
220            "WEB",
221            &set_priority("Urgent"),
222            true,
223        )
224        .await
225        .unwrap();
226        assert_eq!(resolved[0].op, ResolvedOp::Set(vec!["Urgent".into()]));
227        assert_eq!(cache.load("WEB"), Some(schema));
228    }
229
230    #[tokio::test]
231    async fn me_is_looked_up_only_when_used() {
232        let server = MockServer::start().await;
233        Mock::given(method("GET"))
234            .and(path("/api/users/me"))
235            .respond_with(
236                ResponseTemplate::new(200).set_body_json(json!({"id": "1-1", "login": "jane"})),
237            )
238            .expect(1)
239            .mount(&server)
240            .await;
241        let dir = tempfile::tempdir().unwrap();
242        let cache = SchemaCache::new(dir.path(), "acme", Duration::from_secs(60));
243        cache.store(&ProjectSchema {
244            project: "WEB".into(),
245            fields: vec![SchemaField {
246                name: "Assignee".into(),
247                aliases: vec![],
248                type_id: "user".into(),
249                multi: false,
250                can_be_empty: true,
251                has_default: false,
252                values: vec!["jane".into()],
253            }],
254        });
255        let changes = vec![Change {
256            name: "Assignee".into(),
257            op: Op::Set(vec!["me".into()]),
258        }];
259        let (_, resolved) = resolve_for(&test_client(&server.uri()), &cache, "WEB", &changes, true)
260            .await
261            .unwrap();
262        assert_eq!(resolved[0].op, ResolvedOp::Set(vec!["jane".into()]));
263    }
264}