diff --git a/.taskmaster/tasks/tasks.json b/.taskmaster/tasks/tasks.json index a38ea0e..45afd7d 100644 --- a/.taskmaster/tasks/tasks.json +++ b/.taskmaster/tasks/tasks.json @@ -229,7 +229,7 @@ "6", "9" ], - "status": "pending", + "status": "done", "subtasks": [ { "id": 1, @@ -237,9 +237,10 @@ "description": "Develop interactive item-by-item confirmation prompts with color-coded diffs and support for options: yes, no, all, none, diff, quit.", "dependencies": [], "details": "Use the `dialoguer` crate (v0.10+) to render confirmation and selection prompts. Integrate color-coded diff display before each action. Ensure prompt options are handled correctly and user choices are remembered for the session, especially 'all'.", - "status": "pending", + "status": "done", "testStrategy": "Simulate user input for all prompt options. Validate correct rendering of diffs and prompt flow. Test session memory for 'all' choice.", - "parentId": "undefined" + "parentId": "undefined", + "updatedAt": "2025-10-28T16:06:13.029Z" }, { "id": 2, @@ -249,9 +250,10 @@ 1 ], "details": "Parse CLI flags to detect non-interactive mode. Automatically confirm all actions when `--yes-all` is set. Ensure no prompts are shown and actions proceed without user intervention.", - "status": "pending", + "status": "done", "testStrategy": "Run automated tests with flags set. Confirm no prompts are displayed and all actions execute as expected.", - "parentId": "undefined" + "parentId": "undefined", + "updatedAt": "2025-10-28T16:06:13.031Z" }, { "id": 3, @@ -261,9 +263,10 @@ 2 ], "details": "Detect `--dry-run` flag and modify execution flow to only simulate actions. Output should be clearly marked as a preview, showing intended changes without performing them.", - "status": "pending", + "status": "done", "testStrategy": "Test with `--dry-run` flag. Validate that no changes occur and output is clearly marked as a preview.", - "parentId": "undefined" + "parentId": "undefined", + "updatedAt": "2025-10-28T16:06:13.034Z" }, { "id": 4, @@ -273,9 +276,10 @@ 1 ], "details": "Implement session state tracking to remember if the user selects 'all' or similar options, ensuring subsequent actions respect this choice until session end.", - "status": "pending", + "status": "done", "testStrategy": "Test session behavior by selecting 'all' and verifying that subsequent items are processed without further prompts.", - "parentId": "undefined" + "parentId": "undefined", + "updatedAt": "2025-10-28T16:06:13.037Z" }, { "id": 5, @@ -287,14 +291,16 @@ 4 ], "details": "Use the `ctrlc` crate to intercept Ctrl+C and perform cleanup or exit gracefully. Ensure exit codes reflect success, failure, or interruption for scripting compatibility.", - "status": "pending", + "status": "done", "testStrategy": "Simulate Ctrl+C during operation. Validate graceful shutdown and correct exit codes for all operation modes.", - "parentId": "undefined" + "parentId": "undefined", + "updatedAt": "2025-10-28T16:06:13.039Z" } ], "complexity": 6, "recommendedSubtasks": 5, - "expansionPrompt": "Separate into interactive prompt logic, non-interactive automation, dry-run simulation, session state management, and signal/exit handling." + "expansionPrompt": "Separate into interactive prompt logic, non-interactive automation, dry-run simulation, session state management, and signal/exit handling.", + "updatedAt": "2025-10-28T16:06:13.039Z" }, { "id": "5", @@ -616,9 +622,9 @@ ], "metadata": { "version": "1.0.0", - "lastModified": "2025-10-28T12:24:00.564Z", + "lastModified": "2025-10-28T16:06:13.039Z", "taskCount": 9, - "completedCount": 6, + "completedCount": 7, "tags": [ "master" ] diff --git a/Cargo.toml b/Cargo.toml index 2bd2871..c9396be 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,8 @@ repository = "https://github.com/onsails/ccsync" # Core dependencies with tilde requirements for patch compatibility clap = { version = "~4.5.28", features = ["derive", "cargo"] } anyhow = "~1.0.95" +dialoguer = "~0.11.0" +ctrlc = "~3.4.7" # Dev dependencies assert_cmd = "~2.0.17" diff --git a/crates/ccsync-cli/Cargo.toml b/crates/ccsync-cli/Cargo.toml index bf0eef3..dc72e7d 100644 --- a/crates/ccsync-cli/Cargo.toml +++ b/crates/ccsync-cli/Cargo.toml @@ -17,6 +17,8 @@ path = "src/main.rs" ccsync = { path = "../ccsync" } clap.workspace = true anyhow.workspace = true +dialoguer.workspace = true +ctrlc.workspace = true [dev-dependencies] assert_cmd.workspace = true diff --git a/crates/ccsync-cli/src/cli.rs b/crates/ccsync-cli/src/cli.rs index 25bcefe..76ca1a4 100644 --- a/crates/ccsync-cli/src/cli.rs +++ b/crates/ccsync-cli/src/cli.rs @@ -13,10 +13,6 @@ pub struct Cli { #[arg(short, long, global = true)] pub verbose: bool, - /// Run in non-interactive mode (skip all prompts) - #[arg(long, global = true)] - pub non_interactive: bool, - /// Accept all items in interactive mode without prompting #[arg(long, global = true)] pub yes_all: bool, diff --git a/crates/ccsync-cli/src/commands/to_global.rs b/crates/ccsync-cli/src/commands/to_global.rs index 2f27921..3e70547 100644 --- a/crates/ccsync-cli/src/commands/to_global.rs +++ b/crates/ccsync-cli/src/commands/to_global.rs @@ -6,6 +6,7 @@ use ccsync::config::{Config, SyncDirection}; use ccsync::sync::{SyncEngine, SyncReporter}; use crate::cli::{ConfigType, ConflictMode}; +use crate::interactive::InteractivePrompter; pub struct ToGlobal; @@ -15,6 +16,7 @@ impl ToGlobal { conflict: &ConflictMode, verbose: bool, dry_run: bool, + yes_all: bool, ) -> anyhow::Result<()> { if verbose { println!("Executing to-global command"); @@ -39,10 +41,33 @@ impl ToGlobal { let engine = SyncEngine::new(config, SyncDirection::ToGlobal) .context("Failed to initialize sync engine")?; - // Execute sync (source is local, destination is global) - let result = engine - .sync(&local_path, &global_path) - .context("Sync operation failed")?; + // Execute sync with optional interactive approval (source is local, destination is global) + let result = if yes_all || dry_run { + // Non-interactive: auto-approve all or just preview + engine + .sync(&local_path, &global_path) + .context("Sync operation failed")? + } else { + // Interactive mode: prompt for each action + let mut prompter = InteractivePrompter::new(); + match engine.sync_with_approver( + &local_path, + &global_path, + Some(Box::new(move |action| prompter.prompt(action))), + ) { + Ok(result) => result, + Err(e) => { + // Check if this is a user abort (not a real error) + let err_msg = e.to_string(); + if err_msg.contains("User aborted") { + eprintln!("\nSync cancelled by user."); + std::process::exit(0); // Clean exit, not an error + } else { + return Err(e).context("Sync operation failed"); + } + } + } + }; // Display results let summary = SyncReporter::generate_summary(&result); diff --git a/crates/ccsync-cli/src/commands/to_local.rs b/crates/ccsync-cli/src/commands/to_local.rs index 541c6e7..d28b573 100644 --- a/crates/ccsync-cli/src/commands/to_local.rs +++ b/crates/ccsync-cli/src/commands/to_local.rs @@ -6,6 +6,7 @@ use ccsync::config::{Config, SyncDirection}; use ccsync::sync::{SyncEngine, SyncReporter}; use crate::cli::{ConfigType, ConflictMode}; +use crate::interactive::InteractivePrompter; pub struct ToLocal; @@ -15,6 +16,7 @@ impl ToLocal { conflict: &ConflictMode, verbose: bool, dry_run: bool, + yes_all: bool, ) -> anyhow::Result<()> { if verbose { println!("Executing to-local command"); @@ -39,10 +41,33 @@ impl ToLocal { let engine = SyncEngine::new(config, SyncDirection::ToLocal) .context("Failed to initialize sync engine")?; - // Execute sync - let result = engine - .sync(&global_path, &local_path) - .context("Sync operation failed")?; + // Execute sync with optional interactive approval + let result = if yes_all || dry_run { + // Non-interactive: auto-approve all or just preview + engine + .sync(&global_path, &local_path) + .context("Sync operation failed")? + } else { + // Interactive mode: prompt for each action + let mut prompter = InteractivePrompter::new(); + match engine.sync_with_approver( + &global_path, + &local_path, + Some(Box::new(move |action| prompter.prompt(action))), + ) { + Ok(result) => result, + Err(e) => { + // Check if this is a user abort (not a real error) + let err_msg = e.to_string(); + if err_msg.contains("User aborted") { + eprintln!("\nSync cancelled by user."); + std::process::exit(0); // Clean exit, not an error + } else { + return Err(e).context("Sync operation failed"); + } + } + } + }; // Display results let summary = SyncReporter::generate_summary(&result); diff --git a/crates/ccsync-cli/src/interactive.rs b/crates/ccsync-cli/src/interactive.rs new file mode 100644 index 0000000..20fce8d --- /dev/null +++ b/crates/ccsync-cli/src/interactive.rs @@ -0,0 +1,240 @@ +//! Interactive prompting for sync operations + +use anyhow::{bail, Context, Result}; +use ccsync::comparison::FileComparator; +use ccsync::sync::SyncAction; +use dialoguer::console::Term; + +/// User's choice for a sync action +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UserChoice { + /// Approve this action + Yes, + /// Skip this action + No, + /// Approve this and all remaining actions + All, + /// Skip this and all remaining actions + None, + /// Show diff and re-prompt + Diff, + /// Quit immediately + Quit, +} + +/// Session state tracking for "all" or "none" choices +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SessionDecision { + /// Ask for each action + AskEach, + /// Auto-approve all remaining + ApproveAll, + /// Auto-skip all remaining + SkipAll, +} + +/// Interactive prompter for sync operations +pub struct InteractivePrompter { + session_state: SessionDecision, +} + +impl InteractivePrompter { + /// Create a new interactive prompter + #[must_use] + pub const fn new() -> Self { + Self { + session_state: SessionDecision::AskEach, + } + } + + /// Prompt user for approval of a sync action + /// + /// Returns true to proceed with the action, false to skip it. + /// + /// # Errors + /// + /// Returns an error if: + /// - User selects "quit" + /// - Terminal interaction fails + pub fn prompt(&mut self, action: &SyncAction) -> Result { + // Check session state first + match self.session_state { + SessionDecision::ApproveAll => return Ok(true), + SessionDecision::SkipAll => return Ok(false), + SessionDecision::AskEach => { + // Continue to prompt + } + } + + // Show what action will be performed + let description = Self::describe_action(action); + println!("\n{description}"); + + // Prompt with options + loop { + let choice = Self::show_prompt()?; + + match choice { + UserChoice::Yes => return Ok(true), + UserChoice::No => return Ok(false), + UserChoice::All => { + self.session_state = SessionDecision::ApproveAll; + return Ok(true); + } + UserChoice::None => { + self.session_state = SessionDecision::SkipAll; + return Ok(false); + } + UserChoice::Diff => { + Self::show_diff(action); + // Loop back to re-prompt + } + UserChoice::Quit => { + bail!("User aborted sync operation"); + } + } + } + } + + /// Show the selection prompt + fn show_prompt() -> Result { + let term = Term::stderr(); + + print!("Proceed? [y/n/a/s/d/q] (yes/no/all/skip-all/diff/quit): "); + std::io::Write::flush(&mut std::io::stdout()).context("Failed to flush stdout")?; + + loop { + let key = term + .read_char() + .context("Failed to read user input")?; + + // Echo the character + println!("{key}"); + + match key { + 'y' | 'Y' => return Ok(UserChoice::Yes), + 'n' | 'N' => return Ok(UserChoice::No), + 'a' | 'A' => return Ok(UserChoice::All), + 's' | 'S' => return Ok(UserChoice::None), + 'd' | 'D' => return Ok(UserChoice::Diff), + 'q' | 'Q' => return Ok(UserChoice::Quit), + '\n' | '\r' => { + // Enter key - default to no + println!("(defaulted to 'no')"); + return Ok(UserChoice::No); + } + _ => { + println!("Invalid key. Press y/n/a/s/d/q"); + print!("Proceed? [y/n/a/s/d/q]: "); + std::io::Write::flush(&mut std::io::stdout()) + .context("Failed to flush stdout")?; + } + } + } + } + + /// Describe the action in user-friendly terms + fn describe_action(action: &SyncAction) -> String { + match action { + SyncAction::Create { source, dest } => { + format!( + "📄 Create new file:\n Source: {}\n Dest: {}", + source.display(), + dest.display() + ) + } + SyncAction::Skip { path, reason } => { + format!("⊘ Skip file ({}):\n → {}", reason, path.display()) + } + SyncAction::Conflict { + source, + dest, + strategy, + source_newer, + } => { + let newer_indicator = if *source_newer { + "source newer" + } else { + "dest newer" + }; + format!( + "⚠️ Conflict detected ({}):\n Source: {}\n Dest: {}\n Strategy: {:?}", + newer_indicator, + source.display(), + dest.display(), + strategy + ) + } + } + } + + /// Show a diff for the action + fn show_diff(action: &SyncAction) { + match action { + SyncAction::Create { source, dest } => { + // Show new file content as additions + println!("\n--- New file ---"); + println!("+++ {}", dest.display()); + + match std::fs::read_to_string(source) { + Ok(content) => { + println!(); + for line in content.lines() { + println!("\x1b[32m+{line}\x1b[0m"); + } + } + Err(e) => { + eprintln!("\nWarning: Failed to read file: {e}"); + eprintln!("Source: {}", source.display()); + } + } + } + SyncAction::Skip { .. } => { + println!("\n--- No diff (file will be skipped) ---"); + } + SyncAction::Conflict { source, dest, .. } => { + // Generate and display diff + match FileComparator::generate_diff(source, dest) { + Ok(diff) => { + println!("\n{diff}"); + } + Err(e) => { + eprintln!("\nWarning: Failed to generate diff: {e}"); + eprintln!("Source: {}", source.display()); + eprintln!("Dest: {}", dest.display()); + eprintln!("You can inspect these files manually."); + } + } + } + } + } +} + +impl Default for InteractivePrompter { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_session_decision_states() { + assert_eq!(SessionDecision::AskEach, SessionDecision::AskEach); + assert_ne!(SessionDecision::AskEach, SessionDecision::ApproveAll); + } + + #[test] + fn test_user_choice_variants() { + assert_eq!(UserChoice::Yes, UserChoice::Yes); + assert_ne!(UserChoice::Yes, UserChoice::No); + } + + #[test] + fn test_prompter_creation() { + let _prompter = InteractivePrompter::new(); + let _default_prompter = InteractivePrompter::default(); + } +} diff --git a/crates/ccsync-cli/src/main.rs b/crates/ccsync-cli/src/main.rs index 0ef49ad..915c643 100644 --- a/crates/ccsync-cli/src/main.rs +++ b/crates/ccsync-cli/src/main.rs @@ -1,26 +1,34 @@ mod cli; mod commands; +mod interactive; use anyhow::Context; use clap::Parser; use cli::{Cli, Commands}; fn main() -> anyhow::Result<()> { + // Set up Ctrl+C handler for graceful interruption + ctrlc::set_handler(|| { + eprintln!("\n\nInterrupted by user (Ctrl+C)"); + std::process::exit(130); // Standard exit code for SIGINT + }) + .context("Failed to set Ctrl+C handler")?; + let cli = Cli::parse(); if cli.verbose { println!("Verbose mode enabled"); println!("Dry run: {}", cli.dry_run); - println!("Non-interactive: {}", cli.non_interactive); + println!("Yes all: {}", cli.yes_all); } match &cli.command { Commands::ToLocal { types, conflict } => { - commands::ToLocal::execute(types, conflict, cli.verbose, cli.dry_run) + commands::ToLocal::execute(types, conflict, cli.verbose, cli.dry_run, cli.yes_all) .context("Failed to execute to-local command")?; } Commands::ToGlobal { types, conflict } => { - commands::ToGlobal::execute(types, conflict, cli.verbose, cli.dry_run) + commands::ToGlobal::execute(types, conflict, cli.verbose, cli.dry_run, cli.yes_all) .context("Failed to execute to-global command")?; } Commands::Status { types } => { diff --git a/crates/ccsync-cli/tests/cli_tests.rs b/crates/ccsync-cli/tests/cli_tests.rs index 0b5f818..840ff83 100644 --- a/crates/ccsync-cli/tests/cli_tests.rs +++ b/crates/ccsync-cli/tests/cli_tests.rs @@ -103,8 +103,7 @@ fn test_to_local_with_multiple_types() { fn test_to_local_with_conflict_mode() { let mut cmd = Command::cargo_bin("ccsync").unwrap(); // May succeed or fail depending on whether directories exist - cmd.args(["to-local", "--conflict", "overwrite"]) - .assert(); + cmd.args(["to-local", "--conflict", "overwrite"]).assert(); } #[test] @@ -161,8 +160,7 @@ fn test_global_flags_with_to_local() { fn test_preserve_symlinks_flag() { let mut cmd = Command::cargo_bin("ccsync").unwrap(); // May succeed or fail depending on whether directories exist - cmd.args(["--preserve-symlinks", "to-local"]) - .assert(); + cmd.args(["--preserve-symlinks", "to-local"]).assert(); } #[test] diff --git a/crates/ccsync/src/sync.rs b/crates/ccsync/src/sync.rs index 6c4f2e9..fdbabe2 100644 --- a/crates/ccsync/src/sync.rs +++ b/crates/ccsync/src/sync.rs @@ -10,7 +10,8 @@ mod orchestrator; mod reporting; // Public exports for CLI integration -pub use orchestrator::SyncEngine; +pub use actions::SyncAction; +pub use orchestrator::{ApprovalCallback, SyncEngine}; pub use reporting::SyncReporter; /// Synchronization result with statistics diff --git a/crates/ccsync/src/sync/actions.rs b/crates/ccsync/src/sync/actions.rs index 49ea4e7..dd33691 100644 --- a/crates/ccsync/src/sync/actions.rs +++ b/crates/ccsync/src/sync/actions.rs @@ -8,14 +8,28 @@ use crate::comparison::{ComparisonResult, ConflictStrategy}; #[derive(Debug, Clone, PartialEq, Eq)] pub enum SyncAction { /// Create new file at destination - Create { source: PathBuf, dest: PathBuf }, + Create { + /// Source file path + source: PathBuf, + /// Destination file path + dest: PathBuf, + }, /// Skip this file (no action needed) - Skip { path: PathBuf, reason: String }, + Skip { + /// File path being skipped + path: PathBuf, + /// Reason for skipping + reason: String, + }, /// Conflict requiring resolution Conflict { + /// Source file path source: PathBuf, + /// Destination file path dest: PathBuf, + /// Conflict resolution strategy strategy: ConflictStrategy, + /// Whether source is newer than destination source_newer: bool, }, } diff --git a/crates/ccsync/src/sync/orchestrator.rs b/crates/ccsync/src/sync/orchestrator.rs index d97dfc2..3dfc9ef 100644 --- a/crates/ccsync/src/sync/orchestrator.rs +++ b/crates/ccsync/src/sync/orchestrator.rs @@ -5,13 +5,16 @@ use std::path::Path; use anyhow::Context; use super::SyncResult; -use super::actions::SyncActionResolver; +use super::actions::{SyncAction, SyncActionResolver}; use super::executor::FileOperationExecutor; use crate::comparison::{ConflictStrategy, FileComparator}; use crate::config::{Config, PatternMatcher, SyncDirection}; use crate::error::Result; use crate::scanner::{FileFilter, Scanner}; +/// Approval callback for interactive sync operations +pub type ApprovalCallback = Box Result>; + /// Main sync engine pub struct SyncEngine { config: Config, @@ -51,6 +54,23 @@ impl SyncEngine { /// /// Returns an error if sync fails. pub fn sync(&self, source_root: &Path, dest_root: &Path) -> Result { + self.sync_with_approver(source_root, dest_root, None) + } + + /// Execute the sync operation with an optional approval callback + /// + /// The approver callback is called before executing each action. + /// It should return Ok(true) to proceed, Ok(false) to skip, or Err to abort. + /// + /// # Errors + /// + /// Returns an error if sync fails or approver returns an error. + pub fn sync_with_approver( + &self, + source_root: &Path, + dest_root: &Path, + mut approver: Option, + ) -> Result { let mut result = SyncResult::default(); // Scan source directory @@ -86,6 +106,37 @@ impl SyncEngine { // Determine action let action = SyncActionResolver::resolve(file.path.clone(), dest_path, &comparison); + // Skip actions don't need approval (they're automatic decisions) + if matches!(action, super::actions::SyncAction::Skip { .. }) { + if let Err(e) = executor.execute(&action, &mut result) { + eprintln!("Error: {e}"); + result.errors.push(e.to_string()); + } + continue; + } + + // Check approval if callback provided (only for Create and Conflict actions) + if let Some(ref mut approve) = approver { + match approve(&action) { + Ok(true) => { + // Approved - continue to execution + } + Ok(false) => { + // Skipped by user + result.skipped += 1; + *result + .skip_reasons + .entry("user skipped".to_string()) + .or_insert(0) += 1; + continue; + } + Err(e) => { + // User aborted or error in approval + return Err(e); + } + } + } + // Execute action if let Err(e) = executor.execute(&action, &mut result) { eprintln!("Error: {e}");