Skip to main content

ephemeris_forge/
repo.rs

1//! The `<owner>/<name>` pair the CLI operates on.
2
3use std::fmt;
4use std::str::FromStr;
5
6use crate::segment::path_segment;
7
8/// A repository named on the command line.
9///
10/// The CLI never inspects the working directory, so this is the only way a
11/// target is chosen.
12#[derive(Clone, Debug, Eq, PartialEq)]
13pub struct RepoRef {
14    owner: String,
15    name: String,
16}
17
18impl RepoRef {
19    /// The account or organization the repository belongs to.
20    #[must_use]
21    pub fn owner(&self) -> &str {
22        &self.owner
23    }
24
25    /// The repository's slug within its owner.
26    #[must_use]
27    pub fn name(&self) -> &str {
28        &self.name
29    }
30
31    /// The `repos/{owner}/{name}` API path with `rest` appended.
32    ///
33    /// Both segments are percent-encoded. `rest` is used as given.
34    #[must_use]
35    pub fn path(&self, rest: &str) -> String {
36        let base = format!(
37            "repos/{}/{}",
38            path_segment(&self.owner),
39            path_segment(&self.name)
40        );
41        if rest.is_empty() {
42            base
43        } else {
44            format!("{base}/{rest}")
45        }
46    }
47}
48
49impl fmt::Display for RepoRef {
50    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
51        write!(f, "{}/{}", self.owner, self.name)
52    }
53}
54
55/// A [`RepoRef`] could not be parsed from its `<owner>/<name>` form.
56#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
57#[error("expected <owner>/<name>, got {input:?}")]
58pub struct RepoRefParseError {
59    /// The string that failed to parse.
60    pub input: String,
61}
62
63impl FromStr for RepoRef {
64    type Err = RepoRefParseError;
65
66    fn from_str(raw: &str) -> Result<Self, Self::Err> {
67        let mut parts = raw.split('/');
68        let owner = parts.next().unwrap_or_default();
69        let name = parts.next().unwrap_or_default();
70        if owner.is_empty() || name.is_empty() || parts.next().is_some() {
71            return Err(RepoRefParseError {
72                input: raw.to_owned(),
73            });
74        }
75        Ok(Self {
76            owner: owner.to_owned(),
77            name: name.to_owned(),
78        })
79    }
80}
81
82#[cfg(test)]
83mod tests {
84    use std::str::FromStr as _;
85
86    use super::RepoRef;
87
88    #[test]
89    fn a_well_formed_pair_parses() {
90        let repo = RepoRef::from_str("inomotech/finmqtt").expect("parses");
91        assert_eq!(repo.owner(), "inomotech");
92        assert_eq!(repo.name(), "finmqtt");
93        assert_eq!(repo.to_string(), "inomotech/finmqtt");
94    }
95
96    #[test]
97    fn the_path_encodes_both_segments() {
98        let repo = RepoRef::from_str("a b/c#d").expect("parses");
99        assert_eq!(repo.path(""), "repos/a%20b/c%23d");
100        assert_eq!(repo.path("labels"), "repos/a%20b/c%23d/labels");
101    }
102
103    #[test]
104    fn a_malformed_pair_is_rejected() {
105        for raw in ["finmqtt", "a/b/c", "/finmqtt", "inomotech/", ""] {
106            RepoRef::from_str(raw).expect_err(raw);
107        }
108    }
109}