diff --git a/src/cli.rs b/src/cli.rs index 83afee6..75a69df 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -25,6 +25,9 @@ pub enum Commands { /// IDE Management Ide(IdeCommand), + /// Clone a Git repository. + Clone(CloneArgs), + /// Open a project (equivalent to `project open`). Open(OpenArgs), } @@ -40,6 +43,21 @@ pub struct OpenArgs { pub ide: Option, } +/// Arguments for cloning a Git repository. +#[derive(Args)] +pub struct CloneArgs { + /// The Git repository URL to clone. + pub url: String, + + /// Specify which project root to use by index or exact path (defaults to the first root). + #[arg(short, long)] + pub root: Option, + + /// Clone into a specific directory name instead of inferring from the URL. + #[arg(short, long)] + pub name: Option, +} + /// Subcommands for `dev project`. #[derive(Subcommand)] pub enum ProjectSubcommand { diff --git a/src/commands/clone.rs b/src/commands/clone.rs new file mode 100644 index 0000000..6551be5 --- /dev/null +++ b/src/commands/clone.rs @@ -0,0 +1,76 @@ +//! Clone command implementation. + +use anyhow::{Result, anyhow}; +use std::path::PathBuf; +use std::process::{Command, Stdio}; + +use crate::cli::CloneArgs; +use crate::config::Config; + +/// Executes the clone command. +pub fn execute(args: CloneArgs) -> Result<()> { + let cfg = Config::load()?; + if cfg.projects_root.is_empty() { + return Err(anyhow!("No project roots configured. Please add a root first.")); + } + + // Determine target root + let target_root = match args.root { + Some(ref root_arg) => { + // Check if it's an index + if let Ok(idx) = root_arg.parse::() { + if idx < cfg.projects_root.len() { + cfg.projects_root[idx].clone() + } else { + return Err(anyhow!( + "Root index {} is out of bounds. You have {} configured roots.", + idx, + cfg.projects_root.len() + )); + } + } else { + // Otherwise treat as path string + PathBuf::from(root_arg) + } + } + None => cfg.projects_root[0].clone(), + }; + + if !target_root.exists() { + std::fs::create_dir_all(&target_root)?; + } + + // Extract repo name if not provided + let repo_name = match args.name { + Some(name) => name, + None => { + let path = std::path::Path::new(&args.url); + let file_name = path.file_name().unwrap_or_default().to_string_lossy(); + let name = if file_name.is_empty() { "repository" } else { file_name.as_ref() }; + name.strip_suffix(".git").unwrap_or(name).to_string() + } + }; + + let target_path = target_root.join(repo_name); + + println!("Cloning '{}' into '{}'...", args.url, target_path.display()); + + let mut cmd = Command::new("git"); + cmd.arg("clone"); + cmd.arg(&args.url); + cmd.arg(&target_path); + + // Stream output directly to terminal so user can interact if auth is needed + cmd.stdout(Stdio::inherit()); + cmd.stderr(Stdio::inherit()); + cmd.stdin(Stdio::inherit()); + + let status = cmd.status().map_err(|e| anyhow!("Failed to spawn git process: {}", e))?; + + if status.success() { + println!("Successfully cloned into {}", target_path.display()); + Ok(()) + } else { + Err(anyhow!("git clone failed with exit code: {}", status)) + } +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index ef51776..cedbd44 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -9,6 +9,7 @@ //! - [`ide`] — IDE management (`dev ide`) //! - [`project`] — Project management (`dev project`) +pub mod clone; pub mod config; pub mod ide; pub mod project; diff --git a/src/main.rs b/src/main.rs index 52c30a0..2b8d051 100644 --- a/src/main.rs +++ b/src/main.rs @@ -19,6 +19,7 @@ fn main() -> Result<()> { Some(Commands::Project(cmd)) => commands::project::execute(cmd)?, Some(Commands::Config(cmd)) => commands::config::execute(cmd)?, Some(Commands::Ide(cmd)) => commands::ide::execute(cmd)?, + Some(Commands::Clone(args)) => commands::clone::execute(args)?, Some(Commands::Open(args)) => commands::project::open_shortcut(args)?, None => tui::run()?, diff --git a/tests/cli_clone.rs b/tests/cli_clone.rs new file mode 100644 index 0000000..0d80875 --- /dev/null +++ b/tests/cli_clone.rs @@ -0,0 +1,200 @@ +mod common; + +use assert_cmd::Command as AssertCommand; +use predicates::prelude::*; +use serial_test::serial; +use std::path::PathBuf; +use std::process::Command; +use tempfile::TempDir; + +fn setup_test_env() -> (AssertCommand, TempDir, TempDir) { + let config_dir = tempfile::tempdir().unwrap(); + let roots_dir = tempfile::tempdir().unwrap(); + + let config = format!( + r#" +default_ide = "Cursor" +projects_root = ["{}"] +"#, + roots_dir.path().display().to_string().replace('\\', "\\\\") + ); + + std::fs::write(config_dir.path().join("config.toml"), config).unwrap(); + + let mut cmd = AssertCommand::cargo_bin("dev").unwrap(); + cmd.env("DEVCLI_CONFIG_DIR", config_dir.path()); + cmd.env("DEVCLI_SKIP_ONBOARDING", "1"); + + (cmd, config_dir, roots_dir) +} + +fn setup_test_env_with_roots(roots: &[PathBuf]) -> (AssertCommand, TempDir) { + let config_dir = tempfile::tempdir().unwrap(); + let roots_toml = roots + .iter() + .map(|r| format!("\"{}\"", r.display().to_string().replace('\\', "\\\\"))) + .collect::>() + .join(", "); + + let config = format!( + r#" +default_ide = "Cursor" +projects_root = [{roots_toml}] +"# + ); + + std::fs::write(config_dir.path().join("config.toml"), config).unwrap(); + + let mut cmd = AssertCommand::cargo_bin("dev").unwrap(); + cmd.env("DEVCLI_CONFIG_DIR", config_dir.path()); + cmd.env("DEVCLI_SKIP_ONBOARDING", "1"); + + (cmd, config_dir) +} + +#[test] +#[serial] +fn clone_command_fails_without_url() { + let (mut cmd, _cfg, _roots) = setup_test_env(); + + cmd.args(["clone"]).assert().failure().stderr(predicate::str::contains("Usage")); +} + +#[test] +#[serial] +fn clone_command_fails_when_no_roots_configured() { + let (mut cmd, _cfg) = setup_test_env_with_roots(&[]); + + let source_dir = tempfile::tempdir().unwrap(); + Command::new("git").arg("init").current_dir(source_dir.path()).status().unwrap(); + let source_url = source_dir.path().to_string_lossy().to_string(); + + cmd.args(["clone", &source_url]) + .assert() + .failure() + .stderr(predicate::str::contains("No project roots configured")); +} + +#[test] +#[serial] +fn clone_command_clones_local_repo() { + let (mut cmd, _cfg, roots) = setup_test_env(); + + // Create a real local git repo to clone from + let source_dir = tempfile::tempdir().unwrap(); + Command::new("git").arg("init").current_dir(source_dir.path()).status().unwrap(); + + let source_url = source_dir.path().to_string_lossy().to_string(); + + cmd.args(["clone", &source_url]).assert().success(); + + // The cloned repo should exist in roots_dir + let repo_name = source_dir.path().file_name().unwrap().to_string_lossy(); + let target_path = roots.path().join(repo_name.as_ref()); + assert!(target_path.exists()); + assert!(target_path.join(".git").exists()); +} + +#[test] +#[serial] +fn clone_command_with_custom_name() { + let (mut cmd, _cfg, roots) = setup_test_env(); + + let source_dir = tempfile::tempdir().unwrap(); + Command::new("git").arg("init").current_dir(source_dir.path()).status().unwrap(); + + let source_url = source_dir.path().to_string_lossy().to_string(); + + cmd.args(["clone", &source_url, "--name", "CustomRepoName"]).assert().success(); + + let target_path = roots.path().join("CustomRepoName"); + assert!(target_path.exists()); + assert!(target_path.join(".git").exists()); +} + +#[test] +#[serial] +fn clone_command_strips_git_suffix() { + let (mut cmd, _cfg, roots) = setup_test_env(); + + let source_parent = tempfile::tempdir().unwrap(); + let source_dir = source_parent.path().join("sample-repo.git"); + std::fs::create_dir_all(&source_dir).unwrap(); + Command::new("git").arg("init").current_dir(&source_dir).status().unwrap(); + + let source_url = source_dir.to_string_lossy().to_string(); + + cmd.args(["clone", &source_url]).assert().success(); + + let target_path = roots.path().join("sample-repo"); + assert!(target_path.exists()); + assert!(target_path.join(".git").exists()); +} + +#[test] +#[serial] +fn clone_command_with_root_index() { + let root1 = tempfile::tempdir().unwrap(); + let root2 = tempfile::tempdir().unwrap(); + + let (mut cmd, _cfg) = + setup_test_env_with_roots(&[root1.path().to_path_buf(), root2.path().to_path_buf()]); + + let source_dir = tempfile::tempdir().unwrap(); + Command::new("git").arg("init").current_dir(source_dir.path()).status().unwrap(); + + let source_url = source_dir.path().to_string_lossy().to_string(); + + cmd.args(["clone", &source_url, "--root", "1", "--name", "IndexedRepo"]).assert().success(); + + let target_path = root2.path().join("IndexedRepo"); + assert!(target_path.exists()); + assert!(target_path.join(".git").exists()); + assert!(!root1.path().join("IndexedRepo").exists()); +} + +#[test] +#[serial] +fn clone_command_with_out_of_bounds_root_index() { + let (mut cmd, _cfg, _roots) = setup_test_env(); + + let source_dir = tempfile::tempdir().unwrap(); + Command::new("git").arg("init").current_dir(source_dir.path()).status().unwrap(); + let source_url = source_dir.path().to_string_lossy().to_string(); + + cmd.args(["clone", &source_url, "--root", "5"]) + .assert() + .failure() + .stderr(predicate::str::contains("Root index 5 is out of bounds")); +} + +#[test] +#[serial] +fn clone_command_with_custom_root_path_creates_directory() { + let (mut cmd, _cfg, _roots) = setup_test_env(); + + let parent_dir = tempfile::tempdir().unwrap(); + let nonexistent_root = parent_dir.path().join("new_subfolder"); + + let source_dir = tempfile::tempdir().unwrap(); + Command::new("git").arg("init").current_dir(source_dir.path()).status().unwrap(); + let source_url = source_dir.path().to_string_lossy().to_string(); + + let root_str = nonexistent_root.to_string_lossy().to_string(); + cmd.args(["clone", &source_url, "--root", &root_str, "--name", "PathRepo"]).assert().success(); + + let target_path = nonexistent_root.join("PathRepo"); + assert!(target_path.exists()); + assert!(target_path.join(".git").exists()); +} + +#[test] +#[serial] +fn clone_command_fails_on_git_clone_error() { + let (mut cmd, _cfg, _roots) = setup_test_env(); + + cmd.args(["clone", "file:///nonexistent/invalid/repo.git"]) + .assert() + .failure() + .stderr(predicate::str::contains("git clone failed")); +} diff --git a/tests/tui_state.rs b/tests/tui_state.rs index 8e92532..6dd7469 100644 --- a/tests/tui_state.rs +++ b/tests/tui_state.rs @@ -6,6 +6,7 @@ use serial_test::serial; mod common; #[test] +#[serial] fn app_state_starts_empty() { let state = AppState::new(); @@ -16,6 +17,7 @@ fn app_state_starts_empty() { } #[test] +#[serial] fn quit_sets_should_quit() { let mut state = AppState::new(); @@ -25,6 +27,7 @@ fn quit_sets_should_quit() { } #[test] +#[serial] fn visible_items_returns_all_when_query_empty() { let mut state = AppState::new(); @@ -36,6 +39,7 @@ fn visible_items_returns_all_when_query_empty() { } #[test] +#[serial] fn visible_items_filters_case_insensitively() { let mut state = AppState::new(); @@ -51,6 +55,7 @@ fn visible_items_filters_case_insensitively() { } #[test] +#[serial] fn move_down_stops_at_last_project() { let mut state = AppState::new(); @@ -64,6 +69,7 @@ fn move_down_stops_at_last_project() { } #[test] +#[serial] fn move_up_stops_at_zero() { let mut state = AppState::new(); @@ -77,6 +83,7 @@ fn move_up_stops_at_zero() { } #[test] +#[serial] fn push_char_appends_to_search_and_resets_selection() { let mut state = AppState::new(); @@ -90,6 +97,7 @@ fn push_char_appends_to_search_and_resets_selection() { } #[test] +#[serial] fn pop_char_removes_last_character() { let mut state = AppState::new(); @@ -103,6 +111,7 @@ fn pop_char_removes_last_character() { } #[test] +#[serial] fn clamp_selection_resets_when_filtered_list_is_empty() { let mut state = AppState::new(); @@ -116,6 +125,7 @@ fn clamp_selection_resets_when_filtered_list_is_empty() { } #[test] +#[serial] fn clamp_selection_moves_selection_to_last_item() { let mut state = AppState::new(); @@ -129,6 +139,7 @@ fn clamp_selection_moves_selection_to_last_item() { } #[test] +#[serial] fn search_filters_projects() { let mut state = AppState::new(); @@ -142,6 +153,7 @@ fn search_filters_projects() { } #[test] +#[serial] fn move_down_on_recent_tab_stops_at_end() { use dev_cli::models::recent_project::RecentProject; use dev_cli::tui::state::Tab; @@ -163,6 +175,7 @@ fn move_down_on_recent_tab_stops_at_end() { } #[test] +#[serial] fn typing_resets_selection() { let mut state = AppState::new(); @@ -175,6 +188,7 @@ fn typing_resets_selection() { } #[test] +#[serial] fn refresh_reloads_projects_and_recents() { let mut state = AppState::new(); state.selected_index = 100; @@ -183,6 +197,7 @@ fn refresh_reloads_projects_and_recents() { } #[test] +#[serial] fn cycle_selected_ide_updates_override() { use dev_cli::models::ide::IdeSelection; let mut state = AppState::new(); @@ -203,6 +218,7 @@ fn cycle_selected_ide_updates_override() { } #[test] +#[serial] fn cycle_selected_ide_skips_uninstalled_ides() { use dev_cli::models::ide::IdeSelection; let mut state = AppState::new(); @@ -223,6 +239,7 @@ fn cycle_selected_ide_skips_uninstalled_ides() { } #[test] +#[serial] fn get_project_ide_falls_back_to_first_installed_if_default_uninstalled() { use dev_cli::models::ide::IdeSelection; let mut state = AppState::new(); @@ -235,6 +252,7 @@ fn get_project_ide_falls_back_to_first_installed_if_default_uninstalled() { } #[test] +#[serial] fn cycle_selected_ide_on_recent_tab() { use dev_cli::models::ide::IdeSelection; use dev_cli::models::recent_project::RecentProject; @@ -272,6 +290,7 @@ fn cycle_selected_ide_on_recent_tab() { } #[test] +#[serial] fn projects_tab_uses_default_ide_not_recent_ide() { use dev_cli::models::ide::IdeSelection; use dev_cli::models::recent_project::RecentProject; @@ -331,6 +350,7 @@ fn test_app_state_cycling_and_toggles() { } #[test] +#[serial] fn test_search_cursor_and_character_edits() { let mut state = AppState::new(); @@ -361,6 +381,7 @@ fn test_search_cursor_and_character_edits() { } #[test] +#[serial] fn test_editing_mode_field_manipulation() { use dev_cli::tui::state::{IdeTabFocus, InputMode, Tab}; @@ -491,6 +512,7 @@ fn test_settings_tab_root_management() { } #[test] +#[serial] fn test_tree_toggle_and_scroll_offset() { use dev_cli::tui::state::Tab; use dev_cli::tui::tree::TreeNode;