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    /// What the stored token was signed in for. Yandex offers no way to ask a
58    /// token its scopes, so this is the only record there is; a pasted token
59    /// has none.
60    #[serde(default)]
61    pub access: Option<Access>,
62}
63
64/// What a signed-in token may do: `read` from `--read-only`, `write` otherwise.
65#[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/// Display defaults. Every one of these is overridable per profile and per
83/// repository, because a default that cannot be moved becomes someone's papercut.
84#[derive(Debug, Clone, Serialize, Deserialize)]
85#[serde(default)]
86pub struct Display {
87    /// Rows returned by list commands before pagination kicks in.
88    pub limit: usize,
89    /// Hard ceiling for `--all` page walking.
90    pub max: usize,
91    /// Description lines shown before the `--full` hint, when the output is
92    /// being piped or read by an agent.
93    pub description_lines: usize,
94    /// The same, for a terminal. `None` — the default — means no limit: a person
95    /// reading their own screen is not paying for context.
96    pub description_lines_human: Option<usize>,
97    /// Custom field keys pinned into the compact view, in this exact order.
98    /// Order is fixed on purpose: a shuffling field list breaks an agent's
99    /// prompt cache on every call.
100    pub extra_fields: Vec<String>,
101    /// Output format when stdout is not a terminal.
102    pub format: OutputFormat,
103    /// Draw image attachments inline where the terminal can draw them.
104    ///
105    /// On by default: a screenshot is usually the most informative thing on a
106    /// bug, and this costs nothing anywhere it cannot be used — no terminal that
107    /// draws means no request for the attachments in the first place.
108    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/// An organisation reached through an account.
126#[derive(Debug, Clone, Serialize, Deserialize)]
127pub struct Profile {
128    /// Key into [`Config::accounts`].
129    pub account: String,
130    /// Organisation id sent in the header chosen by `org_kind`.
131    pub org_id: String,
132    pub org_kind: OrgKind,
133    /// Human note about which organisation this is. An org id is a number
134    /// nobody recognises and a profile name is whatever was typed at login, so
135    /// this is what answers "am I about to write to production" — which is why
136    /// it rides along on the provenance banner and not only in `auth list`.
137    #[serde(default)]
138    pub description: Option<String>,
139    /// Queue assumed when a command needs one and none was given.
140    #[serde(default)]
141    pub default_queue: Option<String>,
142    #[serde(default)]
143    pub display: Display,
144}
145
146/// The user-level config file, `$XDG_CONFIG_HOME/ytcli/config.toml`.
147#[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/// The committed, secret-free `.tracker.toml` found by walking up from the cwd.
156/// It pins a repository to a profile so that an agent working in a checkout
157/// lands in the right organisation without any global mutable state.
158#[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/// Where the active profile name came from. Always reported by `auth status`
166/// and by every writing command: "which organisation am I about to change" must
167/// never be a guess.
168#[derive(Debug, Clone, PartialEq, Eq)]
169pub enum ProfileSource {
170    Flag,
171    Env,
172    ProjectFile(PathBuf),
173    DefaultProfile,
174    /// Chosen because it is the profile that can see the queue in the key.
175    QueueOwner(String),
176    /// Named in the key itself, as `profile/PROJ-1`.
177    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/// A fully resolved profile plus the provenance of that choice.
210#[derive(Debug, Clone)]
211pub struct Resolved {
212    pub name: String,
213    pub profile: Profile,
214    pub source: ProfileSource,
215    /// Queue override coming from `.tracker.toml`, if any.
216    pub queue: Option<String>,
217}
218
219impl Config {
220    /// Read the user config file, letting `YTCLI_*` environment variables win.
221    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    /// Resolve the active profile.
229    ///
230    /// Precedence, highest first: `--profile`, `YTCLI_PROFILE`, the nearest
231    /// `.tracker.toml` walking up from `start_dir`, `default_profile`.
232    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    /// A screenshot is usually the most informative thing on a bug, so the
286    /// default is to show it. Turning it off is a profile's decision.
287    #[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        // And the rest of the defaults survive naming only one of them.
298        assert_eq!(display.limit, Display::default().limit);
299    }
300}