@@ -4,9 +4,9 @@ use colored::Colorize;
use dialoguer ::{ Confirm , Input , Select } ;
use crate ::config ::manager ::ConfigManager ;
use crate ::config ::{ GitProfile } ;
use crate ::config ::{ GitProfile , TokenConfig , TokenType , ProfileComparison };
use crate ::config ::profile ::{ GpgConfig , SshConfig } ;
use crate ::git ::find_repo ;
use crate ::git ::{ find_repo , GitConfigHelper , UserConfig } ;
use crate ::utils ::validators ::validate_profile_name ;
/// Manage Git profiles
@@ -75,6 +75,51 @@ enum ProfileSubcommand {
/// New profile name
to : String ,
} ,
/// Manage tokens for a profile
Token {
#[ command(subcommand) ]
token_command : TokenSubcommand ,
} ,
/// Check profile configuration against git
Check {
/// Profile name
name : Option < String > ,
} ,
/// Show usage statistics
Stats {
/// Profile name
name : Option < String > ,
} ,
}
#[ derive(Subcommand) ]
enum TokenSubcommand {
/// Add a token to a profile
Add {
/// Profile name
profile : String ,
/// Service name (e.g., github, gitlab)
service : String ,
} ,
/// Remove a token from a profile
Remove {
/// Profile name
profile : String ,
/// Service name
service : String ,
} ,
/// List tokens in a profile
List {
/// Profile name
profile : String ,
} ,
}
impl ProfileCommand {
@@ -90,6 +135,9 @@ impl ProfileCommand {
Some ( ProfileSubcommand ::Apply { name , global } ) = > self . apply_profile ( name . as_deref ( ) , * global ) . await ,
Some ( ProfileSubcommand ::Switch ) = > self . switch_profile ( ) . await ,
Some ( ProfileSubcommand ::Copy { from , to } ) = > self . copy_profile ( from , to ) . await ,
Some ( ProfileSubcommand ::Token { token_command } ) = > self . handle_token_command ( token_command ) . await ,
Some ( ProfileSubcommand ::Check { name } ) = > self . check_profile ( name . as_deref ( ) ) . await ,
Some ( ProfileSubcommand ::Stats { name } ) = > self . show_stats ( name . as_deref ( ) ) . await ,
None = > self . list_profiles ( ) . await ,
}
}
@@ -148,7 +196,6 @@ impl ProfileCommand {
profile . is_work = is_work ;
profile . organization = organization ;
// SSH configuration
let setup_ssh = Confirm ::new ( )
. with_prompt ( " Configure SSH key? " )
. default ( false )
@@ -158,7 +205,6 @@ impl ProfileCommand {
profile . ssh = Some ( self . setup_ssh_interactive ( ) . await ? ) ;
}
// GPG configuration
let setup_gpg = Confirm ::new ( )
. with_prompt ( " Configure GPG signing? " )
. default ( false )
@@ -168,12 +214,20 @@ impl ProfileCommand {
profile . gpg = Some ( self . setup_gpg_interactive ( ) . await ? ) ;
}
let setup_token = Confirm ::new ( )
. with_prompt ( " Add a Personal Access Token? " )
. default ( false )
. interact ( ) ? ;
if setup_token {
self . setup_token_interactive ( & mut profile ) . await ? ;
}
manager . add_profile ( name . clone ( ) , profile ) ? ;
manager . save ( ) ? ;
println! ( " {} Profile ' {} ' added successfully " , " ✓ " . green ( ) , name . cyan ( ) ) ;
// Offer to set as default
if manager . default_profile ( ) . is_none ( ) {
let set_default = Confirm ::new ( )
. with_prompt ( " Set as default profile? " )
@@ -251,6 +305,13 @@ impl ProfileCommand {
if profile . has_gpg ( ) {
println! ( " {} GPG configured " , " 🔒 " . to_string ( ) . dimmed ( ) ) ;
}
if profile . has_tokens ( ) {
println! ( " {} {} token(s) " , " 🔐 " . to_string ( ) . dimmed ( ) , profile . tokens . len ( ) ) ;
}
if let Some ( ref usage ) = profile . usage . last_used {
println! ( " {} Last used: {} " , " 📊 " . to_string ( ) . dimmed ( ) , usage . dimmed ( ) ) ;
}
println! ( ) ;
}
@@ -298,6 +359,24 @@ impl ProfileCommand {
println! ( " Use agent: {} " , if gpg . use_agent { " yes " } else { " no " } ) ;
}
if ! profile . tokens . is_empty ( ) {
println! ( " \n {} " , " Tokens: " . bold ( ) ) ;
for ( service , token ) in & profile . tokens {
println! ( " {} ( {} ) " , service . cyan ( ) , token . token_type ) ;
if let Some ( ref desc ) = token . description {
println! ( " {} " , desc ) ;
}
}
}
if profile . usage . total_uses > 0 {
println! ( " \n {} " , " Usage Statistics: " . bold ( ) ) ;
println! ( " Total uses: {} " , profile . usage . total_uses ) ;
if let Some ( ref last_used ) = profile . usage . last_used {
println! ( " Last used: {} " , last_used ) ;
}
}
Ok ( ( ) )
}
@@ -330,6 +409,8 @@ impl ProfileCommand {
new_profile . organization = profile . organization ;
new_profile . ssh = profile . ssh ;
new_profile . gpg = profile . gpg ;
new_profile . tokens = profile . tokens ;
new_profile . usage = profile . usage ;
manager . update_profile ( name , new_profile ) ? ;
manager . save ( ) ? ;
@@ -365,18 +446,27 @@ impl ProfileCommand {
}
async fn apply_profile ( & self , name : Option < & str > , global : bool ) -> Result < ( ) > {
let manager = ConfigManager ::new ( ) ? ;
let mut manager = ConfigManager ::new ( ) ? ;
let profile = if let Some ( n ) = name {
manager . get_profile ( n )
. ok_or_else ( | | anyhow ::anyhow! ( " Profile '{}' not found " , n ) ) ?
. clone ( )
let profile_name = if let Some ( n ) = name {
n . to_string ( )
} else {
manager . default_profile ( )
manager . default_profile_name ( )
. ok_or_else ( | | anyhow ::anyhow! ( " No default profile set " ) ) ?
. clone ( )
} ;
let profile = manager . get_profile ( & profile_name )
. ok_or_else ( | | anyhow ::anyhow! ( " Profile '{}' not found " , profile_name ) ) ?
. clone ( ) ;
let repo_path = if global {
None
} else {
let repo = find_repo ( std ::env ::current_dir ( ) ? . as_path ( ) ) ? ;
Some ( repo . path ( ) . to_string_lossy ( ) . to_string ( ) )
} ;
if global {
profile . apply_global ( ) ? ;
println! ( " {} Applied profile ' {} ' globally " , " ✓ " . green ( ) , profile . name . cyan ( ) ) ;
@@ -386,6 +476,9 @@ impl ProfileCommand {
println! ( " {} Applied profile ' {} ' to current repository " , " ✓ " . green ( ) , profile . name . cyan ( ) ) ;
}
manager . record_profile_usage ( & profile_name , repo_path ) ? ;
manager . save ( ) ? ;
Ok ( ( ) )
}
@@ -419,7 +512,6 @@ impl ProfileCommand {
println! ( " {} Switched to profile ' {} ' " , " ✓ " . green ( ) , selected . cyan ( ) ) ;
// Offer to apply to current repo
if find_repo ( " . " ) . is_ok ( ) {
let apply = Confirm ::new ( )
. with_prompt ( " Apply to current repository? " )
@@ -445,6 +537,7 @@ impl ProfileCommand {
let mut new_profile = source . clone ( ) ;
new_profile . name = to . to_string ( ) ;
new_profile . usage = Default ::default ( ) ;
manager . add_profile ( to . to_string ( ) , new_profile ) ? ;
manager . save ( ) ? ;
@@ -454,6 +547,192 @@ impl ProfileCommand {
Ok ( ( ) )
}
async fn handle_token_command ( & self , cmd : & TokenSubcommand ) -> Result < ( ) > {
match cmd {
TokenSubcommand ::Add { profile , service } = > self . add_token ( profile , service ) . await ,
TokenSubcommand ::Remove { profile , service } = > self . remove_token ( profile , service ) . await ,
TokenSubcommand ::List { profile } = > self . list_tokens ( profile ) . await ,
}
}
async fn add_token ( & self , profile_name : & str , service : & str ) -> Result < ( ) > {
let mut manager = ConfigManager ::new ( ) ? ;
if ! manager . has_profile ( profile_name ) {
bail! ( " Profile '{}' not found " , profile_name ) ;
}
println! ( " {} " , format! ( " \n Add token to profile ' {} ' " , profile_name ) . bold ( ) ) ;
println! ( " {} " , " ─ " . repeat ( 40 ) ) ;
let token_value : String = Input ::new ( )
. with_prompt ( & format! ( " Token for {} " , service ) )
. interact_text ( ) ? ;
let token_type_options = vec! [ " Personal " , " OAuth " , " Deploy " , " App " ] ;
let selection = Select ::new ( )
. with_prompt ( " Token type " )
. items ( & token_type_options )
. default ( 0 )
. interact ( ) ? ;
let token_type = match selection {
0 = > TokenType ::Personal ,
1 = > TokenType ::OAuth ,
2 = > TokenType ::Deploy ,
3 = > TokenType ::App ,
_ = > TokenType ::Personal ,
} ;
let description : String = Input ::new ( )
. with_prompt ( " Description (optional) " )
. allow_empty ( true )
. interact_text ( ) ? ;
let mut token = TokenConfig ::new ( token_value , token_type ) ;
if ! description . is_empty ( ) {
token . description = Some ( description ) ;
}
manager . add_token_to_profile ( profile_name , service . to_string ( ) , token ) ? ;
manager . save ( ) ? ;
println! ( " {} Token for ' {} ' added to profile ' {} ' " , " ✓ " . green ( ) , service . cyan ( ) , profile_name ) ;
Ok ( ( ) )
}
async fn remove_token ( & self , profile_name : & str , service : & str ) -> Result < ( ) > {
let mut manager = ConfigManager ::new ( ) ? ;
if ! manager . has_profile ( profile_name ) {
bail! ( " Profile '{}' not found " , profile_name ) ;
}
let confirm = Confirm ::new ( )
. with_prompt ( & format! ( " Remove token ' {} ' from profile ' {} '? " , service , profile_name ) )
. default ( false )
. interact ( ) ? ;
if ! confirm {
println! ( " {} " , " Cancelled. " . yellow ( ) ) ;
return Ok ( ( ) ) ;
}
manager . remove_token_from_profile ( profile_name , service ) ? ;
manager . save ( ) ? ;
println! ( " {} Token ' {} ' removed from profile ' {} ' " , " ✓ " . green ( ) , service , profile_name ) ;
Ok ( ( ) )
}
async fn list_tokens ( & self , profile_name : & str ) -> Result < ( ) > {
let manager = ConfigManager ::new ( ) ? ;
let profile = manager . get_profile ( profile_name )
. ok_or_else ( | | anyhow ::anyhow! ( " Profile '{}' not found " , profile_name ) ) ? ;
if profile . tokens . is_empty ( ) {
println! ( " {} No tokens configured for profile ' {} ' " , " ℹ " . yellow ( ) , profile_name ) ;
return Ok ( ( ) ) ;
}
println! ( " {} " , format! ( " \n Tokens for profile ' {} ': " , profile_name ) . bold ( ) ) ;
println! ( " {} " , " ─ " . repeat ( 40 ) ) ;
for ( service , token ) in & profile . tokens {
println! ( " {} ( {} ) " , service . cyan ( ) . bold ( ) , token . token_type ) ;
if let Some ( ref desc ) = token . description {
println! ( " {} " , desc ) ;
}
if let Some ( ref last_used ) = token . last_used {
println! ( " Last used: {} " , last_used ) ;
}
}
Ok ( ( ) )
}
async fn check_profile ( & self , name : Option < & str > ) -> Result < ( ) > {
let manager = ConfigManager ::new ( ) ? ;
let profile_name = if let Some ( n ) = name {
n . to_string ( )
} else {
manager . default_profile_name ( )
. ok_or_else ( | | anyhow ::anyhow! ( " No default profile set " ) ) ?
. clone ( )
} ;
let repo = find_repo ( std ::env ::current_dir ( ) ? . as_path ( ) ) ? ;
let comparison = manager . check_profile_config ( & profile_name , repo . inner ( ) ) ? ;
println! ( " {} " , format! ( " \n Checking profile ' {} ' against git configuration " , profile_name ) . bold ( ) ) ;
println! ( " {} " , " ─ " . repeat ( 60 ) ) ;
if comparison . matches {
println! ( " {} Profile configuration matches git settings " , " ✓ " . green ( ) . bold ( ) ) ;
} else {
println! ( " {} Profile configuration differs from git settings " , " ✗ " . red ( ) . bold ( ) ) ;
println! ( " \n {} " , " Differences: " . bold ( ) ) ;
for diff in & comparison . differences {
println! ( " \n {} : " , diff . key . cyan ( ) ) ;
println! ( " Profile: {} " , diff . profile_value . green ( ) ) ;
println! ( " Git: {} " , diff . git_value . yellow ( ) ) ;
}
}
Ok ( ( ) )
}
async fn show_stats ( & self , name : Option < & str > ) -> Result < ( ) > {
let manager = ConfigManager ::new ( ) ? ;
if let Some ( n ) = name {
let profile = manager . get_profile ( n )
. ok_or_else ( | | anyhow ::anyhow! ( " Profile '{}' not found " , n ) ) ? ;
self . show_single_profile_stats ( profile ) ;
} else {
let profiles = manager . list_profiles ( ) ;
if profiles . is_empty ( ) {
println! ( " {} " , " No profiles configured. " . yellow ( ) ) ;
return Ok ( ( ) ) ;
}
println! ( " {} " , " \n Profile Usage Statistics: " . bold ( ) ) ;
println! ( " {} " , " ─ " . repeat ( 60 ) ) ;
for profile_name in profiles {
if let Some ( profile ) = manager . get_profile ( profile_name ) {
self . show_single_profile_stats ( profile ) ;
println! ( ) ;
}
}
}
Ok ( ( ) )
}
fn show_single_profile_stats ( & self , profile : & GitProfile ) {
println! ( " {} " , format! ( " \n {} " , profile . name ) . bold ( ) ) ;
println! ( " Total uses: {} " , profile . usage . total_uses ) ;
if let Some ( ref last_used ) = profile . usage . last_used {
println! ( " Last used: {} " , last_used ) ;
}
if ! profile . usage . repo_usage . is_empty ( ) {
println! ( " Repositories: " ) ;
for ( repo , count ) in & profile . usage . repo_usage {
println! ( " {} ( {} uses) " , repo , count ) ;
}
}
}
async fn setup_ssh_interactive ( & self ) -> Result < SshConfig > {
use std ::path ::PathBuf ;
@@ -489,4 +768,19 @@ impl ProfileCommand {
use_agent : true ,
} )
}
async fn setup_token_interactive ( & self , profile : & mut GitProfile ) -> Result < ( ) > {
let service : String = Input ::new ( )
. with_prompt ( " Service name (e.g., github, gitlab) " )
. interact_text ( ) ? ;
let token_value : String = Input ::new ( )
. with_prompt ( " Token value " )
. interact_text ( ) ? ;
let token = TokenConfig ::new ( token_value , TokenType ::Personal ) ;
profile . add_token ( service , token ) ;
Ok ( ( ) )
}
}