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};
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";
164
165fn 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 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
207fn 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| ¤t.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
344fn 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
382fn 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;