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, needs_value_fixtures};
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";
164const FIXTURES: &str = "fixtures";
165const FEATURES: &str = "[features]";
166
167/// What the scaffolded files name besides the app and its modules, which a new app lacks.
168fn add_test_dependencies(
169    app: &App,
170    directory: &Path,
171    journal: &mut Journal,
172    outcome: &mut ScaffoldedTests,
173) -> Result<(), GenError> {
174    let mut sources = String::new();
175    for file in files_with_extension(directory, "rs")? {
176        sources.push_str(&fs::read_to_string(&file).map_err(|error| io_error(&file, error))?);
177    }
178    let manifest = app.app.path.join("Cargo.toml");
179    let original = read(&manifest)?;
180    let mut text = with_contracts(app, &sources, &original)?;
181    text = with_module_fixtures(app, &text, journal, outcome)?;
182    if sources.contains("sofie_testing::") && !names_dependency(&text, TESTING_PACKAGE) {
183        let line = format!("{TESTING_PACKAGE}.workspace = true\n");
184        text = with_workspace_dev_dependency(app, &text, TESTING_PACKAGE, &line, journal, outcome)?;
185    }
186    let dev_names = |package| {
187        toml_section(&text, DEV_DEPENDENCIES)
188            .is_some_and(|(start, end)| dependency_line_start(&text[start..end], package).is_some())
189    };
190    if sources.contains("sofie_identity::testing::") && !dev_names(IDENTITY_PACKAGE) {
191        let line =
192            format!("{IDENTITY_PACKAGE} = {{ workspace = true, features = [\"testing\"] }}\n");
193        text =
194            with_workspace_dev_dependency(app, &text, IDENTITY_PACKAGE, &line, journal, outcome)?;
195    }
196    // `granted` writes roles and grants, which only sofie-identity's `permissions` has.
197    if sources.contains(requests::GRANTED)
198        && let Some(edited) =
199            with_feature_in(&text, DEV_DEPENDENCIES, IDENTITY_PACKAGE, "permissions")
200    {
201        text = edited;
202    }
203    if text != original {
204        journal.write(&manifest, &text)?;
205        outcome.changed.push(manifest);
206    }
207    Ok(())
208}
209
210/// The contracts whose route builders the requests name.
211fn with_contracts(app: &App, sources: &str, manifest: &str) -> Result<String, GenError> {
212    let mut text = manifest.to_owned();
213    for module in sorted::modules(app) {
214        let Some(contract) = &module.contract else {
215            continue;
216        };
217        if !sources.contains(&format!("{}::", contract.crate_name))
218            || names_dependency(&text, &contract.package)
219        {
220            continue;
221        }
222        let path = relative_path(&app.app.path, &contract.path).ok_or_else(|| {
223            GenError::Usage(format!(
224                "{} cannot name {} by a relative path",
225                app.app.path.display(),
226                contract.path.display()
227            ))
228        })?;
229        let line = path_dependency(&contract.package, &path);
230        text = with_section_line(&text, DEV_DEPENDENCIES, &line);
231    }
232    Ok(text)
233}
234
235/// A hand-written `Fixture` sits behind the module's own `fixtures` feature,
236/// so a plain build, which lacks the trait, leaves it out.
237fn with_module_fixtures(
238    app: &App,
239    manifest: &str,
240    journal: &mut Journal,
241    outcome: &mut ScaffoldedTests,
242) -> Result<String, GenError> {
243    let mut text = manifest.to_owned();
244    for module in sorted::modules(app) {
245        if !needs_value_fixtures(module) {
246            continue;
247        }
248        add_fixtures_feature(&module.krate.path, journal, outcome)?;
249        let package = &module.krate.package;
250        let dev_names = toml_section(&text, DEV_DEPENDENCIES).is_some_and(|(start, end)| {
251            dependency_line_start(&text[start..end], package).is_some()
252        });
253        if !dev_names {
254            let path = relative_path(&app.app.path, &module.krate.path).ok_or_else(|| {
255                GenError::Usage(format!(
256                    "{} cannot name {} by a relative path",
257                    app.app.path.display(),
258                    module.krate.path.display()
259                ))
260            })?;
261            text = with_section_line(&text, DEV_DEPENDENCIES, &path_dependency(package, &path));
262        }
263        if let Some(edited) = with_feature_in(&text, DEV_DEPENDENCIES, package, FIXTURES) {
264            text = edited;
265        }
266    }
267    Ok(text)
268}
269
270fn add_fixtures_feature(
271    module: &Path,
272    journal: &mut Journal,
273    outcome: &mut ScaffoldedTests,
274) -> Result<(), GenError> {
275    let manifest = module.join("Cargo.toml");
276    let text = read(&manifest)?;
277    let declared = toml_section(&text, FEATURES)
278        .is_some_and(|(start, end)| dependency_line_start(&text[start..end], FIXTURES).is_some());
279    if declared {
280        return Ok(());
281    }
282    let line = format!("{FIXTURES} = [\"sofie/{FIXTURES}\"]\n");
283    journal.write(&manifest, with_section_line(&text, FEATURES, &line))?;
284    outcome.changed.push(manifest);
285    Ok(())
286}
287
288fn with_workspace_dev_dependency(
289    app: &App,
290    manifest: &str,
291    package: &str,
292    line: &str,
293    journal: &mut Journal,
294    outcome: &mut ScaffoldedTests,
295) -> Result<String, GenError> {
296    if let Some((workspace, declared)) = workspace_declaration(&app.app.path, package)? {
297        journal.write(&workspace, declared)?;
298        outcome.changed.push(workspace);
299    }
300    Ok(with_section_line(manifest, DEV_DEPENDENCIES, line))
301}
302
303fn names_dependency(manifest: &str, package: &str) -> bool {
304    [DEPENDENCIES, DEV_DEPENDENCIES].iter().any(|header| {
305        toml_section(manifest, header).is_some_and(|(start, end)| {
306            dependency_line_start(&manifest[start..end], package).is_some()
307        })
308    })
309}
310
311#[expect(
312    clippy::too_many_arguments,
313    reason = "each is one run's state, passed down unchanged"
314)]
315fn scaffold_module(
316    app: &App,
317    module: &Module,
318    screens: &Screens,
319    directory: &Path,
320    written: &[Written],
321    now: Timestamp,
322    journal: &mut Journal,
323    outcome: &mut ScaffoldedTests,
324) -> Result<(), GenError> {
325    let name = &module.krate.crate_name;
326    let found = module_facts(app, module, screens);
327    let earlier: Vec<&Written> = written.iter().filter(|file| &file.module == name).collect();
328    let covered = |text: &str| {
329        earlier
330            .iter()
331            .any(|file| file.facts.iter().any(|(fact, _)| fact == text))
332    };
333    let new: Vec<&Fact> = found
334        .facts
335        .iter()
336        .filter(|fact| !covered(&fact.text))
337        .collect();
338    for file in &earlier {
339        for (fact, test) in &file.facts {
340            if found.facts.iter().all(|current| &current.text != fact) {
341                outcome
342                    .superseded
343                    .push(format!("{}: {test} ({fact})", file.file.display()));
344            }
345        }
346    }
347    for gap in &found.uncovered {
348        let tested = |fact: &&Fact| fact.subject == gap.subject;
349        let older = found.facts.iter().any(|fact| tested(&fact)) && !new.iter().any(tested);
350        let reason = format!("{name}: {}", gap.reason);
351        if older {
352            outcome.older_uncovered.push(reason);
353        } else {
354            outcome.uncovered.push(reason);
355        }
356    }
357    if new.is_empty() {
358        return Ok(());
359    }
360    let file = directory.join(format!("{name}_{}.rs", now.strftime(FILE_MOMENT)));
361    if file.exists() {
362        return Err(GenError::Usage(format!(
363            "{} exists; run again a second later",
364            file.display()
365        )));
366    }
367    let source = format_rust(&render(name, now, &new), &file).map_err(GenError::Rustfmt)?;
368    journal.write(&file, &source)?;
369    outcome.created.push(file);
370    Ok(())
371}
372
373fn read_written(directory: &Path) -> Result<Vec<Written>, GenError> {
374    let mut written = Vec::new();
375    for file in files_with_extension(directory, "rs")? {
376        let Some(stem) = file.file_stem().and_then(|stem| stem.to_str()) else {
377            continue;
378        };
379        if stem == "main" {
380            continue;
381        }
382        let Some((module, _)) = stem.rsplit_once('_') else {
383            continue;
384        };
385        let text = fs::read_to_string(&file).map_err(|error| io_error(&file, error))?;
386        let mut facts = constant_facts(&text);
387        facts.extend(text.lines().filter_map(|line| {
388            let (fact, named) = line.strip_prefix(FACT_LINE)?.rsplit_once("`: `")?;
389            Some((fact.to_owned(), named.strip_suffix('`')?.to_owned()))
390        }));
391        written.push(Written {
392            module: module.to_owned(),
393            file,
394            facts,
395        });
396    }
397    Ok(written)
398}
399
400/// The facts are data, not comments, so a scaffold keeps the app within its comment ratio.
401fn constant_facts(source: &str) -> Vec<(String, String)> {
402    let Ok(file) = syn::parse_file(source) else {
403        return Vec::new();
404    };
405    let list = file.items.iter().find_map(|item| match item {
406        syn::Item::Const(constant) if constant.ident == FACTS_CONSTANT => Some(&*constant.expr),
407        _ => None,
408    });
409    let Some(syn::Expr::Reference(reference)) = list else {
410        return Vec::new();
411    };
412    let syn::Expr::Array(array) = &*reference.expr else {
413        return Vec::new();
414    };
415    array
416        .elems
417        .iter()
418        .filter_map(|pair| match pair {
419            syn::Expr::Tuple(tuple) => match (tuple.elems.first(), tuple.elems.last()) {
420                (Some(fact), Some(test)) => Some((text_of(fact)?, text_of(test)?)),
421                _ => None,
422            },
423            _ => None,
424        })
425        .collect()
426}
427
428fn text_of(expression: &syn::Expr) -> Option<String> {
429    match expression {
430        syn::Expr::Lit(syn::ExprLit {
431            lit: syn::Lit::Str(text),
432            ..
433        }) => Some(text.value()),
434        _ => None,
435    }
436}
437
438/// The root file is the app's once written, so a later run only appends the `mod` lines it lacks.
439fn write_root(
440    directory: &Path,
441    journal: &mut Journal,
442    outcome: &mut ScaffoldedTests,
443) -> Result<(), GenError> {
444    if !directory.is_dir() {
445        return Ok(());
446    }
447    let modules: Vec<String> = files_with_extension(directory, "rs")?
448        .iter()
449        .filter_map(|file| file.file_stem()?.to_str().map(str::to_owned))
450        .filter(|stem| stem != "main")
451        .collect();
452    let file = directory.join(ROOT_FILE);
453    let existing = match fs::read_to_string(&file) {
454        Ok(text) => Some(text),
455        Err(error) if error.kind() == ErrorKind::NotFound => None,
456        Err(error) => return Err(io_error(&file, error)),
457    };
458    let (mut root, declared) = match &existing {
459        Some(text) => (text.clone(), declared_modules(text, &file)?),
460        None => (
461            String::from(
462                "//! @generated by `sofie scaffold tests`, which declares each file beside it; the files are yours.\n\n",
463            ),
464            Vec::new(),
465        ),
466    };
467    let missing: Vec<&String> = modules
468        .iter()
469        .filter(|module| !declared.contains(module))
470        .collect();
471    if missing.is_empty() && existing.is_some() {
472        return Ok(());
473    }
474    if !root.is_empty() && !root.ends_with('\n') {
475        root.push('\n');
476    }
477    for module in missing {
478        let _ = writeln!(root, "mod {module};");
479    }
480    journal.write(&file, &root)?;
481    match existing {
482        Some(_) => outcome.changed.push(file),
483        None => outcome.created.push(file),
484    }
485    Ok(())
486}
487
488fn declared_modules(text: &str, file: &Path) -> Result<Vec<String>, GenError> {
489    let parsed = syn::parse_file(text)
490        .map_err(|error| GenError::App(format!("{}: {error}", file.display())))?;
491    Ok(parsed
492        .items
493        .iter()
494        .filter_map(|item| match item {
495            syn::Item::Mod(module) => Some(module.ident.to_string()),
496            _ => None,
497        })
498        .collect())
499}
500
501fn render(module: &str, now: Timestamp, facts: &[&Fact]) -> String {
502    let mut source = format!(
503        "//! Scaffolded by `sofie scaffold tests` at {} UTC for module `{module}`; yours from now on. \
504         A later run writes new or changed facts to a new file and names the tests of `{FACTS_CONSTANT}` it supersedes.\n\n\
505         const {FACTS_CONSTANT}: &[(&str, &str)] = &[\n",
506        now.strftime(HEADER_MOMENT)
507    );
508    let mut names: Vec<String> = Vec::new();
509    for fact in facts {
510        let mut name = fact.test.clone();
511        let mut suffix = 2;
512        while names.contains(&name) {
513            name = format!("{}_{suffix}", fact.test);
514            suffix += 1;
515        }
516        let _ = writeln!(source, "    ({:?}, {name:?}),", fact.text);
517        names.push(name);
518    }
519    source.push_str("];\n");
520    if facts.iter().any(|fact| fact.on_app) {
521        source.push('\n');
522        if facts
523            .iter()
524            .any(|fact| fact.body.contains(requests::SIGNED_UP_MEMBER))
525        {
526            source.push_str("use sofie_identity::testing::TestMember as _;\n");
527        }
528        if facts.iter().any(|fact| fact.body.contains(".await.id()")) {
529            source.push_str("use sofie::db::Aggregate as _;\n");
530        }
531        source.push_str("use sofie_testing::{TestApp, test_app};\n");
532    }
533    for (fact, name) in facts.iter().zip(&names) {
534        if fact.on_app {
535            let _ = write!(
536                source,
537                "\n#[test_app]\nasync fn {name}(app: TestApp) {{\n{}\n}}\n",
538                fact.body
539            );
540        } else {
541            let _ = write!(source, "\n#[test]\nfn {name}() {{\n{}\n}}\n", fact.body);
542        }
543    }
544    source
545}
546
547#[cfg(test)]
548mod tests;