use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use colored::Colorize;
use dialoguer::{Confirm, Input, Select};
use toml_edit::DocumentMut;
use ferrisup_common::cargo::{read_cargo_toml, update_workspace_members};
type UsageSummary = HashMap<String, HashMap<String, Vec<(usize, String)>>>;
pub fn execute(action: Option<&str>, component_type: Option<&str>, project_path: Option<&str>) -> Result<()> {
println!("{}", "FerrisUp Component Manager".bold().green());
let project_dir = if let Some(path) = project_path {
PathBuf::from(path)
} else {
let use_current = Confirm::new()
.with_prompt("Use current directory?")
.default(true)
.interact()?;
if use_current {
std::env::current_dir()?
} else {
let path = Input::<String>::new()
.with_prompt("Enter project path")
.interact()?;
PathBuf::from(path)
}
};
if !project_dir.join("Cargo.toml").exists() {
return Err(anyhow::anyhow!("Not a Rust project (Cargo.toml not found)"));
}
let action_str = if let Some(act) = action {
act.to_string()
} else {
let options = ["add", "remove", "list"];
let selection = Select::new()
.with_prompt("Select action")
.items(&options)
.default(0)
.interact()?;
options[selection].to_string()
};
match action_str.as_str() {
"add" => add_component(&project_dir, component_type)?,
"remove" => remove_component(&project_dir, component_type)?,
"list" => list_components(&project_dir)?,
_ => return Err(anyhow::anyhow!("Invalid action. Use 'add', 'remove', or 'list'")),
}
Ok(())
}
fn add_component(project_dir: &Path, component_type: Option<&str>) -> Result<()> {
let cargo_content = read_cargo_toml(project_dir)?;
let is_workspace = cargo_content.contains("[workspace]");
let current_dir = std::env::current_dir()?;
std::env::set_current_dir(project_dir)?;
let component_name = component_type.unwrap_or("new").to_string();
let result = if is_workspace {
crate::commands::transform::add_component(project_dir)
} else {
crate::commands::transform::add_component_without_workspace(project_dir)
};
std::env::set_current_dir(current_dir)?;
if let Err(e) = result {
println!("{} {}", "Error adding component:".red().bold(), e);
return Err(anyhow::anyhow!("Failed to add component"));
}
if is_workspace {
update_workspace_members(project_dir)?;
}
println!("{} {} {}",
"Successfully added".green(),
component_name.green(),
"component".green());
Ok(())
}
fn remove_component(project_dir: &Path, component_type: Option<&str>) -> Result<()> {
let components = discover_components(project_dir)?;
if components.is_empty() {
println!("{}", "No components found to remove".yellow());
return Ok(());
}
let component_path = if let Some(ctype) = component_type {
let matching: Vec<String> = components.iter()
.filter(|c| c.contains(ctype))
.cloned()
.collect();
if matching.is_empty() {
return Err(anyhow::anyhow!("No matching component found"));
} else if matching.len() == 1 {
matching[0].clone()
} else {
let selection = Select::new()
.with_prompt("Select component to remove")
.items(&matching)
.default(0)
.interact()?;
matching[selection].clone()
}
} else {
let selection = Select::new()
.with_prompt("Select component to remove")
.items(&components)
.default(0)
.interact()?;
components[selection].clone()
};
let cargo_content = read_cargo_toml(project_dir)?;
let is_workspace = cargo_content.contains("[workspace]");
if is_workspace {
let usage_summary = check_component_usage(project_dir, &component_path, &components)?;
if !usage_summary.is_empty() {
println!("{}", "⚠️ Component is being used in other components:".yellow());
println!();
for (component, files) in &usage_summary {
println!("In {} :", component.cyan());
for (file, lines) in files {
println!(" 📄 {}", file);
let imports: Vec<_> = lines.iter()
.filter(|(_, content)| content.starts_with("use "))
.collect();
if !imports.is_empty() {
println!(" Imports:");
for (line_num, content) in &imports {
println!(" Line {}: {}", line_num, content);
}
}
let direct_refs: Vec<_> = lines.iter()
.filter(|(_, content)| content.starts_with("[DIRECT REFERENCE]"))
.collect();
if !direct_refs.is_empty() {
println!(" Direct References:");
for (line_num, content) in &direct_refs {
let clean_content = content.replace("[DIRECT REFERENCE] ", "");
println!(" Line {}: {}", line_num, clean_content);
}
}
let potential: Vec<_> = lines.iter()
.filter(|(_, content)| content.starts_with("[POTENTIAL USAGE]"))
.collect();
if !potential.is_empty() {
println!(" Potential Usage:");
for (line_num, content) in &potential {
let clean_content = content.replace("[POTENTIAL USAGE] ", "");
println!(" Line {}: {}", line_num, clean_content);
}
}
let definite: Vec<_> = lines.iter()
.filter(|(_, content)| !content.starts_with("use ") &&
!content.starts_with("[POTENTIAL USAGE]") &&
!content.starts_with("[DIRECT REFERENCE]"))
.collect();
if !definite.is_empty() {
println!(" Definite Usage:");
for (line_num, content) in &definite {
println!(" Line {}: {}", line_num, content);
}
}
}
println!();
}
println!("{}", "You must manually remove these references before removing the component.".yellow());
println!("{}", "Component removal has been cancelled.".red());
return Ok(());
}
}
let confirm = Confirm::new()
.with_prompt(format!("Remove component {}?", component_path))
.default(false)
.interact()?;
if !confirm {
println!("Operation cancelled");
return Ok(());
}
if is_workspace {
remove_component_dependencies(project_dir, &component_path)?;
remove_component_from_metadata(project_dir, &component_path)?;
}
let full_path = project_dir.join(&component_path);
fs::remove_dir_all(&full_path)
.context(format!("Failed to remove {}", full_path.display()))?;
println!("{} {}", "Successfully removed component:".green(), component_path);
if is_workspace {
update_workspace_members_after_removal(project_dir, &component_path)?;
update_workspace_members(project_dir)?;
}
Ok(())
}
fn remove_component_from_metadata(project_dir: &Path, component_name: &str) -> Result<()> {
let ferrisup_dir = project_dir.join(".ferrisup");
if !ferrisup_dir.exists() {
return Ok(());
}
let metadata_path = ferrisup_dir.join("metadata.toml");
if !metadata_path.exists() {
return Ok(());
}
let metadata_content = fs::read_to_string(&metadata_path)?;
let mut metadata_doc = metadata_content
.parse::<DocumentMut>()
.context("Failed to parse metadata.toml")?;
let component_key = format!("component.{}", component_name);
if metadata_doc.contains_key(&component_key) {
metadata_doc.remove(&component_key);
println!("{} {}", "Updated".green(), "metadata.toml to remove component".cyan());
fs::write(metadata_path, metadata_doc.to_string())?;
}
Ok(())
}
fn remove_component_dependencies(project_dir: &Path, component_name: &str) -> Result<()> {
let workspace_cargo_path = project_dir.join("Cargo.toml");
if !workspace_cargo_path.exists() {
return Ok(());
}
let workspace_cargo_content = fs::read_to_string(&workspace_cargo_path)?;
let mut workspace_doc = workspace_cargo_content
.parse::<DocumentMut>()
.context("Failed to parse workspace Cargo.toml")?;
if let Some(workspace) = workspace_doc.get_mut("workspace") {
if let Some(workspace_table) = workspace.as_table_mut() {
if let Some(deps) = workspace_table.get_mut("dependencies") {
if let Some(deps_table) = deps.as_table_mut() {
if deps_table.contains_key(component_name) {
deps_table.remove(component_name);
println!("{} {}", "Removed".green(),
format!("'{}' from workspace.dependencies", component_name).cyan());
}
}
}
}
}
fs::write(&workspace_cargo_path, workspace_doc.to_string())?;
let components = discover_components(project_dir)?;
println!("{} {}", "Checking".blue(),
format!("for dependencies and imports in other components").cyan());
for comp in components {
if comp == component_name {
continue;
}
let component_cargo_path = project_dir.join(&comp).join("Cargo.toml");
if component_cargo_path.exists() {
remove_dependency_from_component(&component_cargo_path, component_name)?;
remove_imports_from_component(&project_dir.join(&comp), component_name)?;
}
}
Ok(())
}
fn remove_dependency_from_component(cargo_path: &Path, dependency_name: &str) -> Result<()> {
if !cargo_path.exists() {
return Ok(());
}
let cargo_content = fs::read_to_string(cargo_path)?;
let mut cargo_doc = cargo_content
.parse::<DocumentMut>()
.context(format!("Failed to parse {}", cargo_path.display()))?;
let mut updated = false;
if let Some(deps) = cargo_doc.get_mut("dependencies") {
if let Some(deps_table) = deps.as_table_mut() {
if deps_table.contains_key(dependency_name) {
deps_table.remove(dependency_name);
updated = true;
}
}
}
if let Some(deps) = cargo_doc.get_mut("dev-dependencies") {
if let Some(deps_table) = deps.as_table_mut() {
if deps_table.contains_key(dependency_name) {
deps_table.remove(dependency_name);
updated = true;
}
}
}
if let Some(deps) = cargo_doc.get_mut("build-dependencies") {
if let Some(deps_table) = deps.as_table_mut() {
if deps_table.contains_key(dependency_name) {
deps_table.remove(dependency_name);
updated = true;
}
}
}
if updated {
fs::write(cargo_path, cargo_doc.to_string())?;
println!("{} {}", "Removed".green(),
format!("'{}' dependency from {}", dependency_name, cargo_path.file_name().unwrap().to_string_lossy()).cyan());
}
Ok(())
}
fn remove_imports_from_component(component_dir: &Path, removed_component: &str) -> Result<()> {
let src_dir = component_dir.join("src");
if !src_dir.exists() {
return Ok(());
}
let main_rs = src_dir.join("main.rs");
if main_rs.exists() {
remove_imports_from_file(&main_rs, removed_component)?;
}
let lib_rs = src_dir.join("lib.rs");
if lib_rs.exists() {
remove_imports_from_file(&lib_rs, removed_component)?;
}
visit_rust_files(&src_dir, removed_component)?;
Ok(())
}
fn check_component_usage(project_dir: &Path, component_to_check: &str, all_components: &[String]) -> Result<UsageSummary> {
let mut usage_summary: UsageSummary = HashMap::new();
println!("{} {}", "Checking for usage of".blue(), component_to_check.cyan());
for component in all_components {
if component == component_to_check {
continue;
}
println!("{} {}", "Scanning component".blue(), component.cyan());
let component_dir = project_dir.join(component);
let src_dir = component_dir.join("src");
if !src_dir.exists() || !src_dir.is_dir() {
println!("{} {}", "No src directory found in".yellow(), component.cyan());
continue;
}
visit_rust_files_for_usage(&src_dir, component_to_check, component, project_dir, &mut usage_summary)?;
}
Ok(usage_summary)
}
fn check_file_for_usage(
file_path: &Path,
component_to_check: &str,
component_name: &str,
project_dir: &Path,
usage_summary: &mut UsageSummary
) -> Result<()> {
if !file_path.exists() {
return Ok(());
}
println!(" Checking file: {}", file_path.display());
let content = fs::read_to_string(file_path)?;
let snake_case = component_to_check.replace('-', "_");
let import_patterns = [
format!("use {}::*", component_to_check),
format!("use {}::", component_to_check),
format!("use {}_::", component_to_check),
format!("use {}_::*", component_to_check),
format!("use {}::*", snake_case),
format!("use {}::", snake_case),
];
let mut has_import = false;
for (line_num, line) in content.lines().enumerate() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with("//") {
continue;
}
if trimmed.starts_with("use ") {
for pattern in &import_patterns {
if trimmed.contains(pattern) {
has_import = true;
let file_relative = file_path.strip_prefix(project_dir.join(component_name))
.unwrap_or(file_path)
.to_string_lossy()
.to_string();
let entry = (line_num + 1, line.to_string());
let file_entries = usage_summary
.entry(component_name.to_string())
.or_default()
.entry(file_relative.clone())
.or_default();
if !file_entries.iter().any(|(num, content)| *num == entry.0 && content == &entry.1) {
file_entries.push(entry);
}
}
}
}
}
let snake_case = component_to_check.replace('-', "_");
for (line_num, line) in content.lines().enumerate() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with("//") || trimmed.starts_with("use ") {
continue;
}
if trimmed.contains(&component_to_check) || trimmed.contains(&snake_case) {
println!(" Found usage on line {}: {}", line_num + 1, trimmed);
let file_relative = file_path.strip_prefix(project_dir.join(component_name))
.unwrap_or(file_path)
.to_string_lossy()
.to_string();
usage_summary
.entry(component_name.to_string())
.or_default()
.entry(file_relative)
.or_default()
.push((line_num + 1, line.to_string()));
}
let component_name_snake = component_to_check.replace('-', "_");
if trimmed.contains(&component_name_snake) || trimmed.contains(component_to_check) {
let file_relative = file_path.strip_prefix(project_dir.join(component_name))
.unwrap_or(file_path)
.to_string_lossy()
.to_string();
let entry = (line_num + 1, format!("[DIRECT REFERENCE] {}", line.trim()));
let file_entries = usage_summary
.entry(component_name.to_string())
.or_default()
.entry(file_relative.clone())
.or_default();
if !file_entries.iter().any(|(num, content)| *num == entry.0 && content == &entry.1) {
file_entries.push(entry);
}
continue;
}
if has_import {
let component_dir = project_dir.join(component_to_check);
let lib_rs = component_dir.join("src/lib.rs");
let main_rs = component_dir.join("src/main.rs");
let mut exported_functions = Vec::new();
if lib_rs.exists() {
if let Ok(content) = fs::read_to_string(&lib_rs) {
for line in content.lines() {
let line = line.trim();
if line.starts_with("pub fn ") {
if let Some(name_end) = line["pub fn ".len()..].find('(') {
let fn_name = &line["pub fn ".len()..][..name_end].trim();
exported_functions.push(fn_name.to_string());
}
}
}
}
}
if main_rs.exists() && exported_functions.is_empty() {
if let Ok(content) = fs::read_to_string(&main_rs) {
for line in content.lines() {
let line = line.trim();
if line.starts_with("pub fn ") {
if let Some(name_end) = line["pub fn ".len()..].find('(') {
let fn_name = &line["pub fn ".len()..][..name_end].trim();
exported_functions.push(fn_name.to_string());
}
}
}
}
}
let words: Vec<&str> = trimmed.split(|c: char| !c.is_alphanumeric() && c != '_').collect();
for word in words {
if !word.is_empty() && !word.chars().next().unwrap().is_uppercase() {
if exported_functions.contains(&word.to_string()) ||
(exported_functions.is_empty() &&
(trimmed.contains(&format!("{}{{", word)) ||
trimmed.contains(&format!("{} {{", word)) ||
trimmed.contains(&format!("{}(", word)) ||
trimmed.contains(&format!("{}!", word)))) {
let file_relative = file_path.strip_prefix(project_dir.join(component_name))
.unwrap_or(file_path)
.to_string_lossy()
.to_string();
let entry = (line_num + 1, format!("[POTENTIAL USAGE] {}", line.trim()));
let file_entries = usage_summary
.entry(component_name.to_string())
.or_default()
.entry(file_relative.clone())
.or_default();
if !file_entries.iter().any(|(num, content)| *num == entry.0 && content == &entry.1) {
file_entries.push(entry);
}
}
}
}
}
}
Ok(())
}
fn visit_rust_files_for_usage(
dir: &Path,
component_to_check: &str,
component_name: &str,
project_dir: &Path,
usage_summary: &mut UsageSummary
) -> Result<()> {
if !dir.exists() || !dir.is_dir() {
return Ok(());
}
for entry in fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
visit_rust_files_for_usage(&path, component_to_check, component_name, project_dir, usage_summary)?;
} else if path.is_file() && path.extension().map_or(false, |ext| ext == "rs") {
check_file_for_usage(&path, component_to_check, component_name, project_dir, usage_summary)?;
}
}
Ok(())
}
fn visit_rust_files(dir: &Path, removed_component: &str) -> Result<()> {
if !dir.exists() || !dir.is_dir() {
return Ok(());
}
for entry in fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
visit_rust_files(path.as_path(), removed_component)?;
} else if path.is_file() && path.extension().map_or(false, |ext| ext == "rs") {
remove_imports_from_file(path.as_path(), removed_component)?;
}
}
Ok(())
}
fn update_workspace_members_after_removal(project_dir: &Path, removed_component: &str) -> Result<()> {
let workspace_cargo_path = project_dir.join("Cargo.toml");
if !workspace_cargo_path.exists() {
return Ok(());
}
let workspace_cargo_content = fs::read_to_string(&workspace_cargo_path)?;
let mut workspace_doc = workspace_cargo_content
.parse::<DocumentMut>()
.context("Failed to parse workspace Cargo.toml")?;
if let Some(workspace) = workspace_doc.get_mut("workspace") {
if let Some(workspace_table) = workspace.as_table_mut() {
if let Some(members) = workspace_table.get_mut("members") {
if let Some(members_array) = members.as_array_mut() {
let original_len = members_array.len();
members_array.retain(|member| {
if let Some(member_str) = member.as_str() {
member_str != removed_component
} else {
true
}
});
if members_array.len() < original_len {
println!("{} {}", "Removed".green(),
format!("'{}' from workspace members", removed_component).cyan());
}
}
}
}
}
fs::write(&workspace_cargo_path, workspace_doc.to_string())?;
Ok(())
}
fn remove_imports_from_file(file_path: &Path, removed_component: &str) -> Result<()> {
if !file_path.exists() {
return Ok(());
}
let content = fs::read_to_string(file_path)?;
let mut updated_content = String::new();
let mut updated = false;
let patterns = [
format!("use {}::*", removed_component), ];
for line in content.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with("//") {
updated_content.push_str(line);
updated_content.push('\n');
continue;
}
let mut should_skip = false;
if trimmed.starts_with("use ") || trimmed.starts_with("pub use ") {
for pattern in &patterns {
if trimmed.contains(pattern) ||
(trimmed.contains("{") && trimmed.contains("}") &&
trimmed.contains(&format!(", {}, ", removed_component)) ||
trimmed.contains(&format!("{{{}", removed_component)) ||
trimmed.contains(&format!("{}, ", removed_component)) ||
trimmed.contains(&format!(", {}}}", removed_component))) {
should_skip = true;
updated = true;
println!("{} {}", "Removing import:".yellow(), trimmed.cyan());
break;
}
}
if !should_skip &&
(trimmed.contains(&format!("::{}", removed_component)) ||
trimmed.contains(&format!("{}::", removed_component))) {
should_skip = true;
updated = true;
println!("{} {}", "Removing path import:".yellow(), trimmed.cyan());
}
}
if should_skip {
continue;
}
updated_content.push_str(line);
updated_content.push('\n');
}
if updated {
fs::write(file_path, updated_content)?;
println!("{} {}", "Updated".green(),
format!("imports in {}", file_path.display()).cyan());
}
Ok(())
}
fn list_components(project_dir: &Path) -> Result<()> {
let components = discover_components(project_dir)?;
if components.is_empty() {
println!("{}", "No components found".yellow());
return Ok(());
}
println!("{}", "Components:".green());
for component in components {
println!(" - {}", component);
}
Ok(())
}
fn discover_components(project_dir: &Path) -> Result<Vec<String>> {
let entries = fs::read_dir(project_dir)?
.filter_map(|entry| entry.ok())
.filter(|entry| entry.path().is_dir())
.filter(|entry| {
entry.path().join("Cargo.toml").exists() &&
!entry.file_name().to_string_lossy().starts_with('.')
})
.map(|entry| entry.file_name().to_string_lossy().to_string())
.collect();
Ok(entries)
}