1use std::path::{Path, PathBuf};
8
9use serde::{Deserialize, Serialize};
10use speccoder_core::bug::BugReport;
11
12#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
15pub struct IssueText {
16 pub title: String,
18 pub body: String,
20}
21
22#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
24pub struct Extra {
25 #[serde(default)]
27 pub issue: Option<IssueText>,
28 #[serde(default)]
30 pub candidate_patch: Option<String>,
31 #[serde(default)]
33 pub by: Option<String>,
34 #[serde(default)]
36 pub via: Option<speccoder_core::actions::Via>,
37}
38
39fn key(task: &str) -> String {
40 task.replace([':', '/'], "-")
41}
42
43#[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#[must_use]
55pub fn dir(plane: &Path, unit: &str, task: &str) -> PathBuf {
56 plane.join("bugs").join(unit).join(key(task))
57}
58
59fn 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#[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#[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#[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
106pub 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#[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
126pub 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
134pub 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#[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 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}