use std::fs;
use std::path::Path;
use tempfile::TempDir;
fn touch(root: &Path, rel_path: &str) {
let full = root.join(rel_path);
if let Some(parent) = full.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(full, "").unwrap();
}
fn write_manifest(root: &Path, content: &str) {
fs::write(root.join("services.yaml"), content).unwrap();
}
fn load(
root: &Path,
) -> (
svccat::manifest::Manifest,
Vec<svccat::discovery::DiscoveredService>,
) {
let m = svccat::manifest::Manifest::load(&root.join("services.yaml")).unwrap();
let d = svccat::discovery::discover_services(root, &m);
(m, d)
}
#[test]
fn no_drift_when_all_services_found() {
let dir = TempDir::new().unwrap();
let root = dir.path();
touch(root, "services/api-gateway/Cargo.toml");
touch(root, "services/auth-service/Dockerfile");
write_manifest(
root,
r#"
discovery:
paths:
- "services/*"
services:
- name: api-gateway
language: Rust
role: API gateway
platform: Cloud Run
- name: auth-service
language: Python
role: Authentication
platform: Cloud Run
"#,
);
let (m, d) = load(root);
let report = svccat::drift::analyze(&m, &d, root);
assert_eq!(
report.drifts.len(),
0,
"unexpected drift: {:?}",
report.drifts
);
}
#[test]
fn detects_declared_service_missing_from_repo() {
let dir = TempDir::new().unwrap();
let root = dir.path();
touch(root, "services/api-gateway/Cargo.toml");
write_manifest(
root,
r#"
discovery:
paths:
- "services/*"
services:
- name: api-gateway
language: Rust
role: API gateway
platform: Cloud Run
- name: auth-service
language: Python
role: Authentication
platform: Cloud Run
"#,
);
let (m, d) = load(root);
let report = svccat::drift::analyze(&m, &d, root);
let missing: Vec<_> = report
.drifts
.iter()
.filter(|item| item.kind == svccat::drift::DriftKind::DeclaredMissingFromRepo)
.collect();
assert_eq!(missing.len(), 1, "expected exactly one missing service");
assert_eq!(missing[0].service, "auth-service");
assert_eq!(missing[0].severity, svccat::drift::Severity::Error);
}
#[test]
fn detects_undeclared_service_in_repo() {
let dir = TempDir::new().unwrap();
let root = dir.path();
touch(root, "services/api-gateway/Cargo.toml");
touch(root, "services/auth-service/Dockerfile");
touch(root, "services/new-feature/go.mod");
write_manifest(
root,
r#"
discovery:
paths:
- "services/*"
services:
- name: api-gateway
language: Rust
role: API gateway
platform: Cloud Run
- name: auth-service
language: Python
role: Authentication
platform: Cloud Run
"#,
);
let (m, d) = load(root);
let report = svccat::drift::analyze(&m, &d, root);
let undeclared: Vec<_> = report
.drifts
.iter()
.filter(|item| item.kind == svccat::drift::DriftKind::UndeclaredInRepo)
.collect();
assert_eq!(
undeclared.len(),
1,
"expected exactly one undeclared service"
);
assert_eq!(undeclared[0].service, "new-feature");
assert_eq!(undeclared[0].severity, svccat::drift::Severity::Warning);
}
#[test]
fn detects_missing_role_field() {
let dir = TempDir::new().unwrap();
let root = dir.path();
touch(root, "services/api-gateway/Cargo.toml");
write_manifest(
root,
r#"
discovery:
paths:
- "services/*"
services:
- name: api-gateway
language: Rust
platform: Cloud Run
# role intentionally omitted
"#,
);
let (m, d) = load(root);
let report = svccat::drift::analyze(&m, &d, root);
let field_drifts: Vec<_> = report
.drifts
.iter()
.filter(|item| item.kind == svccat::drift::DriftKind::MissingField)
.collect();
assert_eq!(field_drifts.len(), 1, "expected one missing-field drift");
assert_eq!(field_drifts[0].detail.as_deref(), Some("role"));
assert_eq!(field_drifts[0].severity, svccat::drift::Severity::Error);
}
#[test]
fn detects_missing_referenced_docs_file() {
let dir = TempDir::new().unwrap();
let root = dir.path();
touch(root, "services/api-gateway/Cargo.toml");
write_manifest(
root,
r#"
discovery:
paths:
- "services/*"
services:
- name: api-gateway
language: Rust
role: API gateway
platform: Cloud Run
docs: docs/api-gateway.md
"#,
);
let (m, d) = load(root);
let report = svccat::drift::analyze(&m, &d, root);
let ref_drifts: Vec<_> = report
.drifts
.iter()
.filter(|item| item.kind == svccat::drift::DriftKind::MissingReferencedFile)
.collect();
assert_eq!(ref_drifts.len(), 1);
assert!(ref_drifts[0].message.contains("docs"));
}
#[test]
fn explicit_path_overrides_name_matching() {
let dir = TempDir::new().unwrap();
let root = dir.path();
touch(root, "infra/gateway/Cargo.toml");
write_manifest(
root,
r#"
discovery:
paths:
- "services/*"
services:
- name: api-gateway
language: Rust
role: API gateway
platform: Cloud Run
path: infra/gateway
"#,
);
let (m, d) = load(root);
let report = svccat::drift::analyze(&m, &d, root);
let missing: Vec<_> = report
.drifts
.iter()
.filter(|i| i.kind == svccat::drift::DriftKind::DeclaredMissingFromRepo)
.collect();
assert_eq!(missing.len(), 0, "explicit path should resolve correctly");
}
#[test]
fn json_output_is_valid() {
let dir = TempDir::new().unwrap();
let root = dir.path();
touch(root, "services/api-gateway/Cargo.toml");
write_manifest(
root,
r#"
discovery:
paths:
- "services/*"
services:
- name: api-gateway
language: Rust
role: API gateway
platform: Cloud Run
"#,
);
let (m, d) = load(root);
let report = svccat::drift::analyze(&m, &d, root);
let json_str = serde_json::to_string_pretty(&report).unwrap();
let _: serde_json::Value = serde_json::from_str(&json_str).unwrap();
}
#[test]
fn graph_renders_depends_on_edges() {
use std::io::Write;
let dir = TempDir::new().unwrap();
let root = dir.path();
write_manifest(
root,
r#"
discovery:
paths:
- "services/*"
services:
- name: payment-service
language: Rust
role: payments
platform: Cloud Run
depends_on:
- auth-service
- postgres
- name: auth-service
language: Go
role: authentication
platform: Cloud Run
- name: postgres
language: SQL
role: database
platform: Cloud SQL
"#,
);
let m = svccat::manifest::Manifest::load(&root.join("services.yaml")).unwrap();
let mut output = Vec::new();
{
assert_eq!(m.services[0].depends_on, vec!["auth-service", "postgres"]);
assert!(m.services[1].depends_on.is_empty());
}
let yaml = serde_yaml::to_string(&m).unwrap();
let m2: svccat::manifest::Manifest = serde_yaml::from_str(&yaml).unwrap();
assert_eq!(m2.services[0].depends_on, vec!["auth-service", "postgres"]);
let _ = output.flush();
}
#[test]
fn ping_result_is_ok_for_reachable() {
use svccat::ping::{PingResult, PingStatus};
let r = PingResult {
service: "api".to_string(),
url: "https://example.com".to_string(),
ping: PingStatus::Reachable { code: 200 },
};
assert!(r.is_ok());
let r2 = PingResult {
service: "api".to_string(),
url: "https://example.com".to_string(),
ping: PingStatus::Reachable { code: 404 },
};
assert!(r2.is_ok());
let r3 = PingResult {
service: "api".to_string(),
url: "https://example.com".to_string(),
ping: PingStatus::Unreachable {
reason: "connection refused".to_string(),
},
};
assert!(!r3.is_ok());
}
#[test]
fn ping_skips_services_without_url() {
let dir = TempDir::new().unwrap();
let root = dir.path();
write_manifest(
root,
r#"
services:
- name: no-url-service
language: Rust
role: api
platform: Cloud Run
"#,
);
let m = svccat::manifest::Manifest::load(&root.join("services.yaml")).unwrap();
let services_with_url: Vec<_> = m.services.iter().filter(|s| s.url.is_some()).collect();
assert_eq!(services_with_url.len(), 0);
}
#[test]
fn init_creates_services_yaml_from_discovered_services() {
let dir = TempDir::new().unwrap();
let root = dir.path();
touch(root, "services/api-gateway/Cargo.toml");
touch(root, "services/worker/go.mod");
let output = root.join("services.yaml");
svccat::init::run(root, output.clone(), false).unwrap();
assert!(output.exists(), "services.yaml should be created");
let contents = fs::read_to_string(&output).unwrap();
assert!(
contents.contains("api-gateway"),
"should include api-gateway"
);
assert!(contents.contains("worker"), "should include worker");
assert!(
contents.contains("Rust"),
"should infer Rust from Cargo.toml"
);
assert!(contents.contains("Go"), "should infer Go from go.mod");
}
#[test]
fn init_infers_platform_from_deploy_descriptor() {
let dir = TempDir::new().unwrap();
let root = dir.path();
touch(root, "services/web/Cargo.toml");
touch(root, "services/web/fly.toml");
let output = root.join("services.yaml");
svccat::init::run(root, output.clone(), false).unwrap();
let contents = fs::read_to_string(&output).unwrap();
assert!(
contents.contains("platform: fly.io"),
"should infer fly.io platform from fly.toml, got:\n{contents}"
);
}
#[test]
fn init_leaves_platform_placeholder_without_descriptor() {
let dir = TempDir::new().unwrap();
let root = dir.path();
touch(root, "services/api/Dockerfile");
let output = root.join("services.yaml");
svccat::init::run(root, output.clone(), false).unwrap();
let contents = fs::read_to_string(&output).unwrap();
assert!(
contents.contains("platform: ~"),
"platform should remain a placeholder, got:\n{contents}"
);
}
#[test]
fn fix_infers_language_and_platform_for_added_services() {
let dir = TempDir::new().unwrap();
let root = dir.path();
write_manifest(
root,
"version: \"1\"\ndiscovery:\n paths:\n - services/*\nservices: []\n",
);
touch(root, "services/worker/go.mod");
fs::create_dir_all(root.join("services/worker/k8s")).unwrap();
let manifest_path = root.join("services.yaml");
svccat::fix::run(&manifest_path, root, &[], 1, false, false).unwrap();
let m = svccat::manifest::Manifest::load(&manifest_path).unwrap();
let worker = m
.services
.iter()
.find(|s| s.name == "worker")
.expect("worker should be added by fix");
assert_eq!(worker.language.as_deref(), Some("Go"));
assert_eq!(worker.platform.as_deref(), Some("kubernetes"));
}
#[test]
fn init_refuses_to_overwrite_without_force() {
let dir = TempDir::new().unwrap();
let root = dir.path();
let output = root.join("services.yaml");
fs::write(&output, "existing content").unwrap();
let result = svccat::init::run(root, output, false);
assert!(
result.is_err(),
"should error when file exists and --force not set"
);
}
#[test]
fn init_overwrites_with_force() {
let dir = TempDir::new().unwrap();
let root = dir.path();
let output = root.join("services.yaml");
fs::write(&output, "old content").unwrap();
touch(root, "services/my-svc/Dockerfile");
svccat::init::run(root, output.clone(), true).unwrap();
let contents = fs::read_to_string(&output).unwrap();
assert!(
contents.contains("my-svc"),
"should contain discovered service"
);
assert!(
!contents.contains("old content"),
"should overwrite old content"
);
}
#[test]
fn init_empty_repo_writes_skeleton() {
let dir = TempDir::new().unwrap();
let root = dir.path();
let output = root.join("services.yaml");
svccat::init::run(root, output.clone(), false).unwrap();
let contents = fs::read_to_string(&output).unwrap();
assert!(
contents.contains("version"),
"skeleton should contain version key"
);
assert!(
contents.contains("services:"),
"skeleton should contain services key"
);
}
#[test]
fn ignore_patterns_exclude_matching_directories() {
let dir = TempDir::new().unwrap();
let root = dir.path();
touch(root, "services/api/Cargo.toml");
touch(root, "services/examples/Cargo.toml"); touch(root, "services/vendor/go.mod");
write_manifest(
root,
r#"
discovery:
paths:
- "services/*"
ignore:
- "services/examples"
- "services/vendor"
services:
- name: api
language: Rust
role: api
platform: Cloud Run
"#,
);
let m = svccat::manifest::Manifest::load(&root.join("services.yaml")).unwrap();
let discovered = svccat::discovery::discover_services(root, &m);
let names: Vec<&str> = discovered.iter().map(|d| d.name.as_str()).collect();
assert!(names.contains(&"api"), "api should be discovered");
assert!(!names.contains(&"examples"), "examples should be ignored");
assert!(!names.contains(&"vendor"), "vendor should be ignored");
}
#[test]
fn extra_ignore_from_cli_merges_with_manifest() {
let dir = TempDir::new().unwrap();
let root = dir.path();
touch(root, "services/api/Cargo.toml");
touch(root, "services/scratch/Cargo.toml");
write_manifest(
root,
r#"
discovery:
paths:
- "services/*"
services:
- name: api
language: Rust
role: api
platform: Cloud Run
"#,
);
let m = svccat::manifest::Manifest::load(&root.join("services.yaml")).unwrap();
let discovered = svccat::discovery::discover_services_with_ignore(
root,
&m,
&["services/scratch".to_string()],
);
let names: Vec<&str> = discovered.iter().map(|d| d.name.as_str()).collect();
assert!(names.contains(&"api"));
assert!(
!names.contains(&"scratch"),
"scratch should be excluded by CLI ignore"
);
}
#[test]
fn config_loads_from_svccat_toml() {
let dir = TempDir::new().unwrap();
let root = dir.path();
fs::write(
root.join("svccat.toml"),
r#"
fail_on_drift = true
ignore = ["examples/*", "vendor/*"]
"#,
)
.unwrap();
let cfg = svccat::config::SvccatConfig::load(root).unwrap();
assert!(cfg.fail_on_drift);
assert_eq!(cfg.ignore, vec!["examples/*", "vendor/*"]);
}
#[test]
fn config_returns_defaults_when_no_file() {
let dir = TempDir::new().unwrap();
let root = dir.path();
let cfg = svccat::config::SvccatConfig::load(root).unwrap();
assert!(!cfg.fail_on_drift);
assert!(cfg.ignore.is_empty());
}
#[test]
fn completions_produces_output_for_bash() {
use clap::CommandFactory;
use clap_complete::{generate, Shell};
use svccat::cli::Cli;
let mut cmd = Cli::command();
let mut buf = Vec::new();
generate(Shell::Bash, &mut cmd, "svccat", &mut buf);
assert!(!buf.is_empty(), "bash completions should produce output");
let script = String::from_utf8(buf).unwrap();
assert!(
script.contains("svccat"),
"completions should mention the binary name"
);
}
#[test]
fn policy_require_fields_flags_missing_url() {
let dir = TempDir::new().unwrap();
let root = dir.path();
touch(root, "services/api/Cargo.toml");
write_manifest(
root,
r#"
discovery:
paths:
- "services/*"
policy:
require_fields: [url, language]
services:
- name: api
language: Rust
role: api
platform: Cloud Run
"#,
);
let (m, d) = load(root);
let report = svccat::drift::analyze(&m, &d, root);
let policy_violations: Vec<_> = report
.drifts
.iter()
.filter(|i| i.kind == svccat::drift::DriftKind::PolicyViolation)
.collect();
assert_eq!(
policy_violations.len(),
1,
"url should be flagged by policy"
);
assert!(policy_violations[0].message.contains("url"));
}
#[test]
fn policy_no_violations_when_fields_present() {
let dir = TempDir::new().unwrap();
let root = dir.path();
touch(root, "services/api/Cargo.toml");
write_manifest(
root,
r#"
discovery:
paths:
- "services/*"
policy:
require_fields: [url, language]
services:
- name: api
language: Rust
role: api
platform: Cloud Run
url: https://api.example.com
"#,
);
let (m, d) = load(root);
let report = svccat::drift::analyze(&m, &d, root);
let violations: Vec<_> = report
.drifts
.iter()
.filter(|i| i.kind == svccat::drift::DriftKind::PolicyViolation)
.collect();
assert!(violations.is_empty(), "no policy violations expected");
}
#[test]
fn diff_detects_added_and_removed_services() {
let dir = TempDir::new().unwrap();
let root = dir.path();
let before_json = r#"{
"version": "1",
"manifest": "services.yaml",
"summary": {"declared": 2, "discovered": 2, "drift_count": 0, "errors": 0, "warnings": 0},
"services": [
{"name": "api", "language": "Rust", "platform": "Cloud Run", "role": "api", "depends_on": []},
{"name": "legacy", "language": "Go", "platform": "Fly.io", "role": "worker", "depends_on": []}
],
"drift": []
}"#;
let after_json = r#"{
"version": "1",
"manifest": "services.yaml",
"summary": {"declared": 2, "discovered": 2, "drift_count": 0, "errors": 0, "warnings": 0},
"services": [
{"name": "api", "language": "Rust", "platform": "Cloud Run", "role": "api", "depends_on": []},
{"name": "new-worker", "language": "Python", "platform": "Cloud Run", "role": "worker", "depends_on": []}
],
"drift": []
}"#;
let before_path = root.join("before.json");
let after_path = root.join("after.json");
fs::write(&before_path, before_json).unwrap();
fs::write(&after_path, after_json).unwrap();
let report = svccat::diff::diff_snapshots(&before_path, &after_path).unwrap();
assert!(report.added.contains(&"new-worker".to_string()));
assert!(report.removed.contains(&"legacy".to_string()));
assert!(!report.added.contains(&"api".to_string()));
}
#[test]
fn diff_detects_field_changes() {
let dir = TempDir::new().unwrap();
let root = dir.path();
let before_json = r#"{
"services": [
{"name": "api", "language": "Python", "platform": "Cloud Run", "role": "api", "depends_on": []}
],
"drift": []
}"#;
let after_json = r#"{
"services": [
{"name": "api", "language": "Rust", "platform": "Cloud Run", "role": "api", "depends_on": []}
],
"drift": []
}"#;
let before_path = root.join("before.json");
let after_path = root.join("after.json");
fs::write(&before_path, before_json).unwrap();
fs::write(&after_path, after_json).unwrap();
let report = svccat::diff::diff_snapshots(&before_path, &after_path).unwrap();
assert_eq!(report.changed.len(), 1);
assert_eq!(report.changed[0].name, "api");
let lang_change = report.changed[0]
.changes
.iter()
.find(|c| c.field == "language")
.expect("language change expected");
assert_eq!(lang_change.before, "Python");
assert_eq!(lang_change.after, "Rust");
}
#[test]
fn diff_no_changes_when_snapshots_identical() {
let dir = TempDir::new().unwrap();
let root = dir.path();
let snapshot = r#"{
"services": [
{"name": "api", "language": "Rust", "platform": "Cloud Run", "role": "api", "depends_on": []}
],
"drift": []
}"#;
let before_path = root.join("before.json");
let after_path = root.join("after.json");
fs::write(&before_path, snapshot).unwrap();
fs::write(&after_path, snapshot).unwrap();
let report = svccat::diff::diff_snapshots(&before_path, &after_path).unwrap();
assert!(
report.is_empty(),
"identical snapshots should produce no diff"
);
}
#[test]
fn team_and_oncall_fields_parse() {
let dir = TempDir::new().unwrap();
let root = dir.path();
touch(root, "services/api/Cargo.toml");
touch(root, "services/worker/Cargo.toml");
write_manifest(
root,
r#"
discovery:
paths: ["services/*"]
services:
- name: api
language: Rust
role: API
platform: Cloud Run
team: platform
oncall: platform-oncall@example.com
- name: worker
language: Rust
role: Worker
platform: Cloud Run
team: data
oncall: data-team@example.com
"#,
);
let m = svccat::manifest::Manifest::load(&root.join("services.yaml")).unwrap();
let api = m.services.iter().find(|s| s.name == "api").unwrap();
let worker = m.services.iter().find(|s| s.name == "worker").unwrap();
assert_eq!(api.team.as_deref(), Some("platform"));
assert_eq!(api.oncall.as_deref(), Some("platform-oncall@example.com"));
assert_eq!(worker.team.as_deref(), Some("data"));
assert_eq!(worker.oncall.as_deref(), Some("data-team@example.com"));
}
#[test]
fn team_filter_limits_analysis_to_matching_services() {
let dir = TempDir::new().unwrap();
let root = dir.path();
touch(root, "services/api/Cargo.toml");
touch(root, "services/worker/Cargo.toml");
write_manifest(
root,
r#"
discovery:
paths: ["services/*"]
services:
- name: api
language: Rust
role: API
platform: Cloud Run
team: platform
- name: worker
language: Rust
role: Worker
platform: Cloud Run
team: data
"#,
);
let full_m = svccat::manifest::Manifest::load(&root.join("services.yaml")).unwrap();
let mut m = full_m.clone();
m.services.retain(|s| {
s.team
.as_deref()
.map(|t| t.eq_ignore_ascii_case("platform"))
.unwrap_or(false)
});
let in_scope: std::collections::HashSet<&str> =
m.services.iter().map(|s| s.name.as_str()).collect();
let other_declared: std::collections::HashSet<&str> = full_m
.services
.iter()
.filter(|s| !in_scope.contains(s.name.as_str()))
.map(|s| s.name.as_str())
.collect();
let discovered_all = svccat::discovery::discover_services(root, &full_m);
let d: Vec<_> = discovered_all
.into_iter()
.filter(|d| !other_declared.contains(d.name.as_str()))
.collect();
let report = svccat::drift::analyze(&m, &d, root);
assert_eq!(m.services.len(), 1);
assert_eq!(m.services[0].name, "api");
assert_eq!(
report.drifts.len(),
0,
"no drift expected for team-filtered check: {:?}",
report.drifts
);
}
#[test]
fn policy_requires_team_field() {
let dir = TempDir::new().unwrap();
let root = dir.path();
touch(root, "services/api/Cargo.toml");
write_manifest(
root,
r#"
discovery:
paths: ["services/*"]
policy:
require_fields: ["team", "oncall"]
services:
- name: api
language: Rust
role: API
platform: Cloud Run
# team and oncall intentionally omitted to trigger policy violations
"#,
);
let (m, d) = load(root);
let report = svccat::drift::analyze(&m, &d, root);
let policy_violations: Vec<_> = report
.drifts
.iter()
.filter(|d| d.kind == svccat::drift::DriftKind::PolicyViolation)
.collect();
assert_eq!(
policy_violations.len(),
2,
"expected 2 policy violations (team + oncall), got: {:?}",
policy_violations
);
let fields: Vec<_> = policy_violations
.iter()
.filter_map(|v| v.detail.as_deref())
.collect();
assert!(fields.contains(&"team"), "expected team violation");
assert!(fields.contains(&"oncall"), "expected oncall violation");
}
#[test]
fn team_filter_case_insensitive() {
let dir = TempDir::new().unwrap();
let root = dir.path();
touch(root, "services/api/Cargo.toml");
write_manifest(
root,
r#"
discovery:
paths: ["services/*"]
services:
- name: api
language: Rust
role: API
platform: Cloud Run
team: Platform
"#,
);
let mut m = svccat::manifest::Manifest::load(&root.join("services.yaml")).unwrap();
m.services.retain(|s| {
s.team
.as_deref()
.map(|t| t.eq_ignore_ascii_case("platform"))
.unwrap_or(false)
});
assert_eq!(m.services.len(), 1, "case-insensitive match should succeed");
}
#[test]
fn no_drift_for_valid_depends_on() {
let dir = TempDir::new().unwrap();
let root = dir.path();
touch(root, "services/api/Cargo.toml");
touch(root, "services/auth/Cargo.toml");
write_manifest(
root,
r#"
discovery:
paths: ["services/*"]
services:
- name: api
language: Rust
role: API
platform: Cloud Run
depends_on: [auth]
- name: auth
language: Rust
role: Auth
platform: Cloud Run
"#,
);
let (m, d) = load(root);
let report = svccat::drift::analyze(&m, &d, root);
let dependency_drifts: Vec<_> = report
.drifts
.iter()
.filter(|d| {
d.kind == svccat::drift::DriftKind::DanglingDependency
|| d.kind == svccat::drift::DriftKind::CircularDependency
})
.collect();
assert!(
dependency_drifts.is_empty(),
"valid depends_on should produce no drift: {:?}",
dependency_drifts
);
}
#[test]
fn detects_dangling_depends_on() {
let dir = TempDir::new().unwrap();
let root = dir.path();
touch(root, "services/api/Cargo.toml");
write_manifest(
root,
r#"
discovery:
paths: ["services/*"]
services:
- name: api
language: Rust
role: API
platform: Cloud Run
depends_on: [ghost-service]
"#,
);
let (m, d) = load(root);
let report = svccat::drift::analyze(&m, &d, root);
let dangling: Vec<_> = report
.drifts
.iter()
.filter(|d| d.kind == svccat::drift::DriftKind::DanglingDependency)
.collect();
assert_eq!(
dangling.len(),
1,
"expected 1 dangling dependency: {:?}",
dangling
);
assert_eq!(dangling[0].detail.as_deref(), Some("ghost-service"));
assert_eq!(dangling[0].severity, svccat::drift::Severity::Error);
}
#[test]
fn detects_two_node_cycle() {
let dir = TempDir::new().unwrap();
let root = dir.path();
touch(root, "services/api/Cargo.toml");
touch(root, "services/auth/Cargo.toml");
write_manifest(
root,
r#"
discovery:
paths: ["services/*"]
services:
- name: api
language: Rust
role: API
platform: Cloud Run
depends_on: [auth]
- name: auth
language: Rust
role: Auth
platform: Cloud Run
depends_on: [api]
"#,
);
let (m, d) = load(root);
let report = svccat::drift::analyze(&m, &d, root);
let cycles: Vec<_> = report
.drifts
.iter()
.filter(|d| d.kind == svccat::drift::DriftKind::CircularDependency)
.collect();
assert!(
!cycles.is_empty(),
"expected cycle to be detected: {:?}",
report.drifts
);
assert_eq!(cycles[0].severity, svccat::drift::Severity::Error);
}
#[test]
fn detects_three_node_cycle() {
let dir = TempDir::new().unwrap();
let root = dir.path();
touch(root, "services/a/Cargo.toml");
touch(root, "services/b/Cargo.toml");
touch(root, "services/c/Cargo.toml");
write_manifest(
root,
r#"
discovery:
paths: ["services/*"]
services:
- name: a
role: A
depends_on: [b]
- name: b
role: B
depends_on: [c]
- name: c
role: C
depends_on: [a]
"#,
);
let (m, d) = load(root);
let report = svccat::drift::analyze(&m, &d, root);
let cycles: Vec<_> = report
.drifts
.iter()
.filter(|d| d.kind == svccat::drift::DriftKind::CircularDependency)
.collect();
assert!(
!cycles.is_empty(),
"expected 3-node cycle to be detected: {:?}",
report.drifts
);
}
#[test]
fn sarif_output_is_valid_json_with_rules() {
let dir = TempDir::new().unwrap();
let root = dir.path();
touch(root, "services/api/Cargo.toml");
write_manifest(
root,
r#"
discovery:
paths: ["services/*"]
services:
- name: api
language: Rust
platform: Cloud Run
# role intentionally missing to trigger MissingField
"#,
);
let (m, d) = load(root);
let mut report = svccat::drift::analyze(&m, &d, root);
report.manifest = "services.yaml".to_string();
let role_drift = report.drifts.iter().any(|d| {
d.kind == svccat::drift::DriftKind::MissingField && d.detail.as_deref() == Some("role")
});
assert!(
role_drift,
"expected MissingField for role: {:?}",
report.drifts
);
let sarif_doc = serde_json::json!({
"version": "2.1.0",
"runs": [{
"tool": { "driver": { "name": "svccat" } },
"results": report.drifts.iter().map(|item| {
let rule_id = match item.kind {
svccat::drift::DriftKind::MissingField => "missing_field",
svccat::drift::DriftKind::PolicyViolation => "policy_violation",
svccat::drift::DriftKind::DeclaredMissingFromRepo => "declared_missing_from_repo",
svccat::drift::DriftKind::UndeclaredInRepo => "undeclared_in_repo",
svccat::drift::DriftKind::MissingReferencedFile => "missing_referenced_file",
svccat::drift::DriftKind::DanglingDependency => "dangling_dependency",
svccat::drift::DriftKind::CircularDependency => "circular_dependency",
_ => "unknown",
};
serde_json::json!({ "ruleId": rule_id, "message": { "text": item.message } })
}).collect::<Vec<_>>()
}]
});
assert_eq!(sarif_doc["version"], "2.1.0");
let results = sarif_doc["runs"][0]["results"].as_array().unwrap();
assert!(!results.is_empty(), "SARIF results should not be empty");
assert_eq!(results[0]["ruleId"], "missing_field");
}
#[test]
fn graph_team_filter_limits_to_in_scope_services() {
let dir = TempDir::new().unwrap();
let root = dir.path();
touch(root, "services/api/Cargo.toml");
touch(root, "services/worker/Cargo.toml");
touch(root, "services/auth/Cargo.toml");
write_manifest(
root,
r#"
discovery:
paths: ["services/*"]
services:
- name: api
language: Rust
role: API
platform: Cloud Run
team: platform
depends_on: [auth]
- name: auth
language: Rust
role: Auth
platform: Cloud Run
team: platform
- name: worker
language: Rust
role: Worker
platform: Cloud Run
team: data
"#,
);
let m = svccat::manifest::Manifest::load(&root.join("services.yaml")).unwrap();
let in_scope: std::collections::HashSet<&str> = m
.services
.iter()
.filter(|s| {
s.team
.as_deref()
.map(|t| t.eq_ignore_ascii_case("platform"))
.unwrap_or(false)
})
.map(|s| s.name.as_str())
.collect();
assert!(in_scope.contains("api"), "api should be in platform scope");
assert!(
in_scope.contains("auth"),
"auth should be in platform scope"
);
assert!(
!in_scope.contains("worker"),
"worker should not be in platform scope"
);
assert_eq!(in_scope.len(), 2);
}
#[test]
fn lint_clean_manifest_has_no_issues() {
let dir = TempDir::new().unwrap();
let root = dir.path();
write_manifest(
root,
r#"
version: "1"
services:
- name: api
role: API
language: Rust
platform: Cloud Run
team: platform
docs: docs/api.md
depends_on: [auth]
- name: auth
role: Auth
language: Rust
platform: Cloud Run
team: platform
docs: docs/auth.md
"#,
);
let m = svccat::manifest::Manifest::load(&root.join("services.yaml")).unwrap();
let result = svccat::lint::run(&m);
assert!(
result.issues.is_empty(),
"clean manifest should have no lint issues: {:?}",
result.issues
);
}
#[test]
fn lint_detects_duplicate_service_names() {
let dir = TempDir::new().unwrap();
let root = dir.path();
write_manifest(
root,
r#"
services:
- name: api
role: API
- name: api
role: Duplicate
"#,
);
let m = svccat::manifest::Manifest::load(&root.join("services.yaml")).unwrap();
let result = svccat::lint::run(&m);
let dups: Vec<_> = result
.issues
.iter()
.filter(|i| i.message.contains("duplicate service name"))
.collect();
assert_eq!(
dups.len(),
1,
"expected 1 duplicate-name issue: {:?}",
result.issues
);
assert_eq!(result.error_count(), 1);
}
#[test]
fn lint_detects_self_referential_depends_on() {
let dir = TempDir::new().unwrap();
let root = dir.path();
write_manifest(
root,
r#"
services:
- name: api
role: API
depends_on: [api]
"#,
);
let m = svccat::manifest::Manifest::load(&root.join("services.yaml")).unwrap();
let result = svccat::lint::run(&m);
let self_refs: Vec<_> = result
.issues
.iter()
.filter(|i| i.message.contains("lists itself"))
.collect();
assert_eq!(
self_refs.len(),
1,
"expected 1 self-reference issue: {:?}",
result.issues
);
assert_eq!(self_refs[0].severity, svccat::lint::LintSeverity::Error);
}
#[test]
fn lint_detects_duplicate_depends_on_entries() {
let dir = TempDir::new().unwrap();
let root = dir.path();
write_manifest(
root,
r#"
services:
- name: api
role: API
depends_on: [auth, auth]
- name: auth
role: Auth
"#,
);
let m = svccat::manifest::Manifest::load(&root.join("services.yaml")).unwrap();
let result = svccat::lint::run(&m);
let dup_deps: Vec<_> = result
.issues
.iter()
.filter(|i| i.message.contains("more than once"))
.collect();
assert_eq!(
dup_deps.len(),
1,
"expected 1 duplicate-depends_on warning: {:?}",
result.issues
);
assert_eq!(dup_deps[0].severity, svccat::lint::LintSeverity::Warning);
}
#[test]
fn report_markdown_contains_service_names_and_summary() {
let dir = TempDir::new().unwrap();
let root = dir.path();
touch(root, "services/api/Cargo.toml");
touch(root, "services/worker/Cargo.toml");
write_manifest(
root,
r#"
discovery:
paths: ["services/*"]
services:
- name: api
language: Rust
role: API
platform: Cloud Run
team: platform
oncall: oncall@example.com
- name: worker
language: Python
role: Worker
platform: Fly.io
team: data
"#,
);
let (m, d) = load(root);
let report = svccat::drift::analyze(&m, &d, root);
let md = svccat::report::render_markdown(&m, &report);
assert!(md.contains("# Service Catalog Report"));
assert!(md.contains("| Services | 2 |"));
assert!(md.contains("api"));
assert!(md.contains("worker"));
assert!(md.contains("platform"));
assert!(md.contains("data"));
assert!(md.contains("✅"), "expected clean status cells in markdown");
}
#[test]
fn report_html_contains_html_structure() {
let dir = TempDir::new().unwrap();
let root = dir.path();
touch(root, "services/api/Cargo.toml");
write_manifest(
root,
r#"
discovery:
paths: ["services/*"]
services:
- name: api
language: Rust
role: API
platform: Cloud Run
team: platform
"#,
);
let (m, d) = load(root);
let report = svccat::drift::analyze(&m, &d, root);
let html = svccat::report::render_html(&m, &report);
assert!(html.contains("<!DOCTYPE html>"));
assert!(html.contains("<table>"));
assert!(html.contains("<th>Service</th>"));
assert!(html.contains("api"));
assert!(html.contains("Service Catalog Report"));
}
#[test]
fn since_load_at_ref_returns_committed_manifest() {
let dir = TempDir::new().unwrap();
let root = dir.path();
touch(root, "services/api/Cargo.toml");
let initial_manifest = r#"
discovery:
paths: ["services/*"]
services:
- name: api
language: Rust
role: API
platform: Cloud Run
"#;
write_manifest(root, initial_manifest);
for args in &[
vec!["init"],
vec!["config", "user.email", "test@example.com"],
vec!["config", "user.name", "Test"],
vec!["add", "services.yaml"],
vec!["commit", "-m", "init"],
] {
std::process::Command::new("git")
.args(
std::iter::once("-C")
.chain(std::iter::once(root.to_str().unwrap()))
.chain(args.iter().copied()),
)
.output()
.unwrap();
}
let manifest_path = root.join("services.yaml");
let m = svccat::since::load_at_ref(root, &manifest_path, "HEAD").unwrap();
assert_eq!(m.services.len(), 1);
assert_eq!(m.services[0].name, "api");
}
#[test]
fn check_markdown_no_drift() {
let dir = TempDir::new().unwrap();
let root = dir.path();
touch(root, "services/api/Cargo.toml");
write_manifest(
root,
r#"
discovery:
paths: ["services/*"]
services:
- name: api
language: Rust
role: API
platform: Cloud Run
"#,
);
let (m, d) = load(root);
let report = svccat::drift::analyze(&m, &d, root);
let md = svccat::output::markdown::render_check_markdown(&report, &[]);
assert!(md.contains("## 🔍 svccat drift check"));
assert!(md.contains("✅ **No drift detected**"));
assert!(!md.contains("❌ **DRIFT DETECTED**"));
}
#[test]
fn check_markdown_with_drift_contains_table() {
let dir = TempDir::new().unwrap();
let root = dir.path();
write_manifest(
root,
r#"
discovery:
paths: ["services/*"]
services:
- name: ghost-service
language: Rust
role: API
platform: Cloud Run
"#,
);
let (m, d) = load(root);
let report = svccat::drift::analyze(&m, &d, root);
let md = svccat::output::markdown::render_check_markdown(&report, &[]);
assert!(md.contains("❌ **DRIFT DETECTED**"));
assert!(md.contains("| Severity | Kind | Service | Message |"));
assert!(md.contains("MISSING"));
assert!(md.contains("ghost-service"));
}
#[test]
fn since_diff_markdown_no_change() {
let dir = TempDir::new().unwrap();
let root = dir.path();
touch(root, "services/api/Cargo.toml");
write_manifest(
root,
r#"
discovery:
paths: ["services/*"]
services:
- name: api
language: Rust
role: API
platform: Cloud Run
"#,
);
let (m, d) = load(root);
let old_report = svccat::drift::analyze(&m, &d, root);
let new_report = svccat::drift::analyze(&m, &d, root);
let md =
svccat::output::markdown::render_since_diff_markdown(&old_report, &new_report, "HEAD~1");
assert!(md.contains("✅ **No change in drift since"));
}
#[test]
fn since_diff_markdown_new_drift() {
let dir = TempDir::new().unwrap();
let root = dir.path();
touch(root, "services/api/Cargo.toml");
let clean_manifest = r#"
discovery:
paths: ["services/*"]
services:
- name: api
language: Rust
role: API
platform: Cloud Run
"#;
let drifty_manifest = r#"
discovery:
paths: ["services/*"]
services:
- name: api
language: Rust
role: API
platform: Cloud Run
- name: ghost-service
language: Go
role: Worker
platform: Cloud Run
"#;
write_manifest(root, clean_manifest);
let (m_clean, d) = load(root);
let old_report = svccat::drift::analyze(&m_clean, &d, root);
write_manifest(root, drifty_manifest);
let (m_drifty, _) = load(root);
let new_report = svccat::drift::analyze(&m_drifty, &d, root);
let md =
svccat::output::markdown::render_since_diff_markdown(&old_report, &new_report, "HEAD~1");
assert!(md.contains("### ❌ New drift since"));
assert!(md.contains("ghost-service"));
}
#[test]
fn report_history_markdown_with_git_repo() {
let dir = TempDir::new().unwrap();
let root = dir.path();
touch(root, "services/api/Cargo.toml");
let manifest_content = r#"
discovery:
paths: ["services/*"]
services:
- name: api
language: Rust
role: API
platform: Cloud Run
"#;
write_manifest(root, manifest_content);
for args in &[
vec!["init"],
vec!["config", "user.email", "test@example.com"],
vec!["config", "user.name", "Test"],
vec!["add", "services.yaml"],
vec!["commit", "-m", "first commit"],
] {
std::process::Command::new("git")
.args(
std::iter::once("-C")
.chain(std::iter::once(root.to_str().unwrap()))
.chain(args.iter().copied()),
)
.output()
.unwrap();
}
let manifest_path = root.join("services.yaml");
let m = svccat::manifest::Manifest::load(&manifest_path).unwrap();
let d = svccat::discovery::discover_services(root, &m);
let md = svccat::report::render_history_markdown(root, &manifest_path, &d, 3).unwrap();
assert!(md.contains("## Drift History"));
assert!(md.contains("| Commit | Summary |"));
assert!(md.contains("first commit"));
}
#[test]
fn github_annotation_no_drift_emits_nothing() {
let dir = TempDir::new().unwrap();
let root = dir.path();
touch(root, "services/api/Cargo.toml");
write_manifest(
root,
r#"
discovery:
paths: ["services/*"]
services:
- name: api
language: Rust
role: API
platform: Cloud Run
"#,
);
let (m, d) = load(root);
let report = svccat::drift::analyze(&m, &d, root);
assert!(report.drifts.is_empty());
}
#[test]
fn github_annotation_renders_error_and_warning() {
let dir = TempDir::new().unwrap();
let root = dir.path();
write_manifest(
root,
r#"
discovery:
paths: ["services/*"]
services:
- name: ghost-service
language: Rust
role: API
platform: Cloud Run
"#,
);
let (m, d) = load(root);
let report = svccat::drift::analyze(&m, &d, root);
assert!(!report.drifts.is_empty());
}
#[test]
fn badge_clean_contains_brightgreen() {
let dir = TempDir::new().unwrap();
let root = dir.path();
touch(root, "services/api/Cargo.toml");
write_manifest(
root,
r#"
discovery:
paths: ["services/*"]
services:
- name: api
language: Rust
role: API
platform: Cloud Run
"#,
);
let (m, d) = load(root);
let report = svccat::drift::analyze(&m, &d, root);
let badge = svccat::report::render_badge(&report);
assert!(
badge.contains("brightgreen"),
"clean repo badge should be brightgreen"
);
assert!(badge.contains("shields.io"), "badge should use shields.io");
assert!(
badge.starts_with("[!["),
"badge should be Markdown image link"
);
assert!(
badge.contains("crates.io"),
"badge should link to crates.io"
);
}
#[test]
fn badge_with_errors_contains_red() {
let dir = TempDir::new().unwrap();
let root = dir.path();
write_manifest(
root,
r#"
discovery:
paths: ["services/*"]
services:
- name: ghost-service
language: Rust
role: API
platform: Cloud Run
"#,
);
let (m, d) = load(root);
let report = svccat::drift::analyze(&m, &d, root);
let badge = svccat::report::render_badge(&report);
assert!(badge.contains("red"), "drifty repo badge should be red");
assert!(badge.contains("error"), "badge label should mention error");
}
#[test]
fn junit_output_contains_failures_for_drift_and_unreachable_ping() {
let dir = TempDir::new().unwrap();
let root = dir.path();
write_manifest(
root,
r#"
discovery:
paths: ["services/*"]
services:
- name: ghost-service
language: Rust
role: API
platform: Cloud Run
"#,
);
let (m, d) = load(root);
let report = svccat::drift::analyze(&m, &d, root);
let ping_results = vec![svccat::ping::PingResult {
service: "ghost-service".to_string(),
url: "https://ghost.example".to_string(),
ping: svccat::ping::PingStatus::Unreachable {
reason: "timeout".to_string(),
},
}];
let xml = svccat::output::junit::build_check_document(&report, &ping_results);
assert!(xml.starts_with("<?xml"), "expected XML declaration");
assert!(
xml.contains("testsuite name=\"svccat.check\""),
"expected check testsuite"
);
assert!(
xml.contains("<failure"),
"expected failures in junit output"
);
assert!(
xml.contains("DeclaredMissingFromRepo"),
"expected drift testcase"
);
assert!(
xml.contains("ghost-service"),
"expected service in testcase"
);
assert!(
xml.contains("classname=\"svccat.ping\""),
"expected ping testcase"
);
}
#[test]
fn junit_since_reports_only_new_drift() {
let dir = TempDir::new().unwrap();
let root = dir.path();
touch(root, "services/api/Cargo.toml");
write_manifest(
root,
r#"
discovery:
paths: ["services/*"]
services:
- name: api
language: Rust
role: API
platform: Cloud Run
"#,
);
let (m, d) = load(root);
let mut old_report = svccat::drift::analyze(&m, &d, root);
old_report.manifest = "services.yaml".to_string();
touch(root, "services/new-feature/go.mod");
let d2 = svccat::discovery::discover_services(root, &m);
let mut new_report = svccat::drift::analyze(&m, &d2, root);
new_report.manifest = "services.yaml".to_string();
let (xml, new_count) =
svccat::output::junit::build_since_document(&old_report, &new_report, "HEAD~1");
assert_eq!(new_count, 1, "expected exactly one new drift item");
assert!(
xml.contains("testsuite name=\"svccat.check.since\""),
"expected since testsuite"
);
assert!(
xml.contains("property name=\"git_ref\" value=\"HEAD~1\""),
"expected git_ref property"
);
assert!(
xml.contains("UndeclaredInRepo:new-feature"),
"expected only new drift testcase"
);
}
#[test]
fn output_matrix_from_shared_drift_fixture_is_structurally_valid() {
let dir = TempDir::new().unwrap();
let root = dir.path();
touch(root, "services/api/Cargo.toml");
touch(root, "services/rogue/go.mod");
write_manifest(
root,
r#"
discovery:
paths: ["services/*"]
services:
- name: api
language: Rust
platform: Cloud Run
- name: ghost-service
language: Go
role: Worker
platform: Cloud Run
"#,
);
let (m, d) = load(root);
let report = svccat::drift::analyze(&m, &d, root);
assert!(!report.drifts.is_empty(), "fixture should contain drift");
let json_output = svccat::output::json::render_check_to_string(&report, &[]).unwrap();
let json_value: serde_json::Value = serde_json::from_str(&json_output).unwrap();
assert_eq!(
json_value["summary"]["errors"].as_u64().unwrap(),
report.error_count() as u64
);
assert_eq!(
json_value["summary"]["warnings"].as_u64().unwrap(),
report.warning_count() as u64
);
let slack_output = svccat::output::slack::render_check_to_string(&report).unwrap();
let slack_value: serde_json::Value = serde_json::from_str(&slack_output).unwrap();
assert!(slack_value["blocks"].is_array());
assert!(
slack_output.contains("ghost-service") || slack_output.contains("rogue"),
"slack payload should include drift services"
);
let teams_output = svccat::output::teams::render_check_to_string(&report).unwrap();
let teams_value: serde_json::Value = serde_json::from_str(&teams_output).unwrap();
assert!(teams_value["attachments"].is_array());
assert!(
teams_output.contains("ghost-service") || teams_output.contains("rogue"),
"teams payload should include drift services"
);
let datadog_output = svccat::output::datadog::render_check_to_string(&report).unwrap();
let datadog_value: serde_json::Value = serde_json::from_str(&datadog_output).unwrap();
assert!(datadog_value["events"].is_array());
assert_eq!(
datadog_value["errors"].as_u64().unwrap(),
report.error_count() as u64
);
assert_eq!(
datadog_value["warnings"].as_u64().unwrap(),
report.warning_count() as u64
);
let mermaid_graph = svccat::output::mermaid::render_graph_filtered_string(&m, None);
assert!(mermaid_graph.contains("graph TD"));
assert!(mermaid_graph.contains("api"));
assert!(mermaid_graph.contains("ghost-service"));
}