KeiSeiKit-1.0/_primitives/_rust/kei-decision/src/parser.rs
Parfii-bot a4e667de10 KeiSeiKit-public — clean state
Single-commit clean baseline after security scrub of niche-tells,
project codenames, internal jargon, and contributor-email leaks.

Contents:
- 100 Rust crates (_primitives/_rust/)
- 37 agent manifests (_manifests/) + generated specs (_generated/)
- 67 user-invocable skills (skills/)
- 33 hooks (hooks/)
- Composition blocks (_blocks/)
- Documentation (docs/, README.md)
- TS adapter packages (_ts_packages/)
- Assembler (_assembler/)
- Roles (_roles/)
- Templates (_templates/)
- Forgejo CI (.forgejo/)

Author: Denis Parfionovich <info@greendragon.info>

License: see LICENSE.
2026-05-01 12:09:03 +08:00

192 lines
6.6 KiB
Rust

//! Markdown action-table parser.
//!
//! Looks for a section heading whose text matches one of:
//! - "Actionable plan"
//! - "Backlog"
//! - "Action items"
//! and extracts the markdown table that follows. Each table row becomes one
//! [`RawAction`]. Effort and severity are inferred from the row cells; deps
//! are parsed from a free-text "deps:" hint inside the action cell when
//! present.
//!
//! No md crate — table format is well-defined: `| col | col | ... |`.
use anyhow::Result;
use regex::Regex;
use serde::{Deserialize, Serialize};
use std::path::Path;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RawAction {
pub id: String,
pub title: String,
pub severity: String,
pub effort: String,
pub deps: Vec<String>,
pub source_line: usize,
}
mod thiserror_lite {
/// Local error enum — avoids pulling thiserror as new dep (RULE: no new deps).
#[derive(Debug)]
pub enum ParseError {
FileNotFound(String),
NoActionsFound,
Io(String),
}
impl std::fmt::Display for ParseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::FileNotFound(p) => write!(f, "file not found: {p}"),
Self::NoActionsFound => write!(f, "no Actionable plan / Backlog / Action items table found"),
Self::Io(s) => write!(f, "io error: {s}"),
}
}
}
impl std::error::Error for ParseError {}
}
pub use thiserror_lite::ParseError;
/// Read MASTER-REPORT.md, locate first action-style section, return rows.
pub fn parse_master_report(path: &Path) -> Result<Vec<RawAction>, ParseError> {
if !path.exists() {
return Err(ParseError::FileNotFound(path.display().to_string()));
}
let body = std::fs::read_to_string(path).map_err(|e| ParseError::Io(e.to_string()))?;
let lines: Vec<&str> = body.lines().collect();
let table = find_action_table(&lines).ok_or(ParseError::NoActionsFound)?;
let actions = extract_rows(&lines, table.start_line, table.column_indices);
if actions.is_empty() {
return Err(ParseError::NoActionsFound);
}
Ok(actions)
}
struct TableLocation {
start_line: usize,
column_indices: ColumnMap,
}
#[derive(Clone, Copy, Debug)]
struct ColumnMap {
id: Option<usize>,
action: usize,
effort: Option<usize>,
risk: Option<usize>,
}
/// Walk the doc, find a heading line that names an action section, then the
/// next markdown table whose header includes "Action".
fn find_action_table(lines: &[&str]) -> Option<TableLocation> {
let heading_re = heading_regex();
let mut in_section = false;
for (i, line) in lines.iter().enumerate() {
if heading_re.is_match(line) {
in_section = true;
continue;
}
if !in_section {
continue;
}
if line.trim_start().starts_with('#') {
in_section = false; // moved into a different section
continue;
}
if !line.trim_start().starts_with('|') {
continue;
}
if let Some(map) = parse_header_row(line) {
// Header found — the body rows start two lines below (after the
// separator row). Caller skips separator in extract_rows.
return Some(TableLocation { start_line: i, column_indices: map });
}
}
None
}
fn heading_regex() -> Regex {
// Matches `## Actionable plan`, `### Backlog`, `## Action items` (any depth).
Regex::new(r"(?i)^#{1,6}\s+(actionable\s+plan|backlog|action\s+items)\b").unwrap()
}
/// Inspect the table-header pipe row and locate the columns we care about.
fn parse_header_row(line: &str) -> Option<ColumnMap> {
let cells = split_pipes(line);
if cells.is_empty() {
return None;
}
let lower: Vec<String> = cells.iter().map(|c| c.to_lowercase()).collect();
let action_idx = lower.iter().position(|c| c.contains("action"))?;
let id_idx = lower.iter().position(|c| c == "#" || c.contains("id"));
let effort_idx = lower.iter().position(|c| c.contains("effort") || c.contains("hours") || c.contains("time"));
let risk_idx = lower.iter().position(|c| c.contains("risk") || c.contains("severity") || c.contains("priority"));
Some(ColumnMap { id: id_idx, action: action_idx, effort: effort_idx, risk: risk_idx })
}
/// Walk the body rows below the separator, build [`RawAction`] per row.
fn extract_rows(lines: &[&str], header_line: usize, cols: ColumnMap) -> Vec<RawAction> {
let mut out = Vec::new();
// Skip header and the divider line `|---|---|...`
for (offset, line) in lines.iter().enumerate().skip(header_line + 1) {
if offset == header_line + 1 && is_divider(line) {
continue;
}
if !line.trim_start().starts_with('|') {
break;
}
if is_divider(line) {
continue;
}
if let Some(act) = build_raw_action(line, cols, offset + 1, out.len() + 1) {
out.push(act);
}
}
out
}
fn is_divider(line: &str) -> bool {
let trimmed = line.trim();
trimmed.starts_with('|')
&& trimmed.chars().all(|c| matches!(c, '|' | '-' | ':' | ' '))
}
fn build_raw_action(line: &str, cols: ColumnMap, source_line: usize, fallback_n: usize) -> Option<RawAction> {
let cells = split_pipes(line);
let title = cells.get(cols.action)?.trim().to_string();
if title.is_empty() {
return None;
}
let id = cols.id
.and_then(|i| cells.get(i))
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| fallback_n.to_string());
let effort = cols.effort.and_then(|i| cells.get(i)).map(|s| s.trim().to_string()).unwrap_or_default();
let severity = cols.risk.and_then(|i| cells.get(i)).map(|s| s.trim().to_string()).unwrap_or_default();
let deps = parse_deps_hint(&title);
Some(RawAction { id, title, severity, effort, deps, source_line })
}
/// Split a pipe-row into its inner cells (drop empty leading/trailing).
fn split_pipes(line: &str) -> Vec<String> {
line.trim()
.trim_start_matches('|')
.trim_end_matches('|')
.split('|')
.map(|s| s.to_string())
.collect()
}
/// `deps: 1, 2` or `(after #3)` → vec of id strings.
fn parse_deps_hint(text: &str) -> Vec<String> {
let re = Regex::new(r"(?i)\b(?:deps|after)\s*[:#]?\s*([0-9, ]+)").unwrap();
if let Some(c) = re.captures(text) {
return c[1]
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
}
Vec::new()
}