Skip to main content

automake_rs_cli/
bootstrap.rs

1// autoreconf-rs — the native bootstrap driver (BOOTSTRAP.1).
2//
3// Orchestrates the GNU-free Autotools pipeline, mirroring `autoreconf -fi`:
4//   1. aclocal-rs       configure.ac (+ m4 dirs) -> aclocal.m4
5//   2. autoconf-rs      configure.ac (+ aclocal.m4) -> configure
6//   3. autoheader-rs    configure.ac -> config.h.in   (when AC_CONFIG_HEADERS present)
7//   4. automake-rs      --add-missing                  (native aux files + receipt)
8//   5. automake-rs      every Makefile.am -> Makefile.in
9//
10// Doctrine (NATIVE.1): no GNU tool is invoked. The configure/aclocal/autoheader stages are
11// delegated to the autoconf-rs native tools, resolved by explicit env override or by name; if a
12// resolved tool turns out to be GNU (or is missing under --forbid-gnu), the step FAILS CLOSED with
13// typed evidence — never a silent GNU fallback. A bootstrap receipt records every provider and
14// asserts `gnu_tools_invoked: []`.
15
16use std::path::{Path, PathBuf};
17use std::process::Command;
18
19/// Resolution of one external native stage tool.
20struct Tool {
21    /// Logical stage name (for the receipt / errors).
22    stage: &'static str,
23    /// Resolved executable path, if found.
24    path: Option<PathBuf>,
25    /// Whether the resolved tool looks like GNU (a forbidden provider).
26    is_gnu: bool,
27}
28
29/// Resolve a native tool: prefer the explicit env override, then the autoconf-rs conventional
30/// names, then the bare name. A name resolving to a GNU build is flagged (and rejected later).
31fn resolve_tool(stage: &'static str, env_var: &str, candidates: &[&str]) -> Tool {
32    let mut tried: Vec<String> = Vec::new();
33    if let Ok(p) = std::env::var(env_var) {
34        tried.push(p);
35    }
36    for c in candidates {
37        tried.push(c.to_string());
38    }
39    for cand in &tried {
40        if let Some(path) = which(cand) {
41            let is_gnu = looks_like_gnu(&path);
42            return Tool { stage, path: Some(path), is_gnu };
43        }
44    }
45    Tool { stage, path: None, is_gnu: false }
46}
47
48/// Minimal PATH lookup (also accepts an absolute/relative path that exists).
49fn which(name: &str) -> Option<PathBuf> {
50    let p = Path::new(name);
51    if p.is_absolute() || name.contains('/') {
52        return if p.exists() { Some(p.to_path_buf()) } else { None };
53    }
54    let path = std::env::var("PATH").unwrap_or_default();
55    for dir in path.split(':') {
56        let cand = Path::new(dir).join(name);
57        if cand.exists() {
58            return Some(cand);
59        }
60    }
61    None
62}
63
64/// Heuristic: does this tool identify as GNU? (`--version` mentions GNU and not "rs").
65fn looks_like_gnu(path: &Path) -> bool {
66    let out = Command::new(path).arg("--version").output();
67    if let Ok(o) = out {
68        let v = String::from_utf8_lossy(&o.stdout).to_lowercase();
69        let is_rs = v.contains("-rs") || v.contains("rust") || v.contains("automake-rs");
70        return v.contains("gnu") && !is_rs;
71    }
72    false
73}
74
75/// Result of a bootstrap run (for the receipt + exit code).
76pub struct BootstrapReport {
77    pub ok: bool,
78    pub receipt_json: String,
79}
80
81/// Run the native bootstrap in `dir`. `forbid_gnu` makes a GNU-resolved or missing native tool a
82/// hard, typed failure (the default for the "no GNU dependency" claim).
83pub fn run_bootstrap(dir: &Path, forbid_gnu: bool, verbose: bool) -> BootstrapReport {
84    let cf = ["configure.ac", "configure.in"]
85        .iter()
86        .map(|n| dir.join(n))
87        .find(|p| p.exists());
88    let cf = match cf {
89        Some(p) => p,
90        None => {
91            return BootstrapReport {
92                ok: false,
93                receipt_json: err_receipt("no configure.ac / configure.in found"),
94            }
95        }
96    };
97    let ac_text = std::fs::read_to_string(&cf).unwrap_or_default();
98    let needs_header = ac_text.contains("AC_CONFIG_HEADERS")
99        || ac_text.contains("AC_CONFIG_HEADER")
100        || ac_text.contains("AM_CONFIG_HEADER");
101
102    // Resolve the native stage tools. automake-rs is self (this binary's own generator).
103    let aclocal = resolve_tool("aclocal", "ACLOCAL_RS", &["aclocal-rs", "acrs-aclocal", "aclocal"]);
104    let autoconf = resolve_tool("autoconf", "AUTOCONF_RS", &["autoconf-rs", "acrs-autoconf", "autoconf"]);
105    let autoheader = resolve_tool("autoheader", "AUTOHEADER_RS", &["autoheader-rs", "acrs-autoheader", "autoheader"]);
106
107    let mut steps: Vec<String> = Vec::new();
108    let mut ok = true;
109    let mut run = |tool: &Tool, args: &[&str], stdout_to: Option<&Path>| -> bool {
110        // Fail closed: missing native tool, or a GNU tool under --forbid-gnu.
111        let path = match &tool.path {
112            Some(p) => p,
113            None => {
114                steps.push(step_json(tool.stage, "missing", "no native provider found", None));
115                return false;
116            }
117        };
118        if tool.is_gnu && forbid_gnu {
119            steps.push(step_json(tool.stage, "rejected-gnu", &path.display().to_string(), None));
120            return false;
121        }
122        let mut cmd = Command::new(path);
123        cmd.current_dir(dir).args(args);
124        let output = cmd.output();
125        match output {
126            Ok(o) => {
127                if let Some(dest) = stdout_to {
128                    let _ = std::fs::write(dir.join(dest), &o.stdout);
129                }
130                let provider = if tool.is_gnu { "GNU (allowed: --forbid-gnu off)" } else { "native (autoconf-rs)" };
131                let st = if o.status.success() { "ok" } else { "nonzero-exit" };
132                steps.push(step_json(tool.stage, st, &path.display().to_string(), Some(provider)));
133                o.status.success()
134            }
135            Err(e) => {
136                steps.push(step_json(tool.stage, "spawn-failed", &e.to_string(), None));
137                false
138            }
139        }
140    };
141
142    if verbose { eprintln!("autoreconf-rs: aclocal -> aclocal.m4"); }
143    // aclocal-rs writes aclocal.m4 itself (its -o default). Do NOT also capture its stdout into
144    // aclocal.m4 — that clobbered the real file with empty stdout, leaving 0-line aclocal.m4 so
145    // autoconf had no macro definitions to prepend (AX_*/AM_* "command not found"). Let it write.
146    let _ = run(&aclocal, &[cf.file_name().unwrap().to_str().unwrap()], None);
147
148    if verbose { eprintln!("autoreconf-rs: autoconf -> configure"); }
149    let cfg_ok = run(&autoconf, &[cf.file_name().unwrap().to_str().unwrap()], Some(Path::new("configure")));
150    if cfg_ok {
151        #[cfg(unix)]
152        {
153            use std::os::unix::fs::PermissionsExt;
154            let _ = std::fs::set_permissions(dir.join("configure"), std::fs::Permissions::from_mode(0o755));
155        }
156    } else {
157        ok = false;
158    }
159
160    if needs_header {
161        // The header template name is NOT always config.h.in — AC_CONFIG_HEADERS([ethtool-config.h])
162        // means autoheader must write ethtool-config.h.in (else config.status creates an empty
163        // header and the program sees `PACKAGE undeclared`). Parse the configured header name.
164        let header = config_header_name(&ac_text);
165        let header_in = format!("{}.in", header);
166        if verbose { eprintln!("autoreconf-rs: autoheader -> {}", header_in); }
167        let _ = run(&autoheader, &[cf.file_name().unwrap().to_str().unwrap()], Some(Path::new(&header_in)));
168    }
169
170    // Steps 4 + 5 (aux + Makefile.in) are automake-rs itself; the caller runs them after this
171    // returns, so the receipt records them as native-self.
172    steps.push(step_json("aux-files", "delegated", "automake-rs --add-missing (native)", Some("native (automake-rs)")));
173    steps.push(step_json("makefile.in", "delegated", "automake-rs (native)", Some("native (automake-rs)")));
174
175    let gnu_used: Vec<&str> = [&aclocal, &autoconf, &autoheader]
176        .iter()
177        .filter(|t| t.is_gnu && !forbid_gnu && t.path.is_some())
178        .map(|t| t.stage)
179        .collect();
180
181    let receipt = format!(
182        "{{\n  \"native_bootstrap\": {},\n  \"forbid_gnu\": {},\n  \"gnu_tools_invoked\": [{}],\n  \"providers\": {{\n    \"aclocal\": {},\n    \"autoconf\": {},\n    \"autoheader\": {},\n    \"aux\": \"automake-rs\",\n    \"makefile_in\": \"automake-rs\"\n  }},\n  \"steps\": [\n{}\n  ]\n}}\n",
183        ok && gnu_used.is_empty(),
184        forbid_gnu,
185        gnu_used.iter().map(|s| format!("{:?}", s)).collect::<Vec<_>>().join(", "),
186        provider_str(&aclocal, forbid_gnu),
187        provider_str(&autoconf, forbid_gnu),
188        provider_str(&autoheader, forbid_gnu),
189        steps.iter().map(|s| format!("    {}", s)).collect::<Vec<_>>().join(",\n"),
190    );
191    let _ = std::fs::write(dir.join("bootstrap-receipt.json"), &receipt);
192    BootstrapReport { ok, receipt_json: receipt }
193}
194
195fn provider_str(t: &Tool, forbid_gnu: bool) -> String {
196    match &t.path {
197        None => "\"unresolved\"".to_string(),
198        Some(p) => {
199            let kind = if t.is_gnu {
200                if forbid_gnu { "rejected-gnu" } else { "gnu" }
201            } else {
202                "native"
203            };
204            format!("{{ \"path\": {:?}, \"kind\": {:?} }}", p.display().to_string(), kind)
205        }
206    }
207}
208
209fn step_json(stage: &str, status: &str, detail: &str, provider: Option<&str>) -> String {
210    match provider {
211        Some(p) => format!(
212            "{{ \"stage\": {:?}, \"status\": {:?}, \"detail\": {:?}, \"provider\": {:?} }}",
213            stage, status, detail, p
214        ),
215        None => format!(
216            "{{ \"stage\": {:?}, \"status\": {:?}, \"detail\": {:?} }}",
217            stage, status, detail
218        ),
219    }
220}
221
222fn err_receipt(msg: &str) -> String {
223    format!("{{ \"native_bootstrap\": false, \"error\": {:?} }}\n", msg)
224}
225
226/// Extract the first config-header name from AC_CONFIG_HEADERS / AC_CONFIG_HEADER / AM_CONFIG_HEADER
227/// (e.g. `ethtool-config.h`), defaulting to `config.h`. Only the first whitespace-separated token of
228/// the first arg names the header to generate.
229fn config_header_name(ac_text: &str) -> String {
230    for macro_name in ["AC_CONFIG_HEADERS", "AC_CONFIG_HEADER", "AM_CONFIG_HEADER"] {
231        if let Some(pos) = ac_text.find(macro_name) {
232            let after = &ac_text[pos + macro_name.len()..];
233            if let Some(open) = after.find('(') {
234                if let Some(close) = after[open..].find(')') {
235                    let inner = &after[open + 1..open + close];
236                    let first = inner
237                        .trim()
238                        .trim_start_matches('[')
239                        .split([']', ',', ' ', '\t', '\n'])
240                        .next()
241                        .unwrap_or("")
242                        .trim();
243                    if !first.is_empty() {
244                        return first.to_string();
245                    }
246                }
247            }
248        }
249    }
250    "config.h".to_string()
251}