use std::fs;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use serde::de::Deserializer;
use serde::Deserialize;
use crate::paths::state::{ExtensionBlock, StateLayout};
#[derive(Debug, Default, Deserialize)]
#[serde(default)]
struct BacklogProfileConfigFile {
backlog: BacklogProfileConfig,
extensions: Vec<ExtensionBlock>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(default)]
pub(crate) struct BacklogProfileConfig {
pub(crate) default: Option<String>,
pub(crate) adapters: std::collections::BTreeMap<String, BacklogAdapterConfig>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum BacklogAdapterKind {
Builtin,
ExternalCommand,
Unknown(String),
}
impl BacklogAdapterKind {
#[cfg(feature = "extension-backlog")]
pub(crate) fn as_config_str(&self) -> &str {
match self {
Self::Builtin => "builtin",
Self::ExternalCommand => "external-command",
Self::Unknown(value) => value.as_str(),
}
}
#[cfg(feature = "extension-backlog")]
pub(crate) fn unknown_value(&self) -> Option<&str> {
match self {
Self::Unknown(value) => Some(value.as_str()),
_ => None,
}
}
}
impl<'de> Deserialize<'de> for BacklogAdapterKind {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let raw = String::deserialize(deserializer)?;
Ok(match raw.as_str() {
"builtin" => Self::Builtin,
"external-command" => Self::ExternalCommand,
_ => Self::Unknown(raw),
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum BacklogBuiltinProvider {
GithubIssues,
GitlabIssues,
LocalMarkdown,
Unknown(String),
}
impl BacklogBuiltinProvider {
#[cfg(feature = "extension-backlog")]
pub(crate) fn as_config_str(&self) -> &str {
match self {
Self::GithubIssues => "github-issues",
Self::GitlabIssues => "gitlab-issues",
Self::LocalMarkdown => "local-markdown",
Self::Unknown(value) => value.as_str(),
}
}
#[cfg(feature = "extension-backlog")]
pub(crate) fn unknown_value(&self) -> Option<&str> {
match self {
Self::Unknown(value) => Some(value.as_str()),
_ => None,
}
}
}
impl<'de> Deserialize<'de> for BacklogBuiltinProvider {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let raw = String::deserialize(deserializer)?;
Ok(match raw.as_str() {
"github-issues" => Self::GithubIssues,
"gitlab-issues" => Self::GitlabIssues,
"local-markdown" => Self::LocalMarkdown,
_ => Self::Unknown(raw),
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum BacklogAdapterCapability {
Pull,
Next,
Claim,
SetStatus,
Complete,
PromoteNext,
ResolveRefs,
Unknown(String),
}
impl BacklogAdapterCapability {
pub(crate) fn known_name(&self) -> Option<&'static str> {
match self {
Self::Pull => Some("pull"),
Self::Next => Some("next"),
Self::Claim => Some("claim"),
Self::SetStatus => Some("set-status"),
Self::Complete => Some("complete"),
Self::PromoteNext => Some("promote-next"),
Self::ResolveRefs => Some("resolve-refs"),
Self::Unknown(_) => None,
}
}
}
impl<'de> Deserialize<'de> for BacklogAdapterCapability {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let raw = String::deserialize(deserializer)?;
Ok(match raw.as_str() {
"pull" => Self::Pull,
"next" => Self::Next,
"claim" => Self::Claim,
"set-status" => Self::SetStatus,
"complete" => Self::Complete,
"promote-next" => Self::PromoteNext,
"resolve-refs" | "resolve_refs" => Self::ResolveRefs,
_ => Self::Unknown(raw),
})
}
}
#[derive(Debug, Clone, Deserialize)]
pub(crate) struct BacklogAdapterConfig {
pub(crate) kind: BacklogAdapterKind,
#[serde(default)]
pub(crate) provider: Option<BacklogBuiltinProvider>,
#[serde(default)]
pub(crate) command: Option<Vec<String>>,
#[serde(default)]
pub(crate) timeout_s: Option<u64>,
#[serde(default)]
pub(crate) capabilities: Option<Vec<BacklogAdapterCapability>>,
}
impl BacklogAdapterConfig {
#[cfg(any(feature = "extension-backlog", test))]
pub(crate) fn declared_capability_names(&self) -> Vec<String> {
self.capabilities
.as_ref()
.map(|values| {
values
.iter()
.filter_map(|value| value.known_name().map(str::to_owned))
.collect()
})
.unwrap_or_default()
}
}
#[derive(Debug, Default, Deserialize)]
#[serde(default)]
pub(crate) struct RepoOverlayBacklogFile {
pub(crate) backlog: RepoOverlayBacklogConfig,
}
#[derive(Debug, Default, Deserialize)]
#[serde(default)]
pub(crate) struct RepoOverlayBacklogConfig {
pub(crate) adapter: Option<String>,
pub(crate) inputs: std::collections::BTreeMap<String, String>,
}
#[derive(Debug)]
#[cfg(feature = "extension-backlog")]
pub(crate) struct LoadedBacklogConfig {
pub(crate) profile: BacklogProfileConfig,
pub(crate) overlay: Option<RepoOverlayBacklogFile>,
}
#[cfg(feature = "extension-backlog")]
pub(crate) fn load_profile_config(layout: &StateLayout) -> Result<BacklogProfileConfig> {
load_profile_config_path(&layout.profile_config_path())
}
#[cfg(feature = "extension-backlog")]
pub(crate) fn load_for_repo(layout: &StateLayout, repo_root: &Path) -> Result<LoadedBacklogConfig> {
Ok(LoadedBacklogConfig {
profile: load_profile_config(layout)?,
overlay: load_repo_overlay_if_linked(layout, repo_root)?,
})
}
#[cfg(any(feature = "extension-backlog", test))]
pub(crate) fn repo_overlay_backlog_path(
layout: &StateLayout,
locality_id: &str,
) -> Result<PathBuf> {
Ok(layout.repo_overlay_root(locality_id)?.join("backlog.toml"))
}
#[cfg(feature = "extension-backlog")]
pub(crate) fn load_repo_overlay_if_linked(
layout: &StateLayout,
repo_root: &Path,
) -> Result<Option<RepoOverlayBacklogFile>> {
let Some(marker) = crate::repo::marker::load(repo_root)? else {
return Ok(None);
};
if let Some(overlay) = repo_overlay_from_config(layout, &marker.locality_id)? {
return Ok(Some(overlay));
}
let path = repo_overlay_backlog_path(layout, &marker.locality_id)?;
load_repo_overlay_path(&path)
}
fn repo_overlay_from_config(
layout: &StateLayout,
locality_id: &str,
) -> Result<Option<RepoOverlayBacklogFile>> {
let config = match layout.load_repo_overlay_config(locality_id)? {
Some(config) => config,
None => return Ok(None),
};
let backlog_ext = config
.extensions
.iter()
.find(|ext| ext.extension_type == "backlog");
let ext = match backlog_ext {
Some(ext) => ext,
None => return Ok(None),
};
let adapter = ext.name.clone();
let mut inputs = std::collections::BTreeMap::new();
for (key, value) in &ext.extra {
if matches!(
key.as_str(),
"kind" | "provider" | "command" | "timeout_s" | "capabilities"
) {
continue;
}
if let Some(s) = value.as_str() {
inputs.insert(key.clone(), s.to_owned());
}
}
Ok(Some(RepoOverlayBacklogFile {
backlog: RepoOverlayBacklogConfig { adapter, inputs },
}))
}
fn load_profile_config_path(path: &Path) -> Result<BacklogProfileConfig> {
let contents = match fs::read_to_string(path) {
Ok(contents) => contents,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return Ok(BacklogProfileConfig::default());
}
Err(error) => {
return Err(error).with_context(|| format!("failed to read {}", path.display()));
}
};
if contents.trim().is_empty() {
return Ok(BacklogProfileConfig::default());
}
let file = toml::from_str::<BacklogProfileConfigFile>(&contents)
.with_context(|| format!("failed to parse backlog config from {}", path.display()))?;
let backlog_extensions: Vec<&ExtensionBlock> = file
.extensions
.iter()
.filter(|ext| ext.extension_type == "backlog")
.collect();
if !backlog_extensions.is_empty() {
return Ok(backlog_profile_from_extensions(&backlog_extensions));
}
Ok(file.backlog)
}
fn backlog_profile_from_extensions(extensions: &[&ExtensionBlock]) -> BacklogProfileConfig {
let mut config = BacklogProfileConfig::default();
for ext in extensions {
let name = ext
.name
.clone()
.unwrap_or_else(|| format!("ext_{}", config.adapters.len()));
let kind = ext
.extra
.get("kind")
.and_then(|v| v.as_str())
.unwrap_or("builtin");
let adapter_kind = match kind {
"builtin" => BacklogAdapterKind::Builtin,
"external-command" => BacklogAdapterKind::ExternalCommand,
other => BacklogAdapterKind::Unknown(other.to_owned()),
};
let provider = ext
.extra
.get("provider")
.and_then(|v| v.as_str())
.map(|p| match p {
"github-issues" => BacklogBuiltinProvider::GithubIssues,
"gitlab-issues" => BacklogBuiltinProvider::GitlabIssues,
"local-markdown" => BacklogBuiltinProvider::LocalMarkdown,
other => BacklogBuiltinProvider::Unknown(other.to_owned()),
});
let command = ext.extra.get("command").and_then(|v| {
v.as_array().map(|arr| {
arr.iter()
.filter_map(|item| item.as_str().map(str::to_owned))
.collect()
})
});
let timeout_s = ext
.extra
.get("timeout_s")
.and_then(|v| v.as_integer())
.map(|v| v as u64);
let capabilities = ext.extra.get("capabilities").and_then(|v| {
v.as_array().map(|arr| {
arr.iter()
.filter_map(|item| item.as_str())
.map(|cap| match cap {
"pull" => BacklogAdapterCapability::Pull,
"next" => BacklogAdapterCapability::Next,
"claim" => BacklogAdapterCapability::Claim,
"set-status" => BacklogAdapterCapability::SetStatus,
"complete" => BacklogAdapterCapability::Complete,
"promote-next" => BacklogAdapterCapability::PromoteNext,
"resolve-refs" | "resolve_refs" => BacklogAdapterCapability::ResolveRefs,
other => BacklogAdapterCapability::Unknown(other.to_owned()),
})
.collect()
})
});
config.adapters.insert(
name.clone(),
BacklogAdapterConfig {
kind: adapter_kind,
provider,
command,
timeout_s,
capabilities,
},
);
if ext.default {
config.default = Some(name);
}
}
config
}
fn load_repo_overlay_path(path: &Path) -> Result<Option<RepoOverlayBacklogFile>> {
let raw = match fs::read_to_string(path) {
Ok(contents) => contents,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(error) => {
return Err(error).with_context(|| format!("failed to read {}", path.display()));
}
};
if raw.trim().is_empty() {
return Ok(Some(RepoOverlayBacklogFile::default()));
}
toml::from_str(&raw)
.with_context(|| format!("failed to parse backlog config from {}", path.display()))
.map(Some)
}
#[cfg(test)]
mod tests {
use std::fs;
use tempfile::tempdir;
use super::*;
use crate::profile::ProfileName;
#[test]
fn profile_config_parses_backlog_section() {
let temp = tempdir().expect("tempdir");
let config_path = temp.path().join("config.toml");
fs::write(
&config_path,
r#"
[backlog]
default = "github"
[backlog.adapters.github]
kind = "builtin"
provider = "github-issues"
"#,
)
.expect("write config");
let config = load_profile_config_path(&config_path).expect("parse config");
assert_eq!(config.default.as_deref(), Some("github"));
assert!(config.adapters.contains_key("github"));
assert_eq!(config.adapters["github"].kind, BacklogAdapterKind::Builtin);
assert_eq!(
config.adapters["github"].provider,
Some(BacklogBuiltinProvider::GithubIssues)
);
}
#[test]
fn repo_overlay_backlog_path_is_derived_from_repo_overlay_root() {
let layout = StateLayout::new(
PathBuf::from("/tmp/home/.ccd"),
PathBuf::from("/tmp/repo/.git/ccd"),
ProfileName::new("main").expect("profile"),
);
assert_eq!(
repo_overlay_backlog_path(&layout, "ccdrepo_123").expect("backlog path"),
PathBuf::from("/tmp/home/.ccd/profiles/main/repos/ccdrepo_123/backlog.toml")
);
}
#[test]
fn repo_overlay_backlog_parses_adapter_binding() {
let temp = tempdir().expect("tempdir");
let toml_path = temp.path().join("backlog.toml");
fs::write(
&toml_path,
r#"
[backlog]
adapter = "github"
[backlog.inputs]
repo = "dusk-network/ccd"
"#,
)
.expect("write backlog.toml");
let binding = load_repo_overlay_path(&toml_path).expect("parse");
let binding = binding.expect("binding should be present");
assert_eq!(binding.backlog.adapter.as_deref(), Some("github"));
assert_eq!(
binding.backlog.inputs.get("repo").map(String::as_str),
Some("dusk-network/ccd")
);
}
#[test]
fn repo_overlay_backlog_rejects_invalid_toml() {
let temp = tempdir().expect("tempdir");
let toml_path = temp.path().join("backlog.toml");
fs::write(&toml_path, "[backlog\nadapter = \"github\"\n").expect("write backlog.toml");
let error = load_repo_overlay_path(&toml_path).expect_err("invalid TOML should fail");
assert!(error.to_string().contains("failed to parse backlog config"));
assert!(error.to_string().contains("backlog.toml"));
}
#[test]
fn backlog_adapter_config_parses_capabilities_field() {
let temp = tempdir().expect("tempdir");
let config_path = temp.path().join("config.toml");
fs::write(
&config_path,
r#"
[backlog.adapters.custom]
kind = "external-command"
command = ["my-adapter"]
capabilities = ["pull", "next"]
"#,
)
.expect("write config");
let config = load_profile_config_path(&config_path).expect("parse config");
let caps = config.adapters["custom"]
.capabilities
.as_ref()
.expect("capabilities should be Some");
assert_eq!(
caps,
&[
BacklogAdapterCapability::Pull,
BacklogAdapterCapability::Next,
]
);
}
#[test]
fn profile_extensions_build_backlog_config() {
let temp = tempdir().expect("tempdir");
let config_path = temp.path().join("config.toml");
fs::write(
&config_path,
r#"
[[extensions]]
type = "backlog"
name = "github"
kind = "builtin"
provider = "github-issues"
default = true
[[extensions]]
type = "backlog"
name = "local"
kind = "builtin"
provider = "local-markdown"
"#,
)
.expect("write config");
let config = load_profile_config_path(&config_path).expect("parse config");
assert_eq!(config.default.as_deref(), Some("github"));
assert_eq!(config.adapters.len(), 2);
assert!(config.adapters.contains_key("github"));
assert!(config.adapters.contains_key("local"));
assert_eq!(config.adapters["github"].kind, BacklogAdapterKind::Builtin);
assert_eq!(
config.adapters["github"].provider,
Some(BacklogBuiltinProvider::GithubIssues)
);
assert_eq!(
config.adapters["local"].provider,
Some(BacklogBuiltinProvider::LocalMarkdown)
);
}
#[test]
fn profile_extensions_preferred_over_legacy_backlog_section() {
let temp = tempdir().expect("tempdir");
let config_path = temp.path().join("config.toml");
fs::write(
&config_path,
r#"
[backlog]
default = "old-adapter"
[backlog.adapters.old-adapter]
kind = "builtin"
provider = "local-markdown"
[[extensions]]
type = "backlog"
name = "new-adapter"
kind = "builtin"
provider = "github-issues"
default = true
"#,
)
.expect("write config");
let config = load_profile_config_path(&config_path).expect("parse config");
assert_eq!(config.default.as_deref(), Some("new-adapter"));
assert_eq!(config.adapters.len(), 1);
assert!(config.adapters.contains_key("new-adapter"));
assert!(!config.adapters.contains_key("old-adapter"));
}
#[test]
fn profile_extensions_ignores_non_backlog_types() {
let temp = tempdir().expect("tempdir");
let config_path = temp.path().join("config.toml");
fs::write(
&config_path,
r#"
[[extensions]]
type = "other"
name = "something"
[backlog]
default = "github"
[backlog.adapters.github]
kind = "builtin"
provider = "github-issues"
"#,
)
.expect("write config");
let config = load_profile_config_path(&config_path).expect("parse config");
assert_eq!(config.default.as_deref(), Some("github"));
assert!(config.adapters.contains_key("github"));
}
#[test]
fn repo_overlay_config_extensions_build_backlog_overlay() {
let temp = tempdir().expect("tempdir");
let layout = StateLayout::new(
temp.path().join(".ccd"),
temp.path().join("repo/.git/ccd"),
ProfileName::new("main").expect("profile"),
);
let locality_id = "ccdrepo_bl";
fs::create_dir_all(layout.repo_overlay_root(locality_id).expect("repo overlay"))
.expect("repo overlay");
fs::write(
layout
.repo_overlay_config_path(locality_id)
.expect("config path"),
r#"
[[extensions]]
type = "backlog"
name = "github"
repo = "dusk-network/ccd"
base_branch = "main"
"#,
)
.expect("config.toml");
let result = repo_overlay_from_config(&layout, locality_id).expect("parse");
let overlay = result.expect("should be Some");
assert_eq!(overlay.backlog.adapter.as_deref(), Some("github"));
assert_eq!(
overlay.backlog.inputs.get("repo").map(String::as_str),
Some("dusk-network/ccd")
);
assert_eq!(
overlay
.backlog
.inputs
.get("base_branch")
.map(String::as_str),
Some("main")
);
}
#[test]
fn repo_overlay_config_without_backlog_extension_returns_none() {
let temp = tempdir().expect("tempdir");
let layout = StateLayout::new(
temp.path().join(".ccd"),
temp.path().join("repo/.git/ccd"),
ProfileName::new("main").expect("profile"),
);
let locality_id = "ccdrepo_bl";
fs::create_dir_all(layout.repo_overlay_root(locality_id).expect("repo overlay"))
.expect("repo overlay");
fs::write(
layout
.repo_overlay_config_path(locality_id)
.expect("config path"),
"[sources]\nalways = [\"AGENTS.md\"]\n",
)
.expect("config.toml");
let result = repo_overlay_from_config(&layout, locality_id).expect("parse");
assert!(result.is_none());
}
}