OXYGENHUB

Coverage / crates/flow-checks/src/main.rs 84.0%

142 of 169 lines run, 36 of 42 functions · run #217 on main · run missed

1
//! OxygenFlow's cheap checks as one WASI module (#96): what
2
//! `.flow/jobs/{layout,todos,size,summary}.sh` did in an Alpine microVM, so
3
//! that any runner offering the WASM tier (a phone, plex, windows-1) can take
4
//! them. The job's files are preopened as `/src`, which is also the current
5
//! directory, so every path here is relative to the workspace.
6
//!
7
//! - `layout PATH...`: each path is a file; fails naming the first missing.
8
//! - `todos DIR...`: the lines holding a note marker, counted, the first 20
9
//!   listed; never fails.
10
//! - `size DIR`: lines of Rust in each crate directory under DIR.
11
//! - `summary`: one line naming `$PROJECT`.
12
13
use std::fs;
14
use std::io::{self, Write};
15
use std::path::{Path, PathBuf};
16
use std::process::ExitCode;
17
18
/// The note markers, spelt in pieces so this file does not count itself.
19
const MARKERS: [&str; 3] = [concat!("TO", "DO"), concat!("FIX", "ME"), concat!("X", "XX")];
20
21
/// How many noted lines `todos` lists.
22
const LISTED: usize = 20;
23
24
const USAGE: &str = "usage: flow-checks layout PATH... | todos DIR... | size DIR | summary";
25
260×
fn main() -> ExitCode {
270×
    let args: Vec<String> = std::env::args().skip(1).collect();
280×
    let root = Path::new(".");
290×
    let mut out = io::stdout().lock();
300×
    let done = match args.split_first() {
310×
        Some((cmd, rest)) => match cmd.as_str() {
320×
            "layout" => layout(root, rest, &mut out),
330×
            "todos" => todos(root, rest, &mut out),
340×
            "size" => match rest {
350×
                [dir] => size(root, dir, &mut out),
360×
                _ => Err(USAGE.to_string()),
37
            },
380×
            "summary" => summary(std::env::var("PROJECT").ok().as_deref(), &mut out),
390×
            _ => Err(USAGE.to_string()),
40
        },
410×
        None => Err(USAGE.to_string()),
42
    };
430×
    match done {
440×
        Ok(()) => ExitCode::SUCCESS,
450×
        Err(e) => {
460×
            eprintln!("{e}");
470×
            ExitCode::FAILURE
48
        }
49
    }
500×
}
51
52
type Done = Result<(), String>;
53
5427×
fn emit(out: &mut impl Write, line: &str) -> Done {
5527×
    writeln!(out, "{line}").map_err(|e| format!("write: {e}"))
5627×
}
57
58
/// The workspace has the files it should.
593×
fn layout(root: &Path, paths: &[String], out: &mut impl Write) -> Done {
603×
    if paths.is_empty() {
610×
        return Err(USAGE.to_string());
623×
    }
635×
    for p in paths {
645×
        if !root.join(p).is_file() {
652×
            return Err(format!("missing: {p}"));
663×
        }
673×
        emit(out, &format!("found {p}"))?;
68
    }
691×
    Ok(())
703×
}
71
72
/// Notes left in the code, for the log.
731×
fn todos(root: &Path, dirs: &[String], out: &mut impl Write) -> Done {
741×
    if dirs.is_empty() {
750×
        return Err(USAGE.to_string());
761×
    }
771×
    let mut noted = Vec::new();
781×
    for d in dirs {
793×
        for file in files(&root.join(d))? {
80
            // Text only, as grep reports a binary file rather than its lines.
813×
            let Ok(text) = fs::read_to_string(&file) else { continue };
822×
            let name = shown(root, &file);
8327×
            for (i, line) in text.lines().enumerate() {
8430×
                if MARKERS.iter().any(|m| line.contains(m)) {
8526×
                    noted.push(format!("{name}:{}:{line}", i + 1));
8626×
                }
87
            }
88
        }
89
    }
901×
    emit(out, &format!("{} {} notes in {}", noted.len(), MARKERS.join("/"), shown_dirs(dirs)))?;
9120×
    for line in noted.iter().take(LISTED) {
9220×
        emit(out, line)?;
93
    }
941×
    Ok(())
951×
}
96
97
/// Lines of Rust per crate: each directory directly under `dir`.
981×
fn size(root: &Path, dir: &str, out: &mut impl Write) -> Done {
993×
    for krate in entries(&root.join(dir))?.into_iter().filter(|p| p.is_dir()) {
1002×
        let mut lines = 0;
1014×
        for file in files(&krate)?.into_iter().filter(|f| f.extension().is_some_and(|e| e == "rs")) {
1023×
            let bytes = fs::read(&file).map_err(|e| format!("{}: {e}", file.display()))?;
10312×
            lines += bytes.iter().filter(|&&b| b == b'\n').count();
104
        }
1052×
        emit(out, &format!("{:<24} {lines:>6} lines", format!("{}/", shown(root, &krate))))?;
106
    }
1071×
    Ok(())
1081×
}
109
1101×
fn summary(project: Option<&str>, out: &mut impl Write) -> Done {
1111×
    emit(out, &format!("{}: every check passed on this state.", project.unwrap_or("This project")))
1121×
}
113
114
/// A directory's entries, sorted by name so every runner prints the same.
11511×
fn entries(dir: &Path) -> Result<Vec<PathBuf>, String> {
11611×
    let read = fs::read_dir(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
11717×
    let mut all = read.map(|e| e.map(|e| e.path())).collect::<Result<Vec<_>, _>>().map_err(|e| format!("{}: {e}", dir.display()))?;
11811×
    all.sort();
11911×
    Ok(all)
12011×
}
121
122
/// Every file under `dir`, depth first, in name order; links are not followed.
12310×
fn files(dir: &Path) -> Result<Vec<PathBuf>, String> {
12410×
    let mut found = Vec::new();
12514×
    for p in entries(dir)? {
12614×
        let kind = fs::symlink_metadata(&p).map_err(|e| format!("{}: {e}", p.display()))?.file_type();
12714×
        if kind.is_dir() {
1287×
            found.extend(files(&p)?);
1297×
        } else if kind.is_file() {
1307×
            found.push(p);
1317×
        }
132
    }
13310×
    Ok(found)
13410×
}
135
136
/// A path as the workspace names it, with `/` between its parts.
1374×
fn shown(root: &Path, p: &Path) -> String {
1384×
    let rel = p.strip_prefix(root).unwrap_or(p);
13912×
    rel.components().map(|c| c.as_os_str().to_string_lossy()).collect::<Vec<_>>().join("/")
1404×
}
141
1421×
fn shown_dirs(dirs: &[String]) -> String {
1431×
    dirs.iter().map(|d| format!("{}/", d.trim_end_matches('/'))).collect::<Vec<_>>().join(" ")
1441×
}
145
146
#[cfg(test)]
147
mod tests {
148
    use super::*;
149
1503×
    fn tree(files: &[(&str, &str)]) -> tempfile::TempDir {
1513×
        let dir = tempfile::tempdir().unwrap();
15210×
        for (path, text) in files {
15310×
            let p = dir.path().join(path);
15410×
            fs::create_dir_all(p.parent().unwrap()).unwrap();
15510×
            fs::write(p, text).unwrap();
15610×
        }
1573×
        dir
1583×
    }
159
1605×
    fn run(f: impl FnOnce(&mut Vec<u8>) -> Done) -> (Done, String) {
1615×
        let mut out = Vec::new();
1625×
        let done = f(&mut out);
1635×
        (done, String::from_utf8(out).unwrap())
1645×
    }
165
1664×
    fn args(a: &[&str]) -> Vec<String> {
1677×
        a.iter().map(|s| s.to_string()).collect()
1684×
    }
169
170
    #[test]
1711×
    fn layout_lists_what_it_found_and_fails_on_the_first_missing() {
1721×
        let t = tree(&[("Cargo.toml", ""), ("docs/m0.md", "")]);
1731×
        let (done, out) = run(|o| layout(t.path(), &args(&["Cargo.toml", "docs/m0.md"]), o));
1741×
        assert_eq!(done, Ok(()));
1751×
        assert_eq!(out, "found Cargo.toml\nfound docs/m0.md\n");
1761×
        let (done, out) = run(|o| layout(t.path(), &args(&["Cargo.toml", "nope.rs", "docs/m0.md"]), o));
1771×
        assert_eq!(done, Err("missing: nope.rs".to_string()));
1781×
        assert_eq!(out, "found Cargo.toml\n");
179
        // A directory is not the file asked for.
1801×
        assert!(layout(t.path(), &args(&["docs"]), &mut Vec::new()).is_err());
1811×
    }
182
183
    #[test]
1841×
    fn todos_counts_every_marked_line_and_lists_the_first_twenty() {
1851×
        let marker = MARKERS[0];
18625×
        let many: String = (0..25).map(|i| format!("// {marker} {i}\n")).collect();
1871×
        let t = tree(&[
1881×
            ("crates/a/src/lib.rs", &format!("fn a() {{}}\n// {} later\n", MARKERS[1])),
1891×
            ("crates/b/src/many.rs", &many),
1901×
            ("crates/b/blob.bin", "\u{0}"),
1911×
        ]);
192
        // Invalid UTF-8 is skipped, as grep skips a binary file's lines.
1931×
        fs::write(t.path().join("crates/b/blob.bin"), [0xff, 0xfe, b'T']).unwrap();
1941×
        let (done, out) = run(|o| todos(t.path(), &args(&["crates"]), o));
1951×
        assert_eq!(done, Ok(()));
1961×
        let lines: Vec<&str> = out.lines().collect();
1971×
        assert_eq!(lines[0], format!("26 {} notes in crates/", MARKERS.join("/")));
1981×
        assert_eq!(lines[1], format!("crates/a/src/lib.rs:2:// {} later", MARKERS[1]));
1991×
        assert_eq!(lines[2], format!("crates/b/src/many.rs:1:// {marker} 0"));
2001×
        assert_eq!(lines.len(), 1 + LISTED);
2011×
    }
202
203
    #[test]
2041×
    fn size_counts_rust_lines_per_crate_in_name_order() {
2051×
        let t = tree(&[
2061×
            ("crates/zeta/src/main.rs", "a\nb\nc\n"),
2071×
            ("crates/alpha/src/lib.rs", "a\n"),
2081×
            ("crates/alpha/src/deep/mod.rs", "a\nb\n"),
2091×
            ("crates/alpha/README.md", "not rust\n"),
2101×
            ("crates/loose.txt", "a file, not a crate\n"),
2111×
        ]);
2121×
        let (done, out) = run(|o| size(t.path(), "crates", o));
2131×
        assert_eq!(done, Ok(()));
2141×
        assert_eq!(out, format!("{:<24} {:>6} lines\n{:<24} {:>6} lines\n", "crates/alpha/", 3, "crates/zeta/", 3));
2151×
    }
216
217
    #[test]
2181×
    fn summary_names_the_project() {
2191×
        let (_, out) = run(|o| summary(Some("OxygenFlow"), o));
2201×
        assert_eq!(out, "OxygenFlow: every check passed on this state.\n");
2211×
    }
222
}