Skip to main content

review_queue/
config.rs

1//! `config.toml` schema: review sources and where canonical repos live.
2
3use std::path::PathBuf;
4
5use anyhow::{Context, Result};
6use serde::{Deserialize, Serialize};
7
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct Config {
10    #[serde(default)]
11    pub data_dir: Option<PathBuf>,
12    /// Directory rq scans for existing checkouts to use as canonical repos (see `crate::workdir`)
13    /// instead of cloning its own copy.
14    #[serde(default)]
15    pub workdir: Option<PathBuf>,
16    /// Clone into the data dir without asking, whenever a review's repo isn't found in
17    /// `workdir`. Set by answering "always" to the clone prompt (see `set_auto_clone`).
18    #[serde(default)]
19    pub auto_clone: bool,
20    /// Shell command to run in a review's workspace instead of an interactive `$SHELL` when it's
21    /// opened locally from the TUI. Run via `sh -c` with the workspace as cwd; the review is
22    /// described by `RQ_REVIEW` (`source/id`), `RQ_SOURCE`, `RQ_ID` and `RQ_WORKSPACE`.
23    #[serde(default)]
24    pub open_command: Option<String>,
25    /// Whether rq suspends its TUI until `open_command` exits (the default). Set to `false` for
26    /// commands that hand off elsewhere and return - e.g. switching a tmux client - so rq keeps
27    /// running, with the command's stdio detached. Ignored without `open_command`: the fallback
28    /// `$SHELL` needs the terminal.
29    #[serde(default = "default_true")]
30    pub open_command_wait: bool,
31
32    #[serde(default)]
33    pub source: SourcesConfig,
34}
35
36/// Each source is a fixed, hardcoded slot (`[source.github]`, `[source.moz-phab]`) rather than a
37/// list - there's exactly one of each kind, and its id-namespace prefix (see
38/// `crate::source::github::NAME`/`crate::source::moz_phab::NAME`) is likewise hardcoded, not
39/// user-configurable.
40#[derive(Debug, Clone, Default, Serialize, Deserialize)]
41pub struct SourcesConfig {
42    #[serde(default)]
43    pub github: Option<GithubConfig>,
44    /// Mozilla's Phabricator, via the `moz-phab` CLI - not a general-purpose Phabricator source.
45    /// See `crate::source::moz_phab` for why.
46    #[serde(rename = "moz-phab", default)]
47    pub moz_phab: Option<MozPhabConfig>,
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct MozPhabConfig {
52    pub url: String,
53    #[serde(default)]
54    pub token: Option<String>,
55    #[serde(default)]
56    pub token_cmd: Option<String>,
57    #[serde(default = "default_true")]
58    pub include_groups: bool,
59}
60
61#[derive(Debug, Clone, Default, Serialize, Deserialize)]
62pub struct GithubConfig {
63    /// REST API base URL. Defaults to `https://api.github.com`; set this for GitHub Enterprise
64    /// (typically `https://{host}/api/v3`).
65    #[serde(default)]
66    pub api_url: Option<String>,
67    #[serde(default)]
68    pub token: Option<String>,
69    #[serde(default)]
70    pub token_cmd: Option<String>,
71    #[serde(default)]
72    pub ignore_repos: Vec<String>,
73    #[serde(default)]
74    pub ignore_authors: Vec<String>,
75    #[serde(default)]
76    pub ignore_teams: Vec<String>,
77    #[serde(default)]
78    pub include_drafts: bool,
79}
80
81#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
82#[serde(rename_all = "lowercase")]
83pub enum VcsKind {
84    Git,
85    Jj,
86}
87
88fn default_true() -> bool {
89    true
90}
91
92impl Default for Config {
93    fn default() -> Self {
94        // Go through serde so the field defaults (e.g. `open_command_wait`) live in one place.
95        toml::from_str("").expect("an empty config is valid")
96    }
97}
98
99impl Config {
100    pub fn load(path: &std::path::Path) -> Result<Self> {
101        if !path.exists() {
102            return Ok(Self::default());
103        }
104        let text = std::fs::read_to_string(path)
105            .with_context(|| format!("reading config {}", path.display()))?;
106        let mut config: Config =
107            toml::from_str(&text).with_context(|| format!("parsing config {}", path.display()))?;
108
109        config.data_dir = config.data_dir.map(|p| expand_tilde(&p));
110        config.workdir = config.workdir.map(|p| expand_tilde(&p));
111        Ok(config)
112    }
113}
114
115/// `PathBuf`'s `Deserialize` treats `~` as a literal path component, so config paths need this
116/// run explicitly - nothing does it for free the way a shell would.
117fn expand_tilde(path: &std::path::Path) -> PathBuf {
118    match path.strip_prefix("~") {
119        Ok(rest) => match directories::BaseDirs::new() {
120            Some(base_dirs) => base_dirs.home_dir().join(rest),
121            None => path.to_path_buf(),
122        },
123        Err(_) => path.to_path_buf(),
124    }
125}
126
127/// Record a "always clone without asking" answer to the clone prompt. Prepended (rather than
128/// appended, like nothing else in this file does) because appending a bare `key = value` after
129/// one of `config.toml`'s `[source.*]` tables would parse as belonging to that table instead of
130/// to the top-level document.
131pub fn set_auto_clone(path: &std::path::Path) -> Result<()> {
132    let existing = std::fs::read_to_string(path).unwrap_or_default();
133    if let Some(parent) = path.parent() {
134        std::fs::create_dir_all(parent)
135            .with_context(|| format!("creating {}", parent.display()))?;
136    }
137    std::fs::write(path, format!("auto_clone = true\n{existing}"))
138        .with_context(|| format!("writing {}", path.display()))
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144
145    #[test]
146    fn parses_example_config() {
147        let text = r#"
148workdir = "~/dev"
149
150[source.moz-phab]
151url = "https://phabricator.services.mozilla.com"
152
153[source.github]
154ignore_repos = ["mozilla/some-noisy-repo"]
155"#;
156        let cfg: Config = toml::from_str(text).unwrap();
157        assert_eq!(
158            cfg.source.moz_phab.unwrap().url,
159            "https://phabricator.services.mozilla.com"
160        );
161        assert_eq!(
162            cfg.source.github.unwrap().ignore_repos,
163            vec!["mozilla/some-noisy-repo".to_string()]
164        );
165        assert_eq!(cfg.workdir, Some(PathBuf::from("~/dev")));
166        assert!(!cfg.auto_clone);
167        assert!(cfg.open_command.is_none());
168        assert!(cfg.open_command_wait, "blocking is the default");
169    }
170
171    #[test]
172    fn open_command_wait_can_be_disabled() {
173        let cfg: Config = toml::from_str("open_command_wait = false").unwrap();
174        assert!(!cfg.open_command_wait);
175    }
176
177    #[test]
178    fn parses_open_command() {
179        let cfg: Config = toml::from_str(r#"open_command = "nvim +DiffviewOpen""#).unwrap();
180        assert_eq!(cfg.open_command.as_deref(), Some("nvim +DiffviewOpen"));
181    }
182
183    #[test]
184    fn missing_file_is_empty_config() {
185        let cfg = Config::load(std::path::Path::new("/nonexistent/config.toml")).unwrap();
186        assert!(cfg.source.github.is_none());
187        assert!(cfg.source.moz_phab.is_none());
188        assert!(cfg.workdir.is_none());
189    }
190
191    #[test]
192    fn load_expands_tilde_in_data_dir_and_workdir_paths() {
193        let tmp = tempfile::tempdir().unwrap();
194        let config_path = tmp.path().join("config.toml");
195        std::fs::write(
196            &config_path,
197            r#"
198data_dir = "~/rq-data"
199workdir = "~/dev"
200"#,
201        )
202        .unwrap();
203
204        let cfg = Config::load(&config_path).unwrap();
205        let home = directories::BaseDirs::new()
206            .unwrap()
207            .home_dir()
208            .to_path_buf();
209
210        assert_eq!(cfg.data_dir, Some(home.join("rq-data")));
211        assert_eq!(cfg.workdir, Some(home.join("dev")));
212        assert!(
213            !cfg.workdir.unwrap().starts_with("~"),
214            "the literal `~` component must be gone"
215        );
216    }
217
218    #[test]
219    fn set_auto_clone_prepends_and_survives_a_reload() {
220        let tmp = tempfile::tempdir().unwrap();
221        let config_path = tmp.path().join("config.toml");
222        std::fs::write(
223            &config_path,
224            r#"
225[source.github]
226"#,
227        )
228        .unwrap();
229
230        set_auto_clone(&config_path).unwrap();
231
232        let cfg = Config::load(&config_path).unwrap();
233        assert!(cfg.auto_clone);
234        assert!(cfg.source.github.is_some(), "existing sources must survive");
235    }
236
237    #[test]
238    fn set_auto_clone_creates_a_missing_config_file() {
239        let tmp = tempfile::tempdir().unwrap();
240        let config_path = tmp.path().join("nested").join("config.toml");
241
242        set_auto_clone(&config_path).unwrap();
243
244        assert!(Config::load(&config_path).unwrap().auto_clone);
245    }
246}