Skip to main content

isoloom_spec/
error.rs

1//! Why a spec, a file beside it or a value in it isn't one Isoloom reads: one enum for the
2//! crate. Every message says where (the folder, the file, the field, the value) and why, in
3//! words a user acts on without reading Isoloom's code. A spec that reads but is wrong is
4//! [`Error::Invalid`], holding its [`Problem`]s, one per field.
5
6use std::fmt;
7use std::path::PathBuf;
8
9use crate::model::Target;
10use crate::profile::ProfileProblem;
11use crate::syntax::Problem;
12
13/// Why a spec, a file beside it or a value in it isn't one Isoloom reads.
14#[derive(Debug, thiserror::Error)]
15#[non_exhaustive]
16pub enum Error {
17    /// The folder has no spec file.
18    #[error("no isoloom.yml in {}", .0.display())]
19    NotFound(PathBuf),
20    /// Both `isoloom.yml` and `isoloom.yaml` exist: which one is meant is ambiguous.
21    #[error("both isoloom.yml and isoloom.yaml exist in {}; keep one", .0.display())]
22    Ambiguous(PathBuf),
23    /// A file exists but couldn't be read.
24    #[error("can't read {}: {source}", path.display())]
25    Read {
26        /// The file.
27        path: PathBuf,
28        /// The system's reason.
29        #[source]
30        source: std::io::Error,
31    },
32    /// Not YAML: the spec's text before its overrides, or a profile's spec it merges. The
33    /// message carries the line and column.
34    #[error("{0}")]
35    Yaml(#[from] serde_yaml_ng::Error),
36    /// The spec's problems: every one found, coded and placed, in file order.
37    #[error("{}", Rendered { file, problems })]
38    Invalid {
39        /// The spec file the problems are placed in (`isoloom.yml`).
40        file: String,
41        /// The problems.
42        problems: Vec<Problem>,
43    },
44    /// A `-s` that isn't `key=value`.
45    #[error("`-s {set}`: expected key=value, like -s machines.web.vm.os=ubuntu@24.04")]
46    SetShape {
47        /// The `-s` as given.
48        set: String,
49    },
50    /// A `-s` whose key isn't a dotted path.
51    #[error("`-s {set}`: the key is a dotted path, like cloud.aws.region")]
52    SetKey {
53        /// The `-s` as given.
54        set: String,
55    },
56    /// A name that breaks its rule.
57    #[error("`{value}` isn't a valid {what}: {rule}")]
58    Name {
59        /// What it names (`port name`).
60        what: &'static str,
61        /// The value written.
62        value: String,
63        /// The rule, as an author fixes the value.
64        rule: &'static str,
65    },
66    /// An OS no family names.
67    #[error("unknown OS `{text}`; write `<family>@<version>` with one of: {known}")]
68    UnknownOs {
69        /// What the spec wrote.
70        text: String,
71        /// The families and their versions.
72        known: String,
73    },
74    /// A known OS family without that version.
75    #[error("{family} has no version `{version}`; use one of: {known} (`{family}` alone is {family}@{default})")]
76    UnknownOsVersion {
77        /// The family.
78        family: &'static str,
79        /// The version the spec wrote.
80        version: String,
81        /// The family's versions.
82        known: String,
83        /// The family's default version.
84        default: &'static str,
85    },
86    /// A `{{ path }}` placeholder whose path the grammar doesn't have.
87    #[error("`{{{{ {path} }}}}` isn't a placeholder Isoloom knows: {why}")]
88    Placeholder {
89        /// The path as written, trimmed.
90        path: String,
91        /// What the paths there are.
92        why: &'static str,
93    },
94    /// A `{{` without its `}}`.
95    #[error("a `{{{{` without its `}}}}`")]
96    Unclosed,
97    /// A placeholder the place it is written in can't name (a run-time value in a file).
98    #[error("`{{{{ {reference} }}}}`: {why}")]
99    NotHere {
100        /// What it names.
101        reference: crate::syntax::placeholder::Reference,
102        /// Why the place can't name it.
103        why: &'static str,
104    },
105    /// A file mode that isn't octal digits up to `0777`.
106    #[error("`{0}` isn't a file mode: octal digits up to 0777, like \"0444\"")]
107    Mode(String),
108    /// A machine with both a catalogue `service` and a custom `app`.
109    #[error("`service` and `app` are both given: a machine is a catalogue service or a custom app")]
110    ServiceAndApp,
111    /// A machine's `with` without a `service`.
112    #[error("`with` sets a catalogue service's settings: it goes with `service`")]
113    WithWithoutService,
114    /// An app's `config` without an `appliance`.
115    #[error("`config` is an appliance's: it goes with `appliance`")]
116    ConfigWithoutAppliance,
117    /// An app with both an `image` and a `build`.
118    #[error("`image` and `build` are both given: a container comes from one")]
119    ImageAndBuild,
120    /// An appliance built from a Dockerfile.
121    #[error("an appliance comes from its `image` (or `firmware`), not a `build`")]
122    ApplianceBuild,
123    /// `firmware` without `appliance: cisco-dynamips`.
124    #[error("`firmware` is `cisco-dynamips`'s: it goes with `appliance: cisco-dynamips`")]
125    FirmwareWithoutDynamips,
126    /// An app with no recipe at all.
127    #[error("an app needs a recipe: `image`, `build`, `steps` or `appliance`")]
128    NoRecipe,
129    /// `cisco-dynamips` with an `image`.
130    #[error("`cisco-dynamips` boots its `firmware`; Isoloom builds its image, so no `image`")]
131    DynamipsImage,
132    /// `cisco-dynamips` without its `firmware`.
133    #[error("`cisco-dynamips` needs the `firmware` it boots (the IOS `.bin`)")]
134    DynamipsFirmware,
135    /// Another appliance with a `firmware`.
136    #[error("`firmware` is `cisco-dynamips`'s; this appliance runs its `image`")]
137    ApplianceFirmware,
138    /// An appliance without its `image`.
139    #[error("an appliance runs its `image` (`vrnetlab/cisco_iol:<version>`)")]
140    ApplianceImage,
141    /// A healthcheck with both a `test` and an `http` page.
142    #[error("`test` and `http` are both given: a healthcheck is a `test` or an `http` page")]
143    TestAndHttp,
144    /// A healthcheck that tries nothing.
145    #[error("a healthcheck says what to try: `{{ test: <command> }}` or `{{ http: {{ port, path }} }}`")]
146    NoHealthProbe,
147    /// A file with no source.
148    #[error("a file needs one of `source`, `content`, `input` and `secret`")]
149    NoFileSource,
150    /// A file with several sources.
151    #[error("`{}` are {} given: keep one source", .0.join("` and `"), if .0.len() == 2 { "both" } else { "all" })]
152    FileSources(Vec<&'static str>),
153    /// An environment value written as a reference to neither one input nor one secret.
154    #[error("write one of `{{ input: NAME }}`")]
155    EnvReference,
156    /// A GPU count that is neither a whole number nor `all`.
157    #[error("`gpus.count`: a whole number or `all`, not {0}")]
158    GpuCount(String),
159    /// `gpus` as a word other than `all`.
160    #[error("`gpus`: a number, `all` or a mapping, not `{0}`")]
161    GpusWord(String),
162    /// A device written with more than three parts.
163    #[error("`{0}`: a device is `host[:guest[:permissions]]`")]
164    Device(String),
165    /// A layer of the defaults (the user's or the project's file, `--images`) that isn't YAML.
166    #[error("{}: {source}", path.display())]
167    Layer {
168        /// The file.
169        path: PathBuf,
170        /// What the YAML reader reported.
171        #[source]
172        source: serde_yaml_ng::Error,
173    },
174    /// The defaults, merged, hold a key or a value they don't have.
175    #[error("defaults ({sources}): {source}")]
176    Defaults {
177        /// The layers merged, as `isoloom defaults` names them.
178        sources: String,
179        /// What the typed read reported.
180        #[source]
181        source: serde_yaml_ng::Error,
182    },
183    /// An image table that isn't one (`--images`, the defaults' `images:`).
184    #[error("{0}")]
185    Images(#[source] serde_yaml_ng::Error),
186    /// A profile's name that isn't kebab-case.
187    #[error("profile `{0}`: a profile name is kebab-case (lowercase letters, digits, dashes)")]
188    ProfileName(String),
189    /// No profile by that name.
190    #[error("no profile `{name}` ({})", Known { folder, known })]
191    NoProfile {
192        /// The name asked for.
193        name: String,
194        /// The project's profiles folder.
195        folder: PathBuf,
196        /// The profiles it has, by name.
197        known: Vec<String>,
198    },
199    /// Both `profiles/<name>.yml` and `.yaml` exist.
200    #[error("both {}/{name}.yml and {}/{name}.yaml exist; keep one", crate::profile::DIR, crate::profile::DIR)]
201    TwoProfiles {
202        /// The profile's name.
203        name: String,
204    },
205    /// A profile's file that isn't YAML, or holds a field a profile doesn't have.
206    #[error("profile `{name}` ({}): {source}", path.display())]
207    ProfileYaml {
208        /// The profile's name.
209        name: String,
210        /// Its file.
211        path: PathBuf,
212        /// What the YAML reader reported.
213        #[source]
214        source: serde_yaml_ng::Error,
215    },
216    /// A profile's `spec` or `defaults` that isn't a mapping.
217    #[error("profile `{name}` ({}): `{key}` is a mapping", path.display())]
218    ProfileMapping {
219        /// The profile's name.
220        name: String,
221        /// Its file.
222        path: PathBuf,
223        /// The field.
224        key: &'static str,
225    },
226    /// A profile's input that isn't one value.
227    #[error("profile `{name}` ({}): input `{input}` takes a single value (text, a number or a boolean)", path.display())]
228    ProfileInput {
229        /// The profile's name.
230        name: String,
231        /// Its file.
232        path: PathBuf,
233        /// The input.
234        input: String,
235    },
236    /// The profile names what the spec doesn't have: one line per problem.
237    #[error("{}", Lines(problems))]
238    ProfileMismatch {
239        /// The problems, one per field.
240        problems: Vec<ProfileProblem>,
241    },
242    /// Another target than the one the profile runs on.
243    #[error("profile `{profile}` runs on `{}`, not `{asked}`: drop the target or choose another profile", target.id())]
244    ProfileTarget {
245        /// The profile's name.
246        profile: String,
247        /// The target it runs on.
248        target: Target,
249        /// The target asked for.
250        asked: String,
251    },
252    /// An instance number outside 1 to [`crate::instance::MAX`].
253    #[error("an instance number is 1 to {}", crate::instance::MAX)]
254    InstanceNumber(u8),
255    /// An environment's name too long once it carries its instance.
256    #[error("`{0}` is too long for an instance name (at most 55 characters with the suffix)")]
257    InstanceName(String),
258    /// Text that isn't an IPv4 block.
259    #[error("`{0}` isn't an IPv4 block: write the network's own address, `/` and the prefix length (`10.20.0.0/24`)")]
260    Cidr(String),
261    /// Text that isn't an IPv6 /64 block.
262    #[error("`{0}` isn't an IPv6 block: write a unique local or global network address and `/64` (`fd00:20::/64`)")]
263    Cidr6(String),
264    /// A published port too high to shift for the instance.
265    #[error("machines.{machine}.ports: published port {port} leaves no room for instance {instance} (ports shift by 100 per instance)")]
266    InstancePort {
267        /// The machine.
268        machine: String,
269        /// The published port.
270        port: u16,
271        /// The instance.
272        instance: u8,
273    },
274    /// An IPv6 block too high to shift for the instance.
275    #[error("networks.{network}.ipv6.cidr: {cidr} leaves no room for instance {instance}")]
276    InstanceIpv6 {
277        /// The network.
278        network: String,
279        /// Its IPv6 block.
280        cidr: String,
281        /// The instance.
282        instance: u8,
283    },
284}
285
286/// What a function of this crate returns.
287pub type Result<T, E = Error> = std::result::Result<T, E>;
288
289/// Problems as an author reads them, one after the other.
290struct Rendered<'a> {
291    file: &'a str,
292    problems: &'a [Problem],
293}
294
295impl fmt::Display for Rendered<'_> {
296    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
297        for (i, p) in self.problems.iter().enumerate() {
298            if i > 0 {
299                f.write_str("\n")?;
300            }
301            f.write_str(&p.render(self.file))?;
302        }
303        Ok(())
304    }
305}
306
307/// Items one per line.
308struct Lines<'a, T>(&'a [T]);
309
310impl<T: fmt::Display> fmt::Display for Lines<'_, T> {
311    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
312        for (i, item) in self.0.iter().enumerate() {
313            if i > 0 {
314                f.write_str("\n")?;
315            }
316            write!(f, "{item}")?;
317        }
318        Ok(())
319    }
320}
321
322/// The profiles a project has, or the folder that has none.
323struct Known<'a> {
324    folder: &'a PathBuf,
325    known: &'a [String],
326}
327
328impl fmt::Display for Known<'_> {
329    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
330        if self.known.is_empty() {
331            write!(f, "no profiles in {}", self.folder.display())
332        } else {
333            write!(f, "profiles here: {}", self.known.join(", "))
334        }
335    }
336}
337
338#[cfg(test)]
339mod tests {
340    use super::*;
341    use crate::syntax::{Code, FieldPath};
342
343    #[test]
344    fn a_specs_problems_read_one_after_the_other() {
345        let problems = vec![
346            Problem::new(Code::MissingField, FieldPath::new("version"), "a spec says its format's version"),
347            Problem::new(Code::UnknownField, FieldPath::new("nme"), "`nme` isn't a field here"),
348        ];
349        let invalid = Error::Invalid {
350            file: "isoloom.yaml".into(),
351            problems,
352        };
353        assert_eq!(
354            invalid.to_string(),
355            "error[missing-field] version\n  a spec says its format's version\nerror[unknown-field] nme\n  `nme` isn't a field here"
356        );
357    }
358
359    #[test]
360    fn a_missing_profile_lists_the_profiles_there_are() {
361        let missing = |known: &[&str]| Error::NoProfile {
362            name: "c".into(),
363            folder: PathBuf::from("/lab/profiles"),
364            known: known.iter().map(|k| (*k).to_owned()).collect(),
365        };
366        assert_eq!(missing(&[]).to_string(), "no profile `c` (no profiles in /lab/profiles)");
367        assert_eq!(missing(&["a", "b"]).to_string(), "no profile `c` (profiles here: a, b)");
368    }
369
370    #[test]
371    fn a_profiles_problems_read_one_per_line() {
372        let problems = vec![
373            ProfileProblem::Input {
374                profile: "p".into(),
375                input: "A".into(),
376            },
377            ProfileProblem::Input {
378                profile: "p".into(),
379                input: "B".into(),
380            },
381        ];
382        assert_eq!(
383            Error::ProfileMismatch { problems }.to_string(),
384            "profile `p`: input `A` isn't one of the spec's `inputs`\nprofile `p`: input `B` isn't one of the spec's `inputs`"
385        );
386    }
387
388    #[test]
389    fn several_sources_of_one_file_are_named_together() {
390        assert_eq!(
391            Error::FileSources(vec!["source", "input"]).to_string(),
392            "`source` and `input` are both given: keep one source"
393        );
394        assert_eq!(
395            Error::FileSources(vec!["source", "content", "input"]).to_string(),
396            "`source` and `content` and `input` are all given: keep one source"
397        );
398    }
399}