use crate::cli;
use crate::commands::auto_compact_if_needed;
use crate::commands_search::is_binary_extension;
use crate::docs;
use crate::format::*;
use crate::prompt::*;
use std::sync::RwLock;
use yoagent::agent::Agent;
use yoagent::*;
#[derive(Debug, Clone, PartialEq)]
pub enum TodoStatus {
Pending,
InProgress,
Done,
}
impl std::fmt::Display for TodoStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
TodoStatus::Pending => write!(f, "[ ]"),
TodoStatus::InProgress => write!(f, "[~]"),
TodoStatus::Done => write!(f, "[✓]"),
}
}
}
#[derive(Debug, Clone)]
pub struct TodoItem {
pub id: usize,
pub description: String,
pub status: TodoStatus,
}
static TODO_LIST: RwLock<Vec<TodoItem>> = RwLock::new(Vec::new());
static TODO_NEXT_ID: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(1);
pub fn todo_add(description: &str) -> usize {
let id = TODO_NEXT_ID.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
let item = TodoItem {
id,
description: description.to_string(),
status: TodoStatus::Pending,
};
TODO_LIST.write().unwrap().push(item);
id
}
pub fn todo_update(id: usize, status: TodoStatus) -> Result<(), String> {
let mut list = TODO_LIST.write().unwrap();
match list.iter_mut().find(|item| item.id == id) {
Some(item) => {
item.status = status;
Ok(())
}
None => Err(format!("No todo item with ID {id}")),
}
}
pub fn todo_list() -> Vec<TodoItem> {
TODO_LIST.read().unwrap().clone()
}
pub fn todo_clear() {
TODO_LIST.write().unwrap().clear();
TODO_NEXT_ID.store(1, std::sync::atomic::Ordering::SeqCst);
}
pub fn todo_remove(id: usize) -> Result<TodoItem, String> {
let mut list = TODO_LIST.write().unwrap();
let pos = list
.iter()
.position(|item| item.id == id)
.ok_or_else(|| format!("No todo item with ID {id}"))?;
Ok(list.remove(pos))
}
pub fn format_todo_list(items: &[TodoItem]) -> String {
if items.is_empty() {
return " No tasks. Use /todo add <description> to add one.".to_string();
}
let mut out = String::new();
for item in items {
out.push_str(&format!(
" {} #{} {}\n",
item.status, item.id, item.description
));
}
if out.ends_with('\n') {
out.truncate(out.len() - 1);
}
out
}
pub fn handle_todo(input: &str) -> String {
let arg = input.strip_prefix("/todo").unwrap_or("").trim();
if arg.is_empty() {
let items = todo_list();
return format_todo_list(&items);
}
if arg == "clear" {
todo_clear();
return format!("{GREEN} ✓ Cleared all tasks{RESET}");
}
if let Some(desc) = arg.strip_prefix("add ") {
let desc = desc.trim();
if desc.is_empty() {
return " Usage: /todo add <description>".to_string();
}
let id = todo_add(desc);
return format!("{GREEN} ✓ Added task #{id}: {desc}{RESET}");
}
if arg == "add" {
return " Usage: /todo add <description>".to_string();
}
if let Some(id_str) = arg.strip_prefix("done ") {
let id_str = id_str.trim();
match id_str.parse::<usize>() {
Ok(id) => match todo_update(id, TodoStatus::Done) {
Ok(()) => return format!("{GREEN} ✓ Marked #{id} as done{RESET}"),
Err(e) => return format!("{RED} {e}{RESET}"),
},
Err(_) => return format!("{RED} Invalid ID: {id_str}{RESET}"),
}
}
if let Some(id_str) = arg.strip_prefix("wip ") {
let id_str = id_str.trim();
match id_str.parse::<usize>() {
Ok(id) => match todo_update(id, TodoStatus::InProgress) {
Ok(()) => return format!("{GREEN} ✓ Marked #{id} as in-progress{RESET}"),
Err(e) => return format!("{RED} {e}{RESET}"),
},
Err(_) => return format!("{RED} Invalid ID: {id_str}{RESET}"),
}
}
if let Some(id_str) = arg.strip_prefix("remove ") {
let id_str = id_str.trim();
match id_str.parse::<usize>() {
Ok(id) => match todo_remove(id) {
Ok(item) => {
return format!("{GREEN} ✓ Removed #{id}: {}{RESET}", item.description)
}
Err(e) => return format!("{RED} {e}{RESET}"),
},
Err(_) => return format!("{RED} Invalid ID: {id_str}{RESET}"),
}
}
" Usage:\n\
\x20 /todo Show all tasks\n\
\x20 /todo add <description> Add a new task\n\
\x20 /todo done <id> Mark task as done\n\
\x20 /todo wip <id> Mark as in-progress\n\
\x20 /todo remove <id> Remove a task\n\
\x20 /todo clear Clear all tasks"
.to_string()
}
pub fn handle_context() {
let files = cli::list_project_context_files();
if files.is_empty() {
println!("{DIM} No project context files found.");
println!(" Create a YOYO.md to give yoyo project context.");
println!(" Also supports: CLAUDE.md (compatibility alias), .yoyo/instructions.md");
println!(" Run /init to create a starter YOYO.md.{RESET}\n");
} else {
println!("{DIM} Project context files:");
for (name, lines) in &files {
let word = crate::format::pluralize(*lines, "line", "lines");
println!(" {name} ({lines} {word})");
}
println!("{RESET}");
}
}
pub fn scan_important_files(dir: &std::path::Path) -> Vec<String> {
let candidates = [
"README.md",
"README",
"readme.md",
"LICENSE",
"LICENSE.md",
"CHANGELOG.md",
"CONTRIBUTING.md",
".gitignore",
".editorconfig",
"Cargo.toml",
"Cargo.lock",
"rust-toolchain.toml",
"package.json",
"package-lock.json",
"tsconfig.json",
".eslintrc.json",
".eslintrc.js",
".prettierrc",
"pyproject.toml",
"setup.py",
"setup.cfg",
"requirements.txt",
"Pipfile",
"tox.ini",
"go.mod",
"go.sum",
"Makefile",
"Dockerfile",
"docker-compose.yml",
"docker-compose.yaml",
".dockerignore",
".github/workflows",
".gitlab-ci.yml",
".circleci/config.yml",
".travis.yml",
"Jenkinsfile",
];
candidates
.iter()
.filter(|f| dir.join(f).exists())
.map(|f| f.to_string())
.collect()
}
pub fn scan_important_dirs(dir: &std::path::Path) -> Vec<String> {
let candidates = [
"src",
"lib",
"tests",
"test",
"docs",
"doc",
"examples",
"benches",
"scripts",
".github",
".vscode",
"config",
"public",
"static",
"assets",
"migrations",
];
candidates
.iter()
.filter(|d| dir.join(d).is_dir())
.map(|d| d.to_string())
.collect()
}
pub fn build_commands_for_project(project_type: &ProjectType) -> Vec<(&'static str, &'static str)> {
match project_type {
ProjectType::Rust => vec![
("Build", "cargo build"),
("Test", "cargo test"),
("Lint", "cargo clippy --all-targets -- -D warnings"),
("Format check", "cargo fmt -- --check"),
("Format", "cargo fmt"),
],
ProjectType::Node => vec![
("Install", "npm install"),
("Test", "npm test"),
("Lint", "npx eslint ."),
],
ProjectType::Python => vec![
("Test", "python -m pytest"),
("Lint", "ruff check ."),
("Type check", "python -m mypy ."),
],
ProjectType::Go => vec![
("Build", "go build ./..."),
("Test", "go test ./..."),
("Vet", "go vet ./..."),
],
ProjectType::Make => vec![("Build", "make"), ("Test", "make test")],
ProjectType::Unknown => vec![],
}
}
fn extract_project_name_from_readme(dir: &std::path::Path) -> Option<String> {
let readme_names = ["README.md", "readme.md", "README"];
for name in &readme_names {
if let Ok(content) = std::fs::read_to_string(dir.join(name)) {
for line in content.lines() {
let trimmed = line.trim();
if let Some(title) = trimmed.strip_prefix("# ") {
let title = title.trim();
if !title.is_empty() {
return Some(title.to_string());
}
}
}
}
}
None
}
fn extract_name_from_cargo_toml(dir: &std::path::Path) -> Option<String> {
let content = std::fs::read_to_string(dir.join("Cargo.toml")).ok()?;
for line in content.lines() {
let trimmed = line.trim();
if let Some(rest) = trimmed.strip_prefix("name") {
let rest = rest.trim();
if let Some(rest) = rest.strip_prefix('=') {
let val = rest.trim().trim_matches('"').trim_matches('\'');
if !val.is_empty() {
return Some(val.to_string());
}
}
}
}
None
}
fn extract_name_from_package_json(dir: &std::path::Path) -> Option<String> {
let content = std::fs::read_to_string(dir.join("package.json")).ok()?;
for line in content.lines() {
let trimmed = line.trim().trim_end_matches(',');
if let Some(rest) = trimmed.strip_prefix("\"name\"") {
let rest = rest.trim();
if let Some(rest) = rest.strip_prefix(':') {
let val = rest.trim().trim_matches('"');
if !val.is_empty() {
return Some(val.to_string());
}
}
}
}
None
}
pub fn detect_project_name(dir: &std::path::Path) -> String {
if let Some(name) = extract_name_from_cargo_toml(dir) {
return name;
}
if let Some(name) = extract_name_from_package_json(dir) {
return name;
}
if let Some(name) = extract_project_name_from_readme(dir) {
return name;
}
dir.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "my-project".to_string())
}
pub fn generate_init_content(dir: &std::path::Path) -> String {
let project_type = detect_project_type(dir);
let project_name = detect_project_name(dir);
let important_files = scan_important_files(dir);
let important_dirs = scan_important_dirs(dir);
let build_commands = build_commands_for_project(&project_type);
let mut content = String::new();
content.push_str("# Project Context\n\n");
content.push_str("<!-- YOYO.md — generated by `yoyo /init`. Edit to customize. -->\n");
content.push_str("<!-- Also works as CLAUDE.md for compatibility with other tools. -->\n\n");
content.push_str("## About This Project\n\n");
content.push_str(&format!("**{project_name}**"));
if project_type != ProjectType::Unknown {
content.push_str(&format!(" — {project_type} project"));
}
content.push_str("\n\n");
content.push_str("<!-- Add a description of what this project does. -->\n\n");
content.push_str("## Build & Test\n\n");
if build_commands.is_empty() {
content.push_str("<!-- Add build, test, and run commands for this project. -->\n\n");
} else {
content.push_str("```bash\n");
for (label, cmd) in &build_commands {
content.push_str(&format!("{cmd:<50} # {label}\n"));
}
content.push_str("```\n\n");
}
content.push_str("## Coding Conventions\n\n");
content.push_str(
"<!-- List any coding standards, naming conventions, or patterns to follow. -->\n\n",
);
content.push_str("## Important Files\n\n");
if important_files.is_empty() && important_dirs.is_empty() {
content.push_str("<!-- List key files and directories the agent should know about. -->\n");
} else {
if !important_dirs.is_empty() {
content.push_str("Key directories:\n");
for d in &important_dirs {
content.push_str(&format!("- `{d}/`\n"));
}
content.push('\n');
}
if !important_files.is_empty() {
content.push_str("Key files:\n");
for f in &important_files {
content.push_str(&format!("- `{f}`\n"));
}
content.push('\n');
}
}
content
}
pub fn handle_init() {
let path = "YOYO.md";
if std::path::Path::new(path).exists() {
println!("{DIM} {path} already exists — not overwriting.{RESET}\n");
} else if std::path::Path::new("CLAUDE.md").exists() {
println!("{DIM} CLAUDE.md already exists — yoyo reads it as a compatibility alias.");
println!(" Rename it to YOYO.md when you're ready: mv CLAUDE.md YOYO.md{RESET}\n");
} else {
let cwd = std::env::current_dir().unwrap_or_default();
let project_type = detect_project_type(&cwd);
println!("{DIM} Scanning project...{RESET}");
if project_type != ProjectType::Unknown {
println!("{DIM} Detected: {project_type}{RESET}");
}
let content = generate_init_content(&cwd);
match std::fs::write(path, &content) {
Ok(_) => {
let line_count = content.lines().count();
let word = crate::format::pluralize(line_count, "line", "lines");
println!("{GREEN} ✓ Created {path} ({line_count} {word}) — edit it to add project context.{RESET}");
println!("{DIM} Tip: Use /remember to save project-specific notes that persist across sessions.{RESET}\n");
}
Err(e) => eprintln!("{RED} error creating {path}: {e}{RESET}\n"),
}
}
}
pub fn handle_docs(input: &str) {
if input == "/docs" {
println!("{DIM} usage: /docs <crate> [item]");
println!(" Look up docs.rs documentation for a Rust crate.");
println!(" Examples: /docs serde, /docs tokio task{RESET}\n");
return;
}
let args = input.trim_start_matches("/docs ").trim();
if args.is_empty() {
println!("{DIM} usage: /docs <crate> [item]{RESET}\n");
return;
}
let parts: Vec<&str> = args.splitn(2, char::is_whitespace).collect();
let crate_name = parts[0].trim();
let item_name = parts.get(1).map(|s| s.trim()).unwrap_or("");
let (found, summary) = if item_name.is_empty() {
docs::fetch_docs_summary(crate_name)
} else {
docs::fetch_docs_item(crate_name, item_name)
};
if found {
let label = if item_name.is_empty() {
crate_name.to_string()
} else {
format!("{crate_name}::{item_name}")
};
println!("{GREEN} ✓ {label}{RESET}");
println!("{DIM}{summary}{RESET}\n");
} else {
println!("{RED} ✗ {summary}{RESET}\n");
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum ProjectType {
Rust,
Node,
Python,
Go,
Make,
Unknown,
}
impl std::fmt::Display for ProjectType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ProjectType::Rust => write!(f, "Rust (Cargo)"),
ProjectType::Node => write!(f, "Node.js (npm)"),
ProjectType::Python => write!(f, "Python"),
ProjectType::Go => write!(f, "Go"),
ProjectType::Make => write!(f, "Makefile"),
ProjectType::Unknown => write!(f, "Unknown"),
}
}
}
pub fn detect_project_type(dir: &std::path::Path) -> ProjectType {
if dir.join("Cargo.toml").exists() {
ProjectType::Rust
} else if dir.join("package.json").exists() {
ProjectType::Node
} else if dir.join("pyproject.toml").exists()
|| dir.join("setup.py").exists()
|| dir.join("setup.cfg").exists()
{
ProjectType::Python
} else if dir.join("go.mod").exists() {
ProjectType::Go
} else if dir.join("Makefile").exists() || dir.join("makefile").exists() {
ProjectType::Make
} else {
ProjectType::Unknown
}
}
pub fn parse_plan_task(input: &str) -> Option<String> {
let task = input.strip_prefix("/plan").unwrap_or("").trim().to_string();
if task.is_empty() {
None
} else {
Some(task)
}
}
pub fn build_plan_prompt(task: &str) -> String {
format!(
r#"Create a detailed step-by-step plan for the following task. Do NOT execute any tools — this is planning only.
## Task
{task}
## Instructions
Analyze the task and produce a structured plan covering:
1. **Files to examine** — which existing files need to be read to understand the current state
2. **Files to modify** — which files will be created or changed, and what changes
3. **Step-by-step approach** — ordered list of concrete implementation steps
4. **Tests to write** — what tests should be added or updated
5. **Potential risks** — what could go wrong, edge cases, backwards compatibility concerns
6. **Verification** — how to confirm the changes work correctly
Be specific: mention file paths, function names, and concrete code changes where possible.
Keep the plan actionable — someone (or you, in the next step) should be able to execute it directly."#
)
}
pub async fn handle_plan(
input: &str,
agent: &mut Agent,
session_total: &mut Usage,
model: &str,
) -> Option<String> {
let task = match parse_plan_task(input) {
Some(t) => t,
None => {
println!("{DIM} usage: /plan <task description>{RESET}");
println!("{DIM} Creates a step-by-step plan without executing any tools.{RESET}");
println!("{DIM} Review the plan, then say \"go ahead\" to execute it.{RESET}\n");
return None;
}
};
println!("{DIM} 📋 Planning: {task}{RESET}\n");
let plan_prompt = build_plan_prompt(&task);
run_prompt(agent, &plan_prompt, session_total, model).await;
auto_compact_if_needed(agent);
println!(
"\n{DIM} 💡 Review the plan above. Say \"go ahead\" to execute it, or refine it.{RESET}\n"
);
Some(plan_prompt)
}
pub fn parse_extract_args(input: &str) -> Option<(String, String, String)> {
let rest = input.strip_prefix("/extract").unwrap_or(input).trim();
let parts: Vec<&str> = rest.split_whitespace().collect();
if parts.len() == 3 {
Some((
parts[0].to_string(),
parts[1].to_string(),
parts[2].to_string(),
))
} else {
None
}
}
pub fn find_symbol_block(source: &str, symbol: &str) -> Option<(usize, usize, String)> {
let lines: Vec<&str> = source.lines().collect();
let keyword_patterns: Vec<String> = vec![
format!("fn {symbol}"),
format!("struct {symbol}"),
format!("enum {symbol}"),
format!("impl {symbol}"),
format!("trait {symbol}"),
format!("type {symbol}"),
format!("const {symbol}"),
format!("static mut {symbol}"),
format!("static {symbol}"),
];
let mut decl_line = None;
for (i, line) in lines.iter().enumerate() {
let trimmed = line.trim();
if trimmed.starts_with("//") || trimmed.starts_with('*') || trimmed.starts_with("/*") {
continue;
}
for pat in &keyword_patterns {
if let Some(pos) = trimmed.find(pat.as_str()) {
let after = pos + pat.len();
if after >= trimmed.len()
|| !trimmed.as_bytes()[after].is_ascii_alphanumeric()
&& trimmed.as_bytes()[after] != b'_'
{
let before = &trimmed[..pos];
let is_valid_prefix = before.is_empty()
|| before.trim_end().is_empty()
|| before.trim_end() == "pub"
|| before.trim_end().starts_with("pub(")
|| before.trim_end() == "async"
|| before.trim_end() == "pub async"
|| before.trim_end() == "unsafe"
|| before.trim_end() == "pub unsafe";
if is_valid_prefix {
decl_line = Some(i);
break;
}
}
}
}
if decl_line.is_some() {
break;
}
}
let decl_line = decl_line?;
let mut start_line = decl_line;
while start_line > 0 {
let prev = lines[start_line - 1].trim();
if prev.starts_with("///")
|| prev.starts_with("#[")
|| prev.starts_with("#![")
|| prev.starts_with("//!")
{
start_line -= 1;
} else {
break;
}
}
let decl_trimmed = lines[decl_line].trim();
if decl_trimmed.ends_with(';') {
let block: String = lines[start_line..=decl_line].join("\n");
return Some((start_line, decl_line, block));
}
let mut depth: i32 = 0;
let mut found_open = false;
let mut end_line = decl_line;
for (i, line) in lines.iter().enumerate().skip(decl_line) {
for ch in line.chars() {
if ch == '{' {
depth += 1;
found_open = true;
} else if ch == '}' {
depth -= 1;
}
}
end_line = i;
if found_open && depth == 0 {
break;
}
}
if !found_open {
let has_semi = lines[decl_line..=end_line].iter().any(|l| l.contains(';'));
if !has_semi {
return None;
}
for (idx, line) in lines.iter().enumerate().take(end_line + 1).skip(decl_line) {
if line.contains(';') {
end_line = idx;
break;
}
}
}
let block: String = lines[start_line..=end_line].join("\n");
Some((start_line, end_line, block))
}
pub fn extract_symbol(
source_path: &str,
target_path: &str,
symbol: &str,
) -> Result<String, String> {
let source_content = std::fs::read_to_string(source_path)
.map_err(|e| format!("Cannot read source file '{source_path}': {e}"))?;
let (start_line, end_line, block_text) = find_symbol_block(&source_content, symbol)
.ok_or_else(|| format!("Symbol '{symbol}' not found in '{source_path}'"))?;
let target_content = std::fs::read_to_string(target_path).unwrap_or_default();
let is_pub = block_text.trim_start().starts_with("pub ")
|| block_text.trim_start().starts_with("/// ")
&& block_text.contains(&format!("pub fn {symbol}"))
|| block_text.trim_start().starts_with("#[")
&& block_text.contains(&format!("pub fn {symbol}"))
|| block_text.trim_start().starts_with("pub(")
|| block_text.contains(&format!("pub struct {symbol}"))
|| block_text.contains(&format!("pub enum {symbol}"))
|| block_text.contains(&format!("pub trait {symbol}"))
|| block_text.contains(&format!("pub type {symbol}"))
|| block_text.contains(&format!("pub const {symbol}"))
|| block_text.contains(&format!("pub static {symbol}"));
let source_lines: Vec<&str> = source_content.lines().collect();
let mut new_source_lines: Vec<&str> = Vec::new();
let mut i = 0;
while i < source_lines.len() {
if i >= start_line && i <= end_line {
i += 1;
continue;
}
new_source_lines.push(source_lines[i]);
i += 1;
}
let mut new_source = new_source_lines.join("\n");
if !new_source.ends_with('\n') {
new_source.push('\n');
}
let mut new_target = target_content.clone();
if !new_target.is_empty() && !new_target.ends_with('\n') {
new_target.push('\n');
}
if !new_target.is_empty() {
new_target.push('\n');
}
new_target.push_str(&block_text);
new_target.push('\n');
std::fs::write(source_path, &new_source)
.map_err(|e| format!("Failed to write source file '{source_path}': {e}"))?;
std::fs::write(target_path, &new_target)
.map_err(|e| format!("Failed to write target file '{target_path}': {e}"))?;
let line_count = end_line - start_line + 1;
let line_word = crate::format::pluralize(line_count, "line", "lines");
let pub_note = if is_pub {
format!(
"\n {DIM}Note: '{symbol}' is public — you may need to add a `use` import in '{source_path}'.{RESET}"
)
} else {
String::new()
};
Ok(format!(
"Moved '{symbol}' ({line_count} {line_word}) from '{source_path}' to '{target_path}'.{pub_note}"
))
}
pub fn handle_extract(input: &str) {
let (symbol, source, target) = match parse_extract_args(input) {
Some(args) => args,
None => {
println!("{DIM} usage: /extract <symbol> <source_file> <target_file>");
println!(" Move a function, struct, enum, impl, trait, type alias, const, or static from one file to another.");
println!(" Shows a preview of the block to be moved and asks for confirmation.");
println!();
println!(" Examples:");
println!(" /extract my_func src/lib.rs src/utils.rs");
println!(" /extract MyStruct src/main.rs src/types.rs");
println!(" /extract MyTrait src/old.rs src/new.rs");
println!(" /extract MyResult src/lib.rs src/errors.rs");
println!(" /extract MAX_SIZE src/config.rs src/constants.rs{RESET}\n");
return;
}
};
let source_content = match std::fs::read_to_string(&source) {
Ok(c) => c,
Err(e) => {
println!("{RED} Cannot read '{source}': {e}{RESET}\n");
return;
}
};
let (start_line, end_line, block_text) = match find_symbol_block(&source_content, &symbol) {
Some(found) => found,
None => {
println!("{DIM} Symbol '{symbol}' not found in '{source}'.{RESET}\n");
return;
}
};
let line_count = end_line - start_line + 1;
let line_word = crate::format::pluralize(line_count, "line", "lines");
println!();
println!(" {BOLD}Extract preview:{RESET}");
println!(
" Move {CYAN}{symbol}{RESET} ({line_count} {line_word}) from {RED}{source}{RESET} → {GREEN}{target}{RESET}"
);
println!();
let preview_lines: Vec<&str> = block_text.lines().collect();
let max_preview = 15;
for (i, line) in preview_lines.iter().take(max_preview).enumerate() {
println!(" {CYAN}{:>4}{RESET}: {line}", start_line + i + 1);
}
if preview_lines.len() > max_preview {
println!(
" {DIM}... ({} more lines){RESET}",
preview_lines.len() - max_preview
);
}
println!();
print!(" {BOLD}Move this symbol? (y/n): {RESET}");
use std::io::Write;
std::io::stdout().flush().ok();
let mut answer = String::new();
if std::io::stdin().read_line(&mut answer).is_err() {
println!("{RED} Failed to read input.{RESET}\n");
return;
}
let answer = answer.trim().to_lowercase();
if answer != "y" && answer != "yes" {
println!("{DIM} Extract cancelled.{RESET}\n");
return;
}
match extract_symbol(&source, &target, &symbol) {
Ok(msg) => println!("{GREEN} ✓ {msg}{RESET}\n"),
Err(e) => println!("{RED} ✗ {e}{RESET}\n"),
}
}
pub fn handle_refactor(input: &str) {
let rest = input.strip_prefix("/refactor").unwrap_or(input).trim();
if rest.is_empty() {
println!("{DIM} Refactoring Tools:");
println!(" /rename <old> <new> Rename a symbol across all project files");
println!(
" /extract <item> <src> <dst> Move a function, struct, or type to another file"
);
println!(" /move <Type>::<method> <Target> Relocate a method between impl blocks");
println!();
println!(" Examples:");
println!(" /rename MyOldStruct MyNewStruct");
println!(" /extract parse_config src/lib.rs src/config.rs");
println!(" /move Parser::validate Validator");
println!();
println!(
" These operate on source text (not ASTs), so they work with any language.{RESET}"
);
println!();
return;
}
let parts: Vec<&str> = rest.splitn(2, char::is_whitespace).collect();
let subcmd = parts[0];
let sub_args = if parts.len() > 1 { parts[1].trim() } else { "" };
match subcmd {
"rename" => {
let forwarded = if sub_args.is_empty() {
"/rename".to_string()
} else {
format!("/rename {sub_args}")
};
handle_rename(&forwarded);
}
"extract" => {
let forwarded = if sub_args.is_empty() {
"/extract".to_string()
} else {
format!("/extract {sub_args}")
};
handle_extract(&forwarded);
}
"move" => {
let forwarded = if sub_args.is_empty() {
"/move".to_string()
} else {
format!("/move {sub_args}")
};
handle_move(&forwarded);
}
other => {
println!("{RED} Unknown refactoring subcommand: {other}{RESET}");
println!("{DIM} Available: rename, extract, move");
println!(" Run /refactor with no arguments to see all options.{RESET}\n");
}
}
}
fn is_word_boundary_char(c: char) -> bool {
!c.is_alphanumeric() && c != '_'
}
fn is_word_start(text: &str, pos: usize) -> bool {
if pos == 0 {
return true;
}
text[..pos].chars().last().is_none_or(is_word_boundary_char)
}
fn is_word_end(text: &str, pos: usize) -> bool {
if pos >= text.len() {
return true;
}
text[pos..].chars().next().is_none_or(is_word_boundary_char)
}
#[derive(Debug, Clone, PartialEq)]
pub struct RenameMatch {
pub file: String,
pub line_num: usize,
pub line_text: String,
pub column: usize,
}
#[derive(Debug, Clone, PartialEq)]
pub struct RenameResult {
pub files_changed: Vec<String>,
pub total_replacements: usize,
pub preview: String,
}
pub fn rename_in_project(
old_name: &str,
new_name: &str,
scope: Option<&str>,
) -> Result<RenameResult, String> {
if old_name.is_empty() {
return Err("old_name must not be empty".to_string());
}
if new_name.is_empty() {
return Err("new_name must not be empty".to_string());
}
if old_name == new_name {
return Err("old_name and new_name are identical — nothing to do".to_string());
}
let mut matches = find_rename_matches(old_name);
if let Some(scope_path) = scope {
matches.retain(|m| m.file.starts_with(scope_path));
}
if matches.is_empty() {
let scope_msg = scope
.map(|s| format!(" (scoped to '{s}')"))
.unwrap_or_default();
return Err(format!(
"No word-boundary matches found for '{old_name}'{scope_msg}."
));
}
let preview = format_rename_preview(&matches, old_name, new_name);
let mut files_changed: Vec<String> = matches.iter().map(|m| m.file.clone()).collect();
files_changed.sort();
files_changed.dedup();
let total_replacements = apply_rename(&matches, old_name, new_name);
Ok(RenameResult {
files_changed,
total_replacements,
preview,
})
}
pub fn find_rename_matches(old_name: &str) -> Vec<RenameMatch> {
if old_name.is_empty() {
return Vec::new();
}
let files = list_git_files();
let mut matches = Vec::new();
for file_path in &files {
if is_binary_extension(file_path) {
continue;
}
let content = match std::fs::read_to_string(file_path) {
Ok(c) => c,
Err(_) => continue,
};
for (line_idx, line) in content.lines().enumerate() {
let line_matches = find_word_boundary_matches(line, old_name);
for col in line_matches {
matches.push(RenameMatch {
file: file_path.clone(),
line_num: line_idx + 1,
line_text: line.to_string(),
column: col,
});
}
}
}
matches
}
pub fn find_word_boundary_matches(text: &str, pattern: &str) -> Vec<usize> {
if pattern.is_empty() || text.is_empty() {
return Vec::new();
}
let mut positions = Vec::new();
let mut start = 0;
let pat_len = pattern.len();
while start + pat_len <= text.len() {
if let Some(pos) = text[start..].find(pattern) {
let abs_pos = start + pos;
let end_pos = abs_pos + pat_len;
if is_word_start(text, abs_pos) && is_word_end(text, end_pos) {
positions.push(abs_pos);
}
start = abs_pos + 1;
} else {
break;
}
}
positions
}
fn list_git_files() -> Vec<String> {
let output = std::process::Command::new("git")
.args(["ls-files"])
.output();
match output {
Ok(out) if out.status.success() => {
let stdout = String::from_utf8_lossy(&out.stdout);
stdout
.lines()
.filter(|l| !l.is_empty())
.map(|l| l.to_string())
.collect()
}
_ => Vec::new(),
}
}
pub fn format_rename_preview(matches: &[RenameMatch], old_name: &str, new_name: &str) -> String {
if matches.is_empty() {
return format!("{DIM} No matches found for '{old_name}'.{RESET}\n");
}
let mut output = String::new();
let mut current_file = String::new();
let mut file_count = 0usize;
for m in matches {
if m.file != current_file {
current_file = m.file.clone();
file_count += 1;
output.push_str(&format!("\n {GREEN}{}{RESET}\n", m.file));
}
let highlighted = m.line_text.replace(
old_name,
&format!("{RED}{old_name}{RESET}→{GREEN}{new_name}{RESET}"),
);
output.push_str(&format!(
" {CYAN}{:>4}{RESET}: {}\n",
m.line_num, highlighted
));
}
let match_word = crate::format::pluralize(matches.len(), "match", "matches");
let file_word = crate::format::pluralize(file_count, "file", "files");
output.push_str(&format!(
"\n {BOLD}{} {match_word}{RESET} across {BOLD}{file_count} {file_word}{RESET}\n",
matches.len()
));
output.push_str(&format!(
" Rename {RED}{old_name}{RESET} → {GREEN}{new_name}{RESET}\n"
));
output
}
pub fn apply_rename(matches: &[RenameMatch], old_name: &str, new_name: &str) -> usize {
if matches.is_empty() {
return 0;
}
let mut files_to_update: std::collections::HashMap<&str, Vec<&RenameMatch>> =
std::collections::HashMap::new();
for m in matches {
files_to_update.entry(m.file.as_str()).or_default().push(m);
}
let mut total_replacements = 0usize;
for file_path in files_to_update.keys() {
let content = match std::fs::read_to_string(file_path) {
Ok(c) => c,
Err(_) => continue,
};
let mut new_content = String::new();
for line in content.lines() {
let replaced = replace_word_boundary(line, old_name, new_name);
let orig_count = find_word_boundary_matches(line, old_name).len();
total_replacements += orig_count;
new_content.push_str(&replaced);
new_content.push('\n');
}
if !content.ends_with('\n') && new_content.ends_with('\n') {
new_content.pop();
}
if let Err(e) = std::fs::write(file_path, &new_content) {
println!("{RED} Failed to write {file_path}: {e}{RESET}");
}
}
total_replacements
}
pub fn replace_word_boundary(text: &str, old: &str, new: &str) -> String {
if old.is_empty() {
return text.to_string();
}
let positions = find_word_boundary_matches(text, old);
if positions.is_empty() {
return text.to_string();
}
let mut result = String::new();
let mut last_end = 0;
for pos in positions {
result.push_str(&text[last_end..pos]);
result.push_str(new);
last_end = pos + old.len();
}
result.push_str(&text[last_end..]);
result
}
pub fn parse_rename_args(input: &str) -> Option<(String, String)> {
let rest = input.strip_prefix("/rename").unwrap_or(input).trim();
let parts: Vec<&str> = rest.split_whitespace().collect();
if parts.len() == 2 {
Some((parts[0].to_string(), parts[1].to_string()))
} else {
None
}
}
pub fn handle_rename(input: &str) {
let (old_name, new_name) = match parse_rename_args(input) {
Some(args) => args,
None => {
println!("{DIM} usage: /rename <old_name> <new_name>");
println!(" Cross-file symbol renaming with word-boundary matching.");
println!(" Shows a preview of all changes and asks for confirmation.");
println!();
println!(" Examples:");
println!(" /rename my_func new_func");
println!(" /rename OldStruct NewStruct");
println!(" /rename CONFIG_KEY NEW_KEY{RESET}\n");
return;
}
};
if old_name == new_name {
println!("{DIM} (old and new names are the same — nothing to do){RESET}\n");
return;
}
println!("{DIM} searching for '{old_name}'...{RESET}");
let matches = find_rename_matches(&old_name);
if matches.is_empty() {
println!("{DIM} No word-boundary matches found for '{old_name}'.{RESET}\n");
return;
}
let preview = format_rename_preview(&matches, &old_name, &new_name);
print!("{preview}");
print!("\n {BOLD}Apply rename? (y/n): {RESET}");
use std::io::Write;
std::io::stdout().flush().ok();
let mut answer = String::new();
if std::io::stdin().read_line(&mut answer).is_err() {
println!("{RED} Failed to read input.{RESET}\n");
return;
}
let answer = answer.trim().to_lowercase();
if answer != "y" && answer != "yes" {
println!("{DIM} Rename cancelled.{RESET}\n");
return;
}
let count = apply_rename(&matches, &old_name, &new_name);
let repl_word = crate::format::pluralize(count, "replacement", "replacements");
println!("{GREEN} ✓ Applied {count} {repl_word}.{RESET}\n");
}
pub struct MoveArgs {
pub source_type: String,
pub method_name: String,
pub target_file: Option<String>,
pub target_type: String,
}
pub fn parse_move_args(input: &str) -> Option<MoveArgs> {
let rest = input.strip_prefix("/move").unwrap_or(input).trim();
let parts: Vec<&str> = rest.split_whitespace().collect();
if parts.len() != 2 {
return None;
}
let source_parts: Vec<&str> = parts[0].splitn(2, "::").collect();
if source_parts.len() != 2 {
return None;
}
let source_type = source_parts[0].to_string();
let method_name = source_parts[1].to_string();
if source_type.is_empty() || method_name.is_empty() {
return None;
}
let target = parts[1];
let (target_file, target_type) = if target.contains("::") {
let tparts: Vec<&str> = target.splitn(2, "::").collect();
(Some(tparts[0].to_string()), tparts[1].to_string())
} else {
(None, target.to_string())
};
if target_type.is_empty() {
return None;
}
Some(MoveArgs {
source_type,
method_name,
target_file,
target_type,
})
}
pub fn find_impl_blocks(source: &str, type_name: &str) -> Vec<(usize, usize, String)> {
let lines: Vec<&str> = source.lines().collect();
let mut results = Vec::new();
let patterns = [
format!("impl {type_name} "),
format!("impl {type_name} {{"),
format!("impl {type_name}{{"),
];
let mut i = 0;
while i < lines.len() {
let trimmed = lines[i].trim();
if trimmed.starts_with("//") || trimmed.starts_with('*') || trimmed.starts_with("/*") {
i += 1;
continue;
}
let mut found = false;
for pat in &patterns {
if let Some(pos) = trimmed.find(pat.as_str()) {
let before = &trimmed[..pos];
let is_valid_prefix = before.is_empty()
|| before.trim_end().is_empty()
|| before.trim_end() == "pub"
|| before.trim_end().starts_with("pub(");
if is_valid_prefix {
found = true;
break;
}
}
}
if !found {
let ends_with_type = trimmed.ends_with(&format!("impl {type_name}"))
|| trimmed.ends_with(&format!("impl {type_name} {{"));
if ends_with_type {
let before_impl = trimmed
.find("impl ")
.map(|p| trimmed[..p].trim_end())
.unwrap_or("");
if before_impl.is_empty() || before_impl == "pub" || before_impl.starts_with("pub(")
{
found = true;
}
}
}
if found {
let mut start = i;
while start > 0 {
let prev = lines[start - 1].trim();
if prev.starts_with("///")
|| prev.starts_with("#[")
|| prev.starts_with("#![")
|| prev.starts_with("//!")
{
start -= 1;
} else {
break;
}
}
let mut depth: i32 = 0;
let mut found_open = false;
let mut end = i;
for (j, line) in lines.iter().enumerate().skip(i) {
for ch in line.chars() {
if ch == '{' {
depth += 1;
found_open = true;
} else if ch == '}' {
depth -= 1;
}
}
end = j;
if found_open && depth == 0 {
break;
}
}
let block: String = lines[start..=end].join("\n");
results.push((start, end, block));
i = end + 1;
} else {
i += 1;
}
}
results
}
pub fn find_method_in_impl(
impl_text: &str,
method_name: &str,
) -> Option<(usize, usize, String, bool)> {
let lines: Vec<&str> = impl_text.lines().collect();
let fn_pattern = format!("fn {method_name}");
let mut decl_line = None;
for (i, line) in lines.iter().enumerate() {
let trimmed = line.trim();
if trimmed.starts_with("//") || trimmed.starts_with('*') {
continue;
}
if let Some(pos) = trimmed.find(&fn_pattern) {
let after = pos + fn_pattern.len();
if after < trimmed.len() {
let next_char = trimmed.as_bytes()[after];
if next_char.is_ascii_alphanumeric() || next_char == b'_' {
continue;
}
}
let before = &trimmed[..pos];
let is_valid = before.is_empty()
|| before.trim_end().is_empty()
|| before.trim_end() == "pub"
|| before.trim_end().starts_with("pub(")
|| before.trim_end() == "async"
|| before.trim_end() == "pub async"
|| before.trim_end() == "unsafe"
|| before.trim_end() == "pub unsafe"
|| before.trim_end() == "pub async unsafe"
|| before.trim_end() == "async unsafe";
if is_valid {
decl_line = Some(i);
break;
}
}
}
let decl_line = decl_line?;
let mut start = decl_line;
while start > 0 {
let prev = lines[start - 1].trim();
if prev.starts_with("///") || prev.starts_with("#[") || prev.starts_with("//!") {
start -= 1;
} else {
break;
}
}
let mut depth: i32 = 0;
let mut found_open = false;
let mut end = decl_line;
for (j, line) in lines.iter().enumerate().skip(decl_line) {
for ch in line.chars() {
if ch == '{' {
depth += 1;
found_open = true;
} else if ch == '}' {
depth -= 1;
}
}
end = j;
if found_open && depth == 0 {
break;
}
}
let method_text: String = lines[start..=end].join("\n");
let has_self_ref = method_text.contains("self.");
Some((start, end, method_text, has_self_ref))
}
pub fn move_method(
source_file: &str,
source_type: &str,
method_name: &str,
target_file: Option<&str>,
target_type: &str,
) -> Result<(String, Option<String>), String> {
let source_content = std::fs::read_to_string(source_file)
.map_err(|e| format!("Cannot read source file '{source_file}': {e}"))?;
let source_impls = find_impl_blocks(&source_content, source_type);
if source_impls.is_empty() {
return Err(format!(
"No `impl {source_type}` block found in '{source_file}'"
));
}
let mut found = None;
for (impl_start, impl_end, impl_text) in &source_impls {
if let Some((m_start, m_end, m_text, has_self)) =
find_method_in_impl(impl_text, method_name)
{
found = Some((*impl_start, *impl_end, m_start, m_end, m_text, has_self));
break;
}
}
let (impl_start, _impl_end, method_offset_start, method_offset_end, method_text, has_self_ref) =
found.ok_or_else(|| {
format!("Method '{method_name}' not found in any `impl {source_type}` block in '{source_file}'")
})?;
let abs_method_start = impl_start + method_offset_start;
let abs_method_end = impl_start + method_offset_end;
let same_file = target_file.is_none() || target_file == Some(source_file);
let actual_target = target_file.unwrap_or(source_file);
let target_content = if same_file {
source_content.clone()
} else {
std::fs::read_to_string(actual_target)
.map_err(|e| format!("Cannot read target file '{actual_target}': {e}"))?
};
let target_impls = find_impl_blocks(&target_content, target_type);
if target_impls.is_empty() {
return Err(format!(
"No `impl {target_type}` block found in '{actual_target}'"
));
}
let (target_impl_start, target_impl_end, _target_impl_text) = &target_impls[0];
let source_lines: Vec<&str> = source_content.lines().collect();
let target_lines: Vec<&str> = target_content.lines().collect();
let target_indent = if *target_impl_end > *target_impl_start + 1 {
let sample_line = target_lines[target_impl_start + 1];
let indent_len = sample_line.len() - sample_line.trim_start().len();
&sample_line[..indent_len]
} else {
" "
};
let re_indented = reindent_method(&method_text, target_indent);
if same_file {
let mut new_lines: Vec<String> = Vec::new();
for (i, line) in source_lines.iter().enumerate() {
if i >= abs_method_start && i <= abs_method_end {
continue;
}
if i == *target_impl_end {
new_lines.push(String::new());
new_lines.push(re_indented.clone());
}
new_lines.push(line.to_string());
}
let mut result = new_lines.join("\n");
while result.contains("\n\n\n\n") {
result = result.replace("\n\n\n\n", "\n\n\n");
}
if !result.ends_with('\n') {
result.push('\n');
}
std::fs::write(source_file, &result)
.map_err(|e| format!("Failed to write '{source_file}': {e}"))?;
} else {
let mut new_source_lines: Vec<&str> = Vec::new();
for (i, line) in source_lines.iter().enumerate() {
if i >= abs_method_start && i <= abs_method_end {
continue;
}
new_source_lines.push(line);
}
let mut new_source = new_source_lines.join("\n");
while new_source.contains("\n\n\n\n") {
new_source = new_source.replace("\n\n\n\n", "\n\n\n");
}
if !new_source.ends_with('\n') {
new_source.push('\n');
}
let mut new_target_lines: Vec<String> = Vec::new();
for (i, line) in target_lines.iter().enumerate() {
if i == *target_impl_end {
new_target_lines.push(String::new());
new_target_lines.push(re_indented.clone());
}
new_target_lines.push(line.to_string());
}
let mut new_target = new_target_lines.join("\n");
if !new_target.ends_with('\n') {
new_target.push('\n');
}
std::fs::write(source_file, &new_source)
.map_err(|e| format!("Failed to write source '{source_file}': {e}"))?;
std::fs::write(actual_target, &new_target)
.map_err(|e| format!("Failed to write target '{actual_target}': {e}"))?;
}
let line_count = abs_method_end - abs_method_start + 1;
let line_word = crate::format::pluralize(line_count, "line", "lines");
let target_desc = if same_file {
format!("`impl {target_type}` in '{source_file}'")
} else {
format!("`impl {target_type}` in '{actual_target}'")
};
let summary = format!(
"Moved '{source_type}::{method_name}' ({line_count} {line_word}) to {target_desc}."
);
let warning = if has_self_ref {
Some(format!(
"Method uses `self.` — verify field/method references are valid on `{target_type}`."
))
} else {
None
};
Ok((summary, warning))
}
fn reindent_method(method_text: &str, target_indent: &str) -> String {
let lines: Vec<&str> = method_text.lines().collect();
if lines.is_empty() {
return String::new();
}
let min_indent = lines
.iter()
.filter(|l| !l.trim().is_empty())
.map(|l| l.len() - l.trim_start().len())
.min()
.unwrap_or(0);
lines
.iter()
.map(|line| {
if line.trim().is_empty() {
String::new()
} else {
let stripped = if line.len() >= min_indent {
&line[min_indent..]
} else {
line.trim_start()
};
format!("{target_indent}{stripped}")
}
})
.collect::<Vec<_>>()
.join("\n")
}
pub fn handle_move(input: &str) {
let args = match parse_move_args(input) {
Some(a) => a,
None => {
println!("{DIM} usage: /move <SourceType>::<method> [file::]<TargetType>");
println!(" Relocate a method from one impl block to another.");
println!();
println!(" Examples:");
println!(" /move MyStruct::process TargetStruct (same file)");
println!(" /move MyStruct::process other.rs::TargetStruct (cross-file)");
println!();
println!(" Shows a preview and asks for confirmation before applying.");
println!(" Warns if the method uses `self.` references.{RESET}\n");
return;
}
};
let source_file = find_file_with_impl(&args.source_type);
let source_file = match source_file {
Some(f) => f,
None => {
println!(
"{RED} Could not find a file containing `impl {}`.{RESET}\n",
args.source_type
);
println!("{DIM} Tip: run /move from the project root directory.{RESET}\n");
return;
}
};
let target_file = args.target_file.as_deref();
let source_content = match std::fs::read_to_string(&source_file) {
Ok(c) => c,
Err(e) => {
println!("{RED} Cannot read '{source_file}': {e}{RESET}\n");
return;
}
};
let impls = find_impl_blocks(&source_content, &args.source_type);
let mut method_preview = None;
for (_impl_start, _impl_end, impl_text) in &impls {
if let Some((_ms, _me, m_text, has_self)) =
find_method_in_impl(impl_text, &args.method_name)
{
method_preview = Some((m_text, has_self));
break;
}
}
let (method_text, has_self) = match method_preview {
Some(p) => p,
None => {
println!(
"{DIM} Method '{}' not found in any `impl {}` block.{RESET}\n",
args.method_name, args.source_type
);
return;
}
};
let actual_target = target_file.unwrap_or(&source_file);
let line_count = method_text.lines().count();
let line_word = crate::format::pluralize(line_count, "line", "lines");
println!();
println!(" {BOLD}Move preview:{RESET}");
println!(
" Move {CYAN}{}::{}{RESET} ({line_count} {line_word})",
args.source_type, args.method_name
);
println!(
" from {RED}impl {}{RESET} in '{source_file}'",
args.source_type
);
println!(
" to {GREEN}impl {}{RESET} in '{actual_target}'",
args.target_type
);
println!();
let preview_lines: Vec<&str> = method_text.lines().collect();
let max_preview = 15;
for line in preview_lines.iter().take(max_preview) {
println!(" {CYAN}│{RESET} {line}");
}
if preview_lines.len() > max_preview {
println!(
" {DIM}... ({} more lines){RESET}",
preview_lines.len() - max_preview
);
}
println!();
if has_self {
println!(
" {YELLOW}⚠Method uses `self.` — verify references are valid on `{}`.{RESET}",
args.target_type
);
println!();
}
print!(" {BOLD}Move this method? (y/n): {RESET}");
use std::io::Write;
std::io::stdout().flush().ok();
let mut answer = String::new();
if std::io::stdin().read_line(&mut answer).is_err() {
println!("{RED} Failed to read input.{RESET}\n");
return;
}
let answer = answer.trim().to_lowercase();
if answer != "y" && answer != "yes" {
println!("{DIM} Move cancelled.{RESET}\n");
return;
}
match move_method(
&source_file,
&args.source_type,
&args.method_name,
args.target_file.as_deref(),
&args.target_type,
) {
Ok((summary, warning)) => {
println!("{GREEN} ✓ {summary}{RESET}");
if let Some(w) = warning {
println!(" {YELLOW}âš {w}{RESET}");
}
println!();
}
Err(e) => println!("{RED} ✗ {e}{RESET}\n"),
}
}
fn find_file_with_impl(type_name: &str) -> Option<String> {
let pattern = format!("impl {type_name}");
let output = std::process::Command::new("git")
.args(["ls-files", "--cached", "--others", "--exclude-standard"])
.output()
.ok()?;
let file_list = String::from_utf8_lossy(&output.stdout);
for file in file_list.lines() {
if !file.ends_with(".rs") {
continue;
}
if let Ok(content) = std::fs::read_to_string(file) {
if content.contains(&pattern) {
return Some(file.to_string());
}
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use crate::commands::KNOWN_COMMANDS;
use crate::help::help_text;
use serial_test::serial;
use std::fs;
use tempfile::TempDir;
#[test]
fn detect_project_type_rust() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"x\"").unwrap();
assert_eq!(detect_project_type(dir.path()), ProjectType::Rust);
}
#[test]
fn detect_project_type_node() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("package.json"), "{}").unwrap();
assert_eq!(detect_project_type(dir.path()), ProjectType::Node);
}
#[test]
fn detect_project_type_python_pyproject() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("pyproject.toml"), "[tool]").unwrap();
assert_eq!(detect_project_type(dir.path()), ProjectType::Python);
}
#[test]
fn detect_project_type_python_setup_py() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("setup.py"), "").unwrap();
assert_eq!(detect_project_type(dir.path()), ProjectType::Python);
}
#[test]
fn detect_project_type_python_setup_cfg() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("setup.cfg"), "").unwrap();
assert_eq!(detect_project_type(dir.path()), ProjectType::Python);
}
#[test]
fn detect_project_type_go() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("go.mod"), "module example").unwrap();
assert_eq!(detect_project_type(dir.path()), ProjectType::Go);
}
#[test]
fn detect_project_type_make() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("Makefile"), "all:").unwrap();
assert_eq!(detect_project_type(dir.path()), ProjectType::Make);
}
#[test]
fn detect_project_type_make_lowercase() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("makefile"), "all:").unwrap();
assert_eq!(detect_project_type(dir.path()), ProjectType::Make);
}
#[test]
fn detect_project_type_unknown_empty_dir() {
let dir = TempDir::new().unwrap();
assert_eq!(detect_project_type(dir.path()), ProjectType::Unknown);
}
#[test]
fn detect_project_type_priority_rust_over_make() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("Cargo.toml"), "[package]").unwrap();
fs::write(dir.path().join("Makefile"), "all:").unwrap();
assert_eq!(detect_project_type(dir.path()), ProjectType::Rust);
}
#[test]
fn project_type_display() {
assert_eq!(format!("{}", ProjectType::Rust), "Rust (Cargo)");
assert_eq!(format!("{}", ProjectType::Node), "Node.js (npm)");
assert_eq!(format!("{}", ProjectType::Python), "Python");
assert_eq!(format!("{}", ProjectType::Go), "Go");
assert_eq!(format!("{}", ProjectType::Make), "Makefile");
assert_eq!(format!("{}", ProjectType::Unknown), "Unknown");
}
#[test]
fn scan_important_files_finds_known_files() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("README.md"), "# Hello").unwrap();
fs::write(dir.path().join("Cargo.toml"), "[package]").unwrap();
fs::write(dir.path().join(".gitignore"), "target/").unwrap();
let found = scan_important_files(dir.path());
assert!(found.contains(&"README.md".to_string()));
assert!(found.contains(&"Cargo.toml".to_string()));
assert!(found.contains(&".gitignore".to_string()));
}
#[test]
fn scan_important_files_empty_dir() {
let dir = TempDir::new().unwrap();
let found = scan_important_files(dir.path());
assert!(found.is_empty());
}
#[test]
fn scan_important_files_ignores_unknown() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("random.txt"), "stuff").unwrap();
let found = scan_important_files(dir.path());
assert!(found.is_empty());
}
#[test]
fn scan_important_dirs_finds_known_dirs() {
let dir = TempDir::new().unwrap();
fs::create_dir(dir.path().join("src")).unwrap();
fs::create_dir(dir.path().join("tests")).unwrap();
fs::create_dir(dir.path().join("docs")).unwrap();
let found = scan_important_dirs(dir.path());
assert!(found.contains(&"src".to_string()));
assert!(found.contains(&"tests".to_string()));
assert!(found.contains(&"docs".to_string()));
}
#[test]
fn scan_important_dirs_empty_dir() {
let dir = TempDir::new().unwrap();
let found = scan_important_dirs(dir.path());
assert!(found.is_empty());
}
#[test]
fn scan_important_dirs_ignores_files() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("src"), "not a dir").unwrap();
let found = scan_important_dirs(dir.path());
assert!(!found.contains(&"src".to_string()));
}
#[test]
fn detect_project_name_from_cargo_toml() {
let dir = TempDir::new().unwrap();
fs::write(
dir.path().join("Cargo.toml"),
"[package]\nname = \"my-crate\"",
)
.unwrap();
assert_eq!(detect_project_name(dir.path()), "my-crate");
}
#[test]
fn detect_project_name_from_package_json() {
let dir = TempDir::new().unwrap();
fs::write(
dir.path().join("package.json"),
"{\n \"name\": \"my-app\",\n \"version\": \"1.0.0\"\n}",
)
.unwrap();
assert_eq!(detect_project_name(dir.path()), "my-app");
}
#[test]
fn detect_project_name_from_readme() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("README.md"), "# Cool Project\n\nSome text").unwrap();
assert_eq!(detect_project_name(dir.path()), "Cool Project");
}
#[test]
fn detect_project_name_cargo_over_readme() {
let dir = TempDir::new().unwrap();
fs::write(
dir.path().join("Cargo.toml"),
"[package]\nname = \"cargo-name\"",
)
.unwrap();
fs::write(dir.path().join("README.md"), "# README Title").unwrap();
assert_eq!(detect_project_name(dir.path()), "cargo-name");
}
#[test]
fn detect_project_name_fallback_to_dir_name() {
let dir = TempDir::new().unwrap();
let name = detect_project_name(dir.path());
assert!(!name.is_empty());
}
#[test]
fn extract_readme_skips_blank_lines() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("README.md"), "\n\n \n# Title After Blanks").unwrap();
assert_eq!(detect_project_name(dir.path()), "Title After Blanks");
}
#[test]
fn extract_readme_empty_title_skipped() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("README.md"), "# \n# Real Title").unwrap();
assert_eq!(detect_project_name(dir.path()), "Real Title");
}
#[test]
fn cargo_toml_name_with_single_quotes() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("Cargo.toml"), "[package]\nname = 'quoted'").unwrap();
assert_eq!(detect_project_name(dir.path()), "quoted");
}
#[test]
fn cargo_toml_name_with_spaces_around_equals() {
let dir = TempDir::new().unwrap();
fs::write(
dir.path().join("Cargo.toml"),
"[package]\nname = \"spaced\"",
)
.unwrap();
assert_eq!(detect_project_name(dir.path()), "spaced");
}
#[test]
fn build_commands_rust() {
let cmds = build_commands_for_project(&ProjectType::Rust);
assert!(!cmds.is_empty());
assert!(cmds.iter().any(|(label, _)| *label == "Build"));
assert!(cmds.iter().any(|(label, _)| *label == "Test"));
}
#[test]
fn build_commands_unknown_empty() {
let cmds = build_commands_for_project(&ProjectType::Unknown);
assert!(cmds.is_empty());
}
#[test]
fn build_commands_node() {
let cmds = build_commands_for_project(&ProjectType::Node);
assert!(cmds.iter().any(|(_, cmd)| *cmd == "npm install"));
}
#[test]
fn build_commands_python() {
let cmds = build_commands_for_project(&ProjectType::Python);
assert!(cmds.iter().any(|(_, cmd)| *cmd == "python -m pytest"));
}
#[test]
fn build_commands_go() {
let cmds = build_commands_for_project(&ProjectType::Go);
assert!(cmds.iter().any(|(_, cmd)| *cmd == "go build ./..."));
}
#[test]
fn generate_init_content_rust_project() {
let dir = TempDir::new().unwrap();
fs::write(
dir.path().join("Cargo.toml"),
"[package]\nname = \"test-proj\"",
)
.unwrap();
fs::create_dir(dir.path().join("src")).unwrap();
fs::write(dir.path().join("src/main.rs"), "fn main() {}").unwrap();
let content = generate_init_content(dir.path());
assert!(content.contains("# Project Context"));
assert!(content.contains("test-proj"));
assert!(content.contains("Rust (Cargo)"));
assert!(content.contains("cargo build"));
assert!(content.contains("cargo test"));
}
#[test]
fn generate_init_content_unknown_project() {
let dir = TempDir::new().unwrap();
let content = generate_init_content(dir.path());
assert!(content.contains("# Project Context"));
assert!(!content.contains("Rust"));
assert!(!content.contains("Node"));
assert!(content.contains("Add build, test, and run commands"));
}
#[test]
fn generate_init_content_includes_dirs_and_files() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("README.md"), "# My Project").unwrap();
fs::create_dir(dir.path().join("src")).unwrap();
let content = generate_init_content(dir.path());
assert!(content.contains("`src/`"));
assert!(content.contains("`README.md`"));
}
#[test]
fn parse_plan_task_with_description() {
let result = parse_plan_task("/plan add error handling to the parser");
assert_eq!(result, Some("add error handling to the parser".to_string()));
}
#[test]
fn parse_plan_task_empty() {
let result = parse_plan_task("/plan");
assert!(result.is_none(), "Empty /plan should return None");
}
#[test]
fn parse_plan_task_whitespace_only() {
let result = parse_plan_task("/plan ");
assert!(result.is_none(), "Whitespace-only /plan should return None");
}
#[test]
fn parse_plan_task_preserves_full_description() {
let result = parse_plan_task("/plan refactor main.rs into smaller modules with tests");
assert_eq!(
result,
Some("refactor main.rs into smaller modules with tests".to_string())
);
}
#[test]
fn build_plan_prompt_contains_task() {
let prompt = build_plan_prompt("add a /plan command");
assert!(
prompt.contains("add a /plan command"),
"Plan prompt should contain the task"
);
}
#[test]
fn build_plan_prompt_contains_no_tools_instruction() {
let prompt = build_plan_prompt("something");
assert!(
prompt.contains("Do NOT execute any tools"),
"Plan prompt should instruct not to use tools"
);
}
#[test]
fn build_plan_prompt_contains_structure_sections() {
let prompt = build_plan_prompt("add feature X");
assert!(
prompt.contains("Files to examine"),
"Should mention files to examine"
);
assert!(
prompt.contains("Files to modify"),
"Should mention files to modify"
);
assert!(
prompt.contains("Step-by-step"),
"Should mention step-by-step approach"
);
assert!(prompt.contains("Tests to write"), "Should mention tests");
assert!(prompt.contains("Potential risks"), "Should mention risks");
assert!(
prompt.contains("Verification"),
"Should mention verification"
);
}
#[test]
fn find_word_boundary_simple_match() {
let matches = find_word_boundary_matches("let foo = 42;", "foo");
assert_eq!(matches, vec![4]);
}
#[test]
fn find_word_boundary_no_match_substring() {
let matches = find_word_boundary_matches("let foobar = 42;", "foo");
assert!(matches.is_empty());
}
#[test]
fn find_word_boundary_no_match_prefix() {
let matches = find_word_boundary_matches("let afoo = 42;", "foo");
assert!(matches.is_empty());
}
#[test]
fn find_word_boundary_at_start_of_line() {
let matches = find_word_boundary_matches("foo = 42;", "foo");
assert_eq!(matches, vec![0]);
}
#[test]
fn find_word_boundary_at_end_of_line() {
let matches = find_word_boundary_matches("let x = foo", "foo");
assert_eq!(matches, vec![8]);
}
#[test]
fn find_word_boundary_multiple_matches() {
let matches = find_word_boundary_matches("foo + foo * foo", "foo");
assert_eq!(matches, vec![0, 6, 12]);
}
#[test]
fn find_word_boundary_with_underscore() {
let matches = find_word_boundary_matches("call my_func()", "my");
assert!(matches.is_empty());
}
#[test]
fn find_word_boundary_dots_are_boundaries() {
let matches = find_word_boundary_matches("self.foo.bar", "foo");
assert_eq!(matches, vec![5]);
}
#[test]
fn find_word_boundary_empty_pattern() {
let matches = find_word_boundary_matches("hello", "");
assert!(matches.is_empty());
}
#[test]
fn find_word_boundary_empty_text() {
let matches = find_word_boundary_matches("", "foo");
assert!(matches.is_empty());
}
#[test]
fn find_word_boundary_exact_match() {
let matches = find_word_boundary_matches("foo", "foo");
assert_eq!(matches, vec![0]);
}
#[test]
fn find_word_boundary_parens_are_boundaries() {
let matches = find_word_boundary_matches("call(foo)", "foo");
assert_eq!(matches, vec![5]);
}
#[test]
fn replace_word_boundary_simple() {
let result = replace_word_boundary("let foo = 42;", "foo", "bar");
assert_eq!(result, "let bar = 42;");
}
#[test]
fn replace_word_boundary_no_partial() {
let result = replace_word_boundary("let foobar = 42;", "foo", "bar");
assert_eq!(result, "let foobar = 42;"); }
#[test]
fn replace_word_boundary_multiple() {
let result = replace_word_boundary("foo + foo", "foo", "bar");
assert_eq!(result, "bar + bar");
}
#[test]
fn replace_word_boundary_empty_pattern() {
let result = replace_word_boundary("hello", "", "bar");
assert_eq!(result, "hello");
}
#[test]
fn replace_word_boundary_no_matches() {
let result = replace_word_boundary("nothing here", "foo", "bar");
assert_eq!(result, "nothing here");
}
#[test]
fn replace_word_boundary_with_longer_replacement() {
let result = replace_word_boundary("fn f(x: T) -> T", "T", "MyType");
assert_eq!(result, "fn f(x: MyType) -> MyType");
}
#[test]
fn replace_word_boundary_with_shorter_replacement() {
let result =
replace_word_boundary("let my_variable = my_variable + 1;", "my_variable", "x");
assert_eq!(result, "let x = x + 1;");
}
#[test]
fn parse_rename_args_valid() {
let result = parse_rename_args("/rename foo bar");
assert_eq!(result, Some(("foo".to_string(), "bar".to_string())));
}
#[test]
fn parse_rename_args_no_args() {
let result = parse_rename_args("/rename");
assert_eq!(result, None);
}
#[test]
fn parse_rename_args_one_arg() {
let result = parse_rename_args("/rename foo");
assert_eq!(result, None);
}
#[test]
fn parse_rename_args_too_many_args() {
let result = parse_rename_args("/rename foo bar baz");
assert_eq!(result, None);
}
#[test]
fn parse_rename_args_extra_whitespace() {
let result = parse_rename_args("/rename foo bar");
assert_eq!(result, Some(("foo".to_string(), "bar".to_string())));
}
#[test]
fn format_rename_preview_no_matches() {
let preview = format_rename_preview(&[], "foo", "bar");
assert!(preview.contains("No matches found"));
}
#[test]
fn format_rename_preview_shows_file_and_line() {
let matches = vec![RenameMatch {
file: "src/main.rs".to_string(),
line_num: 10,
line_text: "let foo = 42;".to_string(),
column: 4,
}];
let preview = format_rename_preview(&matches, "foo", "bar");
assert!(preview.contains("src/main.rs"));
assert!(preview.contains("10"));
assert!(preview.contains("1 match"));
assert!(preview.contains("1 file"));
}
#[test]
fn format_rename_preview_multiple_files() {
let matches = vec![
RenameMatch {
file: "a.rs".to_string(),
line_num: 1,
line_text: "use foo;".to_string(),
column: 4,
},
RenameMatch {
file: "b.rs".to_string(),
line_num: 5,
line_text: "foo()".to_string(),
column: 0,
},
];
let preview = format_rename_preview(&matches, "foo", "bar");
assert!(preview.contains("a.rs"));
assert!(preview.contains("b.rs"));
assert!(preview.contains("2 matches"));
assert!(preview.contains("2 files"));
}
#[test]
fn apply_rename_modifies_files() {
let dir = TempDir::new().unwrap();
let file_path = dir.path().join("test.rs");
fs::write(&file_path, "let foo = 1;\nlet bar = foo;\n").unwrap();
let matches = vec![
RenameMatch {
file: file_path.to_str().unwrap().to_string(),
line_num: 1,
line_text: "let foo = 1;".to_string(),
column: 4,
},
RenameMatch {
file: file_path.to_str().unwrap().to_string(),
line_num: 2,
line_text: "let bar = foo;".to_string(),
column: 10,
},
];
let count = apply_rename(&matches, "foo", "baz");
assert_eq!(count, 2);
let content = fs::read_to_string(&file_path).unwrap();
assert!(content.contains("let baz = 1;"));
assert!(content.contains("let bar = baz;"));
assert!(!content.contains("foo"));
}
#[test]
fn apply_rename_preserves_non_matching_lines() {
let dir = TempDir::new().unwrap();
let file_path = dir.path().join("test.rs");
fs::write(&file_path, "// comment\nlet foo = 1;\n// end\n").unwrap();
let matches = vec![RenameMatch {
file: file_path.to_str().unwrap().to_string(),
line_num: 2,
line_text: "let foo = 1;".to_string(),
column: 4,
}];
apply_rename(&matches, "foo", "bar");
let content = fs::read_to_string(&file_path).unwrap();
assert!(content.contains("// comment"));
assert!(content.contains("let bar = 1;"));
assert!(content.contains("// end"));
}
#[test]
fn apply_rename_no_partial_replace() {
let dir = TempDir::new().unwrap();
let file_path = dir.path().join("test.rs");
fs::write(&file_path, "let foobar = foo;\n").unwrap();
let matches = vec![RenameMatch {
file: file_path.to_str().unwrap().to_string(),
line_num: 1,
line_text: "let foobar = foo;".to_string(),
column: 13,
}];
apply_rename(&matches, "foo", "baz");
let content = fs::read_to_string(&file_path).unwrap();
assert!(content.contains("foobar")); assert!(content.contains("= baz;")); }
#[test]
fn apply_rename_empty_matches() {
let count = apply_rename(&[], "foo", "bar");
assert_eq!(count, 0);
}
#[test]
fn parse_extract_args_valid() {
let result = parse_extract_args("/extract my_func src/lib.rs src/utils.rs");
assert_eq!(
result,
Some((
"my_func".to_string(),
"src/lib.rs".to_string(),
"src/utils.rs".to_string()
))
);
}
#[test]
fn parse_extract_args_missing_target() {
assert_eq!(parse_extract_args("/extract my_func src/lib.rs"), None);
}
#[test]
fn parse_extract_args_too_many() {
assert_eq!(parse_extract_args("/extract a b c d"), None);
}
#[test]
fn parse_extract_args_empty() {
assert_eq!(parse_extract_args("/extract"), None);
}
#[test]
fn find_symbol_block_simple_fn() {
let source = "fn hello() {\n println!(\"hi\");\n}\n";
let result = find_symbol_block(source, "hello");
assert!(result.is_some());
let (start, end, block) = result.unwrap();
assert_eq!(start, 0);
assert_eq!(end, 2);
assert!(block.contains("fn hello()"));
assert!(block.contains("println!"));
}
#[test]
fn find_symbol_block_pub_fn() {
let source = "pub fn greet(name: &str) -> String {\n format!(\"Hello {name}\")\n}\n";
let result = find_symbol_block(source, "greet");
assert!(result.is_some());
let (start, end, block) = result.unwrap();
assert_eq!(start, 0);
assert_eq!(end, 2);
assert!(block.contains("pub fn greet"));
}
#[test]
fn find_symbol_block_struct() {
let source = "pub struct MyPoint {\n pub x: f64,\n pub y: f64,\n}\n";
let result = find_symbol_block(source, "MyPoint");
assert!(result.is_some());
let (_, _, block) = result.unwrap();
assert!(block.contains("pub struct MyPoint"));
assert!(block.contains("pub x: f64"));
}
#[test]
fn find_symbol_block_enum() {
let source = "enum Color {\n Red,\n Green,\n Blue,\n}\n";
let result = find_symbol_block(source, "Color");
assert!(result.is_some());
let (_, _, block) = result.unwrap();
assert!(block.contains("enum Color"));
assert!(block.contains("Blue"));
}
#[test]
fn find_symbol_block_impl() {
let source = "struct Foo;\n\nimpl Foo {\n fn bar(&self) {}\n}\n";
let result = find_symbol_block(source, "Foo");
assert!(result.is_some());
let (start, _end, block) = result.unwrap();
assert_eq!(start, 0);
assert!(block.contains("struct Foo"));
}
#[test]
fn find_symbol_block_with_doc_comments() {
let source = "/// A helper function.\n/// Does something.\nfn helper() {\n // body\n}\n";
let result = find_symbol_block(source, "helper");
assert!(result.is_some());
let (start, end, block) = result.unwrap();
assert_eq!(start, 0); assert_eq!(end, 4);
assert!(block.contains("/// A helper function."));
assert!(block.contains("fn helper()"));
}
#[test]
fn find_symbol_block_with_attributes() {
let source = "#[derive(Debug)]\npub struct Config {\n pub name: String,\n}\n";
let result = find_symbol_block(source, "Config");
assert!(result.is_some());
let (start, _, block) = result.unwrap();
assert_eq!(start, 0); assert!(block.contains("#[derive(Debug)]"));
assert!(block.contains("pub struct Config"));
}
#[test]
fn find_symbol_block_not_found() {
let source = "fn other() {\n}\n";
assert!(find_symbol_block(source, "missing").is_none());
}
#[test]
fn find_symbol_block_nested_braces() {
let source = "fn complex() {\n if true {\n for i in 0..10 {\n println!(\"{i}\");\n }\n }\n}\n";
let result = find_symbol_block(source, "complex");
assert!(result.is_some());
let (start, end, _block) = result.unwrap();
assert_eq!(start, 0);
assert_eq!(end, 6);
}
#[test]
fn find_symbol_block_among_multiple() {
let source = "fn first() {\n}\n\nfn second() {\n let x = 1;\n}\n\nfn third() {\n}\n";
let result = find_symbol_block(source, "second");
assert!(result.is_some());
let (start, end, block) = result.unwrap();
assert_eq!(start, 3);
assert_eq!(end, 5);
assert!(block.contains("fn second()"));
assert!(block.contains("let x = 1"));
}
#[test]
fn find_symbol_block_unit_struct() {
let source = "pub struct Unit;\n\nfn other() {}\n";
let result = find_symbol_block(source, "Unit");
assert!(result.is_some());
let (start, end, block) = result.unwrap();
assert_eq!(start, 0);
assert_eq!(end, 0);
assert!(block.contains("pub struct Unit;"));
}
#[test]
fn find_symbol_block_trait() {
let source = "pub trait Drawable {\n fn draw(&self);\n}\n";
let result = find_symbol_block(source, "Drawable");
assert!(result.is_some());
let (_, _, block) = result.unwrap();
assert!(block.contains("pub trait Drawable"));
assert!(block.contains("fn draw"));
}
#[test]
fn find_symbol_block_async_fn() {
let source = "pub async fn fetch_data() {\n // async body\n}\n";
let result = find_symbol_block(source, "fetch_data");
assert!(result.is_some());
let (_, _, block) = result.unwrap();
assert!(block.contains("pub async fn fetch_data"));
}
#[test]
fn find_symbol_block_no_partial_match() {
let source = "fn my_func_extended() {\n}\n\nfn my_func() {\n // target\n}\n";
let result = find_symbol_block(source, "my_func");
assert!(result.is_some());
let (start, _, block) = result.unwrap();
assert_eq!(start, 3);
assert!(block.contains("// target"));
}
#[test]
fn extract_symbol_moves_function() {
let dir = TempDir::new().unwrap();
let source = dir.path().join("source.rs");
let target = dir.path().join("target.rs");
fs::write(
&source,
"fn keep_me() {\n // stays\n}\n\npub fn move_me() {\n // goes\n}\n\nfn also_stays() {\n}\n",
)
.unwrap();
fs::write(&target, "// existing content\n").unwrap();
let result = extract_symbol(
source.to_str().unwrap(),
target.to_str().unwrap(),
"move_me",
);
assert!(result.is_ok());
let source_after = fs::read_to_string(&source).unwrap();
assert!(source_after.contains("fn keep_me()"));
assert!(source_after.contains("fn also_stays()"));
assert!(!source_after.contains("fn move_me()"));
let target_after = fs::read_to_string(&target).unwrap();
assert!(target_after.contains("// existing content"));
assert!(target_after.contains("pub fn move_me()"));
assert!(target_after.contains("// goes"));
}
#[test]
fn extract_symbol_creates_target_if_missing() {
let dir = TempDir::new().unwrap();
let source = dir.path().join("source.rs");
let target = dir.path().join("new_file.rs");
fs::write(&source, "fn movable() {\n let x = 1;\n}\n").unwrap();
let result = extract_symbol(
source.to_str().unwrap(),
target.to_str().unwrap(),
"movable",
);
assert!(result.is_ok());
assert!(target.exists());
let target_content = fs::read_to_string(&target).unwrap();
assert!(target_content.contains("fn movable()"));
}
#[test]
fn extract_symbol_not_found() {
let dir = TempDir::new().unwrap();
let source = dir.path().join("source.rs");
let target = dir.path().join("target.rs");
fs::write(&source, "fn other() {}\n").unwrap();
let result = extract_symbol(
source.to_str().unwrap(),
target.to_str().unwrap(),
"missing",
);
assert!(result.is_err());
assert!(result.unwrap_err().contains("not found"));
}
#[test]
fn extract_symbol_source_not_found() {
let dir = TempDir::new().unwrap();
let result = extract_symbol(
dir.path().join("nope.rs").to_str().unwrap(),
dir.path().join("target.rs").to_str().unwrap(),
"foo",
);
assert!(result.is_err());
assert!(result.unwrap_err().contains("Cannot read"));
}
#[test]
fn extract_symbol_with_doc_comments_moves_docs() {
let dir = TempDir::new().unwrap();
let source = dir.path().join("source.rs");
let target = dir.path().join("target.rs");
fs::write(
&source,
"/// Important docs.\n/// More docs.\npub fn documented() {\n // body\n}\n",
)
.unwrap();
let result = extract_symbol(
source.to_str().unwrap(),
target.to_str().unwrap(),
"documented",
);
assert!(result.is_ok());
let target_content = fs::read_to_string(&target).unwrap();
assert!(target_content.contains("/// Important docs."));
assert!(target_content.contains("/// More docs."));
assert!(target_content.contains("pub fn documented()"));
}
#[test]
fn extract_command_in_known_commands() {
assert!(
KNOWN_COMMANDS.contains(&"/extract"),
"/extract should be in KNOWN_COMMANDS"
);
}
#[test]
fn find_symbol_block_type_alias() {
let source = "pub type Result<T> = std::result::Result<T, MyError>;\n\nfn other() {}\n";
let result = find_symbol_block(source, "Result");
assert!(result.is_some());
let (start, end, block) = result.unwrap();
assert_eq!(start, 0);
assert_eq!(end, 0);
assert!(block.contains("pub type Result<T>"));
}
#[test]
fn find_symbol_block_type_alias_simple() {
let source = "type Callback = fn(u32) -> bool;\n";
let result = find_symbol_block(source, "Callback");
assert!(result.is_some());
let (start, end, block) = result.unwrap();
assert_eq!(start, 0);
assert_eq!(end, 0);
assert!(block.contains("type Callback"));
}
#[test]
fn find_symbol_block_const() {
let source = "pub const MAX_SIZE: usize = 1024;\n\nfn other() {}\n";
let result = find_symbol_block(source, "MAX_SIZE");
assert!(result.is_some());
let (start, end, block) = result.unwrap();
assert_eq!(start, 0);
assert_eq!(end, 0);
assert!(block.contains("pub const MAX_SIZE"));
}
#[test]
fn find_symbol_block_const_with_doc() {
let source = "/// The maximum buffer size.\nconst BUFFER_SIZE: usize = 512;\n";
let result = find_symbol_block(source, "BUFFER_SIZE");
assert!(result.is_some());
let (start, end, block) = result.unwrap();
assert_eq!(start, 0); assert_eq!(end, 1);
assert!(block.contains("/// The maximum buffer size."));
assert!(block.contains("const BUFFER_SIZE"));
}
#[test]
fn find_symbol_block_static() {
let source = "static COUNTER: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);\n";
let result = find_symbol_block(source, "COUNTER");
assert!(result.is_some());
let (_, _, block) = result.unwrap();
assert!(block.contains("static COUNTER"));
}
#[test]
fn find_symbol_block_static_mut() {
let source = "static mut GLOBAL: u32 = 0;\n\nfn other() {}\n";
let result = find_symbol_block(source, "GLOBAL");
assert!(result.is_some());
let (_, _, block) = result.unwrap();
assert!(block.contains("static mut GLOBAL"));
}
#[test]
fn find_symbol_block_pub_const_crate() {
let source = "pub(crate) const INTERNAL_LIMIT: u32 = 100;\n";
let result = find_symbol_block(source, "INTERNAL_LIMIT");
assert!(result.is_some());
let (_, _, block) = result.unwrap();
assert!(block.contains("pub(crate) const INTERNAL_LIMIT"));
}
#[test]
fn find_symbol_block_const_multiline() {
let source = "const ITEMS: &[&str] = &[\n \"alpha\",\n \"beta\",\n];\n";
let result = find_symbol_block(source, "ITEMS");
assert!(result.is_some());
let (start, end, block) = result.unwrap();
assert_eq!(start, 0);
assert_eq!(end, 3);
assert!(block.contains("const ITEMS"));
assert!(block.contains("\"beta\""));
}
#[test]
fn extract_symbol_moves_type_alias() {
let dir = TempDir::new().unwrap();
let source = dir.path().join("source.rs");
let target = dir.path().join("target.rs");
fs::write(
&source,
"pub type MyResult<T> = Result<T, MyError>;\n\nfn keep() {}\n",
)
.unwrap();
fs::write(&target, "// types\n").unwrap();
let result = extract_symbol(
source.to_str().unwrap(),
target.to_str().unwrap(),
"MyResult",
);
assert!(result.is_ok());
let source_after = fs::read_to_string(&source).unwrap();
assert!(!source_after.contains("type MyResult"));
assert!(source_after.contains("fn keep()"));
let target_after = fs::read_to_string(&target).unwrap();
assert!(target_after.contains("pub type MyResult<T>"));
}
#[test]
fn extract_symbol_moves_const() {
let dir = TempDir::new().unwrap();
let source = dir.path().join("source.rs");
let target = dir.path().join("target.rs");
fs::write(&source, "pub const LIMIT: usize = 42;\n\nfn keep() {}\n").unwrap();
fs::write(&target, "").unwrap();
let result = extract_symbol(source.to_str().unwrap(), target.to_str().unwrap(), "LIMIT");
assert!(result.is_ok());
let source_after = fs::read_to_string(&source).unwrap();
assert!(!source_after.contains("const LIMIT"));
let target_after = fs::read_to_string(&target).unwrap();
assert!(target_after.contains("pub const LIMIT: usize = 42;"));
}
#[test]
fn extract_symbol_moves_static() {
let dir = TempDir::new().unwrap();
let source = dir.path().join("source.rs");
let target = dir.path().join("target.rs");
fs::write(
&source,
"pub static INSTANCE: &str = \"hello\";\n\nfn keep() {}\n",
)
.unwrap();
fs::write(&target, "").unwrap();
let result = extract_symbol(
source.to_str().unwrap(),
target.to_str().unwrap(),
"INSTANCE",
);
assert!(result.is_ok());
let source_after = fs::read_to_string(&source).unwrap();
assert!(!source_after.contains("static INSTANCE"));
let target_after = fs::read_to_string(&target).unwrap();
assert!(target_after.contains("pub static INSTANCE"));
}
#[test]
fn test_parse_move_args_basic() {
let args = parse_move_args("/move MyStruct::process TargetStruct").unwrap();
assert_eq!(args.source_type, "MyStruct");
assert_eq!(args.method_name, "process");
assert_eq!(args.target_type, "TargetStruct");
assert!(args.target_file.is_none());
}
#[test]
fn test_parse_move_args_cross_file() {
let args = parse_move_args("/move Parser::parse_expr other.rs::Lexer").unwrap();
assert_eq!(args.source_type, "Parser");
assert_eq!(args.method_name, "parse_expr");
assert_eq!(args.target_file.as_deref(), Some("other.rs"));
assert_eq!(args.target_type, "Lexer");
}
#[test]
fn test_parse_move_args_missing_method() {
assert!(parse_move_args("/move MyStruct TargetStruct").is_none());
}
#[test]
fn test_parse_move_args_empty() {
assert!(parse_move_args("/move").is_none());
}
#[test]
fn test_parse_move_args_too_many() {
assert!(parse_move_args("/move A::b C D").is_none());
}
#[test]
fn test_find_impl_blocks_single() {
let src = "struct Foo;\n\nimpl Foo {\n fn bar(&self) {}\n}\n";
let blocks = find_impl_blocks(src, "Foo");
assert_eq!(blocks.len(), 1);
assert!(blocks[0].2.contains("fn bar"));
}
#[test]
fn test_find_impl_blocks_multiple() {
let src = "\
struct Foo;
impl Foo {
fn one(&self) {}
}
impl Foo {
fn two(&self) {}
}
";
let blocks = find_impl_blocks(src, "Foo");
assert_eq!(blocks.len(), 2);
assert!(blocks[0].2.contains("fn one"));
assert!(blocks[1].2.contains("fn two"));
}
#[test]
fn test_find_impl_blocks_not_found() {
let src = "struct Foo;\nimpl Bar {\n fn baz() {}\n}\n";
let blocks = find_impl_blocks(src, "Foo");
assert!(blocks.is_empty());
}
#[test]
fn test_find_method_in_impl_basic() {
let impl_text = "impl Foo {\n fn bar(&self) -> i32 {\n 42\n }\n}";
let result = find_method_in_impl(impl_text, "bar").unwrap();
assert!(result.2.contains("fn bar"));
assert!(result.2.contains("42"));
assert!(!result.3);
}
#[test]
fn test_find_method_in_impl_with_self_ref() {
let impl_text = "impl Foo {\n fn bar(&self) -> i32 {\n self.value + 1\n }\n}";
let result = find_method_in_impl(impl_text, "bar").unwrap();
assert!(result.3); }
#[test]
fn test_find_method_in_impl_not_found() {
let impl_text = "impl Foo {\n fn bar(&self) {}\n}";
assert!(find_method_in_impl(impl_text, "baz").is_none());
}
#[test]
fn test_find_method_with_doc_comments() {
let impl_text = "impl Foo {\n /// Does something.\n /// Multi-line doc.\n fn documented(&self) {\n // body\n }\n}";
let result = find_method_in_impl(impl_text, "documented").unwrap();
assert!(result.2.contains("/// Does something."));
assert!(result.2.contains("/// Multi-line doc."));
assert!(result.2.contains("fn documented"));
}
#[test]
fn test_find_method_with_attributes() {
let impl_text =
"impl Foo {\n #[inline]\n pub fn fast(&self) -> u32 {\n 0\n }\n}";
let result = find_method_in_impl(impl_text, "fast").unwrap();
assert!(result.2.contains("#[inline]"));
assert!(result.2.contains("pub fn fast"));
}
#[test]
fn test_move_method_same_file() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("lib.rs");
fs::write(
&file,
"\
struct Alpha;
struct Beta;
impl Alpha {
fn greet(&self) -> &str {
\"hello\"
}
fn farewell(&self) -> &str {
\"bye\"
}
}
impl Beta {
fn existing(&self) {}
}
",
)
.unwrap();
let result = move_method(file.to_str().unwrap(), "Alpha", "greet", None, "Beta");
assert!(result.is_ok());
let (summary, warning) = result.unwrap();
assert!(summary.contains("greet"));
assert!(summary.contains("Alpha"));
assert!(summary.contains("Beta"));
assert!(warning.is_none());
let content = fs::read_to_string(&file).unwrap();
assert!(!impl_block_contains(&content, "Alpha", "fn greet"));
assert!(impl_block_contains(&content, "Alpha", "fn farewell"));
assert!(impl_block_contains(&content, "Beta", "fn greet"));
assert!(impl_block_contains(&content, "Beta", "fn existing"));
}
#[test]
fn test_move_method_cross_file() {
let dir = TempDir::new().unwrap();
let source = dir.path().join("source.rs");
let target = dir.path().join("target.rs");
fs::write(
&source,
"\
struct Src;
impl Src {
fn compute(&self) -> i32 {
42
}
}
",
)
.unwrap();
fs::write(
&target,
"\
struct Dst;
impl Dst {
fn other(&self) {}
}
",
)
.unwrap();
let result = move_method(
source.to_str().unwrap(),
"Src",
"compute",
Some(target.to_str().unwrap()),
"Dst",
);
assert!(result.is_ok());
let src_content = fs::read_to_string(&source).unwrap();
assert!(!src_content.contains("fn compute"));
let tgt_content = fs::read_to_string(&target).unwrap();
assert!(tgt_content.contains("fn compute"));
assert!(tgt_content.contains("42"));
assert!(tgt_content.contains("fn other"));
}
#[test]
fn test_move_method_with_doc_comments() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("lib.rs");
fs::write(
&file,
"\
struct A;
struct B;
impl A {
/// Important method.
/// Does important things.
fn important(&self) {
// body
}
}
impl B {
fn placeholder(&self) {}
}
",
)
.unwrap();
let result = move_method(file.to_str().unwrap(), "A", "important", None, "B");
assert!(result.is_ok());
let content = fs::read_to_string(&file).unwrap();
let b_block = extract_impl_block(&content, "B");
assert!(b_block.contains("/// Important method."));
assert!(b_block.contains("/// Does important things."));
assert!(b_block.contains("fn important"));
}
#[test]
fn test_move_method_not_found() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("lib.rs");
fs::write(
&file,
"struct A;\nimpl A {\n fn existing(&self) {}\n}\nstruct B;\nimpl B {}\n",
)
.unwrap();
let result = move_method(file.to_str().unwrap(), "A", "nonexistent", None, "B");
assert!(result.is_err());
assert!(result.unwrap_err().contains("not found"));
}
#[test]
fn test_move_method_target_impl_not_found() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("lib.rs");
fs::write(&file, "struct A;\nimpl A {\n fn method(&self) {}\n}\n").unwrap();
let result = move_method(file.to_str().unwrap(), "A", "method", None, "NonExistent");
assert!(result.is_err());
assert!(result.unwrap_err().contains("No `impl NonExistent`"));
}
#[test]
fn test_move_method_self_reference_warning() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("lib.rs");
fs::write(
&file,
"\
struct A { value: i32 }
struct B;
impl A {
fn get_value(&self) -> i32 {
self.value
}
}
impl B {
fn other(&self) {}
}
",
)
.unwrap();
let result = move_method(file.to_str().unwrap(), "A", "get_value", None, "B");
assert!(result.is_ok());
let (_summary, warning) = result.unwrap();
assert!(warning.is_some());
assert!(warning.unwrap().contains("self."));
}
#[test]
fn test_move_source_impl_not_found() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("lib.rs");
fs::write(&file, "struct B;\nimpl B {\n fn x(&self) {}\n}\n").unwrap();
let result = move_method(file.to_str().unwrap(), "NonExistent", "method", None, "B");
assert!(result.is_err());
assert!(result.unwrap_err().contains("No `impl NonExistent`"));
}
#[test]
fn test_move_in_known_commands() {
assert!(
KNOWN_COMMANDS.contains(&"/move"),
"/move should be in KNOWN_COMMANDS"
);
}
#[test]
fn test_move_in_help_text() {
let text = help_text();
assert!(text.contains("/move"), "/move should appear in help text");
}
#[test]
fn test_reindent_method() {
let method = " fn foo(&self) {\n 42\n }";
let result = reindent_method(method, " ");
assert!(result.starts_with(" fn foo"));
assert!(result.contains(" 42"));
}
fn impl_block_contains(source: &str, type_name: &str, needle: &str) -> bool {
let blocks = find_impl_blocks(source, type_name);
blocks.iter().any(|(_, _, text)| text.contains(needle))
}
fn extract_impl_block(source: &str, type_name: &str) -> String {
let blocks = find_impl_blocks(source, type_name);
if blocks.is_empty() {
String::new()
} else {
blocks[0].2.clone()
}
}
#[test]
fn test_rename_in_project_empty_old_name() {
let result = rename_in_project("", "Bar", None);
assert!(result.is_err());
assert!(result.unwrap_err().contains("old_name must not be empty"));
}
#[test]
fn test_rename_in_project_empty_new_name() {
let result = rename_in_project("Foo", "", None);
assert!(result.is_err());
assert!(result.unwrap_err().contains("new_name must not be empty"));
}
#[test]
fn test_rename_in_project_same_name() {
let result = rename_in_project("Foo", "Foo", None);
assert!(result.is_err());
assert!(result.unwrap_err().contains("identical"));
}
#[test]
fn test_rename_result_fields() {
let r = RenameResult {
files_changed: vec!["a.rs".to_string()],
total_replacements: 3,
preview: "preview".to_string(),
};
assert_eq!(r.files_changed, vec!["a.rs"]);
assert_eq!(r.total_replacements, 3);
assert_eq!(r.preview, "preview");
}
#[test]
fn test_rename_in_project_scoped_no_match() {
let result = rename_in_project("RenameMatch", "RM", Some("nonexistent_dir_xyz/"));
assert!(result.is_err());
assert!(result.unwrap_err().contains("No word-boundary matches"));
}
#[test]
fn test_refactor_no_args_shows_help() {
handle_refactor("/refactor");
}
#[test]
fn test_refactor_in_known_commands() {
assert!(
KNOWN_COMMANDS.contains(&"/refactor"),
"/refactor should be in KNOWN_COMMANDS"
);
}
#[test]
fn test_refactor_help_exists() {
use crate::help::command_help;
assert!(
command_help("refactor").is_some(),
"/refactor should have a help entry"
);
}
#[test]
fn test_refactor_tab_completion() {
use crate::commands::command_arg_completions;
let candidates = command_arg_completions("/refactor", "");
assert!(
candidates.contains(&"rename".to_string()),
"Should include 'rename'"
);
assert!(
candidates.contains(&"extract".to_string()),
"Should include 'extract'"
);
assert!(
candidates.contains(&"move".to_string()),
"Should include 'move'"
);
}
#[test]
fn test_refactor_tab_completion_filters() {
use crate::commands::command_arg_completions;
let candidates = command_arg_completions("/refactor", "re");
assert!(
candidates.contains(&"rename".to_string()),
"Should include 'rename' for prefix 're'"
);
assert!(
!candidates.contains(&"extract".to_string()),
"Should not include 'extract' for prefix 're'"
);
assert!(
!candidates.contains(&"move".to_string()),
"Should not include 'move' for prefix 're'"
);
}
#[test]
fn test_refactor_unknown_subcommand() {
handle_refactor("/refactor foobar");
}
#[test]
fn test_refactor_in_help_text() {
let help = help_text();
assert!(
help.contains("/refactor"),
"/refactor should appear in help text"
);
}
#[test]
#[serial]
fn test_todo_add_returns_incrementing_ids() {
todo_clear();
let id1 = todo_add("first task");
let id2 = todo_add("second task");
assert!(id2 > id1, "IDs should increment: {id1} < {id2}");
let items = todo_list();
assert_eq!(items.len(), 2);
assert_eq!(items[0].description, "first task");
assert_eq!(items[1].description, "second task");
}
#[test]
#[serial]
fn test_todo_update_status() {
todo_clear();
let id = todo_add("update me");
assert_eq!(todo_list()[0].status, TodoStatus::Pending);
todo_update(id, TodoStatus::InProgress).unwrap();
assert_eq!(todo_list()[0].status, TodoStatus::InProgress);
todo_update(id, TodoStatus::Done).unwrap();
assert_eq!(todo_list()[0].status, TodoStatus::Done);
}
#[test]
#[serial]
fn test_todo_update_invalid_id() {
todo_clear();
let result = todo_update(99999, TodoStatus::Done);
assert!(result.is_err());
assert!(result.unwrap_err().contains("99999"));
}
#[test]
#[serial]
fn test_todo_remove() {
todo_clear();
let id = todo_add("remove me");
assert_eq!(todo_list().len(), 1);
let removed = todo_remove(id).unwrap();
assert_eq!(removed.description, "remove me");
assert!(todo_list().is_empty());
}
#[test]
#[serial]
fn test_todo_remove_invalid_id() {
todo_clear();
let result = todo_remove(99998);
assert!(result.is_err());
assert!(result.unwrap_err().contains("99998"));
}
#[test]
#[serial]
fn test_todo_clear() {
todo_clear();
todo_add("one");
todo_add("two");
assert_eq!(todo_list().len(), 2);
todo_clear();
assert!(todo_list().is_empty());
}
#[test]
#[serial]
fn test_todo_list_empty() {
todo_clear();
assert!(todo_list().is_empty());
}
#[test]
#[serial]
fn test_format_todo_list() {
todo_clear();
let id1 = todo_add("pending task");
let id2 = todo_add("wip task");
let id3 = todo_add("done task");
todo_update(id2, TodoStatus::InProgress).unwrap();
todo_update(id3, TodoStatus::Done).unwrap();
let items = todo_list();
let formatted = format_todo_list(&items);
assert!(formatted.contains("[ ]"), "Should contain pending checkbox");
assert!(
formatted.contains("[~]"),
"Should contain in-progress checkbox"
);
assert!(formatted.contains("[✓]"), "Should contain done checkbox");
assert!(formatted.contains(&format!("#{id1}")));
assert!(formatted.contains("pending task"));
assert!(formatted.contains("wip task"));
assert!(formatted.contains("done task"));
}
#[test]
fn test_format_todo_list_empty() {
let formatted = format_todo_list(&[]);
assert!(formatted.contains("No tasks"));
}
#[test]
#[serial]
fn test_handle_todo_add() {
todo_clear();
let result = handle_todo("/todo add write tests");
assert!(result.contains("Added task"));
assert!(result.contains("write tests"));
assert_eq!(todo_list().len(), 1);
}
#[test]
#[serial]
fn test_handle_todo_show_empty() {
todo_clear();
let result = handle_todo("/todo");
assert!(result.contains("No tasks"));
}
#[test]
#[serial]
fn test_handle_todo_done() {
todo_clear();
let id = todo_add("finish me");
let result = handle_todo(&format!("/todo done {id}"));
assert!(result.contains("done"));
assert_eq!(todo_list()[0].status, TodoStatus::Done);
}
#[test]
#[serial]
fn test_handle_todo_wip() {
todo_clear();
let id = todo_add("start me");
let result = handle_todo(&format!("/todo wip {id}"));
assert!(result.contains("in-progress"));
assert_eq!(todo_list()[0].status, TodoStatus::InProgress);
}
#[test]
#[serial]
fn test_handle_todo_remove_via_command() {
todo_clear();
let id = todo_add("delete me");
let result = handle_todo(&format!("/todo remove {id}"));
assert!(result.contains("Removed"));
assert!(todo_list().is_empty());
}
#[test]
#[serial]
fn test_handle_todo_clear_via_command() {
todo_clear();
todo_add("one");
todo_add("two");
let result = handle_todo("/todo clear");
assert!(result.contains("Cleared"));
assert!(todo_list().is_empty());
}
#[test]
fn test_handle_todo_unknown_subcommand() {
let result = handle_todo("/todo badcmd");
assert!(result.contains("Usage"));
}
#[test]
#[serial]
fn test_handle_todo_add_empty_description() {
let result = handle_todo("/todo add");
assert!(result.contains("Usage"));
let result2 = handle_todo("/todo add ");
assert!(result2.contains("Usage"));
}
#[test]
fn test_todo_in_known_commands() {
assert!(
KNOWN_COMMANDS.contains(&"/todo"),
"/todo should be in KNOWN_COMMANDS"
);
}
#[test]
fn test_todo_help_exists() {
let help = crate::help::command_help("todo");
assert!(help.is_some(), "todo should have help text");
let text = help.unwrap();
assert!(text.contains("/todo add"));
assert!(text.contains("/todo done"));
assert!(text.contains("/todo clear"));
}
#[test]
fn test_todo_in_help_text() {
let text = help_text();
assert!(text.contains("/todo"), "/todo should appear in help text");
}
}