use anyhow::{bail, Result};
use serde::{Deserialize, Serialize};
use std::path::Path;
use crate::domain::providers::{load_sdd_config, SddConfig};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ExecutionIsolation {
#[default]
Local,
Worktree,
Cloud,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ArtifactTarget {
#[default]
Local,
Cloud,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct OrchestrationSettings {
pub isolation: ExecutionIsolation,
pub artifacts: ArtifactTarget,
}
const CLOUD_WORK_TRACKERS: &[&str] = &["jira", "linear", "github-issues"];
const CLOUD_DOCS: &[&str] = &["confluence", "notion"];
const CLOUD_REPOS: &[&str] = &["github", "gitlab", "bitbucket"];
pub fn load(root: &Path) -> Result<OrchestrationSettings> {
let cfg = load_sdd_config(root)?;
from_config(&cfg)
}
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)")
}
},
}
}
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)."
),
}
}
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)
}
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"));
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()
};
let err = ensure_artifact_target_ready(root, &settings).unwrap_err();
assert!(err.to_string().contains("provider integrado"));
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();
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);
let empty = tempfile::tempdir().unwrap();
assert_eq!(
load(empty.path()).unwrap(),
OrchestrationSettings::default()
);
}
}