use once_cell::sync::Lazy;
use regex::Regex;
use serde_json::Value;
use super::adapter::{FormatAdapter, ParseError};
use super::newrecruit_text::infer_battle_size_raw;
use super::types::{ParsedRoster, ParsedUnit, ParsedWargear, RosterFormat};
const CHARACTERS_SECTION: &str = "CHARACTERS";
const ALLIED_SECTION: &str = "ALLIED UNITS";
const CHARACTER_SUFFIX: &str = " Character";
const WARLORD_MARKER: &str = "Warlord";
static RE_PTS_LINE: Lazy<Regex> =
Lazy::new(|| Regex::new(r"(?i)^(.+?)\s*\(\s*([\d,]+)\s*(?:pts?|points?)\s*\).*$").unwrap());
static RE_MD_SECTION: Lazy<Regex> = Lazy::new(|| Regex::new(r"^#{1,6}\s*(.+?)\s*$").unwrap());
static RE_CAPS_SECTION: Lazy<Regex> = Lazy::new(|| Regex::new(r"^[A-Z][A-Z0-9 \-/&]+$").unwrap());
static RE_COLON_SECTION: Lazy<Regex> =
Lazy::new(|| Regex::new(r"^([A-Za-z][\w /&-]*):\s*$").unwrap());
static RE_BULLET: Lazy<Regex> = Lazy::new(|| Regex::new(r"^([\t ]*)[•◦]\s*(.+?)\s*$").unwrap());
static RE_NX_PREFIX: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?i)^(\d+)x\s+(.+)$").unwrap());
static RE_ENHANCEMENT_ANNOT: Lazy<Regex> =
Lazy::new(|| Regex::new(r"(?i)^(.+?)\s*\(\+\s*(\d+)\s*pts?\s*\)\s*$").unwrap());
static RE_ENHANCEMENT_LABEL: Lazy<Regex> =
Lazy::new(|| Regex::new(r"(?i)^(?:e|enh|enhancement|enhancements)\s*:\s*(.+)$").unwrap());
static RE_WITH_LINE: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?m)^[\t ]*\d+\s+with\b").unwrap());
static RE_BULLET_ANYWHERE: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?m)^[\t ]*[•◦]").unwrap());
const BATTLE_SIZE_NAMES: &[&str] = &["combat patrol", "incursion", "strike force", "onslaught"];
fn parse_pts(raw: &str) -> Option<u64> {
raw.replace(',', "").parse().ok()
}
fn headerless_text(decoded: &Value) -> Option<&str> {
let s = decoded.as_str()?;
if !RE_BULLET_ANYWHERE.is_match(s) {
return None; }
if s.contains("+ FACTION KEYWORD:") {
return None; }
if RE_WITH_LINE.is_match(s) {
return None; }
if s.lines().any(|l| {
let t = l.trim();
t.starts_with("# ++") && t.contains("Army Roster")
}) {
return None;
}
s.lines()
.any(|l| RE_PTS_LINE.is_match(l.trim()))
.then_some(s)
}
#[derive(Clone)]
struct Bullet {
indent: usize,
count: Option<u64>,
name: String,
colon_wargear: Option<String>,
is_annotation: bool,
enhancement: Option<(String, Option<u64>)>,
}
struct UnitAcc {
raw_name: String,
displayed_pts: Option<u64>,
is_character_section: bool,
bullets: Vec<Bullet>,
}
fn parse_bullet(indent: usize, body: &str) -> Bullet {
if let Some(c) = RE_ENHANCEMENT_LABEL.captures(body) {
return Bullet {
indent,
count: None,
name: String::new(),
colon_wargear: None,
is_annotation: true,
enhancement: Some((c[1].trim().to_string(), None)),
};
}
let (count, rest) = match RE_NX_PREFIX.captures(body) {
Some(nx) => (nx[1].parse::<u64>().ok(), nx[2].trim().to_string()),
None => (None, body.trim().to_string()),
};
if let Some(c) = RE_ENHANCEMENT_ANNOT.captures(&rest) {
return Bullet {
indent,
count,
name: rest.clone(),
colon_wargear: None,
is_annotation: true,
enhancement: Some((c[1].trim().to_string(), c[2].parse().ok())),
};
}
if let Some(idx) = rest.find(':') {
let (model, wargear) = rest.split_at(idx);
let wargear = wargear[1..].trim();
return Bullet {
indent,
count,
name: model.trim().to_string(),
colon_wargear: (!wargear.is_empty()).then(|| wargear.to_string()),
is_annotation: false,
enhancement: None,
};
}
Bullet {
indent,
count,
name: rest,
colon_wargear: None,
is_annotation: count.is_none(),
enhancement: None,
}
}
fn finish_unit(acc: UnitAcc) -> ParsedUnit {
let top_indent = acc.bullets.iter().map(|b| b.indent).min().unwrap_or(0);
let mut wargear: Vec<ParsedWargear> = Vec::new();
let mut add_wargear = |raw_name: &str, count: u64| {
let raw_name = raw_name.trim();
if raw_name.is_empty() {
return;
}
if let Some(w) = wargear.iter_mut().find(|w| w.raw_name == raw_name) {
w.count += count;
} else {
wargear.push(ParsedWargear {
raw_name: raw_name.to_string(),
count,
});
}
};
let mut model_count: u64 = 0;
let mut is_warlord = false;
let mut is_character = acc.is_character_section;
let mut enhancement_raw_name: Option<String> = None;
let mut enhancement_points: Option<u64> = None;
for (i, b) in acc.bullets.iter().enumerate() {
if b.indent > top_indent {
add_wargear(&b.name, b.count.unwrap_or(1));
continue;
}
if let Some((name, pts)) = &b.enhancement {
if enhancement_raw_name.is_none() {
enhancement_raw_name = Some(name.clone());
enhancement_points = *pts;
}
continue;
}
if let Some(csv) = &b.colon_wargear {
let n = b.count.unwrap_or(1);
model_count += n;
for item in csv.split(',').map(str::trim).filter(|s| !s.is_empty()) {
add_wargear(item, n);
}
continue;
}
let next_is_child = acc
.bullets
.get(i + 1)
.map(|n| n.indent > top_indent)
.unwrap_or(false);
if next_is_child {
model_count += b.count.unwrap_or(1);
continue;
}
if b.is_annotation {
let mut leftover: Vec<&str> = Vec::new();
for token in b.name.split(',').map(str::trim).filter(|t| !t.is_empty()) {
if token == WARLORD_MARKER {
is_warlord = true;
} else if token.ends_with(CHARACTER_SUFFIX) {
is_character = true;
} else {
leftover.push(token);
}
}
for token in leftover {
add_wargear(token, 1);
}
continue;
}
add_wargear(&b.name, b.count.unwrap_or(1));
}
if model_count == 0 {
model_count = 1;
}
let points = match (acc.displayed_pts, enhancement_points) {
(Some(displayed), Some(enh)) => Some(displayed.saturating_sub(enh)),
(displayed, _) => displayed,
};
ParsedUnit {
raw_name: acc.raw_name,
is_character,
model_count,
points,
is_warlord,
enhancement_raw_name,
enhancement_points,
wargear,
}
}
fn is_battle_size(name: &str) -> bool {
let lower = name.trim().to_ascii_lowercase();
BATTLE_SIZE_NAMES.iter().any(|b| lower == *b)
}
pub struct GwHeaderlessAdapter;
impl FormatAdapter for GwHeaderlessAdapter {
fn format(&self) -> RosterFormat {
RosterFormat::Gw
}
fn detect(&self, decoded: &Value) -> bool {
headerless_text(decoded).is_some()
}
fn parse(&self, decoded: &Value) -> Result<ParsedRoster, ParseError> {
let text = headerless_text(decoded)
.ok_or_else(|| ParseError("gw-headerless: not a headerless plain-text list".into()))?;
let mut name = String::from("Imported roster");
let mut declared_limit: Option<u64> = None;
let mut battle_size_raw: Option<String> = None;
let mut units: Vec<ParsedUnit> = Vec::new();
let mut current: Option<UnitAcc> = None;
let mut section: Option<String> = None;
let mut allied = 0u64;
let mut consumed_title = false;
let flush = |current: &mut Option<UnitAcc>, units: &mut Vec<ParsedUnit>| {
if let Some(u) = current.take() {
units.push(finish_unit(u));
}
};
for raw in text.split('\n') {
let raw = raw.trim_end_matches('\r');
let line = raw.trim();
if line.is_empty() {
continue;
}
if let Some(c) = RE_BULLET.captures(raw) {
if let Some(unit) = current.as_mut() {
unit.bullets.push(parse_bullet(c[1].len(), &c[2]));
}
continue;
}
if line.starts_with("Exported with") {
continue;
}
if let Some(c) = RE_MD_SECTION.captures(line) {
flush(&mut current, &mut units);
let heading = RE_PTS_LINE
.captures(&c[1])
.map(|p| p[1].trim().to_string())
.unwrap_or_else(|| c[1].trim().to_string());
section = Some(heading);
continue;
}
if let Some(c) = RE_PTS_LINE.captures(line) {
let header_name = c[1].trim().to_string();
let pts = parse_pts(&c[2]);
if !consumed_title && current.is_none() && units.is_empty() {
consumed_title = true;
name = header_name;
declared_limit = pts;
continue;
}
if is_battle_size(&header_name) {
battle_size_raw = Some(line.to_string());
if declared_limit.is_none() {
declared_limit = pts;
}
continue;
}
flush(&mut current, &mut units);
let in_chars = section
.as_deref()
.map(|s| s.eq_ignore_ascii_case(CHARACTERS_SECTION))
.unwrap_or(false);
if section.as_deref() == Some(ALLIED_SECTION) {
allied += 1;
}
current = Some(UnitAcc {
raw_name: header_name,
displayed_pts: pts,
is_character_section: in_chars,
bullets: Vec::new(),
});
continue;
}
if RE_CAPS_SECTION.is_match(line) || RE_COLON_SECTION.is_match(line) {
flush(&mut current, &mut units);
let heading = line.trim_end_matches(':').trim().to_string();
section = Some(heading);
continue;
}
if !consumed_title && current.is_none() && units.is_empty() {
consumed_title = true;
name = line.to_string();
}
}
flush(&mut current, &mut units);
let total_computed: u64 = units
.iter()
.map(|u| u.points.unwrap_or(0) + u.enhancement_points.unwrap_or(0))
.sum();
if battle_size_raw.is_none() {
battle_size_raw = infer_battle_size_raw(declared_limit);
}
Ok(ParsedRoster {
name,
generated_by: None,
faction_raw_name: None,
detachment_raw_name: None,
battle_size_raw,
declared_limit,
total_reported: None,
total_computed,
units,
multi_force: allied > 0,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
const GW_APP: &str = "Ding dong (1995 Points)
World Eaters
Berzerker Warband
Strike Force (2,000 Points)
CHARACTERS
Khârn the Betrayer (100 Points)
• Warlord
• 1x Gorechild
• 1x Plasma pistol
Master of Executions (95 Points)
• 1x Axe of dismemberment
• Enhancements: Berzerker Glaive
BATTLELINE
Khorne Berzerkers (180 Points)
• 1x Khorne Berzerker Champion
◦ 1x Chainblade
• 9x Khorne Berzerker
◦ 8x Bolt pistol
◦ 7x Chainblade
Exported with App Version: v1.48.0 (1), Data Version: v750
";
const MD_FIXTURE: &str = "Test Army - Space Marines - Gladius Task Force (300 pts)
## Battleline (200 pts)
Intercessor Squad (200 pts)
• 4x Intercessor: Bolt rifle
• Intercessor Sergeant: Bolt rifle
";
const NR_TEXT: &str = "all gas no breaks - Chaos Daemons - Daemonic Incursion (1995 Points)
Character:
Bloodmaster (65 pts)
• Blade of blood
Battleline:
Bloodletters (110 pts)
• Bloodreaper
• Hellblade
• Instrument of Chaos
• 9x Bloodletter
• 9x Hellblade
";
#[test]
fn detects_only_headerless_bullet_text() {
assert!(GwHeaderlessAdapter.detect(&json!(GW_APP)));
assert!(GwHeaderlessAdapter.detect(&json!(MD_FIXTURE)));
assert!(GwHeaderlessAdapter.detect(&json!(NR_TEXT)));
assert!(!GwHeaderlessAdapter.detect(&json!("+ FACTION KEYWORD: X\n\nU (1 pts)\n• 1x W\n")));
assert!(!GwHeaderlessAdapter.detect(&json!("U (100 pts)\n")));
assert!(!GwHeaderlessAdapter.detect(&json!({"roster": {}})));
}
#[test]
fn parses_gw_app_export() {
let p = GwHeaderlessAdapter.parse(&json!(GW_APP)).unwrap();
assert_eq!(p.name, "Ding dong");
assert_eq!(p.units.len(), 3);
let kharn = &p.units[0];
assert_eq!(kharn.raw_name, "Khârn the Betrayer");
assert!(kharn.is_warlord);
assert!(kharn.is_character); assert_eq!(kharn.model_count, 1);
assert!(kharn.wargear.iter().any(|w| w.raw_name == "Gorechild"));
let moe = &p.units[1];
assert_eq!(
moe.enhancement_raw_name.as_deref(),
Some("Berzerker Glaive")
);
let zerks = &p.units[2];
assert_eq!(zerks.model_count, 10); let bolt = zerks
.wargear
.iter()
.find(|w| w.raw_name == "Bolt pistol")
.unwrap();
assert_eq!(bolt.count, 8);
}
#[test]
fn parses_md_fixture_model_count() {
let p = GwHeaderlessAdapter.parse(&json!(MD_FIXTURE)).unwrap();
assert_eq!(p.units.len(), 1);
let squad = &p.units[0];
assert_eq!(squad.raw_name, "Intercessor Squad");
assert_eq!(squad.model_count, 5); let bolt = squad
.wargear
.iter()
.find(|w| w.raw_name == "Bolt rifle")
.unwrap();
assert_eq!(bolt.count, 5);
}
#[test]
fn parses_nr_text_dialect() {
let p = GwHeaderlessAdapter.parse(&json!(NR_TEXT)).unwrap();
assert_eq!(p.units.len(), 2);
let bloodmaster = &p.units[0];
assert_eq!(bloodmaster.model_count, 1);
assert!(bloodmaster
.wargear
.iter()
.any(|w| w.raw_name == "Blade of blood"));
let letters = &p.units[1];
assert_eq!(letters.model_count, 10); assert!(letters.wargear.iter().any(|w| w.raw_name == "Hellblade"));
}
}