rush_sync_server/commands/sync/
command.rs1use crate::commands::command::Command;
2use crate::core::prelude::*;
3use crate::sync::profiles::RemoteProfileStore;
4use crate::sync::transport::{
5 git_pull, restart_service, run_remote_command, sync_pull, sync_push, test_connection,
6};
7use std::path::PathBuf;
8
9#[derive(Debug, Default)]
10pub struct SyncCommand;
11
12impl SyncCommand {
13 pub fn new() -> Self {
14 Self
15 }
16}
17
18impl Command for SyncCommand {
19 fn name(&self) -> &'static str {
20 "sync"
21 }
22
23 fn description(&self) -> &'static str {
24 "Sync files and run remote deployment actions"
25 }
26
27 fn matches(&self, command: &str) -> bool {
28 command.trim().to_lowercase().starts_with("sync")
29 }
30
31 fn execute_sync(&self, args: &[&str]) -> Result<String> {
32 let store = RemoteProfileStore::new()?;
33
34 match args.first().copied() {
35 None | Some("-h" | "--help" | "help") => Ok(self.help_text()),
36 Some("push") => self.push(&store, args),
37 Some("pull") => self.pull(&store, args),
38 Some("test") => self.test(&store, args),
39 Some("exec") => self.exec(&store, args),
40 Some("restart") => self.restart(&store, args),
41 Some("git-pull") => self.git_pull(&store, args),
42 Some(sub) => Err(AppError::Validation(format!(
43 "Unknown sync subcommand '{}'. Use 'sync help'.",
44 sub
45 ))),
46 }
47 }
48
49 fn priority(&self) -> u8 {
50 73
51 }
52}
53
54impl SyncCommand {
55 fn push(&self, store: &RemoteProfileStore, args: &[&str]) -> Result<String> {
56 let (positionals, delete, dry_run) = parse_flags(args);
57 let remote_name = positionals.get(1).ok_or_else(|| {
58 AppError::Validation(
59 "Usage: sync push <remote> [local_path] [--delete] [--dry-run]".to_string(),
60 )
61 })?;
62
63 let profile = store.get(remote_name)?;
64 let local_path = positionals
65 .get(2)
66 .map(PathBuf::from)
67 .unwrap_or_else(|| PathBuf::from("www"));
68
69 let result = sync_push(&profile, &local_path, delete, dry_run)?;
70 Ok(format!(
71 "{}PUSH {} -> {} [{}]\n{}",
72 if dry_run { "[DRY-RUN] " } else { "" },
73 local_path.display(),
74 profile.remote_path,
75 remote_name,
76 result
77 ))
78 }
79
80 fn pull(&self, store: &RemoteProfileStore, args: &[&str]) -> Result<String> {
81 let (positionals, delete, dry_run) = parse_flags(args);
82 let remote_name = positionals.get(1).ok_or_else(|| {
83 AppError::Validation(
84 "Usage: sync pull <remote> [local_path] [--delete] [--dry-run]".to_string(),
85 )
86 })?;
87
88 let profile = store.get(remote_name)?;
89 let local_path = positionals
90 .get(2)
91 .map(PathBuf::from)
92 .unwrap_or_else(|| PathBuf::from(format!("download-{}", remote_name)));
93
94 let result = sync_pull(&profile, &local_path, delete, dry_run)?;
95 Ok(format!(
96 "{}PULL {} <- {} [{}]\n{}",
97 if dry_run { "[DRY-RUN] " } else { "" },
98 local_path.display(),
99 profile.remote_path,
100 remote_name,
101 result
102 ))
103 }
104
105 fn test(&self, store: &RemoteProfileStore, args: &[&str]) -> Result<String> {
106 let remote_name = args
107 .get(1)
108 .ok_or_else(|| AppError::Validation("Usage: sync test <remote>".to_string()))?;
109
110 let profile = store.get(remote_name)?;
111 test_connection(&profile)
112 }
113
114 fn exec(&self, store: &RemoteProfileStore, args: &[&str]) -> Result<String> {
115 if args.len() < 3 {
116 return Err(AppError::Validation(
117 "Usage: sync exec <remote> <command...>".to_string(),
118 ));
119 }
120
121 let remote_name = args[1];
122 let profile = store.get(remote_name)?;
123 let command = args[2..].join(" ");
124 run_remote_command(&profile, &command)
125 }
126
127 fn restart(&self, store: &RemoteProfileStore, args: &[&str]) -> Result<String> {
128 let remote_name = args.get(1).ok_or_else(|| {
129 AppError::Validation("Usage: sync restart <remote> [service]".to_string())
130 })?;
131
132 let service = args.get(2).copied().unwrap_or("rush-sync");
133 let profile = store.get(remote_name)?;
134
135 let result = restart_service(&profile, service)?;
136 Ok(format!(
137 "Service '{}' restarted on '{}'\n{}",
138 service, remote_name, result
139 ))
140 }
141
142 fn git_pull(&self, store: &RemoteProfileStore, args: &[&str]) -> Result<String> {
143 let remote_name = args.get(1).ok_or_else(|| {
144 AppError::Validation("Usage: sync git-pull <remote> [branch]".to_string())
145 })?;
146
147 let branch = args.get(2).copied();
148 let profile = store.get(remote_name)?;
149 let result = git_pull(&profile, branch)?;
150 Ok(format!(
151 "Remote git pull on '{}' (branch: {})\n{}",
152 remote_name,
153 branch.unwrap_or("main"),
154 result
155 ))
156 }
157
158 fn help_text(&self) -> String {
159 "Sync and remote actions\n\n\
160 Commands:\n\
161 sync push <remote> [local_path] [--delete] [--dry-run]\n\
162 sync pull <remote> [local_path] [--delete] [--dry-run]\n\
163 sync test <remote>\n\
164 sync exec <remote> <command...>\n\
165 sync restart <remote> [service]\n\
166 sync git-pull <remote> [branch]\n\n\
167 Flags:\n\
168 --delete Remove files on destination not present in source\n\
169 --dry-run Show what would be transferred without making changes (rsync only)\n\n\
170 Notes:\n\
171 - Uses rsync over SSH when available.\n\
172 - Falls back to scp when rsync is not installed.\n\
173 - Configure remotes via the 'remote' command."
174 .to_string()
175 }
176}
177
178fn parse_flags(args: &[&str]) -> (Vec<String>, bool, bool) {
179 let mut delete = false;
180 let mut dry_run = false;
181 let mut positionals = Vec::new();
182
183 for arg in args {
184 match *arg {
185 "--delete" => delete = true,
186 "--dry-run" | "-n" => dry_run = true,
187 _ => positionals.push((*arg).to_string()),
188 }
189 }
190
191 (positionals, delete, dry_run)
192}