1use 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 #[serde(default)]
15 pub workdir: Option<PathBuf>,
16 #[serde(default)]
19 pub auto_clone: bool,
20 #[serde(default)]
24 pub open_command: Option<String>,
25 #[serde(default = "default_true")]
30 pub open_command_wait: bool,
31
32 #[serde(default)]
33 pub source: SourcesConfig,
34}
35
36#[derive(Debug, Clone, Default, Serialize, Deserialize)]
41pub struct SourcesConfig {
42 #[serde(default)]
43 pub github: Option<GithubConfig>,
44 #[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 #[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 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
115fn 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
127pub 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}