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;