use anyhow::{bail, Context, Result};
use clap::{Parser, Subcommand};
use std::path::{Path, PathBuf};
use rs_hack::operations::{self, *};
use rs_hack::editor::{self, RustEditor};
use rs_hack::state::{self, *};
use rs_hack::diff::{print_diff, print_summary_diff, DiffStats};
use rs_hack::files::{collect_rust_files_with_exclusions, expand_kind_to_node_types};
#[derive(Parser)]
#[command(name = "rs-hack")]
#[command(about = "Bulk refactor Rust: find/modify struct literals, enum variants, and function calls across your entire codebase")]
#[command(long_about = "AST-aware Rust refactoring tool that finds and modifies ALL usages across your codebase.
WHAT MAKES RS-HACK DIFFERENT:
• Works on struct LITERALS (instantiation sites), not just definitions
• One command updates 50 struct initializations scattered across many files
• AST-aware: no false positives from comments or strings
COMMON USE CASES:
Add a field to a struct + update ALL places it's instantiated:
rs-hack add --name Config --field-name timeout --field-type Duration \\
--field-value \"Duration::from_secs(30)\" --paths src --apply
Find all places a struct is instantiated (not just where it's defined):
rs-hack find --paths src --node-type struct-literal --name Config
Remove a field from definition AND all 47 places it's used:
rs-hack remove --name User --field-name deprecated_field --paths src --apply")]
#[command(after_help = "For detailed help on any command, use: rs-hack <COMMAND> --help
Examples:
rs-hack find --help
rs-hack add --help
rs-hack rename --help")]
#[command(version)]
struct Cli {
#[arg(long, global = true)]
local_state: bool,
#[arg(long, default_value = "default", global = true)]
format: String,
#[arg(long, global = true)]
summary: bool,
#[arg(long, global = true)]
r#where: Option<String>,
#[arg(long, global = true, num_args = 0..)]
exclude: Vec<String>,
#[arg(long, global = true)]
limit: Option<usize>,
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
#[command(hide = true)]
#[command(after_help = "EXAMPLES:
# Add field to struct definition only
rs-hack add-struct-field --struct-name Config --field \"timeout: Duration\" --paths src --apply
# Add field to definition AND all struct literals
rs-hack add-struct-field --struct-name Config --field \"timeout: Duration\" --literal-default \"Duration::from_secs(30)\" --paths src --apply
# Common case: field exists in struct, add to all literals
rs-hack add-struct-field --struct-name Config --field timeout --literal-default \"Duration::from_secs(30)\" --paths src --apply
# Insert field at specific position
rs-hack add-struct-field --struct-name User --field \"created_at: DateTime\" --position first --paths src --apply
rs-hack add-struct-field --struct-name User --field \"updated_at: DateTime\" --position \"after:created_at\" --paths src --apply
BEHAVIOR WITH --literal-default:
Without --literal-default:
Only modifies struct DEFINITION (adds field to struct declaration)
With --literal-default:
1. Tries to add field to struct definition (skips if already exists)
2. ALWAYS adds field with default value to ALL struct literal expressions
This is useful when:
- Migrating existing code to use a new field
- Field already exists in struct, but not all initializations use it
- You want to ensure every struct creation includes the new field")]
AddStructField {
#[arg(short, long, num_args = 1..)]
paths: Vec<PathBuf>,
#[arg(short, long)]
struct_name: String,
#[arg(short, long)]
field: String,
#[arg(short = 'P', long, default_value = "last")]
position: String,
#[arg(long)]
literal_default: Option<String>,
#[arg(short, long)]
output: Option<PathBuf>,
#[arg(long)]
apply: bool,
},
#[command(hide = true)]
#[command(after_help = "⚠️ DEPRECATED: Use 'rs-hack update --name <NAME> --field <FIELD>' instead
MIGRATION:
Old: rs-hack update-struct-field --struct-name User --field \"pub email: String\"
New: rs-hack update --name User --field \"pub email: String\"
EXAMPLES:
# Update struct field type/visibility
rs-hack update-struct-field --struct-name User --field \"pub email: String\" --paths src --apply
# Update field type
rs-hack update-struct-field --struct-name Config --field \"timeout: u64\" --paths src --apply")]
UpdateStructField {
#[arg(short, long, num_args = 1..)]
paths: Vec<PathBuf>,
#[arg(short, long)]
struct_name: String,
#[arg(short, long)]
field: String,
#[arg(short, long)]
output: Option<PathBuf>,
#[arg(long)]
apply: bool,
},
#[command(hide = true)]
#[command(after_help = "⚠️ DEPRECATED: Use 'rs-hack remove --name <NAME> --field-name <FIELD>' instead
MIGRATION:
Old: rs-hack remove-struct-field --struct-name User --field-name email
New: rs-hack remove --name User --field-name email
EXAMPLES:
# Remove field from struct definition AND all struct literals
rs-hack remove-struct-field --struct-name \"Config\" --field-name \"debug_mode\" --paths src --apply
# Dry-run to preview changes (default behavior)
rs-hack remove-struct-field --struct-name \"Config\" --field-name \"debug_mode\" --paths src
# Remove field from enum variant (use Enum::Variant syntax)
rs-hack remove-struct-field --struct-name \"View::Rectangle\" --field-name \"immediate_mode\" --paths src --apply
# Remove field from literals only (keep in struct definition)
rs-hack remove-struct-field --struct-name \"Config\" --field-name \"debug_mode\" --literal-only --paths src --apply
# Works on multiple files in a directory
rs-hack remove-struct-field --struct-name \"User\" --field-name \"deprecated_field\" --paths src --apply
WHAT IT DOES:
This command removes a field in TWO places:
1. From the struct/enum variant DEFINITION (e.g., struct Config { debug_mode: bool })
2. From ALL struct literal expressions (e.g., Config { color: red, debug_mode: false })
Both removals happen automatically - you don't need separate commands.
With --literal-only: Only removes from struct literals, keeps the field in the definition.")]
RemoveStructField {
#[arg(short, long, num_args = 1..)]
paths: Vec<PathBuf>,
#[arg(short, long)]
struct_name: String,
#[arg(short = 'n', long)]
field_name: String,
#[arg(long)]
literal_only: bool,
#[arg(short, long)]
output: Option<PathBuf>,
#[arg(long)]
apply: bool,
},
#[deprecated]
#[command(hide = true)]
AddStructLiteralField {
#[arg(short, long, num_args = 1..)]
paths: Vec<PathBuf>,
#[arg(short, long)]
struct_name: String,
#[arg(short, long)]
field: String,
#[arg(short = 'P', long, default_value = "last")]
position: String,
#[arg(long)]
apply: bool,
},
#[command(hide = true)]
AddEnumVariant {
#[arg(short, long, num_args = 1..)]
paths: Vec<PathBuf>,
#[arg(short, long)]
enum_name: String,
#[arg(short, long)]
variant: String,
#[arg(short = 'P', long, default_value = "last")]
position: String,
#[arg(short, long)]
output: Option<PathBuf>,
#[arg(long)]
apply: bool,
},
#[command(hide = true)]
#[command(after_help = "⚠️ DEPRECATED: Use 'rs-hack update --name <NAME> --variant <VARIANT>' instead
MIGRATION:
Old: rs-hack update-enum-variant --enum-name Status --variant \"Draft { created_at: u64 }\"
New: rs-hack update --name Status --variant \"Draft { created_at: u64 }\"
EXAMPLES:
# Update enum variant
rs-hack update-enum-variant --enum-name Status --variant \"Draft { created_at: u64 }\" --paths src --apply
# Add field to enum variant
rs-hack update-enum-variant --enum-name Status --variant \"Active { user_id: u32 }\" --paths src --apply")]
UpdateEnumVariant {
#[arg(short, long, num_args = 1..)]
paths: Vec<PathBuf>,
#[arg(short, long)]
enum_name: String,
#[arg(short, long)]
variant: String,
#[arg(short, long)]
output: Option<PathBuf>,
#[arg(long)]
apply: bool,
},
#[command(hide = true)]
#[command(after_help = "⚠️ DEPRECATED: Use 'rs-hack remove --name <NAME> --variant <VARIANT>' instead
MIGRATION:
Old: rs-hack remove-enum-variant --enum-name Status --variant-name Draft
New: rs-hack remove --name Status --variant Draft")]
RemoveEnumVariant {
#[arg(short, long, num_args = 1..)]
paths: Vec<PathBuf>,
#[arg(short, long)]
enum_name: String,
#[arg(short = 'n', long)]
variant_name: String,
#[arg(short, long)]
output: Option<PathBuf>,
#[arg(long)]
apply: bool,
},
#[command(hide = true)]
#[command(after_help = "⚠️ DEPRECATED: Use 'rs-hack rename --name <NAME> --to <NEW_NAME>' instead
MIGRATION:
Old: rs-hack rename-enum-variant --enum-name Status --old-variant Draft --new-variant Pending
New: rs-hack rename --name Status::Draft --to Pending
EXAMPLES:
# Rename enum variant
rs-hack rename-enum-variant --enum-name Status --old-variant Draft --new-variant Pending --paths src --apply")]
RenameEnumVariant {
#[arg(short, long, num_args = 1..)]
paths: Vec<PathBuf>,
#[arg(short, long)]
enum_name: String,
#[arg(short = 'o', long)]
old_variant: String,
#[arg(short = 'n', long)]
new_variant: String,
#[arg(long)]
enum_path: Option<String>,
#[arg(long, default_value = "surgical")]
edit_mode: String,
#[arg(long)]
validate: bool,
#[arg(long)]
apply: bool,
},
#[command(hide = true)]
#[command(after_help = "⚠️ DEPRECATED: Use 'rs-hack rename --name <NAME> --to <NEW_NAME>' instead
MIGRATION:
Old: rs-hack rename-function --old-name process_v2 --new-name process
New: rs-hack rename --name process_v2 --to process
EXAMPLES:
# Rename function
rs-hack rename-function --old-name process_v2 --new-name process --paths src --apply")]
RenameFunction {
#[arg(short, long, num_args = 1..)]
paths: Vec<PathBuf>,
#[arg(short = 'o', long)]
old_name: String,
#[arg(short = 'n', long)]
new_name: String,
#[arg(long)]
function_path: Option<String>,
#[arg(long, default_value = "surgical")]
edit_mode: String,
#[arg(long)]
validate: bool,
#[arg(long)]
apply: bool,
},
#[command(display_order = 3)]
#[command(after_help = "EXAMPLES:
# Rename function
rs-hack rename --name process_v2 --to process --paths src --apply
# Rename enum variant (with :: syntax)
rs-hack rename --name Status::Draft --to Pending --paths src --apply
# Rename enum variant with qualified path for disambiguation
rs-hack rename --name Status::Draft --to Pending --enum-path \"types::Status\" --paths src --apply
# Validation mode (check for remaining references)
rs-hack rename --name Status::Draft --to Pending --validate --paths src
# Use reformat mode instead of surgical (preserves formatting less precisely)
rs-hack rename --name process_v2 --to process --edit-mode reformat --paths src --apply
AUTO-DETECTION:
The command auto-detects whether to rename a function or enum variant:
- If --name contains :: (e.g., Status::Draft), it's an enum variant rename
- Otherwise, it searches the codebase to determine if it's a function or enum variant
- If both exist with the same name, you'll be asked to disambiguate with :: syntax
ENUM VARIANT SYNTAX:
Use EnumName::VariantName to specify an enum variant:
rs-hack rename --name Status::Draft --to Pending --paths src --apply
This works for both the target name (--name) specification.
QUALIFIED PATHS:
Use --enum-path or --function-path to provide fully-qualified paths for disambiguation:
--enum-path \"crate::types::Status\"
--function-path \"crate::utils::process_v2\"
EDIT MODES:
- surgical (default): Preserves formatting precisely, makes minimal changes
- reformat: Uses prettyplease to reformat modified code
VALIDATION:
Use --validate to check for remaining references without making changes:
rs-hack rename --name old_name --to new_name --validate --paths src
NOTES:
- Use --name <NAME> to specify the target to rename
- Use --to <NEW_NAME> to specify the new name
- For enum variants, use :: syntax (EnumName::VariantName)
- The command performs renames across definitions and all usages")]
Rename {
#[arg(short, long, num_args = 1..)]
paths: Vec<PathBuf>,
#[arg(short, long)]
name: String,
#[arg(short = 't', long)]
to: String,
#[arg(long)]
enum_path: Option<String>,
#[arg(long)]
function_path: Option<String>,
#[arg(short = 'k', long, conflicts_with = "node_type")]
kind: Option<String>,
#[arg(short = 'T', long, conflicts_with = "kind")]
node_type: Option<String>,
#[arg(long, default_value = "surgical")]
edit_mode: String,
#[arg(long)]
validate: bool,
#[arg(long)]
apply: bool,
},
#[command(hide = true)]
#[command(after_help = "⚠️ DEPRECATED: Use 'rs-hack add --match-arm <PATTERN> --body <BODY>' instead
MIGRATION:
Old: rs-hack add-match-arm --pattern \"Status::Archived\" --body \"todo!()\"
New: rs-hack add --match-arm \"Status::Archived\" --body \"todo!()\" --paths src --apply
Old: rs-hack add-match-arm --auto-detect --enum-name Status --body \"todo!()\"
New: rs-hack add --auto-detect --enum-name Status --body \"todo!()\" --paths src --apply")]
AddMatchArm {
#[arg(short, long, num_args = 1..)]
paths: Vec<PathBuf>,
#[arg(short = 'P', long)]
pattern: Option<String>,
#[arg(short, long)]
body: String,
#[arg(short, long)]
function: Option<String>,
#[arg(long)]
auto_detect: bool,
#[arg(short, long)]
enum_name: Option<String>,
#[arg(long)]
apply: bool,
},
#[command(hide = true)]
#[command(after_help = "⚠️ DEPRECATED: Use 'rs-hack update --match-arm <PATTERN> --body <BODY>' instead
MIGRATION:
Old: rs-hack update-match-arm --pattern \"Status::Draft\" --body \"\\\"pending\\\".to_string()\"
New: rs-hack update --match-arm \"Status::Draft\" --body \"\\\"pending\\\".to_string()\" --paths src --apply")]
UpdateMatchArm {
#[arg(short, long, num_args = 1..)]
paths: Vec<PathBuf>,
#[arg(short = 'P', long)]
pattern: String,
#[arg(short, long)]
body: String,
#[arg(short, long)]
function: Option<String>,
#[arg(long)]
apply: bool,
},
#[command(hide = true)]
#[command(after_help = "⚠️ DEPRECATED: Use 'rs-hack remove --match-arm <PATTERN>' instead
MIGRATION:
Old: rs-hack remove-match-arm --pattern \"Status::Deleted\"
New: rs-hack remove --match-arm \"Status::Deleted\" --paths src --apply")]
RemoveMatchArm {
#[arg(short, long, num_args = 1..)]
paths: Vec<PathBuf>,
#[arg(short = 'P', long)]
pattern: String,
#[arg(short, long)]
function: Option<String>,
#[arg(long)]
apply: bool,
},
Batch {
#[arg(short, long)]
spec: PathBuf,
#[arg(long)]
apply: bool,
},
Neighbors {
#[arg(short, long)]
path: PathBuf,
},
Impls {
#[arg(short, long, num_args = 1..)]
paths: Vec<PathBuf>,
#[arg(long = "trait")]
r#trait: String,
},
MatchAudit {
#[arg(short, long, num_args = 1..)]
paths: Vec<PathBuf>,
#[arg(long = "enum")]
r#enum: String,
},
DocCoverage {
#[arg(short, long, num_args = 1..)]
paths: Vec<PathBuf>,
#[arg(long)]
fields: bool,
},
Summary {
#[arg(short, long)]
path: PathBuf,
},
#[command(display_order = 1)]
#[command(after_help = "EXAMPLES:
# NEW: Search all node types when you don't know what you're looking for
rs-hack find --paths src --name Rectangle
# Find all struct literal expressions for a specific struct
rs-hack find --paths src --node-type struct-literal --name Shadow
# Find all calls to unwrap() method
rs-hack find --paths src --node-type method-call --name unwrap
# Find all eprintln! debug statements
rs-hack find --paths src --node-type macro-call --name eprintln
# Find enum variant usages
rs-hack find --paths src --node-type enum-usage --name \"Operator::Error\"
# NEW: Enum variant filtering - all four patterns work:
# 1. Find any enum with Rectangle variant
rs-hack find --paths src --node-type enum --variant Rectangle
# 2. Find View enum, show only Rectangle variant
rs-hack find --paths src --node-type enum --name View --variant Rectangle
# 3. Same using :: syntax
rs-hack find --paths src --node-type enum --name View::Rectangle
# 4. Wildcard: any enum with Rectangle variant
rs-hack find --paths src --node-type enum --name \"*::Rectangle\"
# Find nodes containing specific text
rs-hack find --paths src --node-type struct-literal --content-filter \"[SHADOW RENDER]\"
# Get JSON output (useful for scripting)
rs-hack find --paths src --node-type function --name process --format json
# Get just file locations (grep-like output)
rs-hack find --paths src --node-type method-call --name unwrap --format locations
# Search multiple files with glob patterns
rs-hack find --paths \"src/**/*.rs\" --node-type struct --name Config
# Include documentation comments in output
rs-hack find --paths src --node-type function --name main --include-comments true
OUTPUT FORMATS:
snippets Show full code snippets with file locations (default, most readable)
locations Show only file:line:column (grep-style, good for scripting)
json JSON output with all metadata (for programmatic use)")]
Find {
#[arg(short, long, num_args = 1..)]
paths: Vec<PathBuf>,
#[arg(short = 'k', long, conflicts_with = "node_type")]
kind: Option<String>,
#[arg(short = 't', long, conflicts_with = "kind")]
node_type: Option<String>,
#[arg(short, long)]
name: Option<String>,
#[arg(short = 'v', long)]
variant: Option<String>,
#[arg(short = 'c', long)]
content_filter: Option<String>,
#[arg(short = 'F', long)]
field_name: Option<String>,
#[arg(long, default_value = "true", action = clap::ArgAction::Set)]
include_comments: bool,
#[arg(short = 'f', long, default_value = "snippets")]
format: String,
#[arg(long)]
context: Option<usize>,
},
#[command(hide = true)]
AddDerive {
#[arg(short, long, num_args = 1..)]
paths: Vec<PathBuf>,
#[arg(short = 't', long)]
target_type: String,
#[arg(short, long)]
name: String,
#[arg(short, long)]
derives: String,
#[arg(long)]
apply: bool,
},
#[command(hide = true)]
AddImplMethod {
#[arg(short, long, num_args = 1..)]
paths: Vec<PathBuf>,
#[arg(short = 't', long)]
target: String,
#[arg(short, long)]
method: String,
#[arg(short = 'P', long, default_value = "last")]
position: String,
#[arg(long)]
apply: bool,
},
#[command(hide = true)]
AddUse {
#[arg(short, long, num_args = 1..)]
paths: Vec<PathBuf>,
#[arg(short = 'u', long)]
use_path: String,
#[arg(short = 'P', long, default_value = "last")]
position: String,
#[arg(long)]
apply: bool,
},
#[command(display_order = 2)]
#[command(after_help = "COMMON USE CASE - Add field to ALL struct literal instantiations:
# Add a field to every place MyStruct { ... } appears in your codebase
rs-hack add --name MyStruct --field-name new_field --field-value \"None\" --paths src --apply
# For enum variants like View::Container, use the full path
rs-hack add --name \"View::Container\" --field-name style --field-value \"None\" --paths src --apply
EXAMPLES:
# Add field to struct definition only (no --field-value)
rs-hack add --name User --field-name email --field-type String --paths src --apply
# Add field to definition AND all literals (with --field-value)
rs-hack add --name Config --field-name timeout --field-type Duration \\
--field-value \"Duration::from_secs(30)\" --paths src --apply
# Add field to literals only, not definition (--literal-only)
rs-hack add --name Config --field-name timeout --field-value \"Duration::from_secs(30)\" \\
--literal-only --paths src --apply
# Add enum variant
rs-hack add --name Status --variant \"Archived\" --paths src --apply
# Add impl method
rs-hack add --name User --method \"pub fn new() -> Self { Self { id: 0 } }\" --paths src --apply
# Add derive macros
rs-hack add --name User --derive \"Clone,Debug\" --paths src --apply
# Add use statement (no --name required)
rs-hack add --use \"serde::Serialize\" --paths src --apply
# Add ..Default::default() to struct literals that need it
rs-hack add --name Config --default-rest --paths src --apply
# Add custom base expression (e.g., ..other_instance)
rs-hack add --name Config --base \"existing_config\" --paths src --apply
AUTO-DETECTION:
The command auto-detects what to add based on which flags you provide:
- --field-name + --field-type: Add to struct definition
- --field-name + --field-value: Add to all struct literals
- --field-name + --field-type + --field-value: Add to both
- --variant: Add enum variant
- --method: Add impl method
- --derive: Add derive macro
- --use: Add use statement
- --default-rest: Add ..Default::default() to struct literals
- --base: Add custom base expression (..expr) to struct literals
ENUM VARIANT SYNTAX:
For enum struct variants, use \"EnumName::VariantName\" syntax:
rs-hack add --name \"View::Container\" --field-name shadow --field-value \"None\" --paths src
MATCH ARMS (two modes):
Mode 1 - Auto-detect ALL missing arms (enum must be in scanned files):
rs-hack add --auto-detect --enum-name Status --body \"todo!()\" --paths src --apply
Mode 2 - Add ONE specific arm (works with external enums):
rs-hack add --match-arm \"Status::Archived\" --body \"\\\"archived\\\".to_string()\" --paths src --apply
Note: --auto-detect ignores --match-arm. Use one mode or the other.
NOTES:
- Use --name <NAME> to specify the target struct/enum/impl (not needed for --use)
- Position can be controlled with --position (first, last, after:name, before:name)")]
Add {
#[arg(short, long, num_args = 1..)]
paths: Vec<PathBuf>,
#[arg(short, long)]
name: Option<String>,
#[arg(short, long, conflicts_with_all = ["field_name", "field_type", "field_value"])]
field: Option<String>,
#[arg(long)]
field_name: Option<String>,
#[arg(long, requires = "field_name")]
field_type: Option<String>,
#[arg(long, requires = "field_name")]
field_value: Option<String>,
#[arg(short = 'v', long)]
variant: Option<String>,
#[arg(short, long)]
method: Option<String>,
#[arg(short = 'd', long)]
derive: Option<String>,
#[arg(short = 'u', long)]
r#use: Option<String>,
#[arg(long)]
match_arm: Option<String>,
#[arg(long)]
body: Option<String>,
#[arg(long)]
function: Option<String>,
#[arg(long)]
auto_detect: bool,
#[arg(long)]
enum_name: Option<String>,
#[arg(long)]
doc_comment: Option<String>,
#[arg(short = 'k', long, conflicts_with = "node_type")]
kind: Option<String>,
#[arg(short = 't', long, conflicts_with = "kind")]
node_type: Option<String>,
#[arg(long)]
literal_default: Option<String>,
#[arg(long)]
literal_only: bool,
#[arg(long)]
default_rest: bool,
#[arg(long, conflicts_with = "default_rest")]
base: Option<String>,
#[arg(long)]
call: Option<String>,
#[arg(long)]
arg: Option<String>,
#[arg(long, default_value = "last")]
arg_position: String,
#[arg(long)]
call_type: Option<String>,
#[arg(long)]
content_filter: Option<String>,
#[arg(short = 'P', long, default_value = "last")]
position: String,
#[arg(long)]
apply: bool,
},
#[command(display_order = 4)]
#[command(after_help = "EXAMPLES:
# Remove struct field (from definition AND all literals)
rs-hack remove --name User --field-name email --paths src --apply
# Remove enum variant field (use EnumName::VariantName syntax)
rs-hack remove --name View::Rectangle --field-name color --paths src --apply
# Remove field from literals only (keep in definition)
rs-hack remove --name Config --field-name debug_mode --literal-only --paths src --apply
# Remove enum variant
rs-hack remove --name Status --variant Draft --paths src --apply
# Remove derive macro
rs-hack remove --name User --derive Clone --paths src --apply
# Remove impl method
rs-hack remove --name User --method get_email --paths src --apply
AUTO-DETECTION:
The command auto-detects what to remove based on which flag you provide:
- --field-name: Remove struct field (or enum variant field with :: syntax)
- --variant: Remove enum variant
- --method: Remove impl method
- --derive: Remove derive macro
If the target (--name) is not found, the command will search the codebase
and show hints about what exists and how to fix the command.
ENUM VARIANT FIELDS:
To remove a field from an enum variant, use the EnumName::VariantName syntax:
rs-hack remove --name View::Rectangle --field-name color --paths src --apply
This works on both the variant definition AND all enum variant literals.
Use --literal-only to only remove from literals.
NOTES:
- All --name values specify the target struct/enum/impl
- For enum variant fields, --name uses :: syntax (EnumName::VariantName)
- Removing struct fields affects both definitions and literals (unless --literal-only)")]
Remove {
#[arg(short, long, num_args = 1..)]
paths: Vec<PathBuf>,
#[arg(short, long)]
name: Option<String>,
#[arg(short = 'F', long)]
field_name: Option<String>,
#[arg(short = 'v', long)]
variant: Option<String>,
#[arg(short, long)]
method: Option<String>,
#[arg(short = 'd', long)]
derive: Option<String>,
#[arg(long)]
match_arm: Option<String>,
#[arg(long)]
function: Option<String>,
#[arg(long)]
doc_comment: bool,
#[arg(short = 'k', long, conflicts_with = "node_type")]
kind: Option<String>,
#[arg(short = 't', long, conflicts_with = "kind")]
node_type: Option<String>,
#[arg(long)]
literal_only: bool,
#[arg(long)]
call: Option<String>,
#[arg(long)]
arg_index: Option<usize>,
#[arg(long)]
call_type: Option<String>,
#[arg(long)]
content_filter: Option<String>,
#[arg(long)]
apply: bool,
},
#[command(display_order = 5)]
#[command(after_help = "EXAMPLES:
# Update struct field type/visibility
rs-hack update --name User --field \"pub email: String\" --paths src --apply
# Update enum variant
rs-hack update --name Status --variant \"Draft { created_at: u64 }\" --paths src --apply
# Update struct field (change type)
rs-hack update --name Config --field \"timeout: u64\" --paths src --apply
# Update enum variant (add field)
rs-hack update --name Status --variant \"Active { user_id: u32 }\" --paths src --apply
AUTO-DETECTION:
The command auto-detects what to update based on which flag you provide:
- --field: Update struct field (changes type/visibility)
- --variant: Update enum variant (changes fields/type)
If the target (--name) is not found, the command will search the codebase
and show hints about what exists and how to fix the command.
WHAT IT DOES:
- For struct fields: Updates the field definition (type, visibility, etc.)
- For enum variants: Updates the variant definition (changes structure)
NOTES:
- Use --name <NAME> to specify the target struct/enum
- For --field, provide the new field definition (e.g., \"pub email: String\")
- For --variant, provide the new variant definition (e.g., \"Draft { created_at: u64 }\")
- The field/variant name is parsed from the definition you provide")]
Update {
#[arg(short, long, num_args = 1..)]
paths: Vec<PathBuf>,
#[arg(short, long)]
name: Option<String>,
#[arg(short, long, conflicts_with_all = ["field_name", "field_type"])]
field: Option<String>,
#[arg(long)]
field_name: Option<String>,
#[arg(long, requires = "field_name")]
field_type: Option<String>,
#[arg(short = 'v', long)]
variant: Option<String>,
#[arg(long)]
match_arm: Option<String>,
#[arg(long)]
body: Option<String>,
#[arg(long)]
function: Option<String>,
#[arg(long)]
doc_comment: Option<String>,
#[arg(short = 'k', long, conflicts_with = "node_type")]
kind: Option<String>,
#[arg(short = 't', long, conflicts_with = "kind")]
node_type: Option<String>,
#[arg(long)]
call: Option<String>,
#[arg(long)]
arg_index: Option<usize>,
#[arg(long)]
arg: Option<String>,
#[arg(long)]
call_type: Option<String>,
#[arg(long)]
content_filter: Option<String>,
#[arg(long)]
apply: bool,
},
History {
#[arg(short, long, default_value = "10")]
limit: usize,
},
Revert {
run_id: String,
#[arg(long)]
force: bool,
},
Clean {
#[arg(long, default_value = "30")]
keep_days: u32,
},
#[command(after_help = "WHAT IS TRANSFORM?
Transform is for bulk code cleanup and refactoring of EXPRESSIONS (how code is used).
Unlike add/remove/update which modify DEFINITIONS (structs, enums, functions),
transform finds and modifies expressions like method calls, macros, and literals.
Think: 'find + sed' but AST-aware.
WHEN TO USE TRANSFORM:
- Comment out all .unwrap() calls for safety audit
- Remove all debug println!/eprintln! statements
- Replace deprecated function calls across codebase
- Clean up todo!() placeholders
- Remove test-only code markers
WHEN TO USE OTHER COMMANDS:
- add/remove/update: Modify struct/enum definitions (add fields, change types)
- rename: Change names everywhere (rename functions, variants)
- transform: Bulk modify how code is called/used (this command!)
ACTIONS:
comment Wrap code in /* ... */ (preserves it for reference)
remove Delete code entirely
replace Swap with new code (use --with to specify replacement)
SUPPORTED NODE TYPES:
Expression-level nodes (8 types):
struct-literal Struct initialization (e.g., Config { field: value })
match-arm Match arm pattern and body
enum-usage Enum variant usage (e.g., Status::Active)
function-call Function call (e.g., process_data())
method-call Method call (e.g., value.unwrap())
macro-call Macro invocation (e.g., println!(), vec![])
identifier Variable or type identifier
type-ref Type reference in annotations
Definition-level nodes (9 types):
struct Struct definition
enum Enum definition
function Function definition
impl-method Method in impl block
trait Trait definition
const Const item
static Static item
type-alias Type alias
mod Module definition
EXAMPLES:
# Comment out all unwrap() calls
rs-hack transform --paths src --node-type method-call --name unwrap --action comment --apply
# Remove all eprintln! debug statements
rs-hack transform --paths src --node-type macro-call --name eprintln --action remove --apply
# Replace a specific function call
rs-hack transform --paths src --node-type function-call --name old_func --action replace --with new_func --apply
# Remove all struct literals containing a specific value
rs-hack transform --paths src --node-type struct-literal --content-filter \"[SHADOW RENDER]\" --action remove --apply
# Comment out all TODO match arms
rs-hack transform --paths src --node-type match-arm --content-filter \"todo!()\" --action comment --apply
# Preview changes before applying (default dry-run)
rs-hack transform --paths src --node-type method-call --name unwrap --action comment")]
Transform {
#[arg(short, long, num_args = 1..)]
paths: Vec<PathBuf>,
#[arg(short = 't', long)]
node_type: String,
#[arg(short, long)]
name: Option<String>,
#[arg(short = 'c', long)]
content_filter: Option<String>,
#[arg(short, long)]
action: String,
#[arg(short = 'w', long)]
with: Option<String>,
#[arg(long)]
apply: bool,
},
#[command(hide = true)]
#[command(after_help = "⚠️ DEPRECATED: Use 'rs-hack add --name <NAME> --node-type <TYPE> --doc-comment <TEXT>' instead
MIGRATION:
Old: rs-hack add-doc-comment --target-type struct --name User --doc-comment \"User model\"
New: rs-hack add --name User --node-type struct --doc-comment \"User model\" --paths src --apply")]
AddDocComment {
#[arg(short, long, num_args = 1..)]
paths: Vec<PathBuf>,
#[arg(short = 't', long)]
target_type: String,
#[arg(short, long)]
name: String,
#[arg(short = 'd', long)]
doc_comment: String,
#[arg(long, default_value = "line")]
style: String,
#[arg(long)]
apply: bool,
},
#[command(hide = true)]
#[command(after_help = "⚠️ DEPRECATED: Use 'rs-hack update --name <NAME> --node-type <TYPE> --doc-comment <TEXT>' instead
MIGRATION:
Old: rs-hack update-doc-comment --target-type struct --name User --doc-comment \"Updated user model\"
New: rs-hack update --name User --node-type struct --doc-comment \"Updated user model\" --paths src --apply")]
UpdateDocComment {
#[arg(short, long, num_args = 1..)]
paths: Vec<PathBuf>,
#[arg(short = 't', long)]
target_type: String,
#[arg(short, long)]
name: String,
#[arg(short = 'd', long)]
doc_comment: String,
#[arg(long)]
apply: bool,
},
#[command(hide = true)]
#[command(after_help = "⚠️ DEPRECATED: Use 'rs-hack remove --name <NAME> --node-type <TYPE> --doc-comment' instead
MIGRATION:
Old: rs-hack remove-doc-comment --target-type struct --name User
New: rs-hack remove --name User --node-type struct --doc-comment --paths src --apply")]
RemoveDocComment {
#[arg(short, long, num_args = 1..)]
paths: Vec<PathBuf>,
#[arg(short = 't', long)]
target_type: String,
#[arg(short, long)]
name: String,
#[arg(long)]
apply: bool,
},
#[command(hide = true)]
#[command(after_help = "⚠️ DEPRECATED: Use 'rs-hack find --field-name <FIELD>' instead
MIGRATION:
Old: rs-hack find-field --field-name color
New: rs-hack find --field-name color --paths src
EXAMPLES:
# Find all occurrences of a field
rs-hack find-field --paths src --field-name immediate_mode
# Show summary only (don't list all literal occurrences)
rs-hack find-field --paths src --field-name debug_mode --summary
WHAT IT DOES:
This command searches for a field in three places:
1. Struct definitions (where the field is declared)
2. Enum variant definitions (for enum variants with fields)
3. Struct literal expressions (where the field is initialized)
It provides suggested commands for removing the field from each location.")]
FindField {
#[arg(short, long, num_args = 1..)]
paths: Vec<PathBuf>,
#[arg(short = 'n', long)]
field_name: String,
#[arg(long)]
summary: bool,
},
}
fn validate_enum_variant_rename(
files: &[PathBuf],
enum_name: &str,
old_variant: &str,
enum_path: Option<&str>,
) -> Result<()> {
use syn::{visit::Visit, File};
struct VariantFinder<'a> {
enum_name: &'a str,
variant_name: &'a str,
enum_path: Option<&'a str>,
references: Vec<(String, usize, usize, String)>, }
impl<'a, 'ast> Visit<'ast> for VariantFinder<'a> {
fn visit_path(&mut self, path: &'ast syn::Path) {
let path_str = quote::quote!(#path).to_string();
if path_str.contains(self.variant_name) {
let segments: Vec<_> = path.segments.iter().collect();
let len = segments.len();
if len >= 2 {
let enum_seg = &segments[len - 2];
let variant_seg = &segments[len - 1];
if enum_seg.ident == self.enum_name && variant_seg.ident == self.variant_name {
syn::visit::visit_path(self, path);
return;
}
} else if len == 1 && segments[0].ident == self.variant_name {
syn::visit::visit_path(self, path);
return;
}
}
syn::visit::visit_path(self, path);
}
}
let mut finder = VariantFinder {
enum_name,
variant_name: old_variant,
enum_path,
references: Vec::new(),
};
for file_path in files {
let content = std::fs::read_to_string(file_path)
.with_context(|| format!("Failed to read {}", file_path.display()))?;
let syntax_tree: File = syn::parse_str(&content)
.with_context(|| format!("Failed to parse {}", file_path.display()))?;
for (line_num, line) in content.lines().enumerate() {
if line.contains(old_variant) {
if line.contains(&format!("{}::{}", enum_name, old_variant)) ||
line.contains(&format!("::{}", old_variant)) {
finder.references.push((
file_path.display().to_string(),
line_num + 1,
0,
line.trim().to_string(),
));
}
}
}
}
if finder.references.is_empty() {
println!("✓ No references to '{}::{}' found.", enum_name, old_variant);
println!(" All occurrences have been renamed or there were none to begin with.");
} else {
println!("❌ Found {} remaining references to '{}::{}':",
finder.references.len(), enum_name, old_variant);
println!();
for (file, line, _col, code) in &finder.references {
println!(" - {}:{}", file, line);
println!(" {}", code);
}
println!();
println!("💡 Suggestions:");
if enum_path.is_none() {
println!(" - Try using --enum-path to enable better matching of fully qualified paths");
}
println!(" - Run without --validate to rename these references");
println!(" - Check if these are false positives (comments, strings, etc.)");
}
Ok(())
}
fn validate_function_rename(
files: &[PathBuf],
old_name: &str,
function_path: Option<&str>,
) -> Result<()> {
let mut references = Vec::new();
for file_path in files {
let content = std::fs::read_to_string(file_path)
.with_context(|| format!("Failed to read {}", file_path.display()))?;
for (line_num, line) in content.lines().enumerate() {
if line.contains(old_name) {
let patterns = [
format!("fn {}(", old_name),
format!("fn {}<", old_name),
format!("{}(", old_name),
format!("{}::", old_name),
format!("::{}", old_name),
];
if patterns.iter().any(|p| line.contains(p)) {
references.push((
file_path.display().to_string(),
line_num + 1,
line.trim().to_string(),
));
}
}
}
}
if references.is_empty() {
println!("✓ No references to '{}' found.", old_name);
println!(" All occurrences have been renamed or there were none to begin with.");
} else {
println!("❌ Found {} remaining references to '{}':", references.len(), old_name);
println!();
for (file, line, code) in &references {
println!(" - {}:{}", file, line);
println!(" {}", code);
}
println!();
println!("💡 Suggestions:");
if function_path.is_none() {
println!(" - Try using --function-path to enable better matching of fully qualified paths");
}
println!(" - Run without --validate to rename these references");
println!(" - Check if these are false positives (comments, strings, etc.)");
}
Ok(())
}
fn target_exists(files: &[PathBuf], name: &str, node_type: Option<&str>) -> Result<bool> {
for file in files {
let content = std::fs::read_to_string(file)
.context(format!("Failed to read file: {:?}", file))?;
let editor = match RustEditor::new(&content) {
Ok(e) => e,
Err(_) => continue, };
let results = editor.inspect(node_type, Some(name), None, false)?;
if !results.is_empty() {
return Ok(true);
}
}
Ok(false)
}
fn detect_target_type(files: &[PathBuf], name: &str) -> Result<Option<String>> {
for file in files {
let content = std::fs::read_to_string(file)
.context(format!("Failed to read file: {:?}", file))?;
let editor = match RustEditor::new(&content) {
Ok(e) => e,
Err(_) => continue, };
let struct_results = editor.inspect(Some("struct"), Some(name), None, false)?;
if !struct_results.is_empty() {
return Ok(Some("struct".to_string()));
}
let enum_results = editor.inspect(Some("enum"), Some(name), None, false)?;
if !enum_results.is_empty() {
return Ok(Some("enum".to_string()));
}
}
Ok(None)
}
fn show_target_hints(files: &[PathBuf], name: &str, expected_type: &str, paths: &[PathBuf]) -> Result<()> {
use std::collections::HashMap;
use operations::InspectResult;
let mut hint_results: Vec<InspectResult> = Vec::new();
for file in files {
let content = std::fs::read_to_string(file)
.context(format!("Failed to read file: {:?}", file))?;
let editor = match RustEditor::new(&content) {
Ok(e) => e,
Err(_) => continue, };
let mut results = editor.inspect(None, Some(name), None, false)?;
for result in &mut results {
result.file_path = file.to_string_lossy().to_string();
}
hint_results.extend(results);
}
if hint_results.is_empty() {
eprintln!("No {} found named \"{}\"", expected_type, name);
eprintln!();
eprintln!("Hint: Run 'find' to discover what exists:");
eprintln!(" rs-hack find --paths {} --name {}",
paths.iter().map(|p| p.to_string_lossy()).collect::<Vec<_>>().join(" "),
name
);
} else {
let mut by_type: HashMap<String, Vec<&InspectResult>> = HashMap::new();
for result in &hint_results {
by_type.entry(result.node_type.clone()).or_insert_with(Vec::new).push(result);
}
eprintln!("No {} found named \"{}\"", expected_type, name);
eprintln!();
eprintln!("Hint: Found \"{}\" in other contexts:", name);
for (ntype, results) in by_type.iter() {
let count = results.len();
let first = results.first().unwrap();
eprintln!(" - {} ({}): {}:{}:{}",
ntype,
if count == 1 { "1 match".to_string() } else { format!("{} matches", count) },
first.file_path,
first.location.line,
first.location.column
);
}
eprintln!();
eprintln!("To see all matches, run:");
eprintln!(" rs-hack find --paths {} --name {}",
paths.iter().map(|p| p.to_string_lossy()).collect::<Vec<_>>().join(" "),
name
);
}
Ok(())
}
fn main() -> Result<()> {
let cli = Cli::parse();
match cli.command {
Commands::AddStructField { paths, struct_name, field, position, literal_default, output, apply } => {
let files = collect_rust_files_with_exclusions(&paths, &cli.exclude)?;
let op = Operation::AddStructField(AddStructFieldOp {
struct_name: struct_name.clone(),
field_def: field.clone(),
position: parse_position(&position)?,
literal_default: literal_default.clone(),
where_filter: cli.r#where.clone(),
});
execute_operation_with_state(&files, &op, apply, output.as_ref(), &cli.local_state, &cli.format, cli.summary, cli.limit)?;
}
Commands::UpdateStructField { paths, struct_name, field, output, apply } => {
let files = collect_rust_files_with_exclusions(&paths, &cli.exclude)?;
let op = Operation::UpdateStructField(UpdateStructFieldOp {
struct_name: struct_name.clone(),
field_def: field.clone(),
where_filter: cli.r#where.clone(),
});
execute_operation_with_state(&files, &op, apply, output.as_ref(), &cli.local_state, &cli.format, cli.summary, cli.limit)?;
}
Commands::RemoveStructField { paths, struct_name, field_name, literal_only, output, apply } => {
let files = collect_rust_files_with_exclusions(&paths, &cli.exclude)?;
let op = Operation::RemoveStructField(RemoveStructFieldOp {
struct_name: struct_name.clone(),
field_name: field_name.clone(),
literal_only,
where_filter: cli.r#where.clone(),
});
execute_operation_with_state(&files, &op, apply, output.as_ref(), &cli.local_state, &cli.format, cli.summary, cli.limit)?;
}
Commands::AddStructLiteralField { paths, struct_name, field, position, apply } => {
let files = collect_rust_files_with_exclusions(&paths, &cli.exclude)?;
let op = Operation::AddStructLiteralField(AddStructLiteralFieldOp {
struct_name: struct_name.clone(),
field_def: field.clone(),
position: parse_position(&position)?,
struct_path: None, });
execute_operation_with_state(&files, &op, apply, None, &cli.local_state, &cli.format, cli.summary, cli.limit)?;
}
Commands::AddEnumVariant { paths, enum_name, variant, position, output, apply } => {
let files = collect_rust_files_with_exclusions(&paths, &cli.exclude)?;
let op = Operation::AddEnumVariant(AddEnumVariantOp {
enum_name: enum_name.clone(),
variant_def: variant.clone(),
position: parse_position(&position)?,
where_filter: cli.r#where.clone(),
});
execute_operation_with_state(&files, &op, apply, output.as_ref(), &cli.local_state, &cli.format, cli.summary, cli.limit)?;
}
Commands::UpdateEnumVariant { paths, enum_name, variant, output, apply } => {
let files = collect_rust_files_with_exclusions(&paths, &cli.exclude)?;
let op = Operation::UpdateEnumVariant(UpdateEnumVariantOp {
enum_name: enum_name.clone(),
variant_def: variant.clone(),
where_filter: cli.r#where.clone(),
});
execute_operation_with_state(&files, &op, apply, output.as_ref(), &cli.local_state, &cli.format, cli.summary, cli.limit)?;
}
Commands::RemoveEnumVariant { paths, enum_name, variant_name, output, apply } => {
let files = collect_rust_files_with_exclusions(&paths, &cli.exclude)?;
let op = Operation::RemoveEnumVariant(RemoveEnumVariantOp {
enum_name: enum_name.clone(),
variant_name: variant_name.clone(),
where_filter: cli.r#where.clone(),
});
execute_operation_with_state(&files, &op, apply, output.as_ref(), &cli.local_state, &cli.format, cli.summary, cli.limit)?;
}
Commands::RenameEnumVariant { paths, enum_name, old_variant, new_variant, enum_path, edit_mode, validate, apply } => {
let files = collect_rust_files_with_exclusions(&paths, &cli.exclude)?;
if validate {
validate_enum_variant_rename(&files, &enum_name, &old_variant, enum_path.as_deref())?;
} else {
let edit_mode = edit_mode.parse::<EditMode>()
.map_err(|e| anyhow::anyhow!("{}", e))?;
let op = Operation::RenameEnumVariant(RenameEnumVariantOp {
enum_name: enum_name.clone(),
old_variant: old_variant.clone(),
new_variant: new_variant.clone(),
enum_path: enum_path.clone(),
edit_mode,
});
execute_operation_with_state(&files, &op, apply, None, &cli.local_state, &cli.format, cli.summary, cli.limit)?;
}
}
Commands::RenameFunction { paths, old_name, new_name, function_path, edit_mode, validate, apply } => {
let files = collect_rust_files_with_exclusions(&paths, &cli.exclude)?;
if validate {
validate_function_rename(&files, &old_name, function_path.as_deref())?;
} else {
let edit_mode = edit_mode.parse::<EditMode>()
.map_err(|e| anyhow::anyhow!("{}", e))?;
let op = Operation::RenameFunction(RenameFunctionOp {
old_name: old_name.clone(),
new_name: new_name.clone(),
function_path: function_path.clone(),
edit_mode,
});
execute_operation_with_state(&files, &op, apply, None, &cli.local_state, &cli.format, cli.summary, cli.limit)?;
}
}
Commands::Rename { paths, name, to, enum_path, function_path, kind, node_type, edit_mode, validate, apply } => {
let files = collect_rust_files_with_exclusions(&paths, &cli.exclude)?;
let edit_mode = edit_mode.parse::<EditMode>()
.map_err(|e| anyhow::anyhow!("{}", e))?;
let granular_types = ["function-call", "method-call", "identifier", "macro-call", "struct-literal", "enum-usage", "type-ref"];
if let Some(nt) = &node_type {
if granular_types.contains(&nt.as_str()) {
let op = Operation::Transform(TransformOp {
node_type: nt.clone(),
name_filter: Some(name.clone()),
content_filter: None,
action: TransformAction::Replace { with: to.clone() },
});
execute_operation_with_state(&files, &op, apply, None, &cli.local_state, &cli.format, cli.summary, cli.limit)?;
return Ok(());
}
}
if let Some(k) = &kind {
let expanded = expand_kind_to_node_types(k);
if expanded.is_empty() {
anyhow::bail!("Unknown kind '{}'. Valid kinds: struct, function, enum, match, identifier, type, macro, const, trait, mod, use", k);
}
if k == "function" {
} else if k == "identifier" {
let op = Operation::Transform(TransformOp {
node_type: "identifier".to_string(),
name_filter: Some(name.clone()),
content_filter: None,
action: TransformAction::Replace { with: to.clone() },
});
execute_operation_with_state(&files, &op, apply, None, &cli.local_state, &cli.format, cli.summary, cli.limit)?;
return Ok(());
} else {
anyhow::bail!("Rename with --kind is only supported for 'function' and 'identifier' kinds. For other kinds, use --node-type.");
}
}
if name.contains("::") {
let parts: Vec<&str> = name.split("::").collect();
if parts.len() != 2 {
anyhow::bail!("Invalid enum variant syntax. Use EnumName::VariantName");
}
let enum_name = parts[0];
let old_variant = parts[1];
if !target_exists(&files, enum_name, Some("enum"))? {
show_target_hints(&files, enum_name, "enum", &paths)?;
return Ok(());
}
if validate {
validate_enum_variant_rename(&files, enum_name, old_variant, enum_path.as_deref())?;
} else {
let op = Operation::RenameEnumVariant(RenameEnumVariantOp {
enum_name: enum_name.to_string(),
old_variant: old_variant.to_string(),
new_variant: to.clone(),
enum_path: enum_path.clone(),
edit_mode,
});
execute_operation_with_state(&files, &op, apply, None, &cli.local_state, &cli.format, cli.summary, cli.limit)?;
}
} else {
let is_function = target_exists(&files, &name, Some("function"))? ||
target_exists(&files, &name, Some("impl-method"))? ||
target_exists(&files, &name, Some("trait-method"))?;
let mut found_as_enum_variant = false;
let mut enum_candidates: Vec<String> = Vec::new();
for file in &files {
let content = std::fs::read_to_string(file)
.context(format!("Failed to read file: {:?}", file))?;
let editor = match RustEditor::new(&content) {
Ok(e) => e,
Err(_) => continue, };
let enum_results = editor.inspect(Some("enum"), None, None, false)?;
for enum_result in enum_results {
if enum_result.snippet.contains(&format!("{}(", &name)) ||
enum_result.snippet.contains(&format!("{} {{", &name)) ||
enum_result.snippet.contains(&format!("{},", &name)) {
found_as_enum_variant = true;
enum_candidates.push(enum_result.identifier.clone());
}
}
}
if is_function && found_as_enum_variant {
anyhow::bail!(
"Ambiguous target '{}': found both as a function and as an enum variant.\n\
Please disambiguate:\n\
- For function: rs-hack rename --name {} --to {} --paths ... --apply\n\
- For enum variant: rs-hack rename --name <EnumName>::{} --to {} --paths ... --apply\n\
\n\
Found in enums: {}",
name, name, to, name, to,
enum_candidates.join(", ")
);
} else if is_function {
if validate {
validate_function_rename(&files, &name, function_path.as_deref())?;
} else {
let op = Operation::RenameFunction(RenameFunctionOp {
old_name: name.clone(),
new_name: to.clone(),
function_path: function_path.clone(),
edit_mode,
});
execute_operation_with_state(&files, &op, apply, None, &cli.local_state, &cli.format, cli.summary, cli.limit)?;
}
} else if found_as_enum_variant {
if enum_candidates.len() == 1 {
let enum_name = &enum_candidates[0];
if validate {
validate_enum_variant_rename(&files, enum_name, &name, enum_path.as_deref())?;
} else {
let op = Operation::RenameEnumVariant(RenameEnumVariantOp {
enum_name: enum_name.clone(),
old_variant: name.clone(),
new_variant: to.clone(),
enum_path: enum_path.clone(),
edit_mode,
});
execute_operation_with_state(&files, &op, apply, None, &cli.local_state, &cli.format, cli.summary, cli.limit)?;
}
} else {
anyhow::bail!(
"Variant '{}' found in multiple enums: {}\n\
Please specify which enum using :: syntax:\n\
rs-hack rename --name <EnumName>::{} --to {} --paths ... --apply",
name, enum_candidates.join(", "), name, to
);
}
} else {
eprintln!("No function or enum variant found named \"{}\"", name);
eprintln!();
eprintln!("Hint: Run 'find' to discover what exists:");
eprintln!(" rs-hack find --paths {} --name {}",
paths.iter().map(|p| p.to_string_lossy()).collect::<Vec<_>>().join(" "),
name
);
}
}
}
Commands::AddMatchArm { paths, pattern, body, function, auto_detect, enum_name, apply } => {
if auto_detect && enum_name.is_none() {
anyhow::bail!("--enum-name is required when using --auto-detect");
}
if !auto_detect && pattern.is_none() {
anyhow::bail!("--pattern is required when not using --auto-detect");
}
let files = collect_rust_files_with_exclusions(&paths, &cli.exclude)?;
let op = Operation::AddMatchArm(AddMatchArmOp {
pattern: pattern.unwrap_or_default(),
body: body.clone(),
function_name: function,
auto_detect,
enum_name,
});
execute_operation(&files, &op, apply, None, &cli.format, cli.summary, cli.limit)?;
}
Commands::UpdateMatchArm { paths, pattern, body, function, apply } => {
let files = collect_rust_files_with_exclusions(&paths, &cli.exclude)?;
let op = Operation::UpdateMatchArm(UpdateMatchArmOp {
pattern: pattern.clone(),
new_body: body.clone(),
function_name: function,
});
execute_operation(&files, &op, apply, None, &cli.format, cli.summary, cli.limit)?;
}
Commands::RemoveMatchArm { paths, pattern, function, apply } => {
let files = collect_rust_files_with_exclusions(&paths, &cli.exclude)?;
let op = Operation::RemoveMatchArm(RemoveMatchArmOp {
pattern: pattern.clone(),
function_name: function,
});
execute_operation(&files, &op, apply, None, &cli.format, cli.summary, cli.limit)?;
}
Commands::Batch { spec, apply } => {
let content = std::fs::read_to_string(&spec)
.context("Failed to read batch spec file")?;
let batch: BatchSpec = if spec.extension().and_then(|s| s.to_str()) == Some("yaml")
|| spec.extension().and_then(|s| s.to_str()) == Some("yml") {
serde_yaml::from_str(&content)
.context("Failed to parse batch spec YAML")?
} else {
serde_json::from_str(&content)
.or_else(|_| serde_yaml::from_str(&content))
.context("Failed to parse batch spec (tried both JSON and YAML)")?
};
execute_batch(&batch, apply, &cli.exclude)?;
}
Commands::Find { paths, kind, node_type, name, variant, content_filter, field_name, include_comments, format, context } => {
use operations::InspectResult;
let args = rs_hack::commands::find::FindArgs {
paths: paths.clone(),
exclude: cli.exclude.clone(),
kind: kind.clone(),
node_type: node_type.clone(),
name: name.clone(),
variant: variant.clone(),
content_filter: content_filter.clone(),
field_name: field_name.clone(),
include_comments,
context,
};
let result = rs_hack::commands::find::run(&args)?;
if let rs_hack::commands::find::FindResult::Field { matches: all_locations } = &result {
use operations::FieldContext;
use std::collections::HashMap;
let field = field_name.as_ref().expect("field_name set when FindResult::Field");
if all_locations.is_empty() {
println!("No occurrences of field '{}' found.", field);
return Ok(());
}
let mut struct_defs: Vec<&operations::FieldLocation> = Vec::new();
let mut variant_defs: Vec<&operations::FieldLocation> = Vec::new();
let mut struct_literals: Vec<&operations::FieldLocation> = Vec::new();
for loc in all_locations {
match &loc.context {
FieldContext::StructDefinition { .. } => struct_defs.push(loc),
FieldContext::EnumVariantDefinition { .. } => variant_defs.push(loc),
FieldContext::StructLiteral { .. } => struct_literals.push(loc),
}
}
println!("Found {} occurrence{} of field '{}':\n",
all_locations.len(),
if all_locations.len() == 1 { "" } else { "s" },
field);
if !struct_defs.is_empty() {
println!("Struct Definitions ({}):", struct_defs.len());
for loc in &struct_defs {
if let FieldContext::StructDefinition { struct_name, field_type } = &loc.context {
println!(" - {}:{} in struct {} (type: {})",
loc.file_path, loc.line, struct_name, field_type);
println!(" Remove: rs-hack remove --name {} --field-name {} --paths {} --apply",
struct_name, field, loc.file_path);
}
}
println!();
}
if !variant_defs.is_empty() {
println!("Enum Variant Definitions ({}):", variant_defs.len());
for loc in &variant_defs {
if let FieldContext::EnumVariantDefinition { enum_name, variant_name, field_type } = &loc.context {
println!(" - {}:{} in enum {}::{} (type: {})",
loc.file_path, loc.line, enum_name, variant_name, field_type);
println!(" Remove: rs-hack remove --name {}::{} --field-name {} --paths {} --apply",
enum_name, variant_name, field, loc.file_path);
}
}
println!();
}
if !struct_literals.is_empty() {
println!("Struct Literal Expressions ({}):", struct_literals.len());
let mut by_struct: HashMap<String, Vec<&operations::FieldLocation>> = HashMap::new();
for loc in &struct_literals {
if let FieldContext::StructLiteral { struct_name } = &loc.context {
by_struct.entry(struct_name.clone()).or_insert_with(Vec::new).push(loc);
}
}
let mut struct_names: Vec<String> = by_struct.keys().cloned().collect();
struct_names.sort();
for struct_name in struct_names {
let locs = &by_struct[&struct_name];
println!(" {} ({} occurrence{}):", struct_name, locs.len(),
if locs.len() == 1 { "" } else { "s" });
for loc in locs {
println!(" - {}:{}", loc.file_path, loc.line);
}
println!(" Remove from literals: rs-hack remove --name {} --field-name {} --literal-only --paths src --apply",
struct_name, field);
}
println!();
}
return Ok(());
}
let all_results: Vec<InspectResult> = match result {
rs_hack::commands::find::FindResult::Nodes { matches } => matches,
rs_hack::commands::find::FindResult::Field { .. } => unreachable!("handled above"),
};
if all_results.is_empty() && node_type.is_some() && name.is_some() {
let hint_results = rs_hack::commands::find::run_unfiltered_by_node_type(&args)?;
if !hint_results.is_empty() {
use std::collections::HashMap;
let mut by_type: HashMap<String, Vec<&InspectResult>> = HashMap::new();
for result in &hint_results {
by_type.entry(result.node_type.clone()).or_insert_with(Vec::new).push(result);
}
eprintln!("No {} found named \"{}\"",
node_type.as_ref().unwrap(),
name.as_ref().unwrap());
eprintln!();
eprintln!("Hint: Found \"{}\" in other contexts:", name.as_ref().unwrap());
for (ntype, results) in by_type.iter() {
let count = results.len();
let first = results.first().unwrap();
eprintln!(" - {} ({}): {}:{}:{}",
ntype,
if count == 1 { "1 match".to_string() } else { format!("{} matches", count) },
first.file_path,
first.location.line,
first.location.column
);
}
eprintln!();
eprintln!("To see all matches, run without --node-type:");
eprintln!(" rs-hack find --paths {} --name {}",
paths.iter().map(|p| p.to_string_lossy()).collect::<Vec<_>>().join(" "),
name.as_ref().unwrap()
);
return Ok(());
}
}
if all_results.is_empty() && name.is_some() {
let search_name = name.as_ref().unwrap();
let mut text_matches: Vec<(String, usize)> = Vec::new();
let files = collect_rust_files_with_exclusions(&paths, &cli.exclude)?;
for file in &files {
let content = std::fs::read_to_string(&file)
.context(format!("Failed to read file: {:?}", file))?;
let count = content.lines().filter(|line| line.contains(search_name)).count();
if count > 0 {
text_matches.push((file.to_string_lossy().to_string(), count));
}
}
if !text_matches.is_empty() {
let total_matches: usize = text_matches.iter().map(|(_, c)| c).sum();
eprintln!("No AST nodes found for \"{}\"", search_name);
eprintln!();
eprintln!("However, found {} non-AST text occurrence{} of \"{}\":",
total_matches,
if total_matches == 1 { "" } else { "s" },
search_name
);
for (file_path, count) in &text_matches {
eprintln!(" - {} ({} line{})",
file_path,
count,
if *count == 1 { "" } else { "s" }
);
}
eprintln!();
eprintln!("Note: These occurrences may be:");
eprintln!(" - Inside macro invocations (e.g., vec![YourStruct {{ ... }}])");
eprintln!(" - In comments or strings");
eprintln!(" - Part of a qualified path (e.g., module::{})", search_name);
eprintln!();
eprintln!("rs-hack's AST visitor cannot see inside macro expansions.");
eprintln!("Try searching without --name to see all struct literals,");
eprintln!("or use --name with a different pattern (e.g., \"*::{}\").", search_name);
return Ok(());
}
}
match format.as_str() {
"json" => {
println!("{}", serde_json::to_string_pretty(&all_results)?);
}
"locations" => {
for result in &all_results {
println!("{}:{}:{}", result.file_path, result.location.line, result.location.column);
}
}
"snippets" => {
if node_type.is_none() && !all_results.is_empty() {
use std::collections::HashMap;
let mut by_type: HashMap<String, Vec<&InspectResult>> = HashMap::new();
for result in &all_results {
by_type.entry(result.node_type.clone()).or_insert_with(Vec::new).push(result);
}
if let Some(ref search_name) = name {
println!("Found \"{}\" in {} context{}:\n",
search_name,
by_type.len(),
if by_type.len() == 1 { "" } else { "s" }
);
} else {
println!("Found {} result{} across {} node type{}:\n",
all_results.len(),
if all_results.len() == 1 { "" } else { "s" },
by_type.len(),
if by_type.len() == 1 { "" } else { "s" }
);
}
let mut type_names: Vec<String> = by_type.keys().cloned().collect();
type_names.sort();
for type_name in type_names {
let results = &by_type[&type_name];
let count = results.len();
println!("{}{}{}:",
type_name,
if count > 1 { format!(" ({} match{})", count, if count == 1 { "" } else { "es" }) } else { String::new() },
""
);
for result in results {
println!(" // {}:{}:{} - {}",
result.file_path,
result.location.line,
result.location.column,
result.identifier);
if let Some(ref comment) = result.preceding_comment {
for line in comment.lines() {
println!(" {}", line);
}
}
for line in result.snippet.lines() {
println!(" {}", line);
}
println!();
}
}
if let Some(ref search_name) = name {
if !search_name.contains("::") &&
(node_type.as_deref() == Some("struct-literal") ||
kind.as_deref() == Some("struct")) {
if let Some(struct_lit_results) = by_type.get("struct-literal") {
use std::collections::HashMap;
let mut qualified_paths: HashMap<String, usize> = HashMap::new();
for result in struct_lit_results {
if result.identifier.contains("::") {
*qualified_paths.entry(result.identifier.clone()).or_insert(0) += 1;
}
}
if !qualified_paths.is_empty() {
println!("💡 Hint: Found {} struct literal(s) with fully qualified paths:",
qualified_paths.values().sum::<usize>());
let mut paths: Vec<_> = qualified_paths.iter().collect();
paths.sort_by_key(|(path, _)| *path);
for (path, count) in &paths {
println!(" {} ({} instance{})", path, count, if **count == 1 { "" } else { "s" });
}
println!("\nTo find only these:");
for (path, _) in paths.iter().take(3) {
println!(" rs-hack find --name \"{}\" --node-type struct-literal --paths ...", path);
}
if paths.len() > 3 {
println!(" (and {} more...)", paths.len() - 3);
}
println!();
}
}
}
}
} else {
let mut prev_file: Option<&str> = None;
for result in &all_results {
if let Some(n) = context {
if n > 0 {
if prev_file.is_some() {
println!("--");
}
if let Ok(content) = std::fs::read_to_string(&result.file_path) {
let lines: Vec<&str> = content.lines().collect();
let match_line = result.location.line.saturating_sub(1); let start = match_line.saturating_sub(n);
for (i, line) in lines[start..match_line].iter().enumerate() {
println!("{}: {}", start + i + 1, line);
}
}
}
}
println!("// {}:{}:{} - {}",
result.file_path,
result.location.line,
result.location.column,
result.identifier);
if let Some(ref comment) = result.preceding_comment {
println!("{}", comment);
}
println!("{}\n", result.snippet);
prev_file = Some(&result.file_path);
}
}
}
_ => {
anyhow::bail!("Unknown format: {}. Use 'json', 'locations', or 'snippets'", format);
}
}
}
Commands::Neighbors { path } => {
let report = rs_hack::commands::neighbors::run(&path)?;
let display = |p: &std::path::Path| -> String {
std::env::current_dir()
.ok()
.and_then(|cwd| p.strip_prefix(&cwd).ok().map(|r| r.to_path_buf()))
.unwrap_or_else(|| p.to_path_buf())
.to_string_lossy()
.into_owned()
};
println!("Neighbors for {}:", display(&report.target));
if report.siblings.is_empty() {
println!(" Siblings: (none)");
} else {
let s: Vec<String> = report.siblings.iter().map(|p| display(p)).collect();
println!(" Siblings: {}", s.join(", "));
}
if report.twin_files.is_empty() {
println!(" Twin dirs: (none)");
} else {
let s: Vec<String> = report.twin_files.iter().map(|p| display(p)).collect();
println!(" Twin dirs: {}", s.join(", "));
}
if report.test_files.is_empty() {
println!(" Tests: (none)");
} else {
let s: Vec<String> = report.test_files.iter().map(|p| display(p)).collect();
println!(" Tests: {}", s.join(", "));
}
}
Commands::Impls { paths, r#trait } => {
use rs_hack::commands::find::FindArgs;
let args = FindArgs {
paths: paths.clone(),
exclude: cli.exclude.clone(),
node_type: Some("trait-impl".to_string()),
name: Some(r#trait.clone()),
include_comments: false,
..Default::default()
};
let result = rs_hack::commands::find::run(&args)?;
let matches = match result {
rs_hack::commands::find::FindResult::Nodes { matches } => matches,
_ => vec![],
};
if matches.is_empty() {
println!("Trait {} implemented by: (none found)", r#trait);
} else {
println!("Trait {} implemented by:", r#trait);
for m in &matches {
let type_name = m.identifier
.splitn(2, " for ")
.nth(1)
.unwrap_or(&m.identifier);
println!(" {} ({}:{})", type_name, m.file_path, m.location.line);
}
}
}
Commands::MatchAudit { paths, r#enum } => {
let result = rs_hack::commands::match_audit::run(
&paths,
&r#enum,
&cli.exclude,
)?;
rs_hack::commands::match_audit::render(&result);
}
Commands::DocCoverage { paths, fields } => {
let result = rs_hack::commands::doc_coverage::run(
&paths,
fields,
&cli.exclude,
)?;
rs_hack::commands::doc_coverage::render(&result);
}
Commands::Summary { path } => {
let result = rs_hack::commands::summary::run(&path)?;
rs_hack::commands::summary::render(&result);
}
Commands::AddDerive { paths, target_type, name, derives, apply } => {
let files = collect_rust_files_with_exclusions(&paths, &cli.exclude)?;
let derive_vec: Vec<String> = derives
.split(',')
.map(|s| s.trim().to_string())
.collect();
let op = Operation::AddDerive(AddDeriveOp {
target_name: name.clone(),
target_type: target_type.clone(),
derives: derive_vec,
where_filter: cli.r#where.clone(),
});
execute_operation(&files, &op, apply, None, &cli.format, cli.summary, cli.limit)?;
}
Commands::AddImplMethod { paths, target, method, position, apply } => {
let files = collect_rust_files_with_exclusions(&paths, &cli.exclude)?;
let op = Operation::AddImplMethod(AddImplMethodOp {
target: target.clone(),
method_def: method.clone(),
position: parse_position(&position)?,
});
execute_operation(&files, &op, apply, None, &cli.format, cli.summary, cli.limit)?;
}
Commands::AddUse { paths, use_path, position, apply } => {
let files = collect_rust_files_with_exclusions(&paths, &cli.exclude)?;
let op = Operation::AddUseStatement(AddUseStatementOp {
use_path: use_path.clone(),
position: parse_position(&position)?,
});
execute_operation_with_state(&files, &op, apply, None, &cli.local_state, &cli.format, cli.summary, cli.limit)?;
}
Commands::Add { paths, name, field, field_name, field_type, field_value, variant, method, derive, r#use, match_arm, body, function, auto_detect, enum_name, doc_comment, kind, node_type, literal_default, literal_only, default_rest, base, call, arg, arg_position, call_type, content_filter, position, apply } => {
let files = collect_rust_files_with_exclusions(&paths, &cli.exclude)?;
if let Some(call_name) = call {
let arg_expr = arg.ok_or_else(|| anyhow::anyhow!("--arg is required when using --call"))?;
let position = arg_position.parse::<ArgPosition>()
.map_err(|e| anyhow::anyhow!("{}", e))?;
let op = Operation::AddCallArg(AddCallArgOp {
call_name,
arg_expr,
position,
call_type,
content_filter,
});
execute_operation_with_state(&files, &op, apply, None, &cli.local_state, &cli.format, cli.summary, cli.limit)?;
return Ok(());
}
if default_rest || base.is_some() {
let target_name = name.as_ref().ok_or_else(|| anyhow::anyhow!("--name is required when using --default-rest or --base"))?;
let base_expr = if default_rest {
"Default::default()".to_string()
} else {
base.clone().unwrap()
};
let op = Operation::SetStructLiteralBase(SetStructLiteralBaseOp {
struct_name: target_name.clone(),
base_expr,
struct_path: None,
});
execute_operation_with_state(&files, &op, apply, None, &cli.local_state, &cli.format, cli.summary, cli.limit)?;
return Ok(());
}
let op_count = [field.is_some(), field_name.is_some(), variant.is_some(), method.is_some(), derive.is_some(), r#use.is_some(), match_arm.is_some() || auto_detect, doc_comment.is_some()].iter().filter(|&&x| x).count();
if op_count == 0 {
anyhow::bail!("Must specify one of: --field/--field-name, --variant, --method, --derive, --use, --match-arm, --default-rest, --base, --call, or --doc-comment");
}
if op_count > 1 {
if variant.is_some() && (field_name.is_some() || field.is_some()) {
anyhow::bail!(
"Cannot combine --variant with --field-name/--field.\n\n\
Hint: To add a field to enum variant struct literals, use:\n \
rs-hack add --name \"{}::{}\" --field-name <FIELD> --field-value <VALUE> --kind struct --paths <PATHS>\n\n\
Note: --variant is for adding a NEW variant to an enum, not for adding fields to existing variants.",
name.as_deref().unwrap_or("EnumName"),
variant.as_deref().unwrap_or("VariantName")
);
}
anyhow::bail!("Can only specify one operation flag at a time (--field/--field-name, --variant, --method, --derive, --use, --match-arm, --call, or --doc-comment)");
}
if match_arm.is_some() || auto_detect {
if body.is_none() {
anyhow::bail!("--body is required when using --match-arm or --auto-detect");
}
if auto_detect {
if enum_name.is_none() {
anyhow::bail!("--enum-name is required when using --auto-detect");
}
if match_arm.is_some() {
eprintln!("⚠️ Note: --match-arm is ignored with --auto-detect. Auto-detect adds ALL missing variants.");
eprintln!(" To add a specific arm only, remove --auto-detect.");
}
let op = Operation::AddMatchArm(AddMatchArmOp {
pattern: "".to_string(), body: body.clone().unwrap(),
function_name: function.clone(),
auto_detect: true,
enum_name: enum_name.clone(),
});
execute_operation_with_state(&files, &op, apply, None, &cli.local_state, &cli.format, cli.summary, cli.limit)?;
} else {
let op = Operation::AddMatchArm(AddMatchArmOp {
pattern: match_arm.clone().unwrap(),
body: body.clone().unwrap(),
function_name: function.clone(),
auto_detect: false,
enum_name: None,
});
execute_operation_with_state(&files, &op, apply, None, &cli.local_state, &cli.format, cli.summary, cli.limit)?;
}
return Ok(());
}
if let Some(doc_text) = doc_comment {
if name.is_none() {
anyhow::bail!("--name is required when using --doc-comment");
}
let target_type = if let Some(k) = &kind {
let expanded = expand_kind_to_node_types(k);
if expanded.is_empty() {
anyhow::bail!("Unknown kind '{}'. Valid kinds: struct, function, enum, match, identifier, type, macro, const, trait, mod, use", k);
}
if expanded.len() > 1 {
anyhow::bail!("Kind '{}' expands to multiple node types. Use --node-type for doc comments to specify exactly which type.", k);
}
expanded[0].to_string()
} else if let Some(nt) = &node_type {
nt.clone()
} else {
anyhow::bail!("--node-type or --kind is required when using --doc-comment");
};
let op = Operation::AddDocComment(AddDocCommentOp {
target_type,
name: name.clone().unwrap(),
doc_comment: doc_text.clone(),
style: DocCommentStyle::Line,
});
execute_operation_with_state(&files, &op, apply, None, &cli.local_state, &cli.format, cli.summary, cli.limit)?;
return Ok(());
}
if let Some(use_path) = r#use {
let op = Operation::AddUseStatement(AddUseStatementOp {
use_path: use_path.clone(),
position: parse_position(&position)?,
});
execute_operation_with_state(&files, &op, apply, None, &cli.local_state, &cli.format, cli.summary, cli.limit)?;
return Ok(());
}
let target_name = name.as_ref().ok_or_else(|| anyhow::anyhow!("--name is required for this operation"))?;
if field.is_some() || field_name.is_some() {
let (final_field_def, final_literal_default) = if let Some(fname) = field_name {
match (field_type.as_ref(), field_value.as_ref()) {
(Some(ftype), Some(fvalue)) => {
(format!("{}: {}", fname, ftype), Some(fvalue.clone()))
}
(Some(ftype), None) => {
(format!("{}: {}", fname, ftype), None)
}
(None, Some(fvalue)) => {
(fname.clone(), Some(fvalue.clone()))
}
(None, None) => {
anyhow::bail!("--field-name requires either --field-type (for definitions) or --field-value (for literals) or both");
}
}
} else {
(field.clone().unwrap(), literal_default.clone())
};
let is_literal_only = field_type.is_none() && field_value.is_some();
if !is_literal_only {
let exists = if let Some(k) = &kind {
let node_types = expand_kind_to_node_types(k);
let mut found = false;
for nt in node_types {
if target_exists(&files, target_name, Some(nt))? {
found = true;
break;
}
}
found
} else if let Some(nt) = &node_type {
target_exists(&files, target_name, Some(nt))?
} else {
target_exists(&files, target_name, Some("struct"))?
};
if !exists {
show_target_hints(&files, target_name, "struct", &paths)?;
return Ok(());
}
}
let op = Operation::AddStructField(AddStructFieldOp {
struct_name: target_name.clone(),
field_def: final_field_def,
position: parse_position(&position)?,
literal_default: final_literal_default,
where_filter: cli.r#where.clone(),
});
execute_operation_with_state(&files, &op, apply, None, &cli.local_state, &cli.format, cli.summary, cli.limit)?;
} else if let Some(variant_def) = variant {
if !target_exists(&files, target_name, Some("enum"))? {
show_target_hints(&files, target_name, "enum", &paths)?;
return Ok(());
}
let op = Operation::AddEnumVariant(AddEnumVariantOp {
enum_name: target_name.clone(),
variant_def: variant_def.clone(),
position: parse_position(&position)?,
where_filter: cli.r#where.clone(),
});
execute_operation_with_state(&files, &op, apply, None, &cli.local_state, &cli.format, cli.summary, cli.limit)?;
} else if let Some(method_def) = method {
if !target_exists(&files, target_name, None)? {
show_target_hints(&files, target_name, "impl", &paths)?;
return Ok(());
}
let op = Operation::AddImplMethod(AddImplMethodOp {
target: target_name.clone(),
method_def: method_def.clone(),
position: parse_position(&position)?,
});
execute_operation(&files, &op, apply, None, &cli.format, cli.summary, cli.limit)?;
} else if let Some(derives) = derive {
let target_type = detect_target_type(&files, target_name)?;
if target_type.is_none() {
show_target_hints(&files, target_name, "struct or enum", &paths)?;
return Ok(());
}
let derive_vec: Vec<String> = derives
.split(',')
.map(|s| s.trim().to_string())
.collect();
let op = Operation::AddDerive(AddDeriveOp {
target_name: target_name.clone(),
target_type: target_type.unwrap(),
derives: derive_vec,
where_filter: cli.r#where.clone(),
});
execute_operation(&files, &op, apply, None, &cli.format, cli.summary, cli.limit)?;
}
}
Commands::Remove { paths, name, field_name, variant, method, derive, match_arm, function, doc_comment, kind, node_type, literal_only, call, arg_index, call_type, content_filter, apply } => {
let files = collect_rust_files_with_exclusions(&paths, &cli.exclude)?;
if let Some(call_name) = call {
let idx = arg_index.ok_or_else(|| anyhow::anyhow!("--arg-index is required when using --call"))?;
let op = Operation::RemoveCallArg(RemoveCallArgOp {
call_name,
arg_index: idx,
call_type,
content_filter,
});
execute_operation_with_state(&files, &op, apply, None, &cli.local_state, &cli.format, cli.summary, cli.limit)?;
return Ok(());
}
let op_count = [field_name.is_some(), variant.is_some(), method.is_some(), derive.is_some(), match_arm.is_some(), doc_comment].iter().filter(|&&x| x).count();
if op_count == 0 {
anyhow::bail!("Must specify one of: --field-name, --variant, --method, --derive, --match-arm, --call, or --doc-comment");
}
if op_count > 1 {
anyhow::bail!("Can only specify one operation flag at a time (--field-name, --variant, --method, --derive, --match-arm, --call, or --doc-comment)");
}
if let Some(pattern) = match_arm {
let op = Operation::RemoveMatchArm(RemoveMatchArmOp {
pattern: pattern.clone(),
function_name: function.clone(),
});
execute_operation_with_state(&files, &op, apply, None, &cli.local_state, &cli.format, cli.summary, cli.limit)?;
return Ok(());
}
if doc_comment {
if name.is_none() {
anyhow::bail!("--name is required when using --doc-comment");
}
let target_type = if let Some(k) = &kind {
let expanded = expand_kind_to_node_types(k);
if expanded.is_empty() {
anyhow::bail!("Unknown kind '{}'. Valid kinds: struct, function, enum, match, identifier, type, macro, const, trait, mod, use", k);
}
if expanded.len() > 1 {
anyhow::bail!("Kind '{}' expands to multiple node types. Use --node-type for doc comments to specify exactly which type.", k);
}
expanded[0].to_string()
} else if let Some(nt) = &node_type {
nt.clone()
} else {
anyhow::bail!("--node-type or --kind is required when using --doc-comment");
};
let op = Operation::RemoveDocComment(RemoveDocCommentOp {
target_type,
name: name.clone().unwrap(),
});
execute_operation_with_state(&files, &op, apply, None, &cli.local_state, &cli.format, cli.summary, cli.limit)?;
return Ok(());
}
if name.is_none() {
anyhow::bail!("--name is required for this operation");
}
let target_name = name.as_ref().unwrap();
if let Some(field) = field_name {
let exists = if literal_only {
true
} else if let Some(k) = &kind {
let node_types = expand_kind_to_node_types(k);
let mut found = false;
for nt in node_types {
if target_exists(&files, target_name, Some(nt))? {
found = true;
break;
}
}
found
} else if let Some(nt) = &node_type {
target_exists(&files, target_name, Some(nt))?
} else {
if target_name.contains("::") {
let parts: Vec<&str> = target_name.split("::").collect();
if parts.len() == 2 {
let enum_name = parts[0];
target_exists(&files, enum_name, Some("enum"))?
} else {
anyhow::bail!("Invalid enum variant syntax. Use EnumName::VariantName");
}
} else {
target_exists(&files, target_name, Some("struct"))?
}
};
if !exists {
show_target_hints(&files, target_name, "struct", &paths)?;
return Ok(());
}
let op = Operation::RemoveStructField(RemoveStructFieldOp {
struct_name: target_name.clone(),
field_name: field.clone(),
literal_only,
where_filter: cli.r#where.clone(),
});
execute_operation_with_state(&files, &op, apply, None, &cli.local_state, &cli.format, cli.summary, cli.limit)?;
} else if let Some(variant_name) = variant {
if !target_exists(&files, target_name, Some("enum"))? {
show_target_hints(&files, target_name, "enum", &paths)?;
return Ok(());
}
let op = Operation::RemoveEnumVariant(RemoveEnumVariantOp {
enum_name: target_name.clone(),
variant_name: variant_name.clone(),
where_filter: cli.r#where.clone(),
});
execute_operation_with_state(&files, &op, apply, None, &cli.local_state, &cli.format, cli.summary, cli.limit)?;
} else if let Some(method_name) = method {
anyhow::bail!("Remove impl method is not yet implemented. Use the transform command to comment out methods:\n rs-hack transform --paths src --node-type impl-method --name {} --action comment --apply", method_name);
} else if let Some(derive_macro) = derive {
anyhow::bail!("Remove derive macro is not yet implemented. This is planned for a future release.\nFor now, you can manually edit the derive attribute or use the transform command.");
}
}
Commands::Update { paths, name, field, field_name, field_type, variant, match_arm, body, function, doc_comment, kind, node_type, call, arg_index, arg, call_type, content_filter, apply } => {
let files = collect_rust_files_with_exclusions(&paths, &cli.exclude)?;
if let Some(call_name) = call {
let idx = arg_index.ok_or_else(|| anyhow::anyhow!("--arg-index is required when using --call"))?;
let new_expr = arg.ok_or_else(|| anyhow::anyhow!("--arg is required when using --call"))?;
let op = Operation::UpdateCallArg(UpdateCallArgOp {
call_name,
arg_index: idx,
new_expr,
call_type,
content_filter,
});
execute_operation_with_state(&files, &op, apply, None, &cli.local_state, &cli.format, cli.summary, cli.limit)?;
return Ok(());
}
let op_count = [field.is_some(), field_name.is_some(), variant.is_some(), match_arm.is_some(), doc_comment.is_some()].iter().filter(|&&x| x).count();
if op_count == 0 {
anyhow::bail!("Must specify one of: --field, --variant, --match-arm, --call, or --doc-comment");
}
if op_count > 1 {
anyhow::bail!("Can only specify one operation flag at a time (--field, --variant, --match-arm, --call, or --doc-comment)");
}
if let Some(pattern) = match_arm {
if body.is_none() {
anyhow::bail!("--body is required when using --match-arm");
}
let op = Operation::UpdateMatchArm(UpdateMatchArmOp {
pattern: pattern.clone(),
new_body: body.clone().unwrap(),
function_name: function.clone(),
});
execute_operation_with_state(&files, &op, apply, None, &cli.local_state, &cli.format, cli.summary, cli.limit)?;
return Ok(());
}
if let Some(doc_text) = doc_comment {
if name.is_none() {
anyhow::bail!("--name is required when using --doc-comment");
}
let target_type = if let Some(k) = &kind {
let expanded = expand_kind_to_node_types(k);
if expanded.is_empty() {
anyhow::bail!("Unknown kind '{}'. Valid kinds: struct, function, enum, match, identifier, type, macro, const, trait, mod, use", k);
}
if expanded.len() > 1 {
anyhow::bail!("Kind '{}' expands to multiple node types. Use --node-type for doc comments to specify exactly which type.", k);
}
expanded[0].to_string()
} else if let Some(nt) = &node_type {
nt.clone()
} else {
anyhow::bail!("--node-type or --kind is required when using --doc-comment");
};
let op = Operation::UpdateDocComment(UpdateDocCommentOp {
target_type,
name: name.clone().unwrap(),
doc_comment: doc_text.clone(),
});
execute_operation_with_state(&files, &op, apply, None, &cli.local_state, &cli.format, cli.summary, cli.limit)?;
return Ok(());
}
if name.is_none() {
anyhow::bail!("--name is required for this operation");
}
let target_name = name.as_ref().unwrap();
if field.is_some() || field_name.is_some() {
let final_field_def = if let Some(fname) = field_name {
if let Some(ftype) = field_type {
format!("{}: {}", fname, ftype)
} else {
anyhow::bail!("--field-name requires --field-type for UPDATE operation");
}
} else {
field.clone().unwrap()
};
let exists = if let Some(k) = &kind {
let node_types = expand_kind_to_node_types(k);
let mut found = false;
for nt in node_types {
if target_exists(&files, target_name, Some(nt))? {
found = true;
break;
}
}
found
} else if let Some(nt) = &node_type {
target_exists(&files, target_name, Some(nt))?
} else {
target_exists(&files, target_name, Some("struct"))?
};
if !exists {
show_target_hints(&files, target_name, "struct", &paths)?;
return Ok(());
}
let op = Operation::UpdateStructField(UpdateStructFieldOp {
struct_name: target_name.clone(),
field_def: final_field_def,
where_filter: cli.r#where.clone(),
});
execute_operation_with_state(&files, &op, apply, None, &cli.local_state, &cli.format, cli.summary, cli.limit)?;
} else if let Some(variant_def) = variant {
if !target_exists(&files, target_name, Some("enum"))? {
show_target_hints(&files, target_name, "enum", &paths)?;
return Ok(());
}
let op = Operation::UpdateEnumVariant(UpdateEnumVariantOp {
enum_name: target_name.clone(),
variant_def: variant_def.clone(),
where_filter: cli.r#where.clone(),
});
execute_operation_with_state(&files, &op, apply, None, &cli.local_state, &cli.format, cli.summary, cli.limit)?;
}
}
Commands::History { limit } => {
let state_dir = get_state_dir(cli.local_state)?;
show_history(limit, &state_dir)?;
}
Commands::Revert { run_id, force } => {
let state_dir = get_state_dir(cli.local_state)?;
revert_run(&run_id, force, &state_dir)?;
}
Commands::Clean { keep_days } => {
let state_dir = get_state_dir(cli.local_state)?;
clean_old_state(keep_days, &state_dir)?;
}
Commands::Transform { paths, node_type, name, content_filter, action, with, apply } => {
use operations::{TransformOp, TransformAction};
let transform_action = match action.as_str() {
"comment" => TransformAction::Comment,
"remove" => TransformAction::Remove,
"replace" => {
let replacement = with.ok_or_else(|| anyhow::anyhow!("--with is required when action is 'replace'"))?;
TransformAction::Replace { with: replacement }
}
_ => anyhow::bail!("Invalid action: {}. Use 'comment', 'remove', or 'replace'", action),
};
let files = collect_rust_files_with_exclusions(&paths, &cli.exclude)?;
let op = Operation::Transform(TransformOp {
node_type: node_type.clone(),
name_filter: name,
content_filter,
action: transform_action,
});
execute_operation_with_state(&files, &op, apply, None, &cli.local_state, &cli.format, cli.summary, cli.limit)?;
}
Commands::AddDocComment { paths, target_type, name, doc_comment, style, apply } => {
let files = collect_rust_files_with_exclusions(&paths, &cli.exclude)?;
let doc_style = style.parse::<DocCommentStyle>()
.map_err(|e| anyhow::anyhow!("{}", e))?;
let op = Operation::AddDocComment(AddDocCommentOp {
target_type: target_type.clone(),
name: name.clone(),
doc_comment: doc_comment.clone(),
style: doc_style,
});
execute_operation_with_state(&files, &op, apply, None, &cli.local_state, &cli.format, cli.summary, cli.limit)?;
}
Commands::UpdateDocComment { paths, target_type, name, doc_comment, apply } => {
let files = collect_rust_files_with_exclusions(&paths, &cli.exclude)?;
let op = Operation::UpdateDocComment(UpdateDocCommentOp {
target_type: target_type.clone(),
name: name.clone(),
doc_comment: doc_comment.clone(),
});
execute_operation_with_state(&files, &op, apply, None, &cli.local_state, &cli.format, cli.summary, cli.limit)?;
}
Commands::RemoveDocComment { paths, target_type, name, apply } => {
let files = collect_rust_files_with_exclusions(&paths, &cli.exclude)?;
let op = Operation::RemoveDocComment(RemoveDocCommentOp {
target_type: target_type.clone(),
name: name.clone(),
});
execute_operation_with_state(&files, &op, apply, None, &cli.local_state, &cli.format, cli.summary, cli.limit)?;
}
Commands::FindField { paths, field_name, summary } => {
use operations::{FieldLocation, FieldContext};
let files = collect_rust_files_with_exclusions(&paths, &cli.exclude)?;
let mut all_struct_defs = Vec::new();
let mut all_enum_variants = Vec::new();
let mut all_literals = Vec::new();
for file in files {
let content = std::fs::read_to_string(&file)?;
let editor = match RustEditor::new(&content) {
Ok(e) => e,
Err(e) => {
eprintln!("⚠️ Skipping {}: {}", file.display(), e);
continue;
}
};
let locations = editor.find_field_locations(&field_name)?;
for mut loc in locations {
loc.file_path = file.to_string_lossy().to_string();
match loc.context {
FieldContext::StructDefinition { .. } => all_struct_defs.push(loc),
FieldContext::EnumVariantDefinition { .. } => all_enum_variants.push(loc),
FieldContext::StructLiteral { .. } => all_literals.push(loc),
}
}
}
if all_struct_defs.is_empty() && all_enum_variants.is_empty() && all_literals.is_empty() {
println!("No occurrences of field '{}' found.", field_name);
return Ok(());
}
println!("Found field \"{}\" in:\n", field_name);
if !all_struct_defs.is_empty() {
println!("Struct definitions:");
for loc in &all_struct_defs {
if let FieldContext::StructDefinition { struct_name, field_type } = &loc.context {
println!(" - {}.{}: {} ({}:{})", struct_name, field_name, field_type, loc.file_path, loc.line);
}
}
println!();
}
if !all_enum_variants.is_empty() {
println!("Enum variant definitions:");
for loc in &all_enum_variants {
if let FieldContext::EnumVariantDefinition { enum_name, variant_name, field_type } = &loc.context {
println!(" - {}::{}.{}: {} ({}:{})", enum_name, variant_name, field_name, field_type, loc.file_path, loc.line);
}
}
println!();
}
if !all_literals.is_empty() {
println!("Struct literal expressions: ({} occurrences)", all_literals.len());
let to_show = if summary { 5 } else { all_literals.len() };
for loc in all_literals.iter().take(to_show) {
if let FieldContext::StructLiteral { struct_name } = &loc.context {
println!(" - {} ({}:{})", struct_name, loc.file_path, loc.line);
}
}
if all_literals.len() > to_show {
println!(" ... ({} more)", all_literals.len() - to_show);
}
println!();
}
if !all_struct_defs.is_empty() || !all_enum_variants.is_empty() {
println!("Suggested commands:");
for loc in &all_struct_defs {
if let FieldContext::StructDefinition { struct_name, .. } = &loc.context {
println!(" # Remove from struct definition AND all literals");
println!(" rs-hack remove-struct-field --struct-name \"{}\" --field-name \"{}\" --paths src --apply", struct_name, field_name);
println!();
}
}
for loc in &all_enum_variants {
if let FieldContext::EnumVariantDefinition { enum_name, variant_name, .. } = &loc.context {
println!(" # Remove from enum variant definition AND all literals");
println!(" rs-hack remove-struct-field --struct-name \"{}::{}\" --field-name \"{}\" --paths src --apply", enum_name, variant_name, field_name);
println!();
}
}
}
}
}
Ok(())
}
fn parse_position(pos: &str) -> Result<InsertPosition> {
match pos {
"first" => Ok(InsertPosition::First),
"last" => Ok(InsertPosition::Last),
s if s.starts_with("after:") => {
let name = s.strip_prefix("after:").unwrap().to_string();
Ok(InsertPosition::After(name))
}
s if s.starts_with("before:") => {
let name = s.strip_prefix("before:").unwrap().to_string();
Ok(InsertPosition::Before(name))
}
_ => anyhow::bail!("Invalid position: {}. Use 'first', 'last', 'after:name', or 'before:name'", pos),
}
}
fn print_operation_hints(op: &Operation) {
match op {
Operation::AddMatchArm(match_op) => {
if match_op.auto_detect {
let enum_name = match_op.enum_name.as_deref().unwrap_or("ENUM");
eprintln!("\n💡 Hints for --auto-detect mode:");
eprintln!(" • The enum definition must be in the scanned files");
eprintln!(" • Try: rs-hack find --node-type enum --name {} --paths .", enum_name);
eprintln!(" • If enum is in another crate, try: --paths . --paths ../other_crate/src");
eprintln!(" • For external enums, use --match-arm instead (no --auto-detect):");
eprintln!(" rs-hack add --match-arm \"{}::Variant\" --body \"todo!()\" --paths src", enum_name);
} else {
eprintln!("\n💡 Hints for match arm addition:");
eprintln!(" • Make sure match expressions exist in the scanned files");
eprintln!(" • Pattern should be like: EnumName::Variant or EnumName::Variant {{ .. }}");
eprintln!(" • Try: rs-hack find --node-type match-arm --paths src");
}
}
Operation::AddStructField(field_op) => {
eprintln!("\n💡 Hints:");
eprintln!(" • Try: rs-hack find --node-type struct --name {} --paths .", field_op.struct_name);
}
Operation::AddEnumVariant(variant_op) => {
eprintln!("\n💡 Hints:");
eprintln!(" • Try: rs-hack find --node-type enum --name {} --paths .", variant_op.enum_name);
}
_ => {
eprintln!("\n💡 Hint: Use rs-hack find to verify targets exist in scanned files");
}
}
}
fn execute_operation(
files: &[PathBuf],
op: &Operation,
apply: bool,
output: Option<&PathBuf>,
format: &str,
show_summary: bool,
limit: Option<usize>,
) -> Result<()> {
let opts = rs_hack::execute::ExecuteOpts {
apply,
output: output.cloned(),
limit,
};
let result = rs_hack::execute::execute(files, op, &opts)?;
render_execute_result(&result, op, format, show_summary, apply, output);
Ok(())
}
fn render_execute_result(
result: &rs_hack::execute::ExecuteResult,
op: &Operation,
format: &str,
show_summary: bool,
apply: bool,
output: Option<&PathBuf>,
) {
let mut total_stats = DiffStats::default();
for change in &result.changes {
if format == "diff" {
let stats = print_diff(&change.path, &change.old_content, &change.new_content);
total_stats.add(&stats);
} else if format == "summary" {
let stats = print_summary_diff(&change.path, &change.old_content, &change.new_content);
total_stats.add(&stats);
} else if apply {
if let Some(out) = output {
println!("✓ Written to: {}", out.display());
} else {
println!("✓ Modified: {}", change.path.display());
}
} else if let Some(out) = output {
println!("Would write to: {}", out.display());
} else {
println!("Would modify: {}", change.path.display());
}
}
for (path, err) in &result.parse_errors {
eprintln!("⚠️ Skipping {}: {}", path.display(), err);
}
if result.limit_hit {
println!(
"\n⚠️ Limit reached: {} modifications made",
result.total_modifications
);
}
if !result.parse_errors.is_empty() {
eprintln!(
"\n⚠️ {} file(s) skipped due to parse errors:",
result.parse_errors.len()
);
for (path, err) in &result.parse_errors {
eprintln!(" {} — {}", path.display(), err);
}
}
if !result.unmatched_qualified_paths.is_empty() {
if !result.changes.is_empty() {
println!("\n⚠️ Note: Some instances were not matched:");
}
render_unmatched_paths(&result.unmatched_qualified_paths);
} else if result.changes.is_empty() {
println!("No changes made - target not found in any files");
if let Some(err) = &result.last_error {
eprintln!("\n📋 Diagnostic: {}", err);
}
print_operation_hints(op);
}
if format == "diff" && show_summary {
total_stats.print_summary();
} else if format == "default" && !apply {
println!("\n🔍 Dry run complete. Use --apply to make changes, or --format diff to generate a patch.");
println!("Summary: {} file(s) would be modified", result.changes.len());
}
}
fn render_unmatched_paths(unmatched: &std::collections::HashMap<String, usize>) {
println!(
"\n💡 Hint: Found {} struct literal(s) with fully qualified paths that didn't match:",
unmatched.values().sum::<usize>()
);
let mut paths: Vec<_> = unmatched.iter().collect();
paths.sort_by_key(|(path, _)| *path);
for (path, count) in &paths {
println!(
" {} ({} instance{})",
path,
count,
if **count == 1 { "" } else { "s" }
);
}
println!("\nTo match all of these, use:");
if let Some((first_path, _)) = paths.first() {
if let Some(simple_name) = first_path.split("::").last() {
println!(" rs-hack ... --name \"*::{}\" ...", simple_name);
println!("\nOr match specific paths:");
for (path, _) in paths.iter().take(3) {
println!(" rs-hack ... --name \"{}\" ...", path);
}
if paths.len() > 3 {
println!(" (and {} more...)", paths.len() - 3);
}
}
}
}
fn execute_batch(batch: &BatchSpec, apply: bool, exclude_patterns: &[String]) -> Result<()> {
for op in &batch.operations {
let files = collect_rust_files_with_exclusions(&[batch.base_path.clone()], exclude_patterns)?;
execute_operation(&files, op, apply, None, "default", false, None)?;
}
Ok(())
}
fn execute_operation_with_state(
files: &[PathBuf],
op: &Operation,
apply: bool,
output: Option<&PathBuf>,
local_state: &bool,
format: &str,
show_summary: bool,
limit: Option<usize>,
) -> Result<()> {
let opts = rs_hack::execute::ExecuteOpts {
apply,
output: output.cloned(),
limit,
};
let command = std::env::args().collect::<Vec<_>>().join(" ");
let result =
rs_hack::execute::execute_with_state(files, op, &opts, *local_state, command)?;
if !apply || output.is_some() {
render_execute_result(&result, op, format, show_summary, apply, output);
} else {
render_execute_with_state_result(&result, op, format, show_summary);
}
Ok(())
}
fn render_execute_with_state_result(
result: &rs_hack::execute::ExecuteResult,
op: &Operation,
format: &str,
show_summary: bool,
) {
let mut total_stats = DiffStats::default();
for change in &result.changes {
if format == "diff" {
let stats = print_diff(&change.path, &change.old_content, &change.new_content);
total_stats.add(&stats);
} else if format == "summary" {
let stats = print_summary_diff(&change.path, &change.old_content, &change.new_content);
total_stats.add(&stats);
} else {
println!("✓ Modified: {}", change.path.display());
}
}
for (path, err) in &result.parse_errors {
eprintln!("⚠️ Skipping {}: {}", path.display(), err);
}
if result.limit_hit {
println!(
"\n⚠️ Limit reached: {} modifications made",
result.total_modifications
);
}
if !result.parse_errors.is_empty() {
eprintln!(
"\n⚠️ {} file(s) skipped due to parse errors:",
result.parse_errors.len()
);
for (path, err) in &result.parse_errors {
eprintln!(" {} — {}", path.display(), err);
}
}
if let Some(run_id) = &result.run_id {
if format == "diff" && show_summary {
total_stats.print_summary();
}
println!(
"\n📝 Run ID: {} (use 'rs-hack revert {}' to undo)",
run_id, run_id
);
} else if result.changes.is_empty() {
println!("No changes made - target not found in any files");
if let Some(err) = &result.last_error {
eprintln!("\n📋 Diagnostic: {}", err);
}
print_operation_hints(op);
}
if !result.unmatched_qualified_paths.is_empty() {
if !result.changes.is_empty() {
println!("\n⚠️ Note: Some instances were not matched:");
}
render_unmatched_paths(&result.unmatched_qualified_paths);
}
}