use crate::close_cmd::find_ticket_by_slug;
use crate::git;
use crate::lock::PlanrLock;
use crate::ticket::parse_ticket;
use std::path::Path;
fn kind_dir(kind: &str) -> Result<&'static str, String> {
match kind {
"task" => Ok("tasks"),
"story" => Ok("stories"),
"epic" => Ok("epics"),
_ => Err(format!("unknown kind: {kind} (want task|story|epic)")),
}
}
fn abandon_frontmatter(content: &str, message: &str, date: &str) -> Result<String, String> {
let sf = split_fm(content).ok_or_else(|| "no frontmatter".to_string())?;
let mut has_status = false;
let mut has_updated = false;
let mut out: Vec<String> = Vec::with_capacity(sf.fm_lines.len() + 2);
for line in &sf.fm_lines {
if line.starts_with("status:") {
out.push("status: abandoned".to_string());
has_status = true;
} else if line.starts_with("updated:") {
out.push(format!("updated: {date}"));
has_updated = true;
} else if line.starts_with("reason:") {
} else {
out.push(line.to_string());
}
}
if !has_status {
out.push("status: abandoned".to_string());
}
if !has_updated {
out.push(format!("updated: {date}"));
}
let mut body = sf.rest.to_string();
let msg = message.trim();
if !msg.is_empty() {
let trimmed_body = body.trim_end();
body = format!("{trimmed_body}\n\n## Reason Abandoned\n\n{msg}\n");
}
Ok(format!("---\n{}\n---\n{}", out.join("\n"), body))
}
struct FmSplit<'a> {
fm_lines: Vec<&'a str>,
rest: &'a str,
}
fn split_fm(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 local_date_string() -> String {
let now = jiff::Zoned::now();
format!("{:04}-{:02}-{:02}", now.year(), now.month(), now.day())
}
pub fn abandon_ticket(
kind: &str,
slug: &str,
message: &str,
trunk: &str,
plan_dir: &str,
cwd: &Path,
) -> Result<String, String> {
let kind_dir = kind_dir(kind)?;
let _lock = PlanrLock::exclusive(cwd).map_err(|e| format!("lock error: {e}"))?;
let branch = format!("plan/{slug}");
if git::rev_parse_verify(&branch).is_ok() {
return Err(format!(
"refuse abandon: ticket '{slug}' has active branch {branch};\n\
clean up the branch/worktree first; planr abandon never discards or merges it"
));
}
let file = find_ticket_by_slug(slug, kind_dir, trunk, plan_dir)?;
let blob = git::show_ref(trunk, &file)?;
let ticket = parse_ticket(&blob);
if ticket.status == "abandoned" {
return Err(format!(
"refuse abandon: {kind} '{slug}' is already abandoned"
));
}
git::checkout(trunk)?;
let date = local_date_string();
let new_content = abandon_frontmatter(&blob, message, &date)?;
let fpath = Path::new(&file);
let parent = fpath.parent().unwrap_or(Path::new("."));
std::fs::create_dir_all(parent)
.map_err(|e| format!("cannot create dir {}: {e}", parent.display()))?;
std::fs::write(fpath, &new_content).map_err(|e| format!("cannot write {file}: {e}"))?;
git::add_file(&file, Path::new("."))?;
git::commit_in(&format!("plan: abandon {kind} {slug}"), Path::new("."))?;
Ok(format!("abandoned {kind} {slug}"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_kind_dir_accepts_all_ticket_kinds() {
assert_eq!(kind_dir("task"), Ok("tasks"));
assert_eq!(kind_dir("story"), Ok("stories"));
assert_eq!(kind_dir("epic"), Ok("epics"));
}
#[test]
fn test_kind_dir_rejects_unknown_kind() {
let err = kind_dir("milestone").unwrap_err();
assert!(err.contains("unknown kind"));
}
#[test]
fn test_abandon_frontmatter_appends_reason_section() {
let content = "---\nid: x\nstatus: todo\nupdated: 2026-01-01\n---\n\n## Goal\nBody\n";
let result =
abandon_frontmatter(content, "OBE — no longer relevant", "2026-08-11").unwrap();
assert!(result.contains("status: abandoned"));
assert!(result.contains("updated: 2026-08-11"));
assert!(result.contains("## Reason Abandoned"));
assert!(result.contains("OBE — no longer relevant"));
assert!(!result.contains("\nreason:"));
assert!(result.contains("## Goal\nBody"));
}
#[test]
fn test_abandon_frontmatter_inserts_missing_fields() {
let content = "---\nid: x\n---\nbody\n";
let result = abandon_frontmatter(content, "wont-do", "2026-08-11").unwrap();
assert!(result.contains("status: abandoned"));
assert!(result.contains("updated: 2026-08-11"));
assert!(result.contains("## Reason Abandoned\n\nwont-do\n"));
assert!(!result.contains("\nreason:"));
}
#[test]
fn test_abandon_frontmatter_skips_section_when_empty_message() {
let content = "---\nid: x\n---\nbody\n";
let result = abandon_frontmatter(content, "", "2026-08-11").unwrap();
assert!(!result.contains("## Reason Abandoned"));
assert!(!result.contains("\nreason:"));
}
#[test]
fn test_abandon_frontmatter_removes_old_reason_field() {
let content = "---\nid: x\nstatus: todo\nreason: old\n---\nbody";
let result = abandon_frontmatter(content, "new message", "2026-08-11").unwrap();
assert!(!result.contains("reason:"));
assert!(result.contains("## Reason Abandoned\n\nnew message\n"));
}
#[test]
fn test_abandon_frontmatter_multi_line_message() {
let content = "---\nid: x\nstatus: todo\n---\nbody";
let msg = "First paragraph.\n\nSecond paragraph with details.";
let result = abandon_frontmatter(content, msg, "2026-08-11").unwrap();
assert!(result.contains("## Reason Abandoned"));
assert!(result.contains("First paragraph."));
assert!(result.contains("Second paragraph with details."));
}
}