use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(from = "TaskDepDef", into = "TaskDepDef")]
pub enum TaskDep {
Index(usize),
Id(Uuid),
}
impl TaskDep {
pub fn to_uuid(&self, order_to_id: &std::collections::HashMap<usize, Uuid>) -> Option<Uuid> {
match self {
TaskDep::Id(uuid) => Some(*uuid),
TaskDep::Index(idx) => order_to_id.get(idx).copied(),
}
}
pub fn is_uuid(&self) -> bool {
matches!(self, TaskDep::Id(_))
}
pub fn as_uuid(&self) -> Option<Uuid> {
match self {
TaskDep::Id(uuid) => Some(*uuid),
TaskDep::Index(_) => None,
}
}
}
#[derive(Serialize, Deserialize)]
#[serde(untagged)]
enum TaskDepDef {
Uuid(Uuid),
Index(usize),
}
impl From<TaskDepDef> for TaskDep {
fn from(def: TaskDepDef) -> Self {
match def {
TaskDepDef::Uuid(uuid) => TaskDep::Id(uuid),
TaskDepDef::Index(idx) => TaskDep::Index(idx),
}
}
}
impl From<TaskDep> for TaskDepDef {
fn from(dep: TaskDep) -> Self {
match dep {
TaskDep::Id(uuid) => TaskDepDef::Uuid(uuid),
TaskDep::Index(idx) => TaskDepDef::Index(idx),
}
}
}
pub fn deserialize_task_deps<'de, D>(deserializer: D) -> Result<Vec<TaskDep>, D::Error>
where
D: serde::Deserializer<'de>,
{
struct TaskDepVisitor;
impl<'de> serde::de::Visitor<'de> for TaskDepVisitor {
type Value = Vec<TaskDep>;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("an array of task indices (1-based integers) or UUID strings")
}
fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
where
A: serde::de::SeqAccess<'de>,
{
let mut deps = Vec::new();
while let Some(val) = seq.next_element::<serde_json::Value>()? {
match val {
serde_json::Value::Number(n) => {
if let Some(i) = n.as_i64() {
if i < 1 {
return Err(serde::de::Error::custom("task indices start at 1"));
}
deps.push(TaskDep::Index(i as usize));
} else {
return Err(serde::de::Error::custom(
"task index must be a positive integer",
));
}
}
serde_json::Value::String(s) => match Uuid::parse_str(&s) {
Ok(uuid) => deps.push(TaskDep::Id(uuid)),
Err(_) => {
return Err(serde::de::Error::custom(format!("invalid UUID: {}", s)));
}
},
_ => {
return Err(serde::de::Error::custom(
"task dependency must be an integer index or UUID string",
));
}
}
}
Ok(deps)
}
}
deserializer.deserialize_seq(TaskDepVisitor)
}
pub fn serialize_task_type<S>(task_type: &TaskType, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
let s = match task_type {
TaskType::Research => "research",
TaskType::Edit => "edit",
TaskType::Create => "create",
TaskType::Delete => "delete",
TaskType::Test => "test",
TaskType::Refactor => "refactor",
TaskType::Documentation => "documentation",
TaskType::Configuration => "configuration",
TaskType::Build => "build",
TaskType::Other(s) => s.as_str(),
};
serializer.serialize_str(s)
}
pub fn deserialize_task_type<'de, D>(deserializer: D) -> Result<TaskType, D::Error>
where
D: serde::Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
match s.to_lowercase().as_str() {
"research" => Ok(TaskType::Research),
"edit" => Ok(TaskType::Edit),
"create" => Ok(TaskType::Create),
"delete" => Ok(TaskType::Delete),
"test" => Ok(TaskType::Test),
"refactor" => Ok(TaskType::Refactor),
"documentation" => Ok(TaskType::Documentation),
"configuration" => Ok(TaskType::Configuration),
"build" => Ok(TaskType::Build),
"other" => Ok(TaskType::Other("other".to_string())),
unknown => {
tracing::debug!("Unknown task_type '{unknown}' mapped to the 'other' category");
Ok(TaskType::Other(unknown.to_string()))
}
}
}
fn default_uuid() -> Uuid {
Uuid::new_v4()
}
fn default_now() -> DateTime<Utc> {
Utc::now()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ApprovalSource {
User,
Auto,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlanDocument {
#[serde(default = "default_uuid")]
pub id: Uuid,
#[serde(default = "default_uuid")]
pub session_id: Uuid,
#[serde(default)]
pub title: String,
#[serde(default)]
pub description: String,
pub tasks: Vec<PlanTask>,
#[serde(default)]
pub status: PlanStatus,
#[serde(default = "default_now")]
pub created_at: DateTime<Utc>,
#[serde(default = "default_now")]
pub updated_at: DateTime<Utc>,
#[serde(default)]
pub approved_at: Option<DateTime<Utc>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub approval_source: Option<ApprovalSource>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub pre_init_editing: bool,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub pending_approval: bool,
#[serde(default)]
pub working_directory: Option<String>,
}
impl PlanDocument {
pub fn new(session_id: Uuid, title: String) -> Self {
Self {
id: Uuid::new_v4(),
session_id,
title,
description: String::new(),
tasks: Vec::new(),
status: PlanStatus::Editing,
created_at: Utc::now(),
updated_at: Utc::now(),
approved_at: None,
approval_source: None,
pre_init_editing: false,
pending_approval: false,
working_directory: None,
}
}
pub fn add_task(&mut self, task: PlanTask) {
self.tasks.push(task);
self.updated_at = Utc::now();
}
pub fn insert_task(&mut self, index: usize, mut task: PlanTask) {
let insert_idx = index.min(self.tasks.len());
task.order = insert_idx + 1;
self.tasks.insert(insert_idx, task);
for (i, t) in self.tasks.iter_mut().enumerate() {
t.order = i + 1;
}
self.updated_at = Utc::now();
}
pub fn resolve_index_deps(&mut self) {
use std::collections::HashMap;
let mut order_to_id: HashMap<usize, Uuid> = HashMap::new();
for (idx, task) in self.tasks.iter().enumerate() {
let order = if task.order > 0 { task.order } else { idx + 1 };
order_to_id.insert(order, task.id);
}
for task in &mut self.tasks {
let resolved: Vec<TaskDep> = task
.dependencies
.iter()
.map(|dep| {
match dep {
TaskDep::Index(idx) => {
if let Some(uuid) = order_to_id.get(idx) {
TaskDep::Id(*uuid)
} else {
TaskDep::Index(*idx)
}
}
TaskDep::Id(_) => dep.clone(),
}
})
.collect();
task.dependencies = resolved;
}
}
pub fn tasks_in_order(&self) -> Option<Vec<&PlanTask>> {
use std::collections::{HashMap, VecDeque};
let mut in_degree: HashMap<Uuid, usize> = HashMap::new();
let mut dependents: HashMap<Uuid, Vec<Uuid>> = HashMap::new();
for task in &self.tasks {
let uuid_deps: Vec<Uuid> = task
.dependencies
.iter()
.filter_map(|d| d.as_uuid())
.collect();
in_degree.insert(task.id, uuid_deps.len());
for dep_id in &uuid_deps {
dependents.entry(*dep_id).or_default().push(task.id);
}
}
let mut queue: VecDeque<Uuid> = VecDeque::new();
for task in &self.tasks {
if task.dependencies.is_empty() {
queue.push_back(task.id);
}
}
let mut sorted_ids = Vec::new();
while let Some(task_id) = queue.pop_front() {
sorted_ids.push(task_id);
if let Some(deps) = dependents.get(&task_id) {
for &dependent_id in deps {
if let Some(degree) = in_degree.get_mut(&dependent_id) {
*degree -= 1;
if *degree == 0 {
queue.push_back(dependent_id);
}
}
}
}
}
if sorted_ids.len() != self.tasks.len() {
return None; }
let task_map: HashMap<Uuid, &PlanTask> = self.tasks.iter().map(|t| (t.id, t)).collect();
Some(
sorted_ids
.iter()
.filter_map(|id| task_map.get(id).copied())
.collect(),
)
}
pub fn get_task(&self, task_id: &Uuid) -> Option<&PlanTask> {
self.tasks.iter().find(|t| t.id == *task_id)
}
pub fn get_task_mut(&mut self, task_id: &Uuid) -> Option<&mut PlanTask> {
self.updated_at = Utc::now();
self.tasks.iter_mut().find(|t| t.id == *task_id)
}
pub fn count_by_status(&self, status: TaskStatus) -> usize {
self.tasks.iter().filter(|t| t.status == status).count()
}
pub fn progress_percentage(&self) -> f32 {
if self.tasks.is_empty() {
return 0.0;
}
let completed = self.count_by_status(TaskStatus::Completed);
(completed as f32 / self.tasks.len() as f32) * 100.0
}
pub fn is_complete(&self) -> bool {
!self.tasks.is_empty()
&& self
.tasks
.iter()
.all(|t| matches!(t.status, TaskStatus::Completed | TaskStatus::Skipped))
}
pub fn is_active_incomplete(&self) -> bool {
self.status == PlanStatus::Active
&& !self.pre_init_editing
&& !self.tasks.is_empty()
&& !self.is_complete()
}
pub fn complete_trailing_delivery_task(&mut self) -> bool {
let Some(last) = self.tasks.len().checked_sub(1) else {
return false;
};
let head_resolved = self.tasks[..last]
.iter()
.all(|t| matches!(t.status, TaskStatus::Completed | TaskStatus::Skipped));
if head_resolved && matches!(self.tasks[last].status, TaskStatus::Pending) {
if !self.tasks[last].acceptance_criteria.is_empty() {
return false;
}
self.tasks[last].complete(Some(
"Auto-completed at turn settle: the turn delivered its final response (#737)."
.to_string(),
));
return true;
}
false
}
pub fn approve(&mut self, source: ApprovalSource) {
self.status = PlanStatus::Active;
self.approved_at = Some(Utc::now());
self.approval_source = Some(source);
self.updated_at = Utc::now();
}
pub fn start_execution(&mut self) {
self.status = PlanStatus::Active;
self.pending_approval = false;
self.updated_at = Utc::now();
}
pub fn validate_dependencies(&self) -> Result<(), String> {
let task_ids: std::collections::HashSet<Uuid> = self.tasks.iter().map(|t| t.id).collect();
for task in &self.tasks {
for dep in &task.dependencies {
if dep.as_uuid().is_some_and(|id| !task_ids.contains(&id)) {
return Err(format!(
"❌ Invalid Dependency\n\n\
Task '{}' (#{}) depends on a task that doesn't exist.\n\n\
💡 Fix: Remove this dependency or ensure the referenced task is added first.",
task.title, task.order
));
}
}
}
let ordered = self.tasks_in_order();
if ordered.is_none() {
let unprocessed: Vec<&str> = self
.tasks
.iter()
.filter(|task| !task.dependencies.is_empty())
.map(|task| task.title.as_str())
.collect();
return Err(format!(
"❌ Circular Dependency Detected\n\n\
Tasks with dependencies: {}\n\n\
💡 Fix: Review the dependency chain and remove circular references.\n\
Example: If Task A depends on B, B depends on C, and C depends on A,\n\
you need to break one of these dependency links.",
unprocessed.join(", ")
));
}
Ok(())
}
pub fn next_executable_task(&self) -> Option<&PlanTask> {
let completed_ids: std::collections::HashSet<Uuid> = self
.tasks
.iter()
.filter(|t| matches!(t.status, TaskStatus::Completed | TaskStatus::Skipped))
.map(|t| t.id)
.collect();
self.tasks.iter().find(|task| {
matches!(task.status, TaskStatus::Pending)
&& task
.dependencies
.iter()
.all(|dep| dep.as_uuid().is_some_and(|id| completed_ids.contains(&id)))
})
}
pub fn next_executable_task_mut(&mut self) -> Option<&mut PlanTask> {
let completed_ids: std::collections::HashSet<Uuid> = self
.tasks
.iter()
.filter(|t| matches!(t.status, TaskStatus::Completed | TaskStatus::Skipped))
.map(|t| t.id)
.collect();
self.updated_at = Utc::now();
self.tasks.iter_mut().find(|task| {
matches!(task.status, TaskStatus::Pending)
&& task
.dependencies
.iter()
.all(|dep| dep.as_uuid().is_some_and(|id| completed_ids.contains(&id)))
})
}
pub fn get_task_by_order(&self, order: usize) -> Option<&PlanTask> {
self.tasks.iter().find(|t| t.order == order)
}
pub fn get_task_by_order_mut(&mut self, order: usize) -> Option<&mut PlanTask> {
self.updated_at = Utc::now();
self.tasks.iter_mut().find(|t| t.order == order)
}
pub fn dependencies_satisfied(&self, task: &PlanTask) -> bool {
task.dependencies.iter().all(|dep| {
dep.as_uuid()
.and_then(|id| self.get_task(&id))
.map(|dep| matches!(dep.status, TaskStatus::Completed | TaskStatus::Skipped))
.unwrap_or(false)
})
}
pub fn ready_tasks(&self) -> Vec<&PlanTask> {
self.tasks
.iter()
.filter(|task| {
matches!(task.status, TaskStatus::Pending) && self.dependencies_satisfied(task)
})
.collect()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum PlanStatus {
#[default]
Editing,
Active,
}
impl Serialize for PlanStatus {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(match self {
PlanStatus::Editing => "Editing",
PlanStatus::Active => "Active",
})
}
}
impl<'de> Deserialize<'de> for PlanStatus {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let s = String::deserialize(deserializer)?;
Ok(match s.as_str() {
"Active" | "Approved" | "InProgress" | "Completed" => PlanStatus::Active,
_ => PlanStatus::Editing,
})
}
}
impl std::fmt::Display for PlanStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PlanStatus::Editing => write!(f, "Editing"),
PlanStatus::Active => write!(f, "Active"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlanTask {
#[serde(default = "default_uuid")]
pub id: Uuid,
#[serde(default)]
pub order: usize,
pub title: String,
pub description: String,
#[serde(
deserialize_with = "deserialize_task_type",
serialize_with = "serialize_task_type"
)]
pub task_type: TaskType,
#[serde(default, deserialize_with = "deserialize_task_deps")]
pub dependencies: Vec<TaskDep>,
#[serde(default)]
pub complexity: u8,
#[serde(default)]
pub acceptance_criteria: Vec<String>,
#[serde(default)]
pub status: TaskStatus,
#[serde(default)]
pub notes: Option<String>,
#[serde(default)]
pub retry_count: u8,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub verification: Option<VerificationVerdict>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub spec_check: Option<SpecCheck>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scope: Option<TaskScope>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct TaskScope {
#[serde(default)]
pub do_write: Vec<String>,
#[serde(default)]
pub do_not_write: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub do_call: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub do_not_call: Option<Vec<String>>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SpecCheckSource {
#[default]
Criteria,
Generated,
}
impl SpecCheckSource {
pub fn label(&self) -> &'static str {
match self {
SpecCheckSource::Criteria => "criteria",
SpecCheckSource::Generated => "generated",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SpecCheck {
pub commands: Vec<String>,
pub sha256: String,
#[serde(default)]
pub source: SpecCheckSource,
#[serde(default)]
pub confirmed: bool,
}
impl SpecCheck {
pub fn lock(commands: Vec<String>) -> Self {
let sha256 = spec_check_hash(&commands);
Self {
commands,
sha256,
source: SpecCheckSource::Criteria,
confirmed: false,
}
}
pub fn lock_generated(commands: Vec<String>) -> Self {
Self {
source: SpecCheckSource::Generated,
..Self::lock(commands)
}
}
pub fn matches(&self, commands: &[String]) -> bool {
!self.sha256.is_empty() && self.sha256 == spec_check_hash(commands)
}
pub fn may_block(&self) -> bool {
match self.source {
SpecCheckSource::Criteria => true,
SpecCheckSource::Generated => self.confirmed,
}
}
}
pub fn spec_check_hash(commands: &[String]) -> String {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(b"opencrabs.plan.spec_check.v1\0");
for command in commands {
hasher.update((command.len() as u64).to_le_bytes());
hasher.update(command.as_bytes());
}
format!("{:x}", hasher.finalize())
}
impl PlanTask {
pub fn new(order: usize, title: String, description: String, task_type: TaskType) -> Self {
Self {
id: Uuid::new_v4(),
order,
title,
description,
task_type,
dependencies: Vec::new(),
complexity: 3, acceptance_criteria: Vec::new(),
status: TaskStatus::Pending,
notes: None,
retry_count: 0,
verification: None,
spec_check: None,
scope: None,
}
}
pub fn start(&mut self) {
if matches!(self.status, TaskStatus::Failed) {
self.retry_count += 1;
}
self.status = TaskStatus::InProgress;
}
pub fn complete(&mut self, notes: Option<String>) {
self.status = TaskStatus::Completed;
self.notes = notes;
}
pub fn fail(&mut self, reason: String) {
self.status = TaskStatus::Failed;
self.notes = Some(reason);
}
pub fn block(&mut self, reason: String) {
self.status = TaskStatus::Blocked(reason);
}
pub fn skip(&mut self, reason: Option<String>) {
self.status = TaskStatus::Skipped;
if let Some(r) = reason {
self.notes = Some(r);
}
}
pub fn complexity_stars(&self) -> String {
let filled = self.complexity.min(5);
let empty = 5 - filled;
"★".repeat(filled as usize) + &"☆".repeat(empty as usize)
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum TaskType {
Research,
Edit,
Create,
Delete,
Test,
Refactor,
Documentation,
Configuration,
Build,
Other(String),
}
impl std::fmt::Display for TaskType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
TaskType::Research => write!(f, "Research"),
TaskType::Edit => write!(f, "Edit"),
TaskType::Create => write!(f, "Create"),
TaskType::Delete => write!(f, "Delete"),
TaskType::Test => write!(f, "Test"),
TaskType::Refactor => write!(f, "Refactor"),
TaskType::Documentation => write!(f, "Documentation"),
TaskType::Configuration => write!(f, "Configuration"),
TaskType::Build => write!(f, "Build"),
TaskType::Other(s) => write!(f, "{}", s),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub enum TaskStatus {
#[default]
Pending,
InProgress,
Completed,
Skipped,
Failed,
Blocked(String),
}
impl std::fmt::Display for TaskStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
TaskStatus::Pending => write!(f, "Pending"),
TaskStatus::InProgress => write!(f, "In Progress"),
TaskStatus::Completed => write!(f, "Completed"),
TaskStatus::Skipped => write!(f, "Skipped"),
TaskStatus::Failed => write!(f, "Failed"),
TaskStatus::Blocked(reason) => write!(f, "Blocked: {}", reason),
}
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub enum VerificationVerdict {
Verified,
Uncertain,
}
impl VerificationVerdict {
pub fn badge(&self) -> &'static str {
match self {
VerificationVerdict::Verified => "🛡",
VerificationVerdict::Uncertain => "🟡",
}
}
}
pub fn status_mark(status: &TaskStatus) -> char {
match status {
TaskStatus::Completed => '☑',
TaskStatus::Skipped => '⏭',
TaskStatus::InProgress => '▶',
TaskStatus::Pending => '☐',
TaskStatus::Failed => '❌',
TaskStatus::Blocked(_) => '⏸',
}
}
const COMMAND_PATTERNS: &[&str] = &[
"cargo", "git ", "gh ", "npm ", "pnpm ", "make ", "curl ", "pytest", "python", "node ", "grep",
"rg ", "sed ", "awk ", "docker", "psql", "sqlite3", "stat ", "ls ", "wc ",
];
const EXPECTATION_STEMS: &[&str] = &[
"exit",
"pass",
"fail",
"return",
"print",
"output",
"show",
"report",
"match",
"contain",
"clean",
"green",
"no warning",
"0 error",
];
pub fn is_verifiable(criterion: &str) -> bool {
let lower = criterion.to_lowercase();
let has_command = criterion.contains('`') || COMMAND_PATTERNS.iter().any(|p| lower.contains(p));
let stems: Vec<String> = lower
.split(|c: char| !c.is_ascii_alphanumeric())
.map(|w| w.trim_end_matches("es").trim_end_matches('s').to_string())
.collect();
let has_expectation = EXPECTATION_STEMS
.iter()
.any(|p| stems.iter().any(|w| w == p) || lower.contains(p));
has_command && has_expectation
}
pub fn extract_check_commands(criteria: &[String]) -> Vec<String> {
let mut commands: Vec<String> = Vec::new();
for criterion in criteria {
if !is_verifiable(criterion) {
continue;
}
if let Some(command) = extract_one_command(criterion)
&& !commands.contains(&command)
{
commands.push(command);
}
}
commands
}
const COMPOUND_OPERATORS: &[&str] = &["&&", "||", "|", ";", ">", "<", "`", "$(", "&"];
const PROSE_TOKENS: &[&str] = &[
"then", "and", "the", "is", "are", "it", "its", "that", "which", "with", "output", "result",
"when", "should", "must", "to", "of", "in", "for", "on", "no", "not", "be", "has", "have",
"exits", "passes", "fails", "returns", "prints", "shows", "reports", "matches",
];
const SINGLE_TOKEN_OK: &[&str] = &["pytest"];
fn extract_one_command(criterion: &str) -> Option<String> {
let mut rest = criterion;
while let Some(open) = rest.find('`') {
let after = &rest[open + 1..];
let Some(close) = after.find('`') else {
break;
};
let span = after[..close].trim();
if starts_with_runner(span)
&& let Some(command) = accept_candidate(span)
{
return Some(command);
}
rest = &after[close + 1..];
}
let start = command_start_index(criterion)?;
let tail = &criterion[start..];
for cut in expectation_starts(tail).into_iter().chain([tail.len()]) {
let candidate = tail[..cut]
.trim()
.trim_end_matches(&[',', '.', ';', ':'][..])
.trim();
if let Some(command) = accept_candidate(candidate) {
return Some(command);
}
}
None
}
fn starts_with_runner(candidate: &str) -> bool {
let lower = candidate.to_lowercase();
COMMAND_PATTERNS
.iter()
.map(|p| p.trim())
.any(|p| lower.starts_with(p))
}
fn command_start_index(haystack: &str) -> Option<usize> {
let lower = haystack.to_lowercase();
if lower.len() != haystack.len() {
return None;
}
let bytes = lower.as_bytes();
let mut best: Option<usize> = None;
for pattern in COMMAND_PATTERNS.iter().map(|p| p.trim()) {
let mut from = 0;
while let Some(offset) = lower[from..].find(pattern) {
let at = from + offset;
from = at + pattern.len();
let before_ok = at == 0 || !bytes[at - 1].is_ascii_alphanumeric();
let after_ok = match bytes.get(at + pattern.len()).copied() {
None => true,
Some(b) => !b.is_ascii_alphanumeric() || b.is_ascii_digit(),
};
if before_ok && after_ok {
best = Some(best.map_or(at, |seen: usize| seen.min(at)));
}
}
}
best
}
fn expectation_starts(haystack: &str) -> Vec<usize> {
let words = word_runs(haystack);
let mut starts: Vec<usize> = Vec::new();
for stem in EXPECTATION_STEMS {
let parts: Vec<&str> = stem.split_whitespace().collect();
if parts.is_empty() || words.len() < parts.len() {
continue;
}
for window in 0..=(words.len() - parts.len()) {
let hit = parts.iter().enumerate().all(|(k, part)| {
words[window + k]
.1
.trim_end_matches("es")
.trim_end_matches('s')
== *part
});
if hit {
starts.push(words[window].0);
}
}
}
starts.sort_unstable();
starts.dedup();
starts
}
fn word_runs(haystack: &str) -> Vec<(usize, String)> {
let mut words: Vec<(usize, String)> = Vec::new();
let mut start: Option<usize> = None;
for (idx, ch) in haystack.char_indices() {
if ch.is_ascii_alphanumeric() {
start.get_or_insert(idx);
} else if let Some(from) = start.take() {
words.push((from, haystack[from..idx].to_lowercase()));
}
}
if let Some(from) = start {
words.push((from, haystack[from..].to_lowercase()));
}
words
}
fn accept_candidate(candidate: &str) -> Option<String> {
if candidate.is_empty() {
return None;
}
if COMPOUND_OPERATORS
.iter()
.any(|operator| candidate.contains(operator))
{
return None;
}
let tokens: Vec<&str> = candidate.split_whitespace().collect();
let is_prose = tokens.iter().any(|token| {
!token.starts_with('-') && PROSE_TOKENS.contains(&token.to_lowercase().as_str())
});
if is_prose {
return None;
}
if tokens.len() == 1 && !SINGLE_TOKEN_OK.contains(&tokens[0]) {
return None;
}
Some(candidate.to_string())
}
pub fn is_acceptable_check_command(candidate: &str) -> bool {
accept_candidate(candidate.trim()).is_some()
}
pub fn quality_glyph(task: &PlanTask) -> Option<&'static str> {
if !requires_checkable_criteria(&task.task_type) {
return None;
}
if task.acceptance_criteria.is_empty() {
return Some("⚠️");
}
if !task.acceptance_criteria.iter().any(|c| is_verifiable(c)) {
return Some("🔓");
}
None
}
fn requires_checkable_criteria(task_type: &TaskType) -> bool {
matches!(
task_type,
TaskType::Test
| TaskType::Build
| TaskType::Refactor
| TaskType::Edit
| TaskType::Delete
| TaskType::Create
| TaskType::Configuration
)
}
pub fn quality_glyph_suffix(task: &PlanTask) -> String {
quality_glyph(task)
.map(|g| format!(" {g}"))
.unwrap_or_default()
}