1use anyhow::Result;
10use regex::Regex;
11use serde::{Deserialize, Serialize};
12use std::collections::HashMap;
13use std::fs;
14use std::path::{Path, PathBuf};
15
16use crate::{FileNode, Scanner, ScannerConfig};
17
18#[derive(Debug, Clone, Eq, PartialEq, Hash)]
20pub enum NamingConvention {
21 SnakeCase, CamelCase, PascalCase, KebabCase, TitleCase, UpperCase, LowerCase, DotCase, PathCase, }
31
32#[derive(Debug, Clone, PartialEq)]
34pub enum NameContext {
35 FunctionName,
36 VariableName,
37 ClassName,
38 ModuleName,
39 StringLiteral,
40 Comment,
41 ConfigKey,
42 ConfigValue,
43 DocumentationTitle,
44 FilePath,
45 Url,
46 PackageName,
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct RenameOperation {
52 pub file_path: PathBuf,
53 pub line: usize,
54 pub column: usize,
55 pub old_text: String,
56 pub new_text: String,
57 pub context: String,
58 pub confidence: f32,
59}
60
61#[derive(Debug, Clone)]
63pub struct RenameConfig {
64 pub old_name: String,
65 pub new_name: String,
66 pub dry_run: bool,
67 pub interactive: bool,
68 pub preserve_urls: bool,
69 pub update_comments: bool,
70 pub generate_logo: bool,
71 pub backup: bool,
72}
73
74pub struct ProjectRenamer {
76 config: RenameConfig,
77 operations: Vec<RenameOperation>,
78 name_variants: HashMap<NamingConvention, (String, String)>, }
80
81impl ProjectRenamer {
82 pub fn new(config: RenameConfig) -> Self {
83 let name_variants = Self::generate_name_variants(&config.old_name, &config.new_name);
84
85 Self {
86 config,
87 operations: Vec::new(),
88 name_variants,
89 }
90 }
91
92 fn generate_name_variants(
94 old_name: &str,
95 new_name: &str,
96 ) -> HashMap<NamingConvention, (String, String)> {
97 let mut variants = HashMap::new();
98
99 let old_words = Self::extract_words(old_name);
101 let new_words = Self::extract_words(new_name);
102
103 variants.insert(
105 NamingConvention::SnakeCase,
106 (
107 Self::to_snake_case(&old_words),
108 Self::to_snake_case(&new_words),
109 ),
110 );
111
112 variants.insert(
113 NamingConvention::CamelCase,
114 (
115 Self::to_camel_case(&old_words),
116 Self::to_camel_case(&new_words),
117 ),
118 );
119
120 variants.insert(
121 NamingConvention::PascalCase,
122 (
123 Self::to_pascal_case(&old_words),
124 Self::to_pascal_case(&new_words),
125 ),
126 );
127
128 variants.insert(
129 NamingConvention::KebabCase,
130 (
131 Self::to_kebab_case(&old_words),
132 Self::to_kebab_case(&new_words),
133 ),
134 );
135
136 variants.insert(
137 NamingConvention::TitleCase,
138 (
139 Self::to_title_case(&old_words),
140 Self::to_title_case(&new_words),
141 ),
142 );
143
144 variants.insert(
145 NamingConvention::UpperCase,
146 (
147 Self::to_upper_case(&old_words),
148 Self::to_upper_case(&new_words),
149 ),
150 );
151
152 variants.insert(
153 NamingConvention::LowerCase,
154 (
155 Self::to_lower_case(&old_words),
156 Self::to_lower_case(&new_words),
157 ),
158 );
159
160 variants
161 }
162
163 fn extract_words(name: &str) -> Vec<String> {
165 let mut words = Vec::new();
166 let mut current_word = String::new();
167 let mut prev_is_lower = false;
168
169 for ch in name.chars() {
170 if ch.is_uppercase() && prev_is_lower && !current_word.is_empty() {
171 words.push(current_word.to_lowercase());
173 current_word = ch.to_string();
174 prev_is_lower = false;
175 } else if ch == '_' || ch == '-' || ch == ' ' || ch == '.' || ch == '/' {
176 if !current_word.is_empty() {
178 words.push(current_word.to_lowercase());
179 current_word.clear();
180 }
181 prev_is_lower = false;
182 } else {
183 current_word.push(ch);
184 prev_is_lower = ch.is_lowercase();
185 }
186 }
187
188 if !current_word.is_empty() {
189 words.push(current_word.to_lowercase());
190 }
191
192 words
193 }
194
195 fn to_snake_case(words: &[String]) -> String {
196 words.join("_")
197 }
198
199 fn to_camel_case(words: &[String]) -> String {
200 words
201 .iter()
202 .enumerate()
203 .map(|(i, word)| {
204 if i == 0 {
205 word.clone()
206 } else {
207 Self::capitalize(word)
208 }
209 })
210 .collect()
211 }
212
213 fn to_pascal_case(words: &[String]) -> String {
214 words.iter().map(|word| Self::capitalize(word)).collect()
215 }
216
217 fn to_kebab_case(words: &[String]) -> String {
218 words.join("-")
219 }
220
221 fn to_title_case(words: &[String]) -> String {
222 words
223 .iter()
224 .map(|word| Self::capitalize(word))
225 .collect::<Vec<_>>()
226 .join(" ")
227 }
228
229 fn to_upper_case(words: &[String]) -> String {
230 words.join("_").to_uppercase()
231 }
232
233 fn to_lower_case(words: &[String]) -> String {
234 words.join(" ")
235 }
236
237 fn capitalize(word: &str) -> String {
238 let mut chars = word.chars();
239 match chars.next() {
240 None => String::new(),
241 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
242 }
243 }
244
245 pub async fn scan_project(&mut self, project_path: &Path) -> Result<()> {
247 println!(
248 "š Scanning for legacy references to \"{}\"...",
249 self.config.old_name
250 );
251
252 let scanner_config = ScannerConfig {
253 max_depth: 100,
254 follow_symlinks: false,
255 respect_gitignore: true,
256 show_hidden: false,
257 show_ignored: false,
258 find_pattern: None,
259 file_type_filter: None,
260 entry_type_filter: None,
261 min_size: None,
262 max_size: Some(10 * 1024 * 1024), newer_than: None,
264 older_than: None,
265 use_default_ignores: true,
266 search_keyword: None,
267 show_filesystems: false,
268 sort_field: None,
269 top_n: None,
270 include_line_content: false,
271 compute_interest: false,
273 security_scan: false,
274 min_interest: 0.0,
275 track_traversal: false,
276 changes_only: false,
277 compare_state: None,
278 smart_mode: false,
279 };
280
281 let scanner = Scanner::new(project_path, scanner_config)?;
282 let (nodes, _stats) = scanner.scan()?;
283
284 for node in nodes {
286 if !node.is_dir && !node.is_symlink {
287 self.scan_file(&node).await?;
288 }
289 }
290
291 Ok(())
292 }
293
294 async fn scan_file(&mut self, node: &FileNode) -> Result<()> {
296 let content = match fs::read_to_string(&node.path) {
297 Ok(content) => content,
298 Err(_) => return Ok(()), };
300
301 let file_type = Self::detect_file_type(&node.path);
302
303 let variants = self.name_variants.clone();
305 for (convention, (old_variant, new_variant)) in &variants {
306 self.find_occurrences_in_content(
307 &node.path,
308 &content,
309 old_variant,
310 new_variant,
311 &file_type,
312 convention,
313 )?;
314 }
315
316 Ok(())
317 }
318
319 fn detect_file_type(path: &Path) -> FileType {
321 let extension = path.extension().and_then(|e| e.to_str()).unwrap_or("");
322 let filename = path.file_name().and_then(|f| f.to_str()).unwrap_or("");
323
324 match extension {
325 "rs" => FileType::Rust,
326 "py" => FileType::Python,
327 "js" | "jsx" | "ts" | "tsx" => FileType::JavaScript,
328 "go" => FileType::Go,
329 "java" => FileType::Java,
330 "toml" => FileType::Toml,
331 "yaml" | "yml" => FileType::Yaml,
332 "json" => FileType::Json,
333 "md" => FileType::Markdown,
334 "desktop" => FileType::Desktop,
335 _ => {
336 match filename {
338 "Cargo.toml" => FileType::Toml,
339 "package.json" => FileType::Json,
340 "README.md" | "README" => FileType::Markdown,
341 _ => FileType::Unknown,
342 }
343 }
344 }
345 }
346
347 fn find_occurrences_in_content(
349 &mut self,
350 file_path: &Path,
351 content: &str,
352 old_variant: &str,
353 new_variant: &str,
354 file_type: &FileType,
355 _convention: &NamingConvention,
356 ) -> Result<()> {
357 let patterns = self.build_context_patterns(old_variant, file_type, _convention);
359
360 for (pattern, context) in patterns {
361 let re = Regex::new(&pattern)?;
362
363 for (line_no, line) in content.lines().enumerate() {
364 for mat in re.find_iter(line) {
365 let operation = RenameOperation {
366 file_path: file_path.to_path_buf(),
367 line: line_no + 1,
368 column: mat.start() + 1,
369 old_text: mat.as_str().to_string(),
370 new_text: self.calculate_replacement(
371 mat.as_str(),
372 old_variant,
373 new_variant,
374 &context,
375 ),
376 context: format!("{:?} in {:?}", context, file_type),
377 confidence: self.calculate_confidence(&context, file_type),
378 };
379
380 self.operations.push(operation);
381 }
382 }
383 }
384
385 Ok(())
386 }
387
388 fn build_context_patterns(
390 &self,
391 variant: &str,
392 file_type: &FileType,
393 _convention: &NamingConvention,
394 ) -> Vec<(String, NameContext)> {
395 let escaped = regex::escape(variant);
396 let mut patterns = Vec::new();
397
398 match file_type {
399 FileType::Rust => {
400 patterns.push((format!(r"\bfn\s+{}\b", escaped), NameContext::FunctionName));
402 patterns.push((
404 format!(r"\b(struct|enum|trait)\s+{}\b", escaped),
405 NameContext::ClassName,
406 ));
407 patterns.push((
409 format!(r"\b(let|const|static)\s+.*{}\b", escaped),
410 NameContext::VariableName,
411 ));
412 patterns.push((format!(r"\bmod\s+{}\b", escaped), NameContext::ModuleName));
414 }
415 FileType::Python => {
416 patterns.push((
417 format!(r"\b(def|class)\s+{}\b", escaped),
418 NameContext::FunctionName,
419 ));
420 }
421 FileType::Toml | FileType::Yaml | FileType::Json => {
422 patterns.push((
424 format!(r#"(name|package)\s*[=:]\s*"?{}"?"#, escaped),
425 NameContext::PackageName,
426 ));
427 }
428 FileType::Markdown => {
429 patterns.push((
431 format!(r"^#+\s*.*{}", escaped),
432 NameContext::DocumentationTitle,
433 ));
434 }
435 _ => {}
436 }
437
438 patterns.push((format!(r#""{}"#, escaped), NameContext::StringLiteral));
440 patterns.push((format!(r"//.*{}", escaped), NameContext::Comment));
441
442 patterns
443 }
444
445 fn calculate_replacement(
447 &self,
448 matched_text: &str,
449 old_variant: &str,
450 new_variant: &str,
451 _context: &NameContext,
452 ) -> String {
453 matched_text.replace(old_variant, new_variant)
456 }
457
458 fn calculate_confidence(&self, context: &NameContext, _file_type: &FileType) -> f32 {
460 match context {
461 NameContext::FunctionName | NameContext::ClassName | NameContext::ModuleName => 0.95,
462 NameContext::VariableName => 0.85,
463 NameContext::StringLiteral => 0.8,
464 NameContext::PackageName => 0.9,
465 NameContext::DocumentationTitle => 0.9,
466 NameContext::Comment => 0.7,
467 _ => 0.6,
468 }
469 }
470
471 pub async fn apply_renames(&self) -> Result<()> {
473 if self.operations.is_empty() {
474 println!("No renaming operations found.");
475 return Ok(());
476 }
477
478 let mut ops_by_file: HashMap<PathBuf, Vec<&RenameOperation>> = HashMap::new();
480 for op in &self.operations {
481 ops_by_file
482 .entry(op.file_path.clone())
483 .or_default()
484 .push(op);
485 }
486
487 for (file_path, ops) in ops_by_file {
488 self.apply_file_renames(&file_path, ops).await?;
489 }
490
491 Ok(())
492 }
493
494 async fn apply_file_renames(
496 &self,
497 file_path: &Path,
498 operations: Vec<&RenameOperation>,
499 ) -> Result<()> {
500 let content = fs::read_to_string(file_path)?;
501 let mut new_content = content.clone();
502
503 let mut sorted_ops = operations;
505 sorted_ops.sort_by(|a, b| b.line.cmp(&a.line).then(b.column.cmp(&a.column)));
506
507 for op in sorted_ops {
508 new_content = new_content.replace(&op.old_text, &op.new_text);
511 }
512
513 if self.config.backup {
514 let backup_path = file_path.with_extension(format!(
515 "{}.bak",
516 file_path
517 .extension()
518 .unwrap_or_default()
519 .to_str()
520 .unwrap_or("")
521 ));
522 fs::copy(file_path, backup_path)?;
523 }
524
525 fs::write(file_path, new_content)?;
526 Ok(())
527 }
528
529 pub fn show_summary(&self) {
531 println!("\nā
Found {} matches across:", self.operations.len());
532
533 let mut file_counts: HashMap<String, usize> = HashMap::new();
535 for op in &self.operations {
536 let ext = op
537 .file_path
538 .extension()
539 .and_then(|e| e.to_str())
540 .unwrap_or("other");
541 *file_counts.entry(ext.to_string()).or_default() += 1;
542 }
543
544 for (ext, count) in file_counts {
545 println!(" - {} {} files", count, ext);
546 }
547
548 println!("\nšØ Context-aware replacements:");
549 println!(
550 " ⢠Identifiers ā `{}`",
551 self.name_variants
552 .get(&NamingConvention::SnakeCase)
553 .unwrap()
554 .1
555 );
556 println!(" ⢠Strings ā \"{}\"", self.config.new_name);
557 println!(
558 " ⢠Titles ā `{}`",
559 self.name_variants
560 .get(&NamingConvention::TitleCase)
561 .unwrap()
562 .1
563 );
564 println!(" ⢠Comments ā updated branding");
565
566 println!("\nš”ļø Safety net enabled: changes wrapped in diff mode");
567 }
568}
569
570#[derive(Debug, Clone, PartialEq)]
572enum FileType {
573 Rust,
574 Python,
575 JavaScript,
576 Go,
577 Java,
578 Toml,
579 Yaml,
580 Json,
581 Markdown,
582 Desktop,
583 Unknown,
584}
585
586pub enum UserChoice {
588 Preview,
589 Commit,
590 Edit,
591 Cancel,
592}
593
594impl ProjectRenamer {
595 pub async fn run_interactive(&mut self) -> Result<UserChoice> {
597 println!("\nWould you like to:");
598 println!("[1] Preview changes");
599 println!("[2] Commit rename");
600 println!("[3] Edit before apply");
601 println!("[4] Cancel");
602
603 Ok(UserChoice::Preview)
605 }
606
607 pub fn show_preview(&self) {
609 for (i, op) in self.operations.iter().take(10).enumerate() {
610 println!("\n{}) {}:{}", i + 1, op.file_path.display(), op.line);
611 println!(" {} ā {}", op.old_text, op.new_text);
612 println!(
613 " Context: {} (confidence: {:.0}%)",
614 op.context,
615 op.confidence * 100.0
616 );
617 }
618
619 if self.operations.len() > 10 {
620 println!("\n... and {} more changes", self.operations.len() - 10);
621 }
622 }
623}
624
625pub async fn rename_project(old_name: &str, new_name: &str, options: RenameOptions) -> Result<()> {
627 println!("š Project Rebranding Ritual");
628 println!("āāāāāāāāāāāāāāāāāāāāāāāāāāāāā");
629
630 let config = RenameConfig {
631 old_name: old_name.to_string(),
632 new_name: new_name.to_string(),
633 dry_run: options.dry_run,
634 interactive: options.interactive,
635 preserve_urls: options.preserve_urls,
636 update_comments: options.update_comments,
637 generate_logo: options.generate_logo,
638 backup: options.backup,
639 };
640
641 let mut renamer = ProjectRenamer::new(config);
642
643 let project_path = std::env::current_dir()?;
645 renamer.scan_project(&project_path).await?;
646
647 renamer.show_summary();
649
650 if options.interactive {
652 match renamer.run_interactive().await? {
653 UserChoice::Preview => {
654 renamer.show_preview();
655 }
656 UserChoice::Commit => {
657 if !options.dry_run {
658 renamer.apply_renames().await?;
659 println!("\n⨠Project successfully rebranded!");
660 }
661 }
662 UserChoice::Edit => {
663 println!("Edit mode not yet implemented");
665 }
666 UserChoice::Cancel => {
667 println!("Rename cancelled.");
668 }
669 }
670 } else if !options.dry_run {
671 renamer.apply_renames().await?;
672 println!("\n⨠Project successfully rebranded!");
673 }
674
675 if options.generate_logo {
677 generate_placeholder_logo(new_name)?;
678 }
679
680 Ok(())
681}
682
683#[derive(Debug, Clone)]
685pub struct RenameOptions {
686 pub dry_run: bool,
687 pub interactive: bool,
688 pub preserve_urls: bool,
689 pub update_comments: bool,
690 pub generate_logo: bool,
691 pub backup: bool,
692}
693
694impl Default for RenameOptions {
695 fn default() -> Self {
696 Self {
697 dry_run: false,
698 interactive: true,
699 preserve_urls: true,
700 update_comments: true,
701 generate_logo: false,
702 backup: true,
703 }
704 }
705}
706
707fn generate_placeholder_logo(project_name: &str) -> Result<()> {
709 let svg = format!(
710 r##"<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" viewBox="0 0 200 200">
711 <rect width="200" height="200" fill="#1a1a1a"/>
712 <text x="100" y="100" font-family="Arial, sans-serif" font-size="24" fill="#ffffff" text-anchor="middle" dominant-baseline="middle">
713 {}
714 </text>
715</svg>"##,
716 project_name
717 );
718
719 fs::write("assets/logo.svg", svg)?;
720 println!("\nšØ Generated placeholder logo at assets/logo.svg");
721
722 Ok(())
723}