1- //! Shared GitHub repository URL classification.
1+ //! Shared tool source and GitHub repository URL classification.
22
33use 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) ]
43104mod 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 }
0 commit comments