crates/core/src/lib.rs 47.9 KiB 1460 lines Raw
1 #![forbid(unsafe_code)]2 //! f0rge-core: schema v1 entities, strict frontmatter, derived indexes.3 //! PLAN.md M1. Files are the API; this crate never holds state.4 5 use std::collections::BTreeMap;6 use std::fmt;7 use std::fs::{self, File};8 use std::io::Write;9 use std::path::{Path, PathBuf};10 use std::sync::atomic::{AtomicU64, Ordering};11 use std::time::{SystemTime, UNIX_EPOCH};12 13 use serde::{Deserialize, Serialize};14 15 /// Schema version frozen by M0.16 pub const SCHEMA_VERSION: &str = "v1";17 /// SEC-01: max entity file size.18 pub const MAX_FILE_BYTES: usize = 256 * 1024;19 /// SEC-01: max YAML nesting depth / alias count, checked before parse.20 pub const MAX_DEPTH: usize = 32;21 pub const MAX_ALIASES: usize = 100;22 23 // ---- errors: one line, file basename only (SEC-10) ----24 25 #[derive(Debug, Clone, PartialEq, Eq)]26 pub enum ForgeError {27     TooLarge { file: String },28     TooDeep { file: String },29     AliasBomb { file: String },30     NoFrontmatter { file: String },31     MissingField { file: String, field: String },32     BadValue { file: String, field: String },33     Parse { file: String, msg: String },34     Escape { file: String },35     NotFound { file: String },36     Io { file: String },37 }38 39 impl fmt::Display for ForgeError {40     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {41         match self {42             Self::TooLarge { file } => write!(f, "{file}: file over 256 KiB cap"),43             Self::TooDeep { file } => write!(f, "{file}: nesting over depth cap"),44             Self::AliasBomb { file } => write!(f, "{file}: alias count over cap"),45             Self::NoFrontmatter { file } => write!(f, "{file}: missing --- frontmatter"),46             Self::MissingField { file, field } => {47                 write!(f, "{file}: missing field `{field}`")48             }49             Self::BadValue { file, field } => write!(f, "{file}: bad value in `{field}`"),50             Self::Parse { file, msg } => write!(f, "{file}: {msg}"),51             Self::Escape { file } => write!(f, "{file}: escapes forge root"),52             Self::NotFound { file } => write!(f, "{file}: not found"),53             Self::Io { file } => write!(f, "{file}: io error"),54         }55     }56 }57 58 impl std::error::Error for ForgeError {}59 60 /// Basename only; never leak paths (SEC-10).61 pub fn base_name(p: &Path) -> String {62     p.file_name()63         .map(|s| s.to_string_lossy().into_owned())64         .unwrap_or_else(|| "?".to_string())65 }66 67 fn one_line(s: &str) -> String {68     let s: String = s.chars().filter(|c| *c != '\n' && *c != '\r').take(120).collect();69     s.trim().to_string()70 }71 72 // ---- scalar validators ----73 74 /// uuid4, lowercase, dashes kept.75 pub fn is_uuid4_lower(s: &str) -> bool {76     let b = s.as_bytes();77     if b.len() != 36 {78         return false;79     }80     for i in [8usize, 13, 18, 23] {81         if b[i] != b'-' {82             return false;83         }84     }85     if b[14] != b'4' || !matches!(b[19], b'8' | b'9' | b'a' | b'b') {86         return false;87     }88     for (i, c) in b.iter().enumerate() {89         if i == 8 || i == 13 || i == 18 || i == 23 {90             continue;91         }92         if !c.is_ascii_hexdigit() || c.is_ascii_uppercase() {93             return false;94         }95     }96     true97 }98 99 /// RFC3339 UTC: `2025-09-08T02:00:00Z` with optional fractional100 /// seconds (`...00.123456789Z`, 1-9 digits, fixed width in output).101 /// Lexicographic == chronological holds for fixed-width output.102 pub fn is_rfc3339_utc(s: &str) -> bool {103     let t = match s.strip_suffix('Z') {104         Some(x) => x,105         None => return false,106     };107     let (base, frac) = match t.find('.') {108         Some(i) => (&t[..i], Some(&t[i + 1..])),109         None => (t, None),110     };111     if let Some(f) = frac {112         if f.is_empty() || f.len() > 9 || !f.bytes().all(|b| b.is_ascii_digit()) {113             return false;114         }115     }116     let b = base.as_bytes();117     if b.len() != 19 {118         return false;119     }120     for (i, c) in b.iter().enumerate() {121         let ok = match i {122             4 | 7 => *c == b'-',123             10 => *c == b'T',124             13 | 16 => *c == b':',125             _ => c.is_ascii_digit(),126         };127         if !ok {128             return false;129         }130     }131     let num = |a: usize, c: usize| (b[a] - b'0') as u32 * 10 + (b[c] - b'0') as u32;132     let (mo, d, h, mi, se) = (num(5, 6), num(8, 9), num(11, 12), num(14, 15), num(17, 18));133     (1..=12).contains(&mo) && (1..=31).contains(&d) && h < 24 && mi < 60 && se < 61134 }135 136 // ---- hand-rolled RFC3339-UTC from SystemTime (no time crate) ----137 138 fn civil_from_days(z: i64) -> (i64, u32, u32) {139     let era = z.div_euclid(146097);140     let doe = z - era * 146097;141     let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;142     let mut y = yoe + era * 400;143     let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);144     let mp = (5 * doy + 2) / 153;145     let d = (doy - (153 * mp + 2) / 5 + 1) as u32;146     let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;147     if m <= 2 {148         y += 1;149     }150     (y, m, d)151 }152 153 /// Seconds since epoch -> `YYYY-MM-DDTHH:MM:SSZ`.154 pub fn unix_to_rfc3339(secs: u64) -> String {155     let days = (secs / 86400) as i64;156     let sod = (secs % 86400) as u32;157     let (y, mo, d) = civil_from_days(days + 719468);158     let (h, mi, s) = (sod / 3600, sod % 3600 / 60, sod % 60);159     format!("{y:04}-{mo:02}-{d:02}T{h:02}:{mi:02}:{s:02}Z")160 }161 162 /// Now as RFC3339 UTC with fixed-width fractional seconds163 /// (`...SS.nnnnnnnnnZ`). Monotonic: concurrent/rapid calls never tie,164 /// so `(created, id)` rank == creation order. All entities inherit165 /// via this single helper.166 static LAST_NANOS: AtomicU64 = AtomicU64::new(0);167 168 pub fn now_rfc3339() -> String {169     let d = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default();170     let mut total =171         d.as_secs().saturating_mul(1_000_000_000).saturating_add(d.subsec_nanos() as u64);172     loop {173         let last = LAST_NANOS.load(Ordering::Relaxed);174         if total > last {175             if LAST_NANOS176                 .compare_exchange_weak(last, total, Ordering::Relaxed, Ordering::Relaxed)177                 .is_ok()178             {179                 break;180             }181         } else {182             let bumped = last.saturating_add(1);183             if LAST_NANOS184                 .compare_exchange_weak(last, bumped, Ordering::Relaxed, Ordering::Relaxed)185                 .is_ok()186             {187                 total = bumped;188                 break;189             }190         }191     }192     let base = unix_to_rfc3339(total / 1_000_000_000);193     format!("{}.{:09}Z", &base[..base.len() - 1], total % 1_000_000_000)194 }195 196 // ---- frontmatter split + pre-parse guards (SEC-01, before yaml parse) ----197 198 /// Split `---` frontmatter from body. Front excludes both fences.199 pub fn split_frontmatter(text: &str, file: &str) -> Result<(String, String), ForgeError> {200     let err = ForgeError::NoFrontmatter { file: file.to_string() };201     let first_end = text.find('\n').map(|i| i + 1).unwrap_or(text.len());202     if text[..first_end].trim_end() != "---" {203         return Err(err);204     }205     let rest = &text[first_end..];206     let mut off = 0usize;207     let mut last_end = 0usize;208     for line in rest.split_inclusive('\n') {209         if line.trim_end() == "---" {210             return Ok((rest[..off].to_string(), rest[off + line.len()..].to_string()));211         }212         off += line.len();213         last_end = off;214     }215     let _ = last_end;216     if rest.trim_end() == "---" || rest.lines().last().map(|l| l.trim() == "---").unwrap_or(false) {217         if let Some(pos) = rest.rfind("---") {218             return Ok((rest[..pos].to_string(), String::new()));219         }220     }221     Err(err)222 }223 224 /// Indent-depth estimate: leading spaces / 2.225 fn guard_indent(front: &str, file: &str) -> Result<(), ForgeError> {226     for line in front.lines() {227         let n = line.bytes().take_while(|b| *b == b' ').count();228         if n / 2 > MAX_DEPTH {229             return Err(ForgeError::TooDeep { file: file.to_string() });230         }231     }232     Ok(())233 }234 235 /// Flow-depth + alias-count scan, quote-aware, before parse.236 fn guard_flow(front: &str, file: &str) -> Result<(), ForgeError> {237     let mut depth = 0usize;238     let mut aliases = 0usize;239     let mut q: Option<u8> = None;240     for b in front.bytes() {241         if let Some(qc) = q {242             if b == qc {243                 q = None;244             }245             continue;246         }247         match b {248             b'\'' | b'"' => q = Some(b),249             b'[' | b'{' => {250                 depth += 1;251                 if depth > MAX_DEPTH {252                     return Err(ForgeError::TooDeep { file: file.to_string() });253                 }254             }255             b']' | b'}' => depth = depth.saturating_sub(1),256             b'*' => {257                 aliases += 1;258                 if aliases > MAX_ALIASES {259                     return Err(ForgeError::AliasBomb { file: file.to_string() });260                 }261             }262             _ => {}263         }264     }265     Ok(())266 }267 268 fn value_depth(v: &yaml_serde::Value) -> usize {269     match v {270         yaml_serde::Value::Mapping(m) => {271             let mut d = 0;272             for (_, val) in m.iter() {273                 d = d.max(value_depth(val));274             }275             d.saturating_add(1)276         }277         yaml_serde::Value::Sequence(s) => {278             let mut d = 0;279             for val in s.iter() {280                 d = d.max(value_depth(val));281             }282             d.saturating_add(1)283         }284         _ => 0,285     }286 }287 288 fn unknown_fields(v: &yaml_serde::Value, known: &[&str]) -> Vec<String> {289     let mut out = Vec::new();290     if let yaml_serde::Value::Mapping(m) = v {291         for (k, _) in m.iter() {292             let name = match k {293                 yaml_serde::Value::String(s) => s.clone(),294                 _ => "?".to_string(),295             };296             if !known.contains(&name.as_str()) {297                 out.push(format!("unknown field `{name}`"));298             }299             if out.len() >= 16 {300                 break;301             }302         }303     }304     out305 }306 307 fn missing_field_name(msg: &str) -> Option<String> {308     let p = msg.find("missing field `")? + "missing field `".len();309     let end = msg[p..].find('`')?;310     Some(msg[p..p + end].to_string())311 }312 313 // ---- entities (schema v1) ----314 315 /// Parsed doc: value + unknown-field warnings + N-1 schema note + body.316 #[derive(Debug)]317 pub struct Parsed<T> {318     pub value: T,319     pub warnings: Vec<String>,320     pub schema_note: Option<String>,321     pub body: String,322 }323 324 /// Per-entity validation + known-field list for warnings.325 pub trait Validate {326     const KNOWN: &'static [&'static str];327     fn validate(&self, file: &str) -> Result<(), ForgeError>;328 }329 330 fn check_id(id: &str, file: &str) -> Result<(), ForgeError> {331     if !is_uuid4_lower(id) {332         return Err(ForgeError::BadValue { file: file.to_string(), field: "id".to_string() });333     }334     Ok(())335 }336 337 fn check_ts(ts: &str, field: &str, file: &str) -> Result<(), ForgeError> {338     if !is_rfc3339_utc(ts) {339         return Err(ForgeError::BadValue { file: file.to_string(), field: field.to_string() });340     }341     Ok(())342 }343 344 fn check_state(state: &str, allowed: &[&str], field: &str, file: &str) -> Result<(), ForgeError> {345     if !allowed.contains(&state) {346         return Err(ForgeError::BadValue { file: file.to_string(), field: field.to_string() });347     }348     Ok(())349 }350 351 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]352 pub struct Issue {353     pub id: String,354     pub title: String,355     pub state: String,356     #[serde(default)]357     pub labels: Vec<String>,358     #[serde(default)]359     pub assignees: Vec<String>,360     #[serde(default)]361     pub milestone: Option<String>,362     pub created: String,363     #[serde(default)]364     pub updated: Option<String>,365 }366 367 impl Validate for Issue {368     const KNOWN: &'static [&'static str] =369         &["id", "title", "state", "labels", "assignees", "milestone", "created", "updated", "schema"];370     fn validate(&self, file: &str) -> Result<(), ForgeError> {371         check_id(&self.id, file)?;372         check_state(&self.state, &["open", "in-progress", "closed", "archived"], "state", file)?;373         check_ts(&self.created, "created", file)?;374         if let Some(u) = &self.updated {375             check_ts(u, "updated", file)?;376         }377         Ok(())378     }379 }380 381 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]382 pub struct IssueComment {383     pub id: String,384     #[serde(default)]385     pub author: Option<String>,386     pub created: String,387 }388 389 impl Validate for IssueComment {390     const KNOWN: &'static [&'static str] = &["id", "author", "created", "schema"];391     fn validate(&self, file: &str) -> Result<(), ForgeError> {392         check_id(&self.id, file)?;393         check_ts(&self.created, "created", file)?;394         Ok(())395     }396 }397 398 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]399 pub struct Pr {400     pub id: String,401     pub title: String,402     pub state: String,403     pub base: String,404     pub head: String,405     #[serde(default)]406     pub draft: bool,407     #[serde(default)]408     pub issues: Vec<String>,409     #[serde(default)]410     pub ci: Option<String>,411     pub created: String,412     #[serde(default)]413     pub updated: Option<String>,414 }415 416 impl Validate for Pr {417     const KNOWN: &'static [&'static str] =418         &["id", "title", "state", "base", "head", "draft", "issues", "ci", "created", "updated", "schema"];419     fn validate(&self, file: &str) -> Result<(), ForgeError> {420         check_id(&self.id, file)?;421         check_state(&self.state, &["open", "closed", "merged"], "state", file)?;422         for i in &self.issues {423             if !is_uuid4_lower(i) {424                 return Err(ForgeError::BadValue { file: file.to_string(), field: "issues".to_string() });425             }426         }427         if let Some(ci) = &self.ci {428             check_state(ci, &["pass", "fail", "running", "cancelled"], "ci", file)?;429         }430         check_ts(&self.created, "created", file)?;431         if let Some(u) = &self.updated {432             check_ts(u, "updated", file)?;433         }434         Ok(())435     }436 }437 438 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]439 pub struct PrReview {440     pub id: String,441     #[serde(default)]442     pub author: Option<String>,443     #[serde(default)]444     pub state: Option<String>,445     pub created: String,446 }447 448 impl Validate for PrReview {449     const KNOWN: &'static [&'static str] = &["id", "author", "state", "created", "schema"];450     fn validate(&self, file: &str) -> Result<(), ForgeError> {451         check_id(&self.id, file)?;452         if let Some(s) = &self.state {453             check_state(s, &["approved", "changes-requested", "commented"], "state", file)?;454         }455         check_ts(&self.created, "created", file)?;456         Ok(())457     }458 }459 460 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]461 pub struct Task {462     pub id: String,463     pub title: String,464     pub status: String,465     #[serde(default)]466     pub assignee: Option<String>,467     pub created: String,468     #[serde(default)]469     pub updated: Option<String>,470     #[serde(default)]471     pub closed: Option<String>,472 }473 474 impl Validate for Task {475     const KNOWN: &'static [&'static str] =476         &["id", "title", "status", "assignee", "created", "updated", "closed", "schema"];477     fn validate(&self, file: &str) -> Result<(), ForgeError> {478         check_id(&self.id, file)?;479         check_state(&self.status, &["todo", "doing", "blocked", "done"], "status", file)?;480         check_ts(&self.created, "created", file)?;481         for (v, n) in [(&self.updated, "updated"), (&self.closed, "closed")] {482             if let Some(t) = v {483                 check_ts(t, n, file)?;484             }485         }486         Ok(())487     }488 }489 490 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]491 pub struct Label {492     pub name: String,493     #[serde(default)]494     pub color: Option<String>,495     #[serde(default)]496     pub description: Option<String>,497 }498 499 impl Validate for Label {500     const KNOWN: &'static [&'static str] = &["name", "color", "description"];501     fn validate(&self, _file: &str) -> Result<(), ForgeError> {502         Ok(())503     }504 }505 506 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]507 pub struct Milestone {508     pub name: String,509     #[serde(default = "default_open")]510     pub state: String,511     #[serde(default)]512     pub due: Option<String>,513     #[serde(default)]514     pub description: Option<String>,515 }516 517 fn default_open() -> String {518     "open".to_string()519 }520 521 impl Validate for Milestone {522     const KNOWN: &'static [&'static str] = &["name", "state", "due", "description"];523     fn validate(&self, file: &str) -> Result<(), ForgeError> {524         check_state(&self.state, &["open", "closed"], "state", file)?;525         Ok(())526     }527 }528 529 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]530 pub struct Release {531     pub version: String,532     #[serde(default)]533     pub title: Option<String>,534     pub created: String,535     #[serde(default)]536     pub sha: Option<String>,537 }538 539 impl Validate for Release {540     const KNOWN: &'static [&'static str] = &["version", "title", "created", "sha", "schema"];541     fn validate(&self, file: &str) -> Result<(), ForgeError> {542         check_ts(&self.created, "created", file)?;543         Ok(())544     }545 }546 547 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]548 pub struct MergeRecord {549     pub pr: String,550     #[serde(default)]551     pub applied: bool,552     pub verdict: String,553     #[serde(default)]554     pub resolved: Vec<String>,555     pub created: String,556 }557 558 impl Validate for MergeRecord {559     const KNOWN: &'static [&'static str] = &["pr", "applied", "verdict", "resolved", "created", "schema"];560     fn validate(&self, file: &str) -> Result<(), ForgeError> {561         if !is_uuid4_lower(&self.pr) {562             return Err(ForgeError::BadValue { file: file.to_string(), field: "pr".to_string() });563         }564         check_state(&self.verdict, &["pass", "fail"], "verdict", file)?;565         check_ts(&self.created, "created", file)?;566         Ok(())567     }568 }569 570 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]571 pub struct CiRunStep {572     pub name: String,573     pub exit: i32,574     #[serde(default)]575     pub duration_ms: u64,576 }577 578 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]579 pub struct CiRun {580     pub run: String,581     pub verdict: String,582     pub created: String,583     #[serde(default)]584     pub steps: Vec<CiRunStep>,585 }586 587 impl Validate for CiRun {588     const KNOWN: &'static [&'static str] = &["run", "verdict", "created", "steps", "schema"];589     fn validate(&self, file: &str) -> Result<(), ForgeError> {590         check_state(&self.verdict, &["pass", "fail", "running", "cancelled"], "verdict", file)?;591         check_ts(&self.created, "created", file)?;592         Ok(())593     }594 }595 596 /// ci.env: `runner` known, any extra string knob allowed (never secret).597 #[derive(Debug, Clone, PartialEq, Eq)]598 pub struct CiEnv {599     pub runner: String,600     pub knobs: BTreeMap<String, String>,601 }602 603 impl CiEnv {604     pub const KNOWN: &'static [&'static str] = &["runner"];605     pub fn validate(&self, file: &str) -> Result<(), ForgeError> {606         check_state(&self.runner, &["local", "docker"], "runner", file)?;607         Ok(())608     }609 }610 611 fn scalar_to_string(v: &yaml_serde::Value) -> Option<String> {612     match v {613         yaml_serde::Value::String(s) => Some(s.clone()),614         yaml_serde::Value::Bool(b) => Some(b.to_string()),615         yaml_serde::Value::Number(n) => Some(n.to_string()),616         yaml_serde::Value::Null => Some(String::new()),617         _ => None,618     }619 }620 621 /// Parse ci.env mapping (no fences).622 pub fn parse_ci_env(text: &str, file: &str) -> Result<CiEnv, ForgeError> {623     if text.len() > MAX_FILE_BYTES {624         return Err(ForgeError::TooLarge { file: file.to_string() });625     }626     guard_flow(text, file)?;627     let v: yaml_serde::Value = yaml_serde::from_str(text)628         .map_err(|e| ForgeError::Parse { file: file.to_string(), msg: one_line(&e.to_string()) })?;629     if value_depth(&v) > MAX_DEPTH {630         return Err(ForgeError::TooDeep { file: file.to_string() });631     }632     let yaml_serde::Value::Mapping(m) = &v else {633         return Err(ForgeError::Parse { file: file.to_string(), msg: "ci.env must be a mapping".to_string() });634     };635     let mut runner = "local".to_string();636     let mut knobs = BTreeMap::new();637     for (k, val) in m.iter() {638         let yaml_serde::Value::String(key) = k else { continue };639         let s = scalar_to_string(val).ok_or_else(|| ForgeError::BadValue {640             file: file.to_string(),641             field: key.clone(),642         })?;643         if key == "runner" {644             runner = s;645         } else {646             knobs.insert(key.clone(), s);647         }648     }649     let env = CiEnv { runner, knobs };650     env.validate(file)?;651     Ok(env)652 }653 654 // ---- strict parse: size -> split -> guards -> value -> struct ----655 656 /// Parse fenced doc into entity. Unknown field = warning; missing = error.657 pub fn parse_entity<T>(text: &str, file: &str) -> Result<Parsed<T>, ForgeError>658 where659     T: for<'de> Deserialize<'de> + Validate,660 {661     if text.len() > MAX_FILE_BYTES {662         return Err(ForgeError::TooLarge { file: file.to_string() });663     }664     let (front, body) = split_frontmatter(text, file)?;665     guard_indent(&front, file)?;666     guard_flow(&front, file)?;667     let v: yaml_serde::Value = yaml_serde::from_str(&front)668         .map_err(|e| ForgeError::Parse { file: file.to_string(), msg: one_line(&e.to_string()) })?;669     if value_depth(&v) > MAX_DEPTH {670         return Err(ForgeError::TooDeep { file: file.to_string() });671     }672     let warnings = unknown_fields(&v, T::KNOWN);673     let schema_note = schema_note(&v, file);674     let value: T = yaml_serde::from_value(v).map_err(|e| {675         let m = e.to_string();676         let line = m.lines().next().unwrap_or("parse error");677         if let Some(field) = missing_field_name(&m) {678             ForgeError::MissingField { file: file.to_string(), field }679         } else {680             ForgeError::Parse { file: file.to_string(), msg: one_line(line) }681         }682     })?;683     value.validate(file)?;684     Ok(Parsed { value, warnings, schema_note, body })685 }686 687 /// One-hop migration note (M8): a frontmatter `schema` marker older than688 /// SCHEMA_VERSION yields a one-line note naming the file; parse continues.689 /// Absent or current marker -> None. `schema` is in every KNOWN list, so690 /// it never fires the unknown-field warning.691 fn schema_note(v: &yaml_serde::Value, file: &str) -> Option<String> {692     let yaml_serde::Value::Mapping(m) = v else { return None };693     let key = yaml_serde::Value::String("schema".to_string());694     let yaml_serde::Value::String(s) = m.get(&key)? else { return None };695     if s == SCHEMA_VERSION {696         return None;697     }698     let mark: String = s.chars().filter(|c| *c != '\n' && *c != '\r').take(32).collect();699     Some(format!("{file}: older schema `{mark}`, continuing"))700 }701 702 /// Parse fenceless YAML list files (labels.md, milestones.md).703 pub fn parse_list<T>(text: &str, file: &str) -> Result<(Vec<T>, Vec<String>), ForgeError>704 where705     T: for<'de> Deserialize<'de> + Validate,706 {707     if text.len() > MAX_FILE_BYTES {708         return Err(ForgeError::TooLarge { file: file.to_string() });709     }710     guard_indent(text, file)?;711     guard_flow(text, file)?;712     let v: yaml_serde::Value = yaml_serde::from_str(text)713         .map_err(|e| ForgeError::Parse { file: file.to_string(), msg: one_line(&e.to_string()) })?;714     if value_depth(&v) > MAX_DEPTH {715         return Err(ForgeError::TooDeep { file: file.to_string() });716     }717     let mut warnings = Vec::new();718     if let yaml_serde::Value::Sequence(seq) = &v {719         for item in seq.iter() {720             warnings.extend(unknown_fields(item, T::KNOWN));721         }722     }723     let items: Vec<T> = yaml_serde::from_value(v).map_err(|e| {724         let m = e.to_string();725         if let Some(field) = missing_field_name(&m) {726             ForgeError::MissingField { file: file.to_string(), field }727         } else {728             ForgeError::Parse {729                 file: file.to_string(),730                 msg: one_line(m.lines().next().unwrap_or("parse error")),731             }732         }733     })?;734     for item in &items {735         item.validate(file)?;736     }737     Ok((items, warnings))738 }739 740 /// Serialize entity to fenced doc.741 pub fn render_entity<T: Serialize>(value: &T, body: &str) -> Result<String, String> {742     let mut front =743         yaml_serde::to_string(value).map_err(|e| one_line(&e.to_string()))?;744     if !front.ends_with('\n') {745         front.push('\n');746     }747     Ok(format!("---\n{front}---\n{body}"))748 }749 750 // ---- Forge: path resolution + atomic IO ----751 752 #[derive(Debug, Clone)]753 pub struct Forge {754     root: PathBuf,755 }756 757 static TMP_CTR: AtomicU64 = AtomicU64::new(0);758 759 impl Forge {760     pub fn new(root: PathBuf) -> Self {761         Self { root }762     }763 764     /// Root at `<repo>/.forge`.765     pub fn open(repo: &Path) -> Self {766         Self { root: repo.join(".forge") }767     }768 769     pub fn root(&self) -> &Path {770         &self.root771     }772 773     fn canon_root(&self) -> Result<PathBuf, ForgeError> {774         fs::canonicalize(&self.root)775             .map_err(|_| ForgeError::NotFound { file: base_name(&self.root) })776     }777 778     /// Resolve rel under root: canonicalize + prefix check (SEC-02).779     pub fn resolve(&self, rel: &Path) -> Result<PathBuf, ForgeError> {780         let label = base_name(rel);781         let joined = self.root.join(rel);782         let canon = fs::canonicalize(&joined)783             .map_err(|_| ForgeError::NotFound { file: label.clone() })?;784         if !canon.starts_with(&self.canon_root()?) {785             return Err(ForgeError::Escape { file: label });786         }787         Ok(canon)788     }789 790     fn resolve_for_write(&self, rel: &Path) -> Result<PathBuf, ForgeError> {791         let label = base_name(rel);792         if rel.components().any(|c| c == std::path::Component::ParentDir) {793             return Err(ForgeError::Escape { file: label });794         }795         let joined = self.root.join(rel);796         if let Some(parent) = joined.parent() {797             fs::create_dir_all(parent)798                 .map_err(|_| ForgeError::Io { file: label.clone() })?;799             let canon_parent = fs::canonicalize(parent)800                 .map_err(|_| ForgeError::Io { file: label.clone() })?;801             if !canon_parent.starts_with(&self.canon_root()?) {802                 return Err(ForgeError::Escape { file: label });803             }804         }805         // Never follow a symlink on write target (SEC-02).806         if fs::symlink_metadata(&joined)807             .map(|m| m.file_type().is_symlink())808             .unwrap_or(false)809         {810             return Err(ForgeError::Escape { file: label });811         }812         Ok(joined)813     }814 815     // Path resolution: folder layout lives only here.816     pub fn issue_file(&self, id: &str) -> PathBuf {817         Path::new("issues").join(id).join("issue.md")818     }819     pub fn issue_comments(&self, id: &str) -> PathBuf {820         Path::new("issues").join(id).join("comments")821     }822     pub fn pr_file(&self, id: &str) -> PathBuf {823         Path::new("prs").join(id).join("pr.md")824     }825     pub fn pr_patch(&self, id: &str) -> PathBuf {826         Path::new("prs").join(id).join("patch.diff")827     }828     pub fn pr_reviews(&self, id: &str) -> PathBuf {829         Path::new("prs").join(id).join("reviews")830     }831     pub fn task_file(&self, id: &str) -> PathBuf {832         Path::new("tasks").join(format!("{id}.md"))833     }834     pub fn labels_file(&self) -> PathBuf {835         PathBuf::from("labels.md")836     }837     pub fn milestones_file(&self) -> PathBuf {838         PathBuf::from("milestones.md")839     }840     pub fn wiki_file(&self, rel: &str) -> PathBuf {841         Path::new("wiki").join(rel)842     }843     pub fn release_file(&self, version: &str) -> PathBuf {844         Path::new("releases").join(format!("{version}.md"))845     }846     pub fn merge_file(&self, ts: &str) -> PathBuf {847         Path::new("merges").join(format!("{ts}.md"))848     }849     pub fn archive_file(&self, kind: &str, id: &str) -> PathBuf {850         Path::new("archive").join(kind).join(id)851     }852     pub fn ci_env(&self) -> PathBuf {853         Path::new("ci").join("ci.env")854     }855 856     /// Read file, capped pre-alloc (SEC-01), prefix-checked (SEC-02).857     pub fn read_capped(&self, rel: &Path) -> Result<Vec<u8>, ForgeError> {858         let label = base_name(rel);859         let p = self.resolve(rel)?;860         let md = fs::metadata(&p).map_err(|_| ForgeError::NotFound { file: label.clone() })?;861         if md.len() > MAX_FILE_BYTES as u64 {862             return Err(ForgeError::TooLarge { file: label });863         }864         let bytes = fs::read(&p).map_err(|_| ForgeError::Io { file: label.clone() })?;865         if bytes.len() > MAX_FILE_BYTES {866             return Err(ForgeError::TooLarge { file: label });867         }868         Ok(bytes)869     }870 871     /// Atomic write: tmp + fsync + rename (SEC-03). No locks, by design:872     /// concurrent writers are last-wins; the colliding agent reconciles873     /// (bitter lesson). A crash leaves only a stale `*.tmp.*` beside the874     /// target; readers never open tmp paths, so partials are impossible.875     pub fn atomic_write(&self, rel: &Path, bytes: &[u8]) -> Result<(), ForgeError> {876         let label = base_name(rel);877         if bytes.len() > MAX_FILE_BYTES {878             return Err(ForgeError::TooLarge { file: label });879         }880         let target = self.resolve_for_write(rel)?;881         let n = TMP_CTR.fetch_add(1, Ordering::Relaxed);882         let tmp = target.with_extension(format!("tmp.{}-{n}", std::process::id()));883         let mut f = File::create(&tmp).map_err(|_| ForgeError::Io { file: label.clone() })?;884         let r: Result<(), ForgeError> = (|| {885             f.write_all(bytes).map_err(|_| ForgeError::Io { file: label.clone() })?;886             f.sync_all().map_err(|_| ForgeError::Io { file: label.clone() })?;887             drop(f);888             fs::rename(&tmp, &target).map_err(|_| ForgeError::Io { file: label.clone() })?;889             if let Some(dir) = target.parent() {890                 if let Ok(d) = File::open(dir) {891                     let _ = d.sync_all();892                 }893             }894             Ok(())895         })();896         if r.is_err() {897             let _ = fs::remove_file(&tmp);898         }899         r900     }901 902     /// Load + validate entity.903     pub fn load_entity<T>(&self, rel: &Path) -> Result<Parsed<T>, ForgeError>904     where905         T: for<'de> Deserialize<'de> + Validate,906     {907         let bytes = self.read_capped(rel)?;908         let label = base_name(rel);909         let text = String::from_utf8(bytes).map_err(|_| ForgeError::Parse {910             file: label.clone(),911             msg: "not utf-8".to_string(),912         })?;913         parse_entity(&text, &label)914     }915 916     /// Render + atomically save entity.917     pub fn save_entity<T: Serialize>(&self, rel: &Path, value: &T, body: &str) -> Result<(), ForgeError> {918         let front = yaml_serde::to_string(value)919             .map_err(|e| ForgeError::Parse { file: base_name(rel), msg: one_line(&e.to_string()) })?;920         let mut doc = String::from("---\n");921         doc.push_str(&front);922         if !front.ends_with('\n') {923             doc.push('\n');924         }925         doc.push_str("---\n");926         doc.push_str(body);927         self.atomic_write(rel, doc.as_bytes())928     }929 930     /// Fast `(id, created)` scan of `issues/` for numbering.931     /// Zero-copy line scan (no YAML lib, no body copy); strict parse932     /// only on the error path. Skips symlinks, never follows them.933     /// File reads run on a scoped thread pool; output order is934     /// chunk order, callers sort anyway (`issue_numbers` is total).935     pub fn scan_issue_index(&self) -> Result<Vec<(String, String)>, ForgeError> {936         let paths = self.issue_paths()?;937         if paths.is_empty() {938             return Ok(Vec::new());939         }940 941         let workers = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4).min(8);942         let chunk = paths.len().div_ceil(workers).max(1);943         let total = paths.len();944         let mut out: Vec<Vec<(String, String)>> = Vec::new();945         std::thread::scope(|s| {946             let handles: Vec<_> = paths947                 .chunks(chunk)948                 .map(|c| s.spawn(|| scan_chunk(c)))949                 .collect();950             for h in handles {951                 out.push(h.join().unwrap_or(Err(ForgeError::Io { file: "issues".to_string() }))?);952             }953             Ok::<(), ForgeError>(())954         })?;955         let mut flat = Vec::with_capacity(total);956         for v in out {957             flat.extend(v);958         }959         Ok(flat)960     }961 962     /// Fused hot path: parallel scan + parallel run-sort + k-way merge963     /// into id -> number. Same result as `issue_numbers(&scan())`.964     pub fn scan_numbers(&self) -> Result<BTreeMap<String, u64>, ForgeError> {965         let paths = self.issue_paths()?;966         if paths.is_empty() {967             return Ok(BTreeMap::new());968         }969         let workers = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4).min(8);970         let chunk = paths.len().div_ceil(workers).max(1);971         let mut runs: Vec<Vec<(String, String)>> = Vec::new();972         std::thread::scope(|s| {973             let handles: Vec<_> = paths974                 .chunks(chunk)975                 .map(|c| s.spawn(|| scan_chunk_sorted(c)))976                 .collect();977             for h in handles {978                 runs.push(h.join().unwrap_or(Err(ForgeError::Io { file: "issues".to_string() }))?);979             }980             Ok::<(), ForgeError>(())981         })?;982         let mut pairs = merge_ranked(runs);983         pairs.sort_by(|a, b| a.0.cmp(&b.0));984         Ok(pairs.into_iter().collect())985     }986 987     /// `issues/*/issue.md` paths; skips symlinks, never follows them.988     fn issue_paths(&self) -> Result<Vec<PathBuf>, ForgeError> {989         let dir = self.root.join("issues");990         let entries = fs::read_dir(&dir).map_err(|_| ForgeError::NotFound {991             file: "issues".to_string(),992         })?;993         let mut paths = Vec::new();994         for entry in entries.flatten() {995             let ft = match entry.file_type() {996                 Ok(t) => t,997                 Err(_) => continue,998             };999             if ft.is_symlink() || !ft.is_dir() {1000                 continue;1001             }1002             paths.push(entry.path().join("issue.md"));1003         }1004         Ok(paths)1005     }1006 }1007 1008 /// ASCII-only trim (fast path; Unicode docs fall back to strict parse).1009 fn ascii_trim(s: &str) -> &str {1010     let b = s.as_bytes();1011     let mut a = 0;1012     let mut z = b.len();1013     while a < z && (b[a] == b' ' || b[a] == b'\t' || b[a] == b'\r') {1014         a += 1;1015     }1016     while z > a && (b[z - 1] == b' ' || b[z - 1] == b'\t' || b[z - 1] == b'\r') {1017         z -= 1;1018     }1019     &s[a..z]1020 }1021 1022 fn unquote(s: &str) -> &str {1023     let b = s.as_bytes();1024     if b.len() >= 21025         && ((b[0] == b'"' && b[b.len() - 1] == b'"')1026             || (b[0] == b'\'' && b[b.len() - 1] == b'\''))1027     {1028         &s[1..b.len() - 1]1029     } else {1030         s1031     }1032 }1033 1034 /// Value of `key:` on its line (to EOL), trimmed + unquoted.1035 fn line_val(rest: &str) -> Option<&str> {1036     let end = rest.find('\n').unwrap_or(rest.len());1037     let v = unquote(ascii_trim(&rest[..end]));1038     if v.is_empty() {1039         return None;1040     }1041     Some(v)1042 }1043 1044 /// Top-level `key:` lookup by memchr hops (index fast path only).1045 fn field_in<'a>(front: &'a str, key: &str) -> Option<&'a str> {1046     let b = front.as_bytes();1047     let k = key.as_bytes();1048     if front.len() > k.len() && &b[..k.len()] == k && b[k.len()] == b':' {1049         return line_val(&front[k.len() + 1..]);1050     }1051     let mut i = 0;1052     while i < b.len() {1053         let rel = front[i..].find('\n')?;1054         let ls = i + rel + 1;1055         if ls + k.len() + 1 <= b.len() && &b[ls..ls + k.len()] == k && b[ls + k.len()] == b':' {1056             return line_val(&front[ls + k.len() + 1..]);1057         }1058         i = ls;1059     }1060     None1061 }1062 1063 /// End of frontmatter: first `\n---` line closed by EOL/EOF.1064 fn fence_pos(text: &str, from: usize) -> Option<usize> {1065     let b = text.as_bytes();1066     let mut i = from;1067     while let Some(k) = text[i..].find("\n---") {1068         let j = i + k + 4;1069         if j >= b.len() || b[j] == b'\n' || b[j] == b'\r' {1070             return Some(i + k);1071         }1072         i = j;1073     }1074     None1075 }1076 1077 /// Zero-copy `(id, created)` extract from a full issue.md text.1078 /// Returns None when fences/fields are missing or fail validation;1079 /// caller falls back to the strict path for an authoritative error.1080 fn fast_ident(text: &str) -> Option<(&str, &str)> {1081     let rest = text.strip_prefix("---\n").or_else(|| text.strip_prefix("---\r\n"))?;1082     let base = text.len() - rest.len();1083     let fence = fence_pos(text, base.saturating_sub(1))?;1084     let front = &text[base..fence];1085     let id = field_in(front, "id")?;1086     let created = field_in(front, "created")?;1087     if !is_uuid4_lower(id) || !is_rfc3339_utc(created) {1088         return None;1089     }1090     Some((id, created))1091 }1092 1093 // ---- derived indexes (pure, deterministic) ----1094 1095 /// One scan worker: read + fast-extract each file, strict errors only.1096 fn scan_chunk(files: &[PathBuf]) -> Result<Vec<(String, String)>, ForgeError> {1097     let mut out = Vec::with_capacity(files.len());1098     for f in files {1099         let bytes = match fs::read(f) {1100             Ok(b) => b,1101             Err(_) => continue, // racing delete; list tolerates1102         };1103         if bytes.len() > MAX_FILE_BYTES {1104             return Err(ForgeError::TooLarge { file: base_name(f) });1105         }1106         let text = match std::str::from_utf8(&bytes) {1107             Ok(t) => t,1108             Err(_) => {1109                 return Err(ForgeError::Parse { file: base_name(f), msg: "not utf-8".to_string() });1110             }1111         };1112         match fast_ident(text) {1113             Some((id, created)) => out.push((id.to_string(), created.to_string())),1114             None => {1115                 let label = base_name(f);1116                 let _ = parse_entity::<Issue>(text, &label)?; // authoritative error1117                 return Err(ForgeError::Parse {1118                     file: label,1119                     msg: "unreadable index fields".to_string(),1120                 });1121             }1122         }1123     }1124     Ok(out)1125 }1126 1127 /// Numbers: 1-based rank in `(created, id)` total order.1128 /// Same inputs -> same numbers in any copy, any insertion order.1129 pub fn issue_numbers(entries: &[(String, String)]) -> BTreeMap<String, u64> {1130     let mut keys: Vec<(&str, &str)> =1131         entries.iter().map(|e| (e.1.as_str(), e.0.as_str())).collect();1132     keys.sort();1133     keys.into_iter().enumerate().map(|(i, (_, id))| (id.to_string(), i as u64 + 1)).collect()1134 }1135 1136 /// Sorted-run worker for the fused path.1137 fn scan_chunk_sorted(files: &[PathBuf]) -> Result<Vec<(String, String)>, ForgeError> {1138     let mut out = scan_chunk(files)?;1139     out.sort_by(|a, b| (a.1.as_str(), a.0.as_str()).cmp(&(b.1.as_str(), b.0.as_str())));1140     Ok(out)1141 }1142 1143 /// Plain/quoted scalar on one frontmatter line. Returns None on any shape1144 /// needing real YAML (flow, anchors, escapes, comments-in-quotes...);1145 /// the caller then falls back to strict parse.1146 fn scalar_val(s: &str) -> Option<String> {1147     let t = s.trim();1148     if t.is_empty() {1149         return None;1150     }1151     let b = t.as_bytes();1152     if b[0] == b'"' || b[0] == b'\'' {1153         if b.len() < 2 || b[b.len() - 1] != b[0] {1154             return None;1155         }1156         let inner = &t[1..t.len() - 1];1157         if b[0] == b'"' && inner.contains('\\') {1158             return None;1159         }1160         if b[0] == b'\'' {1161             return Some(inner.replace("''", "'"));1162         }1163         return Some(inner.to_string());1164     }1165     if matches!(1166         b[0],1167         b'{' | b'}' | b'[' | b']' | b',' | b'&' | b'*' | b'#' | b'?' | b'|' | b'>' | b'!' | b'@' | b'`' | b'%'1168     ) {1169         return None;1170     }1171     let mut val = t;1172     if let Some(i) = t.find(" #") {1173         val = t[..i].trim_end();1174     }1175     if val.is_empty() {1176         return None;1177     }1178     Some(val.to_string())1179 }1180 1181 /// Flow `[a, b]` seq on one line; None when quoted items or nesting appear.1182 fn flow_items(v: &str) -> Option<Vec<String>> {1183     let t = v.trim();1184     if !t.starts_with('[') || !t.ends_with(']') {1185         return None;1186     }1187     let inner = t[1..t.len() - 1].trim();1188     if inner.is_empty() {1189         return Some(Vec::new());1190     }1191     if inner.bytes().any(|b| b == b'"' || b == b'\'') {1192         return None;1193     }1194     inner.split(',').map(scalar_val).collect()1195 }1196 1197 /// Fast issue extract for `issue list` over huge dirs: plain scalars, flow1198 /// `[]` seqs, and `- ` blocks for labels/assignees only. Returns None on1199 /// any other shape; the caller falls back to strict `parse_entity`, so1200 /// values, warnings, and errors stay identical.1201 fn fast_issue(front: &str, body: &str, file: &str) -> Option<Parsed<Issue>> {1202     if front.bytes().any(|b| b == b'\t') {1203         return None;1204     }1205     let (mut id, mut title, mut state, mut created) = (None, None, None, None);1206     let (mut updated, mut milestone): (Option<String>, Option<String>) = (None, None);1207     let (mut labels, mut assignees) = (Vec::new(), Vec::new());1208     let mut seq: Option<bool> = None; // block mode: true = labels1209     let mut unknowns: Vec<String> = Vec::new();1210     let mut schema: Option<String> = None;1211     for raw in front.split_inclusive('\n') {1212         let l = raw.strip_suffix('\n').unwrap_or(raw);1213         if l.is_empty() {1214             seq = None;1215             continue;1216         }1217         if l.as_bytes()[0] == b' ' {1218             let t = l.trim();1219             match (t.strip_prefix("- "), seq) {1220                 (Some(item), Some(true)) => labels.push(scalar_val(item)?),1221                 (Some(item), Some(false)) => assignees.push(scalar_val(item)?),1222                 _ => return None,1223             }1224             continue;1225         }1226         seq = None;1227         if l.starts_with('-') {1228             return None;1229         }1230         let ci = l.find(':')?;1231         let (k, rest) = (&l[..ci], &l[ci + 1..]);1232         if k.is_empty() || !k.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-') {1233             return None;1234         }1235         if !rest.is_empty() && !rest.starts_with(' ') {1236             return None; // `k:v` is a scalar line, not a mapping1237         }1238         let v = rest.trim();1239         match k {1240             "id" => id = Some(scalar_val(v)?),1241             "title" => title = Some(scalar_val(v)?),1242             "state" => state = Some(scalar_val(v)?),1243             "created" => created = Some(scalar_val(v)?),1244             "updated" => updated = Some(scalar_val(v)?),1245             "milestone" => {1246                 if !v.is_empty() {1247                     milestone = Some(scalar_val(v)?);1248                 }1249             }1250             "labels" | "assignees" => {1251                 if v.is_empty() {1252                     seq = Some(k == "labels");1253                 } else {1254                     let items = flow_items(v)?;1255                     if k == "labels" {1256                         labels = items;1257                     } else {1258                         assignees = items;1259                     }1260                 }1261             }1262             "schema" => schema = Some(scalar_val(v)?),1263             _ => {1264                 let w = format!("unknown field `{k}`");1265                 if unknowns.len() < 16 && !unknowns.contains(&w) {1266                     unknowns.push(w);1267                 }1268             }1269         }1270     }1271     let issue = Issue {1272         id: id?,1273         title: title?,1274         state: state?,1275         labels,1276         assignees,1277         milestone,1278         created: created?,1279         updated,1280     };1281     issue.validate(file).ok()?;1282     let schema_note = match schema {1283         Some(s) if s != SCHEMA_VERSION => {1284             let mark: String = s.chars().take(32).collect();1285             Some(format!("{file}: older schema `{mark}`, continuing"))1286         }1287         _ => None,1288     };1289     Some(Parsed { value: issue, warnings: unknowns, schema_note, body: body.to_string() })1290 }1291 1292 /// List-hot-path issue parse: fast string-ops extract, strict fallback.1293 /// Same values, same warnings, same errors as `parse_entity::<Issue>`.1294 /// Guards run first so hostile input fails identically either way.1295 pub fn parse_issue_card(text: &str, file: &str) -> Result<Parsed<Issue>, ForgeError> {1296     if let Ok((front, body)) = split_frontmatter(text, file) {1297         if guard_indent(&front, file).is_ok() && guard_flow(&front, file).is_ok() {1298             if let Some(p) = fast_issue(&front, &body, file) {1299                 return Ok(p);1300             }1301         }1302     }1303     parse_entity(text, file)1304 }1305 1306 /// K-way merge of `(created, id)`-sorted runs into `(id, rank)` pairs.1307 /// Pure + deterministic: same multiset in -> same pairs out.1308 fn merge_ranked(runs: Vec<Vec<(String, String)>>) -> Vec<(String, u64)> {1309     let total: usize = runs.iter().map(Vec::len).sum();1310     let mut pos = vec![0usize; runs.len()];1311     let mut pairs = Vec::with_capacity(total);1312     for rank in 1..=total as u64 {1313         let mut best: Option<usize> = None;1314         for (r, run) in runs.iter().enumerate() {1315             if pos[r] < run.len()1316                 && best1317                     .map(|b| {1318                         (run[pos[r]].1.as_str(), run[pos[r]].0.as_str())1319                             < (runs[b][pos[b]].1.as_str(), runs[b][pos[b]].0.as_str())1320                     })1321                     .unwrap_or(true)1322             {1323                 best = Some(r);1324             }1325         }1326         let b = best.unwrap_or(0);1327         pairs.push((runs[b][pos[b]].0.clone(), rank));1328         pos[b] += 1;1329     }1330     pairs1331 }1332 1333 /// Kanban: group ids by status; all four columns always present, ids sorted.1334 pub fn kanban(tasks: &[(String, String)]) -> BTreeMap<String, Vec<String>> {1335     let mut cols: BTreeMap<String, Vec<String>> = ["todo", "doing", "blocked", "done"]1336         .iter()1337         .map(|c| (c.to_string(), Vec::new()))1338         .collect();1339     for (id, status) in tasks {1340         if let Some(col) = cols.get_mut(status) {1341             col.push(id.clone());1342         }1343     }1344     for col in cols.values_mut() {1345         col.sort();1346     }1347     cols1348 }1349 1350 #[derive(Debug, Clone, PartialEq, Eq, Serialize)]1351 pub struct WikiEntry {1352     pub path: String,1353     pub title: String,1354 }1355 1356 /// Wiki index from tree + first H1 (never stored).1357 pub fn wiki_index(files: &[(String, String)]) -> Vec<WikiEntry> {1358     let mut out: Vec<WikiEntry> = files1359         .iter()1360         .map(|(path, content)| {1361             let title = content1362                 .lines()1363                 .find_map(|l| {1364                     let t = l.trim();1365                     t.strip_prefix("# ").map(|s| s.trim().to_string()).filter(|s| !s.is_empty())1366                 })1367                 .unwrap_or_else(|| {1368                     Path::new(path)1369                         .file_stem()1370                         .map(|s| s.to_string_lossy().into_owned())1371                         .unwrap_or_else(|| path.clone())1372                 });1373             WikiEntry { path: path.clone(), title }1374         })1375         .collect();1376     out.sort_by(|a, b| a.path.cmp(&b.path));1377     out1378 }1379 1380 // ---- gh-shaped JSON models (compact, fixed field order) ----1381 1382 #[derive(Debug, Clone, Serialize)]1383 pub struct IssueJson {1384     pub number: u64,1385     pub title: String,1386     pub state: String,1387     pub labels: Vec<String>,1388     pub assignees: Vec<String>,1389     pub milestone: Option<String>,1390     pub body: String,1391     #[serde(rename = "createdAt")]1392     pub created_at: String,1393     pub comments: u64,1394 }1395 1396 impl IssueJson {1397     pub fn new(number: u64, issue: &Issue, body: &str, comments: u64) -> Self {1398         Self {1399             number,1400             title: issue.title.clone(),1401             state: issue.state.clone(),1402             labels: issue.labels.clone(),1403             assignees: issue.assignees.clone(),1404             milestone: issue.milestone.clone(),1405             body: body.to_string(),1406             created_at: issue.created.clone(),1407             comments,1408         }1409     }1410 }1411 1412 #[derive(Debug, Clone, Serialize)]1413 pub struct PrJson {1414     pub number: u64,1415     pub title: String,1416     pub state: String,1417     pub base: String,1418     pub head: String,1419     pub draft: bool,1420     #[serde(rename = "databaseId")]1421     pub database_id: String,1422     pub status: String,1423     pub conclusion: Option<String>,1424     #[serde(rename = "createdAt")]1425     pub created_at: String,1426 }1427 1428 impl PrJson {1429     pub fn new(number: u64, pr: &Pr) -> Self {1430         let (status, conclusion) = match pr.ci.as_deref() {1431             Some("pass") => ("completed".to_string(), Some("success".to_string())),1432             Some("fail") => ("completed".to_string(), Some("failure".to_string())),1433             Some("cancelled") => ("completed".to_string(), Some("cancelled".to_string())),1434             Some(_) => ("in_progress".to_string(), None),1435             None => ("queued".to_string(), None),1436         };1437         Self {1438             number,1439             title: pr.title.clone(),1440             state: pr.state.clone(),1441             base: pr.base.clone(),1442             head: pr.head.clone(),1443             draft: pr.draft,1444             database_id: pr.id.clone(),1445             status,1446             conclusion,1447             created_at: pr.created.clone(),1448         }1449     }1450 }1451 1452 /// Compact one-line JSON.1453 pub fn to_json_line<T: Serialize>(v: &T) -> String {1454     serde_json::to_string(v).unwrap_or_else(|_| "{}".to_string())1455 }1456 1457 // ---- tests ----1458 1459 #[cfg(test)]1460 mod tests;