cargo_zng/res/
tool.rs

1use std::{
2    fs,
3    io::{self, BufRead, Read, Write},
4    ops::ControlFlow,
5    path::{Path, PathBuf},
6};
7
8use anyhow::{Context, bail};
9use is_executable::IsExecutable as _;
10use parking_lot::Mutex;
11use zng_env::About;
12
13use crate::{res::built_in::ZR_APP_ID, res_tool_util::*};
14
15/// Visit in the `ToolKind` order.
16pub fn visit_tools(local: &Path, mut tool: impl FnMut(Tool) -> anyhow::Result<ControlFlow<()>>) -> anyhow::Result<()> {
17    macro_rules! tool {
18        ($($args:tt)+) => {
19            let flow = tool($($args)+)?;
20            if flow.is_break() {
21                return Ok(())
22            }
23        };
24    }
25
26    let mut local_bin_crate = None;
27    if local.exists() {
28        for entry in fs::read_dir(local).with_context(|| format!("cannot read_dir {}", local.display()))? {
29            let path = entry.with_context(|| format!("cannot read_dir entry {}", local.display()))?.path();
30            if path.is_dir() {
31                let name = path.file_name().unwrap().to_string_lossy();
32                if let Some(name) = name.strip_prefix("cargo-zng-res-") {
33                    if path.join("Cargo.toml").exists() {
34                        tool!(Tool {
35                            name: name.to_owned(),
36                            kind: ToolKind::LocalCrate,
37                            path,
38                        });
39                    }
40                } else if name == "cargo-zng-res" && path.join("Cargo.toml").exists() {
41                    local_bin_crate = Some(path);
42                }
43            }
44        }
45    }
46
47    if let Some(path) = local_bin_crate {
48        let bin_dir = path.join("src/bin");
49        for entry in fs::read_dir(&bin_dir).with_context(|| format!("cannot read_dir {}", bin_dir.display()))? {
50            let path = entry
51                .with_context(|| format!("cannot read_dir entry {}", bin_dir.display()))?
52                .path();
53            if path.is_file() {
54                let name = path.file_name().unwrap().to_string_lossy();
55                if let Some(name) = name.strip_suffix(".rs") {
56                    tool!(Tool {
57                        name: name.to_owned(),
58                        kind: ToolKind::LocalBin,
59                        path,
60                    });
61                }
62            }
63        }
64    }
65
66    let current_exe = std::env::current_exe()?;
67
68    for &name in crate::res::built_in::BUILT_INS {
69        tool!(Tool {
70            name: name.to_owned(),
71            kind: ToolKind::BuiltIn,
72            path: current_exe.clone(),
73        });
74    }
75
76    let install_dir = current_exe
77        .parent()
78        .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "no cargo install dir"))?;
79
80    for entry in fs::read_dir(install_dir).with_context(|| format!("cannot read_dir {}", install_dir.display()))? {
81        let path = entry
82            .with_context(|| format!("cannot read_dir entry {}", install_dir.display()))?
83            .path();
84        if path.is_file() {
85            let name = path.file_name().unwrap().to_string_lossy();
86            if let Some(name) = name.strip_prefix("cargo-zng-res-")
87                && path.is_executable()
88            {
89                tool!(Tool {
90                    name: name.split('.').next().unwrap().to_owned(),
91                    kind: ToolKind::Installed,
92                    path,
93                });
94            }
95        }
96    }
97
98    Ok(())
99}
100
101pub fn visit_about_vars(about: &About, mut visit: impl FnMut(&str, &str)) {
102    visit(ZR_APP_ID, &about.app_id);
103    visit(ZR_APP, &about.app);
104    visit(ZR_CRATE_NAME, &about.crate_name());
105    visit(ZR_HOMEPAGE, &about.homepage);
106    visit(ZR_LICENSE, &about.license);
107    visit(ZR_ORG, &about.org);
108    visit(ZR_PKG_AUTHORS, &about.pkg_authors.clone().join(","));
109    visit(ZR_PKG_NAME, &about.pkg_name);
110    visit(ZR_QUALIFIER, &about.qualifier());
111    visit(ZR_VERSION, &about.version.to_string());
112    visit(ZR_DESCRIPTION, &about.description);
113    for (key, value) in &about.meta {
114        if !value.is_empty() && !key.is_empty() {
115            visit(&format!("ZR_META_{}", key.to_uppercase().replace('-', "_")), value);
116        }
117    }
118}
119
120pub struct Tool {
121    pub name: String,
122    pub kind: ToolKind,
123
124    pub path: PathBuf,
125}
126impl Tool {
127    pub fn help(&self) -> anyhow::Result<String> {
128        let out = self.cmd().env(ZR_HELP, "").output()?;
129        if !out.status.success() {
130            let error = String::from_utf8_lossy(&out.stderr);
131            bail!("{error}\nhelp run failed, exit code {}", out.status.code().unwrap_or(0));
132        }
133        Ok(String::from_utf8_lossy(&out.stdout).into_owned())
134    }
135
136    fn run(
137        &self,
138        cache: &Path,
139        source_dir: &Path,
140        target_dir: &Path,
141        request: &Path,
142        about: &About,
143        final_args: Option<String>,
144    ) -> anyhow::Result<ToolOutput> {
145        use sha2::Digest;
146        let mut hasher = sha2::Sha256::new();
147
148        hasher.update(source_dir.as_os_str().as_encoded_bytes());
149        hasher.update(target_dir.as_os_str().as_encoded_bytes());
150        hasher.update(request.as_os_str().as_encoded_bytes());
151
152        let mut hash_request = || -> anyhow::Result<()> {
153            let mut file = fs::File::open(request)?;
154            io::copy(&mut file, &mut hasher)?;
155            Ok(())
156        };
157        if let Err(e) = hash_request() {
158            fatal!("cannot read request `{}`, {e}", request.display());
159        }
160
161        let cache_dir = format!("{:x}", hasher.finalize());
162
163        let mut cmd = self.cmd();
164        if let Some(args) = final_args {
165            cmd.env(ZR_FINAL, args);
166        }
167
168        // if the request is already in `target` (recursion)
169        let mut target = request.with_extension("");
170        // if the request is in `source`
171        if let Ok(p) = target.strip_prefix(source_dir) {
172            target = target_dir.join(p);
173        }
174
175        cmd.env(ZR_WORKSPACE_DIR, std::env::current_dir().unwrap())
176            .env(ZR_SOURCE_DIR, source_dir)
177            .env(ZR_TARGET_DIR, target_dir)
178            .env(ZR_REQUEST_DD, request.parent().unwrap())
179            .env(ZR_REQUEST, request)
180            .env(ZR_TARGET_DD, target.parent().unwrap())
181            .env(ZR_TARGET, target)
182            .env(ZR_CACHE_DIR, cache.join(cache_dir));
183        visit_about_vars(about, |key, value| {
184            cmd.env(key, value);
185        });
186        self.run_cmd(&mut cmd)
187    }
188
189    fn cmd(&self) -> std::process::Command {
190        use std::process::Command;
191
192        match self.kind {
193            ToolKind::LocalCrate => {
194                let mut cmd = Command::new("cargo");
195                cmd.arg("run")
196                    .arg("--quiet")
197                    .arg("--manifest-path")
198                    .arg(self.path.join("Cargo.toml"))
199                    .arg("--");
200                cmd
201            }
202            ToolKind::LocalBin => {
203                let mut cmd = Command::new("cargo");
204                cmd.arg("run")
205                    .arg("--quiet")
206                    .arg("--manifest-path")
207                    .arg(self.path.parent().unwrap().parent().unwrap().parent().unwrap().join("Cargo.toml"))
208                    .arg("--bin")
209                    .arg(&self.name)
210                    .arg("--");
211                cmd
212            }
213            ToolKind::BuiltIn => {
214                let mut cmd = Command::new(&self.path);
215                cmd.env(crate::res::built_in::ENV_TOOL, &self.name);
216                cmd
217            }
218            ToolKind::Installed => Command::new(&self.path),
219        }
220    }
221
222    fn run_cmd(&self, cmd: &mut std::process::Command) -> anyhow::Result<ToolOutput> {
223        let mut cmd = cmd
224            .stdin(std::process::Stdio::null())
225            .stdout(std::process::Stdio::piped())
226            .stderr(std::process::Stdio::piped())
227            .spawn()?;
228
229        // indent stderr
230        let cmd_err = cmd.stderr.take().unwrap();
231        let error_pipe = std::thread::Builder::new()
232            .name("stderr-reader".into())
233            .spawn(move || {
234                for line in io::BufReader::new(cmd_err).lines() {
235                    match line {
236                        Ok(l) => eprintln!("  {l}"),
237                        Err(e) => {
238                            error!("{e}");
239                            return;
240                        }
241                    }
242                }
243            })
244            .expect("failed to spawn thread");
245
246        // indent stdout and capture "zng-res::" requests
247        let mut requests = vec![];
248        const REQUEST: &[u8] = b"zng-res::";
249        let mut cmd_out = cmd.stdout.take().unwrap();
250        let mut out = io::stdout();
251        let mut buf = [0u8; 1024];
252
253        let mut at_line_start = true;
254        let mut maybe_request_start = None;
255
256        print!("\x1B[2m"); // dim
257        loop {
258            let len = cmd_out.read(&mut buf)?;
259            if len == 0 {
260                break;
261            }
262
263            for s in buf[..len].split_inclusive(|&c| c == b'\n') {
264                if at_line_start {
265                    if s.starts_with(REQUEST) || REQUEST.starts_with(s) {
266                        maybe_request_start = Some(requests.len());
267                    }
268                    if maybe_request_start.is_none() {
269                        out.write_all(b"  ")?;
270                    }
271                }
272                if maybe_request_start.is_none() {
273                    out.write_all(s)?;
274                    out.flush()?;
275                } else {
276                    requests.write_all(s).unwrap();
277                }
278
279                at_line_start = s.last() == Some(&b'\n');
280                if at_line_start
281                    && let Some(i) = maybe_request_start.take()
282                    && !requests[i..].starts_with(REQUEST)
283                {
284                    out.write_all(&requests[i..])?;
285                    out.flush()?;
286                    requests.truncate(i);
287                }
288            }
289        }
290        print!("\x1B[0m"); // clear styles
291        let _ = std::io::stdout().flush();
292
293        let status = cmd.wait()?;
294        let _ = error_pipe.join();
295        if status.success() {
296            Ok(ToolOutput::from(String::from_utf8_lossy(&requests).as_ref()))
297        } else {
298            bail!("command failed, exit code {}", status.code().unwrap_or(0))
299        }
300    }
301}
302
303pub struct Tools {
304    tools: Vec<Tool>,
305    cache: PathBuf,
306    on_final: Mutex<Vec<(usize, PathBuf, String)>>,
307    about: About,
308}
309impl Tools {
310    pub fn capture(local: &Path, cache: PathBuf, about: About, verbose: bool) -> anyhow::Result<Self> {
311        let mut tools = vec![];
312        visit_tools(local, |t| {
313            if verbose {
314                println!("found tool `{}` in `{}`", t.name, t.path.display())
315            }
316            tools.push(t);
317            Ok(ControlFlow::Continue(()))
318        })?;
319        Ok(Self {
320            tools,
321            cache,
322            on_final: Mutex::new(vec![]),
323            about,
324        })
325    }
326
327    pub fn run(&self, tool_name: &str, source: &Path, target: &Path, request: &Path) -> anyhow::Result<()> {
328        println!("{}", display_path(request));
329        for (i, tool) in self.tools.iter().enumerate() {
330            if tool.name == tool_name {
331                let output = tool.run(&self.cache, source, target, request, &self.about, None)?;
332                for warn in output.warnings {
333                    warn!("{warn}")
334                }
335                for args in output.on_final {
336                    self.on_final.lock().push((i, request.to_owned(), args));
337                }
338                if !output.delegate {
339                    return Ok(());
340                }
341            }
342        }
343        bail!("no tool `{tool_name}` to handle request")
344    }
345
346    pub fn run_final(self, source: &Path, target: &Path) -> anyhow::Result<()> {
347        let on_final = self.on_final.into_inner();
348        if !on_final.is_empty() {
349            println!("--final--");
350            for (i, request, args) in on_final {
351                println!("{}", display_path(&request));
352                let output = self.tools[i].run(&self.cache, source, target, &request, &self.about, Some(args))?;
353                for warn in output.warnings {
354                    warn!("{warn}")
355                }
356            }
357        }
358        Ok(())
359    }
360}
361
362struct ToolOutput {
363    // zng-res::delegate
364    pub delegate: bool,
365    // zng-res::warning=
366    pub warnings: Vec<String>,
367    // zng-res::on-final=
368    pub on_final: Vec<String>,
369}
370impl From<&str> for ToolOutput {
371    fn from(value: &str) -> Self {
372        let mut out = Self {
373            delegate: false,
374            warnings: vec![],
375            on_final: vec![],
376        };
377        for line in value.lines() {
378            if line == "zng-res::delegate" {
379                out.delegate = true;
380            } else if let Some(w) = line.strip_prefix("zng-res::warning=") {
381                out.warnings.push(w.to_owned());
382            } else if let Some(a) = line.strip_prefix("zng-res::on-final=") {
383                out.on_final.push(a.to_owned());
384            }
385        }
386        out
387    }
388}
389
390#[derive(Clone, Copy)]
391pub enum ToolKind {
392    LocalCrate,
393    LocalBin,
394    BuiltIn,
395    Installed,
396}