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        let mut skipped_unknown = Vec::new();
56        let mut resolved_order = Vec::new();
57        for name in &order {
58            let Some(svc) = config.services.iter().find(|s| s.name == *name).cloned() else {
59                // Soft-skip stale members (scoped packages, discovery noise).
60                skipped_unknown.push(name.clone());
61                continue;
62            };
63            if !service_has_env(&svc, env) {
64                return Err(DeployError::Validation(format!(
65                    "service `{name}` in group `{raw}` has no deploy.envs.{env}"
66                )));
67            }
68            resolved_order.push(name.clone());
69            services.push(svc);
70        }
71        if services.is_empty() {
72            return Err(DeployError::Validation(format!(
73                "deploy group `{raw}` has no known services for env `{env}`{}",
74                if skipped_unknown.is_empty() {
75                    String::new()
76                } else {
77                    format!(" (unknown: {})", skipped_unknown.join(", "))
78                }
79            )));
80        }
81        let mut notes = Vec::new();
82        if config.services.iter().any(|s| s.name == raw) {
83            notes.push(format!(
84                "target `{raw}` matches both a deploy group and a service name; using group"
85            ));
86        }
87        if !skipped_unknown.is_empty() {
88            notes.push(format!(
89                "deploy group `{raw}` skipped {} unknown service reference(s)",
90                skipped_unknown.len()
91            ));
92        }
93        let note = if notes.is_empty() {
94            None
95        } else {
96            Some(notes.join("; "))
97        };
98        return Ok(TargetResolution {
99            target: DeployTarget::Group(raw.to_string()),
100            services,
101            group_order: Some(resolved_order),
102            note,
103        });
104    }
105
106    // 2. exact service
107    if let Some(svc) = config.services.iter().find(|s| s.name == raw).cloned() {
108        if !service_has_env(&svc, env) {
109            return Err(DeployError::Validation(format!(
110                "service `{raw}` has no deploy.envs.{env}"
111            )));
112        }
113        return Ok(TargetResolution {
114            target: DeployTarget::Service(raw.to_string()),
115            services: vec![svc],
116            group_order: None,
117            note: None,
118        });
119    }
120
121    // 3. unique prefix
122    let group_prefixes: Vec<String> = config
123        .groups
124        .keys()
125        .filter(|k| k.starts_with(raw))
126        .cloned()
127        .collect();
128    let service_prefixes: Vec<String> = config
129        .services
130        .iter()
131        .map(|s| s.name.clone())
132        .filter(|n| n.starts_with(raw))
133        .collect();
134
135    if group_prefixes.len() == 1 && service_prefixes.is_empty() {
136        return resolve_target(config, &group_prefixes[0], env);
137    }
138    if service_prefixes.len() == 1 && group_prefixes.is_empty() {
139        return resolve_target(config, &service_prefixes[0], env);
140    }
141
142    Err(unknown(raw, config, env, &format!("unknown deploy target `{raw}`")))
143}
144
145fn service_has_env(svc: &ServiceConfigView, env: &str) -> bool {
146    svc.deploy
147        .as_ref()
148        .map(|d| d.envs.contains_key(env))
149        .unwrap_or(false)
150}
151
152fn unknown(target: &str, config: &ProjectConfig, env: &str, prefix: &str) -> DeployError {
153    // Reserved mode words — never valid service names; steer users to flags.
154    let reserved = matches!(
155        target.trim().to_ascii_lowercase().as_str(),
156        "plan" | "run" | "verify" | "status" | "history"
157    );
158    if reserved {
159        return DeployError::UnknownTarget {
160            target: target.to_string(),
161            suggestions: format!(
162                ". `{target}` is a deploy *mode*, not a service.\n\n\
163                 Plan only (no mutations):\n  xbp deploy --plan\n  xbp deploy <service> --plan\n\n\
164                 Apply after reviewing the plan:\n  xbp deploy --run\n  xbp deploy <service> --run\n\n\
165                 Or omit the mode word and pick a service interactively:\n  xbp deploy"
166            ),
167        };
168    }
169
170    let mut suggestions = Vec::new();
171    for g in config.groups.keys() {
172        suggestions.push(format!("- {g} (group)"));
173    }
174    // Cap service suggestions so huge monorepos stay readable.
175    let mut service_count = 0usize;
176    for s in config.services.iter().filter(|s| service_has_env(s, env)) {
177        if service_count < 20 {
178            suggestions.push(format!("- {} (service)", s.name));
179        }
180        service_count += 1;
181    }
182    if service_count > 20 {
183        suggestions.push(format!(
184            "- … and {} more service(s) with deploy.envs.{env}",
185            service_count - 20
186        ));
187    }
188    suggestions.push("- all".into());
189    let body = if suggestions.is_empty() {
190        String::new()
191    } else {
192        format!("\nDid you mean:\n{}", suggestions.join("\n"))
193    };
194    DeployError::UnknownTarget {
195        target: target.to_string(),
196        suggestions: format!(". {prefix}{body}"),
197    }
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203    use crate::types::*;
204    use std::collections::HashMap;
205    use std::path::PathBuf;
206
207    fn cfg() -> ProjectConfig {
208        let mut groups = HashMap::new();
209        groups.insert(
210            "athena".into(),
211            DeployGroupView {
212                description: None,
213                services: vec!["athena-runtime".into()],
214                order: vec!["athena-runtime".into()],
215            },
216        );
217        let mut envs = HashMap::new();
218        envs.insert(
219            "production".into(),
220            ServiceDeployEnvView {
221                env: Default::default(),
222                env_files: vec![],
223                config_files: vec![],
224                container_port: None,
225                required_env: vec![],
226                expose: None,
227                namespace: Some("ns".into()),
228                replicas: None,
229                health: vec![],
230                kubernetes: None,
231            },
232        );
233        ProjectConfig {
234            project_name: "demo".into(),
235            version: "1.0.0".into(),
236            project_root: PathBuf::from("."),
237            services: vec![ServiceConfigView {
238                environment: Default::default(),
239                port: None,
240                name: "athena-runtime".into(),
241                root_directory: None,
242                version: None,
243                depends_on: vec![],
244                oci: None,
245                deploy: Some(ServiceDeployView {
246                    provider: "kubernetes".into(),
247                    worker: None,
248                    rollout: None,
249                    destinations: Default::default(),
250                    envs,
251                }),
252            }],
253            groups,
254            default_env: Some("production".into()),
255            kubernetes: None,
256            history_dir: PathBuf::from(".xbp/deployments"),
257            lock_file: PathBuf::from(".xbp/deploy-lock.json"),
258            git_sha: None,
259        }
260    }
261
262    #[test]
263    fn prefers_group() {
264        let r = resolve_target(&cfg(), "athena", "production").unwrap();
265        assert!(matches!(r.target, DeployTarget::Group(_)));
266    }
267
268    #[test]
269    fn prefix_unique() {
270        let r = resolve_target(&cfg(), "athena-r", "production").unwrap();
271        assert!(matches!(r.target, DeployTarget::Service(_)));
272    }
273
274    #[test]
275    fn group_soft_skips_unknown_members() {
276        let mut config = cfg();
277        // Use a non-reserved group name (`all` is handled as DeployTarget::All).
278        config.groups.insert(
279            "edge".into(),
280            DeployGroupView {
281                description: None,
282                services: vec![
283                    "athena-runtime".into(),
284                    "@lexington/studio".into(),
285                    "default".into(),
286                ],
287                order: vec![
288                    "athena-runtime".into(),
289                    "@lexington/studio".into(),
290                    "default".into(),
291                ],
292            },
293        );
294        let r = resolve_target(&config, "edge", "production").unwrap();
295        assert_eq!(r.services.len(), 1);
296        assert_eq!(r.services[0].name, "athena-runtime");
297        assert!(r
298            .note
299            .as_deref()
300            .unwrap_or("")
301            .contains("skipped 2 unknown"));
302    }
303}