Skip to main content

sofie_cli/scaffold_tests/
mod.rs

1//! A file is never touched once written; new or changed facts get a new file, named by the moment.
2
3mod facts;
4mod link_facts;
5mod requests;
6mod route_facts;
7
8use std::fmt::{self, Write as _};
9use std::fs;
10use std::io::ErrorKind;
11use std::path::{Path, PathBuf};
12
13use sofie_check::Screens;
14use sofie_core::Timestamp;
15use sofie_model::naming::relative_path;
16use sofie_model::{App, Module};
17
18use crate::gen_command::{GenError, io_error};
19use crate::new_command::edits::with_feature_in;
20use crate::new_command::{dependency_line_start, workspace_declaration};
21use crate::rustfmt::format_rust;
22use crate::sorted;
23use crate::source_edits::{
24    DEPENDENCIES, DEV_DEPENDENCIES, Journal, files_with_extension, path_dependency, read,
25    toml_section, with_section_line,
26};
27use facts::{Fact, module_facts};
28
29const DIRECTORY: [&str; 2] = ["tests", "scaffolded"];
30const ROOT_FILE: &str = "main.rs";
31/// Files written before the facts became the `_FACTS` constant list them on these lines.
32const FACT_LINE: &str = "//! fact `";
33const FACTS_CONSTANT: &str = "_FACTS";
34const FILE_MOMENT: &str = "%Y%m%d%H%M%S";
35const HEADER_MOMENT: &str = "%Y-%m-%d %H:%M:%S";
36
37/// A macro, so that constant descriptions can `concat!` it.
38macro_rules! test_places {
39    () => {
40        "Scaffolded tests live in `app/tests/scaffolded/`, one file per run; extend them there; \
41         hand-written tests go in `app/tests/<topic>.rs`. Write each as \
42         `#[test_app] async fn name(app: TestApp)`, as the scaffolded ones are; \
43         `#[tokio::test]` only where `#[test_app]` cannot run it, with a line saying why."
44    };
45}
46pub(crate) use test_places;
47
48#[derive(Debug, Default, PartialEq, Eq)]
49pub struct ScaffoldedTests {
50    pub created: Vec<PathBuf>,
51    pub changed: Vec<PathBuf>,
52    pub superseded: Vec<String>,
53    /// Gaps of the facts this run adds, or of subjects no test covers at all.
54    pub uncovered: Vec<String>,
55    /// Gaps of subjects earlier files already test, left as they were.
56    pub older_uncovered: Vec<String>,
57}
58
59impl ScaffoldedTests {
60    /// The older gaps counted on one line, as an agent asked for this run's tests reads them.
61    pub(crate) fn summary(&self) -> String {
62        let mut text = String::new();
63        let _ = self.write(&mut text, false);
64        text
65    }
66
67    fn write(&self, out: &mut impl fmt::Write, every_gap: bool) -> fmt::Result {
68        for file in &self.created {
69            writeln!(out, "created {}", file.display())?;
70        }
71        for file in &self.changed {
72            writeln!(out, "changed {}", file.display())?;
73        }
74        for test in &self.superseded {
75            writeln!(out, "superseded: {test}")?;
76        }
77        for reason in &self.uncovered {
78            writeln!(out, "not covered: {reason}")?;
79        }
80        if every_gap {
81            for reason in &self.older_uncovered {
82                writeln!(out, "not covered: {reason}")?;
83            }
84        } else if !self.older_uncovered.is_empty() {
85            let gaps = match self.older_uncovered.len() {
86                1 => "1 older gap".to_owned(),
87                count => format!("{count} older gaps"),
88            };
89            writeln!(
90                out,
91                "not covered: {gaps} this run leaves as they were; `scaffold_tests` with `all: true` lists them"
92            )?;
93        }
94        if !self.created.is_empty() {
95            writeln!(out, test_places!())?;
96        }
97        Ok(())
98    }
99}
100
101impl fmt::Display for ScaffoldedTests {
102    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
103        self.write(formatter, true)
104    }
105}
106
107struct Written {
108    file: PathBuf,
109    module: String,
110    facts: Vec<(String, String)>,
111}
112
113pub fn scaffold_tests(root: &Path) -> Result<ScaffoldedTests, GenError> {
114    scaffold_tests_at(root, Timestamp::now())
115}
116
117/// A dry run's moment is not the real run's, so its report names the moment's form instead.
118pub(crate) fn without_moment(report: &str, now: Timestamp) -> String {
119    let undated = report
120        .replace(&now.strftime(FILE_MOMENT).to_string(), "<YYYYMMDDHHMMSS>")
121        .replace(
122            &now.strftime(HEADER_MOMENT).to_string(),
123            "<YYYY-MM-DD HH:MM:SS>",
124        );
125    if undated == report {
126        return undated;
127    }
128    format!(
129        "{undated}\n<YYYYMMDDHHMMSS> is the moment the real run writes the file; it prints the name.\n"
130    )
131}
132
133pub(crate) fn scaffold_tests_at(root: &Path, now: Timestamp) -> Result<ScaffoldedTests, GenError> {
134    let app = sofie_model::load(root).map_err(GenError::Model)?;
135    let directory = DIRECTORY
136        .iter()
137        .fold(app.app.path.clone(), |path, part| path.join(part));
138    let written = read_written(&directory)?;
139    let screens = Screens::read(&app.modules);
140    let mut outcome = ScaffoldedTests::default();
141    let mut journal = Journal::default();
142    let scaffolded = sorted::modules(&app)
143        .into_iter()
144        .try_for_each(|module| {
145            scaffold_module(
146                &app,
147                module,
148                &screens,
149                &directory,
150                &written,
151                now,
152                &mut journal,
153                &mut outcome,
154            )
155        })
156        .and_then(|()| write_root(&directory, &mut journal, &mut outcome))
157        .and_then(|()| add_test_dependencies(&app, &directory, &mut journal, &mut outcome));
158    journal.finish(scaffolded)?;
159    Ok(outcome)
160}
161
162const TESTING_PACKAGE: &str = "sofie-testing";
163const IDENTITY_PACKAGE: &str = "sofie-identity";
164
165/// What the scaffolded files name besides the app and its modules, which a new app lacks.
166fn add_test_dependencies(
167    app: &App,
168    directory: &Path,
169    journal: &mut Journal,
170    outcome: &mut ScaffoldedTests,
171) -> Result<(), GenError> {
172    let mut sources = String::new();
173    for file in files_with_extension(directory, "rs")? {
174        sources.push_str(&fs::read_to_string(&file).map_err(|error| io_error(&file, error))?);
175    }
176    let manifest = app.app.path.join("Cargo.toml");
177    let original = read(&manifest)?;
178    let mut text = with_contracts(app, &sources, &original)?;
179    if sources.contains("sofie_testing::") && !names_dependency(&text, TESTING_PACKAGE) {
180        let line = format!("{TESTING_PACKAGE}.workspace = true\n");
181        text = with_workspace_dev_dependency(app, &text, TESTING_PACKAGE, &line, journal, outcome)?;
182    }
183    let dev_names = |package| {
184        toml_section(&text, DEV_DEPENDENCIES)
185            .is_some_and(|(start, end)| dependency_line_start(&text[start..end], package).is_some())
186    };
187    if sources.contains("sofie_identity::testing::") && !dev_names(IDENTITY_PACKAGE) {
188        let line =
189            format!("{IDENTITY_PACKAGE} = {{ workspace = true, features = [\"testing\"] }}\n");
190        text =
191            with_workspace_dev_dependency(app, &text, IDENTITY_PACKAGE, &line, journal, outcome)?;
192    }
193    // `granted` writes roles and grants, which only sofie-identity's `permissions` has.
194    if sources.contains(requests::GRANTED)
195        && let Some(edited) =
196            with_feature_in(&text, DEV_DEPENDENCIES, IDENTITY_PACKAGE, "permissions")
197    {
198        text = edited;
199    }
200    if text != original {
201        journal.write(&manifest, &text)?;
202        outcome.changed.push(manifest);
203    }
204    Ok(())
205}
206
207/// The contracts whose route builders the requests name.
208fn with_contracts(app: &App, sources: &str, manifest: &str) -> Result<String, GenError> {
209    let mut text = manifest.to_owned();
210    for module in sorted::modules(app) {
211        let Some(contract) = &module.contract else {
212            continue;
213        };
214        if !sources.contains(&format!("{}::", contract.crate_name))
215            || names_dependency(&text, &contract.package)
216        {
217            continue;
218        }
219        let path = relative_path(&app.app.path, &contract.path).ok_or_else(|| {
220            GenError::Usage(format!(
221                "{} cannot name {} by a relative path",
222                app.app.path.display(),
223                contract.path.display()
224            ))
225        })?;
226        let line = path_dependency(&contract.package, &path);
227        text = with_section_line(&text, DEV_DEPENDENCIES, &line);
228    }
229    Ok(text)
230}
231
232fn with_workspace_dev_dependency(
233    app: &App,
234    manifest: &str,
235    package: &str,
236    line: &str,
237    journal: &mut Journal,
238    outcome: &mut ScaffoldedTests,
239) -> Result<String, GenError> {
240    if let Some((workspace, declared)) = workspace_declaration(&app.app.path, package)? {
241        journal.write(&workspace, declared)?;
242        outcome.changed.push(workspace);
243    }
244    Ok(with_section_line(manifest, DEV_DEPENDENCIES, line))
245}
246
247fn names_dependency(manifest: &str, package: &str) -> bool {
248    [DEPENDENCIES, DEV_DEPENDENCIES].iter().any(|header| {
249        toml_section(manifest, header).is_some_and(|(start, end)| {
250            dependency_line_start(&manifest[start..end], package).is_some()
251        })
252    })
253}
254
255#[expect(
256    clippy::too_many_arguments,
257    reason = "each is one run's state, passed down unchanged"
258)]
259fn scaffold_module(
260    app: &App,
261    module: &Module,
262    screens: &Screens,
263    directory: &Path,
264    written: &[Written],
265    now: Timestamp,
266    journal: &mut Journal,
267    outcome: &mut ScaffoldedTests,
268) -> Result<(), GenError> {
269    let name = &module.krate.crate_name;
270    let found = module_facts(app, module, screens);
271    let earlier: Vec<&Written> = written.iter().filter(|file| &file.module == name).collect();
272    let covered = |text: &str| {
273        earlier
274            .iter()
275            .any(|file| file.facts.iter().any(|(fact, _)| fact == text))
276    };
277    let new: Vec<&Fact> = found
278        .facts
279        .iter()
280        .filter(|fact| !covered(&fact.text))
281        .collect();
282    for file in &earlier {
283        for (fact, test) in &file.facts {
284            if found.facts.iter().all(|current| &current.text != fact) {
285                outcome
286                    .superseded
287                    .push(format!("{}: {test} ({fact})", file.file.display()));
288            }
289        }
290    }
291    for gap in &found.uncovered {
292        let tested = |fact: &&Fact| fact.subject == gap.subject;
293        let older = found.facts.iter().any(|fact| tested(&fact)) && !new.iter().any(tested);
294        let reason = format!("{name}: {}", gap.reason);
295        if older {
296            outcome.older_uncovered.push(reason);
297        } else {
298            outcome.uncovered.push(reason);
299        }
300    }
301    if new.is_empty() {
302        return Ok(());
303    }
304    let file = directory.join(format!("{name}_{}.rs", now.strftime(FILE_MOMENT)));
305    if file.exists() {
306        return Err(GenError::Usage(format!(
307            "{} exists; run again a second later",
308            file.display()
309        )));
310    }
311    let source = format_rust(&render(name, now, &new), &file).map_err(GenError::Rustfmt)?;
312    journal.write(&file, &source)?;
313    outcome.created.push(file);
314    Ok(())
315}
316
317fn read_written(directory: &Path) -> Result<Vec<Written>, GenError> {
318    let mut written = Vec::new();
319    for file in files_with_extension(directory, "rs")? {
320        let Some(stem) = file.file_stem().and_then(|stem| stem.to_str()) else {
321            continue;
322        };
323        if stem == "main" {
324            continue;
325        }
326        let Some((module, _)) = stem.rsplit_once('_') else {
327            continue;
328        };
329        let text = fs::read_to_string(&file).map_err(|error| io_error(&file, error))?;
330        let mut facts = constant_facts(&text);
331        facts.extend(text.lines().filter_map(|line| {
332            let (fact, named) = line.strip_prefix(FACT_LINE)?.rsplit_once("`: `")?;
333            Some((fact.to_owned(), named.strip_suffix('`')?.to_owned()))
334        }));
335        written.push(Written {
336            module: module.to_owned(),
337            file,
338            facts,
339        });
340    }
341    Ok(written)
342}
343
344/// The facts are data, not comments, so a scaffold keeps the app within its comment ratio.
345fn constant_facts(source: &str) -> Vec<(String, String)> {
346    let Ok(file) = syn::parse_file(source) else {
347        return Vec::new();
348    };
349    let list = file.items.iter().find_map(|item| match item {
350        syn::Item::Const(constant) if constant.ident == FACTS_CONSTANT => Some(&*constant.expr),
351        _ => None,
352    });
353    let Some(syn::Expr::Reference(reference)) = list else {
354        return Vec::new();
355    };
356    let syn::Expr::Array(array) = &*reference.expr else {
357        return Vec::new();
358    };
359    array
360        .elems
361        .iter()
362        .filter_map(|pair| match pair {
363            syn::Expr::Tuple(tuple) => match (tuple.elems.first(), tuple.elems.last()) {
364                (Some(fact), Some(test)) => Some((text_of(fact)?, text_of(test)?)),
365                _ => None,
366            },
367            _ => None,
368        })
369        .collect()
370}
371
372fn text_of(expression: &syn::Expr) -> Option<String> {
373    match expression {
374        syn::Expr::Lit(syn::ExprLit {
375            lit: syn::Lit::Str(text),
376            ..
377        }) => Some(text.value()),
378        _ => None,
379    }
380}
381
382/// The root file is the app's once written, so a later run only appends the `mod` lines it lacks.
383fn write_root(
384    directory: &Path,
385    journal: &mut Journal,
386    outcome: &mut ScaffoldedTests,
387) -> Result<(), GenError> {
388    if !directory.is_dir() {
389        return Ok(());
390    }
391    let modules: Vec<String> = files_with_extension(directory, "rs")?
392        .iter()
393        .filter_map(|file| file.file_stem()?.to_str().map(str::to_owned))
394        .filter(|stem| stem != "main")
395        .collect();
396    let file = directory.join(ROOT_FILE);
397    let existing = match fs::read_to_string(&file) {
398        Ok(text) => Some(text),
399        Err(error) if error.kind() == ErrorKind::NotFound => None,
400        Err(error) => return Err(io_error(&file, error)),
401    };
402    let (mut root, declared) = match &existing {
403        Some(text) => (text.clone(), declared_modules(text, &file)?),
404        None => (
405            String::from(
406                "//! @generated by `sofie scaffold tests`, which declares each file beside it; the files are yours.\n\n",
407            ),
408            Vec::new(),
409        ),
410    };
411    let missing: Vec<&String> = modules
412        .iter()
413        .filter(|module| !declared.contains(module))
414        .collect();
415    if missing.is_empty() && existing.is_some() {
416        return Ok(());
417    }
418    if !root.is_empty() && !root.ends_with('\n') {
419        root.push('\n');
420    }
421    for module in missing {
422        let _ = writeln!(root, "mod {module};");
423    }
424    journal.write(&file, &root)?;
425    match existing {
426        Some(_) => outcome.changed.push(file),
427        None => outcome.created.push(file),
428    }
429    Ok(())
430}
431
432fn declared_modules(text: &str, file: &Path) -> Result<Vec<String>, GenError> {
433    let parsed = syn::parse_file(text)
434        .map_err(|error| GenError::App(format!("{}: {error}", file.display())))?;
435    Ok(parsed
436        .items
437        .iter()
438        .filter_map(|item| match item {
439            syn::Item::Mod(module) => Some(module.ident.to_string()),
440            _ => None,
441        })
442        .collect())
443}
444
445fn render(module: &str, now: Timestamp, facts: &[&Fact]) -> String {
446    let mut source = format!(
447        "//! Scaffolded by `sofie scaffold tests` at {} UTC for module `{module}`; yours from now on. \
448         A later run writes new or changed facts to a new file and names the tests of `{FACTS_CONSTANT}` it supersedes.\n\n\
449         const {FACTS_CONSTANT}: &[(&str, &str)] = &[\n",
450        now.strftime(HEADER_MOMENT)
451    );
452    let mut names: Vec<String> = Vec::new();
453    for fact in facts {
454        let mut name = fact.test.clone();
455        let mut suffix = 2;
456        while names.contains(&name) {
457            name = format!("{}_{suffix}", fact.test);
458            suffix += 1;
459        }
460        let _ = writeln!(source, "    ({:?}, {name:?}),", fact.text);
461        names.push(name);
462    }
463    source.push_str("];\n");
464    if facts.iter().any(|fact| fact.on_app) {
465        source.push('\n');
466        if facts
467            .iter()
468            .any(|fact| fact.body.contains(requests::SIGNED_UP_MEMBER))
469        {
470            source.push_str("use sofie_identity::testing::TestMember as _;\n");
471        }
472        if facts.iter().any(|fact| fact.body.contains(".await.id()")) {
473            source.push_str("use sofie::db::Aggregate as _;\n");
474        }
475        source.push_str("use sofie_testing::{TestApp, test_app};\n");
476    }
477    for (fact, name) in facts.iter().zip(&names) {
478        if fact.on_app {
479            let _ = write!(
480                source,
481                "\n#[test_app]\nasync fn {name}(app: TestApp) {{\n{}\n}}\n",
482                fact.body
483            );
484        } else {
485            let _ = write!(source, "\n#[test]\nfn {name}() {{\n{}\n}}\n", fact.body);
486        }
487    }
488    source
489}
490
491#[cfg(test)]
492mod tests;