use dialoguer::{Confirm, Input, theme::ColorfulTheme};
use owo_colors::OwoColorize;
use crate::config::{Config, OriginPreference, PartialConfig, config_path};
use crate::output;
use crate::validate::{validate_github_username, validate_tangled_username};
pub fn run() -> Result<(), Box<dyn std::error::Error>> {
let path = config_path()?;
let theme = ColorfulTheme::default();
let existing = PartialConfig::load_from_path(&path)?;
println!(
"{}",
"Setting up entangle. Press Enter to keep an existing value.".bold()
);
println!();
let github_username = match prompt_text(
&theme,
"GitHub username",
existing.github_username.as_deref(),
validate_github_username,
)? {
Some(v) => v,
None => return handle_cancel(),
};
let tangled_username = match prompt_text(
&theme,
"Tangled username (ATProto handle, e.g. atdot.fyi)",
existing.tangled_username.as_deref(),
validate_tangled_username,
)? {
Some(v) => v,
None => return handle_cancel(),
};
let origin_preference = match prompt_origin(&theme, existing.origin_preference.as_ref())? {
Some(v) => v,
None => return handle_cancel(),
};
let config = Config {
github_username,
tangled_username,
origin_preference,
verbosity_preference: Default::default(),
};
config.save()?;
println!();
println!("{}", output::success("Configuration saved."));
println!(
" Run {} in a repository to wire up your remotes.",
output::cmd("entangle init")
);
Ok(())
}
#[cfg_attr(test, mutants::skip)]
fn prompt_text(
theme: &ColorfulTheme,
prompt: &str,
existing: Option<&str>,
validator: fn(&str) -> Result<String, crate::validate::ValidationError>,
) -> Result<Option<String>, Box<dyn std::error::Error>> {
if let Some(current) = existing {
let keep = ask_keep(theme, prompt, current)?;
match keep {
Some(true) => return Ok(Some(current.to_string())),
Some(false) => {} None => return Ok(None), }
}
loop {
let raw = match Input::<String>::with_theme(theme)
.with_prompt(prompt)
.interact_text()
{
Ok(v) => v,
Err(e) if is_cancelled(&e) => return Ok(None),
Err(e) => return Err(e.into()),
};
match validator(&raw) {
Ok(validated) => return Ok(Some(validated)),
Err(e) => {
eprintln!("{}", output::error_inline(&e.to_string()));
}
}
}
}
#[cfg_attr(test, mutants::skip)]
fn prompt_origin(
theme: &ColorfulTheme,
existing: Option<&OriginPreference>,
) -> Result<Option<OriginPreference>, Box<dyn std::error::Error>> {
let prompt = "Origin preference (github/gh or tangled/tngl; which forge is the fetch remote)";
let default_hint = "[github]";
if let Some(current) = existing {
let keep = ask_keep(theme, "Origin preference", ¤t.to_string())?;
match keep {
Some(true) => return Ok(Some(current.clone())),
Some(false) => {}
None => return Ok(None),
}
}
loop {
let raw = match Input::<String>::with_theme(theme)
.with_prompt(format!("{prompt} {default_hint}"))
.default("github".to_string())
.interact_text()
{
Ok(v) => v,
Err(e) if is_cancelled(&e) => return Ok(None),
Err(e) => return Err(e.into()),
};
let sanitized = match crate::validate::sanitize(&raw) {
Ok(s) => s,
Err(e) => {
eprintln!("{}", output::error_inline(&e.to_string()));
continue;
}
};
match OriginPreference::from_alias(&sanitized) {
Some(pref) => return Ok(Some(pref)),
None => {
eprintln!(
"{}",
output::error_inline(&format!(
"'{sanitized}' is not recognised. Enter 'github' (or 'gh') or 'tangled' (or 'tngl')."
))
);
}
}
}
}
#[cfg_attr(test, mutants::skip)]
fn ask_keep(
theme: &ColorfulTheme,
field_name: &str,
current_value: &str,
) -> Result<Option<bool>, Box<dyn std::error::Error>> {
let prompt = format!("{field_name} is already set to '{current_value}'. Keep it?");
match Confirm::with_theme(theme)
.with_prompt(prompt)
.default(true) .interact()
{
Ok(answer) => Ok(Some(answer)),
Err(e) if is_cancelled(&e) => Ok(None),
Err(e) => Err(e.into()),
}
}
#[cfg_attr(test, mutants::skip)]
fn is_cancelled(e: &dialoguer::Error) -> bool {
match e {
dialoguer::Error::IO(io_err) => matches!(
io_err.kind(),
std::io::ErrorKind::Interrupted | std::io::ErrorKind::BrokenPipe
),
}
}
#[cfg_attr(test, mutants::skip)]
fn handle_cancel() -> Result<(), Box<dyn std::error::Error>> {
eprintln!("\nSetup cancelled. No changes were made.");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{Config, OriginPreference, PartialConfig};
fn valid_config() -> Config {
Config {
github_username: "cyrusae".to_string(),
tangled_username: "atdot.fyi".to_string(),
origin_preference: OriginPreference::Github,
verbosity_preference: Default::default(),
}
}
#[test]
fn partial_config_detects_all_fields_set() {
let partial = PartialConfig {
github_username: Some("cyrusae".to_string()),
tangled_username: Some("atdot.fyi".to_string()),
origin_preference: Some(OriginPreference::Github),
};
assert!(partial.github_username.is_some());
assert!(partial.tangled_username.is_some());
assert!(partial.origin_preference.is_some());
}
#[test]
fn partial_config_detects_no_fields_set() {
let partial = PartialConfig::default();
assert!(partial.github_username.is_none());
assert!(partial.tangled_username.is_none());
assert!(partial.origin_preference.is_none());
}
#[test]
fn partial_config_detects_partial_fields() {
let partial = PartialConfig {
github_username: Some("cyrusae".to_string()),
tangled_username: None,
origin_preference: None,
};
assert!(partial.github_username.is_some());
assert!(partial.tangled_username.is_none());
}
#[test]
fn is_cancelled_true_for_interrupted() {
let io_err = std::io::Error::from(std::io::ErrorKind::Interrupted);
let dialoguer_err = dialoguer::Error::IO(io_err);
assert!(is_cancelled(&dialoguer_err));
}
#[test]
fn is_cancelled_true_for_broken_pipe() {
let io_err = std::io::Error::from(std::io::ErrorKind::BrokenPipe);
let dialoguer_err = dialoguer::Error::IO(io_err);
assert!(is_cancelled(&dialoguer_err));
}
#[test]
fn is_cancelled_false_for_permission_denied() {
let io_err = std::io::Error::from(std::io::ErrorKind::PermissionDenied);
let dialoguer_err = dialoguer::Error::IO(io_err);
assert!(!is_cancelled(&dialoguer_err));
}
#[test]
fn existing_config_unchanged_after_cancel_is_structural_guarantee() {
}
#[test]
fn config_saved_correctly_when_all_values_collected() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.json");
let cfg = valid_config();
cfg.save_to_path(&path).unwrap();
let loaded = Config::load_from_path(&path).unwrap();
assert_eq!(loaded, cfg);
}
#[test]
fn pre_existing_config_not_overwritten_if_save_not_called() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.json");
valid_config().save_to_path(&path).unwrap();
let existing = Config::load_from_path(&path).unwrap();
let still_there = Config::load_from_path(&path).unwrap();
assert_eq!(existing, still_there);
}
}