evobench_tools/run/output_directory/
structure.rs

1//! The directory structure for output files.
2
3use std::{
4    collections::BTreeMap,
5    ffi::OsStr,
6    fmt::Display,
7    path::{Path, PathBuf},
8    str::FromStr,
9    sync::{Arc, OnceLock},
10};
11
12use anyhow::{Result, anyhow, bail};
13use cj_path_util::{path_util::AppendToPath, unix::polyfill::add_extension};
14use derive_more::From;
15use kstring::KString;
16
17use crate::{
18    clone, ctx,
19    git::GitHash,
20    info,
21    run::{
22        config::JobTemplate,
23        env_vars::AllowableCustomEnvVar,
24        key::{
25            BenchmarkingJobParameters, CustomParameters, ExtendPath, RunParameters,
26            UncheckedCustomParameters,
27        },
28    },
29    serde_types::{
30        allowed_env_var::AllowedEnvVar, date_and_time::DateTimeWithOffset,
31        proper_dirname::ProperDirname, proper_filename::ProperFilename,
32    },
33    utillib::{
34        arc::CloneArc, into_arc_path::IntoArcPath, invert::Invert, path_is_top::PathIsTop,
35        type_name_short::type_name_short,
36    },
37};
38
39// --- The types ----------------------------------------------------------------
40
41/// The dir representing all of a key except for the commit id
42/// (i.e. custom parameters and target name--note that this is *not*
43/// the same info as `RunParameters` contains!).
44///
45/// Note that it contains env vars that may *not* be checked against
46/// the config. They are still guaranteed to follow the general
47/// requirements for env var names (as per
48/// `AllowedEnvVar<AllowableCustomEnvVar>::from_str`). Similarly,
49/// `target_name` may not be checked against anything (other than
50/// being a directory name).
51#[derive(Debug)]
52pub struct ParametersDir {
53    base_path: Arc<Path>,
54    target_name: ProperDirname,
55    custom_parameters: CheckedOrUncheckedCustomParameters,
56    path_cache: OnceLock<Arc<Path>>,
57}
58
59/// Dir representing all of the key, including commit id at the
60/// end. I.e. one level below a `RunParametersDir`.
61#[derive(Debug, Clone)]
62pub struct KeyDir {
63    parent: Arc<ParametersDir>,
64    commit_id: GitHash,
65    path_cache: OnceLock<Arc<Path>>,
66}
67
68/// Dir with the results for an individual benchmarking run. I.e. one
69/// level below a `KeyDir`.
70#[derive(Debug, Clone)]
71pub struct RunDir {
72    parent: Arc<KeyDir>,
73    timestamp: DateTimeWithOffset,
74    path_cache: OnceLock<Arc<Path>>,
75}
76
77/// Any kind of *Dir
78#[derive(Debug, derive_more::From)]
79pub enum OutputSubdir {
80    ParametersDir(Arc<ParametersDir>),
81    KeyDir(Arc<KeyDir>),
82    RunDir(Arc<RunDir>),
83}
84
85// --- Their implementations ----------------------------------------------------
86
87pub trait ToPath {
88    /// May be slightly costly on first run but then cached in those
89    /// cases
90    fn to_path(&self) -> &Arc<Path>;
91}
92
93pub trait SubDirs: ToPath {
94    type Target;
95
96    fn append_subdir_str(self: Arc<Self>, file_name: &str) -> Result<Self::Target>;
97
98    /// Skips non-directory entries, but requires all directory entries to
99    /// be convertible to `T`.
100    fn sub_dirs(self: &Arc<Self>) -> Result<impl Iterator<Item = Result<Self::Target>>> {
101        let dir_path = self.to_path().to_owned();
102        Ok(std::fs::read_dir(&dir_path)
103            .map_err(ctx!("opening dir {dir_path:?}"))?
104            .map(|entry| -> Result<Option<Self::Target>> {
105                let entry: std::fs::DirEntry = entry?;
106                let ft = entry.file_type()?;
107                if ft.is_dir() {
108                    if let Some(file_name) = entry.file_name().to_str() {
109                        match self.clone_arc().append_subdir_str(&file_name) {
110                            Ok(v) => Ok(Some(v)),
111                            Err(e) => {
112                                info!("ignoring subdir that does not parse: {e:#}");
113                                Ok(None)
114                            }
115                        }
116                    } else {
117                        info!(
118                            "ignoring path with file name that doesn't decode as string: {:?}",
119                            entry.path()
120                        );
121                        Ok(None)
122                    }
123                } else {
124                    Ok(None)
125                }
126            })
127            .filter_map({
128                move |r| {
129                    r.map_err(ctx!(
130                        "getting {} listing for dir {dir_path:?}",
131                        type_name_short::<Self>()
132                    ))
133                    .transpose()
134                }
135            }))
136    }
137}
138
139pub trait ReplaceBasePath {
140    fn replace_base_path(&self, base_path: Arc<Path>) -> Self;
141}
142
143/// Parse the file name part of a path. You should provide context
144/// around this call for the full path or similar.
145fn parse_filename<T: FromStr>(file_name: &OsStr) -> Result<T>
146where
147    T::Err: Display,
148{
149    if let Ok(file_name_str) = file_name.to_owned().into_string() {
150        T::from_str(&file_name_str).map_err(|e| {
151            anyhow!(
152                "dir name {file_name_str:?} does not parse as {}: {e:#}",
153                type_name_short::<T>()
154            )
155        })
156    } else {
157        let lossy1 = file_name.to_string_lossy();
158        let lossy: &str = lossy1.as_ref();
159        bail!("can't decode dir name to string: {lossy:?}");
160    }
161}
162
163/// Parse a path's filename as T.
164fn parse_path_filename<T: FromStr>(path: &Path) -> Result<(T, &Path)>
165where
166    T::Err: Display,
167{
168    let file_name = path
169        .file_name()
170        .ok_or_else(|| anyhow!("path is missing a file name"))?;
171    let dir = path
172        .parent()
173        .ok_or_else(|| anyhow!("path is missing a parent dir"))?;
174    match parse_filename(file_name) {
175        Ok(val) => Ok((val, dir)),
176        Err(e) => Err(e),
177    }
178}
179
180/// Need to be able to handle unchecked custom parameters for parsing
181/// since there's no config file for parsing a file system, or more to
182/// the point, when the config changes, the file system must still be
183/// readable, thus the representation must remain independent of the
184/// config. But allow to represent both. Do this at runtime for
185/// ~simplicity. (Maybe this should be moved to where the contained
186/// types are defined, which maybe shouldn't all be in `key.rs`.)
187#[derive(Debug, Clone, From)]
188pub enum CheckedOrUncheckedCustomParameters {
189    UncheckedCustomParameters(#[from] Arc<UncheckedCustomParameters>),
190    CustomParameters(#[from] Arc<CustomParameters>),
191}
192
193impl CheckedOrUncheckedCustomParameters {
194    pub fn extend_path(&self, path: PathBuf) -> PathBuf {
195        match self {
196            CheckedOrUncheckedCustomParameters::UncheckedCustomParameters(v) => v.extend_path(path),
197            CheckedOrUncheckedCustomParameters::CustomParameters(v) => v.extend_path(path),
198        }
199    }
200
201    pub fn get(&self, key: &AllowedEnvVar<AllowableCustomEnvVar>) -> Option<&str> {
202        match self {
203            CheckedOrUncheckedCustomParameters::UncheckedCustomParameters(v) => {
204                v.btree_map().get(key).map(AsRef::as_ref)
205            }
206            CheckedOrUncheckedCustomParameters::CustomParameters(v) => {
207                v.btree_map().get(key).map(AsRef::as_ref)
208            }
209        }
210    }
211}
212
213impl Display for CheckedOrUncheckedCustomParameters {
214    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
215        match self {
216            CheckedOrUncheckedCustomParameters::UncheckedCustomParameters(v) => v.fmt(f),
217            CheckedOrUncheckedCustomParameters::CustomParameters(v) => v.fmt(f),
218        }
219    }
220}
221
222impl PartialEq for ParametersDir {
223    fn eq(&self, other: &Self) -> bool {
224        self.to_path().eq(other.to_path())
225    }
226}
227impl Eq for ParametersDir {}
228
229impl PartialOrd for ParametersDir {
230    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
231        self.to_path().partial_cmp(other.to_path())
232    }
233}
234
235impl Ord for ParametersDir {
236    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
237        self.to_path().cmp(other.to_path())
238    }
239}
240
241impl ToPath for ParametersDir {
242    fn to_path(&self) -> &Arc<Path> {
243        let Self {
244            base_path,
245            target_name,
246            custom_parameters,
247            path_cache,
248        } = self;
249        path_cache.get_or_init(|| {
250            custom_parameters
251                .extend_path(base_path.append(target_name.as_str()))
252                .into()
253        })
254    }
255}
256
257impl SubDirs for ParametersDir {
258    type Target = KeyDir;
259
260    fn append_subdir_str(self: Arc<Self>, file_name: &str) -> Result<Self::Target> {
261        let commit_id = parse_filename(file_name.as_ref())?;
262        Ok(KeyDir {
263            parent: self,
264            commit_id,
265            path_cache: Default::default(),
266        })
267    }
268}
269
270impl TryFrom<Arc<Path>> for ParametersDir {
271    type Error = anyhow::Error;
272
273    fn try_from(path: Arc<Path>) -> std::result::Result<Self, Self::Error> {
274        let target_name;
275        let custom_env_vars;
276        let base_path;
277        {
278            let mut current_path = &*path;
279            let mut current_vars = BTreeMap::new();
280            loop {
281                if let Some(dir_name) = current_path.file_name() {
282                    let dir_name_str = dir_name.to_str().ok_or_else(|| {
283                        anyhow!(
284                            "directory segment can't be decoded as string: {:?} in {:?}",
285                            dir_name.to_string_lossy().as_ref(),
286                            path
287                        )
288                    })?;
289                    if let Some((var_name, val)) = dir_name_str.split_once('=') {
290                        let key = AllowedEnvVar::from_str(var_name)?;
291                        let val = KString::from_ref(val);
292                        current_vars.insert(key, val);
293
294                        if let Some(parent) = current_path.parent() {
295                            if parent.is_top() {
296                                bail!(
297                                    "parsing {} {:?}: missing target segment left of the var segments",
298                                    type_name_short::<Self>(),
299                                    path
300                                );
301                            }
302                            current_path = parent;
303                        } else {
304                            unreachable!("because file_name() above already failed, right?")
305                        }
306                    } else {
307                        target_name = ProperDirname::from_str(dir_name_str).map_err(|msg| {
308                            anyhow!("not a proper directory name: {dir_name_str:?}: {msg}")
309                        })?;
310                        custom_env_vars = current_vars;
311                        if let Some(parent) = current_path.parent() {
312                            base_path = parent.into();
313                        } else {
314                            // This never happens, right?
315                            bail!("path is missing a base_dir part (1): {path:?}")
316                        }
317                        break;
318                    }
319                } else {
320                    if current_path.is_top() {
321                        bail!("path is missing a target or base_dir part: {path:?}")
322                    }
323                    bail!("path {path:?} contains a '..' or '.' part: {current_path:?}")
324                }
325            }
326        }
327        Ok(Self {
328            base_path,
329            target_name,
330            custom_parameters: CheckedOrUncheckedCustomParameters::UncheckedCustomParameters(
331                Arc::new(UncheckedCustomParameters::from(custom_env_vars)),
332            ),
333            path_cache: path.into(),
334        })
335    }
336}
337
338impl ReplaceBasePath for ParametersDir {
339    fn replace_base_path(&self, base_path: Arc<Path>) -> Self {
340        let Self {
341            base_path: _,
342            target_name,
343            custom_parameters,
344            path_cache: _,
345        } = self;
346        clone!(target_name);
347        clone!(custom_parameters);
348        Self {
349            base_path,
350            target_name,
351            custom_parameters,
352            path_cache: Default::default(),
353        }
354    }
355}
356
357// (There's not impl JobTemplate yet, also JobTemplate is not in its
358// own dir but config.rs, so, just put this here.)
359impl JobTemplate {
360    pub fn to_parameters_dir(&self, base_path: Arc<Path>) -> ParametersDir {
361        ParametersDir::from_job_template(base_path, self)
362    }
363}
364
365impl ParametersDir {
366    pub fn base_path(&self) -> &Arc<Path> {
367        &self.base_path
368    }
369    pub fn target_name(&self) -> &ProperDirname {
370        &self.target_name
371    }
372    pub fn custom_parameters(&self) -> &CheckedOrUncheckedCustomParameters {
373        &self.custom_parameters
374    }
375
376    pub fn from_job_template(base_path: Arc<Path>, job_template: &JobTemplate) -> Self {
377        let JobTemplate {
378            priority: _,
379            initial_boost: _,
380            command,
381            custom_parameters,
382        } = job_template;
383        let target_name = command.target_name.clone();
384        let custom_parameters = custom_parameters.clone_arc().into();
385        Self {
386            base_path,
387            target_name,
388            custom_parameters,
389            path_cache: Default::default(),
390        }
391    }
392}
393
394impl TryFrom<Arc<Path>> for KeyDir {
395    type Error = anyhow::Error;
396
397    fn try_from(path: Arc<Path>) -> std::result::Result<Self, Self::Error> {
398        let (commit_id, parent_dir) = parse_path_filename(&path)?;
399        let parent = ParametersDir::try_from(parent_dir.into_arc_path())?.into();
400        Ok(Self {
401            parent,
402            commit_id,
403            path_cache: path.into(),
404        })
405    }
406}
407
408impl ReplaceBasePath for KeyDir {
409    fn replace_base_path(&self, base_path: Arc<Path>) -> Self {
410        let Self {
411            parent,
412            commit_id,
413            path_cache: _,
414        } = self;
415        let parent = parent.replace_base_path(base_path).into();
416        clone!(commit_id);
417        Self {
418            parent,
419            commit_id,
420            path_cache: Default::default(),
421        }
422    }
423}
424
425impl ToPath for KeyDir {
426    fn to_path(&self) -> &Arc<Path> {
427        let Self {
428            parent,
429            commit_id,
430            path_cache,
431        } = self;
432        path_cache.get_or_init(|| parent.to_path().append(commit_id.to_string()).into())
433    }
434}
435
436impl SubDirs for KeyDir {
437    type Target = RunDir;
438
439    fn append_subdir_str(self: Arc<Self>, file_name: &str) -> Result<Self::Target> {
440        Ok(self.append_subdir(parse_filename(file_name.as_ref())?))
441    }
442}
443
444impl KeyDir {
445    pub fn from_base_target_params(
446        output_base_dir: Arc<Path>,
447        target_name: ProperDirname,
448        RunParameters {
449            commit_id,
450            custom_parameters,
451        }: &RunParameters,
452    ) -> Arc<Self> {
453        let parent = Arc::new(ParametersDir {
454            target_name,
455            custom_parameters: CheckedOrUncheckedCustomParameters::CustomParameters(
456                custom_parameters.clone_arc(),
457            ),
458            base_path: output_base_dir,
459            path_cache: Default::default(),
460        });
461        let commit_id = commit_id.clone();
462        Arc::new(KeyDir {
463            commit_id,
464            parent,
465            path_cache: Default::default(),
466        })
467    }
468
469    pub fn from_benchmarking_job_parameters(
470        output_base_dir: Arc<Path>,
471        benchmarking_job_parameters: &BenchmarkingJobParameters,
472    ) -> Arc<Self> {
473        let BenchmarkingJobParameters {
474            run_parameters,
475            command,
476        } = benchmarking_job_parameters;
477        Self::from_base_target_params(output_base_dir, command.target_name.clone(), run_parameters)
478    }
479
480    pub fn append_subdir(self: Arc<Self>, dir_name: DateTimeWithOffset) -> RunDir {
481        RunDir {
482            parent: self,
483            timestamp: dir_name,
484            path_cache: Default::default(),
485        }
486    }
487
488    pub fn parent(&self) -> &Arc<ParametersDir> {
489        &self.parent
490    }
491    pub fn commit_id(&self) -> &GitHash {
492        &self.commit_id
493    }
494}
495
496impl TryFrom<Arc<Path>> for RunDir {
497    type Error = anyhow::Error;
498
499    fn try_from(path: Arc<Path>) -> std::result::Result<Self, Self::Error> {
500        let (timestamp, parent_path) = parse_path_filename(&path)?;
501        let parent = KeyDir::try_from(parent_path.into_arc_path())?.into();
502        Ok(Self {
503            parent,
504            timestamp,
505            path_cache: path.into(),
506        })
507    }
508}
509
510impl ReplaceBasePath for RunDir {
511    fn replace_base_path(&self, base_path: Arc<Path>) -> Self {
512        let Self {
513            parent,
514            timestamp,
515            path_cache: _,
516        } = self;
517        let parent = parent.replace_base_path(base_path).into();
518        clone!(timestamp);
519        Self {
520            parent,
521            timestamp,
522            path_cache: Default::default(),
523        }
524    }
525}
526
527impl ToPath for RunDir {
528    fn to_path(&self) -> &Arc<Path> {
529        let Self {
530            parent,
531            timestamp,
532            path_cache,
533        } = self;
534        path_cache.get_or_init(|| parent.to_path().append(timestamp.to_string()).into())
535    }
536}
537
538impl RunDir {
539    pub fn parent(&self) -> &Arc<KeyDir> {
540        &self.parent
541    }
542    pub fn timestamp(&self) -> &DateTimeWithOffset {
543        &self.timestamp
544    }
545
546    /// The path to the compressed evobench.log file
547    pub fn evobench_log_path(&self) -> PathBuf {
548        self.to_path().append("evobench.log.zstd")
549    }
550
551    /// The same path as `io_utils::zstd_file::decompressed_file_mmap`
552    /// generates (the call to the aforementioned function happens in
553    /// a context outside the runner, hence can't use this function
554    /// here)
555    pub fn evobench_log_uncompressed_path(&self) -> PathBuf {
556        add_extension(self.evobench_log_path(), "uncompressed")
557            .expect("evobench_log_path has filename")
558    }
559
560    /// The optional output location that target projects can use,
561    /// passed to it via the `BENCH_OUTPUT_LOG` env variable then
562    /// compressed/moved to this location.
563    pub fn bench_output_log_path(&self) -> PathBuf {
564        self.to_path().append("bench_output.log.zstd")
565    }
566
567    /// The path to the compressed stdout/stderr output from the
568    /// target application while running this benchmark.
569    pub fn standard_log_path(&self) -> PathBuf {
570        self.to_path().append("standard.log.zstd")
571    }
572
573    /// Files below a RunDir are normal files (no special type, at
574    /// least for now)
575    pub fn append(&self, file_name: &ProperFilename) -> PathBuf {
576        self.to_path().append(file_name.as_str())
577    }
578
579    /// Same as `append` but returns an error if file_name cannot be a
580    /// `ProperFilename`. Only for files (subdirs would be via an
581    /// `append_subdir_str` method, but `RunDir`s have no subdirs,
582    /// currently.)
583    pub fn append_str(&self, file_name: &str) -> Result<PathBuf> {
584        let proper = ProperFilename::from_str(file_name)
585            .map_err(|msg| anyhow!("not a proper file name ({msg}): {file_name:?}"))?;
586        Ok(self.append(&proper))
587    }
588}
589
590// Implement conversions from the bare (non-Arc) variants
591macro_rules! def_output_subdir_from {
592    { $t:tt } => {
593        impl From<$t> for OutputSubdir {
594            fn from(value: $t) -> Self {
595                Self::$t(Arc::new(value))
596            }
597        }
598    }
599}
600def_output_subdir_from!(ParametersDir);
601def_output_subdir_from!(KeyDir);
602def_output_subdir_from!(RunDir);
603
604impl OutputSubdir {
605    pub fn replace_base_path(self, path: Arc<Path>) -> Self {
606        match self {
607            OutputSubdir::ParametersDir(v) => v.replace_base_path(path).into(),
608            OutputSubdir::KeyDir(v) => v.replace_base_path(path).into(),
609            OutputSubdir::RunDir(v) => v.replace_base_path(path).into(),
610        }
611    }
612
613    pub fn to_path(&self) -> &Arc<Path> {
614        match self {
615            OutputSubdir::ParametersDir(v) => v.to_path(),
616            OutputSubdir::KeyDir(v) => v.to_path(),
617            OutputSubdir::RunDir(v) => v.to_path(),
618        }
619    }
620
621    pub fn type_name(&self) -> &'static str {
622        match self {
623            OutputSubdir::ParametersDir(_) => "ParametersDir",
624            OutputSubdir::KeyDir(_) => "KeyDir",
625            OutputSubdir::RunDir(_) => "RunDir",
626        }
627    }
628}
629
630/// Attempt to parse as all levels, with the deepest type first.
631impl TryFrom<Arc<Path>> for OutputSubdir {
632    type Error = anyhow::Error;
633
634    fn try_from(path: Arc<Path>) -> std::result::Result<Self, Self::Error> {
635        // By exchanging the Ok and Err cases via .invert(), `?` ends
636        // with the first successful result (converted into
637        // OutputSubdir). The code flow stays in the closure while
638        // there are errors. Invert the meaning back outside.
639        (|| -> Result<anyhow::Error, OutputSubdir> {
640            let e1 = RunDir::try_from(path.clone_arc()).invert()?;
641            let e2 = KeyDir::try_from(path.clone_arc()).invert()?;
642            let e3 = ParametersDir::try_from(path.clone_arc()).invert()?;
643            Ok(anyhow!(
644                "can't parse path {path:?}\n\
645                 - as RunDir: {e1:#}\n\
646                 - as KeyDir: {e2:#}\n\
647                 - as ParametersDir: {e3:#}"
648            ))
649        })()
650        .invert()
651    }
652}
653
654impl ToPath for OutputSubdir {
655    fn to_path(&self) -> &Arc<Path> {
656        match self {
657            OutputSubdir::ParametersDir(v) => v.to_path(),
658            OutputSubdir::KeyDir(v) => v.to_path(),
659            OutputSubdir::RunDir(v) => v.to_path(),
660        }
661    }
662}
663
664impl SubDirs for OutputSubdir {
665    type Target = OutputSubdir;
666
667    fn append_subdir_str(self: Arc<Self>, file_name: &str) -> Result<Self::Target> {
668        Ok(match &*self {
669            OutputSubdir::ParametersDir(v) => v.clone_arc().append_subdir_str(file_name)?.into(),
670            OutputSubdir::KeyDir(v) => v.clone_arc().append_subdir_str(file_name)?.into(),
671            // Note: this error never shows up when `sub_dirs` method
672            // is called and there are no subdirs, since the call
673            // doesn't happen then. If there *are* subdirs, then they
674            // will be reported via `info!` just like parsing errors.
675            OutputSubdir::RunDir(_) => bail!("can't get subdirs for RunDir instances"),
676        })
677    }
678}
679
680#[cfg(test)]
681mod tests {
682    use super::*;
683
684    #[test]
685    fn t_parameters_dir() {
686        let path = "/home/evobench/silo-benchmark-outputs/api/CONCURRENCY=120/DATASET=SC2open\
687                    /RANDOMIZED=1/REPEAT=1/SORTED=0"
688            .into_arc_path();
689        let d = ParametersDir::try_from(path.clone_arc()).unwrap();
690        assert_eq!(
691            d.base_path(),
692            &"/home/evobench/silo-benchmark-outputs".into_arc_path()
693        );
694        assert_eq!(d.target_name().as_str(), "api");
695
696        let p = |name: &str| -> AllowedEnvVar<AllowableCustomEnvVar> { name.parse().unwrap() };
697        assert_eq!(d.custom_parameters().get(&p("CONCURRENCY")), Some("120"));
698        assert_eq!(d.custom_parameters().get(&p("DATASET")), Some("SC2open"));
699        assert_eq!(d.custom_parameters().get(&p("SORTED")), Some("0"));
700
701        assert_eq!(d.to_path(), &path);
702
703        let new_base_path = "foo".into_arc_path();
704        let new_path = "foo/api/CONCURRENCY=120/DATASET=SC2open/RANDOMIZED=1/REPEAT=1/SORTED=0"
705            .into_arc_path();
706        let d2 = d.replace_base_path(new_base_path.clone_arc());
707        assert_eq!(d2.base_path(), &new_base_path);
708        assert_eq!(d2.to_path(), &new_path);
709    }
710
711    #[test]
712    fn t_output_subdir() -> Result<(), String> {
713        let t1 = |s: &str| -> Result<OutputSubdir> {
714            let dir = OutputSubdir::try_from(s.into_arc_path())?;
715            Ok(dir.replace_base_path("BASE".into_arc_path()))
716        };
717        let t2 = |s: &str| -> Result<PathBuf> {
718            let dir = t1(s)?;
719            Ok(dir.to_path().to_path_buf())
720        };
721        let t = |s: &str| -> Result<String, String> {
722            match t2(s) {
723                Ok(p) => Ok(p.to_string_lossy().to_string()),
724                Err(e) => Err(e.to_string()),
725            }
726        };
727
728        assert_eq!(&t("/foo=1//bar=2/fa")?, "BASE/fa");
729        assert_eq!(&t("api/foo=1//bar=2/")?, "BASE/api/bar=2/foo=1");
730        assert_eq!(&t("um/api/foo=1/bar=2/")?, "BASE/api/bar=2/foo=1");
731        assert_eq!(&t("/api/foo=1/bar=2/")?, "BASE/api/bar=2/foo=1");
732        // parent() skips over `.`
733        assert_eq!(&t("um/api/foo=1/./bar=2/")?, "BASE/api/bar=2/foo=1");
734        // `..`.file_name() returns None
735        assert_eq!(
736            &t("um/api/foo=1/baz=3/../bar=2/").err().unwrap(),
737            "can't parse path \"um/api/foo=1/baz=3/../bar=2/\"\n- as RunDir: dir name \"bar=2\" does not parse as DateTimeWithOffset: input contains invalid characters\n- as KeyDir: dir name \"bar=2\" does not parse as GitHash: not a git hash of 40 hex bytes: \"bar=2\"\n- as ParametersDir: path \"um/api/foo=1/baz=3/../bar=2/\" contains a '..' or '.' part: \"um/api/foo=1/baz=3/..\""
738        );
739        assert_eq!(
740            &t("api/foo=1/bar=2/09193b52688a964956b3fae0f52eeae471adc027")?,
741            "BASE/api/bar=2/foo=1/09193b52688a964956b3fae0f52eeae471adc027"
742        );
743        assert_eq!(
744            &t("api/foo=1/bar=2/09193b52688a964956b3fae0f52eeae471adc027/\
745                2026-02-02T11:26:48.563793486+00:00")?,
746            "BASE/api/bar=2/foo=1/09193b52688a964956b3fae0f52eeae471adc027/\
747             2026-02-02T11:26:48.563793486+00:00"
748        );
749        // Parses as the commit id *is* a ProperDirname thus used as
750        // target name! Hmm, could it check for the kinds of errors in
751        // earlier parses and decide on that? If it is successful
752        // parsing 2 out of 3 then... basically do those counts. 2
753        // successful parses > 1 parse. Although should perhaps
754        // require "non-trivial", too.
755        assert_eq!(
756            &t("/foo=1//bar=2/09193b52688a964956b3fae0f52eeae471adc027")?,
757            "BASE/09193b52688a964956b3fae0f52eeae471adc027"
758        );
759        // Interesting case since the multi-error message feature is
760        // actually useful here:
761        assert_eq!(
762            &t("/foo=1//bar=2/09193b52688a964956b3fae0f52eeae471adc027/\
763                2026-02-02T11:26:48.563793486+00:00")
764            .err()
765            .unwrap(),
766            "can't parse path \"/foo=1//bar=2/09193b52688a964956b3fae0f52eeae471adc027/2026-02-02T11:26:48.563793486+00:00\"\n- as RunDir: parsing ParametersDir \"/foo=1//bar=2\": missing target segment left of the var segments\n- as KeyDir: dir name \"2026-02-02T11:26:48.563793486+00:00\" does not parse as GitHash: not a git hash of 40 hex bytes: \"2026-02-02T11:26:48.563793486+00:00\"\n- as ParametersDir: not a proper directory name: \"2026-02-02T11:26:48.563793486+00:00\": a file name (not path), must not contain '/', '\\n', '\\0', and must not be \".\", \"..\", the empty string, or longer than 255 bytes, and not have a file extension"
767        );
768        // `.` is not dropped: it's only skiped by
769        // parent(). file_name() then yields another Null.
770        assert_eq!(
771            &t(".").err().unwrap(),
772            "can't parse path \".\"\n- as RunDir: path is missing a file name\n- as KeyDir: path is missing a file name\n- as ParametersDir: path \".\" contains a '..' or '.' part: \".\""
773        );
774        assert_eq!(
775            &t("./foo=1").err().unwrap(),
776            "can't parse path \"./foo=1\"\n- as RunDir: dir name \"foo=1\" does not parse as DateTimeWithOffset: input contains invalid characters\n- as KeyDir: dir name \"foo=1\" does not parse as GitHash: not a git hash of 40 hex bytes: \"foo=1\"\n- as ParametersDir: path \"./foo=1\" contains a '..' or '.' part: \".\""
777        );
778        assert_eq!(&t("./a/foo=1")?, "BASE/a/foo=1");
779        assert_eq!(&t("./a/./foo=1")?, "BASE/a/foo=1");
780        // Only if the `.` is right of a `..` it happens
781        assert_eq!(
782            &t("./../.").err().unwrap(),
783            "can't parse path \"./../.\"\n- as RunDir: path is missing a file name\n- as KeyDir: path is missing a file name\n- as ParametersDir: path \"./../.\" contains a '..' or '.' part: \"./../.\""
784        );
785        assert_eq!(&t("a/.././b")?, "BASE/b");
786        assert_eq!(&t("a/../b/.")?, "BASE/b");
787        Ok(())
788    }
789}