Skip to main content

npmgen_core/target/
resolver.rs

1use std::fs;
2use std::path::Path;
3
4use super::{Target, TargetError};
5use crate::config::Config;
6
7/// Cargo configuration directory and the candidate file names within it.
8const CARGO_CONFIG_DIR: &str = ".cargo";
9const CARGO_CONFIG_FILES: &[&str] = &["config.toml", "config"];
10/// `[build] target` table/key cargo declares its default targets under.
11const BUILD_TABLE: &str = "build";
12const TARGET_KEY: &str = "target";
13
14/// Resolves the build target set for a project by precedence:
15///
16/// 1. `config.targets` if non-empty (explicit, highest precedence);
17/// 2. else cargo's `[build] target` discovered from the workspace root;
18/// 3. else the default platform set.
19///
20/// A `--target` key filter, when present, narrows whichever set wins.
21#[derive(Debug)]
22pub struct TargetResolver<'a> {
23    config: &'a Config,
24    workspace_root: &'a Path,
25}
26
27impl<'a> TargetResolver<'a> {
28    pub fn new(config: &'a Config, workspace_root: &'a Path) -> Self {
29        Self {
30            config,
31            workspace_root,
32        }
33    }
34
35    /// Resolve the targets, then retain only those whose key is in `filter`
36    /// (empty `filter` keeps all). An unmatched filter key is an error.
37    pub fn resolve(&self, filter: &[String]) -> Result<Vec<Target>, TargetError> {
38        let mut targets = self.base_targets()?;
39        if !filter.is_empty() {
40            for key in filter {
41                if !targets.iter().any(|target| &target.key == key) {
42                    return Err(TargetError::UnknownFilterKey { key: key.clone() });
43                }
44            }
45            targets.retain(|target| filter.iter().any(|key| key == &target.key));
46        }
47        Ok(targets)
48    }
49
50    fn base_targets(&self) -> Result<Vec<Target>, TargetError> {
51        if !self.config.targets.is_empty() {
52            return self.config.targets.iter().map(Target::from_spec).collect();
53        }
54        if let Some(triples) = self.cargo_targets()? {
55            return triples
56                .iter()
57                .map(|triple| Target::from_triple(triple))
58                .collect();
59        }
60        Ok(Target::defaults())
61    }
62
63    /// First `[build] target` walking `.cargo/config.toml` from the workspace
64    /// root upward, the way cargo merges configuration. Neither the home-level
65    /// (`$CARGO_HOME`) config nor the `CARGO_BUILD_TARGET` env var is consulted:
66    /// a global default target is an unusual choice for a publish tool, and
67    /// reading either would route a config knob outside the project tree (the
68    /// crate reads no env vars outside clap). Pass `--target` to override.
69    fn cargo_targets(&self) -> Result<Option<Vec<String>>, TargetError> {
70        for dir in self.workspace_root.ancestors() {
71            let cargo_dir = dir.join(CARGO_CONFIG_DIR);
72            let path = CARGO_CONFIG_FILES
73                .iter()
74                .map(|name| cargo_dir.join(name))
75                .find(|candidate| candidate.is_file());
76            let Some(path) = path else { continue };
77
78            let text = fs::read_to_string(&path).map_err(|source| TargetError::CargoConfig {
79                path: path.clone(),
80                source,
81            })?;
82            let value: toml::Value =
83                toml::from_str(&text).map_err(|source| TargetError::CargoConfigParse {
84                    path: path.clone(),
85                    source,
86                })?;
87
88            if let Some(target) = value
89                .get(BUILD_TABLE)
90                .and_then(|build| build.get(TARGET_KEY))
91            {
92                return Ok(Some(Self::triples(target)));
93            }
94        }
95        Ok(None)
96    }
97
98    /// `build.target` is a single triple string or an array of triples.
99    fn triples(value: &toml::Value) -> Vec<String> {
100        match value {
101            toml::Value::String(triple) => vec![triple.clone()],
102            toml::Value::Array(items) => items
103                .iter()
104                .filter_map(|item| item.as_str().map(str::to_owned))
105                .collect(),
106            _ => Vec::new(),
107        }
108    }
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114    use crate::config::TargetSpec;
115
116    fn spec(key: &str) -> TargetSpec {
117        TargetSpec {
118            key: key.to_owned(),
119            triple: None,
120            os: None,
121            cpu: None,
122        }
123    }
124
125    fn config_with(targets: Vec<TargetSpec>) -> Config {
126        Config {
127            targets,
128            ..Config::default()
129        }
130    }
131
132    fn scratch(tag: &str) -> std::path::PathBuf {
133        let dir = std::env::temp_dir().join(format!("npmgen-{}-{tag}", std::process::id()));
134        let _ = fs::remove_dir_all(&dir);
135        fs::create_dir_all(&dir).unwrap();
136        dir
137    }
138
139    #[test]
140    fn explicit_targets_take_precedence_and_skip_the_filesystem() {
141        let config = config_with(vec![spec("linux-x64")]);
142        let targets = TargetResolver::new(&config, Path::new("/does/not/exist"))
143            .resolve(&[])
144            .unwrap();
145        assert_eq!(targets.len(), 1);
146        assert_eq!(targets[0].triple, "x86_64-unknown-linux-gnu");
147    }
148
149    #[test]
150    fn filter_narrows_and_rejects_unknown_keys() {
151        let config = config_with(vec![spec("win32-x64"), spec("linux-x64")]);
152        let resolver = TargetResolver::new(&config, Path::new("/does/not/exist"));
153
154        let kept = resolver.resolve(&["linux-x64".to_owned()]).unwrap();
155        assert_eq!(kept.len(), 1);
156        assert_eq!(kept[0].key, "linux-x64");
157
158        assert!(resolver.resolve(&["bogus".to_owned()]).is_err());
159    }
160
161    #[test]
162    fn inherits_cargo_configured_target() {
163        let root = scratch("cargo-config");
164        fs::create_dir_all(root.join(".cargo")).unwrap();
165        fs::write(
166            root.join(".cargo").join("config.toml"),
167            "[build]\ntarget = \"aarch64-apple-darwin\"\n",
168        )
169        .unwrap();
170
171        let config = Config::default();
172        let targets = TargetResolver::new(&config, &root).resolve(&[]).unwrap();
173        assert_eq!(targets.len(), 1);
174        assert_eq!(targets[0].key, "darwin-arm64");
175
176        let _ = fs::remove_dir_all(&root);
177    }
178
179    #[test]
180    fn inherits_cargo_target_array() {
181        let root = scratch("cargo-array");
182        fs::create_dir_all(root.join(".cargo")).unwrap();
183        fs::write(
184            root.join(".cargo").join("config.toml"),
185            "[build]\ntarget = [\"x86_64-pc-windows-msvc\", \"x86_64-apple-darwin\"]\n",
186        )
187        .unwrap();
188
189        let config = Config::default();
190        let targets = TargetResolver::new(&config, &root).resolve(&[]).unwrap();
191        let keys: Vec<&str> = targets.iter().map(|target| target.key.as_str()).collect();
192        assert_eq!(keys, ["win32-x64", "darwin-x64"]);
193
194        let _ = fs::remove_dir_all(&root);
195    }
196
197    #[test]
198    fn explicit_config_beats_a_present_cargo_config() {
199        let root = scratch("config-beats-cargo");
200        fs::create_dir_all(root.join(".cargo")).unwrap();
201        fs::write(
202            root.join(".cargo").join("config.toml"),
203            "[build]\ntarget = \"aarch64-apple-darwin\"\n",
204        )
205        .unwrap();
206
207        let config = config_with(vec![spec("win32-x64")]);
208        let targets = TargetResolver::new(&config, &root).resolve(&[]).unwrap();
209        assert_eq!(targets.len(), 1);
210        assert_eq!(targets[0].key, "win32-x64");
211
212        let _ = fs::remove_dir_all(&root);
213    }
214
215    #[test]
216    fn nearest_cargo_config_wins_over_an_ancestor() {
217        let root = scratch("nested-cargo");
218        let inner = root.join("sub");
219        fs::create_dir_all(root.join(".cargo")).unwrap();
220        fs::create_dir_all(inner.join(".cargo")).unwrap();
221        fs::write(
222            root.join(".cargo").join("config.toml"),
223            "[build]\ntarget = \"x86_64-unknown-linux-gnu\"\n",
224        )
225        .unwrap();
226        fs::write(
227            inner.join(".cargo").join("config.toml"),
228            "[build]\ntarget = \"aarch64-apple-darwin\"\n",
229        )
230        .unwrap();
231
232        let config = Config::default();
233        let targets = TargetResolver::new(&config, &inner).resolve(&[]).unwrap();
234        assert_eq!(targets.len(), 1);
235        assert_eq!(targets[0].key, "darwin-arm64");
236
237        let _ = fs::remove_dir_all(&root);
238    }
239}