use crate::cli::auto_commit::{commit_paths, print_skip, AutoCommitRequest, AutoCommitResult};
use crate::utils::{
collapse_project_path, default_project_yaml_config_path, find_xbp_config_upwards,
maybe_auto_convert_legacy_xbp_json_to_yaml, parse_config_with_auto_heal, serialize_xbp_yaml,
FoundXbpConfig,
};
use dialoguer::{theme::ColorfulTheme, Confirm, FuzzySelect, Input, MultiSelect};
use serde_json::{json, Map, Value};
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
pub async fn run_config_openapi_setup() -> Result<(), String> {
let (found, mut config, output_path) = load_project_config()?;
let project_root = found.project_root.clone();
println!("OpenAPI setup for project root: {}", project_root.display());
println!("Config file: {}", output_path.display());
println!();
migrate_misplaced_project_openapi_fields(&mut config);
let current = config.get("openapi").cloned().unwrap_or(Value::Null);
let currently_enabled = current
.get("enabled")
.and_then(Value::as_bool)
.unwrap_or(false);
let enabled = Confirm::with_theme(&ColorfulTheme::default())
.with_prompt("Enable static OpenAPI generation for this project?")
.default(currently_enabled || true)
.interact()
.map_err(|e| format!("Failed to read openapi enabled preference: {e}"))?;
if !enabled {
set_project_openapi(
&mut config,
json!({
"enabled": false,
}),
)?;
write_project_yaml_config(&output_path, &config)?;
println!(
"Saved openapi.enabled=false to {}",
output_path.display()
);
maybe_commit(&found.project_root, &output_path, "disable OpenAPI generation").await;
return Ok(());
}
let auto_detect = Confirm::with_theme(&ColorfulTheme::default())
.with_prompt(
"Auto-detect OpenAPI for services that omit services[].openapi.enabled? (recommended: no for large monorepos)",
)
.default(
current
.get("auto_detect_services")
.and_then(Value::as_bool)
.unwrap_or(false),
)
.interact()
.map_err(|e| format!("Failed to read auto_detect preference: {e}"))?;
let generate_on_release = Confirm::with_theme(&ColorfulTheme::default())
.with_prompt("Regenerate OpenAPI contracts during `xbp release`?")
.default(
current
.get("generate_on_release")
.and_then(Value::as_bool)
.unwrap_or(true),
)
.interact()
.map_err(|e| format!("Failed to read generate_on_release preference: {e}"))?;
let defaults = current
.get("service_defaults")
.cloned()
.unwrap_or(Value::Object(Map::new()));
let spec_version: String = Input::<String>::with_theme(&ColorfulTheme::default())
.with_prompt("Default OpenAPI spec version")
.with_initial_text(
defaults
.get("spec_version")
.and_then(Value::as_str)
.unwrap_or("3.1.0")
.to_string(),
)
.interact_text()
.map_err(|e| format!("Failed to read spec_version: {e}"))?
.trim()
.to_string();
let unknown_schema_options = ["permissive", "strict"];
let unknown_default = defaults
.get("unknown_schema")
.and_then(Value::as_str)
.unwrap_or("permissive");
let unknown_idx = unknown_schema_options
.iter()
.position(|v| *v == unknown_default)
.unwrap_or(0);
let unknown_schema = unknown_schema_options[FuzzySelect::with_theme(&ColorfulTheme::default())
.with_prompt("Unresolved type policy")
.items(&unknown_schema_options)
.default(unknown_idx)
.interact()
.map_err(|e| format!("Failed to read unknown_schema: {e}"))?];
let strict = Confirm::with_theme(&ColorfulTheme::default())
.with_prompt("Default strict mode (fail on unresolved types)?")
.default(
defaults
.get("strict")
.and_then(Value::as_bool)
.unwrap_or(false),
)
.interact()
.map_err(|e| format!("Failed to read strict preference: {e}"))?;
let default_source_roots: String = Input::<String>::with_theme(&ColorfulTheme::default())
.with_prompt("Default source roots (comma-separated, relative to each service root)")
.with_initial_text(
defaults
.get("source_roots")
.and_then(Value::as_array)
.map(|arr| {
arr.iter()
.filter_map(Value::as_str)
.collect::<Vec<_>>()
.join(", ")
})
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "src".to_string()),
)
.interact_text()
.map_err(|e| format!("Failed to read source_roots: {e}"))?;
let aggregate_current = current.get("aggregate").cloned().unwrap_or(Value::Null);
let aggregate_enabled = Confirm::with_theme(&ColorfulTheme::default())
.with_prompt("Also write an aggregate OpenAPI document for all enabled services?")
.default(
aggregate_current
.get("enabled")
.and_then(Value::as_bool)
.unwrap_or(false),
)
.interact()
.map_err(|e| format!("Failed to read aggregate preference: {e}"))?;
let mut aggregate_block = json!({ "enabled": aggregate_enabled });
if aggregate_enabled {
let agg_yaml: String = Input::<String>::with_theme(&ColorfulTheme::default())
.with_prompt("Aggregate YAML output path (relative to project root)")
.with_initial_text(
aggregate_current
.get("outputs")
.and_then(|o| o.get("yaml"))
.and_then(Value::as_str)
.unwrap_or("openapi.aggregate.yaml")
.to_string(),
)
.interact_text()
.map_err(|e| format!("Failed to read aggregate yaml path: {e}"))?
.trim()
.to_string();
let agg_json: String = Input::<String>::with_theme(&ColorfulTheme::default())
.with_prompt("Aggregate JSON output path (empty to skip)")
.allow_empty(true)
.with_initial_text(
aggregate_current
.get("outputs")
.and_then(|o| o.get("json"))
.and_then(Value::as_str)
.unwrap_or("openapi.aggregate.json")
.to_string(),
)
.interact_text()
.map_err(|e| format!("Failed to read aggregate json path: {e}"))?
.trim()
.to_string();
let mut outputs = Map::new();
if !agg_yaml.is_empty() {
outputs.insert("yaml".into(), Value::String(agg_yaml));
}
if !agg_json.is_empty() {
outputs.insert("json".into(), Value::String(agg_json));
}
aggregate_block
.as_object_mut()
.expect("object")
.insert("outputs".into(), Value::Object(outputs));
}
let services = list_services(&config);
if services.is_empty() {
return Err(
"No services found in .xbp/xbp.yaml. Add services first, then re-run `xbp config openapi`."
.into(),
);
}
let candidates = score_openapi_candidates(&project_root, &services);
println!();
println!("Detected likely API services (pre-selected when score is high):");
for candidate in &candidates {
println!(
" • {:<40} root={:<40} score={}",
candidate.name, candidate.root_display, candidate.score
);
}
println!();
let labels: Vec<String> = candidates
.iter()
.map(|c| {
let flag = if c.currently_enabled {
"[on]"
} else {
"[off]"
};
format!(
"{flag} {} ({}) score={}",
c.name, c.root_display, c.score
)
})
.collect();
let defaults_selected: Vec<bool> = candidates
.iter()
.map(|c| c.currently_enabled || c.score >= 4)
.collect();
let selected = MultiSelect::with_theme(&ColorfulTheme::default())
.with_prompt("Enable OpenAPI generation for which services? (space to toggle, enter to confirm)")
.items(&labels)
.defaults(&defaults_selected)
.interact()
.map_err(|e| format!("Failed to read service selection: {e}"))?;
if selected.is_empty() && !auto_detect {
println!(
"No services selected and auto-detect is off — generation will match nothing until you enable services."
);
}
let configure_each = Confirm::with_theme(&ColorfulTheme::default())
.with_prompt("Configure source roots / outputs for each selected service now?")
.default(true)
.interact()
.map_err(|e| format!("Failed to read per-service config preference: {e}"))?;
let source_roots: Vec<Value> = split_csv(&default_source_roots)
.into_iter()
.map(Value::String)
.collect();
let project_openapi = json!({
"enabled": true,
"auto_detect_services": auto_detect,
"generate_on_release": generate_on_release,
"service_defaults": {
"spec_version": spec_version,
"strict": strict,
"unknown_schema": unknown_schema,
"source_roots": source_roots,
"outputs": {
"yaml": "openapi.yaml",
"json": "openapi.json",
}
},
"aggregate": aggregate_block,
});
set_project_openapi(&mut config, project_openapi)?;
let selected_names: std::collections::HashSet<String> = selected
.iter()
.map(|&idx| candidates[idx].name.clone())
.collect();
for candidate in &candidates {
if selected_names.contains(&candidate.name) {
let mut service_openapi = existing_service_openapi(&config, &candidate.name)
.unwrap_or_else(|| json!({}));
let obj = service_openapi
.as_object_mut()
.ok_or_else(|| format!("service {} openapi must be an object", candidate.name))?;
obj.insert("enabled".into(), Value::Bool(true));
if configure_each {
println!();
println!("── service: {} ──", candidate.name);
let source_roots_input: String = Input::<String>::with_theme(&ColorfulTheme::default())
.with_prompt(format!(
"Source roots for {} (relative to service root)",
candidate.name
))
.with_initial_text(
service_openapi
.get("source_roots")
.and_then(Value::as_array)
.map(|arr| {
arr.iter()
.filter_map(Value::as_str)
.collect::<Vec<_>>()
.join(", ")
})
.filter(|s| !s.is_empty())
.unwrap_or_else(|| {
if candidate.suggested_source_roots.is_empty() {
"src".to_string()
} else {
candidate.suggested_source_roots.join(", ")
}
}),
)
.interact_text()
.map_err(|e| format!("Failed to read source roots for {}: {e}", candidate.name))?;
let yaml_out: String = Input::<String>::with_theme(&ColorfulTheme::default())
.with_prompt("YAML output path (relative to service root)")
.with_initial_text(
service_openapi
.get("outputs")
.and_then(|o| o.get("yaml"))
.and_then(Value::as_str)
.unwrap_or("openapi.yaml")
.to_string(),
)
.interact_text()
.map_err(|e| format!("Failed to read yaml output for {}: {e}", candidate.name))?
.trim()
.to_string();
let json_out: String = Input::<String>::with_theme(&ColorfulTheme::default())
.with_prompt("JSON output path (empty to skip)")
.allow_empty(true)
.with_initial_text(
service_openapi
.get("outputs")
.and_then(|o| o.get("json"))
.and_then(Value::as_str)
.unwrap_or("openapi.json")
.to_string(),
)
.interact_text()
.map_err(|e| format!("Failed to read json output for {}: {e}", candidate.name))?
.trim()
.to_string();
let default_prefix = format!("/{}", candidate.name);
let prefix: String = Input::<String>::with_theme(&ColorfulTheme::default())
.with_prompt(
"Aggregate path prefix (used when combining services; avoids route collisions)",
)
.with_initial_text(
service_openapi
.get("aggregate_path_prefix")
.and_then(Value::as_str)
.unwrap_or(&default_prefix)
.to_string(),
)
.interact_text()
.map_err(|e| {
format!(
"Failed to read aggregate_path_prefix for {}: {e}",
candidate.name
)
})?
.trim()
.to_string();
let framework: String = Input::<String>::with_theme(&ColorfulTheme::default())
.with_prompt("Framework hint (rust/node/python, empty for auto)")
.allow_empty(true)
.with_initial_text(
service_openapi
.get("framework")
.and_then(Value::as_str)
.unwrap_or("")
.to_string(),
)
.interact_text()
.map_err(|e| format!("Failed to read framework for {}: {e}", candidate.name))?
.trim()
.to_string();
let obj = service_openapi
.as_object_mut()
.ok_or_else(|| format!("service {} openapi must be an object", candidate.name))?;
obj.insert(
"source_roots".into(),
Value::Array(split_csv(&source_roots_input).into_iter().map(Value::String).collect()),
);
let mut outputs = Map::new();
if !yaml_out.is_empty() {
outputs.insert("yaml".into(), Value::String(yaml_out));
}
if !json_out.is_empty() {
outputs.insert("json".into(), Value::String(json_out));
}
obj.insert("outputs".into(), Value::Object(outputs));
if !prefix.is_empty() {
obj.insert("aggregate_path_prefix".into(), Value::String(prefix));
}
if !framework.is_empty() {
obj.insert("framework".into(), Value::String(framework));
} else {
obj.remove("framework");
}
} else {
let obj = service_openapi
.as_object_mut()
.ok_or_else(|| format!("service {} openapi must be an object", candidate.name))?;
if !obj.contains_key("source_roots") {
let roots = if candidate.suggested_source_roots.is_empty() {
vec!["src".to_string()]
} else {
candidate.suggested_source_roots.clone()
};
obj.insert(
"source_roots".into(),
Value::Array(roots.into_iter().map(Value::String).collect()),
);
}
if !obj.contains_key("outputs") {
obj.insert(
"outputs".into(),
json!({ "yaml": "openapi.yaml", "json": "openapi.json" }),
);
}
if !obj.contains_key("aggregate_path_prefix") {
obj.insert(
"aggregate_path_prefix".into(),
Value::String(format!("/{}", candidate.name)),
);
}
}
set_service_openapi(&mut config, &candidate.name, service_openapi)?;
} else if candidate.currently_enabled {
set_service_openapi(
&mut config,
&candidate.name,
json!({ "enabled": false }),
)?;
}
}
write_project_yaml_config(&output_path, &config)?;
println!();
println!("Saved OpenAPI config to {}", output_path.display());
println!("Generate with:");
if selected_names.is_empty() {
println!(" xbp generate openapi --all");
} else {
for name in &selected_names {
println!(" xbp generate openapi --service {name}");
}
println!(" xbp generate openapi --all");
}
maybe_commit(
&found.project_root,
&output_path,
"configure OpenAPI generation",
)
.await;
Ok(())
}
pub async fn run_config_openapi_show() -> Result<(), String> {
let (found, config, output_path) = load_project_config()?;
println!("Config: {}", output_path.display());
println!("Project root: {}", found.project_root.display());
println!();
match config.get("openapi") {
None | Some(Value::Null) => {
println!("openapi: (not configured)");
println!("Run `xbp config openapi` to set it up.");
return Ok(());
}
Some(openapi) => {
println!(
"enabled: {}",
openapi
.get("enabled")
.and_then(Value::as_bool)
.map(|v| v.to_string())
.unwrap_or_else(|| "(unset)".into())
);
println!(
"auto_detect_services: {}",
openapi
.get("auto_detect_services")
.and_then(Value::as_bool)
.map(|v| v.to_string())
.unwrap_or_else(|| "(unset, defaults true)".into())
);
println!(
"generate_on_release: {}",
openapi
.get("generate_on_release")
.and_then(Value::as_bool)
.map(|v| v.to_string())
.unwrap_or_else(|| "(unset)".into())
);
if let Some(defaults) = openapi.get("service_defaults") {
println!(
"service_defaults: {}",
serde_json::to_string(defaults).unwrap_or_default()
);
}
if let Some(aggregate) = openapi.get("aggregate") {
println!(
"aggregate: {}",
serde_json::to_string(aggregate).unwrap_or_default()
);
}
}
}
println!();
println!("Services:");
let services = list_services(&config);
if services.is_empty() {
println!(" (none)");
return Ok(());
}
for service in services {
let status = match &service.openapi {
None => "inherit/auto".to_string(),
Some(obj) => {
if obj.get("enabled") == Some(&Value::Bool(false)) {
"disabled".to_string()
} else {
let enabled = obj
.get("enabled")
.and_then(Value::as_bool)
.map(|v| if v { "enabled" } else { "disabled" })
.unwrap_or("enabled?");
let outputs = obj
.get("outputs")
.map(|o| serde_json::to_string(o).unwrap_or_default())
.unwrap_or_else(|| "(defaults)".into());
format!("{enabled} outputs={outputs}")
}
}
};
println!(
" • {:<40} root={:<30} {status}",
service.name, service.root
);
}
Ok(())
}
struct ServiceCandidate {
name: String,
root_display: String,
score: i32,
currently_enabled: bool,
suggested_source_roots: Vec<String>,
}
struct ServiceRef {
name: String,
root: String,
openapi: Option<Value>,
}
fn load_project_config() -> Result<(FoundXbpConfig, Value, PathBuf), String> {
let current_dir =
env::current_dir().map_err(|e| format!("Failed to get current directory: {e}"))?;
let found = find_xbp_config_upwards(¤t_dir).ok_or_else(|| {
"No XBP project config found. Run this inside a repo with `.xbp/xbp.yaml`.".to_string()
})?;
let source_path = if found.kind == "json" {
maybe_auto_convert_legacy_xbp_json_to_yaml(&found.project_root, &found.config_path)?
.unwrap_or_else(|| default_project_yaml_config_path(&found.project_root))
} else {
found.config_path.clone()
};
let kind = if source_path
.extension()
.and_then(|ext| ext.to_str())
.map(|ext| ext.eq_ignore_ascii_case("yaml") || ext.eq_ignore_ascii_case("yml"))
.unwrap_or(false)
{
"yaml"
} else {
"json"
};
let content = fs::read_to_string(&source_path)
.map_err(|e| format!("Failed to read config {}: {e}", source_path.display()))?;
let (config, _) = parse_config_with_auto_heal::<Value>(&content, kind)
.map_err(|e| format!("Failed to parse project config: {e}"))?;
let output_path = default_project_yaml_config_path(&found.project_root);
Ok((found, config, output_path))
}
fn write_project_yaml_config(path: &Path, config: &Value) -> Result<(), String> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| {
format!(
"Failed to create config directory {}: {e}",
parent.display()
)
})?;
}
let content = serialize_xbp_yaml(config)?;
fs::write(path, content)
.map_err(|e| format!("Failed to write project config {}: {e}", path.display()))
}
fn set_project_openapi(config: &mut Value, openapi: Value) -> Result<(), String> {
let root = config
.as_object_mut()
.ok_or_else(|| "Project config root must be an object.".to_string())?;
root.insert("openapi".into(), openapi);
Ok(())
}
fn set_service_openapi(config: &mut Value, name: &str, openapi: Value) -> Result<(), String> {
let services = config
.as_object_mut()
.ok_or_else(|| "Project config root must be an object.".to_string())?
.get_mut("services")
.and_then(Value::as_array_mut)
.ok_or_else(|| "Project config `services` must be an array.".to_string())?;
for service in services {
let service_name = service.get("name").and_then(Value::as_str).unwrap_or("");
if service_name == name {
let obj = service
.as_object_mut()
.ok_or_else(|| format!("Service `{name}` must be an object."))?;
obj.insert("openapi".into(), openapi);
return Ok(());
}
}
Err(format!("Service `{name}` not found in config."))
}
fn existing_service_openapi(config: &Value, name: &str) -> Option<Value> {
config
.get("services")?
.as_array()?
.iter()
.find(|s| s.get("name").and_then(Value::as_str) == Some(name))
.and_then(|s| s.get("openapi"))
.filter(|v| !v.is_null())
.cloned()
}
fn list_services(config: &Value) -> Vec<ServiceRef> {
config
.get("services")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(|service| {
let name = service.get("name")?.as_str()?.to_string();
let root = service
.get("root_directory")
.and_then(Value::as_str)
.unwrap_or("./")
.to_string();
let openapi = service
.get("openapi")
.filter(|v| !v.is_null())
.cloned();
Some(ServiceRef {
name,
root,
openapi,
})
})
.collect()
}
fn score_openapi_candidates(project_root: &Path, services: &[ServiceRef]) -> Vec<ServiceCandidate> {
let mut out: Vec<ServiceCandidate> = services
.iter()
.map(|service| {
let root_path = if service.root == "./" || service.root.is_empty() {
project_root.to_path_buf()
} else {
project_root.join(&service.root)
};
let root_display = collapse_project_path(
project_root,
root_path.to_string_lossy().as_ref(),
);
let mut score = 0i32;
let mut suggested_source_roots = Vec::new();
let has_src = root_path.join("src").is_dir();
let has_cargo = root_path.join("Cargo.toml").is_file();
let has_pkg = root_path.join("package.json").is_file();
let has_openapi = ["openapi.yaml", "openapi.yml", "openapi.json"]
.iter()
.any(|name| root_path.join(name).is_file());
let has_api_dir = root_path.join("src").join("api").is_dir()
|| root_path.join("src").join("routes").is_dir()
|| root_path.join("src").join("handlers").is_dir();
if has_src {
score += 2;
suggested_source_roots.push("src".into());
}
if has_cargo {
score += 2;
}
if has_pkg {
score += 1;
}
if has_openapi {
score += 3;
}
if has_api_dir {
score += 2;
}
let name_l = service.name.to_ascii_lowercase();
if name_l.contains("demo")
|| name_l.contains("ui")
|| name_l.contains("storybook")
|| name_l.contains("example")
{
score -= 3;
}
if name_l.ends_with("-auth") || name_l == "athena" || name_l.ends_with("-api") {
score += 2;
}
if service.root.matches('/').count() >= 3 {
score -= 1;
}
let currently_enabled = service
.openapi
.as_ref()
.and_then(|o| o.get("enabled"))
.and_then(Value::as_bool)
.unwrap_or(false);
if suggested_source_roots.is_empty() && has_src {
suggested_source_roots.push("src".into());
}
ServiceCandidate {
name: service.name.clone(),
root_display,
score,
currently_enabled,
suggested_source_roots,
}
})
.collect();
out.sort_by(|a, b| b.score.cmp(&a.score).then_with(|| a.name.cmp(&b.name)));
out
}
fn migrate_misplaced_project_openapi_fields(config: &mut Value) {
let Some(openapi) = config.get_mut("openapi") else {
return;
};
if openapi.is_null() {
return;
}
let Some(obj) = openapi.as_object_mut() else {
return;
};
let keys = [
"source_roots",
"outputs",
"unknown_schema",
"strict",
"spec_version",
];
let mut moved: Vec<(String, Value)> = Vec::new();
for key in keys {
if let Some(value) = obj.remove(key) {
moved.push((key.to_string(), value));
}
}
if moved.is_empty() && obj.contains_key("service_defaults") {
return;
}
let defaults = obj
.entry("service_defaults".to_string())
.or_insert_with(|| Value::Object(Map::new()));
if defaults.is_null() {
*defaults = Value::Object(Map::new());
}
let Some(defaults) = defaults.as_object_mut() else {
return;
};
for (key, value) in moved {
defaults.entry(key).or_insert(value);
}
}
fn split_csv(input: &str) -> Vec<String> {
input
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(ToString::to_string)
.collect()
}
async fn maybe_commit(project_root: &Path, path: &Path, action: &str) {
match commit_paths(AutoCommitRequest {
project_root,
paths: vec![path.to_path_buf()],
message: format!("chore(config): {action}"),
action_label: "xbp config openapi",
push: false,
})
.await
{
Ok(AutoCommitResult::Committed(_)) => {}
Ok(AutoCommitResult::Skipped(reason)) => print_skip("xbp config openapi", &reason),
Err(e) => print_skip("xbp config openapi", &e),
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use std::fs;
use std::time::{SystemTime, UNIX_EPOCH};
#[test]
fn migrates_misplaced_project_fields_into_service_defaults() {
let mut config = json!({
"openapi": {
"enabled": true,
"source_roots": ["./"],
"outputs": { "yaml": "openapi.yaml" },
"unknown_schema": "permissive",
"service_defaults": { "spec_version": "3.1.0" }
}
});
migrate_misplaced_project_openapi_fields(&mut config);
let openapi = config.get("openapi").unwrap();
assert!(openapi.get("source_roots").is_none());
assert!(openapi.get("outputs").is_none());
assert_eq!(
openapi
.pointer("/service_defaults/unknown_schema")
.and_then(Value::as_str),
Some("permissive")
);
assert_eq!(
openapi
.pointer("/service_defaults/spec_version")
.and_then(Value::as_str),
Some("3.1.0")
);
}
#[test]
fn scores_athena_and_auth_above_demo_ui() {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let root = env::temp_dir().join(format!("xbp-openapi-config-test-{nanos}"));
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(root.join("src/api")).unwrap();
fs::write(root.join("Cargo.toml"), "[package]\nname=\"athena\"\n").unwrap();
fs::create_dir_all(root.join("services/athena-auth/src")).unwrap();
fs::write(
root.join("services/athena-auth/Cargo.toml"),
"[package]\nname=\"athena-auth\"\n",
)
.unwrap();
fs::write(
root.join("services/athena-auth/openapi.yaml"),
"openapi: 3.1.0\n",
)
.unwrap();
fs::create_dir_all(root.join("packages/athena-auth-ui")).unwrap();
fs::write(
root.join("packages/athena-auth-ui/package.json"),
r#"{"name":"ui"}"#,
)
.unwrap();
let services = vec![
ServiceRef {
name: "athena".into(),
root: "./".into(),
openapi: None,
},
ServiceRef {
name: "athena-auth".into(),
root: "services/athena-auth".into(),
openapi: None,
},
ServiceRef {
name: "athena-auth-ui".into(),
root: "packages/athena-auth-ui".into(),
openapi: None,
},
];
let ranked = score_openapi_candidates(&root, &services);
assert!(
ranked.iter().find(|c| c.name == "athena").unwrap().score
> ranked
.iter()
.find(|c| c.name == "athena-auth-ui")
.unwrap()
.score
);
assert!(
ranked.iter().find(|c| c.name == "athena-auth").unwrap().score
> ranked
.iter()
.find(|c| c.name == "athena-auth-ui")
.unwrap()
.score
);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn set_service_openapi_updates_matching_service() {
let mut config = json!({
"services": [
{ "name": "athena", "openapi": null },
{ "name": "athena-auth", "openapi": null }
]
});
set_service_openapi(
&mut config,
"athena-auth",
json!({ "enabled": true, "source_roots": ["src"] }),
)
.unwrap();
assert_eq!(
config.pointer("/services/1/openapi/enabled"),
Some(&Value::Bool(true))
);
assert!(config.pointer("/services/0/openapi").unwrap().is_null());
}
}