//! Dependency analysis for task execution ordering.
//!
//! This module provides functionality to:
//! - Check if a task's dependencies are satisfied before it can start
//! - Auto-detect dependency patterns based on file patterns
//! - Warn about potential dependency violations
use std::collections::HashSet;
use uuid::Uuid;
/// Dependency ordering configuration.
#[derive(Debug, Clone)]
pub struct DependencyOrderingConfig {
/// Enable dependency ordering checks.
pub enabled: bool,
/// Auto-detect dependencies from file patterns.
pub auto_detect: bool,
/// Warn on detected dependency violations.
pub warn_on_violation: bool,
}
impl Default for DependencyOrderingConfig {
fn default() -> Self {
Self {
enabled: true,
auto_detect: true,
warn_on_violation: true,
}
}
}
/// Result of a dependency check.
#[derive(Debug, Clone)]
pub struct DependencyCheckResult {
/// Whether the task can start (all dependencies satisfied).
pub can_start: bool,
/// IDs of tasks that are still pending/running.
pub unmet_dependencies: Vec<Uuid>,
/// Warnings about potential dependency issues.
pub warnings: Vec<String>,
}
impl DependencyCheckResult {
/// Create a result indicating the task can start.
pub fn can_start() -> Self {
Self {
can_start: true,
unmet_dependencies: Vec::new(),
warnings: Vec::new(),
}
}
/// Create a result indicating the task cannot start due to unmet dependencies.
pub fn blocked(unmet: Vec<Uuid>) -> Self {
Self {
can_start: false,
unmet_dependencies: unmet,
warnings: Vec::new(),
}
}
/// Add a warning to the result.
pub fn with_warning(mut self, warning: String) -> Self {
self.warnings.push(warning);
self
}
}
/// File pattern categories for dependency detection.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum FileCategory {
/// Schema/migration files (must run first).
Schema,
/// Type/model definitions.
Types,
/// Backend/API code.
Backend,
/// UI components (must run last).
Ui,
/// Unknown/uncategorized files.
Unknown,
}
impl FileCategory {
/// Determine the category of a file based on its path.
pub fn from_path(path: &str) -> Self {
let path_lower = path.to_lowercase();
// Schema/migration patterns
if path_lower.contains("migration")
|| path_lower.contains("schema")
|| path_lower.ends_with(".sql")
|| path_lower.contains("prisma/schema")
|| path_lower.contains("drizzle")
{
return FileCategory::Schema;
}
// Type/model patterns
if path_lower.contains("/types")
|| path_lower.contains("/models")
|| path_lower.contains("/entities")
|| path_lower.ends_with(".d.ts")
|| path_lower.ends_with("types.ts")
|| path_lower.ends_with("types.rs")
|| path_lower.ends_with("models.rs")
{
return FileCategory::Types;
}
// UI patterns
if path_lower.contains("/components")
|| path_lower.contains("/views")
|| path_lower.contains("/pages")
|| path_lower.contains("/ui/")
|| path_lower.ends_with(".tsx")
|| path_lower.ends_with(".jsx")
|| path_lower.ends_with(".vue")
|| path_lower.ends_with(".svelte")
{
return FileCategory::Ui;
}
// Backend patterns
if path_lower.contains("/api")
|| path_lower.contains("/server")
|| path_lower.contains("/handlers")
|| path_lower.contains("/services")
|| path_lower.contains("/controllers")
|| path_lower.contains("/routes")
|| path_lower.ends_with(".rs")
|| path_lower.ends_with(".go")
|| path_lower.ends_with(".py")
{
return FileCategory::Backend;
}
FileCategory::Unknown
}
/// Get the expected execution order (lower = earlier).
pub fn execution_order(&self) -> u8 {
match self {
FileCategory::Schema => 0,
FileCategory::Types => 1,
FileCategory::Backend => 2,
FileCategory::Ui => 3,
FileCategory::Unknown => 2, // Treat unknown as backend-level
}
}
}
/// Analyze a task's plan to determine what file categories it affects.
pub fn analyze_task_files(plan: &str) -> HashSet<FileCategory> {
let mut categories = HashSet::new();
// Simple heuristic: look for file patterns in the plan text
let patterns_schema = [
"migration", "schema", ".sql", "prisma", "drizzle", "database",
"ALTER TABLE", "CREATE TABLE", "DROP TABLE",
];
let patterns_types = [
"type ", "interface ", "struct ", "model ", "entity",
"types.ts", "types.rs", "models.rs", ".d.ts",
];
let patterns_backend = [
"api", "endpoint", "handler", "controller", "route", "service",
"server", "backend", "REST", "GraphQL",
];
let patterns_ui = [
"component", "view", "page", "ui ", "frontend", "react",
".tsx", ".jsx", ".vue", ".svelte", "button", "form",
];
let plan_lower = plan.to_lowercase();
for pattern in patterns_schema {
if plan_lower.contains(&pattern.to_lowercase()) {
categories.insert(FileCategory::Schema);
break;
}
}
for pattern in patterns_types {
if plan_lower.contains(&pattern.to_lowercase()) {
categories.insert(FileCategory::Types);
break;
}
}
for pattern in patterns_backend {
if plan_lower.contains(&pattern.to_lowercase()) {
categories.insert(FileCategory::Backend);
break;
}
}
for pattern in patterns_ui {
if plan_lower.contains(&pattern.to_lowercase()) {
categories.insert(FileCategory::Ui);
break;
}
}
categories
}
/// Information about a task's status for dependency checking.
#[derive(Debug, Clone)]
pub struct TaskDependencyInfo {
pub id: Uuid,
pub status: String,
pub plan: String,
}
impl TaskDependencyInfo {
/// Check if this task is considered "complete" for dependency purposes.
pub fn is_complete(&self) -> bool {
matches!(self.status.as_str(), "done" | "merged")
}
}
/// Check if a task can start based on its explicit dependencies.
///
/// Returns a `DependencyCheckResult` indicating whether the task can start
/// and listing any unmet dependencies.
pub fn can_start_task(
depends_on: &[Uuid],
dependency_tasks: &[TaskDependencyInfo],
_config: &DependencyOrderingConfig,
) -> DependencyCheckResult {
// If no explicit dependencies, the task can start
if depends_on.is_empty() {
return DependencyCheckResult::can_start();
}
let complete_task_ids: HashSet<Uuid> = dependency_tasks
.iter()
.filter(|t| t.is_complete())
.map(|t| t.id)
.collect();
let unmet: Vec<Uuid> = depends_on
.iter()
.filter(|dep_id| !complete_task_ids.contains(dep_id))
.copied()
.collect();
if unmet.is_empty() {
DependencyCheckResult::can_start()
} else {
DependencyCheckResult::blocked(unmet)
}
}
/// Auto-detect potential dependency violations based on file patterns.
///
/// This analyzes the task's plan and compares it with sibling tasks to warn
/// if execution order might be problematic.
pub fn detect_dependency_violations(
task_plan: &str,
sibling_tasks: &[TaskDependencyInfo],
config: &DependencyOrderingConfig,
) -> Vec<String> {
if !config.auto_detect || !config.warn_on_violation {
return Vec::new();
}
let mut warnings = Vec::new();
let task_categories = analyze_task_files(task_plan);
// Find the minimum execution order this task touches
let task_min_order = task_categories
.iter()
.map(|c| c.execution_order())
.min()
.unwrap_or(u8::MAX);
// Check if any pending/running sibling tasks should run first
for sibling in sibling_tasks {
if sibling.is_complete() {
continue;
}
let sibling_categories = analyze_task_files(&sibling.plan);
let sibling_min_order = sibling_categories
.iter()
.map(|c| c.execution_order())
.min()
.unwrap_or(u8::MAX);
// Warn if this task touches "earlier" categories while sibling touches "later" ones
// and the sibling is still pending
if sibling_min_order < task_min_order {
let sibling_cat_names: Vec<&str> = sibling_categories
.iter()
.map(|c| match c {
FileCategory::Schema => "schema/migrations",
FileCategory::Types => "types/models",
FileCategory::Backend => "backend/API",
FileCategory::Ui => "UI components",
FileCategory::Unknown => "other",
})
.collect();
let task_cat_names: Vec<&str> = task_categories
.iter()
.map(|c| match c {
FileCategory::Schema => "schema/migrations",
FileCategory::Types => "types/models",
FileCategory::Backend => "backend/API",
FileCategory::Ui => "UI components",
FileCategory::Unknown => "other",
})
.collect();
warnings.push(format!(
"Task may depend on sibling task {} which affects {} (this task affects {})",
sibling.id,
sibling_cat_names.join(", "),
task_cat_names.join(", ")
));
}
}
warnings
}
/// Suggest automatic dependencies based on file pattern analysis.
///
/// This analyzes tasks in a contract and suggests which tasks should depend on others
/// based on the Ralph pattern: schema -> types -> backend -> UI.
pub fn suggest_dependencies(
task_plan: &str,
sibling_tasks: &[TaskDependencyInfo],
) -> Vec<Uuid> {
let mut suggested = Vec::new();
let task_categories = analyze_task_files(task_plan);
// Get this task's minimum execution order
let task_min_order = task_categories
.iter()
.map(|c| c.execution_order())
.min()
.unwrap_or(u8::MAX);
// Suggest dependencies on tasks that touch "earlier" categories
for sibling in sibling_tasks {
let sibling_categories = analyze_task_files(&sibling.plan);
let sibling_max_order = sibling_categories
.iter()
.map(|c| c.execution_order())
.max()
.unwrap_or(0);
// If sibling's work is at an earlier stage, suggest it as a dependency
if sibling_max_order < task_min_order {
suggested.push(sibling.id);
}
}
suggested
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_file_category_from_path() {
assert_eq!(
FileCategory::from_path("migrations/001_create_users.sql"),
FileCategory::Schema
);
assert_eq!(
FileCategory::from_path("src/types/user.ts"),
FileCategory::Types
);
assert_eq!(
FileCategory::from_path("src/api/users.rs"),
FileCategory::Backend
);
assert_eq!(
FileCategory::from_path("src/components/UserProfile.tsx"),
FileCategory::Ui
);
}
#[test]
fn test_execution_order() {
assert!(FileCategory::Schema.execution_order() < FileCategory::Types.execution_order());
assert!(FileCategory::Types.execution_order() < FileCategory::Backend.execution_order());
assert!(FileCategory::Backend.execution_order() < FileCategory::Ui.execution_order());
}
#[test]
fn test_analyze_task_files() {
let plan = "Create a new migration to add the users table";
let categories = analyze_task_files(plan);
assert!(categories.contains(&FileCategory::Schema));
let plan = "Implement the UserProfile component with React";
let categories = analyze_task_files(plan);
assert!(categories.contains(&FileCategory::Ui));
}
#[test]
fn test_can_start_task_no_deps() {
let result = can_start_task(&[], &[], &DependencyOrderingConfig::default());
assert!(result.can_start);
assert!(result.unmet_dependencies.is_empty());
}
#[test]
fn test_can_start_task_with_complete_deps() {
let dep_id = Uuid::new_v4();
let dep_task = TaskDependencyInfo {
id: dep_id,
status: "done".to_string(),
plan: "Some completed task".to_string(),
};
let result = can_start_task(
&[dep_id],
&[dep_task],
&DependencyOrderingConfig::default(),
);
assert!(result.can_start);
assert!(result.unmet_dependencies.is_empty());
}
#[test]
fn test_can_start_task_with_pending_deps() {
let dep_id = Uuid::new_v4();
let dep_task = TaskDependencyInfo {
id: dep_id,
status: "running".to_string(),
plan: "Some running task".to_string(),
};
let result = can_start_task(
&[dep_id],
&[dep_task],
&DependencyOrderingConfig::default(),
);
assert!(!result.can_start);
assert_eq!(result.unmet_dependencies, vec![dep_id]);
}
#[test]
fn test_detect_dependency_violations() {
let ui_task_plan = "Create the UserProfile component";
let schema_sibling = TaskDependencyInfo {
id: Uuid::new_v4(),
status: "pending".to_string(),
plan: "Add migration to create users table".to_string(),
};
let warnings = detect_dependency_violations(
ui_task_plan,
&[schema_sibling],
&DependencyOrderingConfig::default(),
);
// Should warn that UI task might depend on schema task
assert!(!warnings.is_empty());
}
#[test]
fn test_suggest_dependencies() {
let schema_task = TaskDependencyInfo {
id: Uuid::new_v4(),
status: "pending".to_string(),
plan: "Add migration to create users table".to_string(),
};
let ui_task_plan = "Create the UserProfile component";
let suggested = suggest_dependencies(ui_task_plan, &[schema_task.clone()]);
// Should suggest schema task as a dependency for UI task
assert!(suggested.contains(&schema_task.id));
}
}