Skip to main content

karpal_verify/
artifact.rs

1// Copyright (C) 2026 Industrial Algebra
2// SPDX-License-Identifier: Apache-2.0
3
4use crate::{
5    InvocationPlan, KaniConfig, LeanConfig, LeanExport, LeanProject, ObligationBundle, SmtConfig,
6    export_kani_bundle, export_lean_bundle_structured, export_smt_bundle,
7};
8
9#[cfg(not(feature = "std"))]
10use alloc::{string::String, vec::Vec};
11#[cfg(feature = "std")]
12use std::{
13    fs,
14    path::{Path, PathBuf},
15    string::String,
16    vec::Vec,
17};
18
19/// Schema version for serialized Lean manifest JSON.
20pub const LEAN_MANIFEST_SCHEMA_VERSION: &str = "1";
21
22/// Written artifact metadata.
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct ArtifactRecord {
25    pub name: String,
26    pub path: String,
27}
28
29/// Report file links attached back onto a generated Lean manifest.
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct LeanManifestReportFiles {
32    pub schema_version: String,
33    pub json_path: String,
34    pub markdown_path: String,
35    pub lean_diagnostics_json_path: Option<String>,
36}
37
38/// Lean package metadata serialized into the generated manifest.
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct LeanManifestProject {
41    pub package_name: String,
42    pub toolchain: String,
43    pub requires_mathlib: bool,
44}
45
46/// Lean import alias metadata serialized into the generated manifest.
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct LeanManifestAlias {
49    pub alias: String,
50    pub target: String,
51}
52
53/// Lean prelude metadata serialized into the generated manifest.
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct LeanManifestPrelude {
56    pub imports: Vec<String>,
57    pub aliases: Vec<LeanManifestAlias>,
58}
59
60/// Lean theorem metadata serialized into the generated manifest.
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub struct LeanManifestTheorem {
63    pub obligation_name: String,
64    pub theorem_name: String,
65    pub witness_ref: String,
66    pub declaration_start_line: usize,
67    pub declaration_end_line: usize,
68}
69
70/// Typed manifest model for generated Lean verification artifacts.
71#[derive(Debug, Clone, PartialEq, Eq)]
72pub struct LeanManifest {
73    pub schema_version: String,
74    pub module_name: String,
75    pub project: LeanManifestProject,
76    pub prelude: LeanManifestPrelude,
77    pub theorems: Vec<LeanManifestTheorem>,
78    pub report_files: Option<LeanManifestReportFiles>,
79}
80
81/// Result of preparing or writing a verification batch.
82#[derive(Debug, Clone, PartialEq, Eq)]
83pub struct ArtifactBatch {
84    pub root: String,
85    pub records: Vec<ArtifactRecord>,
86    pub plans: Vec<InvocationPlan>,
87    pub lean_export: Option<LeanExport>,
88    pub lean_project: Option<LeanProject>,
89    pub lean_manifest: Option<LeanManifest>,
90}
91
92#[cfg(feature = "std")]
93#[derive(Debug, Clone)]
94pub struct ArtifactLayout {
95    pub root: PathBuf,
96    pub smt_dir: PathBuf,
97    pub lean_dir: PathBuf,
98    pub kani_dir: PathBuf,
99}
100
101#[cfg(feature = "std")]
102impl ArtifactLayout {
103    pub fn new(root: impl AsRef<Path>) -> Self {
104        let root = root.as_ref().to_path_buf();
105        Self {
106            smt_dir: root.join("smt"),
107            lean_dir: root.join("lean"),
108            kani_dir: root.join("kani"),
109            root,
110        }
111    }
112}
113
114impl LeanManifestReportFiles {
115    pub fn new(json_path: impl Into<String>, markdown_path: impl Into<String>) -> Self {
116        Self {
117            schema_version: LEAN_MANIFEST_SCHEMA_VERSION.into(),
118            json_path: json_path.into(),
119            markdown_path: markdown_path.into(),
120            lean_diagnostics_json_path: None,
121        }
122    }
123
124    pub fn with_lean_diagnostics_json_path(mut self, path: impl Into<String>) -> Self {
125        self.lean_diagnostics_json_path = Some(path.into());
126        self
127    }
128}
129
130impl LeanManifest {
131    pub fn from_export(export: &LeanExport, project: &LeanProject) -> Self {
132        Self {
133            schema_version: LEAN_MANIFEST_SCHEMA_VERSION.into(),
134            module_name: export.module_name.clone(),
135            project: LeanManifestProject {
136                package_name: project.package_name.clone(),
137                toolchain: project.toolchain.clone(),
138                requires_mathlib: project.requires_mathlib,
139            },
140            prelude: LeanManifestPrelude {
141                imports: export
142                    .prelude
143                    .imports
144                    .iter()
145                    .map(|import| import.module.clone())
146                    .collect(),
147                aliases: export
148                    .prelude
149                    .aliases
150                    .iter()
151                    .map(|alias| LeanManifestAlias {
152                        alias: alias.alias.clone(),
153                        target: alias.target.clone(),
154                    })
155                    .collect(),
156            },
157            theorems: export
158                .theorems
159                .iter()
160                .map(|theorem| LeanManifestTheorem {
161                    obligation_name: theorem.obligation_name.clone(),
162                    theorem_name: theorem.theorem_name.clone(),
163                    witness_ref: theorem.witness_ref(&export.module_name),
164                    declaration_start_line: theorem.declaration_start_line,
165                    declaration_end_line: theorem.declaration_end_line,
166                })
167                .collect(),
168            report_files: None,
169        }
170    }
171
172    pub fn with_report_files(mut self, report_files: LeanManifestReportFiles) -> Self {
173        self.report_files = Some(report_files);
174        self
175    }
176
177    pub fn to_json(&self) -> String {
178        fn esc(s: &str) -> String {
179            s.replace('\\', "\\\\")
180                .replace('"', "\\\"")
181                .replace('\n', "\\n")
182        }
183
184        let import_entries = self
185            .prelude
186            .imports
187            .iter()
188            .map(|import| format!("\"{}\"", esc(import)))
189            .collect::<Vec<_>>()
190            .join(",");
191
192        let alias_entries = self
193            .prelude
194            .aliases
195            .iter()
196            .map(|alias| {
197                format!(
198                    "{{\"alias\":\"{}\",\"target\":\"{}\"}}",
199                    esc(&alias.alias),
200                    esc(&alias.target)
201                )
202            })
203            .collect::<Vec<_>>()
204            .join(",");
205
206        let theorem_entries = self
207            .theorems
208            .iter()
209            .map(|theorem| {
210                format!(
211                    "{{\"obligation_name\":\"{}\",\"theorem_name\":\"{}\",\"witness_ref\":\"{}\",\"declaration_start_line\":{},\"declaration_end_line\":{}}}",
212                    esc(&theorem.obligation_name),
213                    esc(&theorem.theorem_name),
214                    esc(&theorem.witness_ref),
215                    theorem.declaration_start_line,
216                    theorem.declaration_end_line
217                )
218            })
219            .collect::<Vec<_>>()
220            .join(",");
221
222        let report_files = self
223            .report_files
224            .as_ref()
225            .map(|report_files| {
226                let mut json = format!(
227                    "\"report_files\":{{\"schema_version\":\"{}\",\"json_path\":\"{}\",\"markdown_path\":\"{}\"",
228                    esc(&report_files.schema_version),
229                    esc(&report_files.json_path),
230                    esc(&report_files.markdown_path)
231                );
232                if let Some(path) = &report_files.lean_diagnostics_json_path {
233                    json.push_str(&format!(
234                        ",\"lean_diagnostics_json_path\":\"{}\"",
235                        esc(path)
236                    ));
237                }
238                json.push('}');
239                json
240            })
241            .unwrap_or_default();
242
243        let mut json = format!(
244            "{{\"schema_version\":\"{}\",\"module_name\":\"{}\",\"project\":{{\"package_name\":\"{}\",\"toolchain\":\"{}\",\"requires_mathlib\":{}}},\"prelude\":{{\"imports\":[{}],\"aliases\":[{}]}},\"theorems\":[{}]",
245            esc(&self.schema_version),
246            esc(&self.module_name),
247            esc(&self.project.package_name),
248            esc(&self.project.toolchain),
249            self.project.requires_mathlib,
250            import_entries,
251            alias_entries,
252            theorem_entries
253        );
254        if !report_files.is_empty() {
255            json.push(',');
256            json.push_str(&report_files);
257        }
258        json.push('}');
259        json
260    }
261}
262
263#[cfg(feature = "std")]
264pub fn write_bundle_artifacts(
265    bundle: &ObligationBundle,
266    layout: &ArtifactLayout,
267    lean_module_name: &str,
268    smt: &SmtConfig,
269    lean: &LeanConfig,
270) -> std::io::Result<ArtifactBatch> {
271    fs::create_dir_all(&layout.smt_dir)?;
272    fs::create_dir_all(&layout.lean_dir)?;
273    fs::create_dir_all(&layout.kani_dir)?;
274
275    let mut records = Vec::new();
276    let mut plans = Vec::new();
277    let kani = KaniConfig::default();
278
279    for (name, script) in export_smt_bundle(bundle) {
280        let path = layout.smt_dir.join(format!("{name}.smt2"));
281        fs::write(&path, script)?;
282        plans.push(InvocationPlan::smt(smt, &path));
283        records.push(ArtifactRecord {
284            name,
285            path: path_to_string(&path),
286        });
287    }
288
289    for harness in export_kani_bundle(bundle) {
290        let path = layout.kani_dir.join(format!("{}.rs", harness.harness_name));
291        fs::write(&path, harness.source)?;
292        plans.push(InvocationPlan::kani(&kani, &path, &harness.harness_name));
293        records.push(ArtifactRecord {
294            name: format!("{}_kani", harness.obligation_name),
295            path: path_to_string(&path),
296        });
297    }
298
299    let lean_export = export_lean_bundle_structured(lean_module_name, bundle);
300    let lean_project = lean_export.project();
301    let lean_manifest = LeanManifest::from_export(&lean_export, &lean_project);
302    let lean_path = layout.lean_dir.join(format!("{lean_module_name}.lean"));
303    fs::write(&lean_path, &lean_export.source)?;
304    plans.push(InvocationPlan::lean(lean, &lean_path));
305    records.push(ArtifactRecord {
306        name: lean_module_name.into(),
307        path: path_to_string(&lean_path),
308    });
309
310    let manifest_path = layout
311        .lean_dir
312        .join(format!("{lean_module_name}.manifest.json"));
313    fs::write(&manifest_path, lean_manifest.to_json())?;
314    records.push(ArtifactRecord {
315        name: format!("{lean_module_name}_manifest"),
316        path: path_to_string(&manifest_path),
317    });
318
319    let lakefile_path = layout.root.join("lakefile.lean");
320    fs::write(&lakefile_path, lean_project.render_lakefile())?;
321    records.push(ArtifactRecord {
322        name: "lakefile".into(),
323        path: path_to_string(&lakefile_path),
324    });
325
326    let toolchain_path = layout.root.join("lean-toolchain");
327    fs::write(&toolchain_path, lean_project.render_toolchain())?;
328    records.push(ArtifactRecord {
329        name: "lean_toolchain".into(),
330        path: path_to_string(&toolchain_path),
331    });
332
333    Ok(ArtifactBatch {
334        root: path_to_string(&layout.root),
335        records,
336        plans,
337        lean_export: Some(lean_export),
338        lean_project: Some(lean_project),
339        lean_manifest: Some(lean_manifest),
340    })
341}
342
343#[cfg(feature = "std")]
344pub fn dry_run_bundle_artifacts(
345    bundle: &ObligationBundle,
346    layout: &ArtifactLayout,
347    lean_module_name: &str,
348    smt: &SmtConfig,
349    lean: &LeanConfig,
350) -> ArtifactBatch {
351    let mut records = Vec::new();
352    let mut plans = Vec::new();
353    let kani = KaniConfig::default();
354
355    for (name, _) in export_smt_bundle(bundle) {
356        let path = layout.smt_dir.join(format!("{name}.smt2"));
357        plans.push(InvocationPlan::smt(smt, &path));
358        records.push(ArtifactRecord {
359            name,
360            path: path_to_string(&path),
361        });
362    }
363
364    for harness in export_kani_bundle(bundle) {
365        let path = layout.kani_dir.join(format!("{}.rs", harness.harness_name));
366        plans.push(InvocationPlan::kani(&kani, &path, &harness.harness_name));
367        records.push(ArtifactRecord {
368            name: format!("{}_kani", harness.obligation_name),
369            path: path_to_string(&path),
370        });
371    }
372
373    let lean_export = export_lean_bundle_structured(lean_module_name, bundle);
374    let lean_project = lean_export.project();
375    let lean_manifest = LeanManifest::from_export(&lean_export, &lean_project);
376    let lean_path = layout.lean_dir.join(format!("{lean_module_name}.lean"));
377    plans.push(InvocationPlan::lean(lean, &lean_path));
378    records.push(ArtifactRecord {
379        name: lean_module_name.into(),
380        path: path_to_string(&lean_path),
381    });
382
383    let manifest_path = layout
384        .lean_dir
385        .join(format!("{lean_module_name}.manifest.json"));
386    records.push(ArtifactRecord {
387        name: format!("{lean_module_name}_manifest"),
388        path: path_to_string(&manifest_path),
389    });
390
391    let lakefile_path = layout.root.join("lakefile.lean");
392    records.push(ArtifactRecord {
393        name: "lakefile".into(),
394        path: path_to_string(&lakefile_path),
395    });
396
397    let toolchain_path = layout.root.join("lean-toolchain");
398    records.push(ArtifactRecord {
399        name: "lean_toolchain".into(),
400        path: path_to_string(&toolchain_path),
401    });
402
403    ArtifactBatch {
404        root: path_to_string(&layout.root),
405        records,
406        plans,
407        lean_export: Some(lean_export),
408        lean_project: Some(lean_project),
409        lean_manifest: Some(lean_manifest),
410    }
411}
412
413#[cfg(feature = "std")]
414fn path_to_string(path: &Path) -> String {
415    path.to_string_lossy().into_owned()
416}
417
418#[cfg(test)]
419mod tests {
420    use super::*;
421    use crate::{AlgebraicSignature, Origin, Sort};
422
423    #[cfg(feature = "std")]
424    #[test]
425    fn dry_run_creates_expected_paths_and_plans() {
426        let sig = AlgebraicSignature::monoid(Sort::Int, "combine", "e");
427        let bundle = ObligationBundle::monoid("sum", Origin::new("karpal-core", "Sum<i32>"), &sig);
428        let layout = ArtifactLayout::new("target/karpal-verify-test");
429        let batch = dry_run_bundle_artifacts(
430            &bundle,
431            &layout,
432            "KarpalVerify",
433            &SmtConfig::default(),
434            &LeanConfig::default().with_driver(crate::LeanDriver::LakeEnv),
435        );
436
437        assert_eq!(batch.records.len(), 10);
438        assert_eq!(batch.plans.len(), 7);
439        assert!(
440            batch
441                .records
442                .iter()
443                .any(|r| r.path.ends_with("kani/associativity.rs"))
444        );
445        assert!(
446            batch
447                .plans
448                .iter()
449                .any(|plan| plan.kind == crate::CommandKind::Kani)
450        );
451        assert!(
452            batch
453                .records
454                .iter()
455                .any(|r| r.path.ends_with("KarpalVerify.lean"))
456        );
457        assert!(
458            batch
459                .records
460                .iter()
461                .any(|r| r.path.ends_with("KarpalVerify.manifest.json"))
462        );
463        assert_eq!(
464            batch.lean_export.as_ref().unwrap().module_name,
465            "KarpalVerify"
466        );
467        assert_eq!(
468            batch.lean_project.as_ref().unwrap().package_name,
469            "karpalverify"
470        );
471        assert_eq!(
472            batch.lean_manifest.as_ref().unwrap().schema_version,
473            LEAN_MANIFEST_SCHEMA_VERSION
474        );
475        assert_eq!(
476            batch.lean_manifest.as_ref().unwrap().module_name,
477            "KarpalVerify"
478        );
479        assert!(
480            batch
481                .records
482                .iter()
483                .any(|r| r.path.ends_with("lakefile.lean"))
484        );
485        assert!(
486            batch
487                .records
488                .iter()
489                .any(|r| r.path.ends_with("lean-toolchain"))
490        );
491        assert!(
492            batch
493                .plans
494                .iter()
495                .any(|plan| plan.kind == crate::CommandKind::Lean && plan.executable == "lake")
496        );
497    }
498
499    #[cfg(feature = "std")]
500    #[test]
501    fn write_bundle_artifacts_writes_files() {
502        let sig = AlgebraicSignature::semigroup(Sort::Int, "combine");
503        let bundle =
504            ObligationBundle::semigroup("sum", Origin::new("karpal-core", "Sum<i32>"), &sig);
505        let temp = std::env::temp_dir().join("karpal_verify_artifacts_test");
506        if temp.exists() {
507            let _ = fs::remove_dir_all(&temp);
508        }
509        let layout = ArtifactLayout::new(&temp);
510
511        let batch = write_bundle_artifacts(
512            &bundle,
513            &layout,
514            "KarpalVerify",
515            &SmtConfig::default(),
516            &LeanConfig::default(),
517        )
518        .expect("artifact write should succeed");
519
520        assert!(
521            batch
522                .records
523                .iter()
524                .all(|record| Path::new(&record.path).exists())
525        );
526        assert!(batch.lean_export.is_some());
527        assert!(batch.lean_project.is_some());
528        assert!(batch.lean_manifest.is_some());
529        let manifest = fs::read_to_string(temp.join("lean").join("KarpalVerify.manifest.json"))
530            .expect("lean manifest should be readable");
531        assert!(manifest.contains("\"schema_version\":\"1\""));
532        assert!(temp.join("lakefile.lean").exists());
533        assert!(temp.join("lean-toolchain").exists());
534
535        let _ = fs::remove_dir_all(&temp);
536    }
537}