use crate::map::blast::BlastRadius;
use crate::paths::Paths;
use crate::spec::Spec;
use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
pub const PLAN_SCHEMA: &str = "keel.plan/1";
pub const TASKS_SCHEMA: &str = "keel.tasks/1";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlanFront {
pub id: String,
pub slug: String,
#[serde(default = "default_plan_schema")]
pub schema: String,
#[serde(default)]
pub blast: BlastDeclaration,
#[serde(default)]
pub rollback: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub verified_at: Option<String>,
#[serde(flatten)]
pub extra: serde_yaml::Mapping,
}
fn default_plan_schema() -> String { PLAN_SCHEMA.to_string() }
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct BlastDeclaration {
#[serde(default)]
pub depth: usize,
#[serde(default)]
pub declared: Vec<String>,
#[serde(default)]
pub computed: Vec<String>,
#[serde(default)]
pub computed_lines: usize,
}
#[derive(Debug, Clone)]
pub struct Plan {
pub front: PlanFront,
pub body: String,
}
impl Plan {
pub fn path_for(paths: &Paths, slug: &str) -> PathBuf {
Spec::dir(paths, slug).join("plan.md")
}
pub fn load(paths: &Paths, slug: &str) -> Result<Self> {
let p = Self::path_for(paths, slug);
if !p.exists() {
bail!("no plan at {} — run `keel plan {slug}`", paths.rel(&p).display());
}
let raw = std::fs::read_to_string(&p).with_context(|| format!("reading {}", p.display()))?;
let (front, body) = crate::store::frontmatter::split_typed(&raw)
.with_context(|| format!("in {}", p.display()))?;
Ok(Self { front, body })
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TasksFront {
pub id: String,
pub slug: String,
#[serde(default = "default_tasks_schema")]
pub schema: String,
#[serde(flatten)]
pub extra: serde_yaml::Mapping,
}
fn default_tasks_schema() -> String { TASKS_SCHEMA.to_string() }
#[derive(Debug, Clone, Default)]
pub struct Task {
pub id: String,
pub title: String,
pub criteria: Vec<String>,
pub files: Vec<String>,
pub budget: Option<usize>,
pub exit: Option<String>,
pub depends_on: Vec<String>,
pub line: usize,
pub unknown_fields: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct Tasks {
pub front: TasksFront,
pub tasks: Vec<Task>,
}
impl Tasks {
pub fn path_for(paths: &Paths, slug: &str) -> PathBuf {
Spec::dir(paths, slug).join("tasks.md")
}
pub fn load(paths: &Paths, slug: &str) -> Result<Self> {
let p = Self::path_for(paths, slug);
if !p.exists() {
bail!("no tasks at {} — run `keel plan {slug}`", paths.rel(&p).display());
}
let raw = std::fs::read_to_string(&p).with_context(|| format!("reading {}", p.display()))?;
Self::parse(&p, &raw)
}
pub fn parse(path: &Path, raw: &str) -> Result<Self> {
let (front, body): (TasksFront, String) = crate::store::frontmatter::split_typed(raw)
.with_context(|| format!("in {}", path.display()))?;
let offset = raw.lines().count() - body.lines().count();
let _ = path;
Ok(Self { front, tasks: parse_tasks(&body, offset) })
}
pub fn total_budget(&self) -> usize {
self.tasks.iter().filter_map(|t| t.budget).sum()
}
pub fn waves(&self) -> std::result::Result<Vec<Vec<&Task>>, Vec<String>> {
let ids: Vec<&str> = self.tasks.iter().map(|t| t.id.as_str()).collect();
let mut remaining: Vec<&Task> = self.tasks.iter().collect();
let mut done: Vec<&str> = Vec::new();
let mut waves: Vec<Vec<&Task>> = Vec::new();
while !remaining.is_empty() {
let (ready, blocked): (Vec<&Task>, Vec<&Task>) = remaining.iter().partition(|t| {
t.depends_on
.iter()
.filter(|d| ids.contains(&d.as_str()))
.all(|d| done.contains(&d.as_str()))
});
if ready.is_empty() {
let mut stuck: Vec<String> = blocked.iter().map(|t| t.id.clone()).collect();
stuck.sort();
return Err(stuck);
}
for t in &ready {
done.push(&t.id);
}
waves.push(ready);
remaining = blocked;
}
Ok(waves)
}
pub fn dangling_dependencies(&self) -> Vec<String> {
let ids: Vec<&str> = self.tasks.iter().map(|t| t.id.as_str()).collect();
let mut out = Vec::new();
for t in &self.tasks {
for d in &t.depends_on {
if !ids.contains(&d.as_str()) {
out.push(format!("{} → {d}", t.id));
}
}
}
out
}
}
fn parse_tasks(body: &str, offset: usize) -> Vec<Task> {
let mut out: Vec<Task> = Vec::new();
let mut current: Option<Task> = None;
let mut in_fence = false;
for (i, line) in body.lines().enumerate() {
let trimmed = line.trim();
if trimmed.starts_with("```") {
in_fence = !in_fence;
continue;
}
if in_fence { continue; }
if let Some(heading) = trimmed.strip_prefix("### ") {
if let Some(t) = current.take() { out.push(t); }
if let Some((id, title)) = split_task_heading(heading) {
current = Some(Task { id, title, line: offset + i + 1, ..Default::default() });
}
continue;
}
if trimmed.starts_with("# ") || trimmed.starts_with("## ") {
if let Some(t) = current.take() { out.push(t); }
continue;
}
let Some(t) = current.as_mut() else { continue };
let field = trimmed
.strip_prefix("- ")
.or_else(|| trimmed.strip_prefix("* "))
.unwrap_or(trimmed);
let Some((key, value)) = field.split_once(':') else { continue };
let value = value.trim();
match key.trim().to_ascii_lowercase().as_str() {
"criteria" | "criterion" => t.criteria = split_list(value),
"files" | "file" => t.files = split_list(value),
"budget" | "budget_lines" => t.budget = parse_budget(value),
"depends_on" | "depends" | "after" => t.depends_on = split_list(value),
"exit" | "exit_condition" => {
if !value.is_empty() { t.exit = Some(value.to_string()); }
}
other if !other.is_empty() && !other.contains(' ') => {
t.unknown_fields.push(other.to_string());
}
_ => {}
}
}
if let Some(t) = current.take() { out.push(t); }
out
}
fn split_task_heading(heading: &str) -> Option<(String, String)> {
let h = heading.trim();
let (id, rest) = h.split_once(char::is_whitespace).unwrap_or((h, ""));
let id = id.trim_end_matches([':', '.']);
let ok = id.len() >= 3
&& id.chars().next().is_some_and(|c| c.is_ascii_uppercase())
&& id.contains('-')
&& id.chars().last().is_some_and(|c| c.is_ascii_digit());
ok.then(|| (id.to_string(), rest.trim().to_string()))
}
fn split_list(value: &str) -> Vec<String> {
if crate::spec::placeholder::is_placeholder_value(value) {
return Vec::new();
}
value
.split([',', ' '])
.map(|s| s.trim().trim_matches('`').trim())
.filter(|s| !s.is_empty() && *s != "-")
.map(|s| s.to_string())
.collect()
}
fn parse_budget(value: &str) -> Option<usize> {
let digits: String = value.chars().skip_while(|c| !c.is_ascii_digit())
.take_while(|c| c.is_ascii_digit()).collect();
digits.parse().ok().filter(|n| *n > 0)
}
pub fn render_plan(spec: &Spec, radius: &BlastRadius, existing: Option<&Plan>) -> Result<String> {
let front = PlanFront {
id: format!("PLAN-{}", spec.front.id.trim_start_matches("SPEC-")),
slug: spec.front.slug.clone(),
schema: PLAN_SCHEMA.to_string(),
blast: BlastDeclaration {
depth: radius.depth,
declared: radius.scope.clone(),
computed: radius.impact.iter().map(|i| i.path.clone()).collect(),
computed_lines: radius.impact_lines,
},
rollback: existing.map(|p| p.front.rollback.clone()).unwrap_or_default(),
verified_at: Some(crate::store::today()),
extra: existing.map(|p| p.front.extra.clone()).unwrap_or_default(),
};
let body = match existing {
Some(p) => replace_section(&p.body, "## Blast radius", &blast_section(radius)),
None => format!(
"# Design — {}\n\n\
## Approach\n\n\
_How the change is made. Name the seam you are cutting at._\n\n\
## Blast radius\n\n{}\n\
## Rollback\n\n\
_Fill in `rollback:` in the front matter above. \"git revert\" is a\n\
legitimate answer; \"we would not need to\" is not._\n",
spec.front.slug,
blast_section(radius)
),
};
crate::store::frontmatter::join_typed(&front, &body)
}
fn blast_section(radius: &BlastRadius) -> String {
let mut s = String::new();
s.push_str("<!-- generated by `keel plan`; edits here are overwritten -->\n\n");
s.push_str(&format!(
"Computed from the import graph at depth {}: **{} files, {} lines**.\n\n",
radius.depth,
radius.impact.len(),
radius.impact_lines
));
if radius.impact.is_empty() {
s.push_str("_No indexed file matches the declared scope. If these are new files, that is\nexpected; if not, the scope globs are wrong._\n\n");
return s;
}
s.push_str("| depth | file | lines |\n| --- | --- | --- |\n");
for i in radius.impact.iter().take(40) {
let marker = if i.depth == 0 { "scope" } else { &format!("+{}", i.depth) };
s.push_str(&format!("| {} | `{}` | {} |\n", marker, i.path, i.lines));
}
if radius.impact.len() > 40 {
s.push_str(&format!("\n_… {} more, see `keel blast`._\n", radius.impact.len() - 40));
}
if !radius.unmatched_globs.is_empty() {
s.push_str(&format!(
"\n_Scope globs matching no indexed file (new files, or a typo): {}_\n",
radius.unmatched_globs.join(", ")
));
}
s.push('\n');
s
}
fn replace_section(body: &str, heading: &str, replacement: &str) -> String {
let mut out = String::new();
let mut skipping = false;
let mut replaced = false;
for line in body.lines() {
if line.trim() == heading {
out.push_str(heading);
out.push_str("\n\n");
out.push_str(replacement);
skipping = true;
replaced = true;
continue;
}
if skipping {
if line.starts_with("## ") || line.starts_with("# ") {
skipping = false;
} else {
continue;
}
}
out.push_str(line);
out.push('\n');
}
if !replaced {
out.push_str(&format!("\n{heading}\n\n{replacement}"));
}
out
}
pub fn render_tasks(spec: &Spec) -> Result<String> {
let front = TasksFront {
id: format!("TASKS-{}", spec.front.id.trim_start_matches("SPEC-")),
slug: spec.front.slug.clone(),
schema: TASKS_SCHEMA.to_string(),
extra: Default::default(),
};
let per_task = spec.front.budget.lines.unwrap_or(120) / spec.criteria.len().max(1);
let mut body = String::from(
"# Tasks\n\n\
Each task must name the criteria it satisfies, the files it touches, a line\n\
budget and an exit condition. G1 checks all four, and checks that every\n\
criterion in the spec is covered by at least one task.\n\n\
Add `- depends_on: T-1` where order matters. Tasks with no dependency on\n\
each other form a wave; `keel tasks` shows them.\n\n",
);
for (n, c) in spec.criteria.iter().enumerate() {
body.push_str(&format!(
"### T-{} {}\n\
- criteria: {}\n\
- files: _name the files this task touches_\n\
- budget: {}\n\
- exit: _the condition under which this task is done_\n\n",
n + 1,
c.title,
c.id,
per_task.max(10)
));
}
crate::store::frontmatter::join_typed(&front, &body)
}
#[cfg(test)]
mod tests {
use super::*;
const TASKS: &str = r#"---
id: TASKS-0001
slug: rate-limit
---
# Tasks
### T-1 Add the limiter middleware
- criteria: AC-1, AC-2
- files: src/api/middleware.rs, `src/api/mod.rs`
- budget: 80
- exit: `cargo test --test rate_limit` exits 0
### T-2 Document the setting
- criteria: AC-3
- files: README.md
- budget: 15 lines
- exit: README names the default
## Notes
Prose after the tasks.
"#;
fn tasks() -> Tasks {
Tasks::parse(Path::new("tasks.md"), TASKS).unwrap()
}
#[test]
fn parses_every_task_field() {
let t = tasks();
assert_eq!(t.tasks.len(), 2);
let t1 = &t.tasks[0];
assert_eq!(t1.id, "T-1");
assert_eq!(t1.title, "Add the limiter middleware");
assert_eq!(t1.criteria, vec!["AC-1", "AC-2"]);
assert_eq!(t1.files, vec!["src/api/middleware.rs", "src/api/mod.rs"]);
assert_eq!(t1.budget, Some(80));
assert!(t1.exit.as_deref().unwrap().contains("cargo test"));
}
#[test]
fn budgets_tolerate_units() {
assert_eq!(tasks().tasks[1].budget, Some(15));
assert_eq!(parse_budget("~80"), Some(80));
assert_eq!(parse_budget("none"), None);
assert_eq!(parse_budget("0"), None, "a zero budget is not a budget");
}
fn with_deps(body: &str) -> Tasks {
Tasks::parse(
Path::new("tasks.md"),
&format!("---\nid: TASKS-0001\nslug: demo\n---\n\n# Tasks\n\n{body}"),
)
.unwrap()
}
#[test]
fn independent_tasks_share_a_wave() {
let t = with_deps(
"### T-1 A\n- criteria: AC-1\n- budget: 10\n\n ### T-2 B\n- criteria: AC-2\n- budget: 10\n",
);
let waves = t.waves().unwrap();
assert_eq!(waves.len(), 1, "independent tasks were serialised");
assert_eq!(waves[0].len(), 2);
}
#[test]
fn a_dependency_creates_a_later_wave() {
let t = with_deps(
"### T-1 A\n- criteria: AC-1\n- budget: 10\n\n ### T-2 B\n- criteria: AC-2\n- budget: 10\n- depends_on: T-1\n\n ### T-3 C\n- criteria: AC-3\n- budget: 10\n",
);
let waves = t.waves().unwrap();
assert_eq!(waves.len(), 2);
assert_eq!(waves[0].len(), 2);
assert_eq!(waves[1][0].id, "T-2");
}
#[test]
fn a_chain_produces_one_wave_per_link() {
let t = with_deps(
"### T-1 A\n- budget: 10\n\n ### T-2 B\n- budget: 10\n- depends_on: T-1\n\n ### T-3 C\n- budget: 10\n- depends_on: T-2\n",
);
assert_eq!(t.waves().unwrap().len(), 3);
}
#[test]
fn a_cycle_is_reported_with_the_tasks_in_it() {
let t = with_deps(
"### T-1 A\n- budget: 10\n- depends_on: T-2\n\n ### T-2 B\n- budget: 10\n- depends_on: T-1\n",
);
let stuck = t.waves().unwrap_err();
assert_eq!(stuck, vec!["T-1".to_string(), "T-2".to_string()]);
}
#[test]
fn a_dependency_on_a_missing_task_is_reported_not_a_stall() {
let t = with_deps("### T-1 A\n- budget: 10\n- depends_on: T-9\n");
assert_eq!(t.dangling_dependencies(), vec!["T-1 → T-9".to_string()]);
assert_eq!(t.waves().unwrap().len(), 1);
}
#[test]
fn totals_across_tasks() {
assert_eq!(tasks().total_budget(), 95);
}
#[test]
fn an_unfilled_placeholder_is_not_a_list_of_words() {
let raw = TASKS.replace("- files: src/api/middleware.rs, `src/api/mod.rs`",
"- files: _name the files this task touches_");
let t = Tasks::parse(Path::new("tasks.md"), &raw).unwrap();
assert!(t.tasks[0].files.is_empty(), "got {:?}", t.tasks[0].files);
}
#[test]
fn unknown_fields_are_recorded_so_typos_are_loud() {
let raw = TASKS.replace("- budget: 80", "- budgt: 80");
let t = Tasks::parse(Path::new("tasks.md"), &raw).unwrap();
assert_eq!(t.tasks[0].budget, None);
assert!(t.tasks[0].unknown_fields.contains(&"budgt".to_string()));
}
#[test]
fn prose_after_the_tasks_is_not_a_task() {
assert!(!tasks().tasks.iter().any(|t| t.title.contains("Notes")));
}
#[test]
fn replacing_a_section_preserves_the_rest() {
let body = "# Design\n\n## Approach\n\nKeep me.\n\n## Blast radius\n\nold table\n\n## Rollback\n\nKeep me too.\n";
let out = replace_section(body, "## Blast radius", "new table\n");
assert!(out.contains("Keep me."), "{out}");
assert!(out.contains("Keep me too."), "{out}");
assert!(out.contains("new table"), "{out}");
assert!(!out.contains("old table"), "{out}");
}
#[test]
fn replacing_an_absent_section_appends_it() {
let out = replace_section("# Design\n\n## Approach\n\nText.\n", "## Blast radius", "table\n");
assert!(out.contains("## Blast radius"));
assert!(out.contains("Text."));
}
}