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