use std::fs;
use std::path::{Path, PathBuf};
use std::process;
use serde::Serialize;
use crate::cli::CheckOutputFormat;
use crate::package::{
find_nearest_manifest, normalize_connector_capability, read_manifest_from_path,
ConnectorCapabilities, Manifest,
};
const DEFAULT_CONNECTOR_MATRIX_SOURCE: &str = "conformance/fixtures/connectors/contract-v1";
#[derive(Debug, Clone, Serialize)]
pub(crate) struct ConnectorCapabilityMatrixRow {
pub provider: String,
pub package: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub repository: Option<String>,
pub webhook: bool,
pub oauth: bool,
pub rate_limit: bool,
pub pagination: bool,
pub graphql: bool,
pub streaming: bool,
pub manifest_path: String,
}
pub(crate) fn run(format: CheckOutputFormat, filter: Option<&str>, targets: &[String]) {
let rows = filtered_rows(load_rows_for_cli(targets), filter);
match format {
CheckOutputFormat::Text => print_text(&rows),
CheckOutputFormat::Json => print_json(&rows),
CheckOutputFormat::Markdown => print!("{}", generate_markdown(&rows)),
}
}
pub(crate) fn run_docs(output_path: &str, sources: &[String], check_only: bool) {
let rows = load_rows_for_docs(sources);
let generated = generate_markdown(&rows);
let path = Path::new(output_path);
if check_only {
let existing = match fs::read_to_string(path) {
Ok(s) => s,
Err(e) => {
eprintln!("error: cannot read {}: {e}", path.display());
eprintln!("hint: run `make gen-connector-matrix` to regenerate.");
process::exit(1);
}
};
if existing != generated {
eprintln!(
"error: {} is stale relative to the connector capability matrix.",
path.display()
);
eprintln!("hint: run `make gen-connector-matrix` to regenerate.");
process::exit(1);
}
return;
}
if let Some(parent) = path.parent() {
if let Err(e) = fs::create_dir_all(parent) {
eprintln!("error: cannot create {}: {e}", parent.display());
process::exit(1);
}
}
if let Err(e) = fs::write(path, &generated) {
eprintln!("error: cannot write {}: {e}", path.display());
process::exit(1);
}
println!("wrote {}", path.display());
}
pub(crate) fn generate_markdown(rows: &[ConnectorCapabilityMatrixRow]) -> String {
let mut out = String::new();
out.push_str("# Connector Parity Matrix\n\n");
out.push_str("<!-- GENERATED by `harn dump-connector-matrix` -- do not edit by hand. -->\n");
out.push_str("<!-- Source of truth: connector package harn.toml [[providers]].capabilities declarations. -->\n\n");
out.push_str("<!-- markdownlint-disable MD013 -->\n\n");
out.push_str(
"This table is generated from connector package manifests. A checked feature means the package declares support for that connector surface; missing support highlights either an intentional scope boundary or a package gap.\n\n",
);
out.push_str("Regenerate with `make gen-connector-matrix` and verify with `make check-connector-matrix`.\n\n");
out.push_str(
"| Provider | Package | Webhook | OAuth | Rate limit | Pagination | GraphQL | Streaming |\n",
);
out.push_str("|---|---|---:|---:|---:|---:|---:|---:|\n");
for row in rows {
out.push_str(&format!(
"| `{}` | {} | {} | {} | {} | {} | {} | {} |\n",
row.provider,
package_cell(row),
yes_no(row.webhook),
yes_no(row.oauth),
yes_no(row.rate_limit),
yes_no(row.pagination),
yes_no(row.graphql),
yes_no(row.streaming),
));
}
out
}
fn load_rows_for_cli(targets: &[String]) -> Vec<ConnectorCapabilityMatrixRow> {
let sources = if targets.is_empty() {
default_sources_for_cli()
} else {
targets.iter().map(PathBuf::from).collect()
};
load_rows_from_sources(&sources)
}
fn load_rows_for_docs(sources: &[String]) -> Vec<ConnectorCapabilityMatrixRow> {
let sources = if sources.is_empty() {
vec![PathBuf::from(DEFAULT_CONNECTOR_MATRIX_SOURCE)]
} else {
sources.iter().map(PathBuf::from).collect()
};
let rows = load_rows_from_sources(&sources);
if rows.is_empty() {
eprintln!(
"error: no connector providers with manifests found under {}",
sources
.iter()
.map(|source| source.display().to_string())
.collect::<Vec<_>>()
.join(", ")
);
process::exit(1);
}
rows
}
fn default_sources_for_cli() -> Vec<PathBuf> {
let fixture_source = PathBuf::from(DEFAULT_CONNECTOR_MATRIX_SOURCE);
if fixture_source.exists() {
return vec![fixture_source];
}
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
if let Some((_, manifest_dir)) = find_nearest_manifest(&cwd) {
return vec![manifest_dir.join("harn.toml")];
}
eprintln!(
"error: no harn.toml found for connector matrix; pass package manifests or package directories"
);
process::exit(1);
}
fn load_rows_from_sources(sources: &[PathBuf]) -> Vec<ConnectorCapabilityMatrixRow> {
let mut manifests = Vec::new();
for source in sources {
collect_manifest_paths(source, &mut manifests);
}
manifests.sort();
manifests.dedup();
let mut rows = Vec::new();
for manifest_path in manifests {
let manifest = match read_manifest_from_path(&manifest_path) {
Ok(manifest) => manifest,
Err(error) => {
eprintln!("error: failed to read {}: {error}", manifest_path.display());
process::exit(1);
}
};
rows.extend(rows_from_manifest(&manifest, &manifest_path));
}
rows.sort_by(|left, right| {
left.provider
.cmp(&right.provider)
.then_with(|| left.package.cmp(&right.package))
.then_with(|| left.manifest_path.cmp(&right.manifest_path))
});
rows
}
fn collect_manifest_paths(source: &Path, manifests: &mut Vec<PathBuf>) {
if source.is_file() {
if source.file_name().is_some_and(|name| name == "harn.toml") {
manifests.push(source.to_path_buf());
} else {
eprintln!(
"error: connector matrix source {} is not a harn.toml manifest",
source.display()
);
process::exit(1);
}
return;
}
if !source.is_dir() {
eprintln!(
"error: connector matrix source {} does not exist",
source.display()
);
process::exit(1);
}
let direct_manifest = source.join("harn.toml");
if direct_manifest.is_file() {
manifests.push(direct_manifest);
return;
}
let entries = match fs::read_dir(source) {
Ok(entries) => entries,
Err(error) => {
eprintln!("error: cannot read {}: {error}", source.display());
process::exit(1);
}
};
for entry in entries {
let entry = match entry {
Ok(entry) => entry,
Err(error) => {
eprintln!("error: cannot read directory entry: {error}");
process::exit(1);
}
};
let path = entry.path();
if path.is_file() && path.file_name().is_none_or(|name| name != "harn.toml") {
continue;
}
if should_skip_dir(&path) {
continue;
}
collect_manifest_paths(&path, manifests);
}
}
fn should_skip_dir(path: &Path) -> bool {
if !path.is_dir() {
return false;
}
matches!(
path.file_name().and_then(|name| name.to_str()),
Some(".git" | ".harn" | "node_modules" | "target")
)
}
fn rows_from_manifest(
manifest: &Manifest,
manifest_path: &Path,
) -> Vec<ConnectorCapabilityMatrixRow> {
let package = manifest
.package
.as_ref()
.and_then(|package| package.name.clone())
.unwrap_or_else(|| "-".to_string());
let repository = manifest
.package
.as_ref()
.and_then(|package| package.repository.clone());
manifest
.providers
.iter()
.map(|provider| row_from_provider(&package, repository.clone(), manifest_path, provider))
.collect()
}
fn row_from_provider(
package: &str,
repository: Option<String>,
manifest_path: &Path,
provider: &crate::package::ProviderManifestEntry,
) -> ConnectorCapabilityMatrixRow {
let capabilities = provider.capabilities;
ConnectorCapabilityMatrixRow {
provider: provider.id.as_str().to_string(),
package: package.to_string(),
repository,
webhook: capabilities.webhook,
oauth: capabilities.oauth,
rate_limit: capabilities.rate_limit,
pagination: capabilities.pagination,
graphql: capabilities.graphql,
streaming: capabilities.streaming,
manifest_path: manifest_path.display().to_string(),
}
}
fn filtered_rows(
rows: Vec<ConnectorCapabilityMatrixRow>,
filter: Option<&str>,
) -> Vec<ConnectorCapabilityMatrixRow> {
let Some(feature) = filter else {
return rows;
};
let normalized = normalize_connector_capability(feature);
if !ConnectorCapabilities::FEATURES.contains(&normalized.as_str()) {
eprintln!(
"error: unknown connector matrix feature `{feature}`. Expected one of: {}",
ConnectorCapabilities::FEATURES.join(", ")
);
process::exit(2);
}
rows.into_iter()
.filter(|row| row_supports_feature(row, &normalized))
.collect()
}
fn row_supports_feature(row: &ConnectorCapabilityMatrixRow, feature: &str) -> bool {
match feature {
"webhook" => row.webhook,
"oauth" => row.oauth,
"rate_limit" => row.rate_limit,
"pagination" => row.pagination,
"graphql" => row.graphql,
"streaming" => row.streaming,
_ => false,
}
}
fn print_text(rows: &[ConnectorCapabilityMatrixRow]) {
let table_rows: Vec<[String; 8]> = rows
.iter()
.map(|row| {
[
row.provider.clone(),
row.package.clone(),
yes_no(row.webhook).to_string(),
yes_no(row.oauth).to_string(),
yes_no(row.rate_limit).to_string(),
yes_no(row.pagination).to_string(),
yes_no(row.graphql).to_string(),
yes_no(row.streaming).to_string(),
]
})
.collect();
let headers = [
"provider".to_string(),
"package".to_string(),
"webhook".to_string(),
"oauth".to_string(),
"rate_limit".to_string(),
"pagination".to_string(),
"graphql".to_string(),
"streaming".to_string(),
];
let mut widths: Vec<usize> = headers.iter().map(String::len).collect();
for row in &table_rows {
for (index, value) in row.iter().enumerate() {
widths[index] = widths[index].max(value.len());
}
}
print_row(&headers, &widths);
for row in table_rows {
print_row(&row, &widths);
}
}
fn print_row<const N: usize>(cells: &[String; N], widths: &[usize]) {
for (index, cell) in cells.iter().enumerate() {
if index > 0 {
print!(" ");
}
print!("{cell:<width$}", width = widths[index]);
}
println!();
}
fn print_json(rows: &[ConnectorCapabilityMatrixRow]) {
println!(
"{}",
serde_json::to_string_pretty(rows).unwrap_or_else(|error| {
eprintln!("error: failed to serialize connector matrix: {error}");
process::exit(1);
})
);
}
fn package_cell(row: &ConnectorCapabilityMatrixRow) -> String {
match &row.repository {
Some(repository) if !repository.is_empty() => {
format!("[`{}`]({})", row.package, repository)
}
_ => format!("`{}`", row.package),
}
}
fn yes_no(value: bool) -> &'static str {
if value {
"yes"
} else {
"no"
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn manifest_list_capabilities_generate_rows() {
let manifest: Manifest = toml::from_str(
r#"
[package]
name = "harn-acme-connector"
repository = "https://example.com/acme"
[[providers]]
id = "acme"
connector = { harn = "src/lib.harn" }
capabilities = ["webhook", "rate-limit", "graphql"]
"#,
)
.unwrap();
let rows = rows_from_manifest(&manifest, Path::new("harn.toml"));
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].provider, "acme");
assert!(rows[0].webhook);
assert!(rows[0].rate_limit);
assert!(rows[0].graphql);
assert!(!rows[0].oauth);
}
#[test]
fn manifest_table_capabilities_generate_rows() {
let manifest: Manifest = toml::from_str(
r#"
[[providers]]
id = "acme"
connector = { harn = "src/lib.harn" }
capabilities = { webhook = true, oauth = true, rate_limit = true }
"#,
)
.unwrap();
let rows = rows_from_manifest(&manifest, Path::new("harn.toml"));
assert!(rows[0].webhook);
assert!(rows[0].oauth);
assert!(rows[0].rate_limit);
assert!(!rows[0].streaming);
}
#[test]
fn markdown_includes_connector_columns() {
let rows = vec![ConnectorCapabilityMatrixRow {
provider: "acme".to_string(),
package: "harn-acme-connector".to_string(),
repository: None,
webhook: true,
oauth: false,
rate_limit: true,
pagination: false,
graphql: false,
streaming: false,
manifest_path: "harn.toml".to_string(),
}];
let markdown = generate_markdown(&rows);
assert!(markdown.contains("Connector Parity Matrix"));
assert!(markdown.contains("Source of truth"));
assert!(markdown.contains(
"| Provider | Package | Webhook | OAuth | Rate limit | Pagination | GraphQL | Streaming |"
));
assert!(
markdown.contains("| `acme` | `harn-acme-connector` | yes | no | yes | no | no | no |")
);
}
}