sdd-layer 0.24.0

Spec-Driven Development CLI and agent harness
//! Preferências do fluxo SDD (seção `orchestration:` do `sdd.config.yaml`).
//!
//! Dois eixos config-driven, ambos com default `local`:
//!
//! - `orchestration.isolation` — onde a execução acontece: `local` (checkout
//!   normal no branch da orquestração), `worktree` (cópias isoladas em
//!   `.worktree/<key>/`) ou `cloud` (reservado — ainda sem integração; o
//!   setup falha com orientação clara quando selecionado).
//! - `orchestration.artifacts` — destino canônico dos artefatos: `local`
//!   (`docs/<slug>/`) ou `cloud` (providers integrados — Jira, Confluence,
//!   GitHub etc. — com o Markdown local mantido como espelho/fallback).
//!
//! A decisão de isolamento NÃO é mais uma pergunta obrigatória a cada
//! orquestração: o primeiro `sdd orchestration next` resolve
//! `--worktree yes|no` (flag pontual) > `orchestration.isolation` (config) >
//! default `local`.

use anyhow::{bail, Result};
use serde::{Deserialize, Serialize};
use std::path::Path;

use crate::domain::providers::{load_sdd_config, SddConfig};

/// Isolamento da execução da orquestração.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ExecutionIsolation {
    /// Checkout normal no branch da orquestração (sem `.worktree/`).
    #[default]
    Local,
    /// Cópias de trabalho isoladas em `.worktree/<key>/`.
    Worktree,
    /// Execução em ambiente cloud (reservado — ainda sem integração).
    Cloud,
}

/// Destino canônico dos artefatos gerados pelo fluxo.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ArtifactTarget {
    /// Markdown local em `docs/<slug>/`.
    #[default]
    Local,
    /// Providers integrados (Jira/Confluence/GitHub...); o Markdown local
    /// permanece como espelho/fallback.
    Cloud,
}

/// Preferências efetivas da seção `orchestration:` (defaults aplicados).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct OrchestrationSettings {
    pub isolation: ExecutionIsolation,
    pub artifacts: ArtifactTarget,
}

/// Work trackers considerados "cloud" (provider integrado, não local).
const CLOUD_WORK_TRACKERS: &[&str] = &["jira", "linear", "github-issues"];
/// Sistemas de docs considerados "cloud".
const CLOUD_DOCS: &[&str] = &["confluence", "notion"];
/// Hosts de repositório considerados "cloud".
const CLOUD_REPOS: &[&str] = &["github", "gitlab", "bitbucket"];

/// Lê e valida a seção `orchestration:` do `sdd.config.yaml` de `root`.
/// Sem config (ou sem a seção), retorna os defaults (`local`/`local`).
pub fn load(root: &Path) -> Result<OrchestrationSettings> {
    let cfg = load_sdd_config(root)?;
    from_config(&cfg)
}

/// Valida os valores brutos da seção `orchestration:` de um config já lido.
pub fn from_config(cfg: &SddConfig) -> Result<OrchestrationSettings> {
    Ok(OrchestrationSettings {
        isolation: parse_isolation(cfg.orchestration.isolation.as_deref())?,
        artifacts: parse_artifacts(cfg.orchestration.artifacts.as_deref())?,
    })
}

fn parse_isolation(raw: Option<&str>) -> Result<ExecutionIsolation> {
    match raw.map(str::trim).filter(|v| !v.is_empty()) {
        None => Ok(ExecutionIsolation::default()),
        Some(v) => match v.to_ascii_lowercase().as_str() {
            "local" => Ok(ExecutionIsolation::Local),
            "worktree" => Ok(ExecutionIsolation::Worktree),
            "cloud" => Ok(ExecutionIsolation::Cloud),
            other => bail!(
                "valor inválido para orchestration.isolation: '{other}' (use local|worktree|cloud)"
            ),
        },
    }
}

fn parse_artifacts(raw: Option<&str>) -> Result<ArtifactTarget> {
    match raw.map(str::trim).filter(|v| !v.is_empty()) {
        None => Ok(ArtifactTarget::default()),
        Some(v) => match v.to_ascii_lowercase().as_str() {
            "local" => Ok(ArtifactTarget::Local),
            "cloud" => Ok(ArtifactTarget::Cloud),
            other => {
                bail!("valor inválido para orchestration.artifacts: '{other}' (use local|cloud)")
            }
        },
    }
}

/// Resolve o isolamento efetivo do primeiro `next`: a decisão pontual do
/// usuário via `--worktree yes|no` (`Some`) tem precedência sobre o config;
/// sem flag, vale `orchestration.isolation` (default `local`).
///
/// Retorna `true` quando o fluxo usa git worktrees. `cloud` é valor de
/// config válido, mas ainda não tem integração — falha com orientação clara
/// em vez de criar branch/worktree silenciosamente no modo errado.
pub fn resolve_use_worktrees(
    worktree_choice: Option<bool>,
    settings: &OrchestrationSettings,
) -> Result<bool> {
    let isolation = match worktree_choice {
        Some(true) => ExecutionIsolation::Worktree,
        Some(false) => ExecutionIsolation::Local,
        None => settings.isolation,
    };
    match isolation {
        ExecutionIsolation::Local => Ok(false),
        ExecutionIsolation::Worktree => Ok(true),
        ExecutionIsolation::Cloud => bail!(
            "orchestration.isolation: cloud ainda não tem integração disponível.\n\
             Ajuste `orchestration.isolation` para 'local' ou 'worktree' no sdd.config.yaml \
             (ou rode com `--worktree yes|no` para decidir pontualmente)."
        ),
    }
}

/// Há pelo menos um provider "cloud" integrado configurado em `systems:`?
pub fn cloud_provider_configured(cfg: &SddConfig) -> bool {
    let matches = |raw: Option<&str>, known: &[&str]| {
        raw.map(str::trim)
            .map(str::to_ascii_lowercase)
            .is_some_and(|v| known.contains(&v.as_str()))
    };
    matches(cfg.systems.work_tracker.as_deref(), CLOUD_WORK_TRACKERS)
        || matches(cfg.systems.docs.as_deref(), CLOUD_DOCS)
        || matches(cfg.systems.repo.as_deref(), CLOUD_REPOS)
}

/// Garante que `orchestration.artifacts: cloud` tem provider integrado para
/// receber os artefatos canônicos. Sem provider, falha orientando o ajuste —
/// o Markdown local continuaria sendo o único destino real, o que quebraria
/// a expectativa declarada no config.
pub fn ensure_artifact_target_ready(root: &Path, settings: &OrchestrationSettings) -> Result<()> {
    if settings.artifacts != ArtifactTarget::Cloud {
        return Ok(());
    }
    let cfg = load_sdd_config(root)?;
    if cloud_provider_configured(&cfg) {
        return Ok(());
    }
    bail!(
        "orchestration.artifacts: cloud exige pelo menos um provider integrado configurado.\n\
         Ajuste `systems:` no sdd.config.yaml — ex.: work_tracker: jira|linear|github-issues, \
         docs: confluence|notion ou repo: github|gitlab|bitbucket — \
         ou volte `orchestration.artifacts` para 'local'."
    )
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::domain::providers::{OrchestrationConfig, SystemsConfig};

    fn cfg_with(isolation: Option<&str>, artifacts: Option<&str>) -> SddConfig {
        SddConfig {
            orchestration: OrchestrationConfig {
                isolation: isolation.map(str::to_string),
                artifacts: artifacts.map(str::to_string),
            },
            ..SddConfig::default()
        }
    }

    #[test]
    fn defaults_are_local_local_without_section() {
        let settings = from_config(&SddConfig::default()).unwrap();
        assert_eq!(settings.isolation, ExecutionIsolation::Local);
        assert_eq!(settings.artifacts, ArtifactTarget::Local);
    }

    #[test]
    fn parses_valid_values_case_insensitively() {
        let settings = from_config(&cfg_with(Some(" Worktree "), Some("CLOUD"))).unwrap();
        assert_eq!(settings.isolation, ExecutionIsolation::Worktree);
        assert_eq!(settings.artifacts, ArtifactTarget::Cloud);
    }

    #[test]
    fn rejects_invalid_values_with_targeted_errors() {
        let err = from_config(&cfg_with(Some("sandbox"), None)).unwrap_err();
        assert!(err.to_string().contains("orchestration.isolation"));
        assert!(err.to_string().contains("local|worktree|cloud"));
        let err = from_config(&cfg_with(None, Some("s3"))).unwrap_err();
        assert!(err.to_string().contains("orchestration.artifacts"));
        assert!(err.to_string().contains("local|cloud"));
    }

    #[test]
    fn flag_overrides_config() {
        let settings = OrchestrationSettings {
            isolation: ExecutionIsolation::Worktree,
            ..OrchestrationSettings::default()
        };
        assert!(!resolve_use_worktrees(Some(false), &settings).unwrap());
        let settings = OrchestrationSettings::default();
        assert!(resolve_use_worktrees(Some(true), &settings).unwrap());
    }

    #[test]
    fn config_drives_isolation_without_flag() {
        let worktree = OrchestrationSettings {
            isolation: ExecutionIsolation::Worktree,
            ..OrchestrationSettings::default()
        };
        assert!(resolve_use_worktrees(None, &worktree).unwrap());
        let local = OrchestrationSettings::default();
        assert!(!resolve_use_worktrees(None, &local).unwrap());
    }

    #[test]
    fn cloud_isolation_fails_with_clear_guidance() {
        let settings = OrchestrationSettings {
            isolation: ExecutionIsolation::Cloud,
            ..OrchestrationSettings::default()
        };
        let err = resolve_use_worktrees(None, &settings).unwrap_err();
        assert!(err.to_string().contains("ainda não tem integração"));
        // O flag pontual continua conseguindo destravar o fluxo.
        assert!(resolve_use_worktrees(Some(true), &settings).unwrap());
    }

    #[test]
    fn cloud_artifacts_require_integrated_provider() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        let settings = OrchestrationSettings {
            artifacts: ArtifactTarget::Cloud,
            ..OrchestrationSettings::default()
        };

        // Sem sdd.config.yaml → sem provider → erro orientando o ajuste.
        let err = ensure_artifact_target_ready(root, &settings).unwrap_err();
        assert!(err.to_string().contains("provider integrado"));

        // systems.work_tracker: jira → pronto.
        let cfg = SddConfig {
            systems: SystemsConfig {
                work_tracker: Some("jira".to_string()),
                ..SystemsConfig::default()
            },
            ..SddConfig::default()
        };
        assert!(cloud_provider_configured(&cfg));
        std::fs::write(
            root.join("sdd.config.yaml"),
            "systems:\n  work_tracker: jira\norchestration:\n  artifacts: cloud\n",
        )
        .unwrap();
        ensure_artifact_target_ready(root, &settings).unwrap();

        // markdown/none/git não contam como cloud.
        let local_only = SddConfig {
            systems: SystemsConfig {
                work_tracker: Some("markdown".to_string()),
                docs: Some("markdown".to_string()),
                repo: Some("git".to_string()),
                ..SystemsConfig::default()
            },
            ..SddConfig::default()
        };
        assert!(!cloud_provider_configured(&local_only));
    }

    #[test]
    fn local_artifacts_skip_provider_requirement() {
        let dir = tempfile::tempdir().unwrap();
        let settings = OrchestrationSettings::default();
        ensure_artifact_target_ready(dir.path(), &settings).unwrap();
    }

    #[test]
    fn load_reads_section_from_disk() {
        let dir = tempfile::tempdir().unwrap();
        std::fs::write(
            dir.path().join("sdd.config.yaml"),
            "orchestration:\n  isolation: worktree\n  artifacts: local\n",
        )
        .unwrap();
        let settings = load(dir.path()).unwrap();
        assert_eq!(settings.isolation, ExecutionIsolation::Worktree);
        assert_eq!(settings.artifacts, ArtifactTarget::Local);
        // Arquivo inexistente → defaults.
        let empty = tempfile::tempdir().unwrap();
        assert_eq!(
            load(empty.path()).unwrap(),
            OrchestrationSettings::default()
        );
    }
}