11use crate :: errors:: SampoError ;
22use rustc_hash:: FxHashSet ;
3+ use semver:: Version ;
34use std:: collections:: BTreeSet ;
45use std:: path:: Path ;
56
@@ -23,6 +24,8 @@ pub struct Config {
2324 pub ignore : Vec < String > ,
2425 pub git_default_branch : Option < String > ,
2526 pub git_release_branches : Vec < String > ,
27+ /// Package using short tag format (`v{version}`) for Packagist compatibility.
28+ pub git_short_tags : Option < String > ,
2629}
2730
2831impl Default for Config {
@@ -42,6 +45,7 @@ impl Default for Config {
4245 ignore : Vec :: new ( ) ,
4346 git_default_branch : None ,
4447 git_release_branches : Vec :: new ( ) ,
48+ git_short_tags : None ,
4549 }
4650 }
4751}
@@ -270,7 +274,7 @@ impl Config {
270274 }
271275 }
272276
273- let ( git_default_branch, git_release_branches) = value
277+ let ( git_default_branch, git_release_branches, git_short_tags ) = value
274278 . get ( "git" )
275279 . and_then ( |v| v. as_table ( ) )
276280 . map ( |git_table| {
@@ -294,9 +298,16 @@ impl Config {
294298 } )
295299 . unwrap_or_default ( ) ;
296300
297- ( default_branch, release_branches)
301+ let short_tags = git_table
302+ . get ( "short_tags" )
303+ . and_then ( |v| v. as_str ( ) )
304+ . map ( |s| s. trim ( ) )
305+ . filter ( |s| !s. is_empty ( ) )
306+ . map ( |s| s. to_string ( ) ) ;
307+
308+ ( default_branch, release_branches, short_tags)
298309 } )
299- . unwrap_or ( ( None , Vec :: new ( ) ) ) ;
310+ . unwrap_or ( ( None , Vec :: new ( ) , None ) ) ;
300311
301312 Ok ( Self {
302313 version,
@@ -313,6 +324,7 @@ impl Config {
313324 ignore,
314325 git_default_branch,
315326 git_release_branches,
327+ git_short_tags,
316328 } )
317329 }
318330
@@ -334,6 +346,50 @@ impl Config {
334346 pub fn is_release_branch ( & self , branch : & str ) -> bool {
335347 self . release_branches ( ) . contains ( branch)
336348 }
349+
350+ /// Returns true if the given package should use short tag format (`v{version}`).
351+ pub fn uses_short_tags ( & self , package_name : & str ) -> bool {
352+ self . git_short_tags
353+ . as_ref ( )
354+ . is_some_and ( |name| name == package_name)
355+ }
356+
357+ /// Builds a git tag name for the given package and version.
358+ pub fn build_tag_name ( & self , package_name : & str , version : & str ) -> String {
359+ if self . uses_short_tags ( package_name) {
360+ format ! ( "v{}" , version)
361+ } else {
362+ format ! ( "{}-v{}" , package_name, version)
363+ }
364+ }
365+
366+ /// Parses a tag and returns (package_name, version).
367+ pub fn parse_tag ( & self , tag : & str ) -> Option < ( String , String ) > {
368+ if let Some ( short_pkg) = self
369+ . git_short_tags
370+ . as_ref ( )
371+ . filter ( |_| tag. starts_with ( 'v' ) )
372+ {
373+ let version_str = tag. trim_start_matches ( 'v' ) ;
374+ if Version :: parse ( version_str) . is_ok ( ) {
375+ return Some ( ( short_pkg. clone ( ) , version_str. to_string ( ) ) ) ;
376+ }
377+ }
378+
379+ // Iterate over all "-v" positions to handle prereleases containing "-v" (e.g., "pkg-v1.2.3-v1").
380+ for ( idx, _) in tag. match_indices ( "-v" ) {
381+ let name = & tag[ ..idx] ;
382+ let version = & tag[ idx + 2 ..] ;
383+ if name. is_empty ( ) || version. is_empty ( ) {
384+ continue ;
385+ }
386+ if Version :: parse ( version) . is_ok ( ) {
387+ return Some ( ( name. to_string ( ) , version. to_string ( ) ) ) ;
388+ }
389+ }
390+
391+ None
392+ }
337393}
338394
339395#[ cfg( test) ]
@@ -356,6 +412,7 @@ mod tests {
356412 assert_eq ! ( config. default_branch( ) , "main" ) ;
357413 assert ! ( config. is_release_branch( "main" ) ) ;
358414 assert_eq ! ( config. git_release_branches, Vec :: <String >:: new( ) ) ;
415+ assert ! ( config. git_short_tags. is_none( ) ) ;
359416 }
360417
361418 #[ test]
@@ -660,4 +717,163 @@ mod tests {
660717 vec![ vec![ "pkg-c" . to_string( ) , "pkg-d" . to_string( ) ] ]
661718 ) ;
662719 }
720+
721+ #[ test]
722+ fn reads_short_tags ( ) {
723+ let temp = tempfile:: tempdir ( ) . unwrap ( ) ;
724+ fs:: create_dir_all ( temp. path ( ) . join ( ".sampo" ) ) . unwrap ( ) ;
725+ fs:: write (
726+ temp. path ( ) . join ( ".sampo/config.toml" ) ,
727+ "[git]\n short_tags = \" my-package\" \n " ,
728+ )
729+ . unwrap ( ) ;
730+
731+ let config = Config :: load ( temp. path ( ) ) . unwrap ( ) ;
732+ assert_eq ! ( config. git_short_tags. as_deref( ) , Some ( "my-package" ) ) ;
733+ }
734+
735+ #[ test]
736+ fn defaults_short_tags_to_none ( ) {
737+ let temp = tempfile:: tempdir ( ) . unwrap ( ) ;
738+ let config = Config :: load ( temp. path ( ) ) . unwrap ( ) ;
739+ assert ! ( config. git_short_tags. is_none( ) ) ;
740+ }
741+
742+ #[ test]
743+ fn uses_short_tags_returns_true_for_matching_package ( ) {
744+ let temp = tempfile:: tempdir ( ) . unwrap ( ) ;
745+ fs:: create_dir_all ( temp. path ( ) . join ( ".sampo" ) ) . unwrap ( ) ;
746+ fs:: write (
747+ temp. path ( ) . join ( ".sampo/config.toml" ) ,
748+ "[git]\n short_tags = \" my-package\" \n " ,
749+ )
750+ . unwrap ( ) ;
751+
752+ let config = Config :: load ( temp. path ( ) ) . unwrap ( ) ;
753+ assert ! ( config. uses_short_tags( "my-package" ) ) ;
754+ assert ! ( !config. uses_short_tags( "other-package" ) ) ;
755+ }
756+
757+ #[ test]
758+ fn build_tag_name_uses_short_format_for_configured_package ( ) {
759+ let temp = tempfile:: tempdir ( ) . unwrap ( ) ;
760+ fs:: create_dir_all ( temp. path ( ) . join ( ".sampo" ) ) . unwrap ( ) ;
761+ fs:: write (
762+ temp. path ( ) . join ( ".sampo/config.toml" ) ,
763+ "[git]\n short_tags = \" my-package\" \n " ,
764+ )
765+ . unwrap ( ) ;
766+
767+ let config = Config :: load ( temp. path ( ) ) . unwrap ( ) ;
768+ assert_eq ! ( config. build_tag_name( "my-package" , "1.2.3" ) , "v1.2.3" ) ;
769+ assert_eq ! (
770+ config. build_tag_name( "other-package" , "1.2.3" ) ,
771+ "other-package-v1.2.3"
772+ ) ;
773+ }
774+
775+ #[ test]
776+ fn parse_tag_handles_short_format ( ) {
777+ let temp = tempfile:: tempdir ( ) . unwrap ( ) ;
778+ fs:: create_dir_all ( temp. path ( ) . join ( ".sampo" ) ) . unwrap ( ) ;
779+ fs:: write (
780+ temp. path ( ) . join ( ".sampo/config.toml" ) ,
781+ "[git]\n short_tags = \" my-package\" \n " ,
782+ )
783+ . unwrap ( ) ;
784+
785+ let config = Config :: load ( temp. path ( ) ) . unwrap ( ) ;
786+ assert_eq ! (
787+ config. parse_tag( "v1.2.3" ) ,
788+ Some ( ( "my-package" . to_string( ) , "1.2.3" . to_string( ) ) )
789+ ) ;
790+ assert_eq ! (
791+ config. parse_tag( "v1.2.3-alpha.1" ) ,
792+ Some ( ( "my-package" . to_string( ) , "1.2.3-alpha.1" . to_string( ) ) )
793+ ) ;
794+ // Standard format still works
795+ assert_eq ! (
796+ config. parse_tag( "other-package-v1.2.3" ) ,
797+ Some ( ( "other-package" . to_string( ) , "1.2.3" . to_string( ) ) )
798+ ) ;
799+ }
800+
801+ #[ test]
802+ fn parse_tag_short_format_with_v_in_prerelease ( ) {
803+ let temp = tempfile:: tempdir ( ) . unwrap ( ) ;
804+ fs:: create_dir_all ( temp. path ( ) . join ( ".sampo" ) ) . unwrap ( ) ;
805+ fs:: write (
806+ temp. path ( ) . join ( ".sampo/config.toml" ) ,
807+ "[git]\n short_tags = \" my-package\" \n " ,
808+ )
809+ . unwrap ( ) ;
810+
811+ let config = Config :: load ( temp. path ( ) ) . unwrap ( ) ;
812+
813+ // Prerelease containing -v (the bug case)
814+ assert_eq ! (
815+ config. parse_tag( "v1.2.3-v1" ) ,
816+ Some ( ( "my-package" . to_string( ) , "1.2.3-v1" . to_string( ) ) )
817+ ) ;
818+ assert_eq ! (
819+ config. parse_tag( "v1.0.0-preview1" ) ,
820+ Some ( ( "my-package" . to_string( ) , "1.0.0-preview1" . to_string( ) ) )
821+ ) ;
822+ assert_eq ! (
823+ config. parse_tag( "v2.0.0-v2-beta" ) ,
824+ Some ( ( "my-package" . to_string( ) , "2.0.0-v2-beta" . to_string( ) ) )
825+ ) ;
826+ assert_eq ! (
827+ config. parse_tag( "v1.2.3+build.123" ) ,
828+ Some ( ( "my-package" . to_string( ) , "1.2.3+build.123" . to_string( ) ) )
829+ ) ;
830+ assert_eq ! (
831+ config. parse_tag( "v1.2.3-alpha.1+build.456" ) ,
832+ Some ( (
833+ "my-package" . to_string( ) ,
834+ "1.2.3-alpha.1+build.456" . to_string( )
835+ ) )
836+ ) ;
837+ }
838+
839+ #[ test]
840+ fn parse_tag_rejects_invalid_short_tags ( ) {
841+ let temp = tempfile:: tempdir ( ) . unwrap ( ) ;
842+ fs:: create_dir_all ( temp. path ( ) . join ( ".sampo" ) ) . unwrap ( ) ;
843+ fs:: write (
844+ temp. path ( ) . join ( ".sampo/config.toml" ) ,
845+ "[git]\n short_tags = \" my-package\" \n " ,
846+ )
847+ . unwrap ( ) ;
848+
849+ let config = Config :: load ( temp. path ( ) ) . unwrap ( ) ;
850+
851+ assert_eq ! ( config. parse_tag( "v1.2" ) , None ) ;
852+ assert_eq ! ( config. parse_tag( "vfoo" ) , None ) ;
853+ assert_eq ! ( config. parse_tag( "v01.2.3" ) , None ) ;
854+ assert_eq ! ( config. parse_tag( "v" ) , None ) ;
855+ }
856+
857+ #[ test]
858+ fn parse_tag_without_short_tags_config ( ) {
859+ let temp = tempfile:: tempdir ( ) . unwrap ( ) ;
860+ let config = Config :: load ( temp. path ( ) ) . unwrap ( ) ;
861+
862+ assert_eq ! ( config. parse_tag( "v1.2.3" ) , None ) ;
863+ assert_eq ! (
864+ config. parse_tag( "my-package-v1.2.3" ) ,
865+ Some ( ( "my-package" . to_string( ) , "1.2.3" . to_string( ) ) )
866+ ) ;
867+ assert_eq ! (
868+ config. parse_tag( "my-package-v1.2.3-alpha.1" ) ,
869+ Some ( ( "my-package" . to_string( ) , "1.2.3-alpha.1" . to_string( ) ) )
870+ ) ;
871+ // -v in prerelease requires semver validation to parse correctly
872+ assert_eq ! (
873+ config. parse_tag( "my-package-v1.2.3-v1" ) ,
874+ Some ( ( "my-package" . to_string( ) , "1.2.3-v1" . to_string( ) ) )
875+ ) ;
876+ assert_eq ! ( config. parse_tag( "my-package-vfoo" ) , None ) ;
877+ assert_eq ! ( config. parse_tag( "my-package-v1.2" ) , None ) ;
878+ }
663879}
0 commit comments