use std::path::Path;
use anyhow::{anyhow, Result};
use team_core::compose::Compose;
pub fn resolve(compose: &Compose, name: &str) -> Result<String> {
let mut by_id: Option<&str> = None;
let mut by_file: Option<&str> = None;
for (idx, p) in compose.projects.iter().enumerate() {
if p.project.id == name {
by_id = Some(p.project.id.as_str());
}
if let Some(r) = compose.global.projects.get(idx) {
if Path::new(&r.file)
.file_stem()
.and_then(|s| s.to_str())
.is_some_and(|stem| stem == name)
{
by_file = Some(p.project.id.as_str());
}
}
}
if let Some(id) = by_id.or(by_file) {
return Ok(id.to_string());
}
let available = compose
.projects
.iter()
.map(|p| p.project.id.as_str())
.collect::<Vec<_>>()
.join(", ");
Err(anyhow!("unknown project `{name}` — known: {available}"))
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
use team_core::compose::{
Broker, Budget, Compose, Global, Hitl, Project, ProjectMeta, ProjectRef, RateLimits,
SupervisorCfg,
};
fn project(id: &str, name: &str) -> Project {
Project {
version: 1,
project: ProjectMeta {
id: id.into(),
name: name.into(),
cwd: PathBuf::from("."),
},
channels: vec![],
managers: Default::default(),
workers: Default::default(),
interfaces: None,
}
}
fn compose_with(refs: Vec<(&str, &str, &str)>) -> Compose {
Compose {
root: PathBuf::from("/tmp/fake"),
global: Global {
version: team_core::compose::SchemaVersion::new("2.0.0"),
broker: Broker::default(),
supervisor: SupervisorCfg::default(),
budget: Budget::default(),
hitl: Hitl::default(),
rate_limits: RateLimits::default(),
interfaces: vec![],
projects: refs
.iter()
.map(|(f, _, _)| ProjectRef {
file: PathBuf::from(*f),
})
.collect(),
attachments: Default::default(),
},
projects: refs.iter().map(|(_, id, n)| project(id, n)).collect(),
}
}
#[test]
fn resolve_matches_project_id_exactly() {
let compose = compose_with(vec![("projects/teamctl.yaml", "teamctl", "Teamctl")]);
assert_eq!(resolve(&compose, "teamctl").unwrap(), "teamctl");
}
#[test]
fn resolve_matches_filename_stem_when_id_does_not_match() {
let compose = compose_with(vec![("projects/dev.yaml", "dev-team", "Dev")]);
assert_eq!(resolve(&compose, "dev").unwrap(), "dev-team");
}
#[test]
fn resolve_prefers_project_id_when_id_and_file_resolve_to_different_projects() {
let compose = compose_with(vec![
("projects/foo.yaml", "real-foo", "Foo"),
("projects/bar.yaml", "foo", "Bar"),
]);
assert_eq!(resolve(&compose, "foo").unwrap(), "foo");
}
#[test]
fn resolve_errors_with_available_list_on_miss() {
let compose = compose_with(vec![
("projects/teamctl.yaml", "teamctl", "Teamctl"),
("projects/ops.yaml", "ops", "Ops"),
]);
let err = resolve(&compose, "nope").unwrap_err().to_string();
assert!(
err.contains("nope"),
"error names the rejected input: {err}"
);
assert!(err.contains("teamctl"), "error lists known projects: {err}");
assert!(err.contains("ops"), "error lists known projects: {err}");
}
#[test]
fn resolve_filename_match_returns_canonical_id_not_stem() {
let compose = compose_with(vec![("projects/dev.yaml", "dev-team", "Dev")]);
assert_eq!(resolve(&compose, "dev").unwrap(), "dev-team");
}
#[test]
fn resolve_handles_empty_compose_with_clear_error() {
let compose = compose_with(vec![]);
let err = resolve(&compose, "nope").unwrap_err().to_string();
assert!(err.contains("nope"), "error names the input: {err}");
}
}