1use serde::Deserialize;
2
3use crate::api::custom_fields::IssueCustomField;
4use crate::api::{Client, Projection};
5use crate::error::Result;
6use crate::fields::{choices, closest, same, usage};
7
8#[derive(Clone, Debug, Deserialize)]
9#[serde(rename_all = "camelCase")]
10pub struct LinkType {
11 pub name: String,
12 pub source_to_target: Option<String>,
13 pub target_to_source: Option<String>,
14 #[serde(default)]
15 pub directed: bool,
16}
17
18impl Projection for LinkType {
19 const FIELDS: &'static str = "name,sourceToTarget,targetToSource,directed";
20}
21
22#[derive(Clone, Debug, Deserialize)]
23#[serde(rename_all = "camelCase")]
24pub struct IssueLink {
25 pub direction: String,
26 pub link_type: LinkType,
27 #[serde(default)]
28 pub issues: Vec<LinkedIssue>,
29}
30
31#[derive(Clone, Debug, Deserialize)]
32#[serde(rename_all = "camelCase")]
33pub struct LinkedIssue {
34 pub id_readable: String,
35 pub summary: Option<String>,
36 #[serde(default)]
37 pub custom_fields: Vec<IssueCustomField>,
38}
39
40impl Projection for IssueLink {
41 const FIELDS: &'static str = "direction,linkType(name,sourceToTarget,targetToSource,directed),issues(idReadable,summary,customFields(name,$type,projectCustomField(field(fieldType(id))),value(name,login,fullName,isResolved,minutes,presentation,text,$type)))";
42}
43
44impl IssueLink {
45 pub fn inward(&self) -> bool {
46 self.direction == "INWARD"
47 }
48
49 pub fn word(&self) -> &str {
51 let word = if self.inward() {
52 &self.link_type.target_to_source
53 } else {
54 &self.link_type.source_to_target
55 };
56 word.as_deref().unwrap_or(&self.link_type.name)
57 }
58}
59
60pub async fn types(client: &Client) -> Result<Vec<LinkType>> {
61 client.get("issueLinkTypes", &[("$top", "500")]).await
62}
63
64pub async fn list(client: &Client, issue: &str) -> Result<Vec<IssueLink>> {
65 client.get_all(&format!("issues/{issue}/links")).await
66}
67
68pub fn resolve_word(types: &[LinkType], word: &str) -> Result<String> {
70 let mut words: Vec<String> = Vec::new();
71 for link_type in types {
72 for candidate in [&link_type.source_to_target, &link_type.target_to_source]
73 .into_iter()
74 .flatten()
75 {
76 if !words.contains(candidate) {
77 words.push(candidate.clone());
78 }
79 }
80 }
81 if let Some(found) = words.iter().find(|candidate| same(candidate, word)) {
82 return Ok(found.clone());
83 }
84 let mut message = format!("'{word}' is not a link type; use {}", choices(&words));
85 if let Some(close) = closest(word, words.iter().map(String::as_str)) {
86 message.push_str(&format!(" (did you mean '{close}'?)"));
87 }
88 Err(usage(message))
89}
90
91#[cfg(test)]
92mod tests {
93 use super::*;
94
95 fn link_types() -> Vec<LinkType> {
96 vec![
97 LinkType {
98 name: "Depend".into(),
99 source_to_target: Some("depends on".into()),
100 target_to_source: Some("is required for".into()),
101 directed: true,
102 },
103 LinkType {
104 name: "Relates".into(),
105 source_to_target: Some("relates to".into()),
106 target_to_source: Some("relates to".into()),
107 directed: false,
108 },
109 ]
110 }
111
112 #[test]
113 fn direction_words_take_the_instance_spelling() {
114 assert_eq!(
115 resolve_word(&link_types(), "Depends On").unwrap(),
116 "depends on"
117 );
118 assert_eq!(
119 resolve_word(&link_types(), "is required for").unwrap(),
120 "is required for"
121 );
122 let error = resolve_word(&link_types(), "depend on").unwrap_err();
123 assert!(
124 error.to_string().contains("did you mean 'depends on'"),
125 "{error}"
126 );
127 }
128
129 #[test]
130 fn a_link_reads_in_its_direction() {
131 let link = IssueLink {
132 direction: "INWARD".into(),
133 link_type: link_types().remove(0),
134 issues: vec![],
135 };
136 assert_eq!(link.word(), "is required for");
137 assert!(link.inward());
138 }
139}