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 #[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#[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#[must_use]
58pub fn dir(plane: &Path, unit: &str, task: &str) -> PathBuf {
59 plane.join("bugs").join(unit).join(key(task))
60}
61
62fn 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#[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#[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#[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
109pub 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#[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
129pub 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
137pub 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#[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 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}