1pub mod cache;
13pub mod paths;
14pub mod store;
15pub mod timers;
16
17use std::collections::BTreeMap;
18use std::path::{Path, PathBuf};
19
20use figment::Figment;
21use figment::providers::{Env, Format, Toml};
22use serde::{Deserialize, Serialize};
23
24use crate::render::Format as OutputFormat;
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, clap::ValueEnum)]
29#[serde(rename_all = "kebab-case")]
30#[value(rename_all = "kebab-case")]
31pub enum OrgKind {
32 Cloud,
34 Yandex360,
36}
37
38impl OrgKind {
39 #[must_use]
42 pub fn header_name(self) -> &'static str {
43 match self {
44 Self::Cloud => "x-cloud-org-id",
45 Self::Yandex360 => "x-org-id",
46 }
47 }
48}
49
50#[derive(Debug, Clone, Default, Serialize, Deserialize)]
53pub struct Account {
54 #[serde(default)]
56 pub description: Option<String>,
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize)]
62#[serde(default)]
63pub struct Display {
64 pub limit: usize,
66 pub max: usize,
68 pub description_lines: usize,
71 pub description_lines_human: Option<usize>,
74 pub extra_fields: Vec<String>,
78 pub format: OutputFormat,
80 pub images: bool,
86}
87
88impl Default for Display {
89 fn default() -> Self {
90 Self {
91 limit: 25,
92 max: 500,
93 description_lines: 10,
94 description_lines_human: None,
95 extra_fields: Vec::new(),
96 format: OutputFormat::Text,
97 images: true,
98 }
99 }
100}
101
102#[derive(Debug, Clone, Serialize, Deserialize)]
104pub struct Profile {
105 pub account: String,
107 pub org_id: String,
109 pub org_kind: OrgKind,
110 #[serde(default)]
115 pub description: Option<String>,
116 #[serde(default)]
118 pub default_queue: Option<String>,
119 #[serde(default)]
120 pub display: Display,
121}
122
123#[derive(Debug, Clone, Default, Serialize, Deserialize)]
125#[serde(default)]
126pub struct Config {
127 pub default_profile: Option<String>,
128 pub accounts: BTreeMap<String, Account>,
129 pub profiles: BTreeMap<String, Profile>,
130}
131
132#[derive(Debug, Clone, Default, Serialize, Deserialize)]
136#[serde(default)]
137pub struct ProjectPin {
138 pub profile: Option<String>,
139 pub queue: Option<String>,
140}
141
142#[derive(Debug, Clone, PartialEq, Eq)]
146pub enum ProfileSource {
147 Flag,
148 Env,
149 ProjectFile(PathBuf),
150 DefaultProfile,
151 QueueOwner(String),
153 Qualified(String),
155}
156
157impl std::fmt::Display for ProfileSource {
158 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
159 match self {
160 Self::Flag => f.write_str("--profile"),
161 Self::Env => f.write_str("YTCLI_PROFILE"),
162 Self::ProjectFile(path) => write!(f, "{}", path.display()),
163 Self::DefaultProfile => f.write_str("config default_profile"),
164 Self::QueueOwner(queue) => write!(f, "the only profile that sees {queue}"),
165 Self::Qualified(target) => write!(f, "the key `{target}`"),
166 }
167 }
168}
169
170#[derive(Debug, thiserror::Error)]
171pub enum ConfigError {
172 #[error(
173 "no profile selected: pass --profile, set YTCLI_PROFILE, add .tracker.toml, or set default_profile"
174 )]
175 NoProfile,
176 #[error("profile `{0}` is not defined in the config file")]
177 UnknownProfile(String),
178 #[error("profile `{profile}` refers to account `{account}`, which is not defined")]
179 UnknownAccount { profile: String, account: String },
180 #[error("could not read configuration")]
181 Read(#[from] figment::Error),
182 #[error("could not locate the configuration directory")]
183 Paths(#[from] paths::PathsError),
184}
185
186#[derive(Debug, Clone)]
188pub struct Resolved {
189 pub name: String,
190 pub profile: Profile,
191 pub source: ProfileSource,
192 pub queue: Option<String>,
194}
195
196impl Config {
197 pub fn load(config_file: &Path) -> Result<Self, ConfigError> {
199 Ok(Figment::new()
200 .merge(Toml::file(config_file))
201 .merge(Env::prefixed("YTCLI_").split("__"))
202 .extract()?)
203 }
204
205 pub fn resolve(
210 &self,
211 flag: Option<&str>,
212 env: Option<&str>,
213 start_dir: &Path,
214 ) -> Result<Resolved, ConfigError> {
215 let pin = paths::find_project_pin(start_dir);
216
217 let (name, source) = match (flag, env, &pin) {
218 (Some(name), _, _) => (name.to_owned(), ProfileSource::Flag),
219 (None, Some(name), _) => (name.to_owned(), ProfileSource::Env),
220 (None, None, Some((path, pinned))) if pinned.profile.is_some() => {
221 let Some(name) = pinned.profile.clone() else {
222 return Err(ConfigError::NoProfile);
223 };
224 (name, ProfileSource::ProjectFile(path.clone()))
225 }
226 _ => {
227 let name = self.default_profile.clone().ok_or(ConfigError::NoProfile)?;
228 (name, ProfileSource::DefaultProfile)
229 }
230 };
231
232 let profile = self
233 .profiles
234 .get(&name)
235 .cloned()
236 .ok_or_else(|| ConfigError::UnknownProfile(name.clone()))?;
237
238 if !self.accounts.contains_key(&profile.account) {
239 return Err(ConfigError::UnknownAccount {
240 profile: name,
241 account: profile.account,
242 });
243 }
244
245 Ok(Resolved {
246 name,
247 queue: pin
248 .as_ref()
249 .and_then(|(_, pinned)| pinned.queue.clone())
250 .or_else(|| profile.default_queue.clone()),
251 profile,
252 source,
253 })
254 }
255}
256
257#[cfg(test)]
258#[allow(clippy::expect_used)]
259mod tests {
260 use super::*;
261
262 #[test]
265 fn images_are_on_by_default_and_can_be_turned_off() {
266 assert!(Display::default().images);
267
268 let display: Display = figment::Figment::new()
269 .merge(figment::providers::Toml::string("images = false"))
270 .extract()
271 .expect("parses");
272 assert!(!display.images);
273
274 assert_eq!(display.limit, Display::default().limit);
276 }
277}