1use std::path::{Path, PathBuf};
2
3use serde::Deserialize;
4
5use crate::error::{Result, YtError};
6use crate::instance::InstanceUrl;
7
8#[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
23pub 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}