Skip to main content

silver_platter/
candidates.rs

1//! Candidates for packages.
2use crate::Mode;
3use serde::{Deserialize, Serialize};
4
5#[derive(Debug, Clone, PartialEq, Eq)]
6/// Error type for shortname extraction
7pub enum ShortnameError {
8    /// URL has no path segments
9    NoPathSegments,
10    /// No non-empty path segments found
11    NoValidSegments,
12}
13
14impl std::fmt::Display for ShortnameError {
15    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
16        match self {
17            ShortnameError::NoPathSegments => write!(f, "URL has no path segments"),
18            ShortnameError::NoValidSegments => write!(f, "No non-empty path segments found"),
19        }
20    }
21}
22
23impl std::error::Error for ShortnameError {}
24
25#[derive(Debug, Clone, Deserialize, Serialize)]
26/// A candidate for a package.
27pub struct Candidate {
28    /// The URL of the repository.
29    pub url: url::Url,
30
31    /// The name of the package.
32    pub name: Option<String>,
33
34    /// The branch to use.
35    pub branch: Option<String>,
36
37    /// The subpath to use.
38    pub subpath: Option<std::path::PathBuf>,
39
40    /// Multiple paths to process separately (for monorepos).
41    pub paths: Option<Vec<String>>,
42
43    #[serde(rename = "default-mode")]
44    /// The default mode to use.
45    pub default_mode: Option<Mode>,
46}
47
48impl Candidate {
49    /// Return the short name of the candidate.
50    pub fn shortname(&self) -> Result<std::borrow::Cow<'_, str>, ShortnameError> {
51        match &self.name {
52            Some(name) => Ok(std::borrow::Cow::Borrowed(name)),
53            None => {
54                if let Some(segments) = self.url.path_segments() {
55                    let segments: Vec<_> = segments.collect();
56
57                    // Find the last non-empty segment
58                    let last_non_empty = segments.iter().rev().find(|s| !s.is_empty());
59
60                    match last_non_empty {
61                        Some(segment) => Ok(std::borrow::Cow::Owned(segment.to_string())),
62                        None => Err(ShortnameError::NoValidSegments),
63                    }
64                } else {
65                    Err(ShortnameError::NoPathSegments)
66                }
67            }
68        }
69    }
70}
71
72#[derive(Debug, Clone, Default)]
73/// Candidates
74pub struct Candidates(Vec<Candidate>);
75
76impl Candidates {
77    /// Load packages from a file
78    pub fn from_path(path: &std::path::Path) -> std::io::Result<Self> {
79        let f = std::fs::File::open(path)?;
80        let candidates: Vec<Candidate> = serde_yaml::from_reader(f).unwrap();
81        Ok(Self(candidates))
82    }
83
84    /// Return a slice of the candidates.
85    pub fn candidates(&self) -> &[Candidate] {
86        self.0.as_slice()
87    }
88
89    /// Return an iterator over the candidates.
90    pub fn iter(&self) -> impl Iterator<Item = &Candidate> {
91        self.0.iter()
92    }
93
94    /// Create an empty Candidates object.
95    pub fn new() -> Self {
96        Self(Vec::new())
97    }
98}
99
100impl TryFrom<serde_yaml::Value> for Candidates {
101    type Error = serde_yaml::Error;
102
103    fn try_from(yaml: serde_yaml::Value) -> Result<Self, Self::Error> {
104        Ok(Self(serde_yaml::from_value(yaml)?))
105    }
106}
107
108impl From<Vec<Candidate>> for Candidates {
109    fn from(candidates: Vec<Candidate>) -> Self {
110        Self(candidates)
111    }
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117    #[test]
118    fn test_read() {
119        let td = tempfile::tempdir().unwrap();
120        let path = td.path().join("candidates.yaml");
121        std::fs::write(
122            &path,
123            r#"---
124    - url: https://github.com/jelmer/dulwich
125    - name: samba
126      url: https://git.samba.org/samba.git
127    "#,
128        )
129        .unwrap();
130        let candidates = Candidates::from_path(&path).unwrap();
131        assert_eq!(candidates.candidates().len(), 2);
132        assert_eq!(
133            candidates.candidates()[0].url,
134            url::Url::parse("https://github.com/jelmer/dulwich").unwrap()
135        );
136        assert_eq!(
137            candidates.candidates()[1].url,
138            url::Url::parse("https://git.samba.org/samba.git").unwrap()
139        );
140        assert_eq!(candidates.candidates()[1].name, Some("samba".to_string()));
141    }
142
143    #[test]
144    fn test_shortname() {
145        let candidate = Candidate {
146            url: url::Url::parse("https://github.com/jelmer/dulwich").unwrap(),
147            name: None,
148            branch: None,
149            subpath: None,
150            paths: None,
151            default_mode: None,
152        };
153
154        assert_eq!(candidate.shortname().unwrap(), "dulwich");
155    }
156
157    #[test]
158    fn test_shortname_stored() {
159        let candidate = Candidate {
160            url: url::Url::parse("https://github.com/jelmer/dulwich").unwrap(),
161            name: Some("foo".to_string()),
162            branch: None,
163            subpath: None,
164            paths: None,
165            default_mode: None,
166        };
167
168        assert_eq!(candidate.shortname().unwrap(), "foo");
169    }
170
171    #[test]
172    fn test_shortname_cow_behavior() {
173        use std::borrow::Cow;
174
175        // Test borrowed case (when name exists)
176        let candidate_with_name = Candidate {
177            url: url::Url::parse("https://github.com/jelmer/dulwich").unwrap(),
178            name: Some("myproject".to_string()),
179            branch: None,
180            subpath: None,
181            paths: None,
182            default_mode: None,
183        };
184
185        let shortname = candidate_with_name.shortname().unwrap();
186        assert!(matches!(shortname, Cow::Borrowed(_)));
187        assert_eq!(shortname, "myproject");
188
189        // Test owned case (when name is None)
190        let candidate_without_name = Candidate {
191            url: url::Url::parse("https://github.com/jelmer/dulwich").unwrap(),
192            name: None,
193            branch: None,
194            subpath: None,
195            paths: None,
196            default_mode: None,
197        };
198
199        let shortname = candidate_without_name.shortname().unwrap();
200        assert!(matches!(shortname, Cow::Owned(_)));
201        assert_eq!(shortname, "dulwich");
202    }
203
204    #[test]
205    fn test_shortname_edge_cases() {
206        // Test URL without path segments
207        let candidate_no_path = Candidate {
208            url: url::Url::parse("https://github.com/").unwrap(),
209            name: None,
210            branch: None,
211            subpath: None,
212            paths: None,
213            default_mode: None,
214        };
215        assert!(candidate_no_path.shortname().is_err());
216        assert_eq!(
217            candidate_no_path.shortname().unwrap_err(),
218            ShortnameError::NoValidSegments
219        );
220
221        // Test URL with trailing slash
222        let candidate_trailing_slash = Candidate {
223            url: url::Url::parse("https://github.com/jelmer/project/").unwrap(),
224            name: None,
225            branch: None,
226            subpath: None,
227            paths: None,
228            default_mode: None,
229        };
230        assert_eq!(candidate_trailing_slash.shortname().unwrap(), "project");
231    }
232
233    #[test]
234    fn test_candidates_new() {
235        let candidates = Candidates::new();
236        assert_eq!(candidates.candidates().len(), 0);
237    }
238
239    #[test]
240    fn test_candidates_from_vec() {
241        let candidate1 = Candidate {
242            url: url::Url::parse("https://github.com/jelmer/dulwich").unwrap(),
243            name: Some("dulwich".to_string()),
244            branch: None,
245            subpath: None,
246            paths: None,
247            default_mode: None,
248        };
249
250        let candidate2 = Candidate {
251            url: url::Url::parse("https://github.com/jelmer/silver-platter").unwrap(),
252            name: Some("silver-platter".to_string()),
253            branch: None,
254            subpath: None,
255            paths: None,
256            default_mode: None,
257        };
258
259        let candidates_vec = vec![candidate1, candidate2];
260        let candidates = Candidates::from(candidates_vec);
261
262        assert_eq!(candidates.candidates().len(), 2);
263        assert_eq!(candidates.candidates()[0].name, Some("dulwich".to_string()));
264        assert_eq!(
265            candidates.candidates()[1].name,
266            Some("silver-platter".to_string())
267        );
268    }
269
270    #[test]
271    fn test_candidates_iter() {
272        let candidate1 = Candidate {
273            url: url::Url::parse("https://github.com/jelmer/dulwich").unwrap(),
274            name: Some("dulwich".to_string()),
275            branch: None,
276            subpath: None,
277            paths: None,
278            default_mode: None,
279        };
280
281        let candidate2 = Candidate {
282            url: url::Url::parse("https://github.com/jelmer/silver-platter").unwrap(),
283            name: Some("silver-platter".to_string()),
284            branch: None,
285            subpath: None,
286            paths: None,
287            default_mode: None,
288        };
289
290        let candidates = Candidates::from(vec![candidate1, candidate2]);
291
292        let names: Vec<String> = candidates.iter().map(|c| c.name.clone().unwrap()).collect();
293
294        assert_eq!(
295            names,
296            vec!["dulwich".to_string(), "silver-platter".to_string()]
297        );
298    }
299
300    #[test]
301    fn test_try_from_yaml() {
302        let yaml = serde_yaml::from_str::<serde_yaml::Value>(
303            r#"
304        - url: https://github.com/jelmer/dulwich
305          name: dulwich
306        - url: https://github.com/jelmer/silver-platter
307          name: silver-platter
308          branch: main
309        "#,
310        )
311        .unwrap();
312
313        let candidates = Candidates::try_from(yaml).unwrap();
314
315        assert_eq!(candidates.candidates().len(), 2);
316        assert_eq!(candidates.candidates()[0].name, Some("dulwich".to_string()));
317        assert_eq!(candidates.candidates()[1].branch, Some("main".to_string()));
318    }
319
320    #[test]
321    fn test_candidate_with_paths() {
322        let td = tempfile::tempdir().unwrap();
323        let path = td.path().join("candidates.yaml");
324        std::fs::write(
325            &path,
326            r#"---
327    - url: https://github.com/org/monorepo
328      name: monorepo
329      paths:
330        - frontend
331        - backend
332        - docs
333    "#,
334        )
335        .unwrap();
336        let candidates = Candidates::from_path(&path).unwrap();
337        assert_eq!(candidates.candidates().len(), 1);
338        let candidate = &candidates.candidates()[0];
339        assert_eq!(candidate.name, Some("monorepo".to_string()));
340        assert_eq!(
341            candidate.paths,
342            Some(vec![
343                "frontend".to_string(),
344                "backend".to_string(),
345                "docs".to_string()
346            ])
347        );
348    }
349}