karpal-verify 0.5.0

External prover bridge and trust model for the Industrial Algebra ecosystem
Documentation
#[cfg(not(feature = "std"))]
use alloc::{string::String, vec::Vec};
#[cfg(feature = "std")]
use std::{path::Path, string::String, vec::Vec};

/// External prover backend command kind.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CommandKind {
    Smt,
    Lean,
    Kani,
}

/// Command-line configuration for an SMT solver.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SmtConfig {
    pub executable: String,
    pub args: Vec<String>,
}

impl Default for SmtConfig {
    fn default() -> Self {
        Self {
            executable: "z3".into(),
            args: vec!["-smt2".into()],
        }
    }
}

impl SmtConfig {
    pub fn new(executable: impl Into<String>) -> Self {
        Self {
            executable: executable.into(),
            args: Vec::new(),
        }
    }

    pub fn with_arg(mut self, arg: impl Into<String>) -> Self {
        self.args.push(arg.into());
        self
    }
}

/// How Lean verification should be executed.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LeanDriver {
    Direct,
    LakeEnv,
    LakeBuild,
}

/// Command-line configuration for Lean 4.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LeanConfig {
    pub executable: String,
    pub args: Vec<String>,
    pub driver: LeanDriver,
    pub lake_executable: String,
    pub lake_args: Vec<String>,
}

impl Default for LeanConfig {
    fn default() -> Self {
        Self {
            executable: "lean".into(),
            args: Vec::new(),
            driver: LeanDriver::Direct,
            lake_executable: "lake".into(),
            lake_args: Vec::new(),
        }
    }
}

impl LeanConfig {
    pub fn new(executable: impl Into<String>) -> Self {
        Self {
            executable: executable.into(),
            ..Self::default()
        }
    }

    pub fn with_arg(mut self, arg: impl Into<String>) -> Self {
        self.args.push(arg.into());
        self
    }

    pub fn with_driver(mut self, driver: LeanDriver) -> Self {
        self.driver = driver;
        self
    }

    pub fn with_lake_executable(mut self, executable: impl Into<String>) -> Self {
        self.lake_executable = executable.into();
        self
    }

    pub fn with_lake_arg(mut self, arg: impl Into<String>) -> Self {
        self.lake_args.push(arg.into());
        self
    }
}

/// Command-line configuration for Kani bounded model checking.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct KaniConfig {
    pub executable: String,
    pub args: Vec<String>,
}

impl Default for KaniConfig {
    fn default() -> Self {
        Self {
            executable: "kani".into(),
            args: Vec::new(),
        }
    }
}

impl KaniConfig {
    pub fn new(executable: impl Into<String>) -> Self {
        Self {
            executable: executable.into(),
            args: Vec::new(),
        }
    }

    pub fn with_arg(mut self, arg: impl Into<String>) -> Self {
        self.args.push(arg.into());
        self
    }
}

/// Dry-run invocation plan for an external tool.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InvocationPlan {
    pub kind: CommandKind,
    pub executable: String,
    pub args: Vec<String>,
    pub working_directory: Option<String>,
    pub input_files: Vec<String>,
}

impl InvocationPlan {
    pub fn render_shell(&self) -> String {
        let joined = self
            .args
            .iter()
            .map(|arg| shell_escape(arg))
            .collect::<Vec<_>>()
            .join(" ");
        if joined.is_empty() {
            self.executable.clone()
        } else {
            format!("{} {}", self.executable, joined)
        }
    }
}

#[cfg(feature = "std")]
impl InvocationPlan {
    pub fn smt(config: &SmtConfig, script: impl AsRef<Path>) -> Self {
        let script = script.as_ref().to_path_buf();
        let working_directory = working_directory_for(&script);
        let mut args = config.args.clone();
        args.push(path_arg_for_working_directory(
            &script,
            working_directory.as_deref(),
        ));
        Self {
            kind: CommandKind::Smt,
            executable: config.executable.clone(),
            args,
            working_directory,
            input_files: vec![path_to_string(&script)],
        }
    }

    pub fn lean(config: &LeanConfig, module: impl AsRef<Path>) -> Self {
        let module = module.as_ref().to_path_buf();
        match config.driver {
            LeanDriver::Direct => {
                let working_directory = working_directory_for(&module);
                let mut args = config.args.clone();
                args.push(path_arg_for_working_directory(
                    &module,
                    working_directory.as_deref(),
                ));
                Self {
                    kind: CommandKind::Lean,
                    executable: config.executable.clone(),
                    args,
                    working_directory,
                    input_files: vec![path_to_string(&module)],
                }
            }
            LeanDriver::LakeEnv => {
                let lean_dir = module.parent().unwrap_or_else(|| Path::new("."));
                let root = lean_dir.parent().unwrap_or(lean_dir);
                let relative_module = module
                    .strip_prefix(root)
                    .unwrap_or(&module)
                    .to_string_lossy()
                    .into_owned();
                let mut args = config.lake_args.clone();
                args.push("env".into());
                args.push(config.executable.clone());
                args.extend(config.args.iter().cloned());
                args.push(relative_module);
                Self {
                    kind: CommandKind::Lean,
                    executable: config.lake_executable.clone(),
                    args,
                    working_directory: Some(path_to_string(root)),
                    input_files: vec![
                        path_to_string(&module),
                        path_to_string(&root.join("lakefile.lean")),
                        path_to_string(&root.join("lean-toolchain")),
                    ],
                }
            }
            LeanDriver::LakeBuild => {
                let lean_dir = module.parent().unwrap_or_else(|| Path::new("."));
                let root = lean_dir.parent().unwrap_or(lean_dir);
                let module_target = module
                    .file_stem()
                    .and_then(|stem| stem.to_str())
                    .unwrap_or("Main")
                    .to_string();
                let mut args = config.lake_args.clone();
                args.push("build".into());
                args.push(module_target);
                Self {
                    kind: CommandKind::Lean,
                    executable: config.lake_executable.clone(),
                    args,
                    working_directory: Some(path_to_string(root)),
                    input_files: vec![
                        path_to_string(&module),
                        path_to_string(&root.join("lakefile.lean")),
                        path_to_string(&root.join("lean-toolchain")),
                    ],
                }
            }
        }
    }

    pub fn kani(config: &KaniConfig, harness: impl AsRef<Path>, harness_name: &str) -> Self {
        let harness = harness.as_ref().to_path_buf();
        let working_directory = working_directory_for(&harness);
        let mut args = config.args.clone();
        args.push("--harness".into());
        args.push(harness_name.into());
        args.push(path_arg_for_working_directory(
            &harness,
            working_directory.as_deref(),
        ));
        Self {
            kind: CommandKind::Kani,
            executable: config.executable.clone(),
            args,
            working_directory,
            input_files: vec![path_to_string(&harness)],
        }
    }
}

#[cfg(feature = "std")]
fn working_directory_for(path: &Path) -> Option<String> {
    path.parent()
        .filter(|parent| !parent.as_os_str().is_empty())
        .map(path_to_string)
}

#[cfg(feature = "std")]
fn path_arg_for_working_directory(path: &Path, working_directory: Option<&str>) -> String {
    if working_directory.is_some() {
        path.file_name()
            .and_then(|file_name| file_name.to_str())
            .map(String::from)
            .unwrap_or_else(|| path_to_string(path))
    } else {
        path_to_string(path)
    }
}

#[cfg(feature = "std")]
fn path_to_string(path: &Path) -> String {
    path.to_string_lossy().into_owned()
}

fn shell_escape(arg: &str) -> String {
    if arg
        .chars()
        .all(|c| c.is_ascii_alphanumeric() || matches!(c, '/' | '-' | '_' | '.' | ':'))
    {
        arg.to_string()
    } else {
        format!("'{}'", arg.replace('\'', "'\\''"))
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn shell_render_quotes_complex_args() {
        let plan = InvocationPlan {
            kind: CommandKind::Lean,
            executable: "lean".into(),
            args: vec!["My File.lean".into()],
            working_directory: None,
            input_files: vec!["My File.lean".into()],
        };
        assert!(plan.render_shell().contains("'My File.lean'"));
    }

    #[cfg(feature = "std")]
    #[test]
    fn smt_plan_includes_script_path() {
        let plan = InvocationPlan::smt(
            &SmtConfig::default(),
            std::path::PathBuf::from("out/test.smt2"),
        );
        assert_eq!(plan.kind, CommandKind::Smt);
        assert!(plan.input_files[0].ends_with("test.smt2"));
    }

    #[cfg(feature = "std")]
    #[test]
    fn lean_project_plan_uses_lake_from_project_root() {
        let plan = InvocationPlan::lean(
            &LeanConfig::default().with_driver(LeanDriver::LakeEnv),
            std::path::PathBuf::from("target/verify/lean/KarpalVerify.lean"),
        );
        assert_eq!(plan.kind, CommandKind::Lean);
        assert_eq!(plan.executable, "lake");
        assert_eq!(plan.working_directory.as_deref(), Some("target/verify"));
        assert_eq!(plan.args, vec!["env", "lean", "lean/KarpalVerify.lean"]);
        assert!(
            plan.input_files
                .iter()
                .any(|path| path.ends_with("lakefile.lean"))
        );
        assert!(
            plan.input_files
                .iter()
                .any(|path| path.ends_with("lean-toolchain"))
        );
    }

    #[cfg(feature = "std")]
    #[test]
    fn lean_project_plan_can_use_lake_build() {
        let plan = InvocationPlan::lean(
            &LeanConfig::default().with_driver(LeanDriver::LakeBuild),
            std::path::PathBuf::from("target/verify/lean/KarpalVerify.lean"),
        );
        assert_eq!(plan.kind, CommandKind::Lean);
        assert_eq!(plan.executable, "lake");
        assert_eq!(plan.working_directory.as_deref(), Some("target/verify"));
        assert_eq!(plan.args, vec!["build", "KarpalVerify"]);
        assert!(
            plan.input_files
                .iter()
                .any(|path| path.ends_with("lakefile.lean"))
        );
        assert!(
            plan.input_files
                .iter()
                .any(|path| path.ends_with("lean-toolchain"))
        );
    }

    #[cfg(feature = "std")]
    #[test]
    fn kani_plan_invokes_standalone_kani_for_generated_harness() {
        let plan = InvocationPlan::kani(
            &KaniConfig::default(),
            std::path::PathBuf::from("target/verify/kani/sum_assoc.rs"),
            "sum_assoc",
        );
        assert_eq!(plan.kind, CommandKind::Kani);
        assert_eq!(plan.executable, "kani");
        assert_eq!(plan.args, vec!["--harness", "sum_assoc", "sum_assoc.rs"]);
        assert_eq!(
            plan.working_directory.as_deref(),
            Some("target/verify/kani")
        );
        assert!(plan.input_files[0].ends_with("sum_assoc.rs"));
    }
}