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        && !sh.is_empty()
591    {
592        let sh = PathBuf::from(sh);
593        if sh.exists() {
594            r.push(sh.into_os_string());
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    let r = if capture {
641        sh.output().map(|o| (o.status, String::from_utf8_lossy(&o.stdout).into_owned()))
642    } else {
643        sh.stdout(std::process::Stdio::inherit());
644        sh.status().map(|s| (s, String::new()))
645    };
646    match r {
647        Ok((s, o)) => {
648            if !s.success() {
649                return Err(match s.code() {
650                    Some(c) => io::Error::other(format!("script failed, exit code {c}")),
651                    None => io::Error::other("script failed"),
652                });
653            }
654            Ok(Some(o))
655        }
656        Err(e) => {
657            if e.kind() == io::ErrorKind::NotFound {
658                Ok(None)
659            } else {
660                Err(e)
661            }
662        }
663    }
664}
665
666const SHF_HELP: &str = r#"
667Run a bash script on the final pass
668
669Apart from running on final this tool behaves exactly like .zr-sh
670"#;
671fn shf() {
672    help(SHF_HELP);
673    if std::env::var(ZR_FINAL).is_ok() {
674        sh();
675    } else {
676        println!("zng-res::on-final=");
677    }
678}
679
680const APK_HELP: &str = r#"
681Build an Android APK from a staging directory
682
683The expected file system layout:
684
685| apk/
686| ├── lib/
687| |   └── arm64-v8a
688| |       └── my-app.so
689| ├── assets/
690| |   └── res
691| |       └── zng-res.txt
692| ├── res/
693| |   └── android-res
694| └── AndroidManifest.xml
695| my-app.zr-apk
696
697Both 'apk/' and 'my-app.zr-apk' will be replaced with the built my-app.apk
698
699Expected .zr-apk file content:
700
701| # Relative path to the staging directory. If not set uses ./apk if it exists
702| # or the parent dir .. if it is named something.apk
703| apk-dir = ./apk
704|
705| # Sign using the debug key. Note that if ZR_APK_KEYSTORE or ZR_APK_KEY_ALIAS are not
706| # set the APK is also signed using the debug key.
707| debug = true
708|
709| # Don't sign and don't zipalign the APK. This outputs an incomplete package that
710| # cannot be installed, but can be modified such as custom linking and signing.
711| raw = true
712|
713| # Don't tar assets. By default `assets/res` are packed as `assets/res.tar`
714| # for use with `android_install_res`.
715| tar-assets-res = false
716
717APK signing is configured using these environment variables:
718
719ZR_APK_KEYSTORE - path to the private .keystore file
720ZR_APK_KEYSTORE_PASS - keystore file password
721ZR_APK_KEY_ALIAS - key name in the keystore
722ZR_APK_KEY_PASS - key password
723"#;
724fn apk() {
725    help(APK_HELP);
726    if std::env::var(ZR_FINAL).is_err() {
727        println!("zng-res::on-final=");
728        return;
729    }
730
731    // read config
732    let mut apk_dir = String::new();
733    let mut debug = false;
734    let mut raw = false;
735    let mut tar_assets = true;
736    for line in read_lines(&path(ZR_REQUEST)) {
737        let (ln, line) = line.unwrap_or_else(|e| fatal!("error reading .zr-apk request, {e}"));
738        if let Some((key, value)) = line.split_once('=') {
739            let key = key.trim();
740            let value = value.trim();
741
742            let bool_value = || match value {
743                "true" => true,
744                "false" => false,
745                _ => {
746                    error!("unexpected value, line {ln}\n   {line}");
747                    false
748                }
749            };
750            match key {
751                "apk-dir" => apk_dir = value.to_owned(),
752                "debug" => debug = bool_value(),
753                "raw" => raw = bool_value(),
754                "tar-assets" => tar_assets = bool_value(),
755                _ => error!("unknown key, line {ln}\n   {line}"),
756            }
757        } else {
758            error!("syntax error, line {ln}\n{line}");
759        }
760    }
761    let mut keystore = PathBuf::from(env::var("ZR_APK_KEYSTORE").unwrap_or_default());
762    let mut keystore_pass = env::var("ZR_APK_KEYSTORE_PASS").unwrap_or_default();
763    let mut key_alias = env::var("ZR_APK_KEY_ALIAS").unwrap_or_default();
764    let mut key_pass = env::var("ZR_APK_KEY_PASS").unwrap_or_default();
765    if keystore.as_os_str().is_empty() || key_alias.is_empty() {
766        debug = true;
767    }
768
769    let mut apk_folder = path(ZR_TARGET_DD);
770    let output_file;
771    if apk_dir.is_empty() {
772        let apk = apk_folder.join("apk");
773        if apk.exists() {
774            apk_folder = apk;
775            output_file = path(ZR_TARGET).with_extension("apk");
776        } else if apk_folder.extension().map(|e| e.eq_ignore_ascii_case("apk")).unwrap_or(false) {
777            output_file = apk_folder.clone();
778        } else {
779            fatal!("missing ./apk")
780        }
781    } else {
782        apk_folder = apk_folder.join(apk_dir);
783        if !apk_folder.is_dir() {
784            fatal!("{} not found or not a directory", apk_folder.display());
785        }
786        output_file = path(ZR_TARGET).with_extension("apk");
787    }
788    let apk_folder = apk_folder;
789
790    // find <sdk>/build-tools
791    let android_home = match env::var("ANDROID_HOME") {
792        Ok(h) if !h.is_empty() => h,
793        _ => fatal!("please set ANDROID_HOME to the android-sdk dir"),
794    };
795    let build_tools = Path::new(&android_home).join("build-tools/");
796    let mut best_build = None;
797    let mut best_version = semver::Version::new(0, 0, 0);
798
799    #[cfg(not(windows))]
800    const AAPT2_NAME: &str = "aapt2";
801    #[cfg(windows)]
802    const AAPT2_NAME: &str = "aapt2.exe";
803
804    for dir in fs::read_dir(build_tools).unwrap_or_else(|e| fatal!("cannot read $ANDROID_HOME/build-tools/, {e}")) {
805        let dir = dir
806            .unwrap_or_else(|e| fatal!("cannot read $ANDROID_HOME/build-tools/ entry, {e}"))
807            .path();
808
809        if let Some(ver) = dir
810            .file_name()
811            .and_then(|f| f.to_str())
812            .and_then(|f| semver::Version::parse(f).ok())
813            && ver > best_version
814            && dir.join(AAPT2_NAME).exists()
815        {
816            best_build = Some(dir);
817            best_version = ver;
818        }
819    }
820    let build_tools = match best_build {
821        Some(p) => p,
822        None => fatal!("cannot find $ANDROID_HOME/build-tools/<version>/{AAPT2_NAME}"),
823    };
824    let aapt2_path = build_tools.join(AAPT2_NAME);
825
826    // temp target dir
827    let temp_dir = apk_folder.with_extension("apk.tmp");
828    let _ = fs::remove_dir_all(&temp_dir);
829    fs::create_dir(&temp_dir).unwrap_or_else(|e| fatal!("cannot create {}, {e}", temp_dir.display()));
830
831    // tar assets
832    let assets = apk_folder.join("assets");
833    let assets_res = assets.join("res");
834    if tar_assets && assets_res.exists() {
835        let tar_path = assets.join("res.tar");
836        let r = Command::new("tar")
837            .arg("-cf")
838            .arg(&tar_path)
839            .arg("res")
840            .current_dir(&assets)
841            .status();
842        match r {
843            Ok(s) => {
844                if !s.success() {
845                    fatal!("tar failed")
846                }
847            }
848            Err(e) => fatal!("cannot run 'tar', {e}"),
849        }
850        if let Err(e) = fs::remove_dir_all(&assets_res) {
851            fatal!("failed tar-assets-res cleanup, {e}")
852        }
853    }
854
855    // build resources
856    let compiled_res = temp_dir.join("compiled_res.zip");
857    let res = apk_folder.join("res");
858    if res.exists() {
859        let mut aapt2 = Command::new(&aapt2_path);
860        aapt2.arg("compile").arg("-o").arg(&compiled_res).arg("--dir").arg(res);
861
862        if aapt2.status().map(|s| !s.success()).unwrap_or(true) {
863            fatal!("resources build failed");
864        }
865    }
866
867    let manifest_path = apk_folder.join("AndroidManifest.xml");
868    let manifest = fs::read_to_string(&manifest_path).unwrap_or_else(|e| fatal!("cannot read AndroidManifest.xml, {e}"));
869    let manifest: AndroidManifest = quick_xml::de::from_str(&manifest).unwrap_or_else(|e| fatal!("error parsing AndroidManifest.xml, {e}"));
870
871    // find <sdk>/platforms
872    let platforms = Path::new(&android_home).join("platforms");
873    let mut best_platform = None;
874    let mut best_version = 0;
875    for dir in fs::read_dir(platforms).unwrap_or_else(|e| fatal!("cannot read $ANDROID_HOME/platforms/, {e}")) {
876        let dir = dir
877            .unwrap_or_else(|e| fatal!("cannot read $ANDROID_HOME/platforms/ entry, {e}"))
878            .path();
879
880        if let Some(ver) = dir
881            .file_name()
882            .and_then(|f| f.to_str())
883            .and_then(|f| f.strip_prefix("android-"))
884            .and_then(|f| f.parse().ok())
885            && manifest.uses_sdk.matches(ver)
886            && ver > best_version
887            && dir.join("android.jar").exists()
888        {
889            best_platform = Some(dir);
890            best_version = ver;
891        }
892    }
893    let platform = match best_platform {
894        Some(p) => p,
895        None => fatal!("cannot find $ANDROID_HOME/platforms/<version>/android.jar"),
896    };
897
898    // make apk (link)
899    let apk_path = temp_dir.join("output.apk");
900    let mut aapt2 = Command::new(&aapt2_path);
901    aapt2
902        .arg("link")
903        .arg("-o")
904        .arg(&apk_path)
905        .arg("--manifest")
906        .arg(manifest_path)
907        .arg("-I")
908        .arg(platform.join("android.jar"));
909    if compiled_res.exists() {
910        aapt2.arg(&compiled_res);
911    }
912    if assets.exists() {
913        aapt2.arg("-A").arg(&assets);
914    }
915    if aapt2.status().map(|s| !s.success()).unwrap_or(true) {
916        fatal!("apk linking failed");
917    }
918
919    // add libs
920    let aapt_path = build_tools.join("aapt");
921    for lib in glob::glob(apk_folder.join("lib/*/*.so").display().to_string().as_str()).unwrap() {
922        let lib = lib.unwrap_or_else(|e| fatal!("error searching libs, {e}"));
923
924        let lib = lib.display().to_string().replace('\\', "/");
925        let lib = &lib[lib.rfind("/lib/").unwrap() + 1..];
926
927        let mut aapt = Command::new(&aapt_path);
928        aapt.arg("add").arg(&apk_path).arg(lib).current_dir(&apk_folder);
929        if aapt.status().map(|s| !s.success()).unwrap_or(true) {
930            fatal!("apk linking failed");
931        }
932    }
933
934    let final_apk = if raw {
935        apk_path
936    } else {
937        // align
938        let aligned_apk_path = temp_dir.join("output-aligned.apk");
939        let zipalign_path = build_tools.join("zipalign");
940        let mut zipalign = Command::new(zipalign_path);
941        zipalign.arg("-v").arg("4").arg(apk_path).arg(&aligned_apk_path);
942        if zipalign.status().map(|s| !s.success()).unwrap_or(true) {
943            fatal!("zipalign failed");
944        }
945
946        // sign
947        let signed_apk_path = temp_dir.join("output-signed.apk");
948        if debug {
949            let dirs = directories::BaseDirs::new().unwrap_or_else(|| fatal!("cannot fine $HOME"));
950            keystore = dirs.home_dir().join(".android/debug.keystore");
951            keystore_pass = "android".to_owned();
952            key_alias = "androiddebugkey".to_owned();
953            key_pass = "android".to_owned();
954            if !keystore.exists() {
955                // generate debug.keystore
956                let _ = fs::create_dir_all(keystore.parent().unwrap());
957                let keytool_path = Path::new(&env::var("JAVA_HOME").expect("please set JAVA_HOME")).join("bin/keytool");
958                let mut keytool = Command::new(&keytool_path);
959                keytool
960                    .arg("-genkey")
961                    .arg("-v")
962                    .arg("-keystore")
963                    .arg(&keystore)
964                    .arg("-storepass")
965                    .arg(&keystore_pass)
966                    .arg("-alias")
967                    .arg(&key_alias)
968                    .arg("-keypass")
969                    .arg(&key_pass)
970                    .arg("-keyalg")
971                    .arg("RSA")
972                    .arg("-keysize")
973                    .arg("2048")
974                    .arg("-validity")
975                    .arg("10000")
976                    .arg("-dname")
977                    .arg("CN=Android Debug,O=Android,C=US")
978                    .arg("-storetype")
979                    .arg("pkcs12");
980
981                match keytool.status() {
982                    Ok(s) => {
983                        if !s.success() {
984                            fatal!("keytool failed generating debug keys");
985                        }
986                    }
987                    Err(e) => fatal!("cannot run '{}', {e}", keytool_path.display()),
988                }
989            }
990        }
991
992        #[cfg(not(windows))]
993        const APKSIGNER_NAME: &str = "apksigner";
994        #[cfg(windows)]
995        const APKSIGNER_NAME: &str = "apksigner.bat";
996
997        let apksigner_path = build_tools.join(APKSIGNER_NAME);
998        let mut apksigner = Command::new(&apksigner_path);
999        apksigner
1000            .arg("sign")
1001            .arg("--ks")
1002            .arg(keystore)
1003            .arg("--ks-pass")
1004            .arg(format!("pass:{keystore_pass}"))
1005            .arg("--ks-key-alias")
1006            .arg(key_alias)
1007            .arg("--key-pass")
1008            .arg(format!("pass:{key_pass}"))
1009            .arg("--out")
1010            .arg(&signed_apk_path)
1011            .arg(&aligned_apk_path);
1012
1013        match apksigner.status() {
1014            Ok(s) => {
1015                if !s.success() {
1016                    fatal!("apksigner failed")
1017                }
1018            }
1019            Err(e) => fatal!("cannot run '{}', {e}", apksigner_path.display()),
1020        }
1021        signed_apk_path
1022    };
1023
1024    // finalize
1025    fs::remove_dir_all(&apk_folder).unwrap_or_else(|e| fatal!("apk folder cleanup failed, {e}"));
1026    fs::rename(final_apk, output_file).unwrap_or_else(|e| fatal!("cannot copy built apk to final place, {e}"));
1027    fs::remove_dir_all(&temp_dir).unwrap_or_else(|e| fatal!("temp dir cleanup failed, {e}"));
1028    let _ = fs::remove_file(path(ZR_TARGET));
1029}
1030#[derive(serde::Deserialize)]
1031#[serde(rename = "manifest")]
1032struct AndroidManifest {
1033    #[serde(rename = "uses-sdk")]
1034    #[serde(default)]
1035    pub uses_sdk: AndroidSdk,
1036}
1037#[derive(Default, serde::Deserialize)]
1038#[serde(rename = "uses-sdk")]
1039struct AndroidSdk {
1040    #[serde(rename(serialize = "android:minSdkVersion"))]
1041    pub min_sdk_version: Option<u32>,
1042    #[serde(rename(serialize = "android:targetSdkVersion"))]
1043    pub target_sdk_version: Option<u32>,
1044    #[serde(rename(serialize = "android:maxSdkVersion"))]
1045    pub max_sdk_version: Option<u32>,
1046}
1047impl AndroidSdk {
1048    pub fn matches(&self, version: u32) -> bool {
1049        if let Some(v) = self.target_sdk_version {
1050            return v == version;
1051        }
1052        if let Some(m) = self.min_sdk_version
1053            && version < m
1054        {
1055            return false;
1056        }
1057        if let Some(m) = self.max_sdk_version
1058            && version > m
1059        {
1060            return false;
1061        }
1062        true
1063    }
1064}
1065
1066fn read_line(path: &Path, expected: &str) -> io::Result<String> {
1067    match read_lines(path).next() {
1068        Some(r) => r.map(|(_, l)| l),
1069        None => Err(io::Error::new(
1070            io::ErrorKind::InvalidInput,
1071            format!("expected {expected} in tool file content"),
1072        )),
1073    }
1074}
1075
1076fn read_lines(path: &Path) -> impl Iterator<Item = io::Result<(usize, String)>> {
1077    enum State {
1078        Open(io::Result<fs::File>),
1079        Lines(usize, io::Lines<io::BufReader<fs::File>>),
1080        End,
1081    }
1082    // start -> open
1083    let mut state = State::Open(fs::File::open(path));
1084    std::iter::from_fn(move || {
1085        loop {
1086            match std::mem::replace(&mut state, State::End) {
1087                State::Lines(count, mut lines) => {
1088                    if let Some(l) = lines.next() {
1089                        match l {
1090                            // lines -> lines
1091                            Ok(l) => {
1092                                state = State::Lines(count + 1, lines);
1093                                let test = l.trim();
1094                                if !test.is_empty() && !test.starts_with('#') {
1095                                    return Some(Ok((count, l)));
1096                                }
1097                            }
1098                            // lines -> end
1099                            Err(e) => {
1100                                return Some(Err(e));
1101                            }
1102                        }
1103                    }
1104                }
1105                State::Open(r) => match r {
1106                    // open -> lines
1107                    Ok(f) => state = State::Lines(1, io::BufReader::new(f).lines()),
1108                    // open -> end
1109                    Err(e) => return Some(Err(e)),
1110                },
1111                // end -> end
1112                State::End => return None,
1113            }
1114        }
1115    })
1116}
1117
1118fn read_path(request_file: &Path) -> io::Result<PathBuf> {
1119    read_line(request_file, "path").map(PathBuf::from)
1120}
1121
1122fn copy_dir_all(from: &Path, to: &Path, trace: bool) {
1123    for entry in walkdir::WalkDir::new(from).min_depth(1).max_depth(1).sort_by_file_name() {
1124        let entry = entry.unwrap_or_else(|e| fatal!("cannot walkdir entry `{}`, {e}", from.display()));
1125        let from = entry.path();
1126        let to = to.join(entry.file_name());
1127        if entry.file_type().is_dir() {
1128            fs::create_dir(&to).unwrap_or_else(|e| {
1129                if e.kind() != io::ErrorKind::AlreadyExists {
1130                    fatal!("cannot create_dir `{}`, {e}", to.display())
1131                }
1132            });
1133            if trace {
1134                println!("{}", display_path(&to));
1135            }
1136            copy_dir_all(from, &to, trace);
1137        } else if entry.file_type().is_file() {
1138            fs::copy(from, &to).unwrap_or_else(|e| fatal!("cannot copy `{}` to `{}`, {e}", from.display(), to.display()));
1139            if trace {
1140                println!("{}", display_path(&to));
1141            }
1142        } else if entry.file_type().is_symlink() {
1143            symlink_warn(entry.path())
1144        }
1145    }
1146}
1147
1148pub(crate) fn symlink_warn(path: &Path) {
1149    warn!("symlink ignored in `{}`, use zr-tools to 'link'", path.display());
1150}
1151
1152pub const ENV_TOOL: &str = "ZNG_RES_TOOL";
1153
1154macro_rules! built_in {
1155    ($($tool:tt),+ $(,)?) => {
1156        pub static BUILT_INS: &[&str] = &[
1157            $(stringify!($tool),)+
1158        ];
1159        static BUILT_IN_FNS: &[fn()] = &[
1160            $($tool,)+
1161        ];
1162    };
1163}
1164built_in! { copy, glob, rp, sh, shf, warn, fail, apk }
1165
1166pub fn run() {
1167    if let Ok(tool) = env::var(ENV_TOOL) {
1168        if let Some(i) = BUILT_INS.iter().position(|n| *n == tool.as_str()) {
1169            (BUILT_IN_FNS[i])();
1170            std::process::exit(0);
1171        } else {
1172            fatal!("`tool` is not a built-in tool");
1173        }
1174    }
1175}
1176
1177#[cfg(test)]
1178mod tests {
1179    use super::*;
1180
1181    #[test]
1182    fn replace_tests() {
1183        unsafe {
1184            // SAFETY: potentially not safe as tests run in parallel and I don't want to audit every C dep
1185            // of code that runs in other tests. If a segfault happen during test run caused by this I intend
1186            // to print the test runner log and frame it.
1187            std::env::set_var("ZR_RP_TEST", "test value");
1188        }
1189
1190        assert_eq!("", replace("", 0).unwrap());
1191        assert_eq!("normal text", replace("normal text", 0).unwrap());
1192        assert_eq!("escaped ${NOT}", replace("escaped $${NOT}", 0).unwrap());
1193        assert_eq!("replace 'test value'", replace("replace '${ZR_RP_TEST}'", 0).unwrap());
1194        assert_eq!("${} cannot be read or is empty", replace("empty '${}'", 0).unwrap_err()); // hmm
1195        assert_eq!(
1196            "${ZR_RP_TEST_NOT_SET} cannot be read or is empty",
1197            replace("not set '${ZR_RP_TEST_NOT_SET}'", 0).unwrap_err()
1198        );
1199        assert_eq!(
1200            "not set 'fallback!'",
1201            replace("not set '${ZR_RP_TEST_NOT_SET:?fallback!}'", 0).unwrap()
1202        );
1203        assert_eq!(
1204            "not set 'nested 'test value'.'",
1205            replace("not set '${ZR_RP_TEST_NOT_SET:?nested '${ZR_RP_TEST}'.}'", 0).unwrap()
1206        );
1207        assert_eq!("test value", replace("${ZR_RP_TEST_NOT_SET:?${ZR_RP_TEST}}", 0).unwrap());
1208        assert_eq!(
1209            "curly test value",
1210            replace("curly ${ZR_RP_TEST:?{not {what} {is} {going {on {here {:?}}}}}}", 0).unwrap()
1211        );
1212
1213        assert_eq!("replace not closed at: ${MISSING", replace("${MISSING", 0).unwrap_err());
1214        assert_eq!("replace not closed at: ${MIS", replace("${MIS", 0).unwrap_err());
1215        assert_eq!("replace not closed at: ${MIS:?{", replace("${MIS:?{", 0).unwrap_err());
1216        assert_eq!("replace not closed at: ${MIS:?{}", replace("${MIS:?{}", 0).unwrap_err());
1217
1218        assert_eq!("TEST VALUE", replace("${ZR_RP_TEST:U}", 0).unwrap());
1219        assert_eq!("TEST-VALUE", replace("${ZR_RP_TEST:K}", 0).unwrap());
1220        assert_eq!("TEST_VALUE", replace("${ZR_RP_TEST:S}", 0).unwrap());
1221        assert_eq!("testValue", replace("${ZR_RP_TEST:c}", 0).unwrap());
1222    }
1223
1224    #[test]
1225    fn replace_cmd_case() {
1226        assert_eq!("cmd HELLO:?WORLD", replace("cmd ${!printf \"hello:?world\":U}", 0).unwrap(),)
1227    }
1228}