1use crate::error::{ActrCliError, Result};
4use std::path::Path;
5use std::process::{Command, Output};
6use std::time::Duration;
7use tokio::process::Command as TokioCommand;
8use tracing::{debug, info, warn};
9
10pub const GIT_FETCH_TIMEOUT: Duration = Duration::from_secs(5);
11
12#[allow(dead_code)]
14pub async fn execute_command(cmd: &str, args: &[&str], cwd: Option<&Path>) -> Result<Output> {
15 debug!("Executing command: {} {}", cmd, args.join(" "));
16
17 let mut command = TokioCommand::new(cmd);
18 command.args(args);
19
20 if let Some(cwd) = cwd {
21 command.current_dir(cwd);
22 }
23
24 let output = command.output().await?;
25
26 if !output.status.success() {
27 let stderr = String::from_utf8_lossy(&output.stderr);
28 return Err(ActrCliError::command_error(format!(
29 "Command '{}' failed with exit code {:?}: {}",
30 cmd,
31 output.status.code(),
32 stderr
33 )));
34 }
35
36 Ok(output)
37}
38
39pub async fn execute_command_streaming(cmd: &str, args: &[&str], cwd: Option<&Path>) -> Result<()> {
41 info!("Running: {} {}", cmd, args.join(" "));
42
43 let mut command = TokioCommand::new(cmd);
44 command.args(args);
45
46 if let Some(cwd) = cwd {
47 command.current_dir(cwd);
48 }
49
50 let status = command.status().await?;
51
52 if !status.success() {
53 return Err(ActrCliError::command_error(format!(
54 "Command '{}' failed with exit code {:?}",
55 cmd,
56 status.code()
57 )));
58 }
59
60 Ok(())
61}
62
63pub fn command_exists(cmd: &str) -> bool {
65 Command::new("which")
66 .arg(cmd)
67 .output()
68 .map(|output| output.status.success())
69 .unwrap_or(false)
70}
71
72pub fn check_required_tools() -> Result<()> {
74 let required_tools = vec![
75 ("cargo", "Rust toolchain"),
76 ("protoc", "Protocol Buffers compiler"),
77 ];
78
79 let mut missing_tools = Vec::new();
80
81 for (tool, description) in required_tools {
82 if !command_exists(tool) {
83 missing_tools.push((tool, description));
84 }
85 }
86
87 if !missing_tools.is_empty() {
88 let mut error_msg = "Missing required tools:\n".to_string();
89 for (tool, description) in missing_tools {
90 error_msg.push_str(&format!(" - {tool} ({description})\n"));
91 }
92 error_msg.push_str("\nPlease install the missing tools and try again.");
93 return Err(ActrCliError::command_error(error_msg));
94 }
95
96 Ok(())
97}
98
99pub fn find_workspace_root() -> Result<Option<std::path::PathBuf>> {
101 let mut current = std::env::current_dir()?;
102
103 loop {
104 let cargo_toml = current.join("Cargo.toml");
105 if cargo_toml.exists() {
106 let content = std::fs::read_to_string(&cargo_toml)?;
107 if content.contains("[workspace]") {
108 return Ok(Some(current));
109 }
110 }
111
112 match current.parent() {
113 Some(parent) => current = parent.to_path_buf(),
114 None => break,
115 }
116 }
117
118 Ok(None)
119}
120
121pub fn get_target_dir(project_root: &Path) -> std::path::PathBuf {
123 if let Ok(Some(workspace_root)) = find_workspace_root() {
125 workspace_root.join("target")
126 } else {
127 project_root.join("target")
128 }
129}
130
131pub fn to_pascal_case(input: &str) -> String {
133 heck::AsPascalCase(input).to_string()
134}
135
136pub fn to_snake_case(input: &str) -> String {
138 heck::AsSnakeCase(input).to_string()
139}
140
141#[allow(dead_code)]
143pub fn ensure_dir_exists(path: &Path) -> Result<()> {
144 if !path.exists() {
145 debug!("Creating directory: {}", path.display());
146 std::fs::create_dir_all(path)?;
147 }
148 Ok(())
149}
150
151pub async fn fetch_latest_git_tag(url: &str, fallback: &str) -> String {
153 debug!("Fetching latest tag for {}", url);
154
155 let fetch_task = async {
156 let output = TokioCommand::new("git")
157 .args(["ls-remote", "--tags", "--sort=v:refname", url])
158 .output()
159 .await;
160
161 match output {
162 Ok(output) if output.status.success() => {
163 let stdout = String::from_utf8_lossy(&output.stdout);
164 stdout
166 .lines()
167 .filter_map(|line| {
168 line.split("refs/tags/").nth(1).map(|tag| {
169 let tag = tag.trim();
170 if let Some(stripped) = tag.strip_prefix('v') {
171 stripped.to_string()
172 } else {
173 tag.to_string()
174 }
175 })
176 })
177 .rfind(|tag| !tag.contains("^{}")) }
179 _ => None,
180 }
181 };
182
183 match tokio::time::timeout(GIT_FETCH_TIMEOUT, fetch_task).await {
184 Ok(Some(tag)) => {
185 info!("Successfully fetched latest tag for {}: {}", url, tag);
186 tag
187 }
188 _ => {
189 warn!(
190 "Failed to fetch latest tag for {} or timed out, using fallback: {}",
191 url, fallback
192 );
193 fallback.to_string()
194 }
195 }
196}
197
198#[allow(dead_code)]
200pub fn copy_file_with_dirs(from: &Path, to: &Path) -> Result<()> {
201 if let Some(parent) = to.parent() {
202 ensure_dir_exists(parent)?;
203 }
204 std::fs::copy(from, to)?;
205 Ok(())
206}
207
208pub fn is_actr_project() -> bool {
210 Path::new("Actr.toml").exists()
211}
212
213pub fn warn_if_not_actr_project() {
215 if !is_actr_project() {
216 warn!("Not in an Actor-RTC project directory (no Actr.toml found)");
217 }
218}
219
220#[cfg(test)]
221mod tests {
222 use super::*;
223 use tempfile::TempDir;
224
225 #[test]
226 fn test_command_exists() {
227 assert!(command_exists("ls") || command_exists("dir"));
229 assert!(!command_exists("this_command_definitely_does_not_exist"));
230 }
231
232 #[test]
233 fn test_ensure_dir_exists() {
234 let temp_dir = TempDir::new().unwrap();
235 let test_path = temp_dir.path().join("test/nested/dir");
236
237 assert!(!test_path.exists());
238 ensure_dir_exists(&test_path).unwrap();
239 assert!(test_path.exists());
240
241 ensure_dir_exists(&test_path).unwrap();
243 }
244
245 #[tokio::test]
246 async fn test_execute_command() {
247 let result = execute_command("echo", &["hello"], None).await;
249 assert!(result.is_ok());
250
251 let output = result.unwrap();
252 let stdout = String::from_utf8_lossy(&output.stdout);
253 assert!(stdout.contains("hello"));
254 }
255}