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 #[serde(default)]
61 pub access: Option<Access>,
62}
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
66#[serde(rename_all = "lowercase")]
67pub enum Access {
68 Read,
69 Write,
70}
71
72impl Access {
73 #[must_use]
74 pub const fn name(self) -> &'static str {
75 match self {
76 Self::Read => "read",
77 Self::Write => "write",
78 }
79 }
80}
81
82#[derive(Debug, Clone, Serialize, Deserialize)]
85#[serde(default)]
86pub struct Display {
87 pub limit: usize,
89 pub max: usize,
91 pub description_lines: usize,
94 pub description_lines_human: Option<usize>,
97 pub extra_fields: Vec<String>,
101 pub format: OutputFormat,
103 pub images: bool,
109}
110
111impl Default for Display {
112 fn default() -> Self {
113 Self {
114 limit: 25,
115 max: 500,
116 description_lines: 10,
117 description_lines_human: None,
118 extra_fields: Vec::new(),
119 format: OutputFormat::Text,
120 images: true,
121 }
122 }
123}
124
125#[derive(Debug, Clone, Serialize, Deserialize)]
127pub struct Profile {
128 pub account: String,
130 pub org_id: String,
132 pub org_kind: OrgKind,
133 #[serde(default)]
138 pub description: Option<String>,
139 #[serde(default)]
141 pub default_queue: Option<String>,
142 #[serde(default)]
143 pub display: Display,
144}
145
146#[derive(Debug, Clone, Default, Serialize, Deserialize)]
148#[serde(default)]
149pub struct Config {
150 pub default_profile: Option<String>,
151 pub accounts: BTreeMap<String, Account>,
152 pub profiles: BTreeMap<String, Profile>,
153}
154
155#[derive(Debug, Clone, Default, Serialize, Deserialize)]
159#[serde(default)]
160pub struct ProjectPin {
161 pub profile: Option<String>,
162 pub queue: Option<String>,
163}
164
165#[derive(Debug, Clone, PartialEq, Eq)]
169pub enum ProfileSource {
170 Flag,
171 Env,
172 ProjectFile(PathBuf),
173 DefaultProfile,
174 QueueOwner(String),
176 Qualified(String),
178}
179
180impl std::fmt::Display for ProfileSource {
181 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
182 match self {
183 Self::Flag => f.write_str("--profile"),
184 Self::Env => f.write_str("YTCLI_PROFILE"),
185 Self::ProjectFile(path) => write!(f, "{}", path.display()),
186 Self::DefaultProfile => f.write_str("config default_profile"),
187 Self::QueueOwner(queue) => write!(f, "the only profile that sees {queue}"),
188 Self::Qualified(target) => write!(f, "the key `{target}`"),
189 }
190 }
191}
192
193#[derive(Debug, thiserror::Error)]
194pub enum ConfigError {
195 #[error(
196 "no profile selected: pass --profile, set YTCLI_PROFILE, add .tracker.toml, or set default_profile"
197 )]
198 NoProfile,
199 #[error("profile `{0}` is not defined in the config file")]
200 UnknownProfile(String),
201 #[error("profile `{profile}` refers to account `{account}`, which is not defined")]
202 UnknownAccount { profile: String, account: String },
203 #[error("could not read configuration")]
204 Read(#[from] figment::Error),
205 #[error("could not locate the configuration directory")]
206 Paths(#[from] paths::PathsError),
207}
208
209#[derive(Debug, Clone)]
211pub struct Resolved {
212 pub name: String,
213 pub profile: Profile,
214 pub source: ProfileSource,
215 pub queue: Option<String>,
217}
218
219impl Config {
220 pub fn load(config_file: &Path) -> Result<Self, ConfigError> {
222 Ok(Figment::new()
223 .merge(Toml::file(config_file))
224 .merge(Env::prefixed("YTCLI_").split("__"))
225 .extract()?)
226 }
227
228 pub fn resolve(
233 &self,
234 flag: Option<&str>,
235 env: Option<&str>,
236 start_dir: &Path,
237 ) -> Result<Resolved, ConfigError> {
238 let pin = paths::find_project_pin(start_dir);
239
240 let (name, source) = match (flag, env, &pin) {
241 (Some(name), _, _) => (name.to_owned(), ProfileSource::Flag),
242 (None, Some(name), _) => (name.to_owned(), ProfileSource::Env),
243 (None, None, Some((path, pinned))) if pinned.profile.is_some() => {
244 let Some(name) = pinned.profile.clone() else {
245 return Err(ConfigError::NoProfile);
246 };
247 (name, ProfileSource::ProjectFile(path.clone()))
248 }
249 _ => {
250 let name = self.default_profile.clone().ok_or(ConfigError::NoProfile)?;
251 (name, ProfileSource::DefaultProfile)
252 }
253 };
254
255 let profile = self
256 .profiles
257 .get(&name)
258 .cloned()
259 .ok_or_else(|| ConfigError::UnknownProfile(name.clone()))?;
260
261 if !self.accounts.contains_key(&profile.account) {
262 return Err(ConfigError::UnknownAccount {
263 profile: name,
264 account: profile.account,
265 });
266 }
267
268 Ok(Resolved {
269 name,
270 queue: pin
271 .as_ref()
272 .and_then(|(_, pinned)| pinned.queue.clone())
273 .or_else(|| profile.default_queue.clone()),
274 profile,
275 source,
276 })
277 }
278}
279
280#[cfg(test)]
281#[allow(clippy::expect_used)]
282mod tests {
283 use super::*;
284
285 #[test]
288 fn images_are_on_by_default_and_can_be_turned_off() {
289 assert!(Display::default().images);
290
291 let display: Display = figment::Figment::new()
292 .merge(figment::providers::Toml::string("images = false"))
293 .extract()
294 .expect("parses");
295 assert!(!display.images);
296
297 assert_eq!(display.limit, Display::default().limit);
299 }
300}