use crate::canonical::compute_id;
use crate::capture::{harvested_test_check, Decision};
use crate::store::Store;
use crate::tick::{Ground, Tick};
use std::path::Path;
#[derive(Debug, Clone, PartialEq)]
pub struct MigrationRecord {
pub source_key: String,
pub decision: String,
pub observe: String,
pub blame: Option<String>,
pub grounds: Vec<Ground>,
}
fn first_round_or_issue_token(text: &str) -> Option<String> {
text.split(|c: char| !(c.is_ascii_alphanumeric() || c == '#'))
.find(|tok| {
let rest = tok
.strip_prefix('#')
.or_else(|| tok.strip_prefix('R'))
.or_else(|| tok.strip_prefix('r'));
matches!(rest, Some(d) if !d.is_empty() && d.bytes().all(|b| b.is_ascii_digit()))
})
.map(|t| t.to_string())
}
fn structured_rejected_roads(block: &str) -> Vec<Ground> {
let mut out = Vec::new();
for line in block.lines() {
let l = line.trim_start_matches(['-', '*', ' ', '\t']).trim();
let body = l
.strip_prefix("rejected:")
.or_else(|| l.strip_prefix("rejected "))
.or_else(|| l.strip_prefix("reject:"))
.or_else(|| l.strip_prefix("reject "));
if let Some(rest) = body {
if let Some((opt, why)) = rest.split_once(':') {
let (opt, why) = (opt.trim(), why.trim());
if !opt.is_empty() && !why.is_empty() {
out.push(Ground {
claim: why.to_string(),
supports: format!("rejected:{opt}"),
check: None,
});
}
}
}
}
out
}
fn flush_record(header: &Option<(String, String)>, body: &str, out: &mut Vec<MigrationRecord>) {
if let Some((key, decision)) = header {
out.push(MigrationRecord {
source_key: key.clone(),
decision: decision.clone(),
observe: key.clone(),
blame: None,
grounds: structured_rejected_roads(body),
});
}
}
fn store_key(raw: &serde_json::Value) -> Option<String> {
raw.get("round_id")
.and_then(|x| x.as_str())
.map(|s| s.to_string())
.or_else(|| {
raw.get("observe")
.and_then(|x| x.as_str())
.and_then(first_round_or_issue_token)
})
}
pub fn extract_gitlog(text: &str) -> Vec<MigrationRecord> {
let mut records = Vec::new();
let mut header: Option<(String, String)> = None; let mut body = String::new();
for line in text.lines() {
if let Some(rest) = line.strip_prefix("## ") {
flush_record(&header, &body, &mut records);
body.clear();
let key = first_round_or_issue_token(rest);
let decision = match key.as_deref() {
Some(k) => rest
.split_once(k)
.map(|x| x.1)
.unwrap_or(rest)
.trim_start_matches([' ', '—', '-', ':'])
.trim()
.to_string(),
None => rest.trim().to_string(),
};
header = key.map(|k| {
(
k,
if decision.is_empty() {
rest.trim().into()
} else {
decision
},
)
});
} else {
body.push_str(line);
body.push('\n');
}
}
flush_record(&header, &body, &mut records);
records
}
fn read_resolved_flag_blocks(text: &str) -> Vec<MigrationRecord> {
let mut records = Vec::new();
let mut header: Option<(String, String)> = None;
let mut body = String::new();
for line in text.lines() {
let stripped = line
.trim_start_matches(['#', ' '])
.strip_prefix("RESOLVED")
.or_else(|| line.trim_start_matches(['#', ' ']).strip_prefix("FLAG"));
if let Some(rest) = stripped {
flush_record(&header, &body, &mut records);
body.clear();
let rest = rest.trim();
if let Some((key, decision)) = rest.split_once(':') {
let key = key.trim();
let source_key = first_round_or_issue_token(key).unwrap_or_else(|| key.to_string());
header = Some((source_key, decision.trim().to_string()));
} else {
let source_key =
first_round_or_issue_token(rest).unwrap_or_else(|| rest.to_string());
header = Some((source_key, rest.to_string()));
}
} else {
body.push_str(line);
body.push('\n');
}
}
flush_record(&header, &body, &mut records);
records
}
pub fn extract_to_human(text: &str) -> Vec<MigrationRecord> {
read_resolved_flag_blocks(text)
}
pub fn extract_escalation(text: &str) -> Vec<MigrationRecord> {
read_resolved_flag_blocks(text)
}
pub fn extract_decisions_immutable(text: &str) -> Vec<MigrationRecord> {
let mut records = Vec::new();
let mut header: Option<(String, String)> = None;
let mut body = String::new();
for line in text.lines() {
if let Some(rest) = line.strip_prefix("## ") {
let rest = rest.trim();
let digits: String = rest
.trim_start_matches('§')
.chars()
.take_while(|c| c.is_ascii_digit())
.collect();
if !digits.is_empty() {
flush_record(&header, &body, &mut records);
body.clear();
let decision = rest
.trim_start_matches('§')
.trim_start_matches(|c: char| c.is_ascii_digit())
.trim_start_matches(['.', ' ', ':', '—', '-'])
.trim()
.to_string();
header = Some((format!("§{digits}"), decision));
continue;
}
}
body.push_str(line);
body.push('\n');
}
flush_record(&header, &body, &mut records);
records
}
#[derive(Debug, Default, PartialEq)]
pub struct BackfillSummary {
pub imported: usize,
pub skipped: usize,
pub relinked: usize,
pub source_only_gaps: usize,
}
fn store_key_index(
store: &Store,
) -> Result<std::collections::HashMap<String, (String, String)>, String> {
let files = store
.read_all()
.map_err(|e| format!("reading store: {e}"))?;
let mut idx = std::collections::HashMap::new();
for (name, raw) in &files {
let key = store_key(raw);
let parent = raw
.get("parent_id")
.and_then(|x| x.as_str())
.unwrap_or("")
.to_string();
if let Some(k) = key {
idx.insert(k, (name.clone(), parent));
}
}
Ok(idx)
}
pub fn backfill(
repo: &Path,
mut records: Vec<MigrationRecord>,
blame_fallback: Option<&str>,
dry_run: bool,
) -> Result<BackfillSummary, String> {
records.sort_by(|a, b| a.source_key.cmp(&b.source_key));
let store = Store::at(repo);
if !store.exists() {
return Err("no .evolving/ store here — run `ev init` first".into());
}
let existing = store_key_index(&store)?;
let head = store
.read_head()
.map_err(|e| format!("reading HEAD: {e}"))?;
let first_is_stored_genesis = records
.first()
.and_then(|r| existing.get(&r.source_key))
.map(|(_, p)| p.is_empty())
.unwrap_or(false);
let mut prospective_parent = if first_is_stored_genesis {
String::new()
} else {
head
};
let mut summary = BackfillSummary::default();
for r in records {
if let Some((existing_id, existing_parent)) = existing.get(&r.source_key) {
if *existing_parent != prospective_parent {
summary.relinked += 1;
}
prospective_parent = existing_id.clone();
summary.skipped += 1;
continue;
}
let blame = match r.blame.as_deref().or(blame_fallback) {
Some(b) if !b.trim().is_empty() => b.trim().to_string(),
_ => {
summary.source_only_gaps += 1;
continue;
}
};
if dry_run {
let probe = Tick {
id: String::new(),
parent_id: prospective_parent.clone(),
observe: r.observe.clone(),
decision: r.decision.clone(),
grounds: r.grounds.clone(),
status: "live".into(),
held_since: String::new(),
blame: blame.clone(),
authority: None,
jurisdiction: None,
round_id: Some(r.source_key.clone()),
};
prospective_parent = compute_id(&probe);
summary.imported += 1;
continue;
}
let written = crate::capture::append(
repo,
Decision {
observe: r.observe,
decision: r.decision,
grounds: r.grounds,
blame,
authority: None,
jurisdiction: None,
round_id: Some(r.source_key),
},
)?;
prospective_parent = written.id;
summary.imported += 1;
}
Ok(summary)
}
#[derive(Debug, Default, PartialEq)]
pub struct ReconcileReport {
pub in_both: usize,
pub source_only: usize,
pub store_only: usize,
pub un_keyable: usize,
}
pub fn reconcile(
repo: &Path,
source_records: &[MigrationRecord],
) -> Result<ReconcileReport, String> {
let store = Store::at(repo);
if !store.exists() {
return Err("no .evolving/ store here — run `ev init` first".into());
}
let files = store
.read_all()
.map_err(|e| format!("reading store: {e}"))?;
let mut store_keys: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut un_keyable = 0usize;
for (_name, raw) in &files {
let key = store_key(raw);
match key {
Some(k) => {
store_keys.insert(k);
}
None => un_keyable += 1,
}
}
let source_keys: std::collections::HashSet<String> = source_records
.iter()
.map(|r| r.source_key.clone())
.collect();
let mut report = ReconcileReport {
un_keyable,
..Default::default()
};
for k in &source_keys {
if store_keys.contains(k) {
report.in_both += 1;
} else {
report.source_only += 1;
}
}
report.store_only = store_keys
.iter()
.filter(|k| !source_keys.contains(*k))
.count();
Ok(report)
}
pub fn bind_check(
selector: String,
verified_at_sha: String,
platforms: Vec<String>,
triggered_by: Vec<String>,
surfaces: Vec<String>,
) -> Result<crate::tick::Check, String> {
harvested_test_check(selector, verified_at_sha, platforms, triggered_by, surfaces)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extract_gitlog_should_yield_one_record_per_round_header_when_given_a_chat_room_log() {
let text = "\
## R2289 QA — restore-safety counter DB-backed
- rejected: Redis: would add a new infra dependency
## R2290 Dev — ship the cross-pod drain
some prose nobody parses for grounds
";
let recs = extract_gitlog(text);
assert_eq!(recs.len(), 2);
assert_eq!(recs[0].source_key, "R2289");
assert_eq!(recs[0].decision, "QA — restore-safety counter DB-backed");
assert_eq!(recs[0].grounds.len(), 1);
assert_eq!(recs[0].grounds[0].supports, "rejected:Redis");
assert_eq!(recs[1].source_key, "R2290");
assert!(recs[0].observe.contains("R2289"));
}
#[test]
fn extract_to_human_should_read_a_resolved_block_when_given_the_authority_substrate() {
let text = "\
### RESOLVED R555: restore-safety counter DB-backed; reject Redis
- rejected: Redis: a new infra dependency
### FLAG R600: multi-pod relax policy still open
";
let recs = extract_to_human(text);
assert_eq!(recs.len(), 2);
assert_eq!(recs[0].source_key, "R555");
assert_eq!(
recs[0].decision,
"restore-safety counter DB-backed; reject Redis"
);
assert_eq!(recs[0].grounds.len(), 1);
assert_eq!(recs[1].source_key, "R600");
}
#[test]
fn extract_escalation_should_reuse_the_resolved_flag_reader_when_given_an_escalation_log() {
let text = "### FLAG #1194: re-milestoned without sign-off\n";
let recs = extract_escalation(text);
assert_eq!(recs.len(), 1);
assert_eq!(recs[0].source_key, "#1194");
assert_eq!(recs[0].decision, "re-milestoned without sign-off");
}
#[test]
fn extract_decisions_immutable_should_split_on_numbered_sections_when_given_a_doc() {
let text = "\
## 1. freeze the retrieval schema for v2
- rejected: pgvector: would lock our schema
## 2. restore-safety counter DB-backed
";
let recs = extract_decisions_immutable(text);
assert_eq!(recs.len(), 2);
assert_eq!(recs[0].source_key, "§1");
assert_eq!(recs[0].decision, "freeze the retrieval schema for v2");
assert_eq!(recs[0].grounds.len(), 1);
assert_eq!(recs[1].source_key, "§2");
}
#[test]
fn grounds_are_never_synthesized_when_a_block_has_no_structured_rejected_road() {
let text = "\
## R2289 we considered Redis but rejected it because it adds infra
this paragraph explains at length why redis was rejected, in prose
";
let recs = extract_gitlog(text);
assert_eq!(recs.len(), 1);
assert!(
recs[0].grounds.is_empty(),
"a prose reason must NEVER become a ground (no synthesis)"
);
}
}