use std::collections::HashSet;
use std::path::{Path, PathBuf};
pub(super) const PLAN_TRACKER_START: &str = "<!-- vtcode:plan-tracker:start -->";
pub(super) const PLAN_TRACKER_END: &str = "<!-- vtcode:plan-tracker:end -->";
const PLACEHOLDER_TOKENS: [&str; 18] = [
"[step]",
"[paths]",
"[check]",
"[explicit assumption]",
"[default chosen when user did not specify]",
"[out-of-scope items intentionally not changed]",
"[file, symbol, or behavior confirmed from the repo]",
"[existing pattern or constraint verified before planning]",
"[if any], otherwise: no remaining scope decisions",
"[project build and lint command",
"[project test command",
"[2-4 lines: goal, user impact, what will change, what will not]",
"[explicit commands/manual checks]",
"[what must not break]",
"[todo]",
"todo:",
"[decision needed]",
"tbd",
];
const SUMMARY_SECTION_ALIASES: &[&str] = &["Summary"];
const IMPLEMENTATION_SECTION_ALIASES: &[&str] = &["Implementation Steps", "Steps"];
const VALIDATION_SECTION_ALIASES: &[&str] = &["Test Cases and Validation", "Validation"];
const ASSUMPTIONS_SECTION_ALIASES: &[&str] = &["Assumptions and Defaults", "Assumptions"];
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct PlanValidationReport {
pub missing_sections: Vec<String>,
pub placeholder_tokens: Vec<String>,
pub open_decisions: Vec<String>,
pub invalid_implementation_steps: Vec<String>,
pub implementation_step_count: usize,
pub validation_item_count: usize,
pub assumption_count: usize,
pub summary_present: bool,
}
impl PlanValidationReport {
pub fn is_ready(&self) -> bool {
self.missing_sections.is_empty()
&& self.placeholder_tokens.is_empty()
&& self.open_decisions.is_empty()
&& self.invalid_implementation_steps.is_empty()
&& self.summary_present
&& self.implementation_step_count > 0
&& self.validation_item_count > 0
&& self.assumption_count > 0
}
pub fn reasons(&self) -> Vec<String> {
let mut reasons = Vec::new();
if !self.missing_sections.is_empty() {
reasons.push(format!("missing sections: {}", self.missing_sections.join(", ")));
}
if !self.placeholder_tokens.is_empty() {
reasons.push(format!("placeholder tokens: {}", self.placeholder_tokens.join(", ")));
}
if !self.open_decisions.is_empty() {
reasons.push(format!("unresolved decisions: {}", self.open_decisions.join("; ")));
}
if !self.invalid_implementation_steps.is_empty() {
reasons.push(format!("invalid implementation steps: {}", self.invalid_implementation_steps.join("; ")));
}
if !self.summary_present {
reasons.push("summary is empty".to_string());
}
if self.implementation_step_count == 0 {
reasons.push("no implementation steps".to_string());
}
if self.validation_item_count == 0 {
reasons.push("no validation items".to_string());
}
if self.assumption_count == 0 {
reasons.push("no assumptions or defaults".to_string());
}
reasons
}
}
pub fn tracker_file_for_plan_file(plan_file: &Path) -> Option<PathBuf> {
let stem = plan_file.file_stem()?.to_str()?;
Some(plan_file.with_file_name(format!("{stem}.tasks.md")))
}
pub fn plan_file_for_tracker_file(tracker_file: &Path) -> Option<PathBuf> {
let file_name = tracker_file.file_name()?.to_str()?;
let stem = file_name.strip_suffix(".tasks.md")?;
Some(tracker_file.with_file_name(format!("{stem}.md")))
}
fn strip_embedded_tracker(plan_content: &str) -> String {
let Some(start) = plan_content.find(PLAN_TRACKER_START) else {
return plan_content.trim().to_string();
};
let end = plan_content[start..]
.find(PLAN_TRACKER_END)
.map(|offset| start + offset + PLAN_TRACKER_END.len())
.unwrap_or(plan_content.len());
let mut merged = String::new();
merged.push_str(plan_content[..start].trim_end());
if !merged.is_empty() && !plan_content[end..].trim().is_empty() {
merged.push_str("\n\n");
}
merged.push_str(plan_content[end..].trim_start());
merged.trim().to_string()
}
pub(super) fn extract_embedded_tracker(plan_content: &str) -> Option<String> {
let start = plan_content.find(PLAN_TRACKER_START)?;
let end = plan_content.find(PLAN_TRACKER_END)?;
if end <= start {
return None;
}
let content = plan_content[start + PLAN_TRACKER_START.len()..end].trim();
if content.is_empty() {
None
} else {
Some(content.to_string())
}
}
pub(super) fn render_plan_with_tracker(plan_markdown: &str, tracker_markdown: Option<&str>) -> String {
let base_plan = strip_embedded_tracker(plan_markdown);
let Some(tracker_markdown) = tracker_markdown.map(str::trim).filter(|value| !value.is_empty()) else {
return format!("{}\n", base_plan.trim_end());
};
format!("{}\n\n{}\n{}\n{}\n", base_plan.trim_end(), PLAN_TRACKER_START, tracker_markdown, PLAN_TRACKER_END)
}
pub fn merge_plan_content(plan_content: Option<String>, tracker_content: Option<String>) -> Option<String> {
match (plan_content, tracker_content) {
(Some(plan), Some(tracker)) => Some(render_plan_with_tracker(&plan, Some(&tracker))),
(Some(plan), None) => Some(render_plan_with_tracker(&plan, None)),
(None, Some(tracker)) => Some(render_plan_with_tracker("", Some(&tracker))),
(None, None) => None,
}
}
fn section_body(content: &str, header: &str) -> Option<String> {
let mut capture = false;
let mut lines = Vec::new();
for line in content.lines() {
let trimmed = line.trim();
if capture && is_plan_section_boundary(trimmed) {
break;
}
if let Some(found) = trimmed.strip_prefix("## ") {
if capture {
break;
}
capture = found.trim().trim_end_matches(':').trim().eq_ignore_ascii_case(header);
continue;
}
if capture {
lines.push(line.to_string());
}
}
let body = lines.join("\n").trim().to_string();
(!body.is_empty()).then_some(body)
}
fn section_body_for_aliases(content: &str, headers: &[&str]) -> Option<String> {
headers
.iter()
.find_map(|header| section_body(content, header).or_else(|| standalone_section_body(content, header)))
}
fn normalized_section_label(line: &str) -> &str {
let mut normalized = line.trim().trim_start_matches('>').trim_start();
while let Some(stripped) = normalized.strip_prefix('#') {
normalized = stripped.trim_start();
}
strip_list_marker(normalized).trim()
}
fn is_standalone_section_label(line: &str, header: &str) -> bool {
let normalized = normalized_section_label(line);
normalized.eq_ignore_ascii_case(header)
}
fn standalone_section_body(content: &str, header: &str) -> Option<String> {
let mut capture = false;
let mut lines = Vec::new();
for line in content.lines() {
let trimmed = line.trim();
if is_standalone_section_label(trimmed, header) {
if capture {
break;
}
capture = true;
continue;
}
if capture && is_plan_section_boundary(trimmed) {
break;
}
if capture {
lines.push(line.to_string());
}
}
let body = lines.join("\n").trim().to_string();
(!body.is_empty()).then_some(body)
}
fn strip_ascii_case_insensitive_prefix<'a>(value: &'a str, prefix: &str) -> Option<&'a str> {
let prefix_end = prefix.len();
value
.get(..prefix_end)
.filter(|candidate| candidate.eq_ignore_ascii_case(prefix))
.and_then(|_| value.get(prefix_end..).map(str::trim_start))
}
fn labeled_body_for_aliases(content: &str, labels: &[&str]) -> Option<String> {
let lines = content
.lines()
.map(str::trim)
.filter_map(|line| {
labels
.iter()
.find_map(|label| strip_ascii_case_insensitive_prefix(line, &format!("{label}:")))
})
.filter(|line| !line.is_empty())
.collect::<Vec<_>>();
(!lines.is_empty()).then(|| lines.join("\n"))
}
fn meaningful_section_lines(body: &str) -> Vec<&str> {
body.lines()
.map(str::trim)
.filter(|line| {
!line.is_empty()
&& !line.starts_with('>')
&& !line.starts_with("<!--")
&& *line != PLAN_TRACKER_START
&& *line != PLAN_TRACKER_END
})
.collect()
}
fn numbered_line_parts(line: &str) -> Option<(&str, &str)> {
let trimmed = line.trim();
let digit_end = trimmed
.char_indices()
.take_while(|(_, ch)| ch.is_ascii_digit())
.last()
.map_or(0, |(index, ch)| index + ch.len_utf8());
if digit_end == 0 {
return None;
}
let rest = trimmed.get(digit_end..)?.trim_start();
let punctuation = rest.chars().next()?;
if punctuation != '.' && punctuation != ')' {
return None;
}
Some((trimmed.get(..digit_end)?, rest.get(punctuation.len_utf8()..)?.trim_start()))
}
fn is_numbered_line(line: &str) -> bool {
numbered_line_parts(line).is_some()
}
#[derive(Debug, Clone)]
struct ImplementationStepBlock {
number: String,
lines: Vec<String>,
}
fn strip_list_marker(line: &str) -> &str {
let mut current = line.trim();
loop {
let Some(stripped) = current
.strip_prefix("- ")
.or_else(|| current.strip_prefix("* "))
.or_else(|| current.strip_prefix("• "))
else {
return current;
};
current = stripped.trim_start();
}
}
fn is_plan_section_boundary(line: &str) -> bool {
let mut normalized = line.trim();
while let Some(stripped) = normalized.strip_prefix('#') {
normalized = stripped.trim_start();
}
normalized = strip_list_marker(normalized).trim();
SUMMARY_SECTION_ALIASES
.iter()
.chain(IMPLEMENTATION_SECTION_ALIASES.iter())
.chain(VALIDATION_SECTION_ALIASES.iter())
.chain(ASSUMPTIONS_SECTION_ALIASES.iter())
.any(|alias| {
normalized.eq_ignore_ascii_case(alias)
|| strip_ascii_case_insensitive_prefix(normalized, &format!("{alias}:")).is_some()
})
}
fn collect_implementation_step_blocks(content: &str, stop_at_section_boundaries: bool) -> Vec<ImplementationStepBlock> {
let mut blocks = Vec::new();
let mut current: Option<ImplementationStepBlock> = None;
let mut collecting = true;
let mut started = false;
for line in content.lines() {
let trimmed = line.trim();
if trimmed.is_empty()
|| trimmed.starts_with('>')
|| trimmed.starts_with("<!--")
|| trimmed == PLAN_TRACKER_START
|| trimmed == PLAN_TRACKER_END
{
continue;
}
if let Some((number, step)) = numbered_line_parts(trimmed) {
if !collecting {
continue;
}
if let Some(previous) = current.take() {
blocks.push(previous);
}
started = true;
current = Some(ImplementationStepBlock {
number: number.to_string(),
lines: vec![step.to_string()],
});
continue;
}
if stop_at_section_boundaries && is_plan_section_boundary(trimmed) {
if started {
if let Some(previous) = current.take() {
blocks.push(previous);
}
collecting = false;
}
continue;
}
if collecting
&& started
&& let Some(step) = current.as_mut()
{
step.lines.push(trimmed.to_string());
}
}
if let Some(last) = current {
blocks.push(last);
}
blocks
}
fn marker_value<'a>(line: &'a str, labels: &[&str]) -> Option<&'a str> {
let line = strip_list_marker(line);
labels
.iter()
.find_map(|label| strip_ascii_case_insensitive_prefix(line, &format!("{label}:")))
}
fn is_concrete_value(value: &str) -> bool {
let value = value.trim();
!value.is_empty() && value != "[]" && find_placeholder_tokens(value).is_empty()
}
fn is_concrete_target(value: &str) -> bool {
let value = value.trim();
if !is_concrete_value(value) {
return false;
}
let target = marker_value(value, &["files/symbols", "files", "symbols", "target", "behavior", "behaviour"])
.unwrap_or(value)
.trim();
if !is_concrete_value(target) {
return false;
}
let lower = target.to_ascii_lowercase();
if lower.starts_with('[') && lower.ends_with(']') {
let items = parse_bracket_list(target);
return !items.is_empty() && items.iter().all(|item| is_concrete_target(item));
}
let has_structural_reference = target.split_whitespace().any(|token| {
let token = token.trim_matches(|ch: char| ch.is_ascii_punctuation() && ch != '_' && ch != '/');
token.contains('/')
|| token.contains('\\')
|| token.contains("::")
|| token.contains('_')
|| token.chars().skip(1).any(char::is_uppercase)
|| token.rsplit_once('.').is_some_and(|(_, suffix)| !suffix.is_empty())
});
if has_structural_reference {
return true;
}
const GENERIC_TARGETS: &[&str] = &[
"file",
"files",
"path",
"paths",
"symbol",
"symbols",
"files/symbols",
"files or symbols",
"file/symbol",
"file or symbol",
"behavior",
"behaviour",
"code",
"codebase",
"implementation",
"feature",
"workflow",
"module",
"modules",
"component",
"components",
"target",
"relevant files",
"relevant code",
"relevant modules",
"relevant symbols",
"appropriate files",
"appropriate code",
"appropriate modules",
"affected files",
"affected code",
"affected modules",
"the file",
"the files",
"the path",
"the symbol",
"the symbols",
"the behavior",
"the behaviour",
"the code",
"the codebase",
"the implementation",
"the feature",
"the workflow",
"the module",
"the modules",
"the component",
"the components",
"the relevant files",
"the relevant code",
"the relevant modules",
"the relevant symbols",
"the affected files",
"the affected code",
"the affected modules",
"existing code",
"existing files",
"existing modules",
"changed code",
"changed files",
"changed modules",
"all relevant files",
"all relevant code",
"all relevant modules",
];
if GENERIC_TARGETS.iter().any(|generic| lower == *generic) {
return false;
}
let generic_prefixes = [
"relevant ",
"appropriate ",
"affected ",
"the relevant ",
"the affected ",
"existing ",
"changed ",
"all relevant ",
"the ",
"a ",
"an ",
"some ",
"any ",
];
if generic_prefixes.iter().any(|prefix| lower.starts_with(prefix)) {
return false;
}
const CONCRETE_BEHAVIOR_WORDS: &[&str] = &[
"agent",
"assertion",
"approval",
"artifact",
"budget",
"cache",
"check",
"command",
"configuration",
"confirmation",
"context",
"deferred",
"error",
"event",
"execution",
"fallback",
"flow",
"handoff",
"input",
"interview",
"latency",
"lifecycle",
"logic",
"markup",
"memory",
"output",
"parser",
"parsing",
"path",
"performance",
"permission",
"persistence",
"plan",
"planning",
"policy",
"prompt",
"question",
"read",
"recovery",
"refresh",
"request",
"response",
"runtime",
"state",
"startup",
"step",
"stream",
"symbol",
"task",
"test",
"timeout",
"tracker",
"transition",
"tool",
"ui",
"validation",
"workflow",
"write",
];
let concrete_word_count = lower
.split_whitespace()
.map(|word| word.trim_matches(|character: char| character.is_ascii_punctuation()))
.filter(|word| CONCRETE_BEHAVIOR_WORDS.contains(word))
.count();
lower.split_whitespace().count() >= 2 && concrete_word_count >= 2
}
fn is_concrete_verification(value: &str) -> bool {
let value = value.trim();
if !is_concrete_value(value) {
return false;
}
if value.starts_with('[') && value.ends_with(']') {
let items = parse_bracket_list(value);
return !items.is_empty() && items.iter().all(|item| is_concrete_verification(item));
}
let lower = value.to_ascii_lowercase();
const COMMAND_NAMES: &[&str] = &[
"bun",
"cargo",
"cmake",
"clippy",
"deno",
"dotnet",
"eslint",
"go",
"gradle",
"just",
"make",
"meson",
"mvn",
"mypy",
"ninja",
"nextest",
"npm",
"pnpm",
"python",
"python3",
"pytest",
"rg",
"ruff",
"rustfmt",
"shellcheck",
"swiftlint",
"tsc",
"xcodebuild",
"yarn",
];
let raw_words = lower.split_whitespace().collect::<Vec<_>>();
let is_command_token = |raw_word: &str| {
let word = raw_word.trim_matches(|character: char| matches!(character, '`' | '"' | '\''));
let bare_word = word.trim_matches(|character: char| character.is_ascii_punctuation());
COMMAND_NAMES.contains(&bare_word)
|| word.starts_with("./")
|| word.starts_with("../")
|| word.starts_with('/')
|| word.ends_with(".sh")
|| word.ends_with(".cmd")
|| word.ends_with(".ps1")
|| word.ends_with(".bat")
};
let leading_wrapper = raw_words.first().is_some_and(|word| {
matches!(
word.trim_matches(|character: char| character.is_ascii_punctuation()),
"command" | "execute" | "invoke" | "run" | "use"
)
});
if raw_words.first().is_some_and(|word| is_command_token(word))
|| (leading_wrapper && raw_words.get(1).is_some_and(|word| is_command_token(word)))
{
return true;
}
let words = lower.split_whitespace().collect::<Vec<_>>();
if words.len() < 2 {
return false;
}
let observable_markers = [
"assert",
"available",
"completes",
"contains",
"deferred",
"emits",
"expected",
"fails",
"finishes",
"holds",
"includes",
"matches",
"manual",
"measure",
"never",
"observable",
"outputs",
"persists",
"preserves",
"remains",
"renders",
"reports",
"returns",
"shows",
"starts",
"stays",
"survives",
"updates",
"visible",
"waits",
];
words.iter().any(|word| observable_markers.contains(word))
|| ((words.contains(&"tests") || words.contains(&"checks")) && words.contains(&"pass"))
}
fn implementation_step_shape_error(step: &ImplementationStepBlock) -> Option<String> {
let first_line = step.lines.first().map(String::as_str).unwrap_or_default();
let action = first_line.trim();
if action.is_empty() {
return Some("action is empty".to_string());
}
let segments = action.split("->").map(str::trim).collect::<Vec<_>>();
let verify_index = segments
.iter()
.position(|segment| marker_value(segment, &["verify", "verification"]).is_some());
let mut has_target = false;
let mut invalid_target = false;
if let Some(index) = verify_index {
if index < 2 {
return Some("must include a concrete target before the verification marker".to_string());
}
for target in segments.iter().skip(1).take(index.saturating_sub(1)) {
if marker_value(target, &["outcome"]).is_some() {
continue;
}
has_target = true;
invalid_target |= !is_concrete_target(target);
}
} else if segments.len() > 1 {
for target in segments.iter().skip(1) {
if marker_value(target, &["outcome"]).is_some() {
continue;
}
has_target = true;
invalid_target |= !is_concrete_target(target);
}
}
let mut has_verification = verify_index.is_some();
let mut verification_is_concrete = verify_index
.and_then(|index| marker_value(segments[index], &["verify", "verification"]))
.is_none_or(is_concrete_verification);
for continuation in step.lines.iter().skip(1) {
if let Some(target) = marker_value(continuation, &["files/symbols", "files", "symbols", "target"]) {
has_target = true;
invalid_target |= !is_concrete_target(target);
}
if let Some(verify) = marker_value(continuation, &["verify", "verification"]) {
has_verification = true;
verification_is_concrete &= is_concrete_verification(verify);
}
}
if !has_target || invalid_target {
return Some("must name a concrete file, symbol, or behavior target".to_string());
}
if !has_verification {
return Some("must include a `verify:` or `verification:` marker".to_string());
}
if !verification_is_concrete {
return Some("verification marker must include a concrete command or check".to_string());
}
None
}
fn find_placeholder_tokens(content: &str) -> Vec<String> {
let lower = content.to_ascii_lowercase();
PLACEHOLDER_TOKENS
.iter()
.filter(|token| lower.contains(**token))
.map(|token| token.to_string())
.collect()
}
fn find_open_decisions(content: &str) -> Vec<String> {
content
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.filter(|line| {
let lower = line.to_ascii_lowercase();
(lower.contains("next open decision") || lower.contains("open question"))
&& ![
"none",
"no open",
"no remaining",
"no further",
"resolved",
"closed",
"n/a",
"not applicable",
]
.iter()
.any(|needle| lower.contains(needle))
})
.map(ToString::to_string)
.collect()
}
pub fn validate_plan_content(content: &str) -> PlanValidationReport {
let stripped = strip_embedded_tracker(content);
let mut report = PlanValidationReport {
placeholder_tokens: find_placeholder_tokens(&stripped),
open_decisions: find_open_decisions(&stripped),
..PlanValidationReport::default()
};
let summary_body = section_body_for_aliases(&stripped, SUMMARY_SECTION_ALIASES)
.or_else(|| labeled_body_for_aliases(&stripped, SUMMARY_SECTION_ALIASES));
let implementation_section_body = section_body_for_aliases(&stripped, IMPLEMENTATION_SECTION_ALIASES);
let implementation_labeled_body = labeled_body_for_aliases(&stripped, IMPLEMENTATION_SECTION_ALIASES);
let implementation_blocks = if let Some(body) = implementation_section_body.as_deref() {
collect_implementation_step_blocks(body, false)
} else if implementation_labeled_body.is_some() {
collect_implementation_step_blocks(implementation_labeled_body.as_deref().unwrap_or_default(), false)
} else {
collect_implementation_step_blocks(&stripped, true)
};
let validation_body = section_body_for_aliases(&stripped, VALIDATION_SECTION_ALIASES)
.or_else(|| labeled_body_for_aliases(&stripped, VALIDATION_SECTION_ALIASES));
let assumptions_body = section_body_for_aliases(&stripped, ASSUMPTIONS_SECTION_ALIASES)
.or_else(|| labeled_body_for_aliases(&stripped, ASSUMPTIONS_SECTION_ALIASES));
for (section, body) in [
("Summary", summary_body.as_ref()),
("Implementation Steps", (!implementation_blocks.is_empty()).then_some(&stripped)),
("Test Cases and Validation", validation_body.as_ref()),
("Assumptions and Defaults", assumptions_body.as_ref()),
] {
if body.is_none() {
report.missing_sections.push(section.to_string());
}
}
if let Some(body) = summary_body.as_deref() {
report.summary_present = !meaningful_section_lines(body).is_empty();
}
if !report.summary_present && !report.missing_sections.iter().any(|s| s == "Summary") {
report.missing_sections.push("Summary".to_string());
}
report.implementation_step_count = implementation_blocks.len();
report.invalid_implementation_steps = implementation_blocks
.iter()
.filter_map(|step| {
implementation_step_shape_error(step).map(|reason| format!("step {}: {reason}", step.number))
})
.collect();
if report.implementation_step_count == 0 && !report.missing_sections.iter().any(|s| s == "Implementation Steps") {
report.missing_sections.push("Implementation Steps".to_string());
}
if let Some(body) = validation_body.as_deref() {
let lines = meaningful_section_lines(body);
report.validation_item_count = lines
.iter()
.filter(|line| is_numbered_line(line) || line.starts_with("- "))
.count();
if report.validation_item_count == 0 {
report.validation_item_count = lines.len();
}
}
if report.validation_item_count == 0 && !report.missing_sections.iter().any(|s| s == "Test Cases and Validation") {
report.missing_sections.push("Test Cases and Validation".to_string());
}
if let Some(body) = assumptions_body.as_deref() {
let lines = meaningful_section_lines(body);
report.assumption_count = lines
.iter()
.filter(|line| is_numbered_line(line) || line.starts_with("- "))
.count();
if report.assumption_count == 0 {
report.assumption_count = lines.len();
}
}
if report.assumption_count == 0 && !report.missing_sections.iter().any(|s| s == "Assumptions and Defaults") {
report.missing_sections.push("Assumptions and Defaults".to_string());
}
report
}
fn parse_bracket_list(raw: &str) -> Vec<String> {
let trimmed = raw.trim().trim_start_matches('[').trim_end_matches(']');
trimmed
.split(',')
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.collect()
}
pub(super) fn tracker_has_progress_or_notes(tracker: &str) -> bool {
let lower = tracker.to_ascii_lowercase();
if lower.contains("## notes") {
return true;
}
["[x]", "[~]", "[!]", "[/]"].iter().any(|marker| lower.contains(marker))
}
pub fn generate_tracker_markdown_from_plan(plan_markdown: &str) -> Option<String> {
let stripped = strip_embedded_tracker(plan_markdown);
let implementation = section_body_for_aliases(&stripped, IMPLEMENTATION_SECTION_ALIASES).or_else(|| {
let blocks = collect_implementation_step_blocks(&stripped, true);
(!blocks.is_empty()).then(|| {
blocks
.into_iter()
.filter_map(|block| block.lines.first().map(|line| format!("{}. {line}", block.number)))
.collect::<Vec<_>>()
.join("\n")
})
})?;
let title = plan_markdown
.lines()
.find_map(|line| line.trim().strip_prefix("# ").map(str::trim))
.filter(|line| !line.is_empty())
.unwrap_or("Implementation Plan");
let mut items = Vec::new();
let mut seen_descriptions = HashSet::new();
for line in implementation.lines().map(str::trim).filter(|line| !line.is_empty()) {
if !is_numbered_line(line) {
continue;
}
let description = line.split_once(['.', ')']).map(|(_, rest)| rest.trim()).unwrap_or(line);
let segments = description.split("->").map(str::trim).collect::<Vec<_>>();
let main = segments.first().copied().unwrap_or_default();
if main.is_empty() {
continue;
}
let description_key = main.split_whitespace().collect::<Vec<_>>().join(" ").to_ascii_lowercase();
if !seen_descriptions.insert(description_key) {
continue;
}
let mut entry = format!("- [ ] {main}\n");
for segment in segments.iter().skip(1) {
if let Some(files) = strip_ascii_case_insensitive_prefix(segment, "files:") {
let values = parse_bracket_list(files);
if !values.is_empty() {
entry.push_str(&format!(" files: {}\n", values.join(", ")));
}
continue;
}
if let Some(outcome) = strip_ascii_case_insensitive_prefix(segment, "outcome:") {
let outcome = outcome.trim().trim_start_matches('[').trim_end_matches(']');
if !outcome.is_empty() {
entry.push_str(&format!(" outcome: {outcome}\n"));
}
continue;
}
if let Some(verify) = strip_ascii_case_insensitive_prefix(segment, "verify:") {
let values = parse_bracket_list(verify);
if values.is_empty() {
let trimmed = verify.trim();
if !trimmed.is_empty() {
entry.push_str(&format!(" verify: {trimmed}\n"));
}
} else {
for value in values {
entry.push_str(&format!(" verify: {value}\n"));
}
}
}
}
items.push(entry);
}
if items.is_empty() {
return None;
}
Some(format!("# {}\n\n## Plan of Work\n\n{}", title, items.concat().trim_end()))
}