use crate::assets::frontmatter::strip_frontmatter;
use crate::assets::{find_template, list_all_templates};
use crate::context::AppContext;
use crate::context::tsk_config;
use crate::context::tsk_env::TskEnv;
use crate::git::RepoManager;
use crate::git_operations;
use crate::task::Task;
use crate::utils::sanitize_for_branch_name;
use chrono::Local;
use std::collections::HashSet;
use std::error::Error;
use std::path::{Path, PathBuf};
const TASK_ID_ALPHABET: [char; 63] = [
'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S',
'T', 'U', 'V', 'W', 'X', 'Y', 'Z', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l',
'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', '0', '1', '2', '3', '4',
'5', '6', '7', '8', '9', '_',
];
pub struct TaskBuilder {
repo_root: Option<PathBuf>,
name: Option<String>,
task_type: Option<String>,
prompt: Option<String>,
prompt_file_path: Option<PathBuf>,
existing_instructions_file: Option<PathBuf>,
edit: bool,
agent: Option<String>,
stack: Option<String>,
project: Option<String>,
copied_repo_path: Option<PathBuf>,
is_interactive: bool,
parent_id: Option<String>,
network_isolation: bool,
dind: Option<bool>,
privileged: Option<bool>,
sudo: Option<bool>,
devices: Vec<String>,
repo_copy_source: Option<PathBuf>,
branch: Option<String>,
}
impl TaskBuilder {
pub fn new() -> Self {
Self {
repo_root: None,
name: None,
task_type: None,
prompt: None,
prompt_file_path: None,
existing_instructions_file: None,
edit: false,
agent: None,
stack: None,
project: None,
copied_repo_path: None,
is_interactive: false,
parent_id: None,
network_isolation: true,
dind: None,
privileged: None,
sudo: None,
devices: Vec::new(),
repo_copy_source: None,
branch: None,
}
}
pub fn from_existing(task: &Task) -> Self {
let mut builder = Self::new();
builder.repo_root = Some(task.repo_root.clone());
builder.name = Some(task.name.clone());
builder.task_type = Some(task.task_type.clone());
builder.agent = Some(task.agent.clone());
builder.stack = Some(task.stack.clone());
builder.project = Some(task.project.clone());
builder.copied_repo_path = task.copied_repo_path.clone();
builder.is_interactive = task.is_interactive;
builder.network_isolation = task.network_isolation;
builder.dind = Some(task.dind);
if let Some(ref config_json) = task.resolved_config
&& let Ok(config) =
serde_json::from_str::<crate::context::tsk_config::ResolvedConfig>(config_json)
{
builder.privileged = Some(config.privileged);
builder.sudo = Some(config.sudo);
builder.devices = config.devices;
}
builder.existing_instructions_file = Some(PathBuf::from(&task.instructions_file));
builder
}
pub fn repo_root(mut self, repo_root: PathBuf) -> Self {
self.repo_root = Some(repo_root);
self
}
pub fn name(mut self, name: String) -> Self {
self.name = Some(name);
self
}
pub fn task_type(mut self, task_type: String) -> Self {
self.task_type = Some(task_type);
self
}
pub fn prompt(mut self, prompt: Option<String>) -> Self {
self.prompt = prompt;
self
}
pub fn prompt_file(mut self, path: Option<PathBuf>) -> Self {
self.prompt_file_path = path;
self
}
pub fn existing_instructions_file(mut self, path: Option<PathBuf>) -> Self {
self.existing_instructions_file = path;
self
}
pub fn edit(mut self, edit: bool) -> Self {
self.edit = edit;
self
}
pub fn agent(mut self, agent: Option<String>) -> Self {
self.agent = agent;
self
}
pub fn stack(mut self, stack: Option<String>) -> Self {
self.stack = stack;
self
}
pub fn project(mut self, project: Option<String>) -> Self {
self.project = project;
self
}
pub fn with_interactive(mut self, is_interactive: bool) -> Self {
self.is_interactive = is_interactive;
self
}
pub fn parent_id(mut self, task_id: Option<String>) -> Self {
self.parent_id = task_id;
self
}
pub fn network_isolation(mut self, enabled: bool) -> Self {
self.network_isolation = enabled;
self
}
pub fn dind(mut self, enabled: Option<bool>) -> Self {
self.dind = enabled;
self
}
pub fn privileged(mut self, enabled: Option<bool>) -> Self {
self.privileged = enabled;
self
}
pub fn sudo(mut self, enabled: Option<bool>) -> Self {
self.sudo = enabled;
self
}
pub fn devices(mut self, devices: Vec<String>) -> Self {
self.devices = devices;
self
}
pub fn repo_copy_source(mut self, source: Option<PathBuf>) -> Self {
self.repo_copy_source = source;
self
}
pub fn branch(mut self, branch: Option<String>) -> Self {
self.branch = branch;
self
}
pub async fn build(self, ctx: &AppContext) -> Result<Task, Box<dyn Error>> {
let repo_root = self
.repo_root
.clone()
.ok_or("Repository root is required")?;
let name = self.name.clone().ok_or("Task name is required")?;
let task_type = self
.task_type
.clone()
.unwrap_or_else(|| "generic".to_string());
let template_needs_prompt = if task_type != "generic" {
match find_template(&task_type, Some(&repo_root), &ctx.tsk_env()) {
Ok(template_content) => {
let body = strip_frontmatter(&template_content);
body.contains("{{PROMPT}}") || body.contains("{{DESCRIPTION}}")
}
Err(_) => true, }
} else {
true };
if template_needs_prompt
&& self.prompt.is_none()
&& self.prompt_file_path.is_none()
&& self.existing_instructions_file.is_none()
&& !self.edit
{
return Err("Either prompt or prompt file must be provided, or use edit mode".into());
}
if self.edit && !ctx.interactive() {
return Err("--edit requires an interactive terminal (stdin is not a TTY)".into());
}
let project = match self.project {
Some(ref p) => p.clone(),
None => match crate::repository::detect_project_name(&repo_root).await {
Ok(detected) => detected,
Err(e) => {
eprintln!("Warning: Failed to detect project name: {e}. Using default.");
"default".to_string()
}
},
};
let tsk_config = ctx.tsk_config();
let project_config = tsk_config::load_project_config(&repo_root);
let mut resolved =
tsk_config.resolve_config(&project, project_config.as_ref(), Some(&repo_root));
let agent = tsk_config::resolve_agent(self.agent.clone(), &resolved);
if !crate::agent::AgentProvider::is_valid_agent(&agent) {
let available_agents = crate::agent::AgentProvider::list_agents().join(", ");
return Err(
format!("Unknown agent '{agent}'. Available agents: {available_agents}").into(),
);
}
if task_type != "generic" {
let available_templates = list_all_templates(Some(&repo_root), &ctx.tsk_env());
if !available_templates.contains(&task_type.to_string()) {
return Err(format!(
"No template found for task type '{}'. Available templates: {}",
task_type,
available_templates.join(", ")
)
.into());
}
}
let source_info_path = if self.branch.is_some() {
repo_root.clone()
} else {
self.repo_copy_source.as_ref().unwrap_or(&repo_root).clone()
};
let has_commits = match git_operations::get_current_commit(&source_info_path).await {
Ok(_) => true,
Err(e) => {
if e.contains("Failed to get HEAD") || e.contains("reference 'refs/heads") {
false
} else {
return Err(format!("Failed to check repository status: {e}").into());
}
}
};
if !has_commits {
return Err(
format!(
"Cannot create task in an empty git repository.\n\n\
The repository at '{}' has no commits. TSK needs at least one commit to create a branch and track changes.\n\n\
To fix this, create an initial commit:\n \
git commit --allow-empty -m \"Initial commit\"\n\n\
Then try running your TSK command again.",
repo_root.display()
).into()
);
}
let now = Local::now();
let created_at = now;
let id = nanoid::nanoid!(8, &TASK_ID_ALPHABET);
let task_dir_name = id.clone();
let task_dir = ctx.tsk_env().task_dir(&task_dir_name);
crate::file_system::create_dir(&task_dir).await?;
let output_dir = task_dir.join("output");
crate::file_system::create_dir(&output_dir).await?;
let instructions_path = if self.edit {
let temp_filename = format!(".tsk-edit-{task_dir_name}-instructions.md");
let temp_path = repo_root.join(&temp_filename);
self.write_instructions_content(&temp_path, &task_type, ctx)
.await?;
let tsk_env = ctx.tsk_env();
self.open_editor(temp_path.to_str().ok_or("Invalid path")?, &tsk_env)?;
let needs_cleanup = self.check_instructions_not_empty(&temp_path).await.is_err();
if needs_cleanup {
let _ = crate::file_system::remove_file(&temp_path).await;
let _ = crate::file_system::remove_dir(&task_dir).await;
return Err("Instructions file is empty. Task creation cancelled.".into());
}
let final_path = task_dir.join("instructions.md");
let content = crate::file_system::read_file(&temp_path).await?;
crate::file_system::write_file(&final_path, &content).await?;
crate::file_system::remove_file(&temp_path).await?;
final_path.to_string_lossy().to_string()
} else {
let dest_path = task_dir.join("instructions.md");
self.write_instructions_content(&dest_path, &task_type, ctx)
.await?
};
let (source_commit, source_branch) = if let Some(ref branch) = self.branch {
let commit = git_operations::resolve_branch_commit(&repo_root, branch)
.await
.map_err(|e| -> Box<dyn Error> { e.into() })?;
(commit, Some(branch.clone()))
} else {
let source_commit = match git_operations::get_current_commit(&source_info_path).await {
Ok(commit) => commit,
Err(e) => {
return Err(
format!("Failed to get current commit for task '{name}': {e}").into(),
);
}
};
let source_branch = git_operations::get_current_branch(&source_info_path)
.await
.ok()
.flatten();
(source_commit, source_branch)
};
let stack = tsk_config::resolve_stack(
self.stack,
&tsk_config,
&project,
project_config.as_ref(),
&repo_root,
)
.await;
let dind = self.dind.unwrap_or(resolved.dind);
resolved.privileged = self.privileged.unwrap_or(resolved.privileged);
resolved.sudo = self.sudo.unwrap_or(resolved.sudo);
for device in &self.devices {
if !resolved.devices.contains(device) {
resolved.devices.push(device.clone());
}
}
let sanitized_task_type = sanitize_for_branch_name(&task_type);
let sanitized_name = sanitize_for_branch_name(&name);
let branch_name = format!("tsk/{sanitized_task_type}/{sanitized_name}/{id}");
if let Some(ref pid) = self.parent_id {
let storage = ctx.task_storage();
let tasks = storage.list_tasks().await.map_err(|e| e.to_string())?;
let parent_task = tasks.iter().find(|t| t.id == *pid);
if parent_task.is_none() {
return Err(format!(
"Parent task '{}' not found. Please specify a valid task ID.",
pid
)
.into());
}
let mut visited = HashSet::new();
visited.insert(id.clone());
let mut current_id = Some(pid.clone());
while let Some(check_id) = current_id {
if visited.contains(&check_id) {
return Err(format!(
"Circular parent chain detected: task '{}' would create a cycle",
pid
)
.into());
}
visited.insert(check_id.clone());
current_id = tasks
.iter()
.find(|t| t.id == check_id)
.and_then(|t| t.parent_ids.first().cloned());
}
}
let has_parent = self.parent_id.is_some();
let copied_repo_path = if has_parent {
None
} else {
let copy_source = if self.branch.is_some() {
&repo_root
} else {
self.repo_copy_source.as_ref().unwrap_or(&repo_root)
};
let copy_mode = if self.branch.is_some() {
crate::git::CopyMode::CommittedOnly
} else {
crate::git::CopyMode::WorkingTree
};
let repo_manager = RepoManager::new(ctx);
let copy_result = repo_manager
.copy_repo(
&task_dir_name,
copy_source,
Some(&source_commit),
&branch_name,
copy_mode,
)
.await
.map_err(|e| format!("Failed to copy repository: {e}"))?;
for warning in ©_result.warnings {
eprintln!("{warning}");
}
Some(copy_result.repo_path)
};
let effective_source_branch = if has_parent { None } else { source_branch };
let resolved_config_json = serde_json::to_string(&resolved)
.map_err(|e| format!("Failed to serialize resolved config: {e}"))?;
let task = Task::new(
id,
repo_root,
name,
task_type,
instructions_path,
agent,
branch_name,
source_commit,
effective_source_branch,
stack,
project,
created_at,
copied_repo_path,
self.is_interactive,
self.parent_id.into_iter().collect::<Vec<String>>(),
self.network_isolation,
dind,
Some(resolved_config_json),
);
Ok(task)
}
async fn write_instructions_content(
&self,
dest_path: &Path,
task_type: &str,
ctx: &AppContext,
) -> Result<String, Box<dyn Error>> {
if let Some(ref file_path) = self.existing_instructions_file {
let content = crate::file_system::read_file(file_path).await?;
crate::file_system::write_file(dest_path, &content).await?;
} else {
let effective_prompt = if let Some(ref prompt_text) = self.prompt {
Some(prompt_text.clone())
} else if let Some(ref file_path) = self.prompt_file_path {
let content = crate::file_system::read_file(file_path).await?;
let trimmed = content.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
} else {
None
};
if let Some(ref prompt_text) = effective_prompt {
let content = if task_type != "generic" {
match find_template(task_type, self.repo_root.as_deref(), &ctx.tsk_env()) {
Ok(template_content) => {
let body = strip_frontmatter(&template_content);
let mut content = body.replace("{{PROMPT}}", prompt_text);
if body.contains("{{DESCRIPTION}}") {
eprintln!(
"Warning: {{{{DESCRIPTION}}}} placeholder is deprecated. Use {{{{PROMPT}}}} instead."
);
content = content.replace("{{DESCRIPTION}}", prompt_text);
}
content
}
Err(e) => {
eprintln!("Warning: Failed to read template: {e}");
prompt_text.clone()
}
}
} else {
prompt_text.clone()
};
crate::file_system::write_file(dest_path, &content).await?;
} else {
let initial_content = if task_type != "generic" {
match find_template(task_type, self.repo_root.as_deref(), &ctx.tsk_env()) {
Ok(template_content) => {
let body = strip_frontmatter(&template_content);
if self.edit
&& (body.contains("{{PROMPT}}") || body.contains("{{DESCRIPTION}}"))
{
let content = body.replace(
"{{PROMPT}}",
"<!-- TODO: Add your task prompt here -->",
);
if body.contains("{{DESCRIPTION}}") {
eprintln!(
"Warning: {{{{DESCRIPTION}}}} placeholder is deprecated. Use {{{{PROMPT}}}} instead."
);
}
content.replace(
"{{DESCRIPTION}}",
"<!-- TODO: Add your task prompt here -->",
)
} else {
body.to_string()
}
}
Err(_) => String::new(),
}
} else {
String::new()
};
crate::file_system::write_file(dest_path, &initial_content).await?;
}
}
Ok(dest_path.to_string_lossy().to_string())
}
fn open_editor(&self, instructions_path: &str, tsk_env: &TskEnv) -> Result<(), Box<dyn Error>> {
let editor = tsk_env.editor();
let status = std::process::Command::new(editor)
.arg(instructions_path)
.status()?;
if !status.success() {
return Err("Editor exited with non-zero status".into());
}
Ok(())
}
async fn check_instructions_not_empty(
&self,
instructions_path: &Path,
) -> Result<(), Box<dyn Error>> {
let content = crate::file_system::read_file(instructions_path).await?;
if content.trim().is_empty() {
return Err("Instructions file is empty. Task creation cancelled.".into());
}
Ok(())
}
}
impl Default for TaskBuilder {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::context::AppContext;
async fn create_test_context() -> (crate::test_utils::TestGitRepository, AppContext) {
use crate::test_utils::TestGitRepository;
let test_repo = TestGitRepository::new().unwrap();
test_repo.init_with_commit().unwrap();
let ctx = AppContext::builder().build();
(test_repo, ctx)
}
async fn create_basic_task(name: &str, prompt: &str) -> Task {
let (test_repo, ctx) = create_test_context().await;
let current_dir = test_repo.path().to_path_buf();
TaskBuilder::new()
.repo_root(current_dir)
.name(name.to_string())
.task_type("generic".to_string())
.prompt(Some(prompt.to_string()))
.build(&ctx)
.await
.unwrap()
}
async fn verify_instructions_content(ctx: &AppContext, task: &Task, expected_content: &str) {
let instructions_path = ctx
.tsk_env()
.data_dir()
.join("tasks")
.join(&task.id)
.join(&task.instructions_file);
let content = crate::file_system::read_file(&instructions_path)
.await
.unwrap();
assert!(content.contains(expected_content));
}
#[tokio::test]
async fn test_task_builder_basic() {
let task = create_basic_task("test-task", "Test description").await;
assert_eq!(task.name, "test-task");
assert_eq!(task.task_type, "generic");
assert!(!task.instructions_file.is_empty());
assert_eq!(task.id.len(), 8);
assert!(
task.id
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_'),
"Task ID should only contain [A-Za-z0-9_], got: {}",
task.id
);
}
#[tokio::test]
async fn test_task_builder_with_custom_properties() {
let (test_repo, ctx) = create_test_context().await;
let current_dir = test_repo.path().to_path_buf();
let task = TaskBuilder::new()
.repo_root(current_dir)
.name("custom-task".to_string())
.task_type("feat".to_string())
.prompt(Some("Custom description".to_string()))
.stack(Some("rust".to_string()))
.project(Some("web-api".to_string()))
.build(&ctx)
.await
.unwrap();
assert_eq!(task.name, "custom-task");
assert_eq!(task.task_type, "feat");
assert_eq!(task.stack, "rust");
assert_eq!(task.project, "web-api");
}
#[tokio::test]
async fn test_task_builder_with_template() {
use crate::test_utils::TestGitRepository;
let test_repo = TestGitRepository::new().unwrap();
test_repo.init_with_commit().unwrap();
let current_dir = test_repo.path().to_path_buf();
let template_content =
"---\ndescription: A feature template\n---\n# Feature Template\n\n{{PROMPT}}";
test_repo
.create_file(".tsk/templates/feat.md", template_content)
.unwrap();
let ctx = AppContext::builder().build();
let task = TaskBuilder::new()
.repo_root(current_dir)
.name("test-feature".to_string())
.task_type("feat".to_string())
.prompt(Some("My new feature".to_string()))
.build(&ctx)
.await
.unwrap();
assert_eq!(task.task_type, "feat");
verify_instructions_content(&ctx, &task, "Feature Template").await;
verify_instructions_content(&ctx, &task, "My new feature").await;
let instructions_path = ctx
.tsk_env()
.data_dir()
.join("tasks")
.join(&task.id)
.join(&task.instructions_file);
let content = crate::file_system::read_file(&instructions_path)
.await
.unwrap();
assert!(
!content.contains("description: A feature template"),
"Frontmatter should be stripped from instructions"
);
}
#[tokio::test]
async fn test_deprecated_description_placeholder_still_works() {
use crate::test_utils::TestGitRepository;
let test_repo = TestGitRepository::new().unwrap();
test_repo.init_with_commit().unwrap();
let current_dir = test_repo.path().to_path_buf();
let template_content =
"---\ndescription: A legacy template\n---\n# Legacy Template\n\n{{DESCRIPTION}}";
test_repo
.create_file(".tsk/templates/legacy.md", template_content)
.unwrap();
let ctx = AppContext::builder().build();
let task = TaskBuilder::new()
.repo_root(current_dir)
.name("legacy-test".to_string())
.task_type("legacy".to_string())
.prompt(Some("My prompt text".to_string()))
.build(&ctx)
.await
.unwrap();
verify_instructions_content(&ctx, &task, "My prompt text").await;
let instructions_path = ctx
.tsk_env()
.data_dir()
.join("tasks")
.join(&task.id)
.join(&task.instructions_file);
let content = crate::file_system::read_file(&instructions_path)
.await
.unwrap();
assert!(
!content.contains("{{DESCRIPTION}}"),
"Deprecated placeholder should be replaced"
);
}
#[tokio::test]
async fn test_task_builder_validation_errors() {
let ctx = AppContext::builder().build();
let non_git_dir = ctx.tsk_env().data_dir().to_path_buf();
let result = TaskBuilder::new()
.name("test".to_string())
.build(&ctx)
.await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("Repository root"));
let result = TaskBuilder::new()
.repo_root(non_git_dir.clone())
.build(&ctx)
.await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("Task name"));
let result = TaskBuilder::new()
.repo_root(non_git_dir)
.name("test".to_string())
.build(&ctx)
.await;
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("Either prompt or prompt file")
);
}
#[tokio::test]
async fn test_task_builder_with_prompt_file() {
let (test_repo, ctx) = create_test_context().await;
let current_dir = test_repo.path().to_path_buf();
let instructions_content = "# Instructions for task\n\nDetailed steps here.";
let instructions_path = current_dir.join("task-instructions.md");
crate::file_system::write_file(&instructions_path, instructions_content)
.await
.unwrap();
let task = TaskBuilder::new()
.repo_root(current_dir)
.name("file-task".to_string())
.prompt_file(Some(instructions_path))
.build(&ctx)
.await
.unwrap();
verify_instructions_content(&ctx, &task, "Instructions for task").await;
verify_instructions_content(&ctx, &task, "Detailed steps here").await;
}
#[tokio::test]
async fn test_task_builder_branch_name_generation() {
let task = create_basic_task("my-feature-name", "Description").await;
assert!(task.branch_name.starts_with("tsk/generic/my-feature-name/"));
assert_eq!(task.branch_name.split('/').count(), 4);
}
#[tokio::test]
async fn test_task_builder_captures_source_commit() {
use crate::test_utils::TestGitRepository;
let test_repo = TestGitRepository::new().unwrap();
let initial_commit = test_repo.init_with_commit().unwrap();
test_repo.create_file("new_file.txt", "content").unwrap();
test_repo.stage_all().unwrap();
let current_commit = test_repo.commit("Add new file").unwrap();
let ctx = AppContext::builder().build();
let task = TaskBuilder::new()
.repo_root(test_repo.path().to_path_buf())
.name("test-task".to_string())
.prompt(Some("Test".to_string()))
.build(&ctx)
.await
.unwrap();
assert_eq!(task.source_commit, current_commit);
assert_ne!(task.source_commit, initial_commit);
}
#[tokio::test]
async fn test_task_builder_interactive_mode() {
let (test_repo, ctx) = create_test_context().await;
let task = TaskBuilder::new()
.repo_root(test_repo.path().to_path_buf())
.name("interactive-task".to_string())
.prompt(Some("Test".to_string()))
.with_interactive(true)
.build(&ctx)
.await
.unwrap();
assert!(task.is_interactive);
let task2 = TaskBuilder::new()
.repo_root(test_repo.path().to_path_buf())
.name("regular-task".to_string())
.prompt(Some("Test".to_string()))
.build(&ctx)
.await
.unwrap();
assert!(!task2.is_interactive);
}
#[tokio::test]
async fn test_task_builder_repository_copy() {
use crate::test_utils::TestGitRepository;
let test_repo = TestGitRepository::new().unwrap();
test_repo.init_with_commit().unwrap();
test_repo
.create_file("src/main.rs", "fn main() {}")
.unwrap();
test_repo
.create_file("Cargo.toml", "[package]\nname = \"test\"")
.unwrap();
test_repo.stage_all().unwrap();
test_repo.commit("Add source files").unwrap();
let ctx = AppContext::builder().build();
let task = TaskBuilder::new()
.repo_root(test_repo.path().to_path_buf())
.name("copy-test".to_string())
.prompt(Some("Test repository copy".to_string()))
.build(&ctx)
.await
.unwrap();
let task_dir = ctx.tsk_env().task_dir(&task.id);
let copied_repo = task_dir.join("repo");
assert!(copied_repo.exists());
assert!(copied_repo.join("src/main.rs").exists());
assert!(copied_repo.join("Cargo.toml").exists());
assert!(copied_repo.join(".git").exists());
}
#[tokio::test]
async fn test_task_builder_creates_output_directory() {
use crate::test_utils::TestGitRepository;
let test_repo = TestGitRepository::new().unwrap();
test_repo.init_with_commit().unwrap();
let ctx = AppContext::builder().build();
let task = TaskBuilder::new()
.repo_root(test_repo.path().to_path_buf())
.name("output-test".to_string())
.prompt(Some("Test output directory creation".to_string()))
.build(&ctx)
.await
.unwrap();
let task_dir = ctx.tsk_env().task_dir(&task.id);
let output_dir = task_dir.join("output");
assert!(output_dir.exists());
assert!(output_dir.is_dir());
}
#[tokio::test]
async fn test_task_builder_rejects_empty_repository() {
use crate::test_utils::TestGitRepository;
let test_repo = TestGitRepository::new().unwrap();
test_repo.init().unwrap(); let repo_path = test_repo.path().to_path_buf();
let ctx = AppContext::builder().build();
let result = TaskBuilder::new()
.repo_root(repo_path.clone())
.name("test-task".to_string())
.task_type("generic".to_string())
.prompt(Some("Test description".to_string()))
.build(&ctx)
.await;
assert!(
result.is_err(),
"Task creation should fail on empty repository"
);
let error_message = result.unwrap_err().to_string();
assert!(
error_message.contains("empty git repository"),
"Error should mention empty repository, got: {error_message}"
);
assert!(
error_message.contains("no commits"),
"Error should mention no commits, got: {error_message}"
);
assert!(
error_message.contains("git commit --allow-empty"),
"Error should provide the command to fix it, got: {error_message}"
);
assert!(
error_message.contains(&repo_path.display().to_string()),
"Error should show repository path, got: {error_message}"
);
let task_storage = ctx.task_storage();
let all_tasks = task_storage.list_tasks().await.unwrap();
assert!(
all_tasks.is_empty(),
"No tasks should exist after failed creation"
);
}
#[tokio::test]
async fn test_cli_flags_override_project_config() {
use crate::context::{SharedConfig, TskConfig};
use crate::test_utils::TestGitRepository;
use std::collections::HashMap;
let test_repo = TestGitRepository::new().unwrap();
test_repo.init_with_commit().unwrap();
let repo_path = test_repo.path().to_path_buf();
let project_name = "test-project".to_string();
let mut project_configs = HashMap::new();
project_configs.insert(
project_name.clone(),
SharedConfig {
agent: Some("no-op".to_string()),
stack: Some("python".to_string()),
..Default::default()
},
);
let tsk_config = TskConfig {
project: project_configs,
..Default::default()
};
let ctx = AppContext::builder().with_tsk_config(tsk_config).build();
let task = TaskBuilder::new()
.repo_root(repo_path)
.name("cli-override-test".to_string())
.prompt(Some("Test".to_string()))
.project(Some(project_name))
.agent(Some("claude".to_string()))
.stack(Some("rust".to_string()))
.build(&ctx)
.await
.unwrap();
assert_eq!(
task.agent, "claude",
"CLI agent should override project config"
);
assert_eq!(
task.stack, "rust",
"CLI stack should override project config"
);
}
#[tokio::test]
async fn test_project_config_overrides_auto_detect() {
use crate::context::{SharedConfig, TskConfig};
use crate::test_utils::TestGitRepository;
use std::collections::HashMap;
let test_repo = TestGitRepository::new().unwrap();
test_repo.init_with_commit().unwrap();
let repo_path = test_repo.path().to_path_buf();
test_repo
.create_file("Cargo.toml", "[package]\nname = \"test\"")
.unwrap();
let project_name = "test-project".to_string();
let mut project_configs = HashMap::new();
project_configs.insert(
project_name.clone(),
SharedConfig {
agent: Some("no-op".to_string()),
stack: Some("python".to_string()),
..Default::default()
},
);
let tsk_config = TskConfig {
project: project_configs,
..Default::default()
};
let ctx = AppContext::builder().with_tsk_config(tsk_config).build();
let task = TaskBuilder::new()
.repo_root(repo_path)
.name("config-override-test".to_string())
.prompt(Some("Test".to_string()))
.project(Some(project_name))
.build(&ctx)
.await
.unwrap();
assert_eq!(task.agent, "no-op", "Project config agent should be used");
assert_eq!(
task.stack, "python",
"Project config stack should override auto-detect"
);
}
#[tokio::test]
async fn test_partial_project_config_uses_defaults_for_missing() {
use crate::context::{SharedConfig, TskConfig};
use crate::test_utils::TestGitRepository;
use std::collections::HashMap;
let test_repo = TestGitRepository::new().unwrap();
test_repo.init_with_commit().unwrap();
let repo_path = test_repo.path().to_path_buf();
test_repo
.create_file("Cargo.toml", "[package]\nname = \"test\"")
.unwrap();
let project_name = "test-project".to_string();
let mut project_configs = HashMap::new();
project_configs.insert(
project_name.clone(),
SharedConfig {
agent: Some("no-op".to_string()),
..Default::default()
},
);
let tsk_config = TskConfig {
project: project_configs,
..Default::default()
};
let ctx = AppContext::builder().with_tsk_config(tsk_config).build();
let task = TaskBuilder::new()
.repo_root(repo_path)
.name("partial-config-test".to_string())
.prompt(Some("Test".to_string()))
.project(Some(project_name))
.build(&ctx)
.await
.unwrap();
assert_eq!(task.agent, "no-op", "Project config agent should be used");
assert_eq!(
task.stack, "rust",
"Stack should be auto-detected when not in config"
);
}
#[tokio::test]
async fn test_no_project_config_uses_defaults() {
use crate::test_utils::TestGitRepository;
let test_repo = TestGitRepository::new().unwrap();
test_repo.init_with_commit().unwrap();
let repo_path = test_repo.path().to_path_buf();
test_repo
.create_file("Cargo.toml", "[package]\nname = \"test\"")
.unwrap();
let ctx = AppContext::builder().build();
let task = TaskBuilder::new()
.repo_root(repo_path)
.name("no-config-test".to_string())
.prompt(Some("Test".to_string()))
.build(&ctx)
.await
.unwrap();
assert_eq!(task.agent, "claude", "Agent should use default");
assert_eq!(task.stack, "rust", "Stack should be auto-detected");
}
#[tokio::test]
async fn test_dind_config_resolution_chain() {
use crate::context::{SharedConfig, TskConfig};
use crate::test_utils::TestGitRepository;
use std::collections::HashMap;
let test_repo = TestGitRepository::new().unwrap();
test_repo.init_with_commit().unwrap();
let repo_path = test_repo.path().to_path_buf();
let project_name = "test-project".to_string();
let tsk_config = TskConfig {
defaults: SharedConfig {
dind: Some(true),
..Default::default()
},
..Default::default()
};
let ctx = AppContext::builder().with_tsk_config(tsk_config).build();
let task = TaskBuilder::new()
.repo_root(repo_path.clone())
.name("dind-docker-default".to_string())
.prompt(Some("Test".to_string()))
.build(&ctx)
.await
.unwrap();
assert!(task.dind, "defaults.dind = true should propagate");
let mut project_configs = HashMap::new();
project_configs.insert(
project_name.clone(),
SharedConfig {
dind: Some(false),
..Default::default()
},
);
let tsk_config = TskConfig {
defaults: SharedConfig {
dind: Some(true),
..Default::default()
},
project: project_configs,
..Default::default()
};
let ctx = AppContext::builder().with_tsk_config(tsk_config).build();
let task = TaskBuilder::new()
.repo_root(repo_path.clone())
.name("dind-project-override".to_string())
.prompt(Some("Test".to_string()))
.project(Some(project_name.clone()))
.build(&ctx)
.await
.unwrap();
assert!(
!task.dind,
"project.dind = false should override defaults.dind = true"
);
let mut project_configs = HashMap::new();
project_configs.insert(
project_name.clone(),
SharedConfig {
dind: Some(false),
..Default::default()
},
);
let tsk_config = TskConfig {
project: project_configs,
..Default::default()
};
let ctx = AppContext::builder().with_tsk_config(tsk_config).build();
let task = TaskBuilder::new()
.repo_root(repo_path.clone())
.name("dind-cli-override".to_string())
.prompt(Some("Test".to_string()))
.project(Some(project_name))
.dind(Some(true))
.build(&ctx)
.await
.unwrap();
assert!(task.dind, "CLI --dind should override project.dind = false");
}
#[tokio::test]
async fn test_task_builder_with_valid_parent() {
use crate::test_utils::TestGitRepository;
let test_repo = TestGitRepository::new().unwrap();
test_repo.init_with_commit().unwrap();
let repo_path = test_repo.path().to_path_buf();
let ctx = AppContext::builder().build();
let parent_task = TaskBuilder::new()
.repo_root(repo_path.clone())
.name("parent-task".to_string())
.prompt(Some("Parent task".to_string()))
.build(&ctx)
.await
.unwrap();
let storage = ctx.task_storage();
storage.add_task(parent_task.clone()).await.unwrap();
let child_task = TaskBuilder::new()
.repo_root(repo_path)
.name("child-task".to_string())
.prompt(Some("Child task".to_string()))
.parent_id(Some(parent_task.id.clone()))
.build(&ctx)
.await
.unwrap();
assert_eq!(child_task.parent_ids, vec![parent_task.id]);
assert!(
child_task.copied_repo_path.is_none(),
"Child task should have no copied_repo_path"
);
assert!(
child_task.source_branch.is_none(),
"Child task should have no source_branch initially"
);
}
#[tokio::test]
async fn test_task_builder_invalid_parent_not_found() {
use crate::test_utils::TestGitRepository;
let test_repo = TestGitRepository::new().unwrap();
test_repo.init_with_commit().unwrap();
let repo_path = test_repo.path().to_path_buf();
let ctx = AppContext::builder().build();
let result = TaskBuilder::new()
.repo_root(repo_path)
.name("orphan-task".to_string())
.prompt(Some("Orphan task".to_string()))
.parent_id(Some("nonexistent-task-id".to_string()))
.build(&ctx)
.await;
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("not found"),
"Expected 'not found' error, got: {err_msg}"
);
assert!(
err_msg.contains("nonexistent-task-id"),
"Error should mention the invalid task ID, got: {err_msg}"
);
}
#[tokio::test]
async fn test_task_builder_circular_parent_chain_detection() {
use crate::test_utils::TestGitRepository;
let test_repo = TestGitRepository::new().unwrap();
test_repo.init_with_commit().unwrap();
let repo_path = test_repo.path().to_path_buf();
let ctx = AppContext::builder().build();
let storage = ctx.task_storage();
let task_a = TaskBuilder::new()
.repo_root(repo_path.clone())
.name("task-a".to_string())
.prompt(Some("Task A".to_string()))
.build(&ctx)
.await
.unwrap();
storage.add_task(task_a.clone()).await.unwrap();
let task_b = TaskBuilder::new()
.repo_root(repo_path.clone())
.name("task-b".to_string())
.prompt(Some("Task B".to_string()))
.parent_id(Some(task_a.id.clone()))
.build(&ctx)
.await
.unwrap();
storage.add_task(task_b.clone()).await.unwrap();
let task_c = TaskBuilder::new()
.repo_root(repo_path.clone())
.name("task-c".to_string())
.prompt(Some("Task C".to_string()))
.parent_id(Some(task_b.id.clone()))
.build(&ctx)
.await
.unwrap();
storage.add_task(task_c.clone()).await.unwrap();
assert!(task_a.parent_ids.is_empty());
assert_eq!(task_b.parent_ids, vec![task_a.id.clone()]);
assert_eq!(task_c.parent_ids, vec![task_b.id.clone()]);
}
#[tokio::test]
async fn test_task_builder_populates_resolved_config() {
use crate::context::ResolvedConfig;
let task = create_basic_task("config-test", "Test config snapshotting").await;
assert!(
task.resolved_config.is_some(),
"Task should have resolved_config set at creation"
);
let config: ResolvedConfig =
serde_json::from_str(task.resolved_config.as_ref().unwrap()).unwrap();
assert_eq!(config.agent, "claude", "Default agent should be claude");
}
#[tokio::test]
async fn test_task_builder_from_worktree() {
use crate::test_utils::{ExistingGitRepository, TestGitRepository};
let main_repo = TestGitRepository::new().unwrap();
main_repo.init_with_main_branch().unwrap();
main_repo
.create_file("main-file.txt", "main content")
.unwrap();
main_repo.stage_all().unwrap();
main_repo.commit("Add main-file.txt").unwrap();
let worktree_tmp = tempfile::TempDir::new().unwrap();
let worktree_dir = worktree_tmp.path().join("worktree");
main_repo
.run_git_command(&[
"worktree",
"add",
worktree_dir.to_str().unwrap(),
"-b",
"branch-a",
])
.unwrap();
let worktree_repo = ExistingGitRepository::new(&worktree_dir).unwrap();
worktree_repo.configure_test_user().unwrap();
std::fs::write(worktree_dir.join("branch-a-file.txt"), "branch-a content").unwrap();
worktree_repo.stage_all().unwrap();
let branch_a_commit = worktree_repo.commit("Add branch-a-file.txt").unwrap();
let ctx = AppContext::builder().build();
let canonical_main = main_repo.path().canonicalize().unwrap();
let task = TaskBuilder::new()
.repo_root(canonical_main.clone())
.name("worktree-test".to_string())
.prompt(Some("Test from worktree".to_string()))
.repo_copy_source(Some(worktree_dir.clone()))
.build(&ctx)
.await
.unwrap();
assert_eq!(task.repo_root, canonical_main);
assert_eq!(task.source_branch, Some("branch-a".to_string()));
assert_eq!(task.source_commit, branch_a_commit);
let task_dir = ctx.tsk_env().task_dir(&task.id);
let copied_repo = task_dir.join("repo");
assert!(
copied_repo.join("branch-a-file.txt").exists(),
"Copied repo should contain branch-a-file.txt from the worktree"
);
assert!(
copied_repo.join("main-file.txt").exists(),
"Copied repo should contain main-file.txt inherited from main"
);
std::fs::remove_dir_all(&worktree_dir).ok();
main_repo.run_git_command(&["worktree", "prune"]).ok();
}
#[tokio::test]
async fn test_edit_requires_interactive_terminal() {
use crate::test_utils::TestGitRepository;
let test_repo = TestGitRepository::new().unwrap();
test_repo.init_with_main_branch().unwrap();
let repo_path = test_repo.path().to_path_buf();
let ctx = AppContext::builder().with_interactive(false).build();
let result = TaskBuilder::new()
.repo_root(repo_path)
.name("edit-test".to_string())
.task_type("generic".to_string())
.edit(true)
.build(&ctx)
.await;
assert!(
result.is_err(),
"--edit should fail in non-interactive mode"
);
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("interactive terminal"),
"Error should mention interactive terminal, got: {err_msg}"
);
}
#[tokio::test]
async fn test_task_builder_with_branch() {
use crate::test_utils::TestGitRepository;
let test_repo = TestGitRepository::new().unwrap();
test_repo.init_with_main_branch().unwrap();
test_repo.create_file("main-file.txt", "content").unwrap();
test_repo.stage_all().unwrap();
let main_commit = test_repo.commit("Add main file").unwrap();
test_repo
.run_git_command(&["checkout", "-b", "feature-branch"])
.unwrap();
test_repo
.create_file("feature-file.txt", "feature")
.unwrap();
test_repo.stage_all().unwrap();
test_repo.commit("Add feature file").unwrap();
test_repo.run_git_command(&["checkout", "main"]).unwrap();
test_repo
.create_file("dirty-file.txt", "uncommitted")
.unwrap();
let ctx = AppContext::builder().build();
let task = TaskBuilder::new()
.repo_root(test_repo.path().to_path_buf())
.name("branch-test".to_string())
.prompt(Some("Test".to_string()))
.branch(Some("feature-branch".to_string()))
.build(&ctx)
.await
.unwrap();
assert_eq!(task.source_branch, Some("feature-branch".to_string()));
assert_ne!(
task.source_commit, main_commit,
"Should use feature-branch commit, not main"
);
let task_dir = ctx.tsk_env().task_dir(&task.id);
let copied_repo = task_dir.join("repo");
assert!(
copied_repo.join("feature-file.txt").exists(),
"Should have feature-branch files"
);
assert!(
copied_repo.join("main-file.txt").exists(),
"Should have main-file.txt too"
);
assert!(
!copied_repo.join("dirty-file.txt").exists(),
"Uncommitted working tree changes should not be included with --branch"
);
}
#[tokio::test]
async fn test_task_builder_with_invalid_branch() {
use crate::test_utils::TestGitRepository;
let test_repo = TestGitRepository::new().unwrap();
test_repo.init_with_main_branch().unwrap();
let ctx = AppContext::builder().build();
let result = TaskBuilder::new()
.repo_root(test_repo.path().to_path_buf())
.name("bad-branch".to_string())
.prompt(Some("Test".to_string()))
.branch(Some("nonexistent-branch".to_string()))
.build(&ctx)
.await;
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
err.contains("not found"),
"Should mention branch not found: {err}"
);
}
#[tokio::test]
async fn test_task_builder_resolved_config_reflects_project_config() {
use crate::context::{ResolvedConfig, SharedConfig, TskConfig};
use crate::test_utils::TestGitRepository;
use std::collections::HashMap;
let test_repo = TestGitRepository::new().unwrap();
test_repo.init_with_commit().unwrap();
let repo_path = test_repo.path().to_path_buf();
let project_name = "test-project".to_string();
let mut project_configs = HashMap::new();
project_configs.insert(
project_name.clone(),
SharedConfig {
memory_gb: Some(32.0),
host_ports: vec![5432],
..Default::default()
},
);
let tsk_config = TskConfig {
project: project_configs,
..Default::default()
};
let ctx = AppContext::builder().with_tsk_config(tsk_config).build();
let task = TaskBuilder::new()
.repo_root(repo_path)
.name("config-merge-test".to_string())
.prompt(Some("Test".to_string()))
.project(Some(project_name))
.build(&ctx)
.await
.unwrap();
let config: ResolvedConfig =
serde_json::from_str(task.resolved_config.as_ref().unwrap()).unwrap();
assert_eq!(
config.memory_gb, 32.0,
"Project config memory should be in snapshot"
);
assert_eq!(
config.host_ports,
vec![5432],
"Project config host_ports should be in snapshot"
);
}
}