1mod 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";
31const 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
37macro_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 pub uncovered: Vec<String>,
55 pub older_uncovered: Vec<String>,
57}
58
59impl ScaffoldedTests {
60 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
117pub(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
167fn 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 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
210fn 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
235fn 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| ¤t.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
400fn 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
438fn 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;