Skip to main content

speccoder_runtime/bug/
state.rs

1//! What speccoder keeps of a bug: `<project state>/bugs/<unit>/<task>/` —
2//! the bug (`bug.json`, the `bug` payload), its reproductions' contents
3//! until they are confirmed (`repro/`), and the issue text and candidate
4//! diff it was recorded with (`extra.json`). Nothing here is in the
5//! format's files.
6
7use std::path::{Path, PathBuf};
8
9use serde::{Deserialize, Serialize};
10use speccoder_core::bug::BugReport;
11
12/// The issue a bug came from, as fetched: context for the reproduction
13/// author only (untrusted), never the judge's.
14#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
15pub struct IssueText {
16    /// The title.
17    pub title: String,
18    /// The body.
19    pub body: String,
20}
21
22/// What a bug was recorded with besides its triad.
23#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
24pub struct Extra {
25    /// The issue's text.
26    #[serde(default)]
27    pub issue: Option<IssueText>,
28    /// The candidate's diff.
29    #[serde(default)]
30    pub candidate_patch: Option<String>,
31    /// Who recorded the bug.
32    #[serde(default)]
33    pub by: Option<String>,
34    /// Through what.
35    #[serde(default)]
36    pub via: Option<speccoder_core::actions::Via>,
37}
38
39fn key(task: &str) -> String {
40    task.replace([':', '/'], "-")
41}
42
43/// Whether `unit` and `task` name a place under the state's `bugs/`: each
44/// part of the unit a plain name (no `..`, no root, no backslash), the task
45/// one too — a name from an interface or a commit never becomes a path out.
46#[must_use]
47pub fn names_a_bug(unit: &str, task: &str) -> bool {
48    let plain =
49        |p: &str| !p.is_empty() && p != "." && p != ".." && !p.contains('\\') && !p.contains('\0');
50    !unit.starts_with('/') && unit.split('/').all(plain) && plain(&key(task))
51}
52
53/// A bug's directory.
54#[must_use]
55pub fn dir(plane: &Path, unit: &str, task: &str) -> PathBuf {
56    plane.join("bugs").join(unit).join(key(task))
57}
58
59/// [`dir`], refused for a name that is not one.
60fn dir_checked(plane: &Path, unit: &str, task: &str) -> Result<PathBuf, String> {
61    if names_a_bug(unit, task) {
62        Ok(dir(plane, unit, task))
63    } else {
64        Err(format!("`{unit}/{task}` does not name a bug"))
65    }
66}
67
68/// The bug of `unit`'s `task`, if speccoder recorded one.
69#[must_use]
70pub fn load(plane: &Path, unit: &str, task: &str) -> Option<BugReport> {
71    let text =
72        std::fs::read_to_string(dir_checked(plane, unit, task).ok()?.join("bug.json")).ok()?;
73    serde_json::from_str(&text).ok()
74}
75
76/// The bug named `<unit>/<task>` (a unit may hold a `/`).
77#[must_use]
78pub fn find(plane: &Path, name: &str) -> Option<BugReport> {
79    let (unit, task) = name.rsplit_once('/')?;
80    load(plane, unit, task)
81}
82
83/// Every recorded bug.
84#[must_use]
85pub fn all(plane: &Path) -> Vec<BugReport> {
86    fn walk(d: &Path, out: &mut Vec<BugReport>) {
87        for e in std::fs::read_dir(d).into_iter().flatten().flatten() {
88            let p = e.path();
89            let file = p.join("bug.json");
90            if let Some(b) = std::fs::read_to_string(&file)
91                .ok()
92                .and_then(|t| serde_json::from_str::<BugReport>(&t).ok())
93            {
94                out.push(b);
95            } else if p.is_dir() {
96                walk(&p, out);
97            }
98        }
99    }
100    let mut out = Vec::new();
101    walk(&plane.join("bugs"), &mut out);
102    out.sort_by_key(BugReport::key);
103    out
104}
105
106/// Saves the bug.
107pub fn save(plane: &Path, bug: &BugReport) -> Result<(), String> {
108    let d = dir_checked(plane, &bug.unit, &bug.task)?;
109    std::fs::create_dir_all(&d).map_err(|e| format!("{}: {e}", d.display()))?;
110    let text = serde_json::to_string_pretty(bug).map_err(|e| e.to_string())?;
111    let tmp = d.join("bug.json.tmp");
112    std::fs::write(&tmp, text).map_err(|e| format!("{}: {e}", tmp.display()))?;
113    std::fs::rename(&tmp, d.join("bug.json")).map_err(|e| e.to_string())
114}
115
116/// What the bug was recorded with.
117#[must_use]
118pub fn extra(plane: &Path, unit: &str, task: &str) -> Extra {
119    dir_checked(plane, unit, task)
120        .ok()
121        .and_then(|d| std::fs::read_to_string(d.join("extra.json")).ok())
122        .and_then(|t| serde_json::from_str(&t).ok())
123        .unwrap_or_default()
124}
125
126/// Saves what the bug was recorded with.
127pub fn save_extra(plane: &Path, unit: &str, task: &str, extra: &Extra) -> Result<(), String> {
128    let d = dir_checked(plane, unit, task)?;
129    std::fs::create_dir_all(&d).map_err(|e| format!("{}: {e}", d.display()))?;
130    let text = serde_json::to_string_pretty(extra).map_err(|e| e.to_string())?;
131    std::fs::write(d.join("extra.json"), text).map_err(|e| e.to_string())
132}
133
134/// Keeps the reproductions' contents (replacing earlier ones).
135pub fn save_repro(
136    plane: &Path,
137    unit: &str,
138    task: &str,
139    files: &[(String, String)],
140) -> Result<(), String> {
141    let d = dir_checked(plane, unit, task)?.join("repro");
142    let _ = std::fs::remove_dir_all(&d);
143    for (path, content) in files {
144        if crate::tools::paths::normalise(path).ok().as_deref() != Some(path.as_str()) {
145            return Err(format!("`{path}` is not a project-relative path"));
146        }
147        let file = d.join(path);
148        if let Some(parent) = file.parent() {
149            std::fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
150        }
151        std::fs::write(&file, content).map_err(|e| format!("{}: {e}", file.display()))?;
152    }
153    Ok(())
154}
155
156/// The reproductions' contents, for `paths`.
157#[must_use]
158pub fn repro(plane: &Path, unit: &str, task: &str, paths: &[String]) -> Vec<(String, String)> {
159    let Ok(d) = dir_checked(plane, unit, task).map(|d| d.join("repro")) else {
160        return Vec::new();
161    };
162    paths
163        .iter()
164        .filter(|p| crate::tools::paths::normalise(p).ok().as_deref() == Some(p.as_str()))
165        .filter_map(|p| {
166            std::fs::read_to_string(d.join(p))
167                .ok()
168                .map(|c| (p.clone(), c))
169        })
170        .collect()
171}
172
173#[cfg(test)]
174mod tests {
175    use speccoder_core::bug::{BugHome, HomeKind, Outcome, Stage, Triad};
176
177    use super::*;
178
179    #[test]
180    fn a_bug_reads_back_by_its_name() {
181        let plane = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
182        let triad = Triad::new("x", &["y".to_owned()], &[], crate::integrity::sha256_hex)
183            .unwrap_or_else(|e| panic!("{e:?}"));
184        let bug = BugReport {
185            home: BugHome {
186                kind: HomeKind::NativeSpeckitBug,
187                location: "b".to_owned(),
188                reason: "r".to_owned(),
189            },
190            unit: "bugs/x".to_owned(),
191            requirement: "bug:x".to_owned(),
192            task: "bug:x".to_owned(),
193            title: "x".to_owned(),
194            triad,
195            stage: Stage::Record,
196            outcome: Outcome::Recorded,
197            verdict: None,
198            code: None,
199            reproductions: Vec::new(),
200            held_out: Vec::new(),
201            held_out_sealed: None,
202            held_out_reason: None,
203            confirm_digest: None,
204            confirmation: None,
205            rejections: Vec::new(),
206            acceptance: None,
207            pass_to_pass: None,
208            revert: None,
209            special_case: Vec::new(),
210            tries: Vec::new(),
211            max_tries: 3,
212            handoff: None,
213            issue: None,
214            candidate: None,
215            recorded_commit: None,
216            commit: None,
217            run_id: None,
218        };
219        save(plane.path(), &bug).unwrap_or_else(|e| panic!("{e}"));
220        assert_eq!(find(plane.path(), "bugs/x/bug:x"), Some(bug.clone()));
221        assert_eq!(all(plane.path()), vec![bug.clone()]);
222        save_repro(
223            plane.path(),
224            "bugs/x",
225            "bug:x",
226            &[("tests/a.rs".to_owned(), "t".to_owned())],
227        )
228        .unwrap_or_else(|e| panic!("{e}"));
229        assert_eq!(
230            repro(plane.path(), "bugs/x", "bug:x", &["tests/a.rs".to_owned()]),
231            [("tests/a.rs".to_owned(), "t".to_owned())]
232        );
233        // A name that would leave the state's `bugs/` is no bug.
234        assert_eq!(find(plane.path(), "bugs/../../x/bug:x"), None);
235        assert_eq!(find(plane.path(), "../T001"), None);
236        let mut out = bug;
237        out.unit = "bugs/../../outside".to_owned();
238        assert!(save(plane.path(), &out).is_err());
239        assert!(!plane.path().join("outside").exists());
240        assert!(names_a_bug("007-bugfixes", "T001") && names_a_bug("bugs/x", "bug:x"));
241        assert!(!names_a_bug("/etc", "T001") && !names_a_bug("a", ".."));
242    }
243}