crates/core/src/tests.rs 16.0 KiB 399 lines Raw
1 //! M1 acceptance: goldens, SEC-01/02, permutation property, 10k bench.2 3 use super::*;4 use std::os::unix::fs::symlink;5 6 fn fx(name: &str) -> String {7     let p = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../spec/fixtures").join(name);8     fs::read_to_string(&p).unwrap_or_else(|_| panic!("missing fixture {name}"))9 }10 11 fn tmp_forge(tag: &str) -> (PathBuf, Forge) {12     let dir = std::env::temp_dir()13         .join(format!("f0rge-{tag}-{}-{}", std::process::id(), TMP_CTR.fetch_add(1, Ordering::Relaxed)));14     let _ = fs::remove_dir_all(&dir);15     fs::create_dir_all(dir.join(".forge/issues")).unwrap();16     (dir.clone(), Forge::open(&dir))17 }18 19 fn one_line_err(e: &ForgeError) -> String {20     let s = e.to_string();21     assert!(!s.contains('\n'), "error must be one line: {s:?}");22     s23 }24 25 #[test]26 fn golden_issue_roundtrip() {27     let p: Parsed<Issue> = parse_entity(&fx("issue.md"), "issue.md").unwrap();28     assert!(p.warnings.is_empty());29     assert_eq!(p.value.state, "open");30     let doc = render_entity(&p.value, &p.body).unwrap();31     let q: Parsed<Issue> = parse_entity(&doc, "issue.md").unwrap();32     assert_eq!(p.value, q.value);33     assert_eq!(p.body, q.body);34 }35 36 #[test]37 fn golden_pr_roundtrip() {38     let p: Parsed<Pr> = parse_entity(&fx("pr.md"), "pr.md").unwrap();39     assert!(p.warnings.is_empty());40     let doc = render_entity(&p.value, &p.body).unwrap();41     let q: Parsed<Pr> = parse_entity(&doc, "pr.md").unwrap();42     assert_eq!(p.value, q.value);43 }44 45 #[test]46 fn golden_comment_review_task_release_merge_cirun() {47     let c: Parsed<IssueComment> = parse_entity(&fx("issue-comment.md"), "c.md").unwrap();48     assert_eq!(c.value.author.as_deref(), Some("gl0w"));49     let r: Parsed<PrReview> = parse_entity(&fx("pr-review.md"), "r.md").unwrap();50     assert_eq!(r.value.state.as_deref(), Some("approved"));51     let t: Parsed<Task> = parse_entity(&fx("task.md"), "t.md").unwrap();52     assert_eq!(t.value.status, "doing");53     let rel: Parsed<Release> = parse_entity(&fx("release.md"), "rel.md").unwrap();54     assert_eq!(rel.value.version, "v0.1.0");55     let m: Parsed<MergeRecord> = parse_entity(&fx("merge-record.md"), "m.md").unwrap();56     assert!(m.value.applied);57     let run: Parsed<CiRun> = parse_entity(&fx("ci-run.md"), "run.md").unwrap();58     assert_eq!(run.value.steps.len(), 1);59     assert_eq!(run.value.steps[0].exit, 0);60 }61 62 #[test]63 fn golden_labels_milestones_cienv() {64     let (labels, w): (Vec<Label>, _) = parse_list(&fx("labels.md"), "labels.md").unwrap();65     assert!(w.is_empty());66     assert_eq!(labels.len(), 2);67     let (ms, _): (Vec<Milestone>, _) = parse_list(&fx("milestones.md"), "m.md").unwrap();68     assert_eq!(ms[0].state, "open");69     let env = parse_ci_env(&fx("ci-env"), "ci.env").unwrap();70     assert_eq!(env.runner, "local");71     assert_eq!(env.knobs.get("jobs").map(String::as_str), Some("4"));72 }73 74 #[test]75 fn unknown_field_is_warning() {76     let text = fx("issue.md").replacen("state: open", "state: open\nzzz_new: 1", 1);77     let p: Parsed<Issue> = parse_entity(&text, "issue.md").unwrap();78     assert_eq!(p.warnings, vec!["unknown field `zzz_new`".to_string()]);79 }80 81 #[test]82 fn invalid_fixtures_name_file_and_field() {83     let e = parse_entity::<Issue>(&fx("invalid/missing-title.md"), "missing-title.md").unwrap_err();84     let s = one_line_err(&e);85     assert!(s.contains("missing-title.md") && s.contains("title"), "{s}");86     let e = parse_entity::<Issue>(&fx("invalid/bad-uuid.md"), "bad-uuid.md").unwrap_err();87     let s = one_line_err(&e);88     assert!(s.contains("bad-uuid.md") && s.contains("id"), "{s}");89     let e = parse_entity::<Issue>(&fx("invalid/bad-ts.md"), "bad-ts.md").unwrap_err();90     let s = one_line_err(&e);91     assert!(s.contains("bad-ts.md") && s.contains("created"), "{s}");92 }93 94 #[test]95 fn sec_alias_bomb_rejected() {96     let e = parse_entity::<Issue>(&fx("sec/alias-bomb.md"), "alias-bomb.md").unwrap_err();97     one_line_err(&e);98     assert!(matches!(e, ForgeError::AliasBomb { .. }), "{e}");99 }100 101 #[test]102 fn sec_oversize_rejected() {103     let big = format!("---\nid: x\n{}\n---\n", "a".repeat(MAX_FILE_BYTES));104     let e = parse_entity::<Issue>(&big, "big.md").unwrap_err();105     one_line_err(&e);106     assert!(matches!(e, ForgeError::TooLarge { .. }));107 }108 109 #[test]110 fn sec_size_bomb_between_read_and_parse() {111     let (_dir, fg) = tmp_forge("sizebomb");112     let rel = PathBuf::from("issues/x.md");113     let big = vec![b'a'; MAX_FILE_BYTES + 1];114     let e = fg.atomic_write(&rel, &big).unwrap_err();115     one_line_err(&e);116     assert!(matches!(e, ForgeError::TooLarge { .. }));117 }118 119 #[test]120 fn sec_symlink_escape_rejected() {121     let (dir, fg) = tmp_forge("symlink");122     let outside = dir.join("outside.md");123     fs::write(&outside, fx("issue.md")).unwrap();124     let link = dir.join(".forge/issues/evil.md");125     symlink(&outside, &link).unwrap();126     // resolve follows the link out of a *different* root? link target is inside127     // root here; now test true escape: link to /etc/hostname style target.128     let link2 = dir.join(".forge/issues/evil2.md");129     symlink("/etc/hostname", &link2).unwrap();130     let e = fg.load_entity::<Issue>(Path::new("issues/evil2.md")).unwrap_err();131     one_line_err(&e);132     assert!(matches!(e, ForgeError::Escape { .. }), "{e}");133     // write through symlink refused134     let e = fg.atomic_write(Path::new("issues/evil.md"), b"x").unwrap_err();135     assert!(matches!(e, ForgeError::Escape { .. }), "{e}");136 }137 138 #[test]139 fn hostile_bytes_never_panic() {140     for (i, raw) in [141         String::from_utf8_lossy(&[0xff, 0xfe, 0x00]).into_owned(),142         String::new(),143         "---\n".to_string(),144         "no fences at all".to_string(),145         "---\n[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[\n---\n".to_string(),146         format!("---\n{}\n---\n", "*a ".repeat(200)),147     ]148     .iter()149     .enumerate()150     {151         let r = parse_entity::<Issue>(raw, "hostile.md");152         assert!(r.is_err(), "case {i} should err, not panic");153         one_line_err(&r.unwrap_err());154     }155 }156 157 #[test]158 fn forge_atomic_save_load_no_tmp_leftovers() {159     let (dir, fg) = tmp_forge("atomic");160     let p: Parsed<Issue> = parse_entity(&fx("issue.md"), "issue.md").unwrap();161     let rel = fg.issue_file(&p.value.id);162     fg.save_entity(&rel, &p.value, &p.body).unwrap();163     let q: Parsed<Issue> = fg.load_entity(&rel).unwrap();164     assert_eq!(p.value, q.value);165     assert_eq!(p.body, q.body);166     let leftovers: Vec<_> = fs::read_dir(dir.join(".forge/issues").join(&p.value.id))167         .unwrap()168         .filter_map(|e| e.ok())169         .filter(|e| e.path().extension().map(|x| x == "tmp").unwrap_or(false))170         .collect();171     assert!(leftovers.is_empty());172 }173 174 #[test]175 fn numbers_stable_under_permutation() {176     let mut entries: Vec<(String, String)> = (0..200u32)177         .map(|i| {178             (179                 format!("{:08x}-0000-4000-8000-{:012x}", i, i),180                 format!("2025-09-{:02}T{:02}:00:00Z", 1 + (i % 28), i % 24),181             )182         })183         .collect();184     let base = issue_numbers(&entries);185     // reverse, rotate, deterministic LCG shuffles186     let mut perms: Vec<Vec<(String, String)>> = vec![187         entries.iter().rev().cloned().collect(),188         entries.iter().cloned().cycle().skip(37).take(200).collect(),189     ];190     for seed in [1u64, 7, 12345] {191         let mut v = entries.clone();192         let mut s = seed;193         for i in (1..v.len()).rev() {194             s = s.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);195             v.swap(i, (s >> 33) as usize % (i + 1));196         }197         perms.push(v);198     }199     for p in &perms {200         assert_eq!(issue_numbers(p), base);201     }202     // spot check: earliest created == number 1203     let first = entries.iter().min_by(|a, b| (a.1.clone(), a.0.clone()).cmp(&(b.1.clone(), b.0.clone()))).unwrap();204     assert_eq!(base[&first.0], 1);205     let _ = &mut entries;206 }207 208 #[test]209 fn kanban_and_wiki_derived() {210     let tasks = vec![211         ("a".to_string(), "todo".to_string()),212         ("b".to_string(), "doing".to_string()),213         ("c".to_string(), "blocked".to_string()),214         ("d".to_string(), "done".to_string()),215         ("e".to_string(), "bogus".to_string()),216     ];217     let k = kanban(&tasks);218     assert_eq!(k.len(), 4);219     assert_eq!(k["todo"], vec!["a".to_string()]);220     assert!(k.values().all(|v| !v.contains(&"e".to_string())));221     let w = wiki_index(&[222         ("research/a.md".to_string(), "# Real title\nbody\n".to_string()),223         ("docs/b.md".to_string(), "no heading\n".to_string()),224     ]);225     assert_eq!(w[0].path, "docs/b.md");226     assert_eq!(w[0].title, "b");227     assert_eq!(w[1].title, "Real title");228 }229 230 #[test]231 fn json_models_compact_and_ordered() {232     let p: Parsed<Issue> = parse_entity(&fx("issue.md"), "issue.md").unwrap();233     let j = IssueJson::new(3, &p.value, &p.body, 1);234     let s = to_json_line(&j);235     assert!(s.starts_with("{\"number\":3,"));236     assert!(!s.contains("\", \""));237     assert!(!s.contains("\": "));238     assert!(!s.contains('\n'));239     let order = ["number", "title", "state", "labels", "assignees", "milestone", "body", "createdAt", "comments"];240     let mut last = 0;241     for k in order {242         let i = s.find(&format!("\"{k}\":")).unwrap_or_else(|| panic!("missing {k} in {s}"));243         assert!(i >= last, "field order broken at {k}");244         last = i;245     }246     let pr: Parsed<Pr> = parse_entity(&fx("pr.md"), "pr.md").unwrap();247     let pj = PrJson::new(1, &pr.value);248     let ps = to_json_line(&pj);249     assert!(ps.contains("\"databaseId\":\"33333333-3333-4333-8333-333333333333\""));250     assert!(ps.contains("\"status\":\"completed\"") && ps.contains("\"conclusion\":\"success\""));251 }252 253 #[test]254 fn rfc3339_formatter_known_values() {255     assert_eq!(unix_to_rfc3339(0), "1970-01-01T00:00:00Z");256     assert_eq!(unix_to_rfc3339(946684800), "2000-01-01T00:00:00Z");257     assert_eq!(unix_to_rfc3339(1735689600), "2025-01-01T00:00:00Z");258     assert!(is_rfc3339_utc(&now_rfc3339()));259 }260 261 #[test]262 fn bench_10k_issue_scan_under_100ms_debug() {263     let dir = std::env::temp_dir().join(format!(264         "f0rge-bench-{}-{}",265         std::process::id(),266         TMP_CTR.fetch_add(1, Ordering::Relaxed)267     ));268     let _ = fs::remove_dir_all(&dir);269     let issues = dir.join(".forge/issues");270     for i in 0..10_000u32 {271         let id = format!("{:08x}-0000-4000-8000-{:012x}", i, i);272         let d = issues.join(&id);273         fs::create_dir_all(&d).unwrap();274         fs::write(275             d.join("issue.md"),276             format!(277                 "---\nid: {id}\ntitle: t{i}\nstate: open\ncreated: 2025-09-{:02}T00:00:00Z\n---\nb{i}\n",278                 1 + (i % 28)279             ),280         )281         .unwrap();282     }283     let fg = Forge::open(&dir);284     let t0 = std::time::Instant::now();285     let nums = fg.scan_numbers().unwrap();286     let dt = t0.elapsed();287     assert_eq!(nums.len(), 10_000);288     // fused path agrees with the pure path289     let idx = fg.scan_issue_index().unwrap();290     assert_eq!(issue_numbers(&idx), nums);291     eprintln!("10k scan+numbers: {:?}", dt);292     assert!(dt.as_millis() < 100, "too slow: {:?}", dt);293     let _ = fs::remove_dir_all(&dir);294 }295 296 #[test]297 fn fast_card_matches_strict_on_plain_and_fancy() {298     // plain + flow seq + quoted + comment + block seq + unknown field299     let id = "11111111-1111-4111-8111-111111111111";300     let cases = vec![301         format!("---\nid: {id}\ntitle: plain\nstate: open\ncreated: 2025-09-08T02:00:00Z\n---\nbody\n"),302         format!("---\nid: {id}\ntitle: \"quoted # kept\"\nstate: open # live\nlabels: [a, b]\nassignees: []\nmilestone: v1\ncreated: 2025-09-08T02:00:00Z\nupdated: 2025-09-08T03:00:00Z\n---\nbody\n"),303         format!("---\nid: {id}\ntitle: 'it''s'\nstate: in-progress\nlabels:\n- x\n- y\ncreated: 2025-09-08T02:00:00Z\nzzz_new: 1\n---\nbody\n"),304         // fancy: escape + nested flow -> strict fallback, same values305         format!("---\nid: {id}\ntitle: \"a\\nb\"\nstate: open\nlabels: [\"q,q\", z]\ncreated: 2025-09-08T02:00:00Z\n---\nbody\n"),306     ];307     for (i, text) in cases.iter().enumerate() {308         let a: Parsed<Issue> = parse_entity(text, "c.md").unwrap();309         let b = parse_issue_card(text, "c.md").unwrap();310         assert_eq!(a.value, b.value, "case {i} values");311         assert_eq!(a.warnings, b.warnings, "case {i} warnings");312         assert_eq!(a.schema_note, b.schema_note, "case {i} schema");313         assert_eq!(a.body, b.body, "case {i} body");314     }315     // errors stay identical through the fallback316     for text in [317         "---\nid: nope\ntitle: t\nstate: open\ncreated: 2025-09-08T02:00:00Z\n---\n",318         "---\ntitle: t\nstate: open\ncreated: 2025-09-08T02:00:00Z\n---\n",319     ] {320         assert_eq!(321             parse_entity::<Issue>(text, "e.md").unwrap_err().to_string(),322             parse_issue_card(text, "e.md").unwrap_err().to_string()323         );324     }325 }326 327 #[test]328 fn schema_n1_warns_and_keeps_going() {329     let id = "11111111-1111-4111-8111-111111111111";330     let old = format!("---\nid: {id}\ntitle: t\nstate: open\ncreated: 2025-09-08T02:00:00Z\nschema: v0\n---\nbody\n");331     let p: Parsed<Issue> = parse_entity(&old, "issue.md").unwrap();332     assert_eq!(p.value.title, "t");333     let n = p.schema_note.unwrap();334     assert!(n.contains("issue.md") && n.contains("v0") && !n.contains('\n'), "{n}");335     // current marker + absent marker -> silent; never an unknown-field warning336     for text in [337         old.replace("schema: v0", "schema: v1"),338         old.lines().filter(|l| !l.starts_with("schema:")).collect::<Vec<_>>().join("\n"),339     ] {340         let q: Parsed<Issue> = parse_entity(&text, "issue.md").unwrap();341         assert!(q.schema_note.is_none());342         assert!(q.warnings.is_empty(), "{:?}", q.warnings);343     }344     // card path agrees, incl. fallback shapes345     let c = parse_issue_card(&old, "issue.md").unwrap();346     assert!(c.schema_note.is_some());347 }348 349 #[test]350 fn stale_tmp_never_read_nor_listed() {351     // crash mid-write leaves `*.tmp.*`; readers ignore tmp paths entirely.352     let (dir, fg) = tmp_forge("staletmp");353     let p: Parsed<Issue> = parse_entity(&fx("issue.md"), "issue.md").unwrap();354     let rel = fg.issue_file(&p.value.id);355     fg.save_entity(&rel, &p.value, &p.body).unwrap();356     let target = dir.join(".forge").join(&rel);357     fs::write(target.with_extension("tmp.999-0"), b"---\nid: garbage\n---\npartial").unwrap();358     fs::write(target.with_extension("tmp"), b"partial").unwrap();359     let q: Parsed<Issue> = fg.load_entity(&rel).unwrap();360     assert_eq!(p.value, q.value);361     let c = parse_issue_card(&fs::read_to_string(&target).unwrap(), "issue.md").unwrap();362     assert_eq!(c.value, p.value);363     let idx = fg.scan_issue_index().unwrap();364     assert_eq!(idx.len(), 1);365     assert!(fg.scan_numbers().unwrap().contains_key(&p.value.id));366     // no locks anywhere: two writers are last-wins, both leave valid docs367     fg.save_entity(&rel, &p.value, "first\n").unwrap();368     fg.save_entity(&rel, &p.value, "second\n").unwrap();369     let r: Parsed<Issue> = fg.load_entity(&rel).unwrap();370     assert_eq!(r.body, "second\n");371 }372 373 #[test]374 fn created_fractional_orders_and_parses_both() {375     // #28: new stamps carry fractional seconds; old second-precision files parse.376     let a = now_rfc3339();377     assert!(a.contains('.'), "fraction missing: {a}");378     assert!(is_rfc3339_utc(&a), "{a}");379     assert!(is_rfc3339_utc("2026-09-08T07:44:29Z"));380     assert!(is_rfc3339_utc("2026-09-08T07:44:29.123Z"));381     assert!(is_rfc3339_utc("2026-09-08T07:44:29.123456Z"));382     assert!(!is_rfc3339_utc("2026-09-08T07:44:29.Z"));383     assert!(!is_rfc3339_utc("2026-09-08T07:44:29.1234567890Z"));384     // rapid calls never tie: lexicographic == chronological.385     let mut prev = now_rfc3339();386     for _ in 0..50 {387         let cur = now_rfc3339();388         assert!(prev < cur, "not monotonic: {prev} vs {cur}");389         prev = cur;390     }391     // old file (no fraction) still validates through the entity path.392     let id = "11111111-1111-4111-8111-111111111111";393     let old = format!("---\nid: {id}\ntitle: t\nstate: open\ncreated: 2025-09-08T02:00:00Z\n---\nb\n");394     let p: Parsed<Issue> = parse_entity(&old, "old.md").unwrap();395     assert_eq!(p.value.created, "2025-09-08T02:00:00Z");396     let new = format!("---\nid: {id}\ntitle: t\nstate: open\ncreated: {a}\n---\nb\n");397     let q: Parsed<Issue> = parse_entity(&new, "new.md").unwrap();398     assert_eq!(q.value.created, a);399 }