Skip to main content

cove_sema/
config.rs

1//! `cove.toml`: the host's execution configuration.
2//!
3//! The host chooses the entry function and grants authority at the execution
4//! boundary; it never changes the meaning of the language.
5
6use std::collections::BTreeMap;
7use std::path::{Component, Path, PathBuf};
8use std::time::Duration;
9
10/// A parsed `cove.toml`.
11#[derive(Clone, Debug, Default, PartialEq)]
12pub struct Config {
13    /// `[run.<name>]` tables, keyed by run name.
14    pub runs: BTreeMap<String, RunConfig>,
15    /// The `[check]` table, controlling `cove check` for the whole package.
16    pub check: CheckConfig,
17    /// The `[test]` table, controlling `cove test` for the whole package.
18    pub test: TestConfig,
19}
20
21/// The `[check]` table.
22///
23/// Unlike `[run.<name>]`, this is one setting per package, not per run: the
24/// Language Card treats denying warnings as something "projects" decide, so
25/// it lives at the package's own top-level table rather than being repeated
26/// in every `[run.<name>]`.
27#[derive(Clone, Debug, Default, PartialEq)]
28pub struct CheckConfig {
29    /// Mirrors `cove check --deny-warnings`. When either the config or the
30    /// flag asks for denial, `cove check` fails on any warning: a CI
31    /// invocation asking for stricter behavior always wins over a project
32    /// default that does not.
33    pub deny_warnings: bool,
34}
35
36/// The `[test]` table.
37///
38/// Like `[check]`, this is one setting per package rather than per run: a
39/// test declares no capabilities of its own, so there is no per-test table
40/// for this to live in.
41#[derive(Clone, Debug, Default, PartialEq)]
42pub struct TestConfig {
43    /// The capabilities `cove test` grants with their real implementation
44    /// instead of their fake one.
45    ///
46    /// Defaulting to fakes is what makes a suite deterministic and safe to
47    /// run anywhere, so naming a capability here is how a package says it
48    /// means the real thing for that one.
49    pub allow_real: Vec<String>,
50}
51
52/// Parses the text of a `cove.toml`.
53///
54/// Cove prefers explicit configuration over silently ignored settings, so
55/// unknown top-level tables, unknown keys inside a `[run.<name>]` table, and
56/// unknown keys inside `[check]` or `[test]` are rejected rather than
57/// skipped.
58pub 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
211/// Validates a `generates` path: package-relative, staying inside the
212/// package, and naming a `.cove` file.
213///
214/// A generator's authority is exactly what `[run.<name>] allow` grants, and
215/// letting `generates` name any path at all would hand back the ambient
216/// filesystem authority ADR 0010 exists to avoid: an absolute path, or one
217/// that climbs out of the package with `..`, could write anywhere the `cove`
218/// process can reach. Requiring `.cove` keeps the promise that a generator's
219/// output is inspectable Cove source, not an arbitrary file.
220fn 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
240/// Parses a non-negative integer key, such as `fuel` or `max_host_calls`.
241fn 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
248/// Parses a duration such as `"500ms"` or `"5s"`, using the same unit
249/// meanings as the lexer's duration literals: `ns`, `us`, `ms`, `s`, `m`, and
250/// `h`.
251fn 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/// One `[run.<name>]` table.
281#[derive(Clone, Debug, PartialEq)]
282pub struct RunConfig {
283    /// A fully qualified entry function such as `hello.main`.
284    pub entry: String,
285    /// Coarse capabilities granted to this run.
286    pub allow: Vec<String>,
287    /// The total fuel this run may spend before the runtime stops it.
288    pub fuel: Option<u64>,
289    /// The wall-clock deadline this run may take before the runtime stops
290    /// it, parsed from a duration string such as `"500ms"`.
291    pub deadline: Option<Duration>,
292    /// The total number of host calls this run may make before the runtime
293    /// stops it.
294    pub max_host_calls: Option<u64>,
295    /// The tasks this run may hold alive at once, across the whole run, before
296    /// the runtime stops it: a `spawn` that would exceed it fails the run
297    /// before a thread is created.
298    pub max_tasks: Option<u64>,
299    /// A path to write a JSONL trace of this run to.
300    pub trace: Option<String>,
301    /// The package-relative `.cove` path `cove generate` writes this run's
302    /// entry's returned source to.
303    ///
304    /// A run with `generates` may still be executed by `cove run`; it just
305    /// also names where its output belongs. `cove generate --check`
306    /// regenerates every run that sets this and refuses a stale result.
307    pub generates: Option<PathBuf>,
308}
309
310impl RunConfig {
311    /// Splits `entry` into its module path and function name.
312    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}