use crate::collective::{Entry, Share};
use super::Opts;
use super::config::{self, Want};
use super::prompt::Prompt;
pub const PRIVATE: &str = "private";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Mesh {
Init(String),
Join { ticket: String, paths: Vec<String> },
}
impl Mesh {
#[must_use]
pub fn args(&self) -> Vec<String> {
match self {
Mesh::Init(name) => vec!["init".into(), "--collective".into(), name.clone()],
Mesh::Join { ticket, paths } => {
let paths = if paths.is_empty() { Vec::new() } else { std::iter::once("--paths".to_string()).chain(paths.iter().cloned()).collect() };
[vec!["join".to_string(), ticket.clone()], paths, vec!["--apply".into()]].concat()
}
}
}
}
pub fn choose(o: &Opts, ask: &mut Prompt, existing: bool) -> Result<(Want, Option<Mesh>), String> {
let (private, team) = kinds(o, ask, existing);
let mut want = Want { default: "off".into(), ..Want::default() };
if private {
personal(o, ask, &mut want)?;
}
let mesh = if team { self::team(o, ask, &mut want)? } else { None };
if private || team {
boundaries(ask, &mut want, existing)?;
}
Ok((want, mesh))
}
fn boundaries(ask: &mut Prompt, want: &mut Want, existing: bool) -> Result<(), String> {
want.off = dirs(&ask.list("Repos no collective may ever see (client or confidential work)", &[]), false)?;
if !existing && !want.entries.is_empty() {
want.default = default_of(ask, &want.entries);
}
Ok(())
}
fn kinds(o: &Opts, ask: &mut Prompt, existing: bool) -> (bool, bool) {
let team = o.team.is_some() || o.join.is_some();
if o.private.is_some() || team {
return (o.private.is_some(), team);
}
let mut options = vec!["personal: a private collective, never leaves this machine", "team or company: shared through the mesh", "both"];
if existing {
options.push("keep the existing config as it is");
}
match ask.choose("Which collectives?", &options, if existing { 3 } else { 0 }) {
0 => (true, false),
1 => (false, true),
2 => (true, true),
_ => (false, false),
}
}
fn personal(o: &Opts, ask: &mut Prompt, want: &mut Want) -> Result<(), String> {
let paths = match &o.private {
Some(p) if !p.is_empty() => dirs(p, true)?,
_ => dirs(&ask.list("Repo directories for your personal collective", &config::default_roots()), ask.yes())?,
};
if paths.is_empty() {
println!("no repo directories: no personal collective (borg setup --private DIR...)");
return Ok(());
}
want.personal = Some(PRIVATE.into());
want.entries.push(entry(PRIVATE, paths, Share::Local));
Ok(())
}
fn entry(name: &str, paths: Vec<String>, share: Share) -> Entry {
Entry { name: name.into(), paths, share, experts: Vec::new(), relay: None, mode: None, budget: None, risk: None }
}
fn team(o: &Opts, ask: &mut Prompt, want: &mut Want) -> Result<Option<Mesh>, String> {
let given = ticket_of(o, ask);
if let Some(e) = given.as_deref().and_then(|t| ticket(t).err()) {
println!("{e}: no team collective; later: borg setup --join BORG1... --paths DIR...");
return Ok(None);
}
let paths = team_paths(o, ask)?;
if let Some(ticket) = given {
return Ok(Some(Mesh::Join { ticket, paths }));
}
let name = o.team.clone().unwrap_or_else(|| ask.line("Team collective name", "team"));
let mut e = entry(&name, paths, Share::Team);
e.relay = relay(o, ask)?;
e.mode = (ask.choose("When the conscience judges the work", &["live: during the turn and at its end (default)", "hook: at the turn end only"], 0) == 1)
.then(|| "hook".to_string());
want.entries.push(e);
Ok(Some(Mesh::Init(name)))
}
fn ticket_of(o: &Opts, ask: &mut Prompt) -> Option<String> {
if o.join.is_some() || o.team.is_some() {
return o.join.clone();
}
let join = ask.choose("Team collective", &["found one (you become its admin)", "join one with a ticket (BORG1...)"], 0) == 1;
join.then(|| ask.line("Ticket", "-"))
}
fn team_paths(o: &Opts, ask: &mut Prompt) -> Result<Vec<String>, String> {
let paths = if o.paths.is_empty() { dirs(&ask.list("Repo directories for the team collective", &[]), false)? } else { dirs(&o.paths, true)? };
if paths.is_empty() {
println!("WARN no repo directories: the team collective routes no repo until collectives.json lists some (or: borg setup ... --paths DIR...)");
}
Ok(paths)
}
pub fn ticket(t: &str) -> Result<(), String> {
if t.trim().to_ascii_uppercase().starts_with("BORG1") && t.trim().len() > "BORG1".len() {
Ok(())
} else {
Err(format!("{t:?} is not a mesh ticket: they start with BORG1 (`borg mesh invite` prints one)"))
}
}
fn relay(o: &Opts, ask: &mut Prompt) -> Result<Option<String>, String> {
let r = match &o.relay {
Some(r) => r.clone(),
None => match ask.choose(
"How members reach each other",
&["n0's public relays (default)", "disabled: direct addresses only (LAN or VPN)", "your company's iroh-relay"],
0,
) {
0 => "default".into(),
1 => "disabled".into(),
_ => ask.line("Relay URL", "https://"),
},
};
relay_value(&r)
}
pub fn relay_value(r: &str) -> Result<Option<String>, String> {
match r {
"default" => Ok(None),
"disabled" => Ok(Some(r.into())),
url if url.len() > "https://".len() && (url.starts_with("https://") || url.starts_with("http://")) => Ok(Some(url.into())),
_ => Err(format!("relay {r:?}: an http(s) URL of an iroh-relay, disabled, or default")),
}
}
fn default_of(ask: &mut Prompt, entries: &[Entry]) -> String {
let names: Vec<String> = entries.iter().map(|e| format!("{}: every other repo joins it", e.name)).collect();
let options: Vec<&str> = std::iter::once("off: other repos belong to no collective (recommended)").chain(names.iter().map(String::as_str)).collect();
match ask.choose("Repos outside these directories", &options, 0) {
0 => "off".into(),
n => entries[n - 1].name.clone(),
}
}
fn dirs(list: &[String], strict: bool) -> Result<Vec<String>, String> {
let mut out = Vec::new();
for p in list {
match config::dir(p) {
Ok(d) => out.push(d),
Err(e) if strict => return Err(e),
Err(e) => println!("skipped {e}"),
}
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mesh_commands_and_relays() {
assert_eq!(Mesh::Init("corp".into()).args(), ["init", "--collective", "corp"]);
let join = Mesh::Join { ticket: "BORG1X".into(), paths: vec!["~/w".into()] };
assert_eq!(join.args(), ["join", "BORG1X", "--paths", "~/w", "--apply"]);
assert_eq!(Mesh::Join { ticket: "BORG1X".into(), paths: vec![] }.args(), ["join", "BORG1X", "--apply"]);
assert_eq!(relay_value("default").unwrap(), None);
assert_eq!(relay_value("disabled").unwrap().as_deref(), Some("disabled"));
assert!(relay_value("https://relay.corp.example").is_ok());
assert!(relay_value("https://").is_err() && relay_value("relay.corp").is_err());
assert!(ticket("BORG1abc").is_ok() && ticket("-").is_err() && ticket("BORG1").is_err());
}
}