cargo_zng/res/
built_in.rs

1//! Built-in tools
2
3use std::{
4    env, fs,
5    io::{self, BufRead, Write},
6    mem,
7    path::{Path, PathBuf},
8    process::Command,
9};
10
11use convert_case::{Case, Casing};
12
13use crate::util;
14
15/// Env var set to the Cargo workspace directory that is parent to the res source.
16///
17/// Note that the tool also runs with this dir as working directory (`current_dir`).
18pub const ZR_WORKSPACE_DIR: &str = "ZR_WORKSPACE_DIR";
19/// Env var set to the resources source directory.
20pub const ZR_SOURCE_DIR: &str = "ZR_SOURCE_DIR";
21/// Env var set to the resources build target directory.
22///
23/// Note that this is the 'root' of the built resources, use [`ZR_TARGET_DD`] to get the
24/// parent dir of the target file inside the target directory.
25pub const ZR_TARGET_DIR: &str = "ZR_TARGET_DIR";
26/// Env var set to dir that the tool can use to store intermediary data for the specific request.
27///
28/// The cache key (dir name) is a hash of source, target, request and request content only.
29pub const ZR_CACHE_DIR: &str = "ZR_CACHE_DIR";
30
31/// Env var set to the request file that called the tool.
32pub const ZR_REQUEST: &str = "ZR_REQUEST";
33/// Env var set to the request file parent dir.
34pub const ZR_REQUEST_DD: &str = "ZR_REQUEST_DD";
35/// Env var set to the target file implied by the request file name.
36///
37/// That is, the request filename without `.zr-{tool}` and in the equivalent target subdirectory.
38pub const ZR_TARGET: &str = "ZR_TARGET";
39/// Env var set to the target file parent dir.
40pub const ZR_TARGET_DD: &str = "ZR_TARGET_DD";
41
42/// Env var set when it is running a tool that requested `zng-res::on-final=` again.
43pub const ZR_FINAL: &str = "ZR_FINAL";
44
45/// Env var set when it needs the tool print the help text shown in `cargo zng res --tools`.
46pub const ZR_HELP: &str = "ZR_HELP";
47
48/// Env var set to package.metadata.zng.about.app_id or "qualifier.org.app" in snake_case
49pub const ZR_APP_ID: &str = "ZR_APP_ID";
50/// Env var set to package.metadata.zng.about.app or package.name
51pub const ZR_APP: &str = "ZR_APP";
52/// Env var set to package.metadata.zng.about.org or the first package.authors
53pub const ZR_ORG: &str = "ZR_ORG";
54/// Env var set to package.version
55pub const ZR_VERSION: &str = "ZR_VERSION";
56/// Env var set to package.description
57pub const ZR_DESCRIPTION: &str = "ZR_DESCRIPTION";
58/// Env var set to package.homepage
59pub const ZR_HOMEPAGE: &str = "ZR_HOMEPAGE";
60/// Env var set to package.license
61pub const ZR_LICENSE: &str = "ZR_LICENSE";
62/// Env var set to package.name
63pub const ZR_PKG_NAME: &str = "ZR_PKG_NAME";
64/// Env var set to package.authors
65pub const ZR_PKG_AUTHORS: &str = "ZR_PKG_AUTHORS";
66/// Env var set to package.name in snake_case
67pub const ZR_CRATE_NAME: &str = "ZR_CRATE_NAME";
68/// Env var set to package.metadata.zng.about.qualifier or the first components `ZR_APP_ID` except the last two
69pub const ZR_QUALIFIER: &str = "ZR_QUALIFIER";
70
71/// Print the help and exit if is help request.
72pub fn help(help: &str) {
73    if env::var(ZR_HELP).is_ok() {
74        println!("{help}");
75        std::process::exit(0);
76    };
77}
78
79/// Get a `ZR_` path var.
80pub fn path(var: &str) -> PathBuf {
81    env::var(var).unwrap_or_else(|_| panic!("missing {var}")).into()
82}
83
84/// Format the path in the standard way used by cargo-zng.
85pub fn display_path(p: &Path) -> String {
86    let base = path(ZR_WORKSPACE_DIR);
87    let r = if let Ok(local) = p.strip_prefix(base) {
88        local.display().to_string()
89    } else {
90        p.display().to_string()
91    };
92
93    #[cfg(windows)]
94    return r.replace('\\', "/");
95
96    #[cfg(not(windows))]
97    r
98}
99
100const COPY_HELP: &str = "
101Copy the file or dir
102
103The request file:
104  source/foo.txt.zr-copy
105   | # comment
106   | path/bar.txt
107
108Copies `path/bar.txt` to:
109  target/foo.txt
110
111Paths are relative to the Cargo workspace root.
112";
113fn copy() {
114    help(COPY_HELP);
115
116    // read source
117    let source = read_path(&path(ZR_REQUEST)).unwrap_or_else(|e| fatal!("{e}"));
118    // target derived from the request file name
119    let mut target = path(ZR_TARGET);
120    // request without name "./.zr-copy", take name from source (this is deliberate not documented)
121    if target.ends_with(".zr-copy") {
122        target = target.with_file_name(source.file_name().unwrap());
123    }
124
125    if source.is_dir() {
126        println!("{}", display_path(&target));
127        fs::create_dir(&target).unwrap_or_else(|e| {
128            if e.kind() != io::ErrorKind::AlreadyExists {
129                fatal!("{e}")
130            }
131        });
132        copy_dir_all(&source, &target, true);
133    } else if source.is_file() {
134        println!("{}", display_path(&target));
135        fs::copy(source, &target).unwrap_or_else(|e| fatal!("{e}"));
136    } else if source.is_symlink() {
137        symlink_warn(&source);
138    } else {
139        warn!("cannot copy '{}', not found", source.display());
140    }
141}
142
143const GLOB_HELP: &str = "
144Copy all matches in place
145
146The request file:
147  source/l10n/fluent-files.zr-glob
148   | # localization dir
149   | l10n
150   | # only Fluent files
151   | **/*.ftl
152   | # except test locales
153   | !:**/pseudo*
154
155Copies all '.ftl' not in a *pseudo* path to:
156  target/l10n/
157
158The first path pattern is required and defines the entries that
159will be copied, an initial pattern with '**' flattens the matches.
160The path is relative to the Cargo workspace root.
161
162The subsequent patterns are optional and filter each file or dir selected by
163the first pattern. The paths are relative to each match, if it is a file 
164the filters apply to the file name only, if it is a dir the filters apply to
165the dir and descendants.
166
167The glob pattern syntax is:
168
169    ? — matches any single character.
170    * — matches any (possibly empty) sequence of characters.
171   ** — matches the current directory and arbitrary subdirectories.
172  [c] — matches any character inside the brackets.
173[a-z] — matches any characters in the Unicode sequence.
174 [!b] — negates the brackets match.
175
176And in filter patterns only:
177
178!:pattern — negates the entire pattern.
179
180";
181fn glob() {
182    help(GLOB_HELP);
183
184    // target derived from the request place
185    let target = path(ZR_TARGET);
186    let target = target.parent().unwrap();
187
188    let request_path = path(ZR_REQUEST);
189    let mut lines = read_lines(&request_path);
190    let (ln, selection) = lines
191        .next()
192        .unwrap_or_else(|| fatal!("expected at least one path pattern"))
193        .unwrap_or_else(|e| fatal!("{e}"));
194
195    // parse first pattern
196    let selection = glob::glob(&selection).unwrap_or_else(|e| fatal!("at line {ln}, {e}"));
197    // parse filter patterns
198    let mut filters = vec![];
199    for r in lines {
200        let (ln, filter) = r.unwrap_or_else(|e| fatal!("{e}"));
201        let (filter, matches_if) = if let Some(f) = filter.strip_prefix("!:") {
202            (f, false)
203        } else {
204            (filter.as_str(), true)
205        };
206        let pat = glob::Pattern::new(filter).unwrap_or_else(|e| fatal!("at line {ln}, {e}"));
207        filters.push((pat, matches_if));
208    }
209    // collect first matches
210    let selection = {
211        let mut s = vec![];
212        for entry in selection {
213            s.push(entry.unwrap_or_else(|e| fatal!("{e}")));
214        }
215        // sorted for deterministic results in case flattened files override previous
216        s.sort();
217        s
218    };
219
220    let mut any = false;
221
222    'apply: for source in selection {
223        if source.is_dir() {
224            let filters_root = source.parent().map(Path::to_owned).unwrap_or_default();
225            'copy_dir: for entry in walkdir::WalkDir::new(&source).sort_by_file_name() {
226                let source = entry.unwrap_or_else(|e| fatal!("cannot walkdir entry `{}`, {e}", source.display()));
227                let source = source.path();
228                // filters match 'entry/**'
229                let match_source = source.strip_prefix(&filters_root).unwrap();
230                for (filter, matches_if) in &filters {
231                    if filter.matches_path(match_source) != *matches_if {
232                        continue 'copy_dir;
233                    }
234                }
235                let target = target.join(match_source);
236
237                any = true;
238                if source.is_dir() {
239                    fs::create_dir_all(&target).unwrap_or_else(|e| fatal!("cannot create dir `{}`, {e}", source.display()));
240                } else {
241                    if let Some(p) = &target.parent() {
242                        fs::create_dir_all(p).unwrap_or_else(|e| fatal!("cannot create dir `{}`, {e}", p.display()));
243                    }
244                    fs::copy(source, &target)
245                        .unwrap_or_else(|e| fatal!("cannot copy `{}` to `{}`, {e}", source.display(), target.display()));
246                }
247                println!("{}", display_path(&target));
248            }
249        } else if source.is_file() {
250            // filters match 'entry'
251            let source_name = source.file_name().unwrap().to_string_lossy();
252            for (filter, matches_if) in &filters {
253                if filter.matches(&source_name) != *matches_if {
254                    continue 'apply;
255                }
256            }
257            let target = target.join(source_name.as_ref());
258
259            any = true;
260            fs::copy(&source, &target).unwrap_or_else(|e| fatal!("cannot copy `{}` to `{}`, {e}", source.display(), target.display()));
261            println!("{}", display_path(&target));
262        } else if source.is_symlink() {
263            symlink_warn(&source);
264        }
265    }
266
267    if !any {
268        warn!("no match")
269    }
270}
271
272const RP_HELP: &str = r#"
273Replace ${VAR|<file|!cmd} occurrences in the content
274
275The request file:
276  source/greetings.txt.zr-rp
277   | Thanks for using ${ZR_APP}!
278
279Writes the text content with ZR_APP replaced:
280  target/greetings.txt
281  | Thanks for using Foo App!
282
283The parameters syntax is ${VAR|!|<[:[case]][?else]}:
284
285${VAR}          — Replaces with the env var value, or fails if it is not set.
286${VAR:case}     — Replaces with the env var value, case converted.
287${VAR:?else}    — If VAR is not set or is empty uses 'else' instead.
288
289${<file.txt}    — Replaces with the 'file.txt' content. 
290                  Paths are relative to the workspace root.
291${<file:case}   — Replaces with the 'file.txt' content, case converted.
292${<file:?else}  — If file cannot be read or is empty uses 'else' instead.
293
294${!cmd -h}      — Replaces with the stdout of the bash script line. 
295                  The script runs the same bash used by '.zr-sh'.
296                  The script must be defined all in one line.
297                  A separate bash instance is used for each occurrence.
298                  The working directory is the workspace root.
299${!cmd:case}    — Replaces with the stdout, case converted. 
300                  If the script contains ':' quote it with double quotes\"
301${!cmd:?else}  — If script fails or ha no stdout, uses 'else' instead.
302
303$${VAR}         — Escapes $, replaces with '${VAR}'.
304
305The :case functions are:
306
307:k or :kebab  — kebab-case (cleaned)
308:K or :KEBAB  — UPPER-KEBAB-CASE (cleaned)
309:s or :snake  — snake_case (cleaned)
310:S or :SNAKE  — UPPER_SNAKE_CASE (cleaned)
311:l or :lower  — lower case
312:U or :UPPER  — UPPER CASE
313:T or :Title  — Title Case
314:c or :camel  — camelCase (cleaned)
315:P or :Pascal — PascalCase (cleaned)
316:Tr or :Train — Train-Case (cleaned)
317:           — Unchanged
318:clean      — Cleaned
319:f or :file — Sanitize file name
320
321Cleaned values only keep ascii alphabetic first char and ascii alphanumerics, ' ', '-' and '_' other chars.
322More then one case function can be used, separated by pipe ':T|f' converts to title case and sanitize for file name. 
323
324
325The fallback(:?else) can have nested ${...} patterns. 
326You can set both case and else: '${VAR:case?else}'.
327
328Variables:
329
330All env variables can be used, of particular use with this tool are:
331
332ZR_APP_ID — package.metadata.zng.about.app_id or "qualifier.org.app" in snake_case
333ZR_APP — package.metadata.zng.about.app or package.name
334ZR_ORG — package.metadata.zng.about.org or the first package.authors
335ZR_VERSION — package.version
336ZR_DESCRIPTION — package.description
337ZR_HOMEPAGE — package.homepage
338ZR_LICENSE — package.license
339ZR_PKG_NAME — package.name
340ZR_PKG_AUTHORS — package.authors
341ZR_CRATE_NAME — package.name in snake_case
342ZR_QUALIFIER — package.metadata.zng.about.qualifier or the first components `ZR_APP_ID` except the last two
343ZR_META_*` — any other custom string value in package.metadata.zng.about.*
344
345See `zng::env::about` for more details about metadata vars.
346See the cargo-zng crate docs for a full list of ZR vars.
347
348"#;
349fn rp() {
350    help(RP_HELP);
351
352    // target derived from the request place
353    let content = fs::File::open(path(ZR_REQUEST)).unwrap_or_else(|e| fatal!("cannot read, {e}"));
354    let target = path(ZR_TARGET);
355    let target = fs::File::create(target).unwrap_or_else(|e| fatal!("cannot write, {e}"));
356    let mut target = io::BufWriter::new(target);
357
358    let mut content = io::BufReader::new(content);
359    let mut line = String::new();
360    let mut ln = 1;
361    while content.read_line(&mut line).unwrap_or_else(|e| fatal!("cannot read, {e}")) > 0 {
362        let line_r = replace(&line, 0).unwrap_or_else(|e| fatal!("line {ln}, {e}"));
363        target.write_all(line_r.as_bytes()).unwrap_or_else(|e| fatal!("cannot write, {e}"));
364        ln += 1;
365        line.clear();
366    }
367    target.flush().unwrap_or_else(|e| fatal!("cannot write, {e}"));
368}
369
370const MAX_RECURSION: usize = 32;
371fn replace(line: &str, recursion_depth: usize) -> Result<String, String> {
372    let mut n2 = '\0';
373    let mut n1 = '\0';
374    let mut out = String::with_capacity(line.len());
375
376    let mut iterator = line.char_indices();
377    'main: while let Some((ci, c)) = iterator.next() {
378        if n1 == '$' && c == '{' {
379            out.pop();
380            if n2 == '$' {
381                out.push('{');
382                n1 = '{';
383                continue 'main;
384            }
385
386            let start = ci + 1;
387            let mut depth = 0;
388            let mut end = usize::MAX;
389            'seek_end: for (i, c) in iterator.by_ref() {
390                if c == '{' {
391                    depth += 1;
392                } else if c == '}' {
393                    if depth == 0 {
394                        end = i;
395                        break 'seek_end;
396                    }
397                    depth -= 1;
398                }
399            }
400            if end == usize::MAX {
401                let end = (start + 10).min(line.len());
402                return Err(format!("replace not closed at: ${{{}", &line[start..end]));
403            } else {
404                let mut var = &line[start..end];
405                let mut case = "";
406                let mut fallback = None;
407
408                // escape ":"
409                let mut search_start = 0;
410                if var.starts_with('!') {
411                    let mut quoted = false;
412                    let mut escape_next = false;
413                    for (i, c) in var.char_indices() {
414                        if mem::take(&mut escape_next) {
415                            continue;
416                        }
417                        if c == '\\' {
418                            escape_next = true;
419                        } else if c == '"' {
420                            quoted = !quoted;
421                        } else if !quoted && c == ':' {
422                            search_start = i;
423                            break;
424                        }
425                    }
426                }
427                if let Some(i) = var[search_start..].find(':') {
428                    let i = search_start + i;
429                    case = &var[i + 1..];
430                    var = &var[..i];
431                    if let Some(i) = case.find('?') {
432                        fallback = Some(&case[i + 1..]);
433                        case = &case[..i];
434                    }
435                }
436
437                let value = if let Some(path) = var.strip_prefix('<') {
438                    match std::fs::read_to_string(path) {
439                        Ok(s) => Some(s),
440                        Err(e) => {
441                            error!("cannot read `{path}`, {e}");
442                            None
443                        }
444                    }
445                } else if let Some(script) = var.strip_prefix('!') {
446                    match sh_run(script.to_owned(), true, None) {
447                        Ok(r) => Some(r),
448                        Err(e) => fatal!("{e}"),
449                    }
450                } else {
451                    env::var(var).ok()
452                };
453
454                let value = match value {
455                    Some(s) => {
456                        let st = s.trim();
457                        if st.is_empty() {
458                            None
459                        } else if st == s {
460                            Some(s)
461                        } else {
462                            Some(st.to_owned())
463                        }
464                    }
465                    _ => None,
466                };
467
468                if let Some(mut value) = value {
469                    for case in case.split('|') {
470                        value = match case {
471                            "k" | "kebab" => util::clean_value(&value, false).unwrap().to_case(Case::Kebab),
472                            "K" | "KEBAB" => util::clean_value(&value, false).unwrap().to_case(Case::UpperKebab),
473                            "s" | "snake" => util::clean_value(&value, false).unwrap().to_case(Case::Snake),
474                            "S" | "SNAKE" => util::clean_value(&value, false).unwrap().to_case(Case::UpperSnake),
475                            "l" | "lower" => value.to_case(Case::Lower),
476                            "U" | "UPPER" => value.to_case(Case::Upper),
477                            "T" | "Title" => value.to_case(Case::Title),
478                            "c" | "camel" => util::clean_value(&value, false).unwrap().to_case(Case::Camel),
479                            "P" | "Pascal" => util::clean_value(&value, false).unwrap().to_case(Case::Pascal),
480                            "Tr" | "Train" => util::clean_value(&value, false).unwrap().to_case(Case::Train),
481                            "" => value,
482                            "clean" => util::clean_value(&value, false).unwrap(),
483                            "f" | "file" => sanitise_file_name::sanitise(&value),
484                            unknown => return Err(format!("unknown case '{unknown}'")),
485                        };
486                    }
487                    out.push_str(&value);
488                } else if let Some(fallback) = fallback {
489                    if let Some(error) = fallback.strip_prefix('!') {
490                        if error.contains('$') && recursion_depth < MAX_RECURSION {
491                            return Err(replace(error, recursion_depth + 1).unwrap_or_else(|_| error.to_owned()));
492                        } else {
493                            return Err(error.to_owned());
494                        }
495                    } else if fallback.contains('$') && recursion_depth < MAX_RECURSION {
496                        out.push_str(&replace(fallback, recursion_depth + 1)?);
497                    } else {
498                        out.push_str(fallback);
499                    }
500                } else {
501                    return Err(format!("${{{var}}} output is empty"));
502                }
503            }
504        } else {
505            out.push(c);
506        }
507        n2 = n1;
508        n1 = c;
509    }
510    Ok(out)
511}
512
513const WARN_HELP: &str = "
514Print a warning message
515
516You can combine this with '.zr-rp' tool
517
518The request file:
519  source/warn.zr-warn.zr-rp
520   | ${ZR_APP}!
521
522Prints a warning with the value of ZR_APP
523";
524fn warn() {
525    help(WARN_HELP);
526    let message = fs::read_to_string(path(ZR_REQUEST)).unwrap_or_else(|e| fatal!("{e}"));
527    println!("zng-res::warning={message}");
528}
529
530const FAIL_HELP: &str = "
531Print an error message and fail the build
532
533The request file:
534  some/dir/disallow.zr-fail.zr-rp
535   | Don't copy ${ZR_REQUEST_DD} with a glob!
536
537Prints an error message and fails the build if copied
538";
539fn fail() {
540    help(FAIL_HELP);
541    let message = fs::read_to_string(ZR_REQUEST).unwrap_or_else(|e| fatal!("{e}"));
542    fatal!("{message}");
543}
544
545const SH_HELP: &str = r#"
546Run a bash script
547
548Script is configured using environment variables (like other tools):
549
550ZR_SOURCE_DIR — Resources directory that is being build.
551ZR_TARGET_DIR — Target directory where resources are being built to.
552ZR_CACHE_DIR — Dir to use for intermediary data for the specific request.
553ZR_WORKSPACE_DIR — Cargo workspace that contains source dir. Also the working dir.
554ZR_REQUEST — Request file that called the tool (.zr-sh).
555ZR_REQUEST_DD — Parent dir of the request file.
556ZR_TARGET — Target file implied by the request file name.
557ZR_TARGET_DD — Parent dir of the target file.
558
559ZR_FINAL — Set if the script previously printed `zng-res::on-final={args}`.
560
561In a Cargo workspace the `zng::env::about` metadata is also set:
562
563ZR_APP_ID — package.metadata.zng.about.app_id or "qualifier.org.app" in snake_case
564ZR_APP — package.metadata.zng.about.app or package.name
565ZR_ORG — package.metadata.zng.about.org or the first package.authors
566ZR_VERSION — package.version
567ZR_DESCRIPTION — package.description
568ZR_HOMEPAGE — package.homepage
569ZR_LICENSE — package.license
570ZR_PKG_NAME — package.name
571ZR_PKG_AUTHORS — package.authors
572ZR_CRATE_NAME — package.name in snake_case
573ZR_QUALIFIER — package.metadata.zng.about.qualifier or the first components `ZR_APP_ID` except the last two
574ZR_META_* — any other custom string value in package.metadata.zng.about.*
575
576Script can make requests to the resource builder by printing to stdout.
577Current supported requests:
578
579zng-res::warning={msg} — Prints the `{msg}` as a warning after the script exits.
580zng-res::on-final={args} — Schedule second run with `ZR_FINAL={args}`, on final pass.
581
582If the script fails the entire stderr is printed and the resource build fails. Scripts run with
583`set -e` by default.
584
585Tries to run on $ZR_SH, $PROGRAMFILES/Git/bin/bash.exe, bash, sh.
586"#;
587fn sh() {
588    help(SH_HELP);
589    let script = fs::read_to_string(path(ZR_REQUEST)).unwrap_or_else(|e| fatal!("{e}"));
590    sh_run(script, false, None).unwrap_or_else(|e| fatal!("{e}"));
591}
592
593fn sh_options() -> Vec<std::ffi::OsString> {
594    let mut r = vec![];
595    if let Ok(sh) = env::var("ZR_SH")
596        && !sh.is_empty()
597    {
598        let sh = PathBuf::from(sh);
599        if sh.exists() {
600            r.push(sh.into_os_string());
601        }
602    }
603
604    #[cfg(windows)]
605    if let Ok(pf) = env::var("PROGRAMFILES") {
606        let sh = PathBuf::from(pf).join("Git/bin/bash.exe");
607        if sh.exists() {
608            r.push(sh.into_os_string());
609        }
610    }
611    #[cfg(windows)]
612    if let Ok(c) = env::var("SYSTEMDRIVE") {
613        let sh = PathBuf::from(c).join("Program Files (x86)/Git/bin/bash.exe");
614        if sh.exists() {
615            r.push(sh.into_os_string());
616        }
617    }
618
619    r.push("bash".into());
620    r.push("sh".into());
621
622    r
623}
624pub(crate) fn sh_run(mut script: String, capture: bool, current_dir: Option<&Path>) -> io::Result<String> {
625    script.insert_str(0, "set -e\n");
626
627    for opt in sh_options() {
628        let r = sh_run_try(&opt, &script, capture, current_dir)?;
629        if let Some(r) = r {
630            return Ok(r);
631        }
632    }
633    Err(io::Error::new(
634        io::ErrorKind::NotFound,
635        "cannot find bash, tried $ZR_SH, $PROGRAMFILES/Git/bin/bash.exe, bash, sh",
636    ))
637}
638fn sh_run_try(sh: &std::ffi::OsStr, script: &str, capture: bool, current_dir: Option<&Path>) -> io::Result<Option<String>> {
639    let mut sh = Command::new(sh);
640    if let Some(d) = current_dir {
641        sh.current_dir(d);
642    }
643    sh.arg("-c").arg(script);
644    sh.stdin(std::process::Stdio::null());
645    sh.stderr(std::process::Stdio::inherit());
646    let r = if capture {
647        sh.output().map(|o| (o.status, String::from_utf8_lossy(&o.stdout).into_owned()))
648    } else {
649        sh.stdout(std::process::Stdio::inherit());
650        sh.status().map(|s| (s, String::new()))
651    };
652    match r {
653        Ok((s, o)) => {
654            if !s.success() {
655                return Err(match s.code() {
656                    Some(c) => io::Error::other(format!("script failed, exit code {c}")),
657                    None => io::Error::other("script failed"),
658                });
659            }
660            Ok(Some(o))
661        }
662        Err(e) => {
663            if e.kind() == io::ErrorKind::NotFound {
664                Ok(None)
665            } else {
666                Err(e)
667            }
668        }
669    }
670}
671
672const SHF_HELP: &str = r#"
673Run a bash script on the final pass
674
675Apart from running on final this tool behaves exactly like .zr-sh
676"#;
677fn shf() {
678    help(SHF_HELP);
679    if std::env::var(ZR_FINAL).is_ok() {
680        sh();
681    } else {
682        println!("zng-res::on-final=");
683    }
684}
685
686const APK_HELP: &str = r#"
687Build an Android APK from a staging directory
688
689The expected file system layout:
690
691| apk/
692| ├── lib/
693| |   └── arm64-v8a
694| |       └── my-app.so
695| ├── assets/
696| |   └── res
697| |       └── zng-res.txt
698| ├── res/
699| |   └── android-res
700| └── AndroidManifest.xml
701| my-app.zr-apk
702
703Both 'apk/' and 'my-app.zr-apk' will be replaced with the built my-app.apk
704
705Expected .zr-apk file content:
706
707| # Relative path to the staging directory. If not set uses ./apk if it exists
708| # or the parent dir .. if it is named something.apk
709| apk-dir = ./apk
710|
711| # Sign using the debug key. Note that if ZR_APK_KEYSTORE or ZR_APK_KEY_ALIAS are not
712| # set the APK is also signed using the debug key.
713| debug = true
714|
715| # Don't sign and don't zipalign the APK. This outputs an incomplete package that
716| # cannot be installed, but can be modified such as custom linking and signing.
717| raw = true
718|
719| # Don't tar assets. By default `assets/res` are packed as `assets/res.tar`
720| # for use with `android_install_res`.
721| tar-assets-res = false
722
723APK signing is configured using these environment variables:
724
725ZR_APK_KEYSTORE - path to the private .keystore file
726ZR_APK_KEYSTORE_PASS - keystore file password
727ZR_APK_KEY_ALIAS - key name in the keystore
728ZR_APK_KEY_PASS - key password
729"#;
730fn apk() {
731    help(APK_HELP);
732    if std::env::var(ZR_FINAL).is_err() {
733        println!("zng-res::on-final=");
734        return;
735    }
736
737    // read config
738    let mut apk_dir = String::new();
739    let mut debug = false;
740    let mut raw = false;
741    let mut tar_assets = true;
742    for line in read_lines(&path(ZR_REQUEST)) {
743        let (ln, line) = line.unwrap_or_else(|e| fatal!("error reading .zr-apk request, {e}"));
744        if let Some((key, value)) = line.split_once('=') {
745            let key = key.trim();
746            let value = value.trim();
747
748            let bool_value = || match value {
749                "true" => true,
750                "false" => false,
751                _ => {
752                    error!("unexpected value, line {ln}\n   {line}");
753                    false
754                }
755            };
756            match key {
757                "apk-dir" => apk_dir = value.to_owned(),
758                "debug" => debug = bool_value(),
759                "raw" => raw = bool_value(),
760                "tar-assets" => tar_assets = bool_value(),
761                _ => error!("unknown key, line {ln}\n   {line}"),
762            }
763        } else {
764            error!("syntax error, line {ln}\n{line}");
765        }
766    }
767    let mut keystore = PathBuf::from(env::var("ZR_APK_KEYSTORE").unwrap_or_default());
768    let mut keystore_pass = env::var("ZR_APK_KEYSTORE_PASS").unwrap_or_default();
769    let mut key_alias = env::var("ZR_APK_KEY_ALIAS").unwrap_or_default();
770    let mut key_pass = env::var("ZR_APK_KEY_PASS").unwrap_or_default();
771    if keystore.as_os_str().is_empty() || key_alias.is_empty() {
772        debug = true;
773    }
774
775    let mut apk_folder = path(ZR_TARGET_DD);
776    let output_file;
777    if apk_dir.is_empty() {
778        let apk = apk_folder.join("apk");
779        if apk.exists() {
780            apk_folder = apk;
781            output_file = path(ZR_TARGET).with_extension("apk");
782        } else if apk_folder.extension().map(|e| e.eq_ignore_ascii_case("apk")).unwrap_or(false) {
783            output_file = apk_folder.clone();
784        } else {
785            fatal!("missing ./apk")
786        }
787    } else {
788        apk_folder = apk_folder.join(apk_dir);
789        if !apk_folder.is_dir() {
790            fatal!("{} not found or not a directory", apk_folder.display());
791        }
792        output_file = path(ZR_TARGET).with_extension("apk");
793    }
794    let apk_folder = apk_folder;
795
796    // find <sdk>/build-tools
797    let android_home = match env::var("ANDROID_HOME") {
798        Ok(h) if !h.is_empty() => h,
799        _ => fatal!("please set ANDROID_HOME to the android-sdk dir"),
800    };
801    let build_tools = Path::new(&android_home).join("build-tools/");
802    let mut best_build = None;
803    let mut best_version = semver::Version::new(0, 0, 0);
804
805    #[cfg(not(windows))]
806    const AAPT2_NAME: &str = "aapt2";
807    #[cfg(windows)]
808    const AAPT2_NAME: &str = "aapt2.exe";
809
810    for dir in fs::read_dir(build_tools).unwrap_or_else(|e| fatal!("cannot read $ANDROID_HOME/build-tools/, {e}")) {
811        let dir = dir
812            .unwrap_or_else(|e| fatal!("cannot read $ANDROID_HOME/build-tools/ entry, {e}"))
813            .path();
814
815        if let Some(ver) = dir
816            .file_name()
817            .and_then(|f| f.to_str())
818            .and_then(|f| semver::Version::parse(f).ok())
819            && ver > best_version
820            && dir.join(AAPT2_NAME).exists()
821        {
822            best_build = Some(dir);
823            best_version = ver;
824        }
825    }
826    let build_tools = match best_build {
827        Some(p) => p,
828        None => fatal!("cannot find $ANDROID_HOME/build-tools/<version>/{AAPT2_NAME}"),
829    };
830    let aapt2_path = build_tools.join(AAPT2_NAME);
831
832    // temp target dir
833    let temp_dir = apk_folder.with_extension("apk.tmp");
834    let _ = fs::remove_dir_all(&temp_dir);
835    fs::create_dir(&temp_dir).unwrap_or_else(|e| fatal!("cannot create {}, {e}", temp_dir.display()));
836
837    // tar assets
838    let assets = apk_folder.join("assets");
839    let assets_res = assets.join("res");
840    if tar_assets && assets_res.exists() {
841        let tar_path = assets.join("res.tar");
842        let r = Command::new("tar")
843            .arg("-cf")
844            .arg(&tar_path)
845            .arg("res")
846            .current_dir(&assets)
847            .status();
848        match r {
849            Ok(s) => {
850                if !s.success() {
851                    fatal!("tar failed")
852                }
853            }
854            Err(e) => fatal!("cannot run 'tar', {e}"),
855        }
856        if let Err(e) = fs::remove_dir_all(&assets_res) {
857            fatal!("failed tar-assets-res cleanup, {e}")
858        }
859    }
860
861    // build resources
862    let compiled_res = temp_dir.join("compiled_res.zip");
863    let res = apk_folder.join("res");
864    if res.exists() {
865        let mut aapt2 = Command::new(&aapt2_path);
866        aapt2.arg("compile").arg("-o").arg(&compiled_res).arg("--dir").arg(res);
867
868        if aapt2.status().map(|s| !s.success()).unwrap_or(true) {
869            fatal!("resources build failed");
870        }
871    }
872
873    let manifest_path = apk_folder.join("AndroidManifest.xml");
874    let manifest = fs::read_to_string(&manifest_path).unwrap_or_else(|e| fatal!("cannot read AndroidManifest.xml, {e}"));
875    let manifest: AndroidManifest = quick_xml::de::from_str(&manifest).unwrap_or_else(|e| fatal!("error parsing AndroidManifest.xml, {e}"));
876
877    // find <sdk>/platforms
878    let platforms = Path::new(&android_home).join("platforms");
879    let mut best_platform = None;
880    let mut best_version = 0;
881    for dir in fs::read_dir(platforms).unwrap_or_else(|e| fatal!("cannot read $ANDROID_HOME/platforms/, {e}")) {
882        let dir = dir
883            .unwrap_or_else(|e| fatal!("cannot read $ANDROID_HOME/platforms/ entry, {e}"))
884            .path();
885
886        if let Some(ver) = dir
887            .file_name()
888            .and_then(|f| f.to_str())
889            .and_then(|f| f.strip_prefix("android-"))
890            .and_then(|f| f.parse().ok())
891            && manifest.uses_sdk.matches(ver)
892            && ver > best_version
893            && dir.join("android.jar").exists()
894        {
895            best_platform = Some(dir);
896            best_version = ver;
897        }
898    }
899    let platform = match best_platform {
900        Some(p) => p,
901        None => fatal!("cannot find $ANDROID_HOME/platforms/<version>/android.jar"),
902    };
903
904    // make apk (link)
905    let apk_path = temp_dir.join("output.apk");
906    let mut aapt2 = Command::new(&aapt2_path);
907    aapt2
908        .arg("link")
909        .arg("-o")
910        .arg(&apk_path)
911        .arg("--manifest")
912        .arg(manifest_path)
913        .arg("-I")
914        .arg(platform.join("android.jar"));
915    if compiled_res.exists() {
916        aapt2.arg(&compiled_res);
917    }
918    if assets.exists() {
919        aapt2.arg("-A").arg(&assets);
920    }
921    if aapt2.status().map(|s| !s.success()).unwrap_or(true) {
922        fatal!("apk linking failed");
923    }
924
925    // add libs
926    let aapt_path = build_tools.join("aapt");
927    for lib in glob::glob(apk_folder.join("lib/*/*.so").display().to_string().as_str()).unwrap() {
928        let lib = lib.unwrap_or_else(|e| fatal!("error searching libs, {e}"));
929
930        let lib = lib.display().to_string().replace('\\', "/");
931        let lib = &lib[lib.rfind("/lib/").unwrap() + 1..];
932
933        let mut aapt = Command::new(&aapt_path);
934        aapt.arg("add").arg(&apk_path).arg(lib).current_dir(&apk_folder);
935        if aapt.status().map(|s| !s.success()).unwrap_or(true) {
936            fatal!("apk linking failed");
937        }
938    }
939
940    let final_apk = if raw {
941        apk_path
942    } else {
943        // align
944        let aligned_apk_path = temp_dir.join("output-aligned.apk");
945        let zipalign_path = build_tools.join("zipalign");
946        let mut zipalign = Command::new(zipalign_path);
947        zipalign.arg("-v").arg("4").arg(apk_path).arg(&aligned_apk_path);
948        if zipalign.status().map(|s| !s.success()).unwrap_or(true) {
949            fatal!("zipalign failed");
950        }
951
952        // sign
953        let signed_apk_path = temp_dir.join("output-signed.apk");
954        if debug {
955            let dirs = directories::BaseDirs::new().unwrap_or_else(|| fatal!("cannot fine $HOME"));
956            keystore = dirs.home_dir().join(".android/debug.keystore");
957            keystore_pass = "android".to_owned();
958            key_alias = "androiddebugkey".to_owned();
959            key_pass = "android".to_owned();
960            if !keystore.exists() {
961                // generate debug.keystore
962                let _ = fs::create_dir_all(keystore.parent().unwrap());
963                let keytool_path = Path::new(&env::var("JAVA_HOME").expect("please set JAVA_HOME")).join("bin/keytool");
964                let mut keytool = Command::new(&keytool_path);
965                keytool
966                    .arg("-genkey")
967                    .arg("-v")
968                    .arg("-keystore")
969                    .arg(&keystore)
970                    .arg("-storepass")
971                    .arg(&keystore_pass)
972                    .arg("-alias")
973                    .arg(&key_alias)
974                    .arg("-keypass")
975                    .arg(&key_pass)
976                    .arg("-keyalg")
977                    .arg("RSA")
978                    .arg("-keysize")
979                    .arg("2048")
980                    .arg("-validity")
981                    .arg("10000")
982                    .arg("-dname")
983                    .arg("CN=Android Debug,O=Android,C=US")
984                    .arg("-storetype")
985                    .arg("pkcs12");
986
987                match keytool.status() {
988                    Ok(s) => {
989                        if !s.success() {
990                            fatal!("keytool failed generating debug keys");
991                        }
992                    }
993                    Err(e) => fatal!("cannot run '{}', {e}", keytool_path.display()),
994                }
995            }
996        }
997
998        #[cfg(not(windows))]
999        const APKSIGNER_NAME: &str = "apksigner";
1000        #[cfg(windows)]
1001        const APKSIGNER_NAME: &str = "apksigner.bat";
1002
1003        let apksigner_path = build_tools.join(APKSIGNER_NAME);
1004        let mut apksigner = Command::new(&apksigner_path);
1005        apksigner
1006            .arg("sign")
1007            .arg("--ks")
1008            .arg(keystore)
1009            .arg("--ks-pass")
1010            .arg(format!("pass:{keystore_pass}"))
1011            .arg("--ks-key-alias")
1012            .arg(key_alias)
1013            .arg("--key-pass")
1014            .arg(format!("pass:{key_pass}"))
1015            .arg("--out")
1016            .arg(&signed_apk_path)
1017            .arg(&aligned_apk_path);
1018
1019        match apksigner.status() {
1020            Ok(s) => {
1021                if !s.success() {
1022                    fatal!("apksigner failed")
1023                }
1024            }
1025            Err(e) => fatal!("cannot run '{}', {e}", apksigner_path.display()),
1026        }
1027        signed_apk_path
1028    };
1029
1030    // finalize
1031    fs::remove_dir_all(&apk_folder).unwrap_or_else(|e| fatal!("apk folder cleanup failed, {e}"));
1032    fs::rename(final_apk, output_file).unwrap_or_else(|e| fatal!("cannot copy built apk to final place, {e}"));
1033    fs::remove_dir_all(&temp_dir).unwrap_or_else(|e| fatal!("temp dir cleanup failed, {e}"));
1034    let _ = fs::remove_file(path(ZR_TARGET));
1035}
1036#[derive(serde::Deserialize)]
1037#[serde(rename = "manifest")]
1038struct AndroidManifest {
1039    #[serde(rename = "uses-sdk")]
1040    #[serde(default)]
1041    pub uses_sdk: AndroidSdk,
1042}
1043#[derive(Default, serde::Deserialize)]
1044#[serde(rename = "uses-sdk")]
1045struct AndroidSdk {
1046    #[serde(rename(serialize = "android:minSdkVersion"))]
1047    pub min_sdk_version: Option<u32>,
1048    #[serde(rename(serialize = "android:targetSdkVersion"))]
1049    pub target_sdk_version: Option<u32>,
1050    #[serde(rename(serialize = "android:maxSdkVersion"))]
1051    pub max_sdk_version: Option<u32>,
1052}
1053impl AndroidSdk {
1054    pub fn matches(&self, version: u32) -> bool {
1055        if let Some(v) = self.target_sdk_version {
1056            return v == version;
1057        }
1058        if let Some(m) = self.min_sdk_version
1059            && version < m
1060        {
1061            return false;
1062        }
1063        if let Some(m) = self.max_sdk_version
1064            && version > m
1065        {
1066            return false;
1067        }
1068        true
1069    }
1070}
1071
1072fn read_line(path: &Path, expected: &str) -> io::Result<String> {
1073    match read_lines(path).next() {
1074        Some(r) => r.map(|(_, l)| l),
1075        None => Err(io::Error::new(
1076            io::ErrorKind::InvalidInput,
1077            format!("expected {expected} in tool file content"),
1078        )),
1079    }
1080}
1081
1082fn read_lines(path: &Path) -> impl Iterator<Item = io::Result<(usize, String)>> {
1083    enum State {
1084        Open(io::Result<fs::File>),
1085        Lines(usize, io::Lines<io::BufReader<fs::File>>),
1086        End,
1087    }
1088    // start -> open
1089    let mut state = State::Open(fs::File::open(path));
1090    std::iter::from_fn(move || {
1091        loop {
1092            match std::mem::replace(&mut state, State::End) {
1093                State::Lines(count, mut lines) => {
1094                    if let Some(l) = lines.next() {
1095                        match l {
1096                            // lines -> lines
1097                            Ok(l) => {
1098                                state = State::Lines(count + 1, lines);
1099                                let test = l.trim();
1100                                if !test.is_empty() && !test.starts_with('#') {
1101                                    return Some(Ok((count, l)));
1102                                }
1103                            }
1104                            // lines -> end
1105                            Err(e) => {
1106                                return Some(Err(e));
1107                            }
1108                        }
1109                    }
1110                }
1111                State::Open(r) => match r {
1112                    // open -> lines
1113                    Ok(f) => state = State::Lines(1, io::BufReader::new(f).lines()),
1114                    // open -> end
1115                    Err(e) => return Some(Err(e)),
1116                },
1117                // end -> end
1118                State::End => return None,
1119            }
1120        }
1121    })
1122}
1123
1124fn read_path(request_file: &Path) -> io::Result<PathBuf> {
1125    read_line(request_file, "path").map(PathBuf::from)
1126}
1127
1128fn copy_dir_all(from: &Path, to: &Path, trace: bool) {
1129    for entry in walkdir::WalkDir::new(from).min_depth(1).max_depth(1).sort_by_file_name() {
1130        let entry = entry.unwrap_or_else(|e| fatal!("cannot walkdir entry `{}`, {e}", from.display()));
1131        let from = entry.path();
1132        let to = to.join(entry.file_name());
1133        if entry.file_type().is_dir() {
1134            fs::create_dir(&to).unwrap_or_else(|e| {
1135                if e.kind() != io::ErrorKind::AlreadyExists {
1136                    fatal!("cannot create_dir `{}`, {e}", to.display())
1137                }
1138            });
1139            if trace {
1140                println!("{}", display_path(&to));
1141            }
1142            copy_dir_all(from, &to, trace);
1143        } else if entry.file_type().is_file() {
1144            fs::copy(from, &to).unwrap_or_else(|e| fatal!("cannot copy `{}` to `{}`, {e}", from.display(), to.display()));
1145            if trace {
1146                println!("{}", display_path(&to));
1147            }
1148        } else if entry.file_type().is_symlink() {
1149            symlink_warn(entry.path())
1150        }
1151    }
1152}
1153
1154pub(crate) fn symlink_warn(path: &Path) {
1155    warn!("symlink ignored in `{}`, use zr-tools to 'link'", path.display());
1156}
1157
1158pub const ENV_TOOL: &str = "ZNG_RES_TOOL";
1159
1160macro_rules! built_in {
1161    ($($tool:tt),+ $(,)?) => {
1162        pub static BUILT_INS: &[&str] = &[
1163            $(stringify!($tool),)+
1164        ];
1165        static BUILT_IN_FNS: &[fn()] = &[
1166            $($tool,)+
1167        ];
1168    };
1169}
1170built_in! { copy, glob, rp, sh, shf, warn, fail, apk }
1171
1172pub fn run() {
1173    if let Ok(tool) = env::var(ENV_TOOL) {
1174        if let Some(i) = BUILT_INS.iter().position(|n| *n == tool.as_str()) {
1175            (BUILT_IN_FNS[i])();
1176            std::process::exit(0);
1177        } else {
1178            fatal!("`tool` is not a built-in tool");
1179        }
1180    }
1181}
1182
1183#[cfg(test)]
1184mod tests {
1185    use super::*;
1186
1187    #[test]
1188    fn replace_tests() {
1189        unsafe {
1190            // SAFETY: potentially not safe as tests run in parallel and I don't want to audit every C dep
1191            // of code that runs in other tests. If a segfault happen during test run caused by this I intend
1192            // to print the test runner log and frame it.
1193            std::env::set_var("ZR_RP_TEST", "test value");
1194        }
1195
1196        assert_eq!("", replace("", 0).unwrap());
1197        assert_eq!("normal text", replace("normal text", 0).unwrap());
1198        assert_eq!("escaped ${NOT}", replace("escaped $${NOT}", 0).unwrap());
1199        assert_eq!("replace 'test value'", replace("replace '${ZR_RP_TEST}'", 0).unwrap());
1200        assert_eq!("${} output is empty", replace("empty '${}'", 0).unwrap_err()); // hmm
1201        assert_eq!(
1202            "${ZR_RP_TEST_NOT_SET} output is empty",
1203            replace("not set '${ZR_RP_TEST_NOT_SET}'", 0).unwrap_err()
1204        );
1205        assert_eq!(
1206            "not set 'fallback!'",
1207            replace("not set '${ZR_RP_TEST_NOT_SET:?fallback!}'", 0).unwrap()
1208        );
1209        assert_eq!(
1210            "not set 'nested 'test value'.'",
1211            replace("not set '${ZR_RP_TEST_NOT_SET:?nested '${ZR_RP_TEST}'.}'", 0).unwrap()
1212        );
1213        assert_eq!("test value", replace("${ZR_RP_TEST_NOT_SET:?${ZR_RP_TEST}}", 0).unwrap());
1214        assert_eq!(
1215            "curly test value",
1216            replace("curly ${ZR_RP_TEST:?{not {what} {is} {going {on {here {:?}}}}}}", 0).unwrap()
1217        );
1218
1219        assert_eq!("replace not closed at: ${MISSING", replace("${MISSING", 0).unwrap_err());
1220        assert_eq!("replace not closed at: ${MIS", replace("${MIS", 0).unwrap_err());
1221        assert_eq!("replace not closed at: ${MIS:?{", replace("${MIS:?{", 0).unwrap_err());
1222        assert_eq!("replace not closed at: ${MIS:?{}", replace("${MIS:?{}", 0).unwrap_err());
1223
1224        assert_eq!("TEST VALUE", replace("${ZR_RP_TEST:U}", 0).unwrap());
1225        assert_eq!("TEST-VALUE", replace("${ZR_RP_TEST:K}", 0).unwrap());
1226        assert_eq!("TEST_VALUE", replace("${ZR_RP_TEST:S}", 0).unwrap());
1227        assert_eq!("testValue", replace("${ZR_RP_TEST:c}", 0).unwrap());
1228    }
1229
1230    #[test]
1231    fn replace_cmd_case() {
1232        assert_eq!("cmd HELLO:?WORLD", replace("cmd ${!printf \"hello:?world\":U}", 0).unwrap(),)
1233    }
1234}