bashrs 6.66.0

Rust-to-Shell transpiler for deterministic bootstrap scripts
use super::{Fix, Severity, Validate, ValidationError};
use crate::emitter::escape::shell_escape;
use crate::ir::ShellExpression;
use crate::models::error::RashResult;

#[derive(Debug, Clone)]
pub enum VariableExpansion {
    Quoted(String),
    Unquoted(String),
    WordSplit(String),
    ArrayExpansion(String),
}

impl Validate for VariableExpansion {
    fn validate(&self) -> Result<(), ValidationError> {
        match self {
            VariableExpansion::Unquoted(var) => Err(ValidationError {
                rule: "SC2086",
                severity: Severity::Error,
                message: format!("Double quote to prevent globbing and word splitting: ${var}"),
                suggestion: Some(format!("Use \"${var}\" instead")),
                auto_fix: Some(Fix {
                    description: "Add quotes around variable".to_string(),
                    replacement: format!("\"${var}\""),
                }),
                line: None,
                column: None,
            }),
            _ => Ok(()),
        }
    }
}

#[derive(Debug, Clone)]
pub struct CommandSubstitution {
    pub command: String,
    pub context: SubstitutionContext,
}

#[derive(Debug, Clone)]
pub enum SubstitutionContext {
    Assignment,
    ArrayInit,
    Quoted,
    Unquoted,
}

impl Validate for CommandSubstitution {
    fn validate(&self) -> Result<(), ValidationError> {
        match self.context {
            SubstitutionContext::Unquoted => Err(ValidationError {
                rule: "SC2046",
                severity: Severity::Error,
                message: format!(
                    "Quote command substitution to prevent word splitting: $({})",
                    self.command
                ),
                suggestion: Some(format!("Use \"$({})\"", self.command)),
                auto_fix: Some(Fix {
                    description: "Add quotes around command substitution".to_string(),
                    replacement: format!("\"$({})\"", self.command),
                }),
                line: None,
                column: None,
            }),
            _ => Ok(()),
        }
    }
}

#[allow(clippy::result_large_err)]
pub fn validate_glob_pattern(pattern: &str) -> Result<String, ValidationError> {
    if pattern.starts_with('-') {
        return Err(ValidationError {
            rule: "SC2035",
            severity: Severity::Warning,
            message: "Use './' or -- to prevent glob patterns being interpreted as options"
                .to_string(),
            suggestion: Some(format!("Use './{pattern}'")),
            auto_fix: Some(Fix {
                description: "Prefix with './' to prevent option interpretation".to_string(),
                replacement: format!("./{pattern}"),
            }),
            line: None,
            column: None,
        });
    }

    if pattern.contains(['*', '?', '[']) {
        Ok(pattern.to_string())
    } else {
        Ok(shell_escape(pattern))
    }
}

#[derive(Debug, Clone)]
pub struct CommandSequence {
    pub commands: Vec<String>,
    pub exit_code_checks: Vec<ExitCodeCheck>,
}

#[derive(Debug, Clone)]
pub struct ExitCodeCheck {
    pub command_index: usize,
}

impl Validate for CommandSequence {
    fn validate(&self) -> Result<(), ValidationError> {
        for (i, check) in self.exit_code_checks.iter().enumerate() {
            if check.command_index != i {
                return Err(ValidationError {
                    rule: "SC2181",
                    severity: Severity::Style,
                    message: "Check exit code directly with 'if mycmd;', not indirectly with $?"
                        .to_string(),
                    suggestion: Some("Check $? immediately after command".to_string()),
                    auto_fix: None,
                    line: None,
                    column: None,
                });
            }
        }
        Ok(())
    }
}

#[derive(Debug, Clone)]
pub enum ConditionalExpression {
    StringComparison {
        left: Box<ShellExpression>,
        op: ComparisonOp,
        right: Box<ShellExpression>,
    },
    FileTest {
        op: FileTestOp,
        path: Box<ShellExpression>,
    },
}

#[derive(Debug, Clone)]
pub enum ComparisonOp {
    Eq,
    Ne,
    Lt,
    Gt,
    Le,
    Ge,
}

#[derive(Debug, Clone)]
pub enum FileTestOp {
    Exists,
    IsFile,
    IsDir,
    IsReadable,
    IsWritable,
    IsExecutable,
}

impl Validate for ConditionalExpression {
    fn validate(&self) -> Result<(), ValidationError> {
        match self {
            ConditionalExpression::StringComparison { left, right, .. } => {
                if !left.is_quoted() || !right.is_quoted() {
                    return Err(ValidationError {
                        rule: "SC2086",
                        severity: Severity::Error,
                        message: "Quote variables in conditionals to prevent word splitting"
                            .to_string(),
                        suggestion: Some("Both sides of comparison must be quoted".to_string()),
                        auto_fix: None,
                        line: None,
                        column: None,
                    });
                }
                Ok(())
            }
            ConditionalExpression::FileTest { path, .. } => {
                if !path.is_quoted() {
                    return Err(ValidationError {
                        rule: "SC2086",
                        severity: Severity::Error,
                        message: "Quote file path to prevent word splitting".to_string(),
                        suggestion: Some("File paths in tests must be quoted".to_string()),
                        auto_fix: None,
                        line: None,
                        column: None,
                    });
                }
                Ok(())
            }
        }
    }
}

#[allow(clippy::result_large_err)]
pub fn validate_backticks(command: &str) -> Result<(), ValidationError> {
    if command.contains('`') {
        Err(ValidationError {
            rule: "SC2006",
            severity: Severity::Style,
            message: "Use $(...) notation instead of legacy backticks".to_string(),
            suggestion: Some("Replace backticks with $()".to_string()),
            auto_fix: Some(Fix {
                description: "Convert backticks to modern syntax".to_string(),
                replacement: command.replace('`', "$()"),
            }),
            line: None,
            column: None,
        })
    } else {
        Ok(())
    }
}

#[allow(clippy::result_large_err)]
pub fn validate_cd_usage(command: &str) -> Result<(), ValidationError> {
    if command.trim().starts_with("cd ") && !command.contains("||") {
        Err(ValidationError {
            rule: "SC2164",
            severity: Severity::Warning,
            message: "Use 'cd ... || exit' or 'cd ... || return' in case cd fails".to_string(),
            suggestion: Some("Add error handling for cd command".to_string()),
            auto_fix: Some(Fix {
                description: "Add error handling".to_string(),
                replacement: format!("{} || exit 1", command.trim()),
            }),
            line: None,
            column: None,
        })
    } else {
        Ok(())
    }
}

#[allow(clippy::result_large_err)]
pub fn validate_read_command(command: &str) -> Result<(), ValidationError> {
    if command.contains("read ") && !command.contains("-r") {
        Err(ValidationError {
            rule: "SC2162",
            severity: Severity::Warning,
            message: "read without -r will mangle backslashes".to_string(),
            suggestion: Some("Use 'read -r' to preserve backslashes".to_string()),
            auto_fix: Some(Fix {
                description: "Add -r flag".to_string(),
                replacement: command.replace("read ", "read -r "),
            }),
            line: None,
            column: None,
        })
    } else {
        Ok(())
    }
}

#[allow(clippy::result_large_err)]
pub fn validate_unicode_quotes(text: &str) -> Result<(), ValidationError> {
    let unicode_quotes = ['\u{201c}', '\u{201d}', '\u{2018}', '\u{2019}'];

    for quote in &unicode_quotes {
        if text.contains(*quote) {
            return Err(ValidationError {
                rule: "SC2220",
                severity: Severity::Error,
                message: "Unicode quotes must be replaced with ASCII quotes".to_string(),
                suggestion: Some("Use standard ASCII quotes".to_string()),
                auto_fix: Some(Fix {
                    description: "Replace Unicode quotes".to_string(),
                    replacement: text
                        .replace(['\u{201c}', '\u{201d}'], "\"")
                        .replace(['\u{2018}', '\u{2019}'], "'"),
                }),
                line: None,
                column: None,
            });
        }
    }
    Ok(())
}

pub fn validate_all(snippet: &str) -> RashResult<()> {
    validate_backticks(snippet)
        .map_err(|e| crate::models::error::Error::ShellCheckValidation(Box::new(e)))?;
    validate_cd_usage(snippet)
        .map_err(|e| crate::models::error::Error::ShellCheckValidation(Box::new(e)))?;
    validate_read_command(snippet)
        .map_err(|e| crate::models::error::Error::ShellCheckValidation(Box::new(e)))?;
    validate_unicode_quotes(snippet)
        .map_err(|e| crate::models::error::Error::ShellCheckValidation(Box::new(e)))?;

    Ok(())
}

#[cfg(test)]
#[path = "rules_tests_variable_exp.rs"]
mod tests_extracted;