1use std::collections::BTreeMap;
7use std::path::{Component, Path, PathBuf};
8use std::time::Duration;
9
10#[derive(Clone, Debug, Default, PartialEq)]
12pub struct Config {
13 pub runs: BTreeMap<String, RunConfig>,
15 pub check: CheckConfig,
17 pub test: TestConfig,
19}
20
21#[derive(Clone, Debug, Default, PartialEq)]
28pub struct CheckConfig {
29 pub deny_warnings: bool,
34}
35
36#[derive(Clone, Debug, Default, PartialEq)]
42pub struct TestConfig {
43 pub allow_real: Vec<String>,
50}
51
52pub fn parse(text: &str) -> Result<Config, String> {
59 let table: toml::Table = text.parse().map_err(|e| format!("cove.toml: {e}"))?;
60
61 let mut runs = BTreeMap::new();
62 let mut check = CheckConfig::default();
63 let mut test = TestConfig::default();
64 for (key, value) in &table {
65 match key.as_str() {
66 "run" => {
67 let run_tables = value
68 .as_table()
69 .ok_or_else(|| "cove.toml: `run` must be a table".to_string())?;
70 for (name, run_value) in run_tables {
71 runs.insert(name.clone(), parse_run(name, run_value)?);
72 }
73 }
74 "check" => {
75 check = parse_check(value)?;
76 }
77 "test" => {
78 test = parse_test(value)?;
79 }
80 other => return Err(format!("cove.toml: unknown top-level key `{other}`")),
81 }
82 }
83
84 Ok(Config { runs, check, test })
85}
86
87fn parse_test(value: &toml::Value) -> Result<TestConfig, String> {
88 let table = value
89 .as_table()
90 .ok_or_else(|| "cove.toml: `test` must be a table".to_string())?;
91
92 let mut allow_real = Vec::new();
93 for (key, value) in table {
94 match key.as_str() {
95 "allow_real" => {
96 let items = value.as_array().ok_or_else(|| {
97 "cove.toml: `test.allow_real` must be an array of strings".to_string()
98 })?;
99 for item in items {
100 let item = item.as_str().ok_or_else(|| {
101 "cove.toml: `test.allow_real` must be an array of strings".to_string()
102 })?;
103 allow_real.push(item.to_string());
104 }
105 }
106 other => return Err(format!("cove.toml: unknown key `test.{other}`")),
107 }
108 }
109 Ok(TestConfig { allow_real })
110}
111
112fn parse_check(value: &toml::Value) -> Result<CheckConfig, String> {
113 let table = value
114 .as_table()
115 .ok_or_else(|| "cove.toml: `check` must be a table".to_string())?;
116
117 let mut deny_warnings = false;
118 for (key, value) in table {
119 match key.as_str() {
120 "deny_warnings" => {
121 deny_warnings = value.as_bool().ok_or_else(|| {
122 "cove.toml: `check.deny_warnings` must be a boolean".to_string()
123 })?;
124 }
125 other => return Err(format!("cove.toml: unknown key `check.{other}`")),
126 }
127 }
128 Ok(CheckConfig { deny_warnings })
129}
130
131fn parse_run(name: &str, value: &toml::Value) -> Result<RunConfig, String> {
132 let table = value
133 .as_table()
134 .ok_or_else(|| format!("run `{name}`: must be a table"))?;
135
136 let mut entry = None;
137 let mut allow = Vec::new();
138 let mut fuel = None;
139 let mut deadline = None;
140 let mut max_host_calls = None;
141 let mut max_tasks = None;
142 let mut trace = None;
143 let mut generates = None;
144 for (key, value) in table {
145 match key.as_str() {
146 "entry" => {
147 entry = Some(
148 value
149 .as_str()
150 .ok_or_else(|| format!("run `{name}`: `entry` must be a string"))?
151 .to_string(),
152 );
153 }
154 "allow" => {
155 let items = value
156 .as_array()
157 .ok_or_else(|| format!("run `{name}`: `allow` must be an array of strings"))?;
158 for item in items {
159 let item = item.as_str().ok_or_else(|| {
160 format!("run `{name}`: `allow` must be an array of strings")
161 })?;
162 allow.push(item.to_string());
163 }
164 }
165 "fuel" => {
166 fuel = Some(parse_non_negative_integer(name, "fuel", value)?);
167 }
168 "deadline" => {
169 let text = value
170 .as_str()
171 .ok_or_else(|| format!("run `{name}`: `deadline` must be a string"))?;
172 deadline = Some(parse_duration(name, text)?);
173 }
174 "max_host_calls" => {
175 max_host_calls = Some(parse_non_negative_integer(name, "max_host_calls", value)?);
176 }
177 "max_tasks" => {
178 max_tasks = Some(parse_non_negative_integer(name, "max_tasks", value)?);
179 }
180 "trace" => {
181 trace = Some(
182 value
183 .as_str()
184 .ok_or_else(|| format!("run `{name}`: `trace` must be a string"))?
185 .to_string(),
186 );
187 }
188 "generates" => {
189 let text = value
190 .as_str()
191 .ok_or_else(|| format!("run `{name}`: `generates` must be a string"))?;
192 generates = Some(parse_generates_path(name, text)?);
193 }
194 other => return Err(format!("run `{name}`: unknown key `{other}`")),
195 }
196 }
197
198 let entry = entry.ok_or_else(|| format!("run `{name}`: missing `entry`"))?;
199 Ok(RunConfig {
200 entry,
201 allow,
202 fuel,
203 deadline,
204 max_host_calls,
205 max_tasks,
206 trace,
207 generates,
208 })
209}
210
211fn parse_generates_path(name: &str, text: &str) -> Result<PathBuf, String> {
221 let path = Path::new(text);
222 if path.is_absolute() {
223 return Err(format!(
224 "run `{name}`: `generates` must be a package-relative path, found the absolute path `{text}`"
225 ));
226 }
227 if path.components().any(|c| c == Component::ParentDir) {
228 return Err(format!(
229 "run `{name}`: `generates` must stay inside the package, found `{text}`, which escapes it with `..`"
230 ));
231 }
232 if path.extension().and_then(|e| e.to_str()) != Some("cove") {
233 return Err(format!(
234 "run `{name}`: `generates` must name a `.cove` file, found `{text}`"
235 ));
236 }
237 Ok(path.to_path_buf())
238}
239
240fn parse_non_negative_integer(name: &str, key: &str, value: &toml::Value) -> Result<u64, String> {
242 let int = value
243 .as_integer()
244 .ok_or_else(|| format!("run `{name}`: `{key}` must be an integer"))?;
245 u64::try_from(int).map_err(|_| format!("run `{name}`: `{key}` must not be negative"))
246}
247
248fn parse_duration(name: &str, text: &str) -> Result<Duration, String> {
252 let accepted = "the accepted units are `ns`, `us`, `ms`, `s`, `m`, and `h`";
253 let invalid =
254 || format!("run `{name}`: `deadline` value `{text}` is not a valid duration; {accepted}");
255
256 let split_at = text
257 .find(|c: char| !c.is_ascii_digit())
258 .ok_or_else(invalid)?;
259 let (digits, unit) = text.split_at(split_at);
260 if digits.is_empty() {
261 return Err(invalid());
262 }
263 let value: u64 = digits.parse().map_err(|_| invalid())?;
264
265 let nanos_per_unit: u64 = match unit {
266 "ns" => 1,
267 "us" => 1_000,
268 "ms" => 1_000_000,
269 "s" => 1_000_000_000,
270 "m" => 60_000_000_000,
271 "h" => 3_600_000_000_000,
272 _ => return Err(invalid()),
273 };
274 let nanos = value.checked_mul(nanos_per_unit).ok_or_else(|| {
275 format!("run `{name}`: `deadline` value `{text}` overflows a 64-bit nanosecond count")
276 })?;
277 Ok(Duration::from_nanos(nanos))
278}
279
280#[derive(Clone, Debug, PartialEq)]
282pub struct RunConfig {
283 pub entry: String,
285 pub allow: Vec<String>,
287 pub fuel: Option<u64>,
289 pub deadline: Option<Duration>,
292 pub max_host_calls: Option<u64>,
295 pub max_tasks: Option<u64>,
299 pub trace: Option<String>,
301 pub generates: Option<PathBuf>,
308}
309
310impl RunConfig {
311 pub fn entry_parts(&self) -> Option<(&str, &str)> {
313 self.entry.rsplit_once('.')
314 }
315}
316
317#[cfg(test)]
318mod tests {
319 use super::*;
320
321 #[test]
322 fn parses_the_example_cove_toml() {
323 let path =
324 std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../examples/cove.toml");
325 let text = std::fs::read_to_string(&path).expect("examples/cove.toml exists");
326 let config = parse(&text).expect("examples/cove.toml parses");
327
328 assert_eq!(
329 config.runs.keys().map(String::as_str).collect::<Vec<_>>(),
330 [
331 "callbacks",
332 "config",
333 "covecheck",
334 "covefmtBench",
335 "cq",
336 "cqSample",
337 "hello",
338 "life",
339 "restricted",
340 "reviewPolicy",
341 "server",
342 "statusCodes",
343 "tasks",
344 "traits",
345 "values"
346 ]
347 );
348
349 let hello = &config.runs["hello"];
350 assert_eq!(hello.entry, "hello.main");
351 assert_eq!(hello.allow, vec!["console".to_string()]);
352 assert_eq!(hello.entry_parts(), Some(("hello", "main")));
353
354 let server = &config.runs["server"];
355 assert_eq!(
356 server.allow,
357 vec!["http".to_string(), "console".to_string()]
358 );
359 }
360
361 #[test]
362 fn rejects_missing_entry() {
363 let err = parse("[run.hello]\nallow = [\"console\"]\n").unwrap_err();
364 assert_eq!(err, "run `hello`: missing `entry`");
365 }
366
367 #[test]
368 fn rejects_non_string_allow_item() {
369 let err = parse("[run.hello]\nentry = \"hello.main\"\nallow = [1]\n").unwrap_err();
370 assert_eq!(err, "run `hello`: `allow` must be an array of strings");
371 }
372
373 #[test]
374 fn rejects_non_string_entry() {
375 let err = parse("[run.hello]\nentry = 1\n").unwrap_err();
376 assert_eq!(err, "run `hello`: `entry` must be a string");
377 }
378
379 #[test]
380 fn rejects_unknown_key_in_run_table() {
381 let err =
382 parse("[run.hello]\nentry = \"hello.main\"\nallowed = [\"console\"]\n").unwrap_err();
383 assert_eq!(err, "run `hello`: unknown key `allowed`");
384 }
385
386 #[test]
387 fn rejects_unknown_top_level_key() {
388 let err = parse("[package]\nname = \"cove\"\n").unwrap_err();
389 assert_eq!(err, "cove.toml: unknown top-level key `package`");
390 }
391
392 #[test]
393 fn a_run_table_with_no_resource_keys_still_parses() {
394 let config = parse("[run.hello]\nentry = \"hello.main\"\n").unwrap();
395 let hello = &config.runs["hello"];
396 assert_eq!(hello.fuel, None);
397 assert_eq!(hello.deadline, None);
398 assert_eq!(hello.max_host_calls, None);
399 assert_eq!(hello.max_tasks, None);
400 assert_eq!(hello.trace, None);
401 }
402
403 #[test]
404 fn parses_fuel() {
405 let config = parse("[run.hello]\nentry = \"hello.main\"\nfuel = 1000\n").unwrap();
406 assert_eq!(config.runs["hello"].fuel, Some(1000));
407 }
408
409 #[test]
410 fn rejects_non_integer_fuel() {
411 let err = parse("[run.hello]\nentry = \"hello.main\"\nfuel = \"1000\"\n").unwrap_err();
412 assert_eq!(err, "run `hello`: `fuel` must be an integer");
413 }
414
415 #[test]
416 fn rejects_negative_fuel() {
417 let err = parse("[run.hello]\nentry = \"hello.main\"\nfuel = -1\n").unwrap_err();
418 assert_eq!(err, "run `hello`: `fuel` must not be negative");
419 }
420
421 #[test]
422 fn parses_max_host_calls() {
423 let config = parse("[run.hello]\nentry = \"hello.main\"\nmax_host_calls = 5\n").unwrap();
424 assert_eq!(config.runs["hello"].max_host_calls, Some(5));
425 }
426
427 #[test]
428 fn parses_max_tasks() {
429 let config = parse("[run.hello]\nentry = \"hello.main\"\nmax_tasks = 5\n").unwrap();
430 assert_eq!(config.runs["hello"].max_tasks, Some(5));
431 }
432
433 #[test]
434 fn rejects_negative_max_tasks() {
435 let err = parse("[run.hello]\nentry = \"hello.main\"\nmax_tasks = -1\n").unwrap_err();
436 assert_eq!(err, "run `hello`: `max_tasks` must not be negative");
437 }
438
439 #[test]
440 fn rejects_non_integer_max_host_calls() {
441 let err = parse("[run.hello]\nentry = \"hello.main\"\nmax_host_calls = 1.5\n").unwrap_err();
442 assert_eq!(err, "run `hello`: `max_host_calls` must be an integer");
443 }
444
445 #[test]
446 fn rejects_negative_max_host_calls() {
447 let err = parse("[run.hello]\nentry = \"hello.main\"\nmax_host_calls = -3\n").unwrap_err();
448 assert_eq!(err, "run `hello`: `max_host_calls` must not be negative");
449 }
450
451 #[test]
452 fn parses_every_deadline_unit() {
453 let cases = [
454 ("1ns", Duration::from_nanos(1)),
455 ("1us", Duration::from_micros(1)),
456 ("500ms", Duration::from_millis(500)),
457 ("5s", Duration::from_secs(5)),
458 ("1m", Duration::from_secs(60)),
459 ("1h", Duration::from_secs(3600)),
460 ];
461 for (text, expected) in cases {
462 let toml = format!("[run.hello]\nentry = \"hello.main\"\ndeadline = \"{text}\"\n");
463 let config = parse(&toml).unwrap_or_else(|e| panic!("`{text}` should parse: {e}"));
464 assert_eq!(config.runs["hello"].deadline, Some(expected), "{text}");
465 }
466 }
467
468 #[test]
469 fn rejects_non_string_deadline() {
470 let err = parse("[run.hello]\nentry = \"hello.main\"\ndeadline = 500\n").unwrap_err();
471 assert_eq!(err, "run `hello`: `deadline` must be a string");
472 }
473
474 #[test]
475 fn rejects_deadline_with_an_unknown_unit() {
476 let err = parse("[run.hello]\nentry = \"hello.main\"\ndeadline = \"5x\"\n").unwrap_err();
477 assert_eq!(
478 err,
479 "run `hello`: `deadline` value `5x` is not a valid duration; the accepted units are `ns`, `us`, `ms`, `s`, `m`, and `h`"
480 );
481 }
482
483 #[test]
484 fn rejects_deadline_with_no_unit() {
485 let err = parse("[run.hello]\nentry = \"hello.main\"\ndeadline = \"500\"\n").unwrap_err();
486 assert_eq!(
487 err,
488 "run `hello`: `deadline` value `500` is not a valid duration; the accepted units are `ns`, `us`, `ms`, `s`, `m`, and `h`"
489 );
490 }
491
492 #[test]
493 fn rejects_deadline_with_no_digits() {
494 let err = parse("[run.hello]\nentry = \"hello.main\"\ndeadline = \"ms\"\n").unwrap_err();
495 assert_eq!(
496 err,
497 "run `hello`: `deadline` value `ms` is not a valid duration; the accepted units are `ns`, `us`, `ms`, `s`, `m`, and `h`"
498 );
499 }
500
501 #[test]
502 fn parses_trace() {
503 let config =
504 parse("[run.hello]\nentry = \"hello.main\"\ntrace = \"trace.jsonl\"\n").unwrap();
505 assert_eq!(config.runs["hello"].trace, Some("trace.jsonl".to_string()));
506 }
507
508 #[test]
509 fn rejects_non_string_trace() {
510 let err = parse("[run.hello]\nentry = \"hello.main\"\ntrace = 1\n").unwrap_err();
511 assert_eq!(err, "run `hello`: `trace` must be a string");
512 }
513
514 #[test]
515 fn deny_warnings_defaults_to_false() {
516 let config = parse("[run.hello]\nentry = \"hello.main\"\n").unwrap();
517 assert!(!config.check.deny_warnings);
518 }
519
520 #[test]
521 fn parses_deny_warnings() {
522 let config = parse("[check]\ndeny_warnings = true\n").unwrap();
523 assert!(config.check.deny_warnings);
524 }
525
526 #[test]
527 fn rejects_non_bool_deny_warnings() {
528 let err = parse("[check]\ndeny_warnings = \"true\"\n").unwrap_err();
529 assert_eq!(err, "cove.toml: `check.deny_warnings` must be a boolean");
530 }
531
532 #[test]
533 fn rejects_unknown_key_in_check_table() {
534 let err = parse("[check]\ndeny_warning = true\n").unwrap_err();
535 assert_eq!(err, "cove.toml: unknown key `check.deny_warning`");
536 }
537
538 #[test]
539 fn allow_real_defaults_to_empty() {
540 let config = parse("[run.hello]\nentry = \"hello.main\"\n").unwrap();
541 assert!(config.test.allow_real.is_empty());
542 }
543
544 #[test]
545 fn parses_allow_real() {
546 let config = parse("[test]\nallow_real = [\"clock\", \"files\"]\n").unwrap();
547 assert_eq!(
548 config.test.allow_real,
549 vec!["clock".to_string(), "files".to_string()]
550 );
551 }
552
553 #[test]
554 fn rejects_non_string_allow_real_item() {
555 let err = parse("[test]\nallow_real = [1]\n").unwrap_err();
556 assert_eq!(
557 err,
558 "cove.toml: `test.allow_real` must be an array of strings"
559 );
560 }
561
562 #[test]
563 fn rejects_unknown_key_in_test_table() {
564 let err = parse("[test]\nallow_fake = [\"clock\"]\n").unwrap_err();
565 assert_eq!(err, "cove.toml: unknown key `test.allow_fake`");
566 }
567
568 #[test]
569 fn rejects_non_table_test() {
570 let err = parse("test = true\n").unwrap_err();
571 assert_eq!(err, "cove.toml: `test` must be a table");
572 }
573
574 #[test]
575 fn rejects_non_table_check() {
576 let err = parse("check = true\n").unwrap_err();
577 assert_eq!(err, "cove.toml: `check` must be a table");
578 }
579
580 #[test]
581 fn generates_defaults_to_none() {
582 let config = parse("[run.hello]\nentry = \"hello.main\"\n").unwrap();
583 assert_eq!(config.runs["hello"].generates, None);
584 }
585
586 #[test]
587 fn parses_generates() {
588 let config =
589 parse("[run.hello]\nentry = \"hello.main\"\ngenerates = \"gen/hello.cove\"\n").unwrap();
590 assert_eq!(
591 config.runs["hello"].generates,
592 Some(std::path::PathBuf::from("gen/hello.cove"))
593 );
594 }
595
596 #[test]
597 fn rejects_non_string_generates() {
598 let err = parse("[run.hello]\nentry = \"hello.main\"\ngenerates = 1\n").unwrap_err();
599 assert_eq!(err, "run `hello`: `generates` must be a string");
600 }
601
602 #[test]
603 fn rejects_absolute_generates_path() {
604 let err = parse("[run.hello]\nentry = \"hello.main\"\ngenerates = \"/etc/hello.cove\"\n")
605 .unwrap_err();
606 assert_eq!(
607 err,
608 "run `hello`: `generates` must be a package-relative path, found the absolute path `/etc/hello.cove`"
609 );
610 }
611
612 #[test]
613 fn rejects_generates_path_escaping_the_package() {
614 let err = parse("[run.hello]\nentry = \"hello.main\"\ngenerates = \"../outside.cove\"\n")
615 .unwrap_err();
616 assert_eq!(
617 err,
618 "run `hello`: `generates` must stay inside the package, found `../outside.cove`, which escapes it with `..`"
619 );
620 }
621
622 #[test]
623 fn rejects_generates_path_escaping_the_package_from_within_a_subdirectory() {
624 let err =
625 parse("[run.hello]\nentry = \"hello.main\"\ngenerates = \"gen/../../outside.cove\"\n")
626 .unwrap_err();
627 assert_eq!(
628 err,
629 "run `hello`: `generates` must stay inside the package, found `gen/../../outside.cove`, which escapes it with `..`"
630 );
631 }
632
633 #[test]
634 fn rejects_generates_path_not_ending_in_cove() {
635 let err = parse("[run.hello]\nentry = \"hello.main\"\ngenerates = \"gen/hello.rs\"\n")
636 .unwrap_err();
637 assert_eq!(
638 err,
639 "run `hello`: `generates` must name a `.cove` file, found `gen/hello.rs`"
640 );
641 }
642
643 #[test]
644 fn rejects_generates_path_with_no_extension() {
645 let err =
646 parse("[run.hello]\nentry = \"hello.main\"\ngenerates = \"gen/hello\"\n").unwrap_err();
647 assert_eq!(
648 err,
649 "run `hello`: `generates` must name a `.cove` file, found `gen/hello`"
650 );
651 }
652}