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)]
112 pub default_queue: Option<String>,
113 #[serde(default)]
114 pub display: Display,
115}
116
117#[derive(Debug, Clone, Default, Serialize, Deserialize)]
119#[serde(default)]
120pub struct Config {
121 pub default_profile: Option<String>,
122 pub accounts: BTreeMap<String, Account>,
123 pub profiles: BTreeMap<String, Profile>,
124}
125
126#[derive(Debug, Clone, Default, Serialize, Deserialize)]
130#[serde(default)]
131pub struct ProjectPin {
132 pub profile: Option<String>,
133 pub queue: Option<String>,
134}
135
136#[derive(Debug, Clone, PartialEq, Eq)]
140pub enum ProfileSource {
141 Flag,
142 Env,
143 ProjectFile(PathBuf),
144 DefaultProfile,
145 QueueOwner(String),
147 Qualified(String),
149}
150
151impl std::fmt::Display for ProfileSource {
152 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
153 match self {
154 Self::Flag => f.write_str("--profile"),
155 Self::Env => f.write_str("YTCLI_PROFILE"),
156 Self::ProjectFile(path) => write!(f, "{}", path.display()),
157 Self::DefaultProfile => f.write_str("config default_profile"),
158 Self::QueueOwner(queue) => write!(f, "the only profile that sees {queue}"),
159 Self::Qualified(target) => write!(f, "the key `{target}`"),
160 }
161 }
162}
163
164#[derive(Debug, thiserror::Error)]
165pub enum ConfigError {
166 #[error(
167 "no profile selected: pass --profile, set YTCLI_PROFILE, add .tracker.toml, or set default_profile"
168 )]
169 NoProfile,
170 #[error("profile `{0}` is not defined in the config file")]
171 UnknownProfile(String),
172 #[error("profile `{profile}` refers to account `{account}`, which is not defined")]
173 UnknownAccount { profile: String, account: String },
174 #[error("could not read configuration")]
175 Read(#[from] figment::Error),
176 #[error("could not locate the configuration directory")]
177 Paths(#[from] paths::PathsError),
178}
179
180#[derive(Debug, Clone)]
182pub struct Resolved {
183 pub name: String,
184 pub profile: Profile,
185 pub source: ProfileSource,
186 pub queue: Option<String>,
188}
189
190impl Config {
191 pub fn load(config_file: &Path) -> Result<Self, ConfigError> {
193 Ok(Figment::new()
194 .merge(Toml::file(config_file))
195 .merge(Env::prefixed("YTCLI_").split("__"))
196 .extract()?)
197 }
198
199 pub fn resolve(
204 &self,
205 flag: Option<&str>,
206 env: Option<&str>,
207 start_dir: &Path,
208 ) -> Result<Resolved, ConfigError> {
209 let pin = paths::find_project_pin(start_dir);
210
211 let (name, source) = match (flag, env, &pin) {
212 (Some(name), _, _) => (name.to_owned(), ProfileSource::Flag),
213 (None, Some(name), _) => (name.to_owned(), ProfileSource::Env),
214 (None, None, Some((path, pinned))) if pinned.profile.is_some() => {
215 let Some(name) = pinned.profile.clone() else {
216 return Err(ConfigError::NoProfile);
217 };
218 (name, ProfileSource::ProjectFile(path.clone()))
219 }
220 _ => {
221 let name = self.default_profile.clone().ok_or(ConfigError::NoProfile)?;
222 (name, ProfileSource::DefaultProfile)
223 }
224 };
225
226 let profile = self
227 .profiles
228 .get(&name)
229 .cloned()
230 .ok_or_else(|| ConfigError::UnknownProfile(name.clone()))?;
231
232 if !self.accounts.contains_key(&profile.account) {
233 return Err(ConfigError::UnknownAccount {
234 profile: name,
235 account: profile.account,
236 });
237 }
238
239 Ok(Resolved {
240 name,
241 queue: pin
242 .as_ref()
243 .and_then(|(_, pinned)| pinned.queue.clone())
244 .or_else(|| profile.default_queue.clone()),
245 profile,
246 source,
247 })
248 }
249}
250
251#[cfg(test)]
252#[allow(clippy::expect_used)]
253mod tests {
254 use super::*;
255
256 #[test]
259 fn images_are_on_by_default_and_can_be_turned_off() {
260 assert!(Display::default().images);
261
262 let display: Display = figment::Figment::new()
263 .merge(figment::providers::Toml::string("images = false"))
264 .extract()
265 .expect("parses");
266 assert!(!display.images);
267
268 assert_eq!(display.limit, Display::default().limit);
270 }
271}