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
15pub fn same(a: &str, b: &str) -> bool {
17 a.to_lowercase() == b.to_lowercase()
18}
19
20pub 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
34pub 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
50pub 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
75pub 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}