use anyhow::{Context, Result, bail};
use serde_json::Value;
use std::path::{Path, PathBuf};
use std::time::SystemTime;
use crate::tools::handlers::planning_workflow::artifacts::{
PlanValidationReport, extract_embedded_tracker, generate_tracker_markdown_from_plan, render_plan_with_tracker,
tracker_file_for_plan_file, tracker_has_progress_or_notes, validate_plan_content,
};
use crate::tools::handlers::planning_workflow::state::PlanningWorkflowState;
use crate::utils::file_utils::{
ensure_dir_exists, read_file_with_context, write_file_atomic_with_context, write_file_with_context,
};
#[derive(Debug, Clone)]
pub struct PersistedPlanDraft {
pub plan_file: PathBuf,
pub tracker_file: Option<PathBuf>,
pub validation: PlanValidationReport,
}
async fn persist_global_tracker_if_missing(workspace_root: &Path, tracker_markdown: &str) -> Result<bool> {
if workspace_root.as_os_str().is_empty() {
return Ok(false);
}
let task_file = workspace_root.join(".vtcode").join("tasks").join("current_task.md");
if tokio::fs::read_to_string(&task_file)
.await
.ok()
.is_some_and(|content| !content.trim().is_empty())
{
return Ok(false);
}
if let Some(parent) = task_file.parent() {
ensure_dir_exists(parent)
.await
.with_context(|| format!("Failed to create task tracker directory: {}", parent.display()))?;
}
write_file_atomic_with_context(&task_file, tracker_markdown, "task checklist")
.await
.with_context(|| format!("Failed to write task checklist: {}", task_file.display()))?;
Ok(true)
}
async fn read_optional_file(path: &Path, context: &str) -> Result<Option<String>> {
match tokio::fs::read_to_string(path).await {
Ok(content) => Ok(Some(content)),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(error) => Err(error).with_context(|| format!("Failed to read {context}: {}", path.display())),
}
}
async fn restore_optional_file(path: &Path, content: Option<&str>, context: &str) -> Result<()> {
if let Some(content) = content {
return write_file_atomic_with_context(path, content, context).await;
}
match tokio::fs::remove_file(path).await {
Ok(()) => Ok(()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(error).with_context(|| format!("Failed to remove {context}: {}", path.display())),
}
}
pub async fn sync_tracker_into_plan_file(plan_file: &Path, tracker_markdown: &str) -> Result<()> {
let plan_content = read_file_with_context(plan_file, "plan file")
.await
.with_context(|| format!("Failed to read plan file: {}", plan_file.display()))?;
let updated = render_plan_with_tracker(&plan_content, Some(tracker_markdown));
write_file_with_context(plan_file, &updated, "plan file")
.await
.with_context(|| format!("Failed to write plan file: {}", plan_file.display()))?;
Ok(())
}
pub async fn persist_plan_draft(state: &PlanningWorkflowState, plan_markdown: &str) -> Result<PersistedPlanDraft> {
let validation = validate_plan_content(plan_markdown);
if !validation.is_ready() {
bail!("plan draft is not ready for persistence: {}", validation.reasons().join("; "));
}
let plan_file = match state.get_plan_file().await {
Some(path) => path,
None if state.is_active() => {
let plan_file = state
.plans_dir()
.join(format!("{}.md", vtcode_commons::slug::create_timestamped()));
if let Some(parent) = plan_file.parent() {
ensure_dir_exists(parent)
.await
.with_context(|| format!("Failed to create plans directory: {}", parent.display()))?;
}
state.set_plan_file(Some(plan_file.clone())).await;
state.set_plan_baseline(Some(SystemTime::now())).await;
tracing::info!(
plan_file = %plan_file.display(),
"Initialized missing plan file during active plan synthesis"
);
plan_file
}
None => bail!("No active plan file. Call start_planning first."),
};
let existing_plan = read_optional_file(&plan_file, "plan file").await?;
let tracker_file = tracker_file_for_plan_file(&plan_file);
let (existing_tracker, tracker_from_sidecar, original_tracker_file) = if let Some(path) = tracker_file.as_ref() {
let sidecar = read_optional_file(path, "plan tracker file").await?;
if sidecar.is_some() {
let existing_tracker = sidecar.clone().filter(|value| !value.trim().is_empty());
(existing_tracker, true, sidecar)
} else {
(
existing_plan
.as_deref()
.and_then(extract_embedded_tracker)
.filter(|content: &String| !content.trim().is_empty()),
false,
None,
)
}
} else {
(
existing_plan
.as_deref()
.and_then(extract_embedded_tracker)
.filter(|content: &String| !content.trim().is_empty()),
false,
None,
)
};
let workspace_root = state.workspace_root().unwrap_or_default();
let global_tracker_file = (!workspace_root.as_os_str().is_empty())
.then(|| workspace_root.join(".vtcode").join("tasks").join("current_task.md"));
let existing_global_tracker = match global_tracker_file.as_ref() {
Some(path) => read_optional_file(path, "task checklist").await?,
None => None,
};
let should_refresh_embedded = !tracker_from_sidecar
&& existing_tracker
.as_deref()
.is_some_and(|tracker| !tracker_has_progress_or_notes(tracker));
let generated_tracker = generate_tracker_markdown_from_plan(plan_markdown);
let tracker_to_persist = if should_refresh_embedded {
generated_tracker.or(existing_tracker.clone())
} else {
existing_tracker.clone().or(generated_tracker)
};
let Some(tracker_markdown) = tracker_to_persist.as_deref().filter(|content| !content.trim().is_empty()) else {
bail!("unable to generate a non-empty plan task tracker");
};
let Some(tracker_file_path) = tracker_file.as_ref() else {
bail!("unable to derive a plan task tracker path");
};
let canonical_plan = render_plan_with_tracker(plan_markdown, Some(tracker_markdown));
let mut tracker_published = false;
let mut global_tracker_published = false;
let mut plan_published = false;
let publish_result: Result<()> = async {
if let Some(parent) = tracker_file_path.parent() {
ensure_dir_exists(parent)
.await
.with_context(|| format!("Failed to create plan tracker directory: {}", parent.display()))?;
}
write_file_atomic_with_context(tracker_file_path, tracker_markdown, "plan tracker file")
.await
.with_context(|| format!("Failed to write plan tracker file: {}", tracker_file_path.display()))?;
tracker_published = true;
global_tracker_published = persist_global_tracker_if_missing(&workspace_root, tracker_markdown).await?;
write_file_atomic_with_context(&plan_file, &canonical_plan, "plan file")
.await
.with_context(|| format!("Failed to write plan file: {}", plan_file.display()))?;
plan_published = true;
Ok(())
}
.await;
if let Err(error) = publish_result {
let mut rollback_error = None;
if plan_published
&& let Err(rollback) =
restore_optional_file(&plan_file, existing_plan.as_deref(), "plan file rollback").await
{
rollback_error = Some(rollback);
}
if tracker_published
&& let Err(rollback) =
restore_optional_file(tracker_file_path, original_tracker_file.as_deref(), "plan tracker rollback")
.await
{
rollback_error.get_or_insert(rollback);
}
if global_tracker_published
&& let Some(global_tracker_file) = global_tracker_file.as_ref()
&& let Err(rollback) = restore_optional_file(
global_tracker_file,
existing_global_tracker.as_deref(),
"task checklist rollback",
)
.await
{
rollback_error.get_or_insert(rollback);
}
if let Some(rollback_error) = rollback_error {
return Err(error.context(format!("failed to roll back plan artifacts: {rollback_error}")));
}
return Err(error);
}
Ok(PersistedPlanDraft {
plan_file,
tracker_file: Some(tracker_file_path.clone()),
validation,
})
}
pub(super) fn resolve_plan_path(workspace_root: &Path, raw_path: &str) -> PathBuf {
let trimmed = raw_path.trim();
if Path::new(trimmed).is_absolute() {
PathBuf::from(trimmed)
} else {
workspace_root.join(trimmed)
}
}
pub(super) fn plan_title_seed(path: &Path, fallback_plan_name: &str) -> String {
path.file_stem()
.and_then(|stem| stem.to_str())
.map(|stem| stem.to_string())
.unwrap_or_else(|| fallback_plan_name.to_string())
}
pub(super) async fn initialize_plan_file(
plan_file: &Path,
plan_title: &str,
description: Option<&str>,
validation_hints: &ValidationCommandHints,
) -> Result<()> {
let initial_content = render_initial_plan_file_content(plan_title, description, plan_file, validation_hints);
write_file_with_context(plan_file, &initial_content, "plan file")
.await
.with_context(|| format!("Failed to create plan file: {}", plan_file.display()))
}
pub(super) async fn plan_file_baseline(plan_file: &Path) -> SystemTime {
tokio::fs::metadata(plan_file)
.await
.and_then(|meta| meta.modified())
.unwrap_or_else(|_| SystemTime::now())
}
fn render_initial_plan_file_content(
plan_title: &str,
description: Option<&str>,
plan_file: &Path,
validation_hints: &ValidationCommandHints,
) -> String {
let mut content = format!("# {plan_title}\n\n");
content.push_str("Status: drafting\n");
content.push_str(&format!("Created: {}\n", chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC")));
content.push_str(&format!("Plan file: `{}`\n", plan_file.display()));
if let Some(description) = description.map(str::trim).filter(|value| !value.is_empty()) {
content.push_str(&format!("Description: {description}\n"));
}
content.push('\n');
content.push_str("> Planning workflow is active. Research first, then materialize one compact `<proposed_plan>` spec here (fit ~1500 tokens; steps as `Action -> files -> verify:`, prefer file:symbol refs over prose).\n");
content.push_str(&format!(
"> Suggested validation defaults: build/lint {}; tests {}.\n",
validation_hints.build_and_lint, validation_hints.tests
));
content
}
#[derive(Debug, Clone)]
pub(super) struct ValidationCommandHints {
pub(super) build_and_lint: String,
pub(super) tests: String,
}
fn package_manager_for_workspace(workspace_root: &Path) -> &'static str {
if workspace_root.join("pnpm-lock.yaml").exists() {
"pnpm"
} else if workspace_root.join("yarn.lock").exists() {
"yarn"
} else if workspace_root.join("bun.lockb").exists() || workspace_root.join("bun.lock").exists() {
"bun"
} else {
"npm"
}
}
fn node_script_command(pm: &str, script: &str) -> String {
match pm {
"yarn" => format!("yarn {script}"),
"bun" => format!("bun run {script}"),
_ => format!("{pm} run {script}"),
}
}
fn package_json_has_script(workspace_root: &Path, script: &str) -> bool {
let path = workspace_root.join("package.json");
let Ok(content) = std::fs::read_to_string(path) else {
return false;
};
let Ok(json) = serde_json::from_str::<Value>(&content) else {
return false;
};
json.get("scripts")
.and_then(Value::as_object)
.is_some_and(|scripts| scripts.contains_key(script))
}
pub(super) fn detect_validation_command_hints(workspace_root: &Path) -> ValidationCommandHints {
if workspace_root.join("Cargo.toml").exists() {
return ValidationCommandHints {
build_and_lint: "`cargo check`; `cargo clippy --workspace --all-targets -- -D warnings`".to_string(),
tests: "`cargo test` (or `cargo nextest run` if nextest is configured)".to_string(),
};
}
if workspace_root.join("package.json").exists() {
let pm = package_manager_for_workspace(workspace_root);
let has_build = package_json_has_script(workspace_root, "build");
let has_lint = package_json_has_script(workspace_root, "lint");
let has_test = package_json_has_script(workspace_root, "test");
let build_and_lint = match (has_build, has_lint) {
(true, true) => format!("`{}`; `{}`", node_script_command(pm, "build"), node_script_command(pm, "lint")),
(true, false) => {
format!("`{}`; plus configured lint command for the workspace", node_script_command(pm, "build"))
}
(false, true) => format!(
"`{}`; plus configured build/typecheck command for the workspace",
node_script_command(pm, "lint")
),
(false, false) => {
format!("Use configured {pm} build/lint (or typecheck) scripts for this workspace")
}
};
let tests = if has_test {
format!("`{}`", node_script_command(pm, "test"))
} else {
format!("Use configured {pm} test command for this workspace")
};
return ValidationCommandHints { build_and_lint, tests };
}
if workspace_root.join("pyproject.toml").exists()
|| workspace_root.join("requirements.txt").exists()
|| workspace_root.join("setup.py").exists()
{
return ValidationCommandHints {
build_and_lint: "`python -m compileall .`; run configured linter (for example `ruff check .`)".to_string(),
tests: "`pytest`".to_string(),
};
}
if workspace_root.join("go.mod").exists() {
return ValidationCommandHints {
build_and_lint: "`go build ./...`; `go vet ./...`".to_string(),
tests: "`go test ./...`".to_string(),
};
}
if workspace_root.join("Makefile").exists() {
return ValidationCommandHints {
build_and_lint: "`make lint` (or `make build` if no lint target exists)".to_string(),
tests: "`make test`".to_string(),
};
}
ValidationCommandHints {
build_and_lint: "[project build and lint command(s)]".to_string(),
tests: "[project test command(s)]".to_string(),
}
}