use std::path::PathBuf;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
#[serde(default)]
pub data_dir: Option<PathBuf>,
#[serde(default)]
pub workdir: Option<PathBuf>,
#[serde(default)]
pub auto_clone: bool,
#[serde(default)]
pub open_command: Option<String>,
#[serde(default = "default_true")]
pub open_command_wait: bool,
#[serde(default)]
pub source: SourcesConfig,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SourcesConfig {
#[serde(default)]
pub github: Option<GithubConfig>,
#[serde(rename = "moz-phab", default)]
pub moz_phab: Option<MozPhabConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MozPhabConfig {
pub url: String,
#[serde(default)]
pub token: Option<String>,
#[serde(default)]
pub token_cmd: Option<String>,
#[serde(default = "default_true")]
pub include_groups: bool,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct GithubConfig {
#[serde(default)]
pub api_url: Option<String>,
#[serde(default)]
pub token: Option<String>,
#[serde(default)]
pub token_cmd: Option<String>,
#[serde(default)]
pub ignore_repos: Vec<String>,
#[serde(default)]
pub ignore_authors: Vec<String>,
#[serde(default)]
pub ignore_teams: Vec<String>,
#[serde(default)]
pub include_drafts: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum VcsKind {
Git,
Jj,
}
fn default_true() -> bool {
true
}
impl Default for Config {
fn default() -> Self {
toml::from_str("").expect("an empty config is valid")
}
}
impl Config {
pub fn load(path: &std::path::Path) -> Result<Self> {
if !path.exists() {
return Ok(Self::default());
}
let text = std::fs::read_to_string(path)
.with_context(|| format!("reading config {}", path.display()))?;
let mut config: Config =
toml::from_str(&text).with_context(|| format!("parsing config {}", path.display()))?;
config.data_dir = config.data_dir.map(|p| expand_tilde(&p));
config.workdir = config.workdir.map(|p| expand_tilde(&p));
Ok(config)
}
}
fn expand_tilde(path: &std::path::Path) -> PathBuf {
match path.strip_prefix("~") {
Ok(rest) => match directories::BaseDirs::new() {
Some(base_dirs) => base_dirs.home_dir().join(rest),
None => path.to_path_buf(),
},
Err(_) => path.to_path_buf(),
}
}
pub fn set_auto_clone(path: &std::path::Path) -> Result<()> {
let existing = std::fs::read_to_string(path).unwrap_or_default();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("creating {}", parent.display()))?;
}
std::fs::write(path, format!("auto_clone = true\n{existing}"))
.with_context(|| format!("writing {}", path.display()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_example_config() {
let text = r#"
workdir = "~/dev"
[source.moz-phab]
url = "https://phabricator.services.mozilla.com"
[source.github]
ignore_repos = ["mozilla/some-noisy-repo"]
"#;
let cfg: Config = toml::from_str(text).unwrap();
assert_eq!(
cfg.source.moz_phab.unwrap().url,
"https://phabricator.services.mozilla.com"
);
assert_eq!(
cfg.source.github.unwrap().ignore_repos,
vec!["mozilla/some-noisy-repo".to_string()]
);
assert_eq!(cfg.workdir, Some(PathBuf::from("~/dev")));
assert!(!cfg.auto_clone);
assert!(cfg.open_command.is_none());
assert!(cfg.open_command_wait, "blocking is the default");
}
#[test]
fn open_command_wait_can_be_disabled() {
let cfg: Config = toml::from_str("open_command_wait = false").unwrap();
assert!(!cfg.open_command_wait);
}
#[test]
fn parses_open_command() {
let cfg: Config = toml::from_str(r#"open_command = "nvim +DiffviewOpen""#).unwrap();
assert_eq!(cfg.open_command.as_deref(), Some("nvim +DiffviewOpen"));
}
#[test]
fn missing_file_is_empty_config() {
let cfg = Config::load(std::path::Path::new("/nonexistent/config.toml")).unwrap();
assert!(cfg.source.github.is_none());
assert!(cfg.source.moz_phab.is_none());
assert!(cfg.workdir.is_none());
}
#[test]
fn load_expands_tilde_in_data_dir_and_workdir_paths() {
let tmp = tempfile::tempdir().unwrap();
let config_path = tmp.path().join("config.toml");
std::fs::write(
&config_path,
r#"
data_dir = "~/rq-data"
workdir = "~/dev"
"#,
)
.unwrap();
let cfg = Config::load(&config_path).unwrap();
let home = directories::BaseDirs::new()
.unwrap()
.home_dir()
.to_path_buf();
assert_eq!(cfg.data_dir, Some(home.join("rq-data")));
assert_eq!(cfg.workdir, Some(home.join("dev")));
assert!(
!cfg.workdir.unwrap().starts_with("~"),
"the literal `~` component must be gone"
);
}
#[test]
fn set_auto_clone_prepends_and_survives_a_reload() {
let tmp = tempfile::tempdir().unwrap();
let config_path = tmp.path().join("config.toml");
std::fs::write(
&config_path,
r#"
[source.github]
"#,
)
.unwrap();
set_auto_clone(&config_path).unwrap();
let cfg = Config::load(&config_path).unwrap();
assert!(cfg.auto_clone);
assert!(cfg.source.github.is_some(), "existing sources must survive");
}
#[test]
fn set_auto_clone_creates_a_missing_config_file() {
let tmp = tempfile::tempdir().unwrap();
let config_path = tmp.path().join("nested").join("config.toml");
set_auto_clone(&config_path).unwrap();
assert!(Config::load(&config_path).unwrap().auto_clone);
}
}