use std::collections::{BTreeMap, BTreeSet, HashSet};
use std::path::{Component, Path};
use similar::TextDiff;
use walkdir::WalkDir;
use super::runner::StageResult;
use super::stage::Stage;
const MAX_TRACKED_FILE_BYTES: u64 = 512 * 1024;
const MAX_PATCH_CHARS_PER_TASK: usize = 12_000;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ExecutionTask {
pub id: String,
pub title: String,
pub body: String,
pub dependencies: Vec<String>,
pub independent: bool,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TaskRunStatus {
Pending,
Running,
Succeeded,
Failed,
Skipped,
}
impl TaskRunStatus {
pub fn label(self) -> &'static str {
match self {
TaskRunStatus::Pending => "Aguardando",
TaskRunStatus::Running => "Em execução",
TaskRunStatus::Succeeded => "Concluída",
TaskRunStatus::Failed => "Erro",
TaskRunStatus::Skipped => "Pulada",
}
}
pub fn marker(self) -> &'static str {
match self {
TaskRunStatus::Pending => "○",
TaskRunStatus::Running => "●",
TaskRunStatus::Succeeded => "✓",
TaskRunStatus::Failed => "✗",
TaskRunStatus::Skipped => "⊘",
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct DiffReport {
pub changed_files: Vec<String>,
pub summary: String,
pub patch: String,
}
impl DiffReport {
pub fn is_empty(&self) -> bool {
self.changed_files.is_empty()
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct WorkspaceSnapshot {
files: BTreeMap<String, TrackedFile>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
enum TrackedFile {
Text(String),
BinaryOrLarge,
}
#[derive(Clone, Debug)]
pub struct CompletedTask {
pub task: ExecutionTask,
pub status: TaskRunStatus,
pub report: String,
pub diff: DiffReport,
pub result: Option<StageResult>,
pub trace: Vec<String>,
}
pub fn parse_execution_tasks(md: &str) -> Vec<ExecutionTask> {
let independent_ids = extract_independent_ids(md);
let section_tasks = parse_section_tasks(md, &independent_ids);
let table_tasks = parse_backlog_table(md, &independent_ids);
if !table_tasks.is_empty() {
return merge_table_and_section_tasks(table_tasks, section_tasks);
}
section_tasks
}
fn extract_independent_ids(md: &str) -> HashSet<String> {
let mut ids = HashSet::new();
let mut in_section = false;
for line in md.lines() {
let trimmed = line.trim();
if trimmed.starts_with('#') {
let heading = trimmed.trim_start_matches('#').trim().to_lowercase();
in_section = heading.contains("independente") || heading.contains("paralel");
continue;
}
if in_section {
for id in extract_task_ids(trimmed) {
ids.insert(id);
}
}
}
ids
}
fn parse_backlog_table(md: &str, independent_ids: &HashSet<String>) -> Vec<ExecutionTask> {
let mut tasks = Vec::new();
let mut in_backlog = false;
for line in md.lines() {
let trimmed = line.trim();
if trimmed.starts_with('#') {
let heading = trimmed.trim_start_matches('#').trim().to_lowercase();
in_backlog = heading == "backlog";
continue;
}
if !in_backlog || !trimmed.starts_with('|') {
continue;
}
let cols = trimmed
.split('|')
.map(str::trim)
.filter(|cell| !cell.is_empty())
.collect::<Vec<_>>();
if cols.len() < 2 {
continue;
}
let Some((id, _)) = task_id_at_start(cols[0]) else {
continue;
};
let title = cols[1].trim().to_string();
if title.is_empty() {
continue;
}
let deps = cols.get(3).copied().unwrap_or_default();
let dependencies = extract_task_ids(deps);
let independent = independent_ids.contains(&id) || has_no_declared_dependency(deps);
tasks.push(ExecutionTask {
id,
title,
body: String::new(),
dependencies,
independent,
});
}
tasks
}
fn parse_section_tasks(md: &str, independent_ids: &HashSet<String>) -> Vec<ExecutionTask> {
let mut tasks = Vec::new();
let mut in_backlog = false;
let mut current: Option<ExecutionTask> = None;
for line in md.lines() {
let trimmed = line.trim();
if trimmed.starts_with("## ") {
if let Some(task) = current.take() {
tasks.push(finalize_section_task(task, independent_ids));
}
let heading = trimmed.trim_start_matches('#').trim().to_lowercase();
in_backlog = is_task_detail_section(&heading);
continue;
}
if !in_backlog && current.is_none() {
continue;
}
if trimmed.starts_with('#') {
if let Some((id, title)) = parse_task_heading(trimmed) {
if let Some(task) = current.take() {
tasks.push(finalize_section_task(task, independent_ids));
}
current = Some(ExecutionTask {
id,
title,
body: String::new(),
dependencies: Vec::new(),
independent: false,
});
continue;
}
}
if let Some(task) = current.as_mut() {
if let Some(value) = trimmed.strip_prefix("- Dependências:") {
task.dependencies.extend(extract_task_ids(value));
}
task.body.push_str(line);
task.body.push('\n');
}
}
if let Some(task) = current.take() {
tasks.push(finalize_section_task(task, independent_ids));
}
tasks
}
fn is_task_detail_section(heading: &str) -> bool {
matches!(
heading,
"backlog" | "prompts agent" | "prompts de execução" | "prompts de execucao"
)
}
fn merge_table_and_section_tasks(
mut table_tasks: Vec<ExecutionTask>,
section_tasks: Vec<ExecutionTask>,
) -> Vec<ExecutionTask> {
let details_by_id: BTreeMap<String, ExecutionTask> = section_tasks
.into_iter()
.map(|task| (task.id.clone(), task))
.collect();
for task in &mut table_tasks {
let Some(detail) = details_by_id.get(&task.id) else {
continue;
};
if !detail.body.trim().is_empty() {
task.body = detail.body.clone();
}
let mut seen: BTreeSet<String> = task.dependencies.iter().cloned().collect();
for dep in &detail.dependencies {
if seen.insert(dep.clone()) {
task.dependencies.push(dep.clone());
}
}
task.independent = task.independent || (task.dependencies.is_empty() && detail.independent);
}
table_tasks
}
fn finalize_section_task(
mut task: ExecutionTask,
independent_ids: &HashSet<String>,
) -> ExecutionTask {
task.body = task.body.trim().to_string();
task.independent = independent_ids.contains(&task.id) || task.dependencies.is_empty();
task
}
fn parse_task_heading(line: &str) -> Option<(String, String)> {
let heading = line.trim_start_matches('#').trim();
let (id, end) = task_id_at_start(heading)?;
let title = heading[end..]
.trim()
.trim_start_matches(['—', '-', ':', '–'])
.trim()
.to_string();
if title.is_empty() {
None
} else {
Some((id, title))
}
}
fn task_id_at_start(input: &str) -> Option<(String, usize)> {
let trimmed = input.trim_start();
let offset = input.len().saturating_sub(trimmed.len());
for prefix in ["TSK-", "T-"] {
if !trimmed.starts_with(prefix) {
continue;
}
let digits: String = trimmed[prefix.len()..]
.chars()
.take_while(|ch| ch.is_ascii_digit())
.collect();
if digits.is_empty() {
continue;
}
let end = offset + prefix.len() + digits.len();
return Some((format!("{prefix}{digits}"), end));
}
None
}
fn extract_task_ids(input: &str) -> Vec<String> {
let mut ids = Vec::new();
let mut seen = HashSet::new();
let chars = input.char_indices().collect::<Vec<_>>();
for (idx, _) in &chars {
let tail = &input[*idx..];
if let Some((id, _)) = task_id_at_start(tail) {
if seen.insert(id.clone()) {
ids.push(id);
}
}
}
ids
}
fn has_no_declared_dependency(deps: &str) -> bool {
let normalized = deps.trim();
normalized.is_empty() || matches!(normalized, "—" | "-" | "–" | "Nenhuma" | "nenhuma")
}
pub fn capture_workspace_snapshot(
root: &Path,
artifact_dir_to_exclude: Option<&Path>,
) -> std::io::Result<WorkspaceSnapshot> {
let mut files = BTreeMap::new();
let artifact_dir = artifact_dir_to_exclude.and_then(|path| path.canonicalize().ok());
let root_canonical = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
for entry in WalkDir::new(root)
.follow_links(false)
.into_iter()
.filter_entry(|entry| {
!should_skip_entry(entry.path(), &root_canonical, artifact_dir.as_deref())
})
{
let entry = entry?;
if !entry.file_type().is_file() {
continue;
}
let metadata = entry.metadata()?;
let rel = match entry.path().strip_prefix(root) {
Ok(rel) => normalize_path(rel),
Err(_) => continue,
};
if metadata.len() > MAX_TRACKED_FILE_BYTES {
files.insert(rel, TrackedFile::BinaryOrLarge);
continue;
}
let bytes = std::fs::read(entry.path())?;
match String::from_utf8(bytes) {
Ok(text) => {
files.insert(rel, TrackedFile::Text(text));
}
Err(_) => {
files.insert(rel, TrackedFile::BinaryOrLarge);
}
}
}
Ok(WorkspaceSnapshot { files })
}
fn should_skip_entry(path: &Path, root: &Path, artifact_dir: Option<&Path>) -> bool {
let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
if artifact_dir.is_some_and(|artifact_dir| canonical.starts_with(artifact_dir)) {
return true;
}
let rel = canonical.strip_prefix(root).unwrap_or(&canonical);
crate::runtime::optimization::path_has_ignored_component(rel)
}
fn normalize_path(path: &Path) -> String {
path.components()
.filter_map(|component| match component {
Component::Normal(value) => Some(value.to_string_lossy().to_string()),
_ => None,
})
.collect::<Vec<_>>()
.join("/")
}
pub fn diff_snapshots(before: &WorkspaceSnapshot, after: &WorkspaceSnapshot) -> DiffReport {
let mut keys = BTreeSet::new();
keys.extend(before.files.keys().cloned());
keys.extend(after.files.keys().cloned());
let mut changed_files = Vec::new();
let mut summary = Vec::new();
let mut patch = String::new();
for path in keys {
let old = before.files.get(&path);
let new = after.files.get(&path);
if old == new {
continue;
}
changed_files.push(path.clone());
match (old, new) {
(None, Some(TrackedFile::Text(new_text))) => {
summary.push(format!("A {path}"));
append_unified_diff(&mut patch, &path, "", new_text);
}
(Some(TrackedFile::Text(old_text)), None) => {
summary.push(format!("D {path}"));
append_unified_diff(&mut patch, &path, old_text, "");
}
(Some(TrackedFile::Text(old_text)), Some(TrackedFile::Text(new_text))) => {
summary.push(format!("M {path}"));
append_unified_diff(&mut patch, &path, old_text, new_text);
}
(None, Some(TrackedFile::BinaryOrLarge)) => {
summary.push(format!("A {path} (binário/grande)"));
patch.push_str(&format!(
"diff -- {path}\nArquivo binário ou grande adicionado.\n\n"
));
}
(Some(TrackedFile::BinaryOrLarge), None) => {
summary.push(format!("D {path} (binário/grande)"));
patch.push_str(&format!(
"diff -- {path}\nArquivo binário ou grande removido.\n\n"
));
}
_ => {
summary.push(format!("M {path} (binário/grande)"));
patch.push_str(&format!(
"diff -- {path}\nArquivo binário ou grande alterado.\n\n"
));
}
}
}
DiffReport {
changed_files,
summary: summary.join("\n"),
patch,
}
}
fn append_unified_diff(out: &mut String, path: &str, old_text: &str, new_text: &str) {
let diff = TextDiff::from_lines(old_text, new_text);
let rendered = diff
.unified_diff()
.header(&format!("a/{path}"), &format!("b/{path}"))
.to_string();
if rendered.trim().is_empty() {
return;
}
out.push_str(&rendered);
if !out.ends_with('\n') {
out.push('\n');
}
out.push('\n');
}
pub fn git_summary(root: &Path) -> Option<String> {
if !root.join(".git").exists() {
return None;
}
let status = std::process::Command::new("git")
.arg("-C")
.arg(root)
.arg("status")
.arg("--short")
.output()
.ok()?;
if !status.status.success() {
return None;
}
let diff_stat = std::process::Command::new("git")
.arg("-C")
.arg(root)
.arg("diff")
.arg("--stat")
.output()
.ok();
let mut out = String::new();
let status_text = String::from_utf8_lossy(&status.stdout);
if status_text.trim().is_empty() {
out.push_str("git status --short: sem alterações rastreadas.\n");
} else {
out.push_str("git status --short:\n");
out.push_str(status_text.trim());
out.push('\n');
}
if let Some(diff_stat) = diff_stat.filter(|output| output.status.success()) {
let stat = String::from_utf8_lossy(&diff_stat.stdout);
if !stat.trim().is_empty() {
out.push_str("\ngit diff --stat:\n");
out.push_str(stat.trim());
out.push('\n');
}
}
Some(out)
}
pub fn compose_execution_artifact(
orchestration_name: &str,
tasks: &[CompletedTask],
git_summary: Option<&str>,
partial: bool,
) -> String {
let mut out = String::new();
out.push_str(&format!("# Execution - {orchestration_name}\n\n"));
out.push_str("## Rastreabilidade\n\n");
out.push_str(&format!("- Orquestração: {orchestration_name}\n"));
out.push_str(&format!("- Stage: `{}`\n", Stage::Execution.key()));
out.push_str("- Modo TUI: execução real task-by-task com diff por task\n");
out.push_str("- Fonte das tasks: `04-tasks.md`\n");
out.push_str(&format!("- Total de tasks planejadas: {}\n", tasks.len()));
out.push_str(&format!(
"- Estado do artefato: {}\n\n",
if partial {
"execução parcial documentada"
} else {
"execução concluída e documentada"
}
));
out.push_str("## Tarefa\n\n");
out.push_str("| Task | Status | Arquivos alterados |\n");
out.push_str("|------|--------|--------------------|\n");
for task in tasks {
let files = if task.diff.changed_files.is_empty() {
"nenhum".to_string()
} else {
task.diff.changed_files.join(", ")
};
out.push_str(&format!(
"| {} — {} | {} | {} |\n",
escape_cell(&task.task.id),
escape_cell(&task.task.title),
task.status.label(),
escape_cell(&files)
));
}
out.push('\n');
out.push_str("## Resumo da implementação\n\n");
for task in tasks {
out.push_str(&format!("### {} — {}\n\n", task.task.id, task.task.title));
match task.status {
TaskRunStatus::Succeeded => out.push_str("- Status: concluída.\n"),
TaskRunStatus::Failed => out.push_str("- Status: falhou durante a execução.\n"),
TaskRunStatus::Skipped => {
out.push_str("- Status: pulada ao aceitar resultado parcial.\n")
}
TaskRunStatus::Pending | TaskRunStatus::Running => {
out.push_str("- Status: não finalizada.\n")
}
}
if let Some(result) = &task.result {
out.push_str(&format!(
"- Exit code: `{}` · duração: {}ms.\n",
result.exit_code, result.duration_ms
));
}
if !task.report.trim().is_empty() {
out.push('\n');
out.push_str(clip_chars(task.report.trim(), 4_000).trim());
out.push_str("\n\n");
} else if !task.trace.is_empty() {
out.push_str("\nTrace final:\n");
for item in task
.trace
.iter()
.rev()
.take(5)
.collect::<Vec<_>>()
.into_iter()
.rev()
{
out.push_str(&format!("- {item}\n"));
}
out.push('\n');
}
}
out.push_str("## Arquivos alterados\n\n");
let files = tasks
.iter()
.flat_map(|task| task.diff.changed_files.iter().cloned())
.collect::<BTreeSet<_>>();
if files.is_empty() {
out.push_str("- Nenhum arquivo alterado foi detectado pelo snapshot da TUI.\n\n");
} else {
for file in files {
out.push_str(&format!("- `{file}`\n"));
}
out.push('\n');
}
if let Some(git_summary) = git_summary.filter(|value| !value.trim().is_empty()) {
out.push_str("### Resumo Git\n\n```text\n");
out.push_str(git_summary.trim());
out.push_str("\n```\n\n");
}
out.push_str("## Testes e evidências\n\n");
for task in tasks {
out.push_str(&format!("- `{}`: {}.\n", task.task.id, task.status.label()));
}
out.push('\n');
out.push_str("## Riscos e pendências\n\n");
let failed = tasks
.iter()
.filter(|task| task.status == TaskRunStatus::Failed)
.collect::<Vec<_>>();
if failed.is_empty() && !partial {
out.push_str("- Nenhuma falha automática registrada na execução das tasks.\n\n");
} else {
if partial {
out.push_str("- Resultado parcial aceito no TUI; revisar tasks não concluídas antes de aprovar para produção.\n");
}
for task in failed {
out.push_str(&format!(
"- `{}` — {} falhou; revisar relatório e diff antes de prosseguir.\n",
task.task.id, task.task.title
));
}
out.push('\n');
}
out.push_str("## Diff por task\n\n");
for task in tasks {
out.push_str(&format!("### {} — {}\n\n", task.task.id, task.task.title));
if task.diff.is_empty() {
out.push_str("- Sem diff detectado para esta task.\n\n");
continue;
}
out.push_str("Resumo:\n\n```text\n");
out.push_str(task.diff.summary.trim());
out.push_str("\n```\n\n");
out.push_str("Patch:\n\n```diff\n");
out.push_str(clip_chars(&task.diff.patch, MAX_PATCH_CHARS_PER_TASK).trim());
out.push_str("\n```\n\n");
}
ensure_required_sections(&mut out);
out
}
fn ensure_required_sections(out: &mut String) {
for section in [
"Rastreabilidade",
"Tarefa",
"Resumo da implementação",
"Arquivos alterados",
"Testes e evidências",
"Riscos e pendências",
] {
if !out.contains(&format!("## {section}\n")) {
out.push_str(&format!("## {section}\n\n"));
out.push_str("- Seção preservada para compatibilidade com o contrato do artefato.\n\n");
}
}
}
fn escape_cell(input: &str) -> String {
input.replace('|', "\\|").replace('\n', " ")
}
fn clip_chars(input: &str, max: usize) -> String {
if input.chars().count() <= max {
return input.to_string();
}
let mut out = input.chars().take(max).collect::<String>();
out.push_str("\n[conteúdo truncado]");
out
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test]
fn parses_section_style_tasks() {
let md = "\
## Backlog
### TSK-01 — Validar contrato real
- Objetivo: mapear arquivos
### TSK-02 — Implementar ordenação
- Dependências: TSK-01
- Objetivo: alterar listagem
";
let tasks = parse_execution_tasks(md);
assert_eq!(tasks.len(), 2);
assert_eq!(tasks[0].id, "TSK-01");
assert_eq!(tasks[0].title, "Validar contrato real");
assert!(tasks[0].independent);
assert_eq!(tasks[1].dependencies, vec!["TSK-01"]);
assert!(!tasks[1].independent);
}
#[test]
fn parses_table_style_tasks() {
let md = "\
## Backlog
| ID | Título | Estimativa | Dependências |
|----|--------|------------|--------------|
| T-01 | Base | P | — |
| T-02 | UI | P | T-01 |
### Tasks independentes entre si
- T-02 pode rodar em paralelo.
";
let tasks = parse_execution_tasks(md);
assert_eq!(tasks.len(), 2);
assert_eq!(tasks[0].id, "T-01");
assert!(tasks[0].independent);
assert!(tasks[1].independent);
}
#[test]
fn merges_table_tasks_with_prompt_sections() {
let md = "\
## Backlog
| ID | Título | Estimativa | Dependências | Arquivos |
|----|--------|------------|--------------|----------|
| T-01 | Base | P | — | src/base.rs |
| T-02 | UI | P | T-01 | src/ui.rs |
## Prompts Agent
### T-02 — UI detalhada
- Dependências: T-01
- Objetivo: implementar a interface principal.
#### Prompt Agent
Execute apenas a UI, rode testes focados e reporte evidências.
";
let tasks = parse_execution_tasks(md);
assert_eq!(tasks.len(), 2);
assert_eq!(tasks[1].id, "T-02");
assert_eq!(tasks[1].title, "UI");
assert_eq!(tasks[1].dependencies, vec!["T-01"]);
assert!(tasks[1].body.contains("Prompt Agent"));
assert!(tasks[1].body.contains("Execute apenas a UI"));
}
#[test]
fn snapshot_diff_detects_added_modified_removed_files_without_git() {
let dir = tempfile::tempdir().unwrap();
fs::create_dir_all(dir.path().join("src")).unwrap();
fs::write(dir.path().join("src/a.txt"), "one\n").unwrap();
fs::write(dir.path().join("src/remove.txt"), "bye\n").unwrap();
let before = capture_workspace_snapshot(dir.path(), None).unwrap();
fs::write(dir.path().join("src/a.txt"), "one\ntwo\n").unwrap();
fs::write(dir.path().join("src/new.txt"), "new\n").unwrap();
fs::remove_file(dir.path().join("src/remove.txt")).unwrap();
fs::create_dir_all(dir.path().join("node_modules/pkg")).unwrap();
fs::write(dir.path().join("node_modules/pkg/ignored.txt"), "ignored").unwrap();
let after = capture_workspace_snapshot(dir.path(), None).unwrap();
let diff = diff_snapshots(&before, &after);
assert!(diff.summary.contains("M src/a.txt"));
assert!(diff.summary.contains("A src/new.txt"));
assert!(diff.summary.contains("D src/remove.txt"));
assert!(!diff.summary.contains("node_modules"));
assert!(diff.patch.contains("--- a/src/a.txt"));
}
#[test]
fn snapshot_excludes_artifact_dir() {
let dir = tempfile::tempdir().unwrap();
let artifact = dir.path().join("docs/feat");
fs::create_dir_all(&artifact).unwrap();
fs::write(artifact.join("06-execution.md"), "ignored").unwrap();
let snapshot = capture_workspace_snapshot(dir.path(), Some(&artifact)).unwrap();
assert!(snapshot.files.is_empty());
}
#[test]
fn compose_execution_artifact_contains_required_sections_and_diff() {
let task = ExecutionTask {
id: "TSK-01".to_string(),
title: "Alterar lista".to_string(),
body: String::new(),
dependencies: Vec::new(),
independent: true,
};
let artifact = compose_execution_artifact(
"Minha Feature",
&[CompletedTask {
task,
status: TaskRunStatus::Succeeded,
report: "Implementado.".to_string(),
diff: DiffReport {
changed_files: vec!["src/a.rs".to_string()],
summary: "M src/a.rs".to_string(),
patch: "--- a/src/a.rs\n+++ b/src/a.rs\n".to_string(),
},
result: None,
trace: Vec::new(),
}],
Some("git status --short:\n M src/a.rs"),
false,
);
assert!(artifact.contains("## Rastreabilidade"));
assert!(artifact.contains("## Diff por task"));
assert!(artifact.contains("src/a.rs"));
}
}