Skip to content

Commit 2787927

Browse files
authored
Represent tool sources with a shared enum (#1905)
* Represent tool sources with a shared enum * Simplify tool source deserialization and construction
1 parent 52aca42 commit 2787927

9 files changed

Lines changed: 309 additions & 48 deletions

File tree

‎ci/Cargo.lock‎

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎ci/crates/github-repo/Cargo.toml‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,8 @@ workspace = true
1313

1414
[dependencies]
1515
anyhow.workspace = true
16+
serde.workspace = true
17+
18+
[dev-dependencies]
19+
serde_json.workspace = true
20+
serde-saphyr.workspace = true

‎ci/crates/github-repo/src/lib.rs‎

Lines changed: 140 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,25 @@
1-
//! Shared GitHub repository URL classification.
1+
//! Shared tool source and GitHub repository URL classification.
22
33
use anyhow::{Context, Result, ensure};
4+
use serde::{Deserialize, Serialize, Serializer};
45

5-
/// A repository parsed from a GitHub HTTP(S) URL, borrowing its owner and name.
6-
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7-
pub struct GithubRepo<'a> {
8-
owner: &'a str,
9-
name: &'a str,
6+
/// A repository parsed from a GitHub HTTP(S) URL.
7+
#[derive(Debug, Clone, PartialEq, Eq)]
8+
pub struct GithubRepo {
9+
owner: String,
10+
name: String,
11+
original_url: String,
1012
}
1113

12-
impl<'a> TryFrom<&'a str> for GithubRepo<'a> {
14+
impl GithubRepo {
15+
/// Returns the original URL without normalizing its spelling or trailing slashes.
16+
#[must_use]
17+
pub fn as_str(&self) -> &str {
18+
&self.original_url
19+
}
20+
}
21+
22+
impl TryFrom<&str> for GithubRepo {
1323
type Error = anyhow::Error;
1424

1525
/// Parses an HTTP(S) GitHub repository URL, ignoring trailing slashes.
@@ -18,8 +28,8 @@ impl<'a> TryFrom<&'a str> for GithubRepo<'a> {
1828
///
1929
/// Returns an error for an unsupported prefix, missing owner or repository,
2030
/// or a repository path containing a subpath.
21-
fn try_from(url: &'a str) -> Result<Self> {
22-
let url = url.trim_end_matches('/');
31+
fn try_from(original_url: &str) -> Result<Self> {
32+
let url = original_url.trim_end_matches('/');
2333
let path = url
2434
.strip_prefix("https://github.com/")
2535
.or_else(|| url.strip_prefix("http://github.com/"))
@@ -29,44 +39,86 @@ impl<'a> TryFrom<&'a str> for GithubRepo<'a> {
2939
!owner.is_empty() && !name.is_empty() && !name.contains('/'),
3040
"Expected a repository URL with no subpath"
3141
);
32-
Ok(Self { owner, name })
42+
Ok(Self {
43+
owner: owner.to_owned(),
44+
name: name.to_owned(),
45+
original_url: original_url.to_owned(),
46+
})
3347
}
3448
}
3549

36-
impl std::fmt::Display for GithubRepo<'_> {
50+
impl std::fmt::Display for GithubRepo {
3751
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3852
write!(f, "{}/{}", self.owner, self.name)
3953
}
4054
}
4155

56+
/// A tool's source URL, classified without rejecting unsupported or malformed URLs.
57+
///
58+
/// Serialized as the original plain string, not as a tagged enum.
59+
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
60+
#[serde(from = "String")]
61+
pub enum ToolSource {
62+
/// A supported GitHub repository URL.
63+
Github(GithubRepo),
64+
/// Any other source string, retained verbatim for manual review.
65+
Other(String),
66+
}
67+
68+
impl ToolSource {
69+
/// Returns the original source string without URL normalization.
70+
#[must_use]
71+
pub fn as_str(&self) -> &str {
72+
match self {
73+
Self::Github(repo) => repo.as_str(),
74+
Self::Other(source) => source,
75+
}
76+
}
77+
}
78+
79+
impl From<String> for ToolSource {
80+
fn from(source: String) -> Self {
81+
GithubRepo::try_from(source.as_str()).map_or(Self::Other(source), Self::Github)
82+
}
83+
}
84+
85+
impl From<&str> for ToolSource {
86+
fn from(source: &str) -> Self {
87+
GithubRepo::try_from(source).map_or_else(|_| Self::Other(source.to_owned()), Self::Github)
88+
}
89+
}
90+
91+
impl std::fmt::Display for ToolSource {
92+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
93+
f.write_str(self.as_str())
94+
}
95+
}
96+
97+
impl Serialize for ToolSource {
98+
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
99+
serializer.serialize_str(self.as_str())
100+
}
101+
}
102+
42103
#[cfg(test)]
43104
mod tests {
44105
use super::*;
45106

46107
#[test]
47108
fn parses_plain_github_url() -> Result<()> {
48109
let repo = GithubRepo::try_from("https://github.com/owner/repo")?;
49-
assert_eq!(
50-
repo,
51-
GithubRepo {
52-
owner: "owner",
53-
name: "repo"
54-
}
55-
);
110+
assert_eq!(repo.owner, "owner");
111+
assert_eq!(repo.name, "repo");
112+
assert_eq!(repo.as_str(), "https://github.com/owner/repo");
56113
assert_eq!(repo.to_string(), "owner/repo");
57114
Ok(())
58115
}
59116

60117
#[test]
61118
fn parses_trailing_slash() -> Result<()> {
62119
let repo = GithubRepo::try_from("https://github.com/owner/repo/")?;
63-
assert_eq!(
64-
repo,
65-
GithubRepo {
66-
owner: "owner",
67-
name: "repo"
68-
}
69-
);
120+
assert_eq!(repo.to_string(), "owner/repo");
121+
assert_eq!(repo.as_str(), "https://github.com/owner/repo/");
70122
Ok(())
71123
}
72124

@@ -113,6 +165,66 @@ mod tests {
113165
}
114166
}
115167

168+
#[test]
169+
fn source_variants_preserve_raw_strings_through_serde() -> Result<()> {
170+
for (raw, github) in [
171+
("https://github.com/Owner/Repo.git", true),
172+
("http://github.com/Owner/Repo///", true),
173+
("https://github.com/owner/repo?tab=readme", true),
174+
("https://github.com/owner/repo#readme", true),
175+
("https://github.com/owner/repo%2Ftree", true),
176+
("https://github.com/owner/repo ", true),
177+
("https://gitlab.com/owner/repo/", false),
178+
("https://github.com/owner/repo/tree/main", false),
179+
("https://github.com/owner/", false),
180+
("https://github.com//repo", false),
181+
("https://GitHub.com/owner/repo", false),
182+
(" https://github.com/owner/repo", false),
183+
("git@github.com:owner/repo.git", false),
184+
("not a URL", false),
185+
("", false),
186+
] {
187+
let encoded = serde_json::to_string(raw)?;
188+
for source in [
189+
ToolSource::from(raw),
190+
ToolSource::from(raw.to_owned()),
191+
serde_json::from_str::<ToolSource>(&encoded)?,
192+
serde_saphyr::from_str::<ToolSource>(&encoded)?,
193+
] {
194+
assert_eq!(matches!(source, ToolSource::Github(_)), github, "{raw}");
195+
assert_eq!(source.as_str(), raw);
196+
assert_eq!(source.to_string(), raw);
197+
assert_eq!(serde_json::to_string(&source)?, encoded);
198+
}
199+
}
200+
Ok(())
201+
}
202+
203+
#[test]
204+
fn source_deserialization_matches_plain_string_acceptance() {
205+
for raw in ["null", "42", "true", "[]", r#"{"Github":"owner/repo"}"#] {
206+
assert!(serde_json::from_str::<ToolSource>(raw).is_err(), "{raw}");
207+
assert_eq!(
208+
serde_saphyr::from_str::<ToolSource>(raw).ok(),
209+
serde_saphyr::from_str::<String>(raw)
210+
.ok()
211+
.map(ToolSource::from),
212+
"{raw}"
213+
);
214+
}
215+
}
216+
217+
#[test]
218+
fn repository_owns_its_url() -> Result<()> {
219+
let repo = {
220+
let url = String::from("http://github.com/Owner/Repo///");
221+
GithubRepo::try_from(url.as_str())?
222+
};
223+
assert_eq!(repo.to_string(), "Owner/Repo");
224+
assert_eq!(repo.as_str(), "http://github.com/Owner/Repo///");
225+
Ok(())
226+
}
227+
116228
#[test]
117229
fn rejects_subpath() {
118230
assert!(GithubRepo::try_from("https://github.com/owner/repo/tree/main/subdir").is_err());
@@ -138,8 +250,9 @@ mod tests {
138250
assert_eq!(
139251
GithubRepo::try_from(url).ok(),
140252
Some(GithubRepo {
141-
owner: "owner",
142-
name: "repo"
253+
owner: "owner".into(),
254+
name: "repo".into(),
255+
original_url: url.into(),
143256
})
144257
);
145258
}

‎ci/crates/pr-check/src/input.rs‎

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
//! Catalog input paths and tool loading.
22
33
use anyhow::{Context, Result, ensure};
4+
use github_repo::ToolSource;
45
use serde::Deserialize;
56
use std::fmt;
67
use std::path::{Component, PathBuf};
@@ -40,7 +41,7 @@ impl fmt::Display for ToolPath {
4041
#[derive(Debug, Deserialize)]
4142
pub struct ToolEntry {
4243
pub name: String,
43-
pub source: Option<String>,
44+
pub source: Option<ToolSource>,
4445
pub homepage: Option<String>,
4546
}
4647

@@ -104,6 +105,30 @@ mod tests {
104105
Ok(())
105106
}
106107

108+
#[test]
109+
fn tool_sources_are_classified_during_deserialization() -> Result<()> {
110+
let tool: ToolEntry =
111+
serde_saphyr::from_str("name: Example\nsource: http://github.com/Owner/Repo///\n")?;
112+
let Some(ToolSource::Github(repo)) = tool.source else {
113+
anyhow::bail!("Expected a GitHub repository");
114+
};
115+
assert_eq!(repo.to_string(), "Owner/Repo");
116+
assert_eq!(repo.as_str(), "http://github.com/Owner/Repo///");
117+
for source in [
118+
"https://gitlab.com/owner/repo",
119+
"https://github.com/owner/repo/tree/main",
120+
"",
121+
] {
122+
let yaml = format!("name: Example\nsource: '{source}'\n");
123+
let tool: ToolEntry = serde_saphyr::from_str(&yaml)?;
124+
assert!(matches!(tool.source, Some(ToolSource::Other(ref value)) if value == source));
125+
}
126+
for yaml in ["name: Example\n", "name: Example\nsource: null\n"] {
127+
assert!(serde_saphyr::from_str::<ToolEntry>(yaml)?.source.is_none());
128+
}
129+
Ok(())
130+
}
131+
107132
#[test]
108133
fn tool_yaml_keeps_optional_fields_and_ignores_unrelated_metadata() -> Result<()> {
109134
let tool: ToolEntry =

‎ci/crates/pr-check/src/network.rs‎

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
33
use anyhow::{Context, Result, bail};
44
use chrono::{DateTime, Utc};
5-
use github_repo::GithubRepo;
5+
use github_repo::{GithubRepo, ToolSource};
66
use serde::{Deserialize, de::DeserializeOwned};
77

88
use crate::checks::{Check, DomainAge};
@@ -67,7 +67,7 @@ impl GithubClient {
6767
/// # Errors
6868
///
6969
/// Returns an error if the API call fails.
70-
async fn repo_info(&self, repo: GithubRepo<'_>) -> Result<Option<RepoInfo>> {
70+
async fn repo_info(&self, repo: &GithubRepo) -> Result<Option<RepoInfo>> {
7171
let url = format!("https://api.github.com/repos/{repo}");
7272
self.get::<RepoInfo>(&url).await
7373
}
@@ -77,7 +77,7 @@ impl GithubClient {
7777
/// # Errors
7878
///
7979
/// Returns an error if the API call fails.
80-
async fn contributor_count(&self, repo: GithubRepo<'_>) -> Result<Option<usize>> {
80+
async fn contributor_count(&self, repo: &GithubRepo) -> Result<Option<usize>> {
8181
let url = format!("https://api.github.com/repos/{repo}/contributors?per_page=100&anon=0");
8282
Ok(self
8383
.get::<Vec<Contributor>>(&url)
@@ -95,11 +95,7 @@ impl GithubClient {
9595
pub async fn check_tool(client: &GithubClient, tool: &ToolEntry) -> Result<ToolReport> {
9696
let source = &tool.source;
9797

98-
let repo = source
99-
.as_deref()
100-
.and_then(|url| GithubRepo::try_from(url).ok());
101-
102-
if let Some(repo) = repo {
98+
if let Some(ToolSource::Github(repo)) = source {
10399
let repo_result = client.repo_info(repo).await;
104100
let contributors_result = client.contributor_count(repo).await;
105101

‎ci/crates/pr-check/src/report.rs‎

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
//! Report status, Markdown rendering, and workflow exit-code contract.
22
33
use askama::Template;
4+
use github_repo::ToolSource;
45
use std::process::ExitCode;
56

67
// Identifies the report as output from the contribution checker.
@@ -42,7 +43,7 @@ impl CheckResult {
4243
#[derive(Debug)]
4344
pub struct ToolReport {
4445
pub name: String,
45-
pub source: Option<String>,
46+
pub source: Option<ToolSource>,
4647
pub stars: CheckResult,
4748
pub contributors: CheckResult,
4849
pub age: CheckResult,
@@ -140,6 +141,26 @@ mod tests {
140141
}
141142
}
142143

144+
#[test]
145+
fn comments_preserve_source_text_for_each_variant() {
146+
for source in [
147+
"http://github.com/Owner/Repo///",
148+
"https://gitlab.com/owner/repo",
149+
"",
150+
] {
151+
let report = ToolReport {
152+
source: Some(source.into()),
153+
..passing_report()
154+
};
155+
let reports = Reports::from_iter([report]);
156+
assert!(
157+
Comment::from(&reports)
158+
.to_string()
159+
.contains(&format!("Source: {source}\n"))
160+
);
161+
}
162+
}
163+
143164
#[test]
144165
fn collect_and_extend_preserve_input_order() {
145166
let report = |name: &str| ToolReport {

0 commit comments

Comments
 (0)