use super::{ConfigIssue, Severity};
use regex::Regex;
#[allow(clippy::expect_used)] static DATE_PATTERN: std::sync::LazyLock<Regex> =
std::sync::LazyLock::new(|| Regex::new(r"\$\(date[^)]*\)").expect("valid regex pattern"));
#[derive(Debug, Clone, PartialEq)]
pub struct NonDeterministicConstruct {
pub line: usize,
pub column: usize,
pub construct_type: ConstructType,
pub context: String,
}
#[derive(Debug, Clone, PartialEq)]
pub enum ConstructType {
Random, Timestamp, ProcessId, Hostname, Uptime, }
impl ConstructType {
pub fn description(&self) -> &str {
match self {
ConstructType::Random => "$RANDOM generates unpredictable values",
ConstructType::Timestamp => "Timestamp generation is non-deterministic",
ConstructType::ProcessId => "$$ (process ID) changes between sessions",
ConstructType::Hostname => "$(hostname) may vary across environments",
ConstructType::Uptime => "$(uptime) changes constantly",
}
}
pub fn suggestion(&self) -> &str {
match self {
ConstructType::Random => "Use a fixed seed or remove randomness from config",
ConstructType::Timestamp => "Use a fixed version string instead of timestamps",
ConstructType::ProcessId => "Use a fixed session ID or remove process ID",
ConstructType::Hostname => "Use environment-specific config files instead",
ConstructType::Uptime => "Remove uptime-based logic from config",
}
}
}
pub fn analyze_nondeterminism(source: &str) -> Vec<NonDeterministicConstruct> {
let mut constructs = Vec::new();
for (line_num, line) in source.lines().enumerate() {
let line_num = line_num + 1;
if line.trim().starts_with('#') {
continue;
}
if let Some(col) = line.find("$RANDOM") {
constructs.push(NonDeterministicConstruct {
line: line_num,
column: col,
construct_type: ConstructType::Random,
context: line.trim().to_string(),
});
}
for mat in DATE_PATTERN.find_iter(line) {
constructs.push(NonDeterministicConstruct {
line: line_num,
column: mat.start(),
construct_type: ConstructType::Timestamp,
context: line.trim().to_string(),
});
}
if let Some(col) = line.find("$$") {
constructs.push(NonDeterministicConstruct {
line: line_num,
column: col,
construct_type: ConstructType::ProcessId,
context: line.trim().to_string(),
});
}
if line.contains("$(hostname)") {
constructs.push(NonDeterministicConstruct {
line: line_num,
column: line.find("$(hostname)").unwrap(),
construct_type: ConstructType::Hostname,
context: line.trim().to_string(),
});
}
if line.contains("$(uptime)") {
constructs.push(NonDeterministicConstruct {
line: line_num,
column: line.find("$(uptime)").unwrap(),
construct_type: ConstructType::Uptime,
context: line.trim().to_string(),
});
}
}
constructs
}
pub fn detect_nondeterminism(constructs: &[NonDeterministicConstruct]) -> Vec<ConfigIssue> {
constructs
.iter()
.map(|construct| ConfigIssue {
rule_id: "CONFIG-004".to_string(),
severity: Severity::Warning,
message: format!(
"Non-deterministic construct: {}",
construct.construct_type.description()
),
line: construct.line,
column: construct.column,
suggestion: Some(construct.construct_type.suggestion().to_string()),
})
.collect()
}
pub fn remove_nondeterminism(source: &str) -> String {
let constructs = analyze_nondeterminism(source);
if constructs.is_empty() {
return source.to_string();
}
let mut lines_to_comment: std::collections::HashSet<usize> = std::collections::HashSet::new();
for construct in &constructs {
lines_to_comment.insert(construct.line);
}
let mut result = Vec::new();
for (line_num, line) in source.lines().enumerate() {
let line_num = line_num + 1;
if lines_to_comment.contains(&line_num) {
result.push("# RASH: Non-deterministic construct removed".to_string());
result.push(format!("# {}", line));
} else {
result.push(line.to_string());
}
}
result.join("\n")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_config_004_detect_random() {
let source = r#"export SESSION_ID=$RANDOM"#;
let constructs = analyze_nondeterminism(source);
assert_eq!(constructs.len(), 1);
assert_eq!(constructs[0].construct_type, ConstructType::Random);
assert_eq!(constructs[0].line, 1);
}
#[test]
fn test_config_004_detect_timestamp() {
let source = r#"export BUILD_TAG="build-$(date +%s)""#;
let constructs = analyze_nondeterminism(source);
assert_eq!(constructs.len(), 1);
assert_eq!(constructs[0].construct_type, ConstructType::Timestamp);
assert_eq!(constructs[0].line, 1);
}
#[test]
fn test_config_004_detect_process_id() {
let source = r#"export TEMP_DIR="/tmp/work-$$""#;
let constructs = analyze_nondeterminism(source);
assert_eq!(constructs.len(), 1);
assert_eq!(constructs[0].construct_type, ConstructType::ProcessId);
assert_eq!(constructs[0].line, 1);
}
#[test]
fn test_config_004_detect_hostname() {
let source = r#"export HOST=$(hostname)"#;
let constructs = analyze_nondeterminism(source);
assert_eq!(constructs.len(), 1);
assert_eq!(constructs[0].construct_type, ConstructType::Hostname);
assert_eq!(constructs[0].line, 1);
}
#[test]
fn test_config_004_detect_multiple() {
let source = r#"export SESSION_ID=$RANDOM
export BUILD_TAG="build-$(date +%s)"
export TEMP_DIR="/tmp/work-$$""#;
let constructs = analyze_nondeterminism(source);
assert_eq!(constructs.len(), 3);
assert_eq!(constructs[0].construct_type, ConstructType::Random);
assert_eq!(constructs[1].construct_type, ConstructType::Timestamp);
assert_eq!(constructs[2].construct_type, ConstructType::ProcessId);
}
#[test]
fn test_config_004_create_issues() {
let source = r#"export SESSION_ID=$RANDOM"#;
let constructs = analyze_nondeterminism(source);
let issues = detect_nondeterminism(&constructs);
assert_eq!(issues.len(), 1);
assert_eq!(issues[0].rule_id, "CONFIG-004");
assert_eq!(issues[0].severity, Severity::Warning);
assert!(issues[0].message.contains("Non-deterministic"));
assert!(issues[0].suggestion.is_some());
}
#[test]
fn test_config_004_remove_nondeterminism() {
let source = r#"export PATH="/usr/local/bin:$PATH"
export SESSION_ID=$RANDOM
export EDITOR=vim"#;
let result = remove_nondeterminism(source);
assert!(result.contains("export PATH"));
assert!(result.contains("export EDITOR"));
assert!(result.contains("# RASH: Non-deterministic construct removed"));
assert!(result.contains("# export SESSION_ID=$RANDOM"));
}
#[test]
fn test_config_004_no_constructs() {
let source = r#"export PATH="/usr/local/bin:$PATH"
export EDITOR=vim"#;
let constructs = analyze_nondeterminism(source);
assert_eq!(constructs.len(), 0);
}
#[test]
fn test_config_004_idempotent() {
let source = r#"export SESSION_ID=$RANDOM"#;
let removed_once = remove_nondeterminism(source);
let removed_twice = remove_nondeterminism(&removed_once);
assert_eq!(removed_once, removed_twice, "Removal should be idempotent");
}
#[test]
fn test_config_004_process_id_not_in_variable() {
let source = r#"export VAR$$NAME=value"#;
let constructs = analyze_nondeterminism(source);
assert_eq!(constructs.len(), 1);
assert_eq!(constructs[0].construct_type, ConstructType::ProcessId);
}
#[test]
fn test_config_004_timestamp_variations() {
let source = r#"export T1=$(date)
export T2=$(date +%Y-%m-%d)
export T3=$(date -u +%s)"#;
let constructs = analyze_nondeterminism(source);
assert_eq!(constructs.len(), 3);
assert!(constructs
.iter()
.all(|c| c.construct_type == ConstructType::Timestamp));
}
}