Skip to main content

typed_openapi/
tree.rs

1//! The clap tree, and the trip back from `ArgMatches` to a sent request.
2//!
3//! The tree is two levels: one subcommand per resource the document groups its
4//! paths into, and one subcommand under that per operation — `vouchers update`
5//! rather than `update-voucher`. Both names are decided while the document is
6//! reduced and travel in the reduced model, so nothing here derives them.
7//!
8//! [`commands`] turns the document into those subcommands and [`dispatch`] runs
9//! whichever one the user typed, so an adopter who wants the generated surface
10//! and nothing else writes those two calls and renders the [`Outcome`].
11//!
12//! [`select`] and [`Selection::send`] are the same trip with a seam in the
13//! middle, for an adopter who has a check of their own to make — holding the
14//! body to a generated type, say — between what the user typed and what goes
15//! out. [`dispatch`] is the two of them in order.
16//!
17//! The flag-to-wire-name translation lives here and nowhere else: below this
18//! module the vocabulary is the document's own names, which is why a Rust
19//! caller can use the same request builder without ever meeting a flag.
20
21use std::io::Read as _;
22use std::path::{Path, PathBuf};
23
24use clap::{Arg, ArgAction, ArgMatches, Command, ValueHint, builder::PossibleValuesParser};
25use http::Uri;
26use thiserror::Error;
27
28use crate::model::{
29    Body, COMMIT, Document, Effect, FIELD_PART, FILE_PART, Field, Gate, JSON_BODY, Location,
30    Operation, Param, RAW_BODY, Shape,
31};
32use crate::names::{CommandName, renamed};
33use crate::plan::{Answers, Plan, PlanError};
34use crate::scalar::Scalar;
35use crate::transport::{HttpRequest, HttpResponse, SyncClient};
36use crate::values::{Part, Payload, Values};
37
38/// Something about the arguments the document cannot repair.
39#[derive(Debug, Error)]
40pub enum ArgError {
41    #[error("cannot read {}: {source}", path.display())]
42    ReadBody {
43        path: PathBuf,
44        #[source]
45        source: std::io::Error,
46    },
47    #[error("{} does not hold JSON: {source}", path.display())]
48    ParseBody {
49        path: PathBuf,
50        #[source]
51        source: serde_json::Error,
52    },
53    #[error("`--{flag} {raw}` is not `NAME=VALUE`")]
54    PartSyntax { flag: &'static str, raw: String },
55}
56
57/// One subcommand per group, in document order, holding the operations under
58/// it in document order.
59#[must_use]
60pub fn commands(doc: &Document) -> Vec<Command> {
61    let mut groups: Vec<(&CommandName, Vec<&Operation>)> = Vec::new();
62    for op in doc {
63        match groups.iter_mut().find(|(name, _)| *name == op.group()) {
64            Some((_, under)) => under.push(op),
65            None => groups.push((op.group(), vec![op])),
66        }
67    }
68    groups
69        .into_iter()
70        .map(|(name, under)| group(name, under))
71        .collect()
72}
73
74/// The subcommand for one group: a name the document grouped by, and every
75/// operation it grouped under it.
76fn group(name: &CommandName, under: Vec<&Operation>) -> Command {
77    Command::new(name.as_str().to_owned())
78        .about(format!("Operations on {name}"))
79        .subcommand_required(true)
80        .arg_required_else_help(true)
81        .subcommands(under.into_iter().map(command))
82}
83
84/// The subcommand for one operation.
85#[must_use]
86pub fn command(op: &Operation) -> Command {
87    let mut cmd = Command::new(op.command().as_str().to_owned());
88    if let Some(summary) = op.summary() {
89        cmd = cmd.about(summary.to_owned());
90    }
91    cmd = cmd.long_about(long_about(op));
92    for arg in op.params().iter().filter_map(param_arg) {
93        cmd = cmd.arg(arg);
94    }
95    cmd = body_args(cmd, op.body());
96    if op.effect() == Effect::Write {
97        cmd = cmd.arg(
98            Arg::new(COMMIT)
99                .long(COMMIT)
100                .action(ArgAction::SetTrue)
101                .help("Send the request. Without it this is a dry run that prints it"),
102        );
103    }
104    gates(cmd, op)
105}
106
107/// The gate flags one operation names, added to a command of your own.
108///
109/// [`command`] puts them on the subcommand it builds, and this is that same
110/// door: a verb you write yourself — one that fetches, decides, and then calls a
111/// gated operation — adds them with this and spells nothing itself. One
112/// definition rather than two is what keeps the two command lines from coming to
113/// disagree about one operation, and an Overlay that renames a gate renames the
114/// flag on both.
115///
116/// An operation that names no gate is handed back unchanged, so a caller does
117/// not have to ask first:
118///
119/// ```rust,ignore
120/// tree::gates(Command::new("finalize-voucher").arg(id).arg(commit), op)
121/// ```
122#[must_use]
123pub fn gates(cmd: Command, op: &Operation) -> Command {
124    op.gates()
125        .iter()
126        .fold(cmd, |cmd, gate| cmd.arg(gate_arg(gate)))
127}
128
129/// One named gate: a flag that has to be typed in addition to `--commit`.
130///
131/// `required(true)` rather than merely read at the gate, and that is the whole
132/// point of naming a hazard: a dry run of the operation that mails a stranger
133/// is still a command line somebody had to write `--email` on. The word comes
134/// before the request exists, not after it is built.
135fn gate_arg(gate: &Gate) -> Arg {
136    Arg::new(gate.as_str().to_owned())
137        .long(gate.as_str().to_owned())
138        .action(ArgAction::SetTrue)
139        .required(true)
140        .help(format!(
141            "Required, and demanded in addition to --commit: this operation \
142             stands behind the `{gate}` gate"
143        ))
144}
145
146/// The long help: what the document says, then the wire request this
147/// subcommand stands for, so an agent can see the method and path without
148/// opening the document.
149fn long_about(op: &Operation) -> String {
150    use std::fmt::Write as _;
151
152    let mut out = String::new();
153    if let Some(text) = op.description().or_else(|| op.summary()) {
154        out.push_str(text);
155        out.push_str("\n\n");
156    }
157    let _ = write!(
158        out,
159        "{} {}  (operationId: {})",
160        op.method(),
161        op.path(),
162        op.id()
163    );
164    if op.effect() == Effect::Write {
165        out.push_str("\n\nThis operation writes. Without --commit it is a dry run.");
166    }
167    if !op.gates().is_empty() {
168        let named: Vec<String> = op.gates().iter().map(|gate| format!("--{gate}")).collect();
169        let _ = write!(
170            out,
171            "\n\nNamed gates: {}. Each one is required, and demanded in \
172             addition to --commit.",
173            named.join(", ")
174        );
175    }
176    // A parameter with no flag is said here, where a flag would have been. The
177    // document declares it and this CLI cannot supply it, and a user reading
178    // `--help` is owed both halves of that.
179    for param in op.params() {
180        if let Shape::Unreachable(why) = param.shape() {
181            let _ = write!(
182                out,
183                "\n\n`{}` has no flag: it is {why}. The request goes out without it.",
184                param.name()
185            );
186        }
187    }
188    out
189}
190
191/// What the user answered at the gate, read off one operation's `ArgMatches`:
192/// the write confirmation, and every named gate the operation carries.
193///
194/// A read carries neither flag, so there is nothing to ask it.
195///
196/// A flag the command never declared reads as unanswered, which is the closed
197/// answer: it holds the request back. That is what makes this safe to point at
198/// a command this crate did not build — a verb of your own, or one built before
199/// an Overlay named a new gate. Give your own command its flags with [`gates`]
200/// and the two stay in step by construction.
201#[must_use]
202pub fn answers(op: &Operation, matches: &ArgMatches) -> Answers {
203    if op.effect() == Effect::Read {
204        return Answers::new();
205    }
206    let mut answered = Answers::new();
207    if flag(matches, COMMIT) {
208        answered = answered.commit();
209    }
210    for gate in op.gates() {
211        if flag(matches, gate.as_str()) {
212            answered = answered.gate(gate.as_str());
213        }
214    }
215    answered
216}
217
218/// Did the command line carry this flag?
219///
220/// `ArgMatches::get_flag` panics on an id the `Command` never declared, and a
221/// panic is the wrong answer to "is this gate answered?". A command with no
222/// flag for a gate has no answer for it, and no answer is `false` — the reading
223/// the gate has everywhere else, and the one that holds the request back.
224fn flag(matches: &ArgMatches, id: &str) -> bool {
225    matches
226        .try_get_one::<bool>(id)
227        .ok()
228        .flatten()
229        .copied()
230        .unwrap_or(false)
231}
232
233/// Read one operation's arguments out of its `ArgMatches`, under the names the
234/// document uses.
235pub fn values(op: &Operation, matches: &ArgMatches) -> Result<Values, ArgError> {
236    let mut values = Values::new();
237    for param in op.params() {
238        let Shape::Flag { flag, .. } = param.shape() else {
239            continue;
240        };
241        // `get_many` serves both kinds: a flag given once yields one value, and
242        // a repeatable one yields every occurrence, in the order they were
243        // typed — which is the order they go on the wire in.
244        for raw in strings(matches, flag) {
245            values = values.param(param.name(), raw);
246        }
247    }
248    Ok(values.body(payload(op, matches)?))
249}
250
251/// What running one operation produced.
252///
253/// The two arms are the gate's two answers, carried far enough to render: a
254/// response that came back, or the request that was not sent. Printing is the
255/// adopter's — this crate decides and executes, and hands the result over.
256#[derive(Debug)]
257pub enum Outcome {
258    /// The request went out; this is what came back, status and all. A 4xx is
259    /// an outcome, not an error: the body that came with it is what a caller
260    /// needs.
261    Sent(HttpResponse),
262    /// A write without confirmation. Nothing was sent, and this is the exact
263    /// request that a confirmed run would have sent.
264    DryRun(HttpRequest),
265}
266
267/// Why an operation the user named did not run.
268///
269/// The transport's own error is the source of [`Self::Transport`] rather than
270/// a variant of its own, so this enum does not grow a type parameter for the
271/// client and an adopter's error type can absorb it whole.
272#[derive(Debug, Error)]
273pub enum DispatchError {
274    #[error("no command given")]
275    NoCommand,
276    #[error("no operation named `{group} {command}` in the document")]
277    Unknown { group: String, command: String },
278    #[error(transparent)]
279    Arg(#[from] ArgError),
280    #[error(transparent)]
281    Plan(#[from] PlanError),
282    /// The client refused the request or never got an answer, carrying the
283    /// error the client itself returned.
284    ///
285    /// What the box costs is worth saying out loud: this crate cannot tell
286    /// whether the request left, and on a write that is the difference between
287    /// an operation that did nothing and one that may have done everything.
288    /// Only an adapter can read that out of its own client's error, so the
289    /// reading belongs above the seam — the position
290    /// [`RecorderError`](crate::RecorderError) states for a scripted failure,
291    /// and the same one here. A failure nobody has classified is one that may
292    /// have arrived, which is the only safe default to hold it at.
293    ///
294    /// Reading it is not shut off, though. The box holds `C::Error` exactly as
295    /// the client returned it, so `downcast_ref` recovers it — and the type a
296    /// catch site names is the error type the adopter's own seam fixes, not
297    /// whichever client the call went through, because `&dyn SyncClient<Error
298    /// = E>` fixes `E` for every client behind it. A caller who wants no box at
299    /// all builds the request with [`Plan`] and sends it through the client's
300    /// own `send`. `docs/client.md` shows both.
301    #[error("transport: {0}")]
302    Transport(#[source] Box<dyn std::error::Error + Send + Sync>),
303}
304
305/// One operation the user named, with its arguments read and its gate answered
306/// — everything needed to send it, and nothing sent yet.
307///
308/// This is the seam an adopter puts a check of their own into. `send` consumes
309/// it, so the request is built once and cannot be sent twice.
310#[derive(Debug)]
311pub struct Selection<'d> {
312    operation: &'d Operation,
313    values: Values,
314    answers: Answers,
315}
316
317impl<'d> Selection<'d> {
318    /// The operation the subcommand named.
319    #[must_use]
320    pub fn operation(&self) -> &'d Operation {
321        self.operation
322    }
323
324    /// The arguments, under the document's own names rather than the flags they
325    /// arrived as. A body a generated type should vet is
326    /// `selection.values().payload()`.
327    #[must_use]
328    pub fn values(&self) -> &Values {
329        &self.values
330    }
331
332    /// What the user answered at the gate: the write confirmation, and each
333    /// named gate the operation carries. A read answers nothing, because a read
334    /// is asked nothing.
335    #[must_use]
336    pub fn answers(&self) -> &Answers {
337        &self.answers
338    }
339
340    /// Build the request, put it to the gate, and do what the gate decided.
341    pub fn send<C: SyncClient>(self, client: &C, base: &Uri) -> Result<Outcome, DispatchError> {
342        match Plan::build(self.operation, base, self.values, &self.answers)? {
343            Plan::Send(request) => client
344                .send(request)
345                .map(Outcome::Sent)
346                .map_err(|error| DispatchError::Transport(Box::new(error))),
347            Plan::DryRun(request) => Ok(Outcome::DryRun(request)),
348        }
349    }
350}
351
352/// Read the two subcommands the user typed, whichever command the groups were
353/// mounted on.
354///
355/// `matches` belongs to that command: the root itself when the operations are
356/// the whole CLI, or the `raw` subcommand when they sit under one. This
357/// function reads the group below it and the operation below that, and never
358/// looks above it, which is what lets the same tree mount anywhere.
359pub fn select<'d>(doc: &'d Document, matches: &ArgMatches) -> Result<Selection<'d>, DispatchError> {
360    let (group, under) = matches.subcommand().ok_or(DispatchError::NoCommand)?;
361    let (command, args) = under.subcommand().ok_or(DispatchError::NoCommand)?;
362    let operation = doc
363        .by_command(group, command)
364        .ok_or_else(|| DispatchError::Unknown {
365            group: group.to_owned(),
366            command: command.to_owned(),
367        })?;
368    Ok(Selection {
369        values: values(operation, args)?,
370        answers: answers(operation, args),
371        operation,
372    })
373}
374
375/// [`select`], then [`Selection::send`]: the whole generated surface in one
376/// call, for an adopter with no check of their own to make.
377pub fn dispatch<C: SyncClient>(
378    doc: &Document,
379    base: &Uri,
380    client: &C,
381    matches: &ArgMatches,
382) -> Result<Outcome, DispatchError> {
383    select(doc, matches)?.send(client, base)
384}
385
386fn payload(op: &Operation, matches: &ArgMatches) -> Result<Option<Payload>, ArgError> {
387    match op.body() {
388        Body::None => Ok(None),
389        // A missing required body is `Invocation::new`'s to report, so that one
390        // place decides what satisfies an operation.
391        Body::JsonWhole { .. } => matches
392            .get_one::<PathBuf>(JSON_BODY)
393            .map(|path| read_json(path).map(Payload::Json))
394            .transpose(),
395        Body::Opaque { .. } => matches
396            .get_one::<PathBuf>(RAW_BODY)
397            .map(|path| read_bytes(path).map(Payload::Raw))
398            .transpose(),
399        Body::Multipart { .. } => parts(matches).map(|parts| parts.map(Payload::Multipart)),
400        Body::JsonFields(fields) => Ok(Some(Payload::Json(assembled(fields, matches)?))),
401    }
402}
403
404/// `--json-body` is the base document; per-field flags are merged over it, so a
405/// flag beside a file is an edit rather than a value the CLI silently drops.
406fn assembled(fields: &[Field], matches: &ArgMatches) -> Result<serde_json::Value, ArgError> {
407    let mut body = match matches.get_one::<PathBuf>(JSON_BODY) {
408        Some(path) => read_json(path)?,
409        None => serde_json::Value::Object(serde_json::Map::new()),
410    };
411    let Some(object) = body.as_object_mut() else {
412        return Ok(body);
413    };
414    for field in fields {
415        let Some(raw) = matches.get_one::<String>(field.flag()) else {
416            continue;
417        };
418        // The flag's value parser has already accepted this, so the same rule
419        // is not applied twice with two error shapes; the second call is a
420        // conversion, and `Invocation::new` re-checks the whole set anyway.
421        if let Ok(value) = field.scalar().parse(raw) {
422            object.insert(field.name().to_owned(), value);
423        }
424    }
425    Ok(body)
426}
427
428/// `--file NAME=PATH` and `--field NAME=VALUE`, in the order they were given.
429fn parts(matches: &ArgMatches) -> Result<Option<Vec<Part>>, ArgError> {
430    let mut parts = Vec::new();
431    for raw in strings(matches, FIELD_PART) {
432        let (name, value) = split_part(FIELD_PART, raw)?;
433        parts.push(Part::text(name, value));
434    }
435    for raw in strings(matches, FILE_PART) {
436        let (name, path) = split_part(FILE_PART, raw)?;
437        let path = PathBuf::from(path);
438        let filename = path
439            .file_name()
440            .map_or_else(|| name.to_owned(), |f| f.to_string_lossy().into_owned());
441        parts.push(Part::file(name, filename, read_bytes(&path)?));
442    }
443    Ok((!parts.is_empty()).then_some(parts))
444}
445
446fn strings<'m>(matches: &'m ArgMatches, id: &str) -> impl Iterator<Item = &'m String> {
447    matches.get_many::<String>(id).into_iter().flatten()
448}
449
450/// `NAME=REST`, splitting at the first `=` so a value may contain more.
451fn split_part<'r>(flag: &'static str, raw: &'r str) -> Result<(&'r str, &'r str), ArgError> {
452    match raw.split_once('=') {
453        Some((name, rest)) if !name.is_empty() => Ok((name, rest)),
454        Some(_) | None => Err(ArgError::PartSyntax {
455            flag,
456            raw: raw.to_owned(),
457        }),
458    }
459}
460
461/// The flag one parameter grows — and nothing at all for one this CLI cannot
462/// supply, which the subcommand's long help names instead, where a dead flag
463/// would otherwise have stood.
464fn param_arg(param: &Param) -> Option<Arg> {
465    let Shape::Flag {
466        flag,
467        location,
468        scalar,
469        join,
470    } = param.shape()
471    else {
472        return None;
473    };
474    // A document that describes nothing still knows where the value goes, and
475    // a help page with an empty line beside a flag helps nobody.
476    let described = param.description().map_or_else(
477        || {
478            Some(format!(
479                "The `{}` {} parameter",
480                param.name(),
481                match location {
482                    Location::Path => "path",
483                    Location::Query => "query",
484                    Location::Header => "header",
485                }
486            ))
487        },
488        |text| Some(text.to_owned()),
489    );
490    let notes = join
491        .map(|join| join.note().to_owned())
492        .into_iter()
493        .chain(wire(renamed(flag, param.name()), param.name()));
494    let mut arg = value_arg(
495        flag,
496        scalar,
497        help_line(described.as_deref(), scalar, notes),
498        param.required(),
499    );
500    if join.is_some() {
501        arg = arg.action(ArgAction::Append);
502    }
503    Some(arg)
504}
505
506/// A flag that had to move aside says which wire name it carries.
507fn wire(renamed: bool, name: &str) -> Option<String> {
508    renamed.then(|| format!("sends `{name}`"))
509}
510
511fn body_args(cmd: Command, body: &Body) -> Command {
512    match body {
513        Body::None => cmd,
514        Body::JsonFields(fields) => json_field_args(cmd, fields),
515        Body::JsonWhole { required } => cmd.arg(file_arg(JSON_BODY, *required).help(
516            "JSON body read from a file; `-` is stdin. This body is nested, so it has \
517             no per-field flags",
518        )),
519        Body::Multipart { names, required } => multipart_args(cmd, names, *required),
520        Body::Opaque {
521            media_type,
522            required,
523        } => cmd.arg(file_arg(RAW_BODY, *required).help(format!(
524            "Request body read from a file, sent verbatim as `{media_type}`; `-` is stdin. \
525             This CLI does not assemble that media type"
526        ))),
527    }
528}
529
530/// One flag per scalar property, and `--json-body` as the base document they
531/// are merged over.
532fn json_field_args(cmd: Command, fields: &[Field]) -> Command {
533    let mut cmd = cmd;
534    for field in fields {
535        let mut arg = value_arg(
536            field.flag(),
537            field.scalar(),
538            help_line(
539                field.description(),
540                field.scalar(),
541                wire(field.renamed(), field.name()),
542            ),
543            false,
544        );
545        if field.required() {
546            arg = arg.required_unless_present(JSON_BODY);
547        }
548        cmd = cmd.arg(arg);
549    }
550    cmd.arg(file_arg(JSON_BODY, false).help(
551        "JSON body read from a file; `-` is stdin. It is the base document: \
552         the per-field flags above are merged over it, so a flag wins over \
553         the same key in the file",
554    ))
555}
556
557/// `--file` and `--field`, repeatable. The part names are the document's, but
558/// they are advice rather than a constraint: a document that declares none (or
559/// declares them wrongly) is common, and refusing would help nobody.
560fn multipart_args(cmd: Command, names: &[String], required: bool) -> Command {
561    let declared = if names.is_empty() {
562        String::new()
563    } else {
564        format!(" The document declares: {}.", names.join(", "))
565    };
566    cmd.arg(
567        Arg::new(FILE_PART)
568            .long(FILE_PART)
569            .value_name("NAME=PATH")
570            .action(ArgAction::Append)
571            .required(required)
572            .value_hint(ValueHint::Other)
573            .help(format!(
574                "One file part of the multipart body, repeatable.{declared}"
575            )),
576    )
577    .arg(
578        Arg::new(FIELD_PART)
579            .long(FIELD_PART)
580            .value_name("NAME=VALUE")
581            .action(ArgAction::Append)
582            .value_hint(ValueHint::Other)
583            .help("One text part of the multipart body, repeatable"),
584    )
585}
586
587fn value_arg(flag: &str, scalar: &Scalar, help: Option<String>, required: bool) -> Arg {
588    let mut arg = Arg::new(flag.to_owned())
589        .long(flag.to_owned())
590        .value_name(scalar.value_name())
591        .required(required)
592        // Never `FilePath`: a value flag is not a path, and a dynamic completer
593        // that offers the working directory for `--currency` is worse than one
594        // that offers nothing.
595        .value_hint(ValueHint::Other);
596    // The document's rule runs at parse time, so a bad amount reports itself
597    // the way a bad enum value does — one error shape for one kind of mistake.
598    arg = if let Scalar::Choice(values) = scalar {
599        arg.value_parser(PossibleValuesParser::new(values))
600    } else {
601        let scalar = scalar.clone();
602        arg.value_parser(move |raw: &str| {
603            scalar
604                .parse(raw)
605                .map(|_| raw.to_owned())
606                .map_err(|e| e.to_string())
607        })
608    };
609    if let Some(help) = help {
610        arg = arg.help(help);
611    }
612    arg
613}
614
615fn file_arg(flag: &'static str, required: bool) -> Arg {
616    Arg::new(flag)
617        .long(flag)
618        .value_name("FILE")
619        .required(required)
620        .value_parser(clap::value_parser!(PathBuf))
621        .value_hint(ValueHint::FilePath)
622}
623
624/// The description, then whatever the document constrains, then whatever else
625/// the caller has to add — how a repeatable flag is joined, the wire name a flag
626/// that moved aside carries — each in brackets, none of them invented.
627fn help_line(
628    description: Option<&str>,
629    scalar: &Scalar,
630    extra: impl IntoIterator<Item = String>,
631) -> Option<String> {
632    let notes: Vec<String> = scalar.note().into_iter().chain(extra).collect();
633    match (description, notes.is_empty()) {
634        (Some(text), true) => Some(text.to_owned()),
635        (Some(text), false) => Some(format!("{text} ({})", notes.join("; "))),
636        (None, true) => None,
637        (None, false) => Some(notes.join("; ")),
638    }
639}
640
641fn read_json(path: &Path) -> Result<serde_json::Value, ArgError> {
642    let bytes = read_bytes(path)?;
643    serde_json::from_slice(&bytes).map_err(|source| ArgError::ParseBody {
644        path: path.to_owned(),
645        source,
646    })
647}
648
649fn read_bytes(path: &Path) -> Result<Vec<u8>, ArgError> {
650    let read = |source| ArgError::ReadBody {
651        path: path.to_owned(),
652        source,
653    };
654    if path == Path::new("-") {
655        let mut bytes = Vec::new();
656        std::io::stdin().read_to_end(&mut bytes).map_err(read)?;
657        return Ok(bytes);
658    }
659    std::fs::read(path).map_err(read)
660}
661
662#[cfg(test)]
663mod tests {
664    use super::*;
665
666    #[test]
667    fn a_part_splits_at_the_first_equals_only() {
668        assert_eq!(
669            split_part(FILE_PART, "file=a=b").ok(),
670            Some(("file", "a=b"))
671        );
672        assert_eq!(split_part(FIELD_PART, "k=").ok(), Some(("k", "")));
673        assert!(split_part(FILE_PART, "nope").is_err());
674        assert!(split_part(FILE_PART, "=path").is_err());
675    }
676}