Skip to main content

xbp_deploy/
target_resolver.rs

1use crate::error::{DeployError, Result};
2use crate::types::{DeployTarget, ProjectConfig, ServiceConfigView};
3
4#[derive(Debug, Clone)]
5pub struct TargetResolution {
6    pub target: DeployTarget,
7    pub services: Vec<ServiceConfigView>,
8    pub group_order: Option<Vec<String>>,
9    pub note: Option<String>,
10}
11
12/// Resolve target with priority:
13/// 1. exact group match
14/// 2. exact service match
15/// 3. unique prefix match
16/// 4. error with suggestions
17pub fn resolve_target(
18    config: &ProjectConfig,
19    raw: &str,
20    env: &str,
21) -> Result<TargetResolution> {
22    let raw = raw.trim();
23    if raw.is_empty() {
24        return Err(unknown(raw, config, env, "deploy target is required"));
25    }
26
27    if raw.eq_ignore_ascii_case("all") {
28        let services: Vec<_> = config
29            .services
30            .iter()
31            .filter(|s| service_has_env(s, env))
32            .cloned()
33            .collect();
34        if services.is_empty() {
35            return Err(DeployError::Validation(format!(
36                "no services declare deploy.envs.{env}"
37            )));
38        }
39        return Ok(TargetResolution {
40            target: DeployTarget::All,
41            services,
42            group_order: None,
43            note: None,
44        });
45    }
46
47    // 1. exact group
48    if let Some(group) = config.groups.get(raw) {
49        let order = if group.order.is_empty() {
50            group.services.clone()
51        } else {
52            group.order.clone()
53        };
54        let mut services = Vec::new();
55        for name in &order {
56            let svc = config
57                .services
58                .iter()
59                .find(|s| s.name == *name)
60                .cloned()
61                .ok_or_else(|| {
62                    DeployError::Validation(format!(
63                        "deploy group `{raw}` references unknown service `{name}`"
64                    ))
65                })?;
66            if !service_has_env(&svc, env) {
67                return Err(DeployError::Validation(format!(
68                    "service `{name}` in group `{raw}` has no deploy.envs.{env}"
69                )));
70            }
71            services.push(svc);
72        }
73        let note = if config.services.iter().any(|s| s.name == raw) {
74            Some(format!(
75                "target `{raw}` matches both a deploy group and a service name; using group"
76            ))
77        } else {
78            None
79        };
80        return Ok(TargetResolution {
81            target: DeployTarget::Group(raw.to_string()),
82            services,
83            group_order: Some(order),
84            note,
85        });
86    }
87
88    // 2. exact service
89    if let Some(svc) = config.services.iter().find(|s| s.name == raw).cloned() {
90        if !service_has_env(&svc, env) {
91            return Err(DeployError::Validation(format!(
92                "service `{raw}` has no deploy.envs.{env}"
93            )));
94        }
95        return Ok(TargetResolution {
96            target: DeployTarget::Service(raw.to_string()),
97            services: vec![svc],
98            group_order: None,
99            note: None,
100        });
101    }
102
103    // 3. unique prefix
104    let group_prefixes: Vec<String> = config
105        .groups
106        .keys()
107        .filter(|k| k.starts_with(raw))
108        .cloned()
109        .collect();
110    let service_prefixes: Vec<String> = config
111        .services
112        .iter()
113        .map(|s| s.name.clone())
114        .filter(|n| n.starts_with(raw))
115        .collect();
116
117    if group_prefixes.len() == 1 && service_prefixes.is_empty() {
118        return resolve_target(config, &group_prefixes[0], env);
119    }
120    if service_prefixes.len() == 1 && group_prefixes.is_empty() {
121        return resolve_target(config, &service_prefixes[0], env);
122    }
123
124    Err(unknown(raw, config, env, &format!("unknown deploy target `{raw}`")))
125}
126
127fn service_has_env(svc: &ServiceConfigView, env: &str) -> bool {
128    svc.deploy
129        .as_ref()
130        .map(|d| d.envs.contains_key(env))
131        .unwrap_or(false)
132}
133
134fn unknown(target: &str, config: &ProjectConfig, env: &str, prefix: &str) -> DeployError {
135    let mut suggestions = Vec::new();
136    for g in config.groups.keys() {
137        suggestions.push(format!("- {g} (group)"));
138    }
139    for s in config.services.iter().filter(|s| service_has_env(s, env)) {
140        suggestions.push(format!("- {} (service)", s.name));
141    }
142    suggestions.push("- all".into());
143    let body = if suggestions.is_empty() {
144        String::new()
145    } else {
146        format!("\nDid you mean:\n{}", suggestions.join("\n"))
147    };
148    DeployError::UnknownTarget {
149        target: target.to_string(),
150        suggestions: format!(". {prefix}{body}"),
151    }
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157    use crate::types::*;
158    use std::collections::HashMap;
159    use std::path::PathBuf;
160
161    fn cfg() -> ProjectConfig {
162        let mut groups = HashMap::new();
163        groups.insert(
164            "athena".into(),
165            DeployGroupView {
166                description: None,
167                services: vec!["athena-runtime".into()],
168                order: vec!["athena-runtime".into()],
169            },
170        );
171        let mut envs = HashMap::new();
172        envs.insert(
173            "production".into(),
174            ServiceDeployEnvView {
175                namespace: Some("ns".into()),
176                replicas: None,
177                health: vec![],
178                kubernetes: None,
179            },
180        );
181        ProjectConfig {
182            project_name: "demo".into(),
183            version: "1.0.0".into(),
184            project_root: PathBuf::from("."),
185            services: vec![ServiceConfigView {
186                name: "athena-runtime".into(),
187                root_directory: None,
188                version: None,
189                depends_on: vec![],
190                oci: None,
191                deploy: Some(ServiceDeployView {
192                    provider: "kubernetes".into(),
193                    envs,
194                }),
195            }],
196            groups,
197            default_env: Some("production".into()),
198            kubernetes: None,
199            history_dir: PathBuf::from(".xbp/deployments"),
200            lock_file: PathBuf::from(".xbp/deploy-lock.json"),
201            git_sha: None,
202        }
203    }
204
205    #[test]
206    fn prefers_group() {
207        let r = resolve_target(&cfg(), "athena", "production").unwrap();
208        assert!(matches!(r.target, DeployTarget::Group(_)));
209    }
210
211    #[test]
212    fn prefix_unique() {
213        let r = resolve_target(&cfg(), "athena-r", "production").unwrap();
214        assert!(matches!(r.target, DeployTarget::Service(_)));
215    }
216}