Skip to main content

ytcli/config/
mod.rs

1//! Layered configuration and profile resolution.
2//!
3//! Two entities that are easy to conflate (see `CONTEXT.md`):
4//!
5//! * an **account** owns a credential — one `auth login` per account, one keychain entry;
6//! * a **profile** is an *organisation seen through an account*, plus display defaults.
7//!
8//! One account can serve many profiles (same login, several organisations) and one
9//! organisation can be reached through several accounts (admin and read-only).
10//! That is why the token is keyed by account and never by profile.
11
12pub 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/// Which header carries the organisation id. Sending the wrong one is a 403,
27/// so it is a profile-level decision rather than something we probe at runtime.
28#[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    /// Yandex Cloud Organization — `X-Cloud-Org-Id`.
33    Cloud,
34    /// Yandex 360 for Business — `X-Org-Id`.
35    Yandex360,
36}
37
38impl OrgKind {
39    /// Lowercase on purpose: header names are case-insensitive, and
40    /// `HeaderName::from_static` only accepts the lowercase form.
41    #[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/// An identity that holds a credential. The struct is intentionally empty of
51/// secrets: the token lives in the OS keychain under this account's name.
52#[derive(Debug, Clone, Default, Serialize, Deserialize)]
53pub struct Account {
54    /// Human note about who this is; shown by `auth list`.
55    #[serde(default)]
56    pub description: Option<String>,
57}
58
59/// Display defaults. Every one of these is overridable per profile and per
60/// repository, because a default that cannot be moved becomes someone's papercut.
61#[derive(Debug, Clone, Serialize, Deserialize)]
62#[serde(default)]
63pub struct Display {
64    /// Rows returned by list commands before pagination kicks in.
65    pub limit: usize,
66    /// Hard ceiling for `--all` page walking.
67    pub max: usize,
68    /// Description lines shown before the `--full` hint, when the output is
69    /// being piped or read by an agent.
70    pub description_lines: usize,
71    /// The same, for a terminal. `None` — the default — means no limit: a person
72    /// reading their own screen is not paying for context.
73    pub description_lines_human: Option<usize>,
74    /// Custom field keys pinned into the compact view, in this exact order.
75    /// Order is fixed on purpose: a shuffling field list breaks an agent's
76    /// prompt cache on every call.
77    pub extra_fields: Vec<String>,
78    /// Output format when stdout is not a terminal.
79    pub format: OutputFormat,
80    /// Draw image attachments inline where the terminal can draw them.
81    ///
82    /// On by default: a screenshot is usually the most informative thing on a
83    /// bug, and this costs nothing anywhere it cannot be used — no terminal that
84    /// draws means no request for the attachments in the first place.
85    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/// An organisation reached through an account.
103#[derive(Debug, Clone, Serialize, Deserialize)]
104pub struct Profile {
105    /// Key into [`Config::accounts`].
106    pub account: String,
107    /// Organisation id sent in the header chosen by `org_kind`.
108    pub org_id: String,
109    pub org_kind: OrgKind,
110    /// Queue assumed when a command needs one and none was given.
111    #[serde(default)]
112    pub default_queue: Option<String>,
113    #[serde(default)]
114    pub display: Display,
115}
116
117/// The user-level config file, `$XDG_CONFIG_HOME/ytcli/config.toml`.
118#[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/// The committed, secret-free `.tracker.toml` found by walking up from the cwd.
127/// It pins a repository to a profile so that an agent working in a checkout
128/// lands in the right organisation without any global mutable state.
129#[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/// Where the active profile name came from. Always reported by `auth status`
137/// and by every writing command: "which organisation am I about to change" must
138/// never be a guess.
139#[derive(Debug, Clone, PartialEq, Eq)]
140pub enum ProfileSource {
141    Flag,
142    Env,
143    ProjectFile(PathBuf),
144    DefaultProfile,
145    /// Chosen because it is the profile that can see the queue in the key.
146    QueueOwner(String),
147    /// Named in the key itself, as `profile/PROJ-1`.
148    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/// A fully resolved profile plus the provenance of that choice.
181#[derive(Debug, Clone)]
182pub struct Resolved {
183    pub name: String,
184    pub profile: Profile,
185    pub source: ProfileSource,
186    /// Queue override coming from `.tracker.toml`, if any.
187    pub queue: Option<String>,
188}
189
190impl Config {
191    /// Read the user config file, letting `YTCLI_*` environment variables win.
192    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    /// Resolve the active profile.
200    ///
201    /// Precedence, highest first: `--profile`, `YTCLI_PROFILE`, the nearest
202    /// `.tracker.toml` walking up from `start_dir`, `default_profile`.
203    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    /// A screenshot is usually the most informative thing on a bug, so the
257    /// default is to show it. Turning it off is a profile's decision.
258    #[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        // And the rest of the defaults survive naming only one of them.
269        assert_eq!(display.limit, Display::default().limit);
270    }
271}