vik 0.1.2

Vik is an issue-driven coding workflow automation tool.
use std::path::PathBuf;

use crate::config::IssueStageSchema;
use crate::config::WorkflowSchema;

use super::Workflow;

impl Workflow {
  pub fn builder() -> WorkflowBuilder {
    WorkflowBuilder::new()
  }
}

pub struct WorkflowBuilder {
  workflow_path: PathBuf,
  schema: WorkflowSchema,
}

#[cfg(test)]
impl WorkflowBuilder {
  pub fn new() -> Self {
    Self {
      workflow_path: "/virtual/path/to/workflow.yml".into(),
      schema: WorkflowSchema::default(),
    }
  }

  pub fn workflow_path(mut self, workflow_path: impl Into<PathBuf>) -> Self {
    self.workflow_path = workflow_path.into();
    self
  }

  pub fn max_issue_concurrency(mut self, max_issue_concurrency: u32) -> Self {
    self.schema.loop_.max_issue_concurrency = max_issue_concurrency;
    self
  }

  pub fn workspace_root(mut self, workspace_root: impl Into<PathBuf>) -> Self {
    self.schema.workspace.root = Some(workspace_root.into());
    self
  }

  pub fn without_workspace_root(mut self) -> Self {
    self.schema.workspace.root = None;
    self
  }

  pub fn pull_command(mut self, pull_command: impl Into<String>) -> Self {
    self.schema.issues.pull.command = pull_command.into();
    self
  }

  pub fn after_issue_workdir_create_hook(mut self, after_create: impl Into<String>) -> Self {
    self.schema.issue.hooks.after_create = Some(after_create.into());
    self
  }

  pub fn add_stage(
    mut self,
    name: impl Into<String>,
    state: impl Into<String>,
    prompt_file: impl Into<PathBuf>,
  ) -> Self {
    let name = name.into();
    let stage = IssueStageSchema::new(state)
      .with_name(name.clone())
      .with_prompt_file(prompt_file);
    self.schema.issue.stages.insert(name, stage);
    self
  }

  pub fn add_inline_stage(
    mut self,
    name: impl Into<String>,
    state: impl Into<String>,
    prompt: impl Into<String>,
  ) -> Self {
    let name = name.into();
    let stage = IssueStageSchema::new(state).with_name(name.clone()).with_inline_prompt(prompt);
    self.schema.issue.stages.insert(name, stage);
    self
  }

  pub fn build(self) -> Workflow {
    Workflow::from_schema_unchecked(self.workflow_path, self.schema)
      .expect("Test workflow builder must build successfully")
  }
}

#[cfg(test)]
mod tests {
  use super::*;
  use crate::utils;

  #[test]
  fn workflow_builder_starts_without_test_fixture_data() {
    let builder: crate::workflow::WorkflowBuilder = Workflow::builder();
    let workflow = builder.build();

    assert!(workflow.schema().agents.is_empty());
    assert!(workflow.schema().issues.pull.command.is_empty());
    assert!(workflow.schema().issue.stages.is_empty());
  }

  #[test]
  fn workflow_builder_applies_fluent_overrides() {
    let temp = tempfile::tempdir().expect("tempdir");
    let workflow_path = temp.path().join("workflow.yml");

    let workflow = Workflow::builder()
      .max_issue_concurrency(10)
      .workspace_root(temp.path())
      .workflow_path(workflow_path.clone())
      .pull_command("printf '%s' '[]'")
      .after_issue_workdir_create_hook("echo created")
      .add_stage("implement", "todo", "./implement.md")
      .build();

    assert_eq!(workflow.schema().loop_.max_issue_concurrency, 10);
    assert_eq!(workflow.schema().workspace.root.as_deref(), Some(temp.path()));
    assert_eq!(workflow.schema().issues.pull.command, "printf '%s' '[]'");
    assert_eq!(
      workflow.schema().issue.hooks.after_create.as_deref(),
      Some("echo created")
    );
    assert_eq!(
      workflow
        .schema()
        .issue
        .stages
        .values()
        .map(|stage| stage.name.as_str())
        .collect::<Vec<_>>(),
      ["implement"]
    );
    assert_eq!(
      workflow.workspace().root(),
      temp
        .path()
        .join("workflows")
        .join(workflow_path.to_string_lossy().replace('/', "-"))
    );
  }

  #[test]
  fn workflow_builder_inline_stage_uses_map_key_as_stage_name() {
    let workflow = Workflow::builder()
      .add_inline_stage("plan", "todo", "plan on {{ issue.id }}")
      .build();

    assert_eq!(
      workflow
        .schema()
        .issue
        .stages
        .values()
        .map(|stage| stage.name.as_str())
        .collect::<Vec<_>>(),
      ["plan"]
    );
  }

  #[test]
  fn workflow_root_resolution() {
    let temp = tempfile::tempdir().expect("tempdir");
    let workflow_path = temp.path().join("workflow.yml");
    let workflow = Workflow::builder()
      .workspace_root(".vik")
      .workflow_path(workflow_path.clone())
      .build();

    assert_eq!(
      workflow.workspace().root(),
      temp
        .path()
        .join(".vik")
        .join("workflows")
        .join(workflow_path.to_string_lossy().replace('/', "-"))
    );
  }

  #[test]
  fn workflow_without_workspace_root_defaults_to_home_vik_and_workflow_namespace() {
    let temp = tempfile::tempdir().expect("tempdir");
    let workflow_path = temp.path().join("workflow.yml");
    let workflow = Workflow::builder()
      .workflow_path(workflow_path.clone())
      .without_workspace_root()
      .build();

    assert_eq!(
      workflow.workspace().root(),
      utils::paths::default_home()
        .join("workflows")
        .join(workflow_path.to_string_lossy().replace('/', "-"))
    );
  }
}