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