//! Terraform provider information retrieval.
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::Path;
use std::process::Command;
/// Provider information from terraform providers command
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProviderInfo {
pub name: String,
pub namespace: String,
pub version: Option<String>,
pub version_constraints: Option<String>,
pub source: String,
}
/// Lock file entry
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProviderLock {
pub name: String,
pub version: String,
pub constraints: Option<String>,
pub hashes: Vec<String>,
}
/// Complete provider information result
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProvidersResult {
pub success: bool,
pub providers: Vec<ProviderInfo>,
pub locks: Option<Vec<ProviderLock>>,
pub message: String,
}
/// Get provider information
pub fn get_providers(
terraform_path: &Path,
project_dir: &Path,
include_lock: bool,
) -> anyhow::Result<ProvidersResult> {
// Run terraform providers command
let output = Command::new(terraform_path)
.arg("providers")
.current_dir(project_dir)
.output()?;
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
if !output.status.success() {
return Err(anyhow::anyhow!("Failed to get providers: {}", stderr));
}
// Parse provider output
let providers = parse_providers_output(&stdout);
// Parse lock file if requested
let locks = if include_lock {
parse_lock_file(project_dir).ok()
} else {
None
};
let message = format!("Found {} providers", providers.len());
Ok(ProvidersResult {
success: true,
providers,
locks,
message,
})
}
/// Parse terraform providers output
fn parse_providers_output(output: &str) -> Vec<ProviderInfo> {
let mut providers = Vec::new();
let mut seen: HashMap<String, bool> = HashMap::new();
for line in output.lines() {
let line = line.trim();
// Skip empty lines and headers
if line.is_empty()
|| line.starts_with("Providers required")
|| line.starts_with(".")
|| line.starts_with("├")
|| line.starts_with("└")
{
// Check if this is a provider line in tree format
if line.contains("provider[") || line.contains("registry.terraform.io") {
if let Some(provider) = parse_provider_line(line) {
let key = format!("{}/{}", provider.namespace, provider.name);
if let std::collections::hash_map::Entry::Vacant(e) = seen.entry(key) {
e.insert(true);
providers.push(provider);
}
}
}
continue;
}
// Parse provider lines
if line.contains("provider[") || line.contains("registry.terraform.io") {
if let Some(provider) = parse_provider_line(line) {
let key = format!("{}/{}", provider.namespace, provider.name);
if let std::collections::hash_map::Entry::Vacant(e) = seen.entry(key) {
e.insert(true);
providers.push(provider);
}
}
}
}
providers
}
/// Parse a single provider line
fn parse_provider_line(line: &str) -> Option<ProviderInfo> {
// Format: provider[registry.terraform.io/hashicorp/aws] 5.0.0
// or: └── provider[registry.terraform.io/hashicorp/aws] 5.0.0
let line = line
.trim_start_matches('├')
.trim_start_matches('└')
.trim_start_matches('─')
.trim_start_matches(' ')
.trim();
// Extract the provider part
if let Some(start) = line.find("provider[") {
let rest = &line[start + 9..];
if let Some(end) = rest.find(']') {
let provider_path = &rest[..end];
// Parse provider path: registry.terraform.io/namespace/name
let parts: Vec<&str> = provider_path.split('/').collect();
if parts.len() >= 3 {
let namespace = parts[parts.len() - 2].to_string();
let name = parts[parts.len() - 1].to_string();
// Extract version if present
let version = rest[end + 1..]
.split_whitespace()
.next()
.filter(|v| !v.is_empty())
.map(|v| v.to_string());
return Some(ProviderInfo {
name,
namespace: namespace.clone(),
version: version.clone(),
version_constraints: version,
source: provider_path.to_string(),
});
}
}
}
None
}
/// Parse .terraform.lock.hcl file
fn parse_lock_file(project_dir: &Path) -> anyhow::Result<Vec<ProviderLock>> {
let lock_path = project_dir.join(".terraform.lock.hcl");
if !lock_path.exists() {
return Ok(vec![]);
}
let content = fs::read_to_string(&lock_path)?;
parse_lock_hcl(&content)
}
/// Parse the lock file HCL content
fn parse_lock_hcl(content: &str) -> anyhow::Result<Vec<ProviderLock>> {
let mut locks = Vec::new();
let mut current_provider: Option<String> = None;
let mut current_version: Option<String> = None;
let mut current_constraints: Option<String> = None;
let mut current_hashes: Vec<String> = Vec::new();
for line in content.lines() {
let line = line.trim();
// Provider block start
if line.starts_with("provider \"") {
// Save previous provider if exists
if let (Some(name), Some(version)) = (¤t_provider, ¤t_version) {
locks.push(ProviderLock {
name: name.clone(),
version: version.clone(),
constraints: current_constraints.take(),
hashes: std::mem::take(&mut current_hashes),
});
}
// Extract new provider name
if let Some(start) = line.find('"') {
if let Some(end) = line[start + 1..].find('"') {
current_provider = Some(line[start + 1..start + 1 + end].to_string());
}
}
current_version = None;
current_constraints = None;
current_hashes.clear();
}
// Version
else if line.starts_with("version") {
if let Some(start) = line.find('"') {
if let Some(end) = line[start + 1..].find('"') {
current_version = Some(line[start + 1..start + 1 + end].to_string());
}
}
}
// Constraints
else if line.starts_with("constraints") {
if let Some(start) = line.find('"') {
if let Some(end) = line[start + 1..].find('"') {
current_constraints = Some(line[start + 1..start + 1 + end].to_string());
}
}
}
// Hashes
else if line.starts_with("\"h1:") || line.starts_with("\"zh:") {
if let Some(end) = line[1..].find('"') {
current_hashes.push(line[1..end + 1].to_string());
}
}
}
// Save last provider
if let (Some(name), Some(version)) = (current_provider, current_version) {
locks.push(ProviderLock {
name,
version,
constraints: current_constraints,
hashes: current_hashes,
});
}
Ok(locks)
}
/// Get provider version constraints from configuration
#[allow(dead_code)]
pub fn get_provider_requirements(project_dir: &Path) -> anyhow::Result<HashMap<String, String>> {
let mut requirements = HashMap::new();
// Read all .tf files looking for required_providers blocks
if let Ok(entries) = fs::read_dir(project_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_file() && path.extension().is_some_and(|e| e == "tf") {
if let Ok(content) = fs::read_to_string(&path) {
extract_provider_requirements(&content, &mut requirements);
}
}
}
}
Ok(requirements)
}
/// Extract provider requirements from HCL content
#[allow(dead_code)]
fn extract_provider_requirements(content: &str, requirements: &mut HashMap<String, String>) {
let mut in_required_providers = false;
let mut brace_depth = 0;
for line in content.lines() {
let line = line.trim();
if line.contains("required_providers") {
in_required_providers = true;
brace_depth = 0;
}
if in_required_providers {
brace_depth += line.matches('{').count() as i32;
brace_depth -= line.matches('}').count() as i32;
if brace_depth <= 0 && line.contains('}') {
in_required_providers = false;
continue;
}
// Look for version constraints
if line.contains("version") {
if let Some(start) = line.find('"') {
if let Some(end) = line[start + 1..].find('"') {
let version = line[start + 1..start + 1 + end].to_string();
// Try to find the provider name from previous lines or same line
if let Some(eq_pos) = line.find('=') {
let name = line[..eq_pos].trim().to_string();
if !name.is_empty() && name != "version" {
requirements.insert(name, version.clone());
}
}
}
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_provider_line() {
let line = "provider[registry.terraform.io/hashicorp/aws] 5.31.0";
let provider = parse_provider_line(line).unwrap();
assert_eq!(provider.name, "aws");
assert_eq!(provider.namespace, "hashicorp");
assert_eq!(provider.version, Some("5.31.0".to_string()));
}
#[test]
fn test_parse_provider_line_tree_format() {
let line = "└── provider[registry.terraform.io/hashicorp/random] 3.5.0";
let provider = parse_provider_line(line).unwrap();
assert_eq!(provider.name, "random");
assert_eq!(provider.namespace, "hashicorp");
}
#[test]
fn test_parse_lock_hcl() {
let content = r#"
provider "registry.terraform.io/hashicorp/aws" {
version = "5.31.0"
constraints = "~> 5.0"
hashes = [
"h1:abc123",
"zh:def456",
]
}
"#;
let locks = parse_lock_hcl(content).unwrap();
assert_eq!(locks.len(), 1);
assert_eq!(locks[0].version, "5.31.0");
assert_eq!(locks[0].constraints, Some("~> 5.0".to_string()));
}
#[test]
fn test_extract_provider_requirements() {
let content = r#"
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
"#;
let mut reqs = HashMap::new();
extract_provider_requirements(content, &mut reqs);
// Note: This simple parser may not catch all cases
// The terraform providers command is more reliable
}
}