sticks 0.3.6

A tool for managing C and C++ projects
Documentation
use anyhow::Result;
use clap::{Parser, Subcommand};
use std::env;
use sticks::{add_dependencies, add_sources, remove_dependencies, update_project, Language};

#[derive(Parser)]
#[command(name = "sticks")]
#[command(version, about = "A tool for managing C and C++ projects")]
#[command(args_conflicts_with_subcommands = true)]
struct Cli {
	#[command(subcommand)]
	command: Option<Commands>,
}

#[derive(Subcommand)]
enum Commands {
	#[command(about = "Create a new C project in a subdirectory")]
	#[command(
		after_help = "Examples:\n  sticks c myproject            # Create C project with Makefile\n  sticks c myproject --build cmake  # Create C project with CMake\n  sticks c myproject -p conan   # Create C project with Conan support"
	)]
	C {
		project_name: Vec<String>,
		#[arg(
			long,
			short,
			default_value = "makefile",
			help = "Build system: 'makefile' or 'cmake'"
		)]
		build: String,
		#[arg(long, short = 'p', help = "Package manager: 'conan' or 'vcpkg'")]
		package_manager: Option<String>,
	},
	#[command(about = "Create a new C++ project in a subdirectory")]
	#[command(
		after_help = "Examples:\n  sticks cpp myproject          # Create C++ project with Makefile\n  sticks cpp myproject --build cmake  # Create C++ project with CMake\n  sticks cpp myproject -p vcpkg # Create C++ project with vcpkg support"
	)]
	Cpp {
		project_name: Vec<String>,
		#[arg(
			long,
			short,
			default_value = "makefile",
			help = "Build system: 'makefile' or 'cmake'"
		)]
		build: String,
		#[arg(long, short = 'p', help = "Package manager: 'conan' or 'vcpkg'")]
		package_manager: Option<String>,
	},
	#[command(about = "Initialize a project in the current directory")]
	#[command(
		after_help = "Examples:\n  sticks init c                 # Initialize C project\n  sticks i cpp --build cmake    # Initialize C++ project with CMake\n  sticks i c -p conan           # Initialize C project with Conan support"
	)]
	#[command(visible_alias = "i")]
	Init {
		#[arg(value_parser = ["c", "cpp"])]
		language: Option<String>,
		#[arg(
			long,
			short,
			default_value = "makefile",
			help = "Build system: 'makefile' or 'cmake'"
		)]
		build: String,
		#[arg(long, short = 'p', help = "Package manager: 'conan' or 'vcpkg'")]
		package_manager: Option<String>,
	},
	#[command(about = "Add dependencies to your project's Makefile")]
	#[command(
		after_help = "Examples:\n  sticks add libcurl            # Add single dependency\n  sticks a libcurl openssl      # Add multiple dependencies\n  sticks add sqlite3 pthread    # Add libraries for C project"
	)]
	#[command(visible_alias = "a")]
	Add { dependency_name: Vec<String> },
	#[command(about = "Remove dependencies from your project's Makefile")]
	#[command(
		after_help = "Examples:\n  sticks remove libcurl         # Remove single dependency\n  sticks r libcurl openssl      # Remove multiple dependencies\n  sticks remove sqlite3         # Remove library from project"
	)]
	#[command(visible_alias = "r")]
	Remove { dependency_name: Vec<String> },
	#[command(about = "Add new source files to your project")]
	#[command(
		after_help = "Examples:\n  sticks src utils              # Add utils.c/.cpp\n  sticks s math parser          # Add multiple source files\n  sticks src database           # Add database.c/.cpp"
	)]
	#[command(visible_alias = "s")]
	Src { source_names: Vec<String> },
	#[command(about = "Update sticks to the latest version")]
	#[command(visible_alias = "u")]
	Update,
	#[command(about = "Manage project features (build system, package managers)")]
	#[command(
		after_help = "Examples:\n  sticks feature list           # List current project features\n  sticks f convert cmake        # Convert to CMake build system\n  sticks f add-pm conan myapp   # Add Conan package manager\n  sticks f rm-pm vcpkg          # Remove vcpkg package manager"
	)]
	#[command(visible_alias = "f")]
	Feature {
		#[command(subcommand)]
		action: FeatureAction,
	},
}

#[derive(Subcommand)]
enum FeatureAction {
	#[command(about = "List detected project features")]
	List,
	#[command(about = "Convert between build systems (makefile <-> cmake)")]
	#[command(
		after_help = "Examples:\n  sticks f convert cmake        # Convert current project to CMake\n  sticks f convert makefile     # Convert current project to Makefile\n  sticks f convert cmake myapp  # Convert specific project to CMake"
	)]
	Convert {
		#[arg(value_parser = ["makefile", "cmake"])]
		to_system: String,
		#[arg(help = "Project name (auto-detected from current directory if not provided)")]
		project_name: Option<String>,
	},
	#[command(about = "Add a package manager to the project")]
	#[command(
		after_help = "Examples:\n  sticks f add-pm conan         # Add Conan to current project\n  sticks f add-pm vcpkg         # Add vcpkg to current project\n  sticks f add-pm conan myapp   # Add Conan to specific project"
	)]
	#[command(visible_alias = "add-pm")]
	AddPackageManager {
		#[arg(value_parser = ["conan", "vcpkg"])]
		package_manager: String,
		#[arg(help = "Project name (auto-detected if not provided)")]
		project_name: Option<String>,
	},
	#[command(about = "Remove a package manager from the project")]
	#[command(
		after_help = "Examples:\n  sticks f rm-pm conan          # Remove Conan from current project\n  sticks f rm-pm vcpkg          # Remove vcpkg from current project"
	)]
	#[command(visible_alias = "rm-pm")]
	RemovePackageManager {
		#[arg(value_parser = ["conan", "vcpkg"])]
		package_manager: String,
	},
}

fn main() {
	if let Err(e) = run() {
		eprintln!("Error: {:#}", e);
		std::process::exit(1);
	}
}

fn run() -> Result<()> {
	let args: Vec<String> = env::args().collect();

	if args.len() == 1 {
		return sticks::interactive::run_interactive();
	}

	let args = handle_shortcuts(args);

	let cli = Cli::parse_from(&args);

	let command = match cli.command {
		Some(cmd) => cmd,
		None => return sticks::interactive::run_interactive(),
	};

	match command {
		Commands::C {
			project_name,
			build,
			package_manager,
		} => {
			validate_project_names(&project_name)?;
			let build_system = build.parse::<sticks::BuildSystem>()?;
			for name in project_name {
				match package_manager {
					Some(ref pm_str) => {
						let pm = pm_str.parse::<sticks::PackageManager>()?;
						sticks::create_project_with_system_and_pm(
							&name,
							Language::C,
							build_system,
							pm,
						)?;
					}
					None => {
						sticks::new_project_with_system(&name, Language::C, build_system)?;
					}
				}
			}
		}
		Commands::Cpp {
			project_name,
			build,
			package_manager,
		} => {
			validate_project_names(&project_name)?;
			let build_system = build.parse::<sticks::BuildSystem>()?;
			for name in project_name {
				match package_manager {
					Some(ref pm_str) => {
						let pm = pm_str.parse::<sticks::PackageManager>()?;
						sticks::create_project_with_system_and_pm(
							&name,
							Language::Cpp,
							build_system,
							pm,
						)?;
					}
					None => {
						sticks::new_project_with_system(&name, Language::Cpp, build_system)?;
					}
				}
			}
		}
		Commands::Init {
			language,
			build,
			package_manager,
		} => {
			let lang = match language {
				Some(l) => l.parse::<Language>()?,
				None => sticks::interactive::select_language(),
			};
			let build_system = build.parse::<sticks::BuildSystem>()?;
			match package_manager {
				Some(pm_str) => {
					let pm = pm_str.parse::<sticks::PackageManager>()?;
					sticks::init_project_with_system_and_pm(lang, build_system, pm)?;
				}
				None => {
					sticks::init_project_with_system(lang, build_system)?;
				}
			}
		}
		Commands::Add { dependency_name } => {
			if dependency_name.is_empty() {
				anyhow::bail!("Please specify at least one dependency to add");
			}
			add_dependencies(&dependency_name)?;
		}
		Commands::Remove { dependency_name } => {
			if dependency_name.is_empty() {
				anyhow::bail!("Please specify at least one dependency to remove");
			}
			remove_dependencies(&dependency_name)?;
		}
		Commands::Src { source_names } => {
			if source_names.is_empty() {
				anyhow::bail!("Please specify at least one source file to add");
			}
			let sources: Vec<&str> = source_names.iter().map(|s| s.as_str()).collect();
			add_sources(&sources)?;
		}
		Commands::Update => {
			update_project()?;
		}
		Commands::Feature { action } => {
			handle_feature_action(action)?;
		}
	}

	Ok(())
}

fn handle_feature_action(action: FeatureAction) -> Result<()> {
	use FeatureAction::*;

	match action {
		List => {
			sticks::list_features()?;
		}
		Convert {
			to_system,
			project_name,
		} => {
			let current_system = sticks::detect_build_system()?.ok_or_else(|| {
				anyhow::anyhow!("No build system detected in current project. Cannot convert.")
			})?;

			let target_system = to_system.parse::<sticks::BuildSystem>()?;
			let proj_name = project_name.unwrap_or_else(|| {
				std::env::current_dir()
					.ok()
					.and_then(|p| p.file_name().map(|n| n.to_string_lossy().to_string()))
					.unwrap_or_else(|| "project".to_string())
			});

			sticks::convert_build_system_interactive(current_system, target_system, &proj_name)?;
		}
		AddPackageManager {
			package_manager,
			project_name,
		} => {
			let pm = package_manager.parse::<sticks::PackageManager>()?;
			let proj_name = project_name.unwrap_or_else(|| {
				std::env::current_dir()
					.ok()
					.and_then(|p| p.file_name().map(|n| n.to_string_lossy().to_string()))
					.unwrap_or_else(|| "project".to_string())
			});

			sticks::add_package_manager_to_project(pm, &proj_name)?;
		}
		RemovePackageManager { package_manager } => {
			let pm = package_manager.parse::<sticks::PackageManager>()?;
			sticks::remove_package_manager_from_project(pm)?;
		}
	}

	Ok(())
}

fn handle_shortcuts(args: Vec<String>) -> Vec<String> {
	if args.len() < 2 {
		return args;
	}

	let mut new_args = vec![args[0].clone()];
	let first_arg = &args[1];

	let expanded = match first_arg.as_str() {
		"i" => "init",
		"s" => "src",
		"a" => "add",
		"r" => "remove",
		"u" => "update",
		_ => return args,
	};

	new_args.push(expanded.to_string());
	new_args.extend_from_slice(&args[2..]);
	new_args
}

fn validate_project_names(names: &[String]) -> Result<()> {
	if names.is_empty() {
		anyhow::bail!("Please specify at least one project name");
	}

	for name in names {
		if name.is_empty() {
			anyhow::bail!("Project name cannot be empty");
		}
		if name.starts_with('-') {
			anyhow::bail!("Project name cannot start with '-': {}", name);
		}
		if !name
			.chars()
			.all(|c| c.is_alphanumeric() || c == '_' || c == '-')
		{
			anyhow::bail!(
				"Project name can only contain alphanumeric characters, '-', or '_': {}",
				name
			);
		}
	}

	Ok(())
}