use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use speccoder_core::bug::BugReport;
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct IssueText {
pub title: String,
pub body: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Extra {
#[serde(default)]
pub issue: Option<IssueText>,
#[serde(default)]
pub candidate_patch: Option<String>,
#[serde(default)]
pub by: Option<String>,
#[serde(default)]
pub via: Option<speccoder_core::actions::Via>,
}
fn key(task: &str) -> String {
task.replace([':', '/'], "-")
}
#[must_use]
pub fn names_a_bug(unit: &str, task: &str) -> bool {
let plain =
|p: &str| !p.is_empty() && p != "." && p != ".." && !p.contains('\\') && !p.contains('\0');
!unit.starts_with('/') && unit.split('/').all(plain) && plain(&key(task))
}
#[must_use]
pub fn dir(plane: &Path, unit: &str, task: &str) -> PathBuf {
plane.join("bugs").join(unit).join(key(task))
}
fn dir_checked(plane: &Path, unit: &str, task: &str) -> Result<PathBuf, String> {
if names_a_bug(unit, task) {
Ok(dir(plane, unit, task))
} else {
Err(format!("`{unit}/{task}` does not name a bug"))
}
}
#[must_use]
pub fn load(plane: &Path, unit: &str, task: &str) -> Option<BugReport> {
let text =
std::fs::read_to_string(dir_checked(plane, unit, task).ok()?.join("bug.json")).ok()?;
serde_json::from_str(&text).ok()
}
#[must_use]
pub fn find(plane: &Path, name: &str) -> Option<BugReport> {
let (unit, task) = name.rsplit_once('/')?;
load(plane, unit, task)
}
#[must_use]
pub fn all(plane: &Path) -> Vec<BugReport> {
fn walk(d: &Path, out: &mut Vec<BugReport>) {
for e in std::fs::read_dir(d).into_iter().flatten().flatten() {
let p = e.path();
let file = p.join("bug.json");
if let Some(b) = std::fs::read_to_string(&file)
.ok()
.and_then(|t| serde_json::from_str::<BugReport>(&t).ok())
{
out.push(b);
} else if p.is_dir() {
walk(&p, out);
}
}
}
let mut out = Vec::new();
walk(&plane.join("bugs"), &mut out);
out.sort_by_key(BugReport::key);
out
}
pub fn save(plane: &Path, bug: &BugReport) -> Result<(), String> {
let d = dir_checked(plane, &bug.unit, &bug.task)?;
std::fs::create_dir_all(&d).map_err(|e| format!("{}: {e}", d.display()))?;
let text = serde_json::to_string_pretty(bug).map_err(|e| e.to_string())?;
let tmp = d.join("bug.json.tmp");
std::fs::write(&tmp, text).map_err(|e| format!("{}: {e}", tmp.display()))?;
std::fs::rename(&tmp, d.join("bug.json")).map_err(|e| e.to_string())
}
#[must_use]
pub fn extra(plane: &Path, unit: &str, task: &str) -> Extra {
dir_checked(plane, unit, task)
.ok()
.and_then(|d| std::fs::read_to_string(d.join("extra.json")).ok())
.and_then(|t| serde_json::from_str(&t).ok())
.unwrap_or_default()
}
pub fn save_extra(plane: &Path, unit: &str, task: &str, extra: &Extra) -> Result<(), String> {
let d = dir_checked(plane, unit, task)?;
std::fs::create_dir_all(&d).map_err(|e| format!("{}: {e}", d.display()))?;
let text = serde_json::to_string_pretty(extra).map_err(|e| e.to_string())?;
std::fs::write(d.join("extra.json"), text).map_err(|e| e.to_string())
}
pub fn save_repro(
plane: &Path,
unit: &str,
task: &str,
files: &[(String, String)],
) -> Result<(), String> {
let d = dir_checked(plane, unit, task)?.join("repro");
let _ = std::fs::remove_dir_all(&d);
for (path, content) in files {
if crate::tools::paths::normalise(path).ok().as_deref() != Some(path.as_str()) {
return Err(format!("`{path}` is not a project-relative path"));
}
let file = d.join(path);
if let Some(parent) = file.parent() {
std::fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
}
std::fs::write(&file, content).map_err(|e| format!("{}: {e}", file.display()))?;
}
Ok(())
}
#[must_use]
pub fn repro(plane: &Path, unit: &str, task: &str, paths: &[String]) -> Vec<(String, String)> {
let Ok(d) = dir_checked(plane, unit, task).map(|d| d.join("repro")) else {
return Vec::new();
};
paths
.iter()
.filter(|p| crate::tools::paths::normalise(p).ok().as_deref() == Some(p.as_str()))
.filter_map(|p| {
std::fs::read_to_string(d.join(p))
.ok()
.map(|c| (p.clone(), c))
})
.collect()
}
#[cfg(test)]
mod tests {
use speccoder_core::bug::{BugHome, HomeKind, Outcome, Stage, Triad};
use super::*;
#[test]
fn a_bug_reads_back_by_its_name() {
let plane = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
let triad = Triad::new("x", &["y".to_owned()], &[], crate::integrity::sha256_hex)
.unwrap_or_else(|e| panic!("{e:?}"));
let bug = BugReport {
home: BugHome {
kind: HomeKind::NativeSpeckitBug,
location: "b".to_owned(),
reason: "r".to_owned(),
},
unit: "bugs/x".to_owned(),
requirement: "bug:x".to_owned(),
task: "bug:x".to_owned(),
title: "x".to_owned(),
triad,
stage: Stage::Record,
outcome: Outcome::Recorded,
verdict: None,
code: None,
reproductions: Vec::new(),
held_out: Vec::new(),
held_out_sealed: None,
held_out_reason: None,
confirm_digest: None,
confirmation: None,
rejections: Vec::new(),
acceptance: None,
pass_to_pass: None,
revert: None,
special_case: Vec::new(),
tries: Vec::new(),
max_tries: 3,
handoff: None,
issue: None,
candidate: None,
recorded_commit: None,
commit: None,
run_id: None,
};
save(plane.path(), &bug).unwrap_or_else(|e| panic!("{e}"));
assert_eq!(find(plane.path(), "bugs/x/bug:x"), Some(bug.clone()));
assert_eq!(all(plane.path()), vec![bug.clone()]);
save_repro(
plane.path(),
"bugs/x",
"bug:x",
&[("tests/a.rs".to_owned(), "t".to_owned())],
)
.unwrap_or_else(|e| panic!("{e}"));
assert_eq!(
repro(plane.path(), "bugs/x", "bug:x", &["tests/a.rs".to_owned()]),
[("tests/a.rs".to_owned(), "t".to_owned())]
);
assert_eq!(find(plane.path(), "bugs/../../x/bug:x"), None);
assert_eq!(find(plane.path(), "../T001"), None);
let mut out = bug;
out.unit = "bugs/../../outside".to_owned();
assert!(save(plane.path(), &out).is_err());
assert!(!plane.path().join("outside").exists());
assert!(names_a_bug("007-bugfixes", "T001") && names_a_bug("bugs/x", "bug:x"));
assert!(!names_a_bug("/etc", "T001") && !names_a_bug("a", ".."));
}
}