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