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    /// Human note about which organisation this is. An org id is a number
111    /// nobody recognises and a profile name is whatever was typed at login, so
112    /// this is what answers "am I about to write to production" — which is why
113    /// it rides along on the provenance banner and not only in `auth list`.
114    #[serde(default)]
115    pub description: Option<String>,
116    /// Queue assumed when a command needs one and none was given.
117    #[serde(default)]
118    pub default_queue: Option<String>,
119    #[serde(default)]
120    pub display: Display,
121}
122
123/// The user-level config file, `$XDG_CONFIG_HOME/ytcli/config.toml`.
124#[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/// The committed, secret-free `.tracker.toml` found by walking up from the cwd.
133/// It pins a repository to a profile so that an agent working in a checkout
134/// lands in the right organisation without any global mutable state.
135#[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/// Where the active profile name came from. Always reported by `auth status`
143/// and by every writing command: "which organisation am I about to change" must
144/// never be a guess.
145#[derive(Debug, Clone, PartialEq, Eq)]
146pub enum ProfileSource {
147    Flag,
148    Env,
149    ProjectFile(PathBuf),
150    DefaultProfile,
151    /// Chosen because it is the profile that can see the queue in the key.
152    QueueOwner(String),
153    /// Named in the key itself, as `profile/PROJ-1`.
154    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/// A fully resolved profile plus the provenance of that choice.
187#[derive(Debug, Clone)]
188pub struct Resolved {
189    pub name: String,
190    pub profile: Profile,
191    pub source: ProfileSource,
192    /// Queue override coming from `.tracker.toml`, if any.
193    pub queue: Option<String>,
194}
195
196impl Config {
197    /// Read the user config file, letting `YTCLI_*` environment variables win.
198    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    /// Resolve the active profile.
206    ///
207    /// Precedence, highest first: `--profile`, `YTCLI_PROFILE`, the nearest
208    /// `.tracker.toml` walking up from `start_dir`, `default_profile`.
209    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    /// A screenshot is usually the most informative thing on a bug, so the
263    /// default is to show it. Turning it off is a profile's decision.
264    #[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        // And the rest of the defaults survive naming only one of them.
275        assert_eq!(display.limit, Display::default().limit);
276    }
277}