OXYGENHUB
◆ v0.5.1 oxygenflow / crates / flow-checks / src / main.rs
8.3 KiB · Rust · last changed The cheap checks run as WASM: layout, todos, size and summary are one WASI module (crates/flow-checks, .flow/checks.wasm), so any runner with the WASM tier takes them; the fmt job checks the module is what its source builds; the build image gains wasm32-wasip1 (re-pinned e52c41a1) (closes #96) 8 days ago source blame history raw
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
26
fn main() -> ExitCode {
27
    let args: Vec<String> = std::env::args().skip(1).collect();
28
    let root = Path::new(".");
29
    let mut out = io::stdout().lock();
30
    let done = match args.split_first() {
31
        Some((cmd, rest)) => match cmd.as_str() {
32
            "layout" => layout(root, rest, &mut out),
33
            "todos" => todos(root, rest, &mut out),
34
            "size" => match rest {
35
                [dir] => size(root, dir, &mut out),
36
                _ => Err(USAGE.to_string()),
37
            },
38
            "summary" => summary(std::env::var("PROJECT").ok().as_deref(), &mut out),
39
            _ => Err(USAGE.to_string()),
40
        },
41
        None => Err(USAGE.to_string()),
42
    };
43
    match done {
44
        Ok(()) => ExitCode::SUCCESS,
45
        Err(e) => {
46
            eprintln!("{e}");
47
            ExitCode::FAILURE
48
        }
49
    }
50
}
51
52
type Done = Result<(), String>;
53
54
fn emit(out: &mut impl Write, line: &str) -> Done {
55
    writeln!(out, "{line}").map_err(|e| format!("write: {e}"))
56
}
57
58
/// The workspace has the files it should.
59
fn layout(root: &Path, paths: &[String], out: &mut impl Write) -> Done {
60
    if paths.is_empty() {
61
        return Err(USAGE.to_string());
62
    }
63
    for p in paths {
64
        if !root.join(p).is_file() {
65
            return Err(format!("missing: {p}"));
66
        }
67
        emit(out, &format!("found {p}"))?;
68
    }
69
    Ok(())
70
}
71
72
/// Notes left in the code, for the log.
73
fn todos(root: &Path, dirs: &[String], out: &mut impl Write) -> Done {
74
    if dirs.is_empty() {
75
        return Err(USAGE.to_string());
76
    }
77
    let mut noted = Vec::new();
78
    for d in dirs {
79
        for file in files(&root.join(d))? {
80
            // Text only, as grep reports a binary file rather than its lines.
81
            let Ok(text) = fs::read_to_string(&file) else { continue };
82
            let name = shown(root, &file);
83
            for (i, line) in text.lines().enumerate() {
84
                if MARKERS.iter().any(|m| line.contains(m)) {
85
                    noted.push(format!("{name}:{}:{line}", i + 1));
86
                }
87
            }
88
        }
89
    }
90
    emit(out, &format!("{} {} notes in {}", noted.len(), MARKERS.join("/"), shown_dirs(dirs)))?;
91
    for line in noted.iter().take(LISTED) {
92
        emit(out, line)?;
93
    }
94
    Ok(())
95
}
96
97
/// Lines of Rust per crate: each directory directly under `dir`.
98
fn size(root: &Path, dir: &str, out: &mut impl Write) -> Done {
99
    for krate in entries(&root.join(dir))?.into_iter().filter(|p| p.is_dir()) {
100
        let mut lines = 0;
101
        for file in files(&krate)?.into_iter().filter(|f| f.extension().is_some_and(|e| e == "rs")) {
102
            let bytes = fs::read(&file).map_err(|e| format!("{}: {e}", file.display()))?;
103
            lines += bytes.iter().filter(|&&b| b == b'\n').count();
104
        }
105
        emit(out, &format!("{:<24} {lines:>6} lines", format!("{}/", shown(root, &krate))))?;
106
    }
107
    Ok(())
108
}
109
110
fn summary(project: Option<&str>, out: &mut impl Write) -> Done {
111
    emit(out, &format!("{}: every check passed on this state.", project.unwrap_or("This project")))
112
}
113
114
/// A directory's entries, sorted by name so every runner prints the same.
115
fn entries(dir: &Path) -> Result<Vec<PathBuf>, String> {
116
    let read = fs::read_dir(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
117
    let mut all = read.map(|e| e.map(|e| e.path())).collect::<Result<Vec<_>, _>>().map_err(|e| format!("{}: {e}", dir.display()))?;
118
    all.sort();
119
    Ok(all)
120
}
121
122
/// Every file under `dir`, depth first, in name order; links are not followed.
123
fn files(dir: &Path) -> Result<Vec<PathBuf>, String> {
124
    let mut found = Vec::new();
125
    for p in entries(dir)? {
126
        let kind = fs::symlink_metadata(&p).map_err(|e| format!("{}: {e}", p.display()))?.file_type();
127
        if kind.is_dir() {
128
            found.extend(files(&p)?);
129
        } else if kind.is_file() {
130
            found.push(p);
131
        }
132
    }
133
    Ok(found)
134
}
135
136
/// A path as the workspace names it, with `/` between its parts.
137
fn shown(root: &Path, p: &Path) -> String {
138
    let rel = p.strip_prefix(root).unwrap_or(p);
139
    rel.components().map(|c| c.as_os_str().to_string_lossy()).collect::<Vec<_>>().join("/")
140
}
141
142
fn shown_dirs(dirs: &[String]) -> String {
143
    dirs.iter().map(|d| format!("{}/", d.trim_end_matches('/'))).collect::<Vec<_>>().join(" ")
144
}
145
146
#[cfg(test)]
147
mod tests {
148
    use super::*;
149
150
    fn tree(files: &[(&str, &str)]) -> tempfile::TempDir {
151
        let dir = tempfile::tempdir().unwrap();
152
        for (path, text) in files {
153
            let p = dir.path().join(path);
154
            fs::create_dir_all(p.parent().unwrap()).unwrap();
155
            fs::write(p, text).unwrap();
156
        }
157
        dir
158
    }
159
160
    fn run(f: impl FnOnce(&mut Vec<u8>) -> Done) -> (Done, String) {
161
        let mut out = Vec::new();
162
        let done = f(&mut out);
163
        (done, String::from_utf8(out).unwrap())
164
    }
165
166
    fn args(a: &[&str]) -> Vec<String> {
167
        a.iter().map(|s| s.to_string()).collect()
168
    }
169
170
    #[test]
171
    fn layout_lists_what_it_found_and_fails_on_the_first_missing() {
172
        let t = tree(&[("Cargo.toml", ""), ("docs/m0.md", "")]);
173
        let (done, out) = run(|o| layout(t.path(), &args(&["Cargo.toml", "docs/m0.md"]), o));
174
        assert_eq!(done, Ok(()));
175
        assert_eq!(out, "found Cargo.toml\nfound docs/m0.md\n");
176
        let (done, out) = run(|o| layout(t.path(), &args(&["Cargo.toml", "nope.rs", "docs/m0.md"]), o));
177
        assert_eq!(done, Err("missing: nope.rs".to_string()));
178
        assert_eq!(out, "found Cargo.toml\n");
179
        // A directory is not the file asked for.
180
        assert!(layout(t.path(), &args(&["docs"]), &mut Vec::new()).is_err());
181
    }
182
183
    #[test]
184
    fn todos_counts_every_marked_line_and_lists_the_first_twenty() {
185
        let marker = MARKERS[0];
186
        let many: String = (0..25).map(|i| format!("// {marker} {i}\n")).collect();
187
        let t = tree(&[
188
            ("crates/a/src/lib.rs", &format!("fn a() {{}}\n// {} later\n", MARKERS[1])),
189
            ("crates/b/src/many.rs", &many),
190
            ("crates/b/blob.bin", "\u{0}"),
191
        ]);
192
        // Invalid UTF-8 is skipped, as grep skips a binary file's lines.
193
        fs::write(t.path().join("crates/b/blob.bin"), [0xff, 0xfe, b'T']).unwrap();
194
        let (done, out) = run(|o| todos(t.path(), &args(&["crates"]), o));
195
        assert_eq!(done, Ok(()));
196
        let lines: Vec<&str> = out.lines().collect();
197
        assert_eq!(lines[0], format!("26 {} notes in crates/", MARKERS.join("/")));
198
        assert_eq!(lines[1], format!("crates/a/src/lib.rs:2:// {} later", MARKERS[1]));
199
        assert_eq!(lines[2], format!("crates/b/src/many.rs:1:// {marker} 0"));
200
        assert_eq!(lines.len(), 1 + LISTED);
201
    }
202
203
    #[test]
204
    fn size_counts_rust_lines_per_crate_in_name_order() {
205
        let t = tree(&[
206
            ("crates/zeta/src/main.rs", "a\nb\nc\n"),
207
            ("crates/alpha/src/lib.rs", "a\n"),
208
            ("crates/alpha/src/deep/mod.rs", "a\nb\n"),
209
            ("crates/alpha/README.md", "not rust\n"),
210
            ("crates/loose.txt", "a file, not a crate\n"),
211
        ]);
212
        let (done, out) = run(|o| size(t.path(), "crates", o));
213
        assert_eq!(done, Ok(()));
214
        assert_eq!(out, format!("{:<24} {:>6} lines\n{:<24} {:>6} lines\n", "crates/alpha/", 3, "crates/zeta/", 3));
215
    }
216
217
    #[test]
218
    fn summary_names_the_project() {
219
        let (_, out) = run(|o| summary(Some("OxygenFlow"), o));
220
        assert_eq!(out, "OxygenFlow: every check passed on this state.\n");
221
    }
222
}