Skip to main content

actr_cli/
utils.rs

1//! Utility functions for actr-cli
2
3use 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/// Execute a command and return the output
15#[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
41/// Execute a command and stream its output
42pub 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
65/// Check if a command is available in the system PATH
66pub 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
74/// Check if required tools are available
75pub 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
101/// Find the workspace root by looking for Cargo.toml with [workspace]
102pub 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
123/// Get the target directory for build outputs
124pub fn get_target_dir(project_root: &Path) -> std::path::PathBuf {
125    // Check for workspace root first
126    if let Ok(Some(workspace_root)) = find_workspace_root() {
127        workspace_root.join("target")
128    } else {
129        project_root.join("target")
130    }
131}
132
133/// Convert a string to PascalCase using heck crate
134pub fn to_pascal_case(input: &str) -> String {
135    heck::AsPascalCase(input).to_string()
136}
137
138/// Convert a string to snake_case using heck crate
139pub fn to_snake_case(input: &str) -> String {
140    heck::AsSnakeCase(input).to_string()
141}
142
143/// Ensure a directory exists, creating it if necessary
144#[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
153/// Read a fixture file, falling back to embedded assets when not on disk.
154pub 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
197/// Fetch the latest tag from a git repository with a timeout
198pub 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                // Parse tags like "refs/tags/v0.1.10" and get the last one
211                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("^{}")) // Filter out dereferenced tags
224            }
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/// Copy a file, creating parent directories as needed
245#[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
254/// Check if the current directory contains an Actr.toml file
255pub fn is_actr_project() -> bool {
256    Path::new("Actr.toml").exists()
257}
258
259/// Warn if not in an actr project directory
260pub 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        // These commands should exist on most systems
274        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        // Should not fail if directory already exists
288        ensure_dir_exists(&test_path).unwrap();
289    }
290
291    #[tokio::test]
292    async fn test_execute_command() {
293        // Test a simple command that should succeed
294        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}