Skip to main content

actr_cli/
utils.rs

1//! Utility functions for actr-cli
2
3use 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/// Execute a command and return the output
13#[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
39/// Execute a command and stream its output
40pub 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
63/// Check if a command is available in the system PATH
64pub 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
72/// Check if required tools are available
73pub 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
99/// Find the workspace root by looking for Cargo.toml with [workspace]
100pub 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
121/// Get the target directory for build outputs
122pub fn get_target_dir(project_root: &Path) -> std::path::PathBuf {
123    // Check for workspace root first
124    if let Ok(Some(workspace_root)) = find_workspace_root() {
125        workspace_root.join("target")
126    } else {
127        project_root.join("target")
128    }
129}
130
131/// Convert a string to PascalCase using heck crate
132pub fn to_pascal_case(input: &str) -> String {
133    heck::AsPascalCase(input).to_string()
134}
135
136/// Convert a string to snake_case using heck crate
137pub fn to_snake_case(input: &str) -> String {
138    heck::AsSnakeCase(input).to_string()
139}
140
141/// Ensure a directory exists, creating it if necessary
142#[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
151/// Fetch the latest tag from a git repository with a timeout
152pub 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                // Parse tags like "refs/tags/v0.1.10" and get the last one
165                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("^{}")) // Filter out dereferenced tags
178            }
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/// Copy a file, creating parent directories as needed
199#[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
208/// Check if the current directory contains an Actr.toml file
209pub fn is_actr_project() -> bool {
210    Path::new("Actr.toml").exists()
211}
212
213/// Warn if not in an actr project directory
214pub 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        // These commands should exist on most systems
228        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        // Should not fail if directory already exists
242        ensure_dir_exists(&test_path).unwrap();
243    }
244
245    #[tokio::test]
246    async fn test_execute_command() {
247        // Test a simple command that should succeed
248        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}