Skip to main content

yt/config/
repo.rs

1use std::path::{Path, PathBuf};
2
3use serde::Deserialize;
4
5use crate::error::{Result, YtError};
6use crate::instance::InstanceUrl;
7
8/// A repository's `.yt.toml` or `.config/yt.toml`: pins the instance and default project, never credentials.
9#[derive(Clone, Debug)]
10pub struct RepoConfig {
11    pub path: PathBuf,
12    pub url: Option<InstanceUrl>,
13    pub project: Option<String>,
14}
15
16#[derive(Deserialize)]
17#[serde(deny_unknown_fields)]
18struct RepoFile {
19    url: Option<String>,
20    project: Option<String>,
21}
22
23/// The nearest repo config, searching from `start` up to the filesystem root.
24pub fn find(start: &Path) -> Result<Option<RepoConfig>> {
25    for dir in start.ancestors() {
26        let dotted = dir.join(".yt.toml");
27        let nested = dir.join(".config").join("yt.toml");
28        match (dotted.is_file(), nested.is_file()) {
29            (true, true) => {
30                return Err(YtError::Config {
31                    path: dir.display().to_string(),
32                    message: "both .yt.toml and .config/yt.toml exist; keep one".into(),
33                }
34                .into());
35            }
36            (true, false) => return load(dotted).map(Some),
37            (false, true) => return load(nested).map(Some),
38            (false, false) => {}
39        }
40    }
41    Ok(None)
42}
43
44fn load(path: PathBuf) -> Result<RepoConfig> {
45    let invalid = |message: String| -> miette::Report {
46        YtError::Config {
47            path: path.display().to_string(),
48            message,
49        }
50        .into()
51    };
52    let text = std::fs::read_to_string(&path).map_err(|error| invalid(error.to_string()))?;
53    let file: RepoFile = toml::from_str(&text).map_err(|error| invalid(error.to_string()))?;
54    if let Some(project) = &file.project {
55        crate::fields::schema::check_short_name(project)
56            .map_err(|error| invalid(error.to_string()))?;
57    }
58    let url = file.url.as_deref().map(InstanceUrl::parse).transpose()?;
59    Ok(RepoConfig {
60        path,
61        url,
62        project: file.project,
63    })
64}
65
66#[cfg(test)]
67mod tests {
68    use super::*;
69
70    #[test]
71    fn nearest_file_wins_and_both_forms_are_found() {
72        let root = tempfile::tempdir().unwrap();
73        let nested = root.path().join("a/b");
74        std::fs::create_dir_all(nested.join(".config")).unwrap();
75        std::fs::write(
76            root.path().join(".yt.toml"),
77            "url = \"https://outer.youtrack.cloud\"\n",
78        )
79        .unwrap();
80        std::fs::write(
81            nested.join(".config/yt.toml"),
82            "url = \"https://inner.youtrack.cloud\"\nproject = \"WEB\"\n",
83        )
84        .unwrap();
85
86        let found = find(&nested).unwrap().unwrap();
87        assert_eq!(
88            found.url.unwrap().to_string(),
89            "https://inner.youtrack.cloud"
90        );
91        assert_eq!(found.project.as_deref(), Some("WEB"));
92
93        let found = find(&root.path().join("a")).unwrap().unwrap();
94        assert_eq!(
95            found.url.unwrap().to_string(),
96            "https://outer.youtrack.cloud"
97        );
98    }
99
100    #[test]
101    fn both_files_in_one_directory_is_an_error() {
102        let root = tempfile::tempdir().unwrap();
103        std::fs::create_dir_all(root.path().join(".config")).unwrap();
104        std::fs::write(root.path().join(".yt.toml"), "").unwrap();
105        std::fs::write(root.path().join(".config/yt.toml"), "").unwrap();
106        assert!(
107            find(root.path())
108                .unwrap_err()
109                .to_string()
110                .contains("keep one")
111        );
112    }
113
114    #[test]
115    fn the_project_must_be_a_short_name() {
116        let root = tempfile::tempdir().unwrap();
117        std::fs::write(root.path().join(".yt.toml"), "project = \"/tmp/x\"\n").unwrap();
118        let error = find(root.path()).unwrap_err().to_string();
119        assert!(error.contains("not a project short name"), "{error}");
120    }
121
122    #[test]
123    fn profile_names_are_not_allowed_in_shared_files() {
124        let root = tempfile::tempdir().unwrap();
125        std::fs::write(root.path().join(".yt.toml"), "profile = \"acme\"\n").unwrap();
126        assert!(find(root.path()).is_err());
127    }
128}