use crate::cli::{CreateServiceArgs, ServiceCommand, ServiceTargetArgs};
use crate::util::{Result, make_vars, repo_path, repo_root, run_make, usage_error};
use std::collections::{BTreeSet, HashMap, HashSet};
use std::ffi::OsStr;
use std::fs;
use std::io::Read;
use std::path::{Path, PathBuf};
pub(crate) fn run_service_command(command: ServiceCommand) -> Result<()> {
match command {
ServiceCommand::Check { path } => check_service(&repo_path(&path)),
ServiceCommand::CheckAll => check_all_services(),
ServiceCommand::Create(args) => create_service(args),
ServiceCommand::Test(args) => run_service_make("test", args),
ServiceCommand::Build(args) => run_service_make("build", args),
ServiceCommand::Generate(args) => run_service_make("generate", args),
ServiceCommand::Run(args) => run_service_make("run", args),
ServiceCommand::Clippy(args) => run_service_make("clippy", args),
ServiceCommand::Compliance(args) => run_service_make("compliance", args),
ServiceCommand::GatewayRoutesCheck(args) => run_service_make("gateway-routes-check", args),
}
}
fn run_service_make(target: &str, args: ServiceTargetArgs) -> Result<()> {
let dir = resolve_service_dir(&args.target)?;
run_make(&dir, target, &make_vars(&args.vars))
}
fn create_service(args: CreateServiceArgs) -> Result<()> {
let name = args.name;
let domain = args.domain;
let template_lang = args.template_lang;
let service_type = args.service_type;
let framework = args.framework;
let owner_team = args.owner_team.unwrap_or_else(|| format!("team-{name}"));
let output = args
.output
.unwrap_or_else(|| format!("services/{domain}/{name}"));
let template = format!("tools/templates/template-{template_lang}-{service_type}");
let root = repo_root();
let template_dir = root.join(&template);
if !template_dir.is_dir() {
return usage_error(format!("Template not found: {template}"));
}
let output_dir = repo_path_for_create(&root, &output);
let template_name = args.template_name.unwrap_or_else(|| {
template_dir
.file_name()
.and_then(OsStr::to_str)
.unwrap_or("template")
.to_string()
});
let proto_package = args
.proto_package
.unwrap_or_else(|| package_from_name(&domain, &name));
let service_class = args.service_class.unwrap_or_else(|| class_from_name(&name));
create_service_from_template(CreateServiceSpec {
root: &root,
template_dir: &template_dir,
output_dir: &output_dir,
service_name: &name,
domain: &domain,
owner_team: &owner_team,
framework: &framework,
template_name: &template_name,
proto_package: &proto_package,
service_class: &service_class,
})?;
if template_lang == "rust" {
add_workspace_member(&root, &output_dir)?;
}
check_service(&output_dir)
}
fn repo_path_for_create(root: &Path, value: &str) -> PathBuf {
let path = PathBuf::from(value);
if path.is_absolute() {
path
} else {
root.join(path)
}
}
pub(crate) struct CreateServiceSpec<'a> {
pub(crate) root: &'a Path,
pub(crate) template_dir: &'a Path,
pub(crate) output_dir: &'a Path,
pub(crate) service_name: &'a str,
pub(crate) domain: &'a str,
pub(crate) owner_team: &'a str,
pub(crate) framework: &'a str,
pub(crate) template_name: &'a str,
pub(crate) proto_package: &'a str,
pub(crate) service_class: &'a str,
}
pub(crate) fn create_service_from_template(spec: CreateServiceSpec<'_>) -> Result<()> {
let framework_skeleton = spec
.template_dir
.join("skeleton-frameworks")
.join(spec.framework);
let skeleton = if spec.framework != "native" && framework_skeleton.is_dir() {
framework_skeleton
} else {
spec.template_dir.join("skeleton")
};
if !skeleton.is_dir() {
return usage_error(format!(
"template skeleton does not exist: {}",
skeleton.display()
));
}
if spec.output_dir.exists() {
return usage_error(format!(
"output already exists: {}",
spec.output_dir.display()
));
}
copy_template_dir(&skeleton, spec.output_dir)?;
replace_template_tokens(spec.output_dir, &spec)?;
let local_contract = spec.output_dir.join("api/service.proto");
let shared_contract = spec.root.join(format!(
"contracts/protobuf/{}-{}.proto",
spec.domain, spec.service_name
));
if local_contract.is_file() {
if !shared_contract.exists() {
if let Some(parent) = shared_contract.parent() {
fs::create_dir_all(parent)?;
}
fs::copy(&local_contract, &shared_contract)?;
}
println!("created shared contract {}", shared_contract.display());
}
println!("created {}", spec.output_dir.display());
Ok(())
}
pub(crate) fn add_workspace_member(root: &Path, member_dir: &Path) -> Result<()> {
let manifest = root.join("Cargo.toml");
if !manifest.exists() {
return Ok(());
}
let relative = member_dir
.strip_prefix(root)
.unwrap_or(member_dir)
.to_string_lossy()
.replace('\\', "/");
let text = fs::read_to_string(&manifest)?;
if text
.lines()
.any(|line| line.trim().trim_matches(',').trim_matches('"').trim() == relative)
{
return Ok(());
}
let Some(members_start) = text.find("members = [") else {
return Ok(());
};
let Some(close_offset) = text[members_start..].find("\n]") else {
return Ok(());
};
let insert_at = members_start + close_offset;
let mut updated = String::new();
updated.push_str(&text[..insert_at]);
updated.push_str(&format!("\n \"{relative}\","));
updated.push_str(&text[insert_at..]);
fs::write(&manifest, updated)?;
println!("added Cargo workspace member {relative}");
Ok(())
}
fn copy_template_dir(source: &Path, destination: &Path) -> Result<()> {
fs::create_dir_all(destination)?;
for entry in fs::read_dir(source)? {
let entry = entry?;
let path = entry.path();
let name = entry.file_name();
let name_str = name.to_string_lossy();
if ["target", "__pycache__", ".pytest_cache"].contains(&name_str.as_ref()) {
continue;
}
let target = destination.join(name);
if path.is_dir() {
copy_template_dir(&path, &target)?;
} else if path.is_file() {
fs::copy(&path, &target)?;
}
}
Ok(())
}
fn replace_template_tokens(root: &Path, spec: &CreateServiceSpec<'_>) -> Result<()> {
let service_title = title_from_name(spec.service_name);
let service_title_lc = service_title.to_lowercase();
let crate_name = crate_from_name(spec.service_name);
let replacements = [
("__SERVICE_NAME__", spec.service_name.to_string()),
("__SERVICE_TITLE__", service_title),
("__SERVICE_TITLE_LC__", service_title_lc),
("__DOMAIN__", spec.domain.to_string()),
("__OWNER_TEAM__", spec.owner_team.to_string()),
("__FRAMEWORK__", spec.framework.to_string()),
("__TEMPLATE_NAME__", spec.template_name.to_string()),
("__PROTO_PACKAGE__", spec.proto_package.to_string()),
("__SERVICE_CLASS__", spec.service_class.to_string()),
("template_service", crate_name),
("template-service", spec.service_name.to_string()),
];
replace_template_tokens_walk(root, &replacements)
}
fn replace_template_tokens_walk(root: &Path, replacements: &[(&str, String)]) -> Result<()> {
for entry in fs::read_dir(root)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
replace_template_tokens_walk(&path, replacements)?;
continue;
}
if !path.is_file() || !is_text_file(&path)? {
continue;
}
let mut text = fs::read_to_string(&path)?;
for (needle, replacement) in replacements {
text = text.replace(needle, replacement);
}
fs::write(&path, text)?;
}
Ok(())
}
fn is_text_file(path: &Path) -> Result<bool> {
let mut file = fs::File::open(path)?;
let mut buf = Vec::new();
file.read_to_end(&mut buf)?;
Ok(!buf.contains(&0) && std::str::from_utf8(&buf).is_ok())
}
fn title_from_name(value: &str) -> String {
value
.split(['-', '_'])
.filter(|part| !part.is_empty())
.map(capitalize)
.collect::<Vec<_>>()
.join(" ")
}
fn class_from_name(value: &str) -> String {
value
.split(['-', '_', '.'])
.filter(|part| !part.is_empty())
.map(capitalize)
.collect::<String>()
}
fn package_from_name(domain: &str, service_name: &str) -> String {
format!(
"executesoft.{}.{}.v1",
domain.replace(['-', '_'], "."),
service_name.replace(['-', '_'], ".")
)
}
fn crate_from_name(service_name: &str) -> String {
service_name.replace(['-', '.'], "_")
}
fn capitalize(value: &str) -> String {
let mut chars = value.chars();
match chars.next() {
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
None => String::new(),
}
}
fn check_all_services() -> Result<()> {
let root = repo_root();
let mut failed = false;
let mut service_yamls = Vec::new();
collect_template_service_yamls(&root.join("tools/templates"), &mut service_yamls)?;
collect_service_root_yamls(&root.join("services"), &mut service_yamls)?;
service_yamls.sort();
service_yamls.dedup();
for service_yaml in service_yamls {
if let Some(dir) = service_yaml.parent()
&& let Err(err) = check_service(dir)
{
failed = true;
eprintln!("{err}");
}
}
if failed {
Err("service compliance failed".into())
} else {
Ok(())
}
}
fn collect_template_service_yamls(templates_dir: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
if !templates_dir.exists() {
return Ok(());
}
for entry in fs::read_dir(templates_dir)? {
let path = entry?.path();
if path.is_dir() {
let candidate = path.join("skeleton/service.yaml");
if candidate.exists() {
out.push(candidate);
}
}
}
Ok(())
}
fn collect_service_root_yamls(services_dir: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
if !services_dir.exists() {
return Ok(());
}
for domain in fs::read_dir(services_dir)? {
let domain = domain?.path();
if !domain.is_dir() {
continue;
}
let direct = domain.join("service.yaml");
if direct.exists() {
out.push(direct);
}
for service in fs::read_dir(&domain)? {
let service = service?.path();
if !service.is_dir() {
continue;
}
let candidate = service.join("service.yaml");
if candidate.exists() {
out.push(candidate);
}
}
}
Ok(())
}
pub(crate) fn check_service(service_dir: &Path) -> Result<()> {
let service_dir = service_dir
.canonicalize()
.unwrap_or_else(|_| service_dir.to_path_buf());
let failures = validate_service(&service_dir);
if failures.is_empty() {
println!("OK {}", service_dir.display());
Ok(())
} else {
for failure in &failures {
eprintln!("FAIL {}: {failure}", service_dir.display());
}
Err("service validation failed".into())
}
}
fn validate_service(service_dir: &Path) -> Vec<String> {
let mut failures = Vec::new();
if !service_dir.is_dir() {
return vec!["service directory does not exist".into()];
}
let metadata_path = service_dir.join("service.yaml");
if !metadata_path.exists() {
return vec!["service.yaml missing".into()];
}
let meta = parse_simple_yaml(&metadata_path);
for key in [
"name",
"domain",
"owner_team",
"runtime",
"framework",
"template",
"certified_template",
"api_type",
] {
if meta
.get(key)
.map(String::as_str)
.unwrap_or("")
.trim()
.is_empty()
{
failures.push(format!("service.yaml missing metadata key: {key}"));
}
}
if !failures.is_empty() {
return failures;
}
let name = meta.get("name").map(String::as_str).unwrap_or("");
let domain = meta.get("domain").map(String::as_str).unwrap_or("");
let runtime = meta.get("runtime").map(String::as_str).unwrap_or("");
let api_type = meta.get("api_type").map(String::as_str).unwrap_or("");
let is_gateway = domain == "core" && name == "gateway";
let is_cdn = domain == "core" && name == "cdn";
let allowed: HashSet<&str> = [
"api",
"cmd",
"configs",
"deploy",
"development",
"docs",
"gateway",
"migrations",
"observability",
"scripts",
"internal",
"seeds",
"src",
"tasks",
"tests",
"tools",
".agents",
".dockerignore",
"AGENTS.md",
"README.md",
"go.mod",
"go.sum",
"package.json",
"package-lock.json",
"tsconfig.json",
"pyproject.toml",
"Cargo.toml",
"Cargo.lock",
"build.rs",
"Dockerfile",
"Makefile",
"service.yaml",
"taskfile.yaml",
]
.into_iter()
.collect();
let ignored: HashSet<&str> = [
".git",
".pytest_cache",
"__pycache__",
"node_modules",
"target",
".DS_Store",
]
.into_iter()
.collect();
if let Ok(entries) = fs::read_dir(service_dir) {
for entry in entries.flatten() {
let name = entry.file_name();
let name = name.to_string_lossy();
if !ignored.contains(name.as_ref()) && !allowed.contains(name.as_ref()) {
failures.push(format!("top-level path is not allowed: {name}"));
}
}
}
if is_cdn {
require_files(
service_dir,
&mut failures,
&["Cargo.toml", "src/main.rs", "configs/cdn.toml"],
);
require_text(service_dir, &mut failures, "Dockerfile", &["EXPOSE 8090"]);
require_text(
service_dir,
&mut failures,
"README.md",
&["CDN Service", "DigitalOcean Spaces"],
);
return failures;
}
require_dirs(
service_dir,
&mut failures,
&[
"api",
"configs",
"deploy",
"development",
"docs",
"gateway",
"gateway/routes",
"migrations",
"observability",
"tasks",
"tests",
"docs/Overview",
"docs/Architecture",
"docs/Setup",
"docs/API",
"docs/Tasks",
"docs/Bugs",
"docs/Decisions",
"docs/Changelog",
"tests/contract",
],
);
require_files(
service_dir,
&mut failures,
&[
"AGENTS.md",
"Dockerfile",
"service.yaml",
".agents/skills/service-structure/SKILL.md",
"docs/Overview/README.md",
"docs/API/api.md",
"docs/API/api-development.md",
"docs/API/api-errors.md",
"docs/Setup/developer-onboarding.md",
"docs/Setup/runbook.md",
"tasks/README.md",
"configs/app.env.example",
"development/README.md",
"development/compose.dev.yml",
"gateway/README.md",
"deploy/deployment.yaml",
"deploy/service.yaml",
"deploy/hpa.yaml",
"deploy/pdb.yaml",
"deploy/network-policy.yaml",
"deploy/service-account.yaml",
"deploy/configmap.yaml",
"deploy/external-secret.yaml",
"observability/alerts.yaml",
"observability/dashboard.md",
],
);
if !has_matching_file(&service_dir.join("gateway/routes"), "-routes.json") {
failures.push("gateway route request missing: gateway/routes/*-routes.json".into());
}
if !service_dir.join("Makefile").exists() && !service_dir.join("taskfile.yaml").exists() {
failures
.push("one required file missing from alternatives: Makefile, taskfile.yaml".into());
}
if !service_dir.join("src").exists() && !service_dir.join("cmd").exists() {
failures.push("one application directory is required: src, cmd".into());
}
if is_gateway {
match runtime {
"go" => {
require_dirs(
service_dir,
&mut failures,
&[
"internal/bootstrap",
"internal/infrastructure/config",
"internal/infrastructure/logger",
"internal/interfaces/http",
],
);
require_files(
service_dir,
&mut failures,
&[
"internal/bootstrap/container.go",
"internal/bootstrap/infrastructure.go",
"internal/bootstrap/handlers.go",
"internal/bootstrap/server.go",
],
);
}
"rust" => {
require_dirs(
service_dir,
&mut failures,
&[
"src/bootstrap",
"src/infrastructure/config",
"src/infrastructure/logger",
"src/interfaces/http",
],
);
require_files(
service_dir,
&mut failures,
&[
"Cargo.toml",
"src/bootstrap/container.rs",
"src/bootstrap/infrastructure.rs",
"src/bootstrap/handlers.rs",
"src/bootstrap/server.rs",
"src/bootstrap/mod.rs",
"src/main.rs",
],
);
}
_ => {}
}
require_files(
service_dir,
&mut failures,
&[
"api/routes.yaml",
"api/public-routes.json",
"api/openapi.yaml",
"docs/Architecture/public-api.md",
],
);
require_text(
service_dir,
&mut failures,
"api/openapi.yaml",
&[
"openapi: 3.1.0",
"bearerAuth:",
"x-target-service:",
"x-target-rpc:",
],
);
} else {
match runtime {
"go" => {
require_dirs(
service_dir,
&mut failures,
&[
"internal/domain",
"internal/application",
"internal/infrastructure",
"internal/infrastructure/cache",
"internal/infrastructure/persistence",
"internal/infrastructure/persistence/database",
"internal/interfaces",
"internal/bootstrap",
],
);
require_files(
service_dir,
&mut failures,
&[
"go.mod",
"internal/bootstrap/container.go",
"internal/bootstrap/infrastructure.go",
"internal/bootstrap/repositories.go",
"internal/bootstrap/use_cases.go",
"internal/bootstrap/handlers.go",
"internal/bootstrap/server.go",
"cmd/server/main.go",
],
);
}
"typescript" => {
require_dirs(
service_dir,
&mut failures,
&[
"src/domain",
"src/application",
"src/infrastructure",
"src/infrastructure/cache",
"src/infrastructure/persistence",
"src/infrastructure/persistence/database",
"src/interfaces",
"src/bootstrap",
],
);
require_files(
service_dir,
&mut failures,
&[
"package.json",
"src/bootstrap/container.ts",
"src/bootstrap/infrastructure.ts",
"src/bootstrap/repositories.ts",
"src/bootstrap/use-cases.ts",
"src/bootstrap/handlers.ts",
"src/bootstrap/server.ts",
"src/server.ts",
],
);
}
"python" => {
require_dirs(
service_dir,
&mut failures,
&[
"src/domain",
"src/application",
"src/infrastructure",
"src/infrastructure/cache",
"src/infrastructure/persistence",
"src/infrastructure/persistence/database",
"src/interfaces",
"src/bootstrap",
],
);
require_files(
service_dir,
&mut failures,
&[
"pyproject.toml",
"src/bootstrap/container.py",
"src/bootstrap/infrastructure.py",
"src/bootstrap/repositories.py",
"src/bootstrap/use_cases.py",
"src/bootstrap/handlers.py",
"src/bootstrap/server.py",
"src/server.py",
],
);
}
"rust" => {
require_dirs(
service_dir,
&mut failures,
&[
"src/domain",
"src/application",
"src/infrastructure",
"src/infrastructure/cache",
"src/infrastructure/persistence",
"src/infrastructure/persistence/database",
"src/interfaces",
"src/bootstrap",
],
);
require_files(
service_dir,
&mut failures,
&[
"Cargo.toml",
"src/domain/model.rs",
"src/bootstrap/container.rs",
"src/bootstrap/infrastructure.rs",
"src/bootstrap/repositories.rs",
"src/bootstrap/use_cases.rs",
"src/bootstrap/handlers.rs",
"src/bootstrap/server.rs",
"src/bootstrap/mod.rs",
"src/main.rs",
],
);
}
_ => {}
}
}
for (key, value) in parse_exposes(&metadata_path) {
if value.is_empty() {
failures.push(format!("service.yaml exposes.{key} must be a path string"));
} else if !service_dir.join(&value).exists() {
failures.push(format!("service.yaml exposes path missing: {key}: {value}"));
}
}
match api_type {
"grpc" => {
if !has_matching_file(&service_dir.join("api"), ".proto") {
failures.push("required grpc contract glob missing: one of api/*.proto".into());
}
if !parse_exposes(&metadata_path).contains_key("protobuf") {
failures.push("service.yaml exposes must include key: protobuf".into());
}
let shared = repo_root().join(format!("contracts/protobuf/{domain}-{name}.proto"));
if !metadata_has_placeholder(&metadata_path) && !shared.exists() {
failures.push(format!(
"shared contract missing: contracts/protobuf/{domain}-{name}.proto"
));
}
require_text(service_dir, &mut failures, "Dockerfile", &["EXPOSE"]);
require_text(
service_dir,
&mut failures,
"deploy/deployment.yaml",
&["containerPort:", "name: grpc"],
);
require_text(
service_dir,
&mut failures,
"deploy/service.yaml",
&["port:", "name: grpc"],
);
require_text(
service_dir,
&mut failures,
"docs/API/api.md",
&[
"Local contract:",
"Shared contract:",
"gRPC services:",
"RPC methods:",
],
);
}
"rest" => {
if !has_matching_file(&service_dir.join("api"), ".yaml")
&& !has_matching_file(&service_dir.join("api"), ".yml")
{
failures.push(
"required rest contract glob missing: one of api/*.yaml, api/*.yml".into(),
);
}
if !parse_exposes(&metadata_path).contains_key("openapi") {
failures.push("service.yaml exposes must include key: openapi".into());
}
}
_ => {}
}
if metadata_has_placeholder(&metadata_path) {
require_text(
service_dir,
&mut failures,
"configs/app.env.example",
&[
"LOG_LEVEL=",
"LOG_FORMAT=json",
"SERVICE_DOMAIN=",
"SERVICE_ENVIRONMENT=",
"DB_DRIVER=",
"DATABASE_URL=",
"CACHE_DRIVER=",
"CACHE_URL=",
"CACHE_PREFIX=",
"OTEL_SERVICE_NAME=",
"OTEL_EXPORTER_OTLP_ENDPOINT=",
"OTEL_TRACES_EXPORTER=otlp",
"OTEL_METRICS_EXPORTER=otlp",
"OTEL_LOGS_EXPORTER=otlp",
],
);
}
require_text(
service_dir,
&mut failures,
"deploy/configmap.yaml",
&[
"DB_DRIVER:",
"CACHE_DRIVER:",
"CACHE_PREFIX:",
"SERVICE_DOMAIN:",
"SERVICE_ENVIRONMENT:",
"LOG_FORMAT:",
],
);
require_text(
service_dir,
&mut failures,
"deploy/deployment.yaml",
&[
"envFrom:",
"configMapRef:",
"secretRef:",
"-runtime-secrets",
],
);
require_text(
service_dir,
&mut failures,
"docs/API/api-errors.md",
&[
"contracts/protobuf/common/error.proto",
"ErrorResponse",
"INVALID_ARGUMENT",
"UNAUTHENTICATED",
"PERMISSION_DENIED",
"NOT_FOUND",
"INTERNAL",
],
);
require_text(
service_dir,
&mut failures,
"docs/API/api.md",
&[
"Owner team:",
"Protocol:",
"Development endpoint:",
"Auth",
"Error Model",
"Consumer Workflow",
"Do not use another team's source code",
],
);
require_text(
service_dir,
&mut failures,
"docs/API/api-development.md",
&[
"New API Development Process",
"gateway-routes.json",
"make generate",
"Do not manually edit",
"Golden Rules",
],
);
if !is_gateway && meta.get("certified_template").map(String::as_str) != Some("true") {
failures.push("service.yaml certified_template must be true".into());
}
failures
}
fn require_files(root: &Path, failures: &mut Vec<String>, files: &[&str]) {
for file in files {
if !root.join(file).is_file() {
failures.push(format!("required file missing: {file}"));
}
}
}
fn require_dirs(root: &Path, failures: &mut Vec<String>, dirs: &[&str]) {
for dir in dirs {
if !root.join(dir).is_dir() {
failures.push(format!("required directory missing: {dir}"));
}
}
}
fn require_text(root: &Path, failures: &mut Vec<String>, file: &str, snippets: &[&str]) {
let path = root.join(file);
let Ok(text) = fs::read_to_string(&path) else {
failures.push(format!("required text file missing: {file}"));
return;
};
for snippet in snippets {
if !text.contains(snippet) {
failures.push(format!("{file} missing required text: {snippet}"));
}
}
}
fn has_matching_file(dir: &Path, suffix: &str) -> bool {
let Ok(entries) = fs::read_dir(dir) else {
return false;
};
entries.flatten().any(|entry| {
entry.path().is_file() && entry.file_name().to_string_lossy().ends_with(suffix)
})
}
pub(crate) fn parse_simple_yaml(path: &Path) -> HashMap<String, String> {
let text = fs::read_to_string(path).unwrap_or_default();
let mut values = HashMap::new();
for line in text.lines() {
let trimmed = line.trim();
if trimmed.is_empty()
|| trimmed.starts_with('#')
|| line.starts_with(' ')
|| line.starts_with('\t')
{
continue;
}
if let Some((key, value)) = line.split_once(':') {
values.insert(
key.trim().to_string(),
value.trim().trim_matches(['"', '\'']).to_string(),
);
}
}
values
}
pub(crate) fn parse_exposes(path: &Path) -> HashMap<String, String> {
let text = fs::read_to_string(path).unwrap_or_default();
let mut out = HashMap::new();
let mut in_exposes = false;
for line in text.lines() {
let trimmed = line.trim();
if trimmed == "exposes:" {
in_exposes = true;
continue;
}
if in_exposes && !trimmed.is_empty() && !line.starts_with(' ') && !line.starts_with('\t') {
break;
}
if in_exposes
&& trimmed.starts_with("- ")
&& let Some((key, value)) = trimmed.trim_start_matches("- ").split_once(':')
{
out.insert(
key.trim().to_string(),
value.trim().trim_matches(['"', '\'']).to_string(),
);
}
}
out
}
fn metadata_has_placeholder(path: &Path) -> bool {
parse_simple_yaml(path)
.values()
.any(|value| value.starts_with("__"))
}
pub(crate) fn resolve_service_dir(value: &str) -> Result<PathBuf> {
if value.contains('/') || value.starts_with('.') {
let path = repo_path(value);
if !path.join("Makefile").exists() {
return usage_error(format!("service Makefile not found: {value}"));
}
return Ok(path);
}
let mut matches = BTreeSet::new();
let mut service_yamls = Vec::new();
collect_service_root_yamls(&repo_root().join("services"), &mut service_yamls)?;
for service_yaml in service_yamls {
if let Some(dir) = service_yaml.parent()
&& dir.file_name().and_then(OsStr::to_str) == Some(value)
{
matches.insert(dir.to_path_buf());
}
}
match matches.len() {
0 => usage_error(format!("service not found: {value}")),
1 => Ok(matches.into_iter().next().unwrap()),
_ => usage_error(format!("service name is ambiguous: {value}")),
}
}