use anyhow::{Context, Result};
use colored::Colorize;
use dialoguer::{Confirm, Input, Select};
use std::path::{Path, PathBuf};
use std::fs;
use ferrisup_common::{fs::create_directory, cargo::*};
pub fn execute(action: Option<&str>, path: Option<&str>) -> Result<()> {
println!("{}", "FerrisUp Workspace Manager".bold().green());
let project_dir = if let Some(p) = path {
PathBuf::from(p)
} 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)
}
};
let action_str = if let Some(act) = action {
act.to_string()
} else {
let options = vec!["init", "add", "remove", "list", "optimize"];
let selection = Select::new()
.with_prompt("Select workspace action")
.items(&options)
.default(0)
.interact()?;
options[selection].to_string()
};
match action_str.as_str() {
"init" => init_workspace(&project_dir)?,
"add" => add_crate_to_workspace(&project_dir)?,
"remove" => remove_crate_from_workspace(&project_dir)?,
"list" => list_workspace_members(&project_dir)?,
"optimize" => optimize_workspace(&project_dir)?,
_ => return Err(anyhow::anyhow!("Invalid action. Use 'init', 'add', 'remove', 'list', or 'optimize'")),
}
Ok(())
}
fn init_workspace(project_dir: &Path) -> Result<()> {
let cargo_toml_path = project_dir.join("Cargo.toml");
let workspace_exists = if cargo_toml_path.exists() {
let content = read_cargo_toml(project_dir)?;
content.contains("[workspace]")
} else {
false
};
if workspace_exists {
println!("{}", "Workspace already initialized!".yellow());
return Ok(());
}
let default_dirs = vec![
"client/*".to_string(),
"server/*".to_string(),
"ferrisup_common/*".to_string(),
];
let mut dirs = if !cargo_toml_path.exists() {
default_dirs
} else {
println!("\n{}", "Converting existing project to workspace".green());
let options = vec![
"Use default workspace structure (client/*, server/*, ferrisup_common/*)",
"Discover existing crates",
"Manually specify members",
];
let selection = Select::new()
.with_prompt("How would you like to initialize the workspace?")
.items(&options)
.default(0)
.interact()?;
match selection {
0 => default_dirs,
1 => discover_crates(project_dir)?,
2 => {
let input = Input::<String>::new()
.with_prompt("Enter comma-separated workspace members (e.g. 'crate1, crate2/*, ferrisup_common/*')")
.interact()?;
input.split(',')
.map(|s| s.trim().to_string())
.collect()
},
_ => default_dirs,
}
};
let cargo_content = if !cargo_toml_path.exists() {
format!(
r#"[workspace]
members = [
{}
]
[workspace.dependencies]
# Common dependencies for workspace members
anyhow = "1.0"
serde = {{ version = "1.0", features = ["derive"] }}
log = "0.4"
"#,
dirs.iter()
.map(|dir| format!(" \"{}\",", dir))
.collect::<Vec<String>>()
.join("\n")
)
} else {
let content = read_cargo_toml(project_dir)?;
if content.contains("[package]") {
let package_name = extract_package_name(&content).unwrap_or("app".to_string());
let app_dir = project_dir.join(&package_name);
if !app_dir.exists() {
create_directory(&app_dir)?;
let src_dir = project_dir.join("src");
if src_dir.exists() {
let target_dir = app_dir.join("src");
fs::rename(&src_dir, &target_dir)?;
}
let app_cargo = app_dir.join("Cargo.toml");
fs::write(&app_cargo, extract_package_section(&content))?;
println!("{} {}", "Moved existing package to:".green(), app_dir.display());
dirs.push(package_name);
}
format!(
r#"[workspace]
members = [
{}
]
[workspace.dependencies]
# Common dependencies for workspace members
anyhow = "1.0"
serde = {{ version = "1.0", features = ["derive"] }}
log = "0.4"
"#,
dirs.iter()
.map(|dir| format!(" \"{}\",", dir))
.collect::<Vec<String>>()
.join("\n")
)
} else {
format!(
r#"{}
[workspace]
members = [
{}
]
[workspace.dependencies]
# Common dependencies for workspace members
anyhow = "1.0"
serde = {{ version = "1.0", features = ["derive"] }}
log = "0.4"
"#,
content,
dirs.iter()
.map(|dir| format!(" \"{}\",", dir))
.collect::<Vec<String>>()
.join("\n")
)
}
};
write_cargo_toml_content(project_dir, &cargo_content)?;
println!("{} {}", "Initialized workspace in:".green(), project_dir.display());
println!("{} {}", "Workspace members:".green(), dirs.join(", "));
for dir in &["client", "server", "ferrisup_common"] {
let path = project_dir.join(dir);
if !path.exists() {
create_directory(&path)?;
println!("{} {}", "Created directory:".green(), path.display());
}
}
Ok(())
}
fn add_crate_to_workspace(project_dir: &Path) -> Result<()> {
let cargo_content = read_cargo_toml(project_dir)?;
if !cargo_content.contains("[workspace]") {
return Err(anyhow::anyhow!("Not a Cargo workspace (no [workspace] section in Cargo.toml)"));
}
let crate_types = vec!["client", "server", "ferrisup_common", "custom"];
let selection = Select::new()
.with_prompt("Select crate type")
.items(&crate_types)
.default(0)
.interact()?;
let crate_type = crate_types[selection];
let crate_name = Input::<String>::new()
.with_prompt("Enter crate name")
.interact()?;
let crate_path = match crate_type {
"client" => project_dir.join("../../../client").join(&crate_name),
"server" => project_dir.join("server").join(&crate_name),
"ferrisup_common" => project_dir.join("../../../ferrisup_common").join(&crate_name),
_ => project_dir.join(&crate_name),
};
create_directory(&crate_path)?;
let is_bin = if crate_type == "client" || crate_type == "server" {
true
} else {
Confirm::new()
.with_prompt("Is this a binary crate? (No for library)")
.default(false)
.interact()?
};
let src_dir = crate_path.join("src");
create_directory(&src_dir)?;
if is_bin {
fs::write(
src_dir.join("main.rs"),
"fn main() {\n println!(\"Hello from {}!\");\n}\n".replace("{}", &crate_name)
)?;
} else {
fs::write(
src_dir.join("lib.rs"),
"//! {} library\n\n/// Example function\npub fn hello() -> &'static str {\n \"Hello from {}!\"\n}\n"
.replace("{}", &crate_name)
)?;
}
let crate_cargo_content = format!(
r#"[package]
name = "{}"
version = "0.1.0"
edition = "2021"
[dependencies]
"#,
if crate_type == "custom" {
crate_name.clone()
} else {
let project_name = project_dir.file_name()
.and_then(|name| name.to_str())
.map(|s| s.replace('-', "_"))
.unwrap_or_else(|| "project".to_string());
format!("{}-{}", project_name, crate_name)
}
);
fs::write(crate_path.join("Cargo.toml"), crate_cargo_content)?;
println!("{} {}", "Created crate:".green(), crate_path.display());
update_workspace_members(project_dir)?;
Ok(())
}
fn remove_crate_from_workspace(project_dir: &Path) -> Result<()> {
let cargo_content = read_cargo_toml(project_dir)?;
if !cargo_content.contains("[workspace]") {
return Err(anyhow::anyhow!("Not a Cargo workspace (no [workspace] section in Cargo.toml)"));
}
let members = list_workspace_crates(project_dir)?;
if members.is_empty() {
println!("{}", "No workspace members found".yellow());
return Ok(());
}
let selection = Select::new()
.with_prompt("Select crate to remove from workspace")
.items(&members)
.default(0)
.interact()?;
let crate_path = members[selection].clone();
let delete_files = Confirm::new()
.with_prompt(format!("Also delete {} files?", crate_path))
.default(false)
.interact()?;
if delete_files {
let full_path = project_dir.join(&crate_path);
fs::remove_dir_all(&full_path)
.context(format!("Failed to remove {}", full_path.display()))?;
println!("{} {}", "Deleted crate files:".green(), crate_path);
}
update_workspace_members(project_dir)?;
println!("{} {}", "Removed crate from workspace:".green(), crate_path);
Ok(())
}
fn list_workspace_members(project_dir: &Path) -> Result<()> {
let cargo_content = read_cargo_toml(project_dir)?;
if !cargo_content.contains("[workspace]") {
return Err(anyhow::anyhow!("Not a Cargo workspace (no [workspace] section in Cargo.toml)"));
}
let members = extract_workspace_members(&cargo_content);
println!("\n{}", "Workspace Members:".bold());
if members.is_empty() {
println!(" No members found");
} else {
for (i, member) in members.iter().enumerate() {
println!(" {}. {}", i + 1, member);
}
}
let crates = list_workspace_crates(project_dir)?;
println!("\n{}", "Found Crates:".bold());
if crates.is_empty() {
println!(" No crates found");
} else {
for (i, crate_path) in crates.iter().enumerate() {
println!(" {}. {}", i + 1, crate_path);
}
}
Ok(())
}
fn optimize_workspace(project_dir: &Path) -> Result<()> {
println!("{}", "Optimizing workspace...".green());
let cargo_content = read_cargo_toml(project_dir)?;
if !cargo_content.contains("[workspace]") {
return Err(anyhow::anyhow!("Not a Cargo workspace (no [workspace] section in Cargo.toml)"));
}
let mut improvements = Vec::new();
improvements.push("Checking for dependency issues...".to_string());
let updated = update_workspace_members(project_dir)?;
if updated {
improvements.push("✓ Updated workspace members list".to_string());
}
if !cargo_content.contains("[workspace.dependencies]") {
let updated_content = format!(
r#"{}\n
[workspace.dependencies]
# Common dependencies for workspace members
"#,
cargo_content
);
write_cargo_toml_content(project_dir, &updated_content)?;
let common_deps = vec![
("anyhow".to_string(), "1.0".to_string(), None),
("serde".to_string(), "1.0".to_string(), Some(vec!["derive".to_string()])),
("log".to_string(), "0.4".to_string(), None)
];
let cargo_path = project_dir.join("Cargo.toml");
update_cargo_with_dependencies(&cargo_path, common_deps, false)?;
improvements.push("✓ Added [workspace.dependencies] section".to_string());
}
println!("\n{}", "Workspace Optimization Results:".bold());
for improvement in improvements {
println!(" {}", improvement);
}
println!("\n{}", "Workspace optimized successfully!".green());
Ok(())
}
fn discover_crates(project_dir: &Path) -> Result<Vec<String>> {
let mut crates = Vec::new();
let walkdir = walkdir::WalkDir::new(project_dir)
.follow_links(true)
.max_depth(3) .into_iter()
.filter_map(|e| e.ok());
for entry in walkdir {
let path = entry.path();
if path == project_dir {
continue;
}
if path.is_dir() && path.join("Cargo.toml").exists() {
if let Ok(rel_path) = path.strip_prefix(project_dir) {
let rel_path_str = rel_path.to_string_lossy().to_string();
crates.push(rel_path_str);
}
}
}
if crates.is_empty() {
for dir in &["client", "server", "ferrisup_common"] {
let dir_path = project_dir.join(dir);
if dir_path.exists() && dir_path.is_dir() {
crates.push(format!("{}/*", dir));
}
}
}
Ok(crates)
}
fn list_workspace_crates(project_dir: &Path) -> Result<Vec<String>> {
let mut crates = Vec::new();
let cargo_content = read_cargo_toml(project_dir)?;
let members = extract_workspace_members(&cargo_content);
for member in members {
if member.contains('*') {
let parts: Vec<&str> = member.split('*').collect();
let prefix = parts[0];
let prefix_path = project_dir.join(prefix);
if prefix_path.exists() && prefix_path.is_dir() {
if let Ok(entries) = fs::read_dir(&prefix_path) {
for entry in entries.filter_map(|e| e.ok()) {
if entry.path().is_dir() && entry.path().join("Cargo.toml").exists() {
if let Ok(rel_path) = entry.path().strip_prefix(project_dir) {
crates.push(rel_path.to_string_lossy().to_string());
}
}
}
}
}
} else {
let member_path = project_dir.join(&member);
if member_path.exists() && member_path.is_dir() && member_path.join("Cargo.toml").exists() {
crates.push(member);
}
}
}
Ok(crates)
}
fn extract_workspace_members(cargo_content: &str) -> Vec<String> {
let mut members = Vec::new();
if let Some(workspace_section) = cargo_content.split("[workspace]").nth(1) {
if let Some(members_section) = workspace_section.split("members").nth(1) {
if let Some(members_list) = members_section.split('[').nth(1) {
if let Some(members_list) = members_list.split(']').next() {
for line in members_list.lines() {
let line = line.trim();
if line.starts_with('"') && line.contains('"') {
let member = line
.trim_start_matches('"')
.split('"')
.next()
.unwrap_or("")
.trim()
.trim_end_matches(',');
if !member.is_empty() {
members.push(member.to_string());
}
}
}
}
}
}
}
members
}
fn extract_package_name(cargo_content: &str) -> Option<String> {
if let Some(package_section) = cargo_content.split("[package]").nth(1) {
if let Some(name_line) = package_section
.lines()
.find(|line| line.trim().starts_with("name"))
{
if let Some(name) = name_line
.split('=')
.nth(1)
.map(|s| s.trim())
.map(|s| s.trim_matches('"'))
.map(|s| s.trim_matches('\''))
{
return Some(name.to_string());
}
}
}
None
}
fn extract_package_section(cargo_content: &str) -> String {
if let Some(package_section) = cargo_content.split("[package]").nth(1) {
if let Some(end_index) = package_section.find('[') {
let section = &package_section[..end_index];
return format!("[package]{}", section);
} else {
return format!("[package]{}", package_section);
}
}
String::new()
}