@@ -118,6 +118,13 @@ fn get_index_dir() -> PathBuf {
118118 . join ( "search-index" )
119119}
120120
121+ fn get_command_stats_path ( ) -> PathBuf {
122+ dirs:: data_local_dir ( )
123+ . unwrap_or_else ( || PathBuf :: from ( "." ) )
124+ . join ( "lovcode" )
125+ . join ( "command-stats.json" )
126+ }
127+
121128const JIEBA_TOKENIZER_NAME : & str = "jieba" ;
122129
123130fn create_schema ( ) -> Schema {
@@ -889,6 +896,61 @@ async fn build_search_index() -> Result<usize, String> {
889896 let projects_dir = get_claude_dir ( ) . join ( "projects" ) ;
890897 let mut indexed_count = 0 ;
891898
899+ // === Command stats collection ===
900+ let mut command_stats: HashMap < String , HashMap < String , usize > > = HashMap :: new ( ) ;
901+ let command_pattern = regex:: Regex :: new ( r"<command-name>(/[^<]+)</command-name>" )
902+ . map_err ( |e| e. to_string ( ) ) ?;
903+
904+ // Build alias -> canonical name mapping
905+ let mut alias_map: HashMap < String , String > = HashMap :: new ( ) ;
906+ let commands_dir = get_claude_dir ( ) . join ( "commands" ) ;
907+
908+ fn scan_commands_for_aliases ( dir : & std:: path:: Path , alias_map : & mut HashMap < String , String > , base_dir : & std:: path:: Path ) {
909+ if let Ok ( entries) = fs:: read_dir ( dir) {
910+ for entry in entries. filter_map ( |e| e. ok ( ) ) {
911+ let path = entry. path ( ) ;
912+ if path. is_dir ( ) {
913+ scan_commands_for_aliases ( & path, alias_map, base_dir) ;
914+ } else if path. extension ( ) . map_or ( false , |e| e == "md" ) {
915+ let rel_path = path. strip_prefix ( base_dir) . unwrap_or ( & path) ;
916+ let canonical = rel_path
917+ . with_extension ( "" )
918+ . to_string_lossy ( )
919+ . replace ( '/' , ":" )
920+ . replace ( '\\' , ":" ) ;
921+
922+ if let Ok ( content) = fs:: read_to_string ( & path) {
923+ if content. starts_with ( "---" ) {
924+ if let Some ( end) = content[ 3 ..] . find ( "---" ) {
925+ let fm = & content[ 3 ..3 + end] ;
926+ for line in fm. lines ( ) {
927+ if line. starts_with ( "aliases:" ) {
928+ let aliases_str = line. trim_start_matches ( "aliases:" ) . trim ( ) ;
929+ for alias in aliases_str. split ( ',' ) {
930+ let alias = alias. trim ( )
931+ . trim_matches ( '"' )
932+ . trim_matches ( '\'' )
933+ . trim_start_matches ( '/' )
934+ . to_string ( ) ;
935+ if !alias. is_empty ( ) {
936+ alias_map. insert ( alias, canonical. clone ( ) ) ;
937+ }
938+ }
939+ }
940+ }
941+ }
942+ }
943+ }
944+ }
945+ }
946+ }
947+ }
948+
949+ if commands_dir. exists ( ) {
950+ scan_commands_for_aliases ( & commands_dir, & mut alias_map, & commands_dir) ;
951+ }
952+ // === End command stats setup ===
953+
892954 if !projects_dir. exists ( ) {
893955 return Ok ( 0 ) ;
894956 }
@@ -925,7 +987,7 @@ async fn build_search_index() -> Result<usize, String> {
925987 }
926988 }
927989
928- // Second pass: index messages
990+ // Second pass: index messages + collect command stats
929991 for line in file_content. lines ( ) {
930992 if let Ok ( parsed) = serde_json:: from_str :: < RawLine > ( line) {
931993 let line_type = parsed. line_type . as_deref ( ) ;
@@ -952,6 +1014,27 @@ async fn build_search_index() -> Result<usize, String> {
9521014 }
9531015 }
9541016 }
1017+
1018+ // Collect command stats from any line containing <command-name>
1019+ if line. contains ( "<command-name>" ) {
1020+ if let Some ( ts_str) = & parsed. timestamp {
1021+ if let Ok ( ts) = chrono:: DateTime :: parse_from_rfc3339 ( ts_str) {
1022+ let week_key = ts. format ( "%Y-W%V" ) . to_string ( ) ;
1023+ for cap in command_pattern. captures_iter ( line) {
1024+ if let Some ( cmd_match) = cap. get ( 1 ) {
1025+ let raw_name = cmd_match. as_str ( ) . trim_start_matches ( '/' ) . to_string ( ) ;
1026+ let name = alias_map. get ( & raw_name) . cloned ( ) . unwrap_or ( raw_name) ;
1027+ command_stats
1028+ . entry ( name)
1029+ . or_default ( )
1030+ . entry ( week_key. clone ( ) )
1031+ . and_modify ( |c| * c += 1 )
1032+ . or_insert ( 1 ) ;
1033+ }
1034+ }
1035+ }
1036+ }
1037+ }
9551038 }
9561039 }
9571040 }
@@ -960,10 +1043,21 @@ async fn build_search_index() -> Result<usize, String> {
9601043
9611044 index_writer. commit ( ) . map_err ( |e| e. to_string ( ) ) ?;
9621045
963- // Store index in global state
1046+ // Store search index in global state
9641047 let mut guard = SEARCH_INDEX . lock ( ) . map_err ( |e| e. to_string ( ) ) ?;
9651048 * guard = Some ( SearchIndex { index, schema } ) ;
9661049
1050+ // Write command stats to file
1051+ let stats_path = get_command_stats_path ( ) ;
1052+ if let Some ( parent) = stats_path. parent ( ) {
1053+ fs:: create_dir_all ( parent) . ok ( ) ;
1054+ }
1055+ let stats_json = serde_json:: json!( {
1056+ "updated_at" : chrono:: Utc :: now( ) . timestamp( ) ,
1057+ "commands" : command_stats,
1058+ } ) ;
1059+ fs:: write ( & stats_path, serde_json:: to_string_pretty ( & stats_json) . unwrap_or_default ( ) ) . ok ( ) ;
1060+
9671061 Ok ( indexed_count)
9681062 } )
9691063 . await
@@ -3711,109 +3805,40 @@ async fn get_command_stats() -> Result<HashMap<String, usize>, String> {
37113805 Ok ( new_stats)
37123806}
37133807
3714- /// Returns command usage counts grouped by week
3808+ /// Returns command usage counts grouped by week (from pre-built index)
37153809/// Format: { "command_name": { "2024-W01": count, "2024-W02": count, ... } }
37163810#[ tauri:: command]
3717- async fn get_command_weekly_stats ( weeks : Option < usize > ) -> Result < HashMap < String , HashMap < String , usize > > , String > {
3718- let weeks = weeks. unwrap_or ( 12 ) ;
3719-
3720- tauri:: async_runtime:: spawn_blocking ( move || {
3721- let projects_dir = get_claude_dir ( ) . join ( "projects" ) ;
3722- // command_name -> (week_key -> count)
3723- let mut stats: HashMap < String , HashMap < String , usize > > = HashMap :: new ( ) ;
3724-
3725- if !projects_dir. exists ( ) {
3726- return Ok ( stats) ;
3727- }
3728-
3729- let command_pattern = regex:: Regex :: new ( r"<command-name>(/[^<]+)</command-name>" )
3730- . map_err ( |e| e. to_string ( ) ) ?;
3731-
3732- // Calculate cutoff date (N weeks ago)
3733- let now = chrono:: Utc :: now ( ) ;
3734- let cutoff = now - chrono:: Duration :: weeks ( weeks as i64 ) ;
3811+ fn get_command_weekly_stats ( _weeks : Option < usize > ) -> Result < HashMap < String , HashMap < String , usize > > , String > {
3812+ // Read from pre-built index (created by build_search_index)
3813+ let stats_path = get_command_stats_path ( ) ;
37353814
3736- for project_entry in fs:: read_dir ( & projects_dir) . map_err ( |e| e. to_string ( ) ) ? {
3737- let project_entry = project_entry. map_err ( |e| e. to_string ( ) ) ?;
3738- let project_path = project_entry. path ( ) ;
3739-
3740- if !project_path. is_dir ( ) {
3741- continue ;
3742- }
3743-
3744- for session_entry in fs:: read_dir ( & project_path) . map_err ( |e| e. to_string ( ) ) ? {
3745- let session_entry = session_entry. map_err ( |e| e. to_string ( ) ) ?;
3746- let session_path = session_entry. path ( ) ;
3747- let name = session_path
3748- . file_name ( )
3749- . unwrap ( )
3750- . to_string_lossy ( )
3751- . to_string ( ) ;
3752-
3753- if !name. ends_with ( ".jsonl" ) || name. starts_with ( "agent-" ) {
3754- continue ;
3755- }
3756-
3757- if let Ok ( content) = fs:: read_to_string ( & session_path) {
3758- for line in content. lines ( ) {
3759- // Check if line contains a command
3760- if !line. contains ( "<command-name>" ) {
3761- continue ;
3762- }
3763-
3764- // Parse timestamp from the line
3765- if let Ok ( parsed) = serde_json:: from_str :: < serde_json:: Value > ( line) {
3766- let timestamp = parsed. get ( "timestamp" )
3767- . and_then ( |v| v. as_str ( ) )
3768- . and_then ( |s| chrono:: DateTime :: parse_from_rfc3339 ( s) . ok ( ) )
3769- . map ( |dt| dt. with_timezone ( & chrono:: Utc ) ) ;
3770-
3771- if let Some ( ts) = timestamp {
3772- // Skip if before cutoff
3773- if ts < cutoff {
3774- continue ;
3775- }
3815+ if !stats_path. exists ( ) {
3816+ return Ok ( HashMap :: new ( ) ) ;
3817+ }
37763818
3777- // Get week key (ISO week)
3778- let week_key = ts. format ( "%Y-W%V" ) . to_string ( ) ;
3779-
3780- // Extract command names from content
3781- let content_str = parsed. get ( "message" )
3782- . and_then ( |m| m. get ( "content" ) )
3783- . and_then ( |c| {
3784- if c. is_string ( ) {
3785- Some ( c. as_str ( ) . unwrap ( ) . to_string ( ) )
3786- } else if c. is_array ( ) {
3787- // Handle array content (tool results etc)
3788- Some ( serde_json:: to_string ( c) . unwrap_or_default ( ) )
3789- } else {
3790- None
3791- }
3792- } )
3793- . unwrap_or_default ( ) ;
3819+ let content = fs:: read_to_string ( & stats_path) . map_err ( |e| e. to_string ( ) ) ?;
3820+ let parsed: serde_json:: Value = serde_json:: from_str ( & content) . map_err ( |e| e. to_string ( ) ) ?;
37943821
3795- for cap in command_pattern. captures_iter ( & content_str) {
3796- if let Some ( cmd_name) = cap. get ( 1 ) {
3797- let name = cmd_name. as_str ( ) . trim_start_matches ( '/' ) . to_string ( ) ;
3798- stats
3799- . entry ( name)
3800- . or_default ( )
3801- . entry ( week_key. clone ( ) )
3802- . and_modify ( |c| * c += 1 )
3803- . or_insert ( 1 ) ;
3804- }
3805- }
3806- }
3807- }
3808- }
3822+ // Extract commands map
3823+ let commands = parsed
3824+ . get ( "commands" )
3825+ . and_then ( |v| v. as_object ( ) )
3826+ . ok_or ( "Invalid command stats format" ) ?;
3827+
3828+ let mut stats: HashMap < String , HashMap < String , usize > > = HashMap :: new ( ) ;
3829+ for ( cmd_name, week_data) in commands {
3830+ if let Some ( weeks) = week_data. as_object ( ) {
3831+ let mut week_map: HashMap < String , usize > = HashMap :: new ( ) ;
3832+ for ( week_key, count) in weeks {
3833+ if let Some ( n) = count. as_u64 ( ) {
3834+ week_map. insert ( week_key. clone ( ) , n as usize ) ;
38093835 }
38103836 }
3837+ stats. insert ( cmd_name. clone ( ) , week_map) ;
38113838 }
3839+ }
38123840
3813- Ok ( stats)
3814- } )
3815- . await
3816- . map_err ( |e| e. to_string ( ) ) ?
3841+ Ok ( stats)
38173842}
38183843
38193844// ============================================================================
0 commit comments