agentic-outer-dag-bin 0.1.1

External outer-DAG driver for worktree→PR→CodeRabbit loops
use crate::dag::engine::PlannedAction;
use crate::dag::engine::planned_actions_for_start;
use crate::state;
use crate::worktree;
use anyhow::Result;
use serde::Serialize;
use std::path::Path;
use std::path::PathBuf;

#[derive(Debug, Serialize)]
pub struct DryRunStartPreview {
    pub ticket: String,
    pub branch: String,
    pub worktree: Option<String>,
    pub stage: state::StageKind,
    pub state_file: String,
    pub would_create: bool,
    pub blocked_without_force: bool,
    pub planned_actions: Vec<PlannedAction>,
}

pub fn build_dry_run_start_preview(
    ticket: &str,
    branch: Option<&str>,
    worktree_path: Option<&Path>,
    force: bool,
) -> Result<DryRunStartPreview> {
    let plan = worktree::preview_resolve(branch, worktree_path)?;
    let state_file = format!(
        "./thoughts/{}/artifacts/{}",
        plan.branch,
        state::STATE_FILENAME
    );
    let blocked_without_force = state_file_path(&plan, worktree_path)?.exists() && !force;

    Ok(DryRunStartPreview {
        ticket: ticket.to_string(),
        branch: plan.branch,
        worktree: plan.path.as_ref().map(|path| path.display().to_string()),
        stage: state::StageKind::FreshnessBeforeTicketToPr,
        state_file,
        would_create: plan.would_create,
        blocked_without_force,
        planned_actions: planned_actions_for_start(),
    })
}

fn state_file_path(
    plan: &worktree::WorktreePreview,
    worktree_path: Option<&Path>,
) -> Result<PathBuf> {
    let anchor = if let Some(path) = plan.path.as_ref() {
        path.clone()
    } else if let Some(path) = worktree_path {
        path.to_path_buf()
    } else {
        std::env::current_dir()?
    };

    Ok(anchor
        .join("thoughts")
        .join(&plan.branch)
        .join("artifacts")
        .join(state::STATE_FILENAME))
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::test_support::CwdGuard;
    use crate::test_support::process_state_lock;
    use anyhow::Result;
    use std::env;
    use std::fs;
    use tempfile::TempDir;

    #[test]
    fn serializes_nullable_worktree_field_as_null() {
        let preview = DryRunStartPreview {
            ticket: "ENG-992".to_string(),
            branch: "feature/eng-992".to_string(),
            worktree: None,
            stage: state::StageKind::FreshnessBeforeTicketToPr,
            state_file: "./thoughts/feature/eng-992/artifacts/agentic-outer-dag-state.json"
                .to_string(),
            would_create: true,
            blocked_without_force: false,
            planned_actions: planned_actions_for_start(),
        };

        let json = serde_json::to_value(&preview).unwrap();

        assert!(json.get("worktree").unwrap().is_null());
        assert_eq!(
            json.get("stage").unwrap(),
            &serde_json::Value::String("freshness_before_ticket_to_pr".to_string())
        );
    }

    #[test]
    fn reports_blocked_without_force_when_state_file_exists() {
        let fixture = GitFixture::new().unwrap();
        let branch = fixture.current_branch().unwrap();
        let state_dir = fixture
            .repo
            .join("thoughts")
            .join(&branch)
            .join("artifacts");
        fs::create_dir_all(&state_dir).unwrap();
        fs::write(state_dir.join(state::STATE_FILENAME), "{}\n").unwrap();

        let preview =
            build_dry_run_start_preview("ENG-992", Some(&branch), Some(&fixture.repo), false)
                .unwrap();

        assert!(preview.blocked_without_force);
        assert!(!preview.would_create);
        assert_eq!(
            preview.state_file,
            format!("./thoughts/{branch}/artifacts/{}", state::STATE_FILENAME)
        );
    }

    #[test]
    fn reports_would_create_for_branch_without_existing_worktree() {
        let _guard = process_state_lock().lock().unwrap();
        let fixture = GitFixture::new().unwrap();
        let _cwd = CwdGuard::pushd(&fixture.repo).unwrap();

        let preview =
            build_dry_run_start_preview("ENG-992", Some("feature/preview-only"), None, false)
                .unwrap();

        assert!(preview.would_create);
        assert_eq!(preview.branch, "feature/preview-only");
        assert_eq!(
            preview.planned_actions.first().map(|action| action.id),
            Some("worktree.resolve")
        );
    }

    #[test]
    fn cwd_guard_restores_on_panic() {
        let _guard = process_state_lock().lock().unwrap();
        let fixture = GitFixture::new().unwrap();
        let saved = env::current_dir().unwrap();

        let panic = std::panic::catch_unwind(|| {
            let _cwd = CwdGuard::pushd(&fixture.repo).unwrap();
            panic!("intentional panic to test cwd restoration");
        });

        assert!(panic.is_err());
        assert_eq!(env::current_dir().unwrap(), saved);
    }

    struct GitFixture {
        _temp: TempDir,
        repo: PathBuf,
    }

    impl GitFixture {
        fn new() -> Result<Self> {
            let temp = TempDir::new()?;
            let repo = temp.path().join("repo");

            run_git(temp.path(), ["init", repo.to_str().unwrap()])?;
            run_git(&repo, ["config", "user.name", "Test User"])?;
            run_git(&repo, ["config", "user.email", "test@example.com"])?;
            fs::write(repo.join("README.md"), "base\n")?;
            run_git(&repo, ["add", "README.md"])?;
            run_git(&repo, ["commit", "-m", "initial"])?;
            run_git(&repo, ["branch", "feature/preview-only"])?;

            Ok(Self { _temp: temp, repo })
        }

        fn current_branch(&self) -> Result<String> {
            git_output(&self.repo, ["branch", "--show-current"])
        }
    }

    fn run_git<const N: usize>(cwd: &Path, args: [&str; N]) -> Result<()> {
        let output = std::process::Command::new("git")
            .current_dir(cwd)
            .args(args)
            .output()?;
        if output.status.success() {
            Ok(())
        } else {
            anyhow::bail!(
                "git {} failed: {}",
                args.join(" "),
                String::from_utf8_lossy(&output.stderr).trim()
            )
        }
    }

    fn git_output<const N: usize>(cwd: &Path, args: [&str; N]) -> Result<String> {
        let output = std::process::Command::new("git")
            .current_dir(cwd)
            .args(args)
            .output()?;
        if output.status.success() {
            Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
        } else {
            anyhow::bail!(
                "git {} failed: {}",
                args.join(" "),
                String::from_utf8_lossy(&output.stderr).trim()
            )
        }
    }
}