#[cfg(any(feature = "extension-backlog", test))]
use std::collections::BTreeMap;
use std::path::PathBuf;
#[cfg(any(feature = "extension-backlog", test))]
use serde::{Deserialize, Serialize};
#[cfg(any(feature = "extension-backlog", test))]
use super::backlog_state::GitHubBacklogItem;
#[cfg(any(feature = "extension-backlog", test))]
use crate::paths::git as git_paths;
#[cfg(any(feature = "extension-backlog", test))]
use crate::state::issue_refs::{ContinuityReferenceQuery, TransientContinuityReferenceResolution};
#[cfg(any(feature = "extension-backlog", test))]
#[derive(Debug, Clone)]
pub enum ResolvedAdapter {
Builtin {
#[cfg(any(feature = "extension-backlog", test))]
name: String,
provider: BuiltinProvider,
source: AdapterSource,
},
ExternalCommand(ExternalCommandConfig),
}
#[cfg(any(feature = "extension-backlog", test))]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BuiltinProvider {
GithubIssues,
LocalMarkdown,
}
#[cfg(any(feature = "extension-backlog", test))]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AdapterSource {
RepoOverlay,
ProfileDefault,
BuiltinFallback,
}
#[cfg(any(feature = "extension-backlog", test))]
impl AdapterSource {
#[cfg_attr(not(feature = "extension-backlog"), allow(dead_code))]
pub fn as_str(self) -> &'static str {
match self {
Self::RepoOverlay => "repo-overlay",
Self::ProfileDefault => "profile-default",
Self::BuiltinFallback => "built-in-fallback",
}
}
}
#[cfg(any(feature = "extension-backlog", test))]
#[derive(Debug, Clone)]
pub struct ExternalCommandConfig {
#[cfg(any(feature = "extension-backlog", test))]
pub name: String,
#[cfg(any(feature = "extension-backlog", test))]
pub command: Vec<String>,
#[cfg(any(feature = "extension-backlog", test))]
pub timeout_s: u64,
#[cfg(any(feature = "extension-backlog", test))]
pub capabilities: Vec<String>,
pub source: AdapterSource,
}
#[cfg(any(feature = "extension-backlog", test))]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BacklogAdapterCapabilities {
pub pull: bool,
pub next: bool,
pub claim: bool,
pub set_status: bool,
pub complete: bool,
pub promote_next: bool,
#[serde(default)]
pub resolve_refs: bool,
}
#[cfg(any(feature = "extension-backlog", test))]
impl BacklogAdapterCapabilities {
#[cfg(feature = "extension-backlog")]
pub fn none() -> Self {
Self {
pull: false,
next: false,
claim: false,
set_status: false,
complete: false,
promote_next: false,
resolve_refs: false,
}
}
#[cfg(any(feature = "extension-backlog", test))]
pub fn read_only() -> Self {
Self {
pull: true,
next: true,
claim: false,
set_status: false,
complete: false,
promote_next: false,
resolve_refs: false,
}
}
#[cfg(feature = "extension-backlog")]
pub fn supported_ops(&self) -> Vec<&'static str> {
let mut ops = Vec::new();
if self.pull {
ops.push("pull");
}
if self.next {
ops.push("next");
}
if self.claim {
ops.push("claim");
}
if self.set_status {
ops.push("set-status");
}
if self.complete {
ops.push("complete");
}
if self.promote_next {
ops.push("promote-next");
}
if self.resolve_refs {
ops.push("resolve-refs");
}
ops
}
}
#[cfg(any(feature = "extension-backlog", test))]
#[derive(Debug, Clone, Serialize)]
pub struct AdapterRequest {
pub schema_version: u32,
pub operation: String,
pub profile: String,
pub repo_root: PathBuf,
pub locality_id: String,
pub inputs: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub continuity_refs: Option<Vec<ContinuityReferenceQuery>>,
}
#[cfg(any(feature = "extension-backlog", test))]
impl AdapterRequest {
pub fn new(
operation: String,
profile: String,
repo_root: PathBuf,
locality_id: String,
inputs: BTreeMap<String, String>,
) -> Self {
Self {
schema_version: 1,
operation,
profile,
repo_root,
locality_id,
inputs,
continuity_refs: None,
}
}
}
#[cfg(any(feature = "extension-backlog", test))]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AdapterResponse {
pub schema_version: u32,
pub ok: bool,
pub provider: String,
#[serde(default)]
pub error: Option<String>,
#[serde(default)]
pub fetched_at_epoch_s: Option<u64>,
#[serde(default)]
pub stale_after_s: Option<u64>,
#[serde(default)]
pub items: Option<Vec<GitHubBacklogItem>>,
#[serde(default)]
pub resolved_refs: Option<Vec<TransientContinuityReferenceResolution>>,
#[serde(default)]
pub capabilities: Option<BacklogAdapterCapabilities>,
}
#[cfg(any(feature = "extension-backlog", test))]
impl ResolvedAdapter {
#[cfg(any(feature = "extension-backlog", test))]
pub fn name(&self) -> &str {
match self {
Self::Builtin { name, .. } => name,
Self::ExternalCommand(cfg) => &cfg.name,
}
}
#[cfg_attr(not(feature = "extension-backlog"), allow(dead_code))]
pub fn source(&self) -> AdapterSource {
match self {
Self::Builtin { source, .. } => *source,
Self::ExternalCommand(cfg) => cfg.source,
}
}
#[cfg_attr(not(feature = "extension-backlog"), allow(dead_code))]
pub fn source_str(&self) -> &'static str {
self.source().as_str()
}
#[cfg(any(feature = "extension-backlog", test))]
pub fn is_github_adapter(&self) -> bool {
matches!(
self,
Self::Builtin {
provider: BuiltinProvider::GithubIssues,
..
}
)
}
}
use std::path::Path;
#[cfg(any(feature = "extension-backlog", test))]
use anyhow::bail;
use anyhow::Result;
#[cfg(feature = "extension-backlog")]
use super::backlog_config::{self, LoadedBacklogConfig};
#[cfg(any(feature = "extension-backlog", test))]
use super::backlog_config::{BacklogAdapterConfig, BacklogAdapterKind, BacklogBuiltinProvider};
use crate::paths::state::StateLayout;
#[cfg(feature = "extension-backlog")]
use crate::repo::registry as repo_registry;
#[cfg(any(feature = "extension-backlog", test))]
pub fn build_resolved_adapter(
name: &str,
config: Option<&BacklogAdapterConfig>,
source: AdapterSource,
) -> ResolvedAdapter {
match config {
Some(cfg) if matches!(cfg.kind, BacklogAdapterKind::ExternalCommand) => {
ResolvedAdapter::ExternalCommand(ExternalCommandConfig {
#[cfg(any(feature = "extension-backlog", test))]
name: name.to_owned(),
#[cfg(any(feature = "extension-backlog", test))]
command: cfg.command.clone().unwrap_or_default(),
#[cfg(any(feature = "extension-backlog", test))]
timeout_s: cfg.timeout_s.unwrap_or(30),
#[cfg(any(feature = "extension-backlog", test))]
capabilities: cfg.declared_capability_names(),
source,
})
}
Some(cfg) if matches!(cfg.kind, BacklogAdapterKind::Builtin) => {
let provider = match cfg.provider.as_ref() {
Some(BacklogBuiltinProvider::GithubIssues) => BuiltinProvider::GithubIssues,
Some(BacklogBuiltinProvider::LocalMarkdown) => BuiltinProvider::LocalMarkdown,
_ => BuiltinProvider::LocalMarkdown,
};
ResolvedAdapter::Builtin {
#[cfg(any(feature = "extension-backlog", test))]
name: name.to_owned(),
provider,
source,
}
}
_ => {
let provider = if name.eq_ignore_ascii_case("github")
|| name.eq_ignore_ascii_case("github-issues")
{
BuiltinProvider::GithubIssues
} else {
BuiltinProvider::LocalMarkdown
};
ResolvedAdapter::Builtin {
#[cfg(any(feature = "extension-backlog", test))]
name: name.to_owned(),
provider,
source,
}
}
}
}
#[cfg(feature = "extension-backlog")]
pub(crate) fn validate_registered_adapter(
adapter_name: &str,
cfg: &BacklogAdapterConfig,
) -> Result<()> {
if let Some(kind) = cfg.kind.unknown_value() {
if kind.is_empty() {
bail!(
"backlog adapter `{adapter_name}` has an empty `kind` field in profile config; \
valid kinds are `builtin` and `external-command`",
);
}
bail!(
"backlog adapter `{adapter_name}` declares unknown kind `{kind}`; valid kinds are \
`builtin` and `external-command`"
);
}
if matches!(cfg.kind, BacklogAdapterKind::Builtin) {
match cfg.provider.as_ref() {
Some(provider) => {
if let Some(provider) = provider.unknown_value() {
bail!(
"backlog adapter `{adapter_name}` declares kind=\"builtin\" with unknown \
provider `{provider}`; does not yet support pull. \
Valid built-in providers are `github-issues` and `local-markdown`."
);
}
}
None => {
bail!(
"backlog adapter `{adapter_name}` declares kind=\"builtin\" but does not set \
`provider`; valid built-in providers are `github-issues` and \
`local-markdown`."
);
}
}
}
Ok(())
}
#[cfg(feature = "extension-backlog")]
fn resolve_adapter_with_loaded(
loaded: &LoadedBacklogConfig,
repo_input_override: Option<&str>,
) -> Result<(ResolvedAdapter, BTreeMap<String, String>)> {
let overlay = loaded.overlay.as_ref();
let (adapter_name, source) = if let Some(name) = overlay.and_then(|o| o.backlog.adapter.clone())
{
(name, AdapterSource::RepoOverlay)
} else if let Some(name) = loaded.profile.default.clone() {
(name, AdapterSource::ProfileDefault)
} else {
("local-markdown".to_owned(), AdapterSource::BuiltinFallback)
};
let adapter_config = loaded.profile.adapters.get(&adapter_name);
if let Some(cfg) = adapter_config {
validate_registered_adapter(&adapter_name, cfg)?;
}
let mut inputs = overlay
.map(|value| value.backlog.inputs.clone())
.unwrap_or_default();
if let Some(repo) = repo_input_override {
inputs.insert("repo".to_owned(), repo.to_owned());
}
if adapter_config.is_none()
&& !adapter_name.eq_ignore_ascii_case("github")
&& !adapter_name.eq_ignore_ascii_case("github-issues")
&& !adapter_name.eq_ignore_ascii_case("local-markdown")
{
bail!(
"backlog adapter `{adapter_name}` is not registered in profile config and is \
not a recognized built-in name"
);
}
let adapter = build_resolved_adapter(&adapter_name, adapter_config, source);
Ok((adapter, inputs))
}
#[cfg(feature = "extension-backlog")]
pub fn resolve_adapter(
layout: &StateLayout,
repo_root: &Path,
repo_input_override: Option<&str>,
) -> Result<(ResolvedAdapter, BTreeMap<String, String>)> {
let loaded = backlog_config::load_for_repo(layout, repo_root)?;
resolve_adapter_with_loaded(&loaded, repo_input_override)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GitHubBacklogFallbackDiagnostic {
pub github_repo: String,
pub profile_config_path: PathBuf,
pub repo_overlay_config_path: PathBuf,
pub missing_profile_default: bool,
pub missing_repo_binding: bool,
pub missing_repo_input: bool,
}
impl GitHubBacklogFallbackDiagnostic {
pub fn message(&self) -> String {
let mut reasons = Vec::new();
if self.missing_repo_binding {
reasons.push(format!(
"{} does not bind a backlog extension yet",
self.repo_overlay_config_path.display()
));
} else if self.missing_repo_input {
reasons.push(format!(
"{} does not set `repo = \"{}\"` on the backlog extension",
self.repo_overlay_config_path.display(),
self.github_repo
));
}
let reason = match reasons.len() {
0 => "the GitHub backlog binding is not active".to_owned(),
1 => reasons[0].clone(),
_ => format!(
"{} and {}",
reasons[..reasons.len() - 1].join(", "),
reasons[reasons.len() - 1]
),
};
format!(
"repo registry indicates GitHub repo `{}`, but the active backlog adapter is still \
the built-in `local-markdown` fallback because {}; run `ccd backlog pull --path .` \
to activate the GitHub backlog from the detected `origin` remote when it is \
unambiguous, or pass `--repo {}` to write the GitHub backlog binding in `{}`",
self.github_repo,
reason,
self.github_repo,
self.repo_overlay_config_path.display(),
)
}
}
#[cfg(feature = "extension-backlog")]
pub fn diagnose_github_backlog_fallback(
layout: &StateLayout,
repo_root: &Path,
locality_id: &str,
) -> Result<Option<GitHubBacklogFallbackDiagnostic>> {
let loaded = backlog_config::load_for_repo(layout, repo_root)?;
let (resolved, inputs) = resolve_adapter_with_loaded(&loaded, None)?;
let source = resolved.source();
let ResolvedAdapter::Builtin {
provider: BuiltinProvider::LocalMarkdown,
..
} = resolved
else {
return Ok(None);
};
if source != AdapterSource::BuiltinFallback {
return Ok(None);
}
let registry_path = layout.repo_metadata_path(locality_id)?;
let Some(registry) = repo_registry::load(®istry_path)? else {
return Ok(None);
};
let Some(github_repo) = registry
.aliases
.remote_urls
.iter()
.find_map(|remote| github_repo_from_remote_url(remote))
.or_else(|| inputs.get("repo").cloned())
else {
return Ok(None);
};
let overlay_config_path = layout.repo_overlay_config_path(locality_id)?;
let missing_profile_default = loaded.profile.default.is_none();
let missing_repo_binding = loaded
.overlay
.as_ref()
.and_then(|value| value.backlog.adapter.as_deref())
.is_none();
let missing_repo_input = loaded
.overlay
.as_ref()
.and_then(|value| value.backlog.inputs.get("repo"))
.map(|value| value != &github_repo)
.unwrap_or(true);
Ok(Some(GitHubBacklogFallbackDiagnostic {
github_repo,
profile_config_path: layout.profile_config_path(),
repo_overlay_config_path: overlay_config_path,
missing_profile_default,
missing_repo_binding,
missing_repo_input,
}))
}
#[cfg(not(feature = "extension-backlog"))]
pub fn diagnose_github_backlog_fallback(
_layout: &StateLayout,
_repo_root: &Path,
_locality_id: &str,
) -> Result<Option<GitHubBacklogFallbackDiagnostic>> {
Ok(None)
}
#[cfg(any(feature = "extension-backlog", test))]
fn github_repo_from_remote_url(remote: &str) -> Option<String> {
git_paths::parse_github_owner_repo(remote)
}
#[cfg(feature = "extension-backlog")]
pub fn find_in_path(binary: &str) -> Option<PathBuf> {
let path = Path::new(binary);
if path.is_absolute() || binary.contains(std::path::MAIN_SEPARATOR) {
return if path.is_file() {
Some(path.to_owned())
} else {
None
};
}
let path_var = std::env::var_os("PATH")?;
for dir in std::env::split_paths(&path_var) {
let candidate = dir.join(binary);
if candidate.is_file() {
return Some(candidate);
}
}
None
}
#[cfg(feature = "extension-backlog")]
fn normalize_capability_name(name: &str) -> String {
name.trim().to_ascii_lowercase().replace('-', "_")
}
#[cfg(feature = "extension-backlog")]
pub fn execute_external(
config: &ExternalCommandConfig,
request: &AdapterRequest,
) -> Result<AdapterResponse> {
use std::io::Write;
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
use tracing::debug;
if request.operation != "capabilities" {
let op_key = normalize_capability_name(&request.operation.replace("backlog.", ""));
let cap_name = match op_key.as_str() {
"pull" => "pull",
"next" => "next",
"claim" => "claim",
"set_status" => "set-status",
"complete" => "complete",
"promote_next" => "promote-next",
"resolve_refs" => "resolve-refs",
other => other,
};
let requested_capability = normalize_capability_name(cap_name);
if !config
.capabilities
.iter()
.any(|capability| normalize_capability_name(capability) == requested_capability)
{
bail!(
"external adapter `{}` does not declare capability `{}`; \
declared capabilities: [{}]",
config.name,
cap_name,
config.capabilities.join(", ")
);
}
}
if config.command.is_empty() {
bail!(
"external adapter `{}` has an empty command; \
set `command = [\"my-adapter\"]` in profile config.toml",
config.name
);
}
let binary = &config.command[0];
let resolved_binary = find_in_path(binary).ok_or_else(|| {
anyhow::anyhow!(
"external adapter `{}`: binary `{}` not found on PATH",
config.name,
binary
)
})?;
debug!(binary = %resolved_binary.display(), "spawning external adapter");
let mut child = Command::new(&resolved_binary)
.args(&config.command[1..])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.with_context(|| {
format!(
"external adapter `{}`: failed to spawn `{}`",
config.name,
resolved_binary.display()
)
})?;
{
let stdin = child.stdin.as_mut().expect("stdin was piped");
let json = serde_json::to_vec(request)?;
stdin.write_all(&json)?;
}
drop(child.stdin.take());
let deadline = Instant::now() + Duration::from_secs(config.timeout_s);
loop {
match child.try_wait()? {
Some(exit_status) => {
let output = child.wait_with_output()?;
debug!(
success = exit_status.success(),
"external adapter completed"
);
if !exit_status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let truncated: String = stderr.chars().take(1024).collect();
bail!(
"external adapter `{}` exited with {}: {}",
config.name,
exit_status,
truncated.trim()
);
}
let stdout = String::from_utf8_lossy(&output.stdout);
return parse_and_validate_response(&config.name, &stdout);
}
None => {
if Instant::now() >= deadline {
let _ = child.kill();
let _ = child.wait();
bail!(
"external adapter `{}` timed out after {}s",
config.name,
config.timeout_s
);
}
std::thread::sleep(Duration::from_millis(50));
}
}
}
}
#[cfg(any(feature = "extension-backlog", test))]
use anyhow::Context;
#[cfg(any(feature = "extension-backlog", test))]
pub fn parse_and_validate_response(adapter_name: &str, stdout: &str) -> Result<AdapterResponse> {
let response: AdapterResponse = serde_json::from_str(stdout).with_context(|| {
format!("external adapter `{adapter_name}`: failed to parse response JSON")
})?;
if response.schema_version != 1 {
bail!(
"external adapter `{adapter_name}`: unsupported schema_version {} (expected 1)",
response.schema_version
);
}
if !response.ok {
let msg = response
.error
.as_deref()
.unwrap_or("unknown error (no `error` field)");
bail!("external adapter `{adapter_name}` returned error: {msg}");
}
Ok(response)
}
#[cfg(feature = "extension-backlog")]
pub fn execute_adapter_operation(
adapter: &ResolvedAdapter,
request: &AdapterRequest,
) -> Result<AdapterResponse> {
match adapter {
ResolvedAdapter::Builtin { name, provider, .. } => execute_builtin(name, provider, request),
ResolvedAdapter::ExternalCommand(cfg) => execute_external(cfg, request),
}
}
#[cfg(any(feature = "extension-backlog", test))]
fn execute_builtin(
name: &str,
provider: &BuiltinProvider,
request: &AdapterRequest,
) -> Result<AdapterResponse> {
match (provider, request.operation.as_str()) {
(BuiltinProvider::GithubIssues, "capabilities") => {
let caps = BacklogAdapterCapabilities::read_only();
Ok(AdapterResponse {
schema_version: 1,
ok: true,
provider: "github-issues".to_owned(),
error: None,
fetched_at_epoch_s: None,
stale_after_s: None,
items: None,
resolved_refs: None,
capabilities: Some(caps),
})
}
(BuiltinProvider::GithubIssues, "backlog.pull") => {
bail!(
"builtin adapter `{name}` (github-issues): backlog.pull must be \
called through the command layer, not via execute_builtin"
);
}
(BuiltinProvider::GithubIssues, "backlog.resolve_refs") => {
bail!(
"builtin adapter `{name}` (github-issues): backlog.resolve_refs must be \
called through the command layer, not via execute_builtin"
);
}
(BuiltinProvider::LocalMarkdown, "capabilities") => {
let caps = BacklogAdapterCapabilities::read_only();
Ok(AdapterResponse {
schema_version: 1,
ok: true,
provider: "local-markdown".to_owned(),
error: None,
fetched_at_epoch_s: None,
stale_after_s: None,
items: None,
resolved_refs: None,
capabilities: Some(caps),
})
}
(BuiltinProvider::LocalMarkdown, "backlog.pull") => {
Ok(AdapterResponse {
schema_version: 1,
ok: true,
provider: "local-markdown".to_owned(),
error: None,
fetched_at_epoch_s: None,
stale_after_s: None,
items: Some(Vec::new()),
resolved_refs: None,
capabilities: None,
})
}
(_, op) => {
bail!("builtin adapter `{name}` does not support operation `{op}`");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn builtin_github_construction_and_accessors() {
let adapter = ResolvedAdapter::Builtin {
name: "github".to_owned(),
provider: BuiltinProvider::GithubIssues,
source: AdapterSource::RepoOverlay,
};
assert_eq!(adapter.name(), "github");
assert_eq!(adapter.source(), AdapterSource::RepoOverlay);
assert_eq!(adapter.source_str(), "repo-overlay");
assert!(adapter.is_github_adapter());
}
#[test]
fn builtin_local_markdown_is_not_github() {
let adapter = ResolvedAdapter::Builtin {
name: "local-markdown".to_owned(),
provider: BuiltinProvider::LocalMarkdown,
source: AdapterSource::BuiltinFallback,
};
assert!(!adapter.is_github_adapter());
assert_eq!(adapter.source_str(), "built-in-fallback");
}
#[test]
fn external_command_construction() {
let adapter = ResolvedAdapter::ExternalCommand(ExternalCommandConfig {
name: "my-ext".to_owned(),
command: vec!["my-adapter".to_owned(), "--flag".to_owned()],
timeout_s: 60,
capabilities: vec!["pull".to_owned(), "next".to_owned()],
source: AdapterSource::ProfileDefault,
});
assert_eq!(adapter.name(), "my-ext");
assert_eq!(adapter.source_str(), "profile-default");
assert!(!adapter.is_github_adapter());
}
#[test]
fn adapter_source_as_str() {
assert_eq!(AdapterSource::RepoOverlay.as_str(), "repo-overlay");
assert_eq!(AdapterSource::ProfileDefault.as_str(), "profile-default");
assert_eq!(AdapterSource::BuiltinFallback.as_str(), "built-in-fallback");
}
#[test]
fn adapter_request_serialization() {
let req = AdapterRequest::new(
"backlog.pull".to_owned(),
"main".to_owned(),
PathBuf::from("/tmp/repo"),
"ccdrepo_123".to_owned(),
BTreeMap::from([("repo".to_owned(), "owner/name".to_owned())]),
);
let json = serde_json::to_string(&req).expect("serialize");
assert!(json.contains("\"schema_version\":1"));
assert!(json.contains("\"operation\":\"backlog.pull\""));
assert!(json.contains("\"repo\":\"owner/name\""));
}
#[test]
fn adapter_response_deserialization_success() {
let json = r#"{
"schema_version": 1,
"ok": true,
"provider": "github-issues",
"items": []
}"#;
let resp: AdapterResponse = serde_json::from_str(json).expect("deserialize");
assert_eq!(resp.schema_version, 1);
assert!(resp.ok);
assert_eq!(resp.provider, "github-issues");
assert!(resp.error.is_none());
assert!(resp.items.unwrap().is_empty());
}
#[test]
fn adapter_response_deserialization_with_defaults() {
let json = r#"{
"schema_version": 1,
"ok": true,
"provider": "test"
}"#;
let resp: AdapterResponse = serde_json::from_str(json).expect("deserialize");
assert!(resp.items.is_none());
assert!(resp.capabilities.is_none());
assert!(resp.fetched_at_epoch_s.is_none());
assert!(resp.stale_after_s.is_none());
}
#[test]
fn parse_and_validate_rejects_wrong_schema_version() {
let json = r#"{"schema_version": 2, "ok": true, "provider": "test"}"#;
let err = parse_and_validate_response("test-adapter", json).unwrap_err();
assert!(err.to_string().contains("unsupported schema_version 2"));
}
#[test]
fn parse_and_validate_rejects_not_ok() {
let json = r#"{"schema_version": 1, "ok": false, "provider": "test", "error": "bad"}"#;
let err = parse_and_validate_response("test-adapter", json).unwrap_err();
assert!(err.to_string().contains("returned error: bad"));
}
#[test]
fn parse_and_validate_accepts_valid_response() {
let json = r#"{"schema_version": 1, "ok": true, "provider": "test"}"#;
let resp = parse_and_validate_response("test-adapter", json).expect("valid");
assert!(resp.ok);
}
#[test]
fn build_resolved_adapter_builtin_github() {
let cfg = BacklogAdapterConfig {
kind: BacklogAdapterKind::Builtin,
provider: Some(BacklogBuiltinProvider::GithubIssues),
command: None,
timeout_s: None,
capabilities: None,
};
let adapter = build_resolved_adapter("github", Some(&cfg), AdapterSource::ProfileDefault);
assert!(adapter.is_github_adapter());
assert_eq!(adapter.name(), "github");
}
#[test]
fn build_resolved_adapter_external_command() {
let cfg = BacklogAdapterConfig {
kind: BacklogAdapterKind::ExternalCommand,
provider: None,
command: Some(vec!["my-adapter".to_owned()]),
timeout_s: Some(45),
capabilities: Some(vec![
crate::extensions::backlog_config::BacklogAdapterCapability::Pull,
]),
};
let adapter = build_resolved_adapter("custom", Some(&cfg), AdapterSource::RepoOverlay);
match adapter {
ResolvedAdapter::ExternalCommand(ext) => {
assert_eq!(ext.name, "custom");
assert_eq!(ext.command, vec!["my-adapter"]);
assert_eq!(ext.timeout_s, 45);
assert_eq!(ext.capabilities, vec!["pull"]);
}
_ => panic!("expected ExternalCommand variant"),
}
}
#[test]
fn build_resolved_adapter_no_config_github_name() {
let adapter = build_resolved_adapter("github-issues", None, AdapterSource::BuiltinFallback);
assert!(adapter.is_github_adapter());
}
#[test]
fn build_resolved_adapter_no_config_unknown_name() {
let adapter =
build_resolved_adapter("something-else", None, AdapterSource::BuiltinFallback);
assert!(!adapter.is_github_adapter());
}
#[test]
fn github_repo_from_remote_url_accepts_supported_formats() {
for (remote, expected) in [
("git@github.com:owner/repo.git", "owner/repo"),
("ssh://git@github.com/owner/repo.git", "owner/repo"),
("https://github.com/owner/repo.git", "owner/repo"),
("http://github.com/owner/repo", "owner/repo"),
("git://github.com/owner/repo/", "owner/repo"),
] {
assert_eq!(
github_repo_from_remote_url(remote),
Some(expected.to_owned()),
"remote = {remote}"
);
}
}
#[test]
fn github_repo_from_remote_url_rejects_unsupported_or_invalid_formats() {
for remote in [
"",
"git@example.com:owner/repo.git",
"ssh://git@github.com:22/owner/repo.git",
"https://github.com/owner",
"https://github.com/owner/repo/extra",
] {
assert_eq!(
github_repo_from_remote_url(remote),
None,
"remote = {remote}"
);
}
}
#[test]
fn execute_builtin_github_capabilities() {
let req = AdapterRequest::new(
"capabilities".to_owned(),
"main".to_owned(),
PathBuf::from("/tmp"),
"ccdrepo_1".to_owned(),
BTreeMap::new(),
);
let resp = execute_builtin("github", &BuiltinProvider::GithubIssues, &req).expect("caps");
assert!(resp.ok);
assert_eq!(resp.provider, "github-issues");
let caps = resp.capabilities.expect("capabilities present");
assert!(caps.pull);
assert!(caps.next);
assert!(!caps.claim);
}
#[test]
fn execute_builtin_local_markdown_capabilities() {
let req = AdapterRequest::new(
"capabilities".to_owned(),
"main".to_owned(),
PathBuf::from("/tmp"),
"ccdrepo_1".to_owned(),
BTreeMap::new(),
);
let resp =
execute_builtin("local-md", &BuiltinProvider::LocalMarkdown, &req).expect("caps");
assert!(resp.ok);
let caps = resp.capabilities.expect("capabilities present");
assert!(caps.pull);
assert!(caps.next);
assert!(!caps.claim);
}
#[test]
fn execute_builtin_local_markdown_pull_returns_empty() {
let req = AdapterRequest::new(
"backlog.pull".to_owned(),
"main".to_owned(),
PathBuf::from("/tmp"),
"ccdrepo_1".to_owned(),
BTreeMap::new(),
);
let resp =
execute_builtin("local-md", &BuiltinProvider::LocalMarkdown, &req).expect("pull");
assert!(resp.ok);
assert!(resp.items.unwrap().is_empty());
}
#[test]
fn execute_builtin_github_pull_fails_through_execute_builtin() {
let req = AdapterRequest::new(
"backlog.pull".to_owned(),
"main".to_owned(),
PathBuf::from("/tmp"),
"ccdrepo_1".to_owned(),
BTreeMap::new(),
);
let err = execute_builtin("github", &BuiltinProvider::GithubIssues, &req).unwrap_err();
assert!(err
.to_string()
.contains("must be called through the command layer"));
}
#[test]
fn execute_builtin_rejects_unsupported_operation() {
let req = AdapterRequest::new(
"backlog.unknown".to_owned(),
"main".to_owned(),
PathBuf::from("/tmp"),
"ccdrepo_1".to_owned(),
BTreeMap::new(),
);
let err = execute_builtin("github", &BuiltinProvider::GithubIssues, &req).unwrap_err();
assert!(err
.to_string()
.contains("does not support operation `backlog.unknown`"));
}
}