use std::fs;
use std::path::Path;
use chrono::{SecondsFormat, TimeZone, Utc};
use rusqlite::{params, Connection};
use serde::{Deserialize, Serialize};
use crate::error::ApiError;
#[derive(Debug, Serialize, Deserialize)]
pub struct SeedFile {
pub project: SeedProject,
pub issues: Vec<SeedIssue>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct SeedProject {
pub slug: String,
pub name: String,
#[serde(default)]
pub description: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct SeedIssue {
pub id: u64,
pub title: String,
pub status: String,
#[serde(default)]
pub assignee: Option<String>,
#[serde(default)]
pub labels: Vec<String>,
#[serde(default)]
pub body: String,
}
pub fn load_seed(conn: &Connection, path: &Path) -> Result<usize, ApiError> {
let raw = fs::read_to_string(path)
.map_err(|e| ApiError::Internal(format!("read seed {}: {e}", path.display())))?;
let parsed: SeedFile = serde_json::from_str(&raw)?;
apply_seed(conn, &parsed)
}
pub fn apply_seed(conn: &Connection, seed: &SeedFile) -> Result<usize, ApiError> {
let ts = Utc
.with_ymd_and_hms(2026, 4, 13, 0, 0, 0)
.single()
.expect("2026-04-13T00:00:00Z is unambiguous")
.to_rfc3339_opts(SecondsFormat::Secs, true);
let mut inserted = 0usize;
for issue in &seed.issues {
let labels_json = serde_json::to_string(&issue.labels)?;
#[allow(clippy::cast_possible_wrap)] let id_signed = issue.id as i64;
let affected = conn.execute(
"INSERT OR IGNORE INTO issues \
(project, id, title, status, assignee, labels, created_at, updated_at, version, body) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 1, ?9)",
params![
seed.project.slug,
id_signed,
issue.title,
issue.status,
issue.assignee,
labels_json,
ts,
ts,
issue.body,
],
)?;
inserted += affected;
}
Ok(inserted)
}
#[cfg(test)]
mod tests {
use super::{apply_seed, load_seed, SeedFile};
use crate::db::open_db;
use std::path::{Path, PathBuf};
fn seed_fixture_path() -> PathBuf {
let mut p = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
p.push("fixtures/seed.json");
p
}
#[test]
fn load_seed_inserts_six_issues() {
let conn = open_db(Path::new(":memory:"), true).expect("open");
let inserted = load_seed(&conn, &seed_fixture_path()).expect("load");
assert_eq!(inserted, 6, "seed should insert exactly 6 rows");
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM issues", [], |r| r.get(0))
.expect("count");
assert_eq!(count, 6);
}
#[test]
fn at_least_one_body_contains_fake_version_999_line() {
let conn = open_db(Path::new(":memory:"), true).expect("open");
load_seed(&conn, &seed_fixture_path()).expect("load");
let bodies: Vec<String> = {
let mut stmt = conn
.prepare("SELECT body FROM issues ORDER BY id")
.expect("prep");
stmt.query_map([], |r| r.get::<_, String>(0))
.expect("query")
.map(std::result::Result::unwrap)
.collect()
};
let has_fake_version = bodies.iter().any(|b| b.contains("version: 999"));
assert!(
has_fake_version,
"at least one seed body must contain the literal `version: 999` adversarial fixture"
);
}
#[test]
fn at_least_one_body_contains_script_tag() {
let conn = open_db(Path::new(":memory:"), true).expect("open");
load_seed(&conn, &seed_fixture_path()).expect("load");
let bodies: Vec<String> = {
let mut stmt = conn
.prepare("SELECT body FROM issues ORDER BY id")
.expect("prep");
stmt.query_map([], |r| r.get::<_, String>(0))
.expect("query")
.map(std::result::Result::unwrap)
.collect()
};
let has_script = bodies.iter().any(|b| b.contains("<script>"));
assert!(
has_script,
"at least one seed body must contain a literal <script> adversarial fixture"
);
}
#[test]
fn load_seed_is_idempotent() {
let conn = open_db(Path::new(":memory:"), true).expect("open");
let first = load_seed(&conn, &seed_fixture_path()).expect("first");
let second = load_seed(&conn, &seed_fixture_path()).expect("second");
assert_eq!(first, 6);
assert_eq!(second, 0, "INSERT OR IGNORE must no-op on rerun");
}
#[test]
fn apply_seed_round_trips() {
let seed: SeedFile =
serde_json::from_str(include_str!("../fixtures/seed.json")).expect("json");
let conn = open_db(Path::new(":memory:"), true).expect("open");
let n = apply_seed(&conn, &seed).expect("apply");
assert_eq!(n, seed.issues.len());
}
}