vtcode-core 0.141.11

Core library for VT Code - a Rust-based terminal coding agent
//! Plan artifact I/O: persisting drafts, syncing the embedded tracker,
//! and detecting workspace validation commands.
//!
//! Depends on `artifacts` for pure content shaping and on `state` for the
//! plan-file location. Tool wiring lives in `start.rs` / `finish.rs`.

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() => {
            // The dedicated plan agent can enter planning without invoking the
            // `start_planning` tool first. Plan synthesis must still have a
            // durable artifact before approval, so lazily allocate the same
            // workspace-local plan location used by `start_planning`.
            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 {
        // Publish the tracker artifacts before replacing the plan. Each file
        // is written atomically so readers never observe a truncated artifact.
        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(),
    }
}