Skip to main content

speccoder_runtime/
pre_frozen.rs

1//! The store of acceptance tests decided before the run
2//! ([`PreFrozenAcceptance`]): `<project state>/pre-frozen/<unit>/<requirement>/`
3//! holds the record and the files it names. Written only by the producer's
4//! person-commanded step; every load checks each file's hash.
5
6use std::path::{Path, PathBuf};
7
8use speccoder_core::acceptance::PreFrozenAcceptance;
9
10use crate::integrity::sha256_hex;
11
12/// A record and its files.
13pub type Loaded = (PreFrozenAcceptance, Vec<(String, String)>);
14
15/// A stored set that no longer matches its record.
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct Tampered(pub String);
18
19/// Where a record lives.
20#[must_use]
21pub fn dir(project_state: &Path, unit: &str, requirement: &str) -> PathBuf {
22    project_state
23        .join("pre-frozen")
24        .join(unit)
25        .join(requirement.replace([':', '/'], "-"))
26}
27
28/// Stores `record` with the visible files' `contents` (path, text); each
29/// content must hash to the record's entry. A previous record for the
30/// requirement is replaced.
31pub fn store(
32    project_state: &Path,
33    record: &PreFrozenAcceptance,
34    contents: &[(String, String)],
35) -> Result<PathBuf, String> {
36    record.validate()?;
37    for t in &record.visible {
38        let content = contents
39            .iter()
40            .find(|(p, _)| *p == t.path)
41            .map(|(_, c)| c)
42            .ok_or_else(|| format!("{} has no content", t.path))?;
43        if format!("sha256:{}", sha256_hex(content.as_bytes())) != t.sha256 {
44            return Err(format!("{} does not match its digest", t.path));
45        }
46    }
47    let d = dir(project_state, &record.unit, &record.requirement);
48    let _ = std::fs::remove_dir_all(&d);
49    for (path, content) in contents {
50        let file = d.join("files").join(path);
51        if let Some(parent) = file.parent() {
52            std::fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
53        }
54        std::fs::write(&file, content).map_err(|e| format!("{}: {e}", file.display()))?;
55    }
56    let text = serde_json::to_string_pretty(record).map_err(|e| e.to_string())?;
57    std::fs::write(d.join("record.json"), text).map_err(|e| e.to_string())?;
58    Ok(d)
59}
60
61/// The record for `(unit, requirement)` and its files, every hash checked;
62/// `Ok(None)`: there is none.
63pub fn load(
64    project_state: &Path,
65    unit: &str,
66    requirement: &str,
67) -> Result<Option<Loaded>, Tampered> {
68    let d = dir(project_state, unit, requirement);
69    let Ok(text) = std::fs::read_to_string(d.join("record.json")) else {
70        return Ok(None);
71    };
72    let record: PreFrozenAcceptance = serde_json::from_str(&text)
73        .map_err(|e| Tampered(format!("the pre-frozen record is not valid: {e}")))?;
74    let mut contents = Vec::new();
75    for t in &record.visible {
76        let content = std::fs::read_to_string(d.join("files").join(&t.path))
77            .map_err(|_| Tampered(format!("pre-frozen test {} is missing", t.path)))?;
78        if format!("sha256:{}", sha256_hex(content.as_bytes())) != t.sha256 {
79            return Err(Tampered(format!("pre-frozen test {} changed", t.path)));
80        }
81        contents.push((t.path.clone(), content));
82    }
83    Ok(Some((record, contents)))
84}
85
86/// Removes the record (a reworded triad, a dropped task).
87pub fn remove(project_state: &Path, unit: &str, requirement: &str) {
88    let _ = std::fs::remove_dir_all(dir(project_state, unit, requirement));
89}
90
91/// `sha256:<hex>` of the visible set's manifest.
92#[must_use]
93pub fn manifest_digest(record: &PreFrozenAcceptance) -> String {
94    format!(
95        "sha256:{}",
96        sha256_hex(speccoder_core::acceptance::manifest_text(&record.visible).as_bytes())
97    )
98}
99
100#[cfg(test)]
101mod tests {
102    use speccoder_core::acceptance::{Decided, Producer, VisibleTest};
103    use speccoder_core::actions::Via;
104
105    use super::*;
106
107    fn record(content: &str) -> PreFrozenAcceptance {
108        PreFrozenAcceptance {
109            unit: "007-bugfixes".to_owned(),
110            requirement: "FR-001".to_owned(),
111            wording_digest: "sha256:w".to_owned(),
112            approval_digest: "sha256:a".to_owned(),
113            visible: vec![VisibleTest {
114                path: "tests/repro_e1.rs".to_owned(),
115                sha256: format!("sha256:{}", sha256_hex(content.as_bytes())),
116                cites: vec!["E1".to_owned()],
117            }],
118            held_out_sealed: None,
119            producer: Producer::BugConfirmation,
120            decided: Decided {
121                by: "Ada".to_owned(),
122                via: Via::Terminal,
123                at: 1,
124            },
125        }
126    }
127
128    #[test]
129    fn a_stored_set_loads_back_and_a_changed_file_is_tampering() {
130        let state = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
131        let r = record("#[test] fn t() {}");
132        let files = vec![(
133            "tests/repro_e1.rs".to_owned(),
134            "#[test] fn t() {}".to_owned(),
135        )];
136        let d = store(state.path(), &r, &files).unwrap_or_else(|e| panic!("{e}"));
137        let (back, contents) = load(state.path(), "007-bugfixes", "FR-001")
138            .unwrap_or_else(|e| panic!("{e:?}"))
139            .unwrap_or_else(|| panic!("stored"));
140        assert_eq!(back, r);
141        assert_eq!(contents, files);
142        std::fs::write(d.join("files/tests/repro_e1.rs"), "changed")
143            .unwrap_or_else(|e| panic!("{e}"));
144        assert!(load(state.path(), "007-bugfixes", "FR-001").is_err());
145        assert!(
146            store(
147                state.path(),
148                &r,
149                &[("tests/repro_e1.rs".to_owned(), "other".to_owned())]
150            )
151            .is_err()
152        );
153        assert!(
154            load(state.path(), "007-bugfixes", "FR-002")
155                .unwrap_or_default()
156                .is_none()
157        );
158    }
159}