use crate::git;
use crate::lock::PlanrLock;
use std::path::{Path, PathBuf};
fn find_task_file<'a>(files: &'a [String], slug: &str) -> Option<&'a str> {
let pat = format!("{}.md", slug);
files.iter().find(|f| f.ends_with(&pat)).map(|s| s.as_str())
}
struct FmSplit<'a> {
fm_lines: Vec<&'a str>,
rest: &'a str,
}
fn split_frontmatter(blob: &str) -> Option<FmSplit<'_>> {
if !blob.starts_with("---\n") {
return None;
}
let end = blob[4..].find("\n---\n")?;
let fm_end = 4 + end;
let fm_str = &blob[4..fm_end];
let rest = &blob[fm_end + 5..];
let fm_lines: Vec<&str> = fm_str.lines().collect();
Some(FmSplit { fm_lines, rest })
}
fn read_status_from_fm<'a>(fm: &'a [&str]) -> &'a str {
for l in fm {
if let Some(val) = l.strip_prefix("status:") {
return val.trim();
}
}
""
}
fn read_deps_from_fm(fm: &[&str]) -> Vec<String> {
let mut deps: Vec<String> = Vec::new();
for (i, l) in fm.iter().enumerate() {
let val = if let Some(v) = l.strip_prefix("depends_on:") {
v.trim()
} else {
continue;
};
if val.starts_with('[') {
if let Some(inner) = val.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
for x in inner.split(',') {
let x = x.trim();
if !x.is_empty() {
deps.push(x.to_string());
}
}
}
} else if !val.is_empty() && val != "[]" {
deps.push(val.to_string());
}
for cl in fm.iter().skip(i + 1) {
if let Some(item) = cl.trim_start().strip_prefix("- ") {
deps.push(item.trim().to_string());
continue;
}
if cl.trim().is_empty() {
continue;
}
break;
}
break;
}
deps
}
fn flip_status_in_fm(fm: &[&str], rest: &str, new_status: &str, date: &str) -> (String, String) {
let mut has_status = false;
let mut has_updated = false;
let mut out: Vec<String> = Vec::with_capacity(fm.len() + 2);
for line in fm {
if line.starts_with("status:") {
out.push(format!("status: {new_status}"));
has_status = true;
} else if line.starts_with("updated:") {
out.push(format!("updated: {date}"));
has_updated = true;
} else {
out.push(line.to_string());
}
}
if !has_status {
out.insert(0, format!("status: {new_status}"));
}
if !has_updated {
out.insert(0, format!("updated: {date}"));
}
let fm_str = out.join("\n");
let full = format!("---\n{fm_str}\n---\n{rest}");
(fm_str, full)
}
struct DepCheck {
deps: Vec<String>,
status: String,
}
fn read_task_on_ref(ref_: &str, task_file: &str) -> Result<DepCheck, String> {
let blob = git::show_ref(ref_, task_file)?;
let sf = split_frontmatter(&blob).ok_or_else(|| format!("no frontmatter in {task_file}"))?;
Ok(DepCheck {
deps: read_deps_from_fm(&sf.fm_lines),
status: read_status_from_fm(&sf.fm_lines).to_string(),
})
}
fn find_dep_on_ref(dep_slug: &str, ref_: &str, plan_dir: &str) -> Result<Option<String>, String> {
for kd in &["epics", "stories", "tasks"] {
let dir = format!("{plan_dir}/{kd}");
let files = git::ls_tree_md(ref_, &dir).unwrap_or_default();
if let Some(f) = find_task_file(&files, dep_slug) {
return Ok(Some(f.to_string()));
}
}
Ok(None)
}
fn local_date_string() -> String {
let now = jiff::Zoned::now();
format!("{:04}-{:02}-{:02}", now.year(), now.month(), now.day(),)
}
pub fn claim_task(
slug: &str,
trunk: &str,
plan_dir: &str,
worktree_path: Option<String>,
cwd: &Path,
) -> Result<String, String> {
let branch = format!("plan/{slug}");
let trunk_report = crate::lint::lint_ref(trunk, plan_dir);
let trunk_findings = crate::lint::render_report(&trunk_report);
if !trunk_findings.is_empty() {
eprint!("{trunk_findings}");
}
let _lock = PlanrLock::shared(cwd).map_err(|e| format!("lock error: {e}"))?;
let task_files = git::ls_tree_md(trunk, &format!("{plan_dir}/tasks"))
.map_err(|_| format!("no task file for slug '{slug}' on {trunk}"))?;
let task_file = find_task_file(&task_files, slug)
.ok_or_else(|| format!("no task file for slug '{slug}' on {trunk}"))?
.to_string();
let info = read_task_on_ref(trunk, &task_file)?;
if info.status == "abandoned" {
return Err(format!(
"refuse claim: task '{slug}' is abandoned; update the ticket or create a new one"
));
}
let mut blockers: Vec<String> = Vec::new();
for dep in &info.deps {
let dep_file = find_dep_on_ref(dep, trunk, plan_dir)?;
let dep_status = match dep_file {
Some(ref f) => {
let blob = git::show_ref(trunk, f)?;
let sf = split_frontmatter(&blob)
.ok_or_else(|| format!("no frontmatter in dep '{dep}'"))?;
read_status_from_fm(&sf.fm_lines).to_string()
}
None => String::new(),
};
if dep_status != "done" {
blockers.push(format!("{dep}({dep_status})"));
}
}
if !blockers.is_empty() {
let err = format!(
"refuse claim: '{slug}' has unfinished depends_on: {}\n\
resolve or complete these first, or have the leader update depends_on.",
blockers.join(" ")
);
return Err(err);
}
let Some(worktree_path) = worktree_path else {
return Ok(format!("claimed: {slug}"));
};
let wt_path = if worktree_path.is_empty() {
let mut p = cwd.to_path_buf();
p.push(plan_dir);
p.push("worktrees");
p.push(format!("wt-{slug}"));
p
} else {
let p = PathBuf::from(&worktree_path);
if p.is_absolute() {
p
} else {
cwd.join(&p)
}
};
let wt_display = wt_path.display();
git::worktree_add(&wt_path, &branch, Some(trunk))?;
let wf_path = wt_path.join(&task_file);
let content = std::fs::read_to_string(&wf_path)
.map_err(|e| format!("cannot read {}: {e}", wf_path.display()))?;
let sf = split_frontmatter(&content).ok_or_else(|| format!("no frontmatter in {task_file}"))?;
let date = local_date_string();
let (_new_fm, new_content) = flip_status_in_fm(&sf.fm_lines, sf.rest, "in_progress", &date);
std::fs::write(&wf_path, &new_content)
.map_err(|e| format!("cannot write {}: {e}", wf_path.display()))?;
git::add_file(&task_file, &wt_path)?;
git::commit_in(&format!("plan: claim {slug} (in_progress)"), &wt_path)?;
Ok(wt_display.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_split_frontmatter_simple() {
let blob = "---\nid: x\nstatus: todo\n---\nbody";
let sf = split_frontmatter(blob).unwrap();
assert_eq!(sf.fm_lines[0], "id: x");
assert_eq!(sf.fm_lines[1], "status: todo");
assert_eq!(sf.rest, "body");
}
#[test]
fn test_split_frontmatter_no_fm() {
assert!(split_frontmatter("no frontmatter").is_none());
}
#[test]
fn test_read_status_from_fm() {
let fm = ["id: x", "status: todo", "parent: p"];
assert_eq!(read_status_from_fm(&fm), "todo");
}
#[test]
fn test_read_status_from_fm_missing() {
let fm = ["id: x", "parent: p"];
assert_eq!(read_status_from_fm(&fm), "");
}
#[test]
fn test_read_deps_inline_list() {
let fm = ["id: x", "depends_on: [dep1, dep2]", "status: todo"];
let deps = read_deps_from_fm(&fm);
assert_eq!(deps, vec!["dep1", "dep2"]);
}
#[test]
fn test_read_deps_bare_string() {
let fm = ["id: x", "depends_on: dep1", "status: todo"];
let deps = read_deps_from_fm(&fm);
assert_eq!(deps, vec!["dep1"]);
}
#[test]
fn test_read_deps_block_list() {
let fm = [
"id: x",
"depends_on:",
" - dep1",
" - dep2",
"status: todo",
];
let deps = read_deps_from_fm(&fm);
assert_eq!(deps, vec!["dep1", "dep2"]);
}
#[test]
fn test_read_deps_empty() {
let fm = ["id: x", "depends_on: []", "status: todo"];
let deps = read_deps_from_fm(&fm);
assert!(deps.is_empty());
}
#[test]
fn test_flip_status_in_fm_replaces() {
let fm = ["id: x", "status: todo", "updated: 2026-01-01"];
let rest = "body\n";
let (_new_fm, full) = flip_status_in_fm(&fm, rest, "in_progress", "2026-08-05");
assert!(full.contains("status: in_progress"));
assert!(full.contains("updated: 2026-08-05"));
assert!(full.contains("id: x"));
assert!(full.ends_with("body\n"));
}
#[test]
fn test_flip_status_in_fm_inserts_if_absent() {
let fm = ["id: x"];
let rest = "body\n";
let (_new_fm, full) = flip_status_in_fm(&fm, rest, "in_progress", "2026-08-05");
let sep = full.find("\n---\n").unwrap();
let fm_part = &full[4..sep];
let lines: Vec<&str> = fm_part.split('\n').collect();
let status_pos = lines.iter().position(|l| l.starts_with("status:"));
let updated_pos = lines.iter().position(|l| l.starts_with("updated:"));
assert!(status_pos.is_some(), "status missing: {full}");
assert!(updated_pos.is_some(), "updated missing: {full}");
assert!(
updated_pos.unwrap() < status_pos.unwrap(),
"updated (pos {}) should be above status (pos {}): {full}",
updated_pos.unwrap(),
status_pos.unwrap(),
);
}
#[test]
fn test_find_task_file_exact() {
let files = vec![
"tasks/01-scaffold.md".to_string(),
"tasks/02-parse.md".to_string(),
"tasks/03-http-proxy.md".to_string(),
];
assert_eq!(
find_task_file(&files, "http-proxy"),
Some("tasks/03-http-proxy.md")
);
}
#[test]
fn test_find_task_file_not_found() {
let files = vec!["tasks/01-scaffold.md".to_string()];
assert!(find_task_file(&files, "nonexistent").is_none());
}
#[test]
fn test_find_task_file_ends_with_semantics() {
let files = vec!["tasks/01-http-proxy.md".to_string()];
assert_eq!(
find_task_file(&files, "http-proxy"),
Some("tasks/01-http-proxy.md")
);
}
#[test]
fn test_local_date_string_format() {
let s = local_date_string();
assert_eq!(s.len(), 10, "bad format: {s}");
assert_eq!(&s[4..5], "-");
assert_eq!(&s[7..8], "-");
let y: u32 = s[..4].parse().unwrap();
let m: u32 = s[5..7].parse().unwrap();
let d: u32 = s[8..].parse().unwrap();
assert!((2025..=2030).contains(&y));
assert!((1..=12).contains(&m));
assert!((1..=31).contains(&d));
}
}