1mod collectives;
19mod config;
20mod prompt;
21mod steps;
22
23use std::io::IsTerminal;
24use std::path::Path;
25
26use crate::harness::{self, Harness};
27use crate::install::{self, Action, Change, Targets};
28use crate::{collective, learn, runner};
29use collectives::Mesh;
30use prompt::Prompt;
31
32const USAGE: &str = "usage: borg setup [--yes] [--no-vectors] [--harness HARNESS,...|all] [--private DIR...] \
33[--team NAME --paths DIR... [--relay URL|disabled]] [--join TICKET [--paths DIR...]] [--home DIR]";
34
35pub const FLAGS: &[&str] = &["--yes", "--no-vectors", "--harness", "--private", "--team", "--paths", "--relay", "--join", "--home"];
37
38#[derive(Debug, Default)]
40pub struct Opts {
41 yes: bool,
42 no_vectors: bool,
43 harness: Option<String>,
44 join: Option<String>,
45 team: Option<String>,
46 relay: Option<String>,
47 home: Option<String>,
48 private: Option<Vec<String>>,
50 paths: Vec<String>,
51}
52
53impl Opts {
54 fn set(&mut self, flag: &str, rest: &[&str]) -> Result<usize, String> {
56 let values: Vec<String> = rest.iter().take_while(|a| !a.starts_with("--")).map(|a| (*a).to_string()).collect();
57 match flag {
58 "--yes" | "-y" => self.yes = true,
59 "--no-vectors" => self.no_vectors = true,
60 "--private" => self.private = Some(values.clone()),
61 "--paths" => self.paths.clone_from(&values),
62 _ => {
63 let slot = self.value(flag).ok_or(format!("unknown flag {flag}"))?;
64 *slot = Some(values.first().ok_or(format!("{flag} needs a value"))?.clone());
65 return Ok(1);
66 }
67 }
68 Ok(if matches!(flag, "--private" | "--paths") { values.len() } else { 0 })
69 }
70
71 fn value(&mut self, flag: &str) -> Option<&mut Option<String>> {
72 match flag {
73 "--harness" => Some(&mut self.harness),
74 "--join" => Some(&mut self.join),
75 "--team" => Some(&mut self.team),
76 "--relay" => Some(&mut self.relay),
77 "--home" => Some(&mut self.home),
78 _ => None,
79 }
80 }
81
82 fn check(&self) -> Result<(), String> {
84 if self.team.is_some() && self.join.is_some() {
85 return Err("--team founds a collective, --join joins one: not both".into());
86 }
87 if !self.paths.is_empty() && self.team.is_none() && self.join.is_none() {
88 return Err("--paths names a team collective's repos: with --team or --join".into());
89 }
90 if self.yes && self.team.is_some() && self.paths.is_empty() {
91 return Err("--yes --team NAME needs --paths DIR...: a team collective without repo directories routes no repo".into());
92 }
93 self.values()
94 }
95
96 fn values(&self) -> Result<(), String> {
98 if self.relay.is_some() && self.team.is_none() {
99 return Err("--relay is the founded collective's (--team); a joiner gets it from the ticket".into());
100 }
101 self.join.as_deref().map_or(Ok(()), collectives::ticket)?;
102 self.relay.as_deref().map_or(Ok(None), collectives::relay_value).map(drop)
103 }
104}
105
106fn parse(args: &[&str]) -> Result<Opts, String> {
107 let mut o = Opts::default();
108 let mut i = 0;
109 while let Some(flag) = args.get(i) {
110 i += 1 + o.set(flag, &args[i + 1..])?;
111 }
112 o.check()?;
113 Ok(o)
114}
115
116#[must_use]
118pub fn main(args: &[&str]) -> i32 {
119 if args.iter().any(|a| matches!(*a, "-h" | "--help")) {
120 println!("{USAGE}");
121 return 0;
122 }
123 let o = match parse(args) {
124 Ok(o) => o,
125 Err(e) => {
126 eprintln!("borg setup: {e}\n{USAGE}");
127 return 2;
128 }
129 };
130 if !o.yes && !std::io::stdin().is_terminal() {
131 eprintln!("borg setup asks questions and needs a terminal; --yes takes every default without asking");
132 return 2;
133 }
134 if let Some(h) = &o.home {
135 install::isolate("setup", Path::new(h));
136 }
137 let mut stdin = std::io::stdin().lock();
138 let mut ask = Prompt::new(o.yes, &mut stdin);
139 run(&o, &mut ask).unwrap_or_else(|e| {
140 eprintln!("borg setup: {e}");
141 1
142 })
143}
144
145type Wire = (&'static dyn Harness, Vec<Change>);
147
148struct Plan {
150 config: Option<Change>,
151 mesh: Option<Mesh>,
152 vectors: Option<String>,
154 wiring: Vec<Wire>,
155}
156
157fn run(o: &Opts, ask: &mut Prompt) -> Result<i32, String> {
158 let t = o.home.as_deref().map_or_else(Targets::from_env, |h| Targets::under(Path::new(h)));
159 ask.heading("1. What is here");
160 let found = detect(&t);
161 ask.heading("2. Collectives");
162 let (want, mesh) = collectives::choose(o, ask, collective::config_path().exists())?;
163 let config = config::change(&collective::config_path(), &want)?;
164 ask.heading("3. Wiring");
165 let wiring = wiring(&harnesses(o, ask, &found)?, &t)?;
166 let mesh = mesh.filter(|m| !steps::done(m));
167 let plan = Plan { config, mesh, vectors: steps::vectors_plan(o.no_vectors), wiring };
168 ask.heading("4. The plan");
169 if !show(&plan, &t) {
170 println!("nothing to change");
171 } else if !ask.yes_no("Do all of this?", true) {
172 println!("nothing written");
173 return Ok(0);
174 }
175 let stamp = format!("{}Z", install::utc(learn::now_ms()));
176 let mut warns = Vec::new();
177 let (code, mesh) = match plan.config.as_ref().map_or(0, |c| steps::config(c, &stamp)) {
179 0 => act(o, &plan, ask, &stamp, &mut warns),
180 code => {
181 warns
182 .push("nothing else was done (mesh, vectors, wiring): the collectives config could not be written; fix that and run `borg setup` again".into());
183 (code, None)
184 }
185 };
186 ask.heading("5. Check");
187 Ok(verify(o, &warns, mesh).max(code))
188}
189
190fn act<'p>(o: &Opts, plan: &'p Plan, ask: &mut Prompt, stamp: &str, warns: &mut Vec<String>) -> (i32, Option<&'p Mesh>) {
193 let mesh = plan.mesh.as_ref().filter(|m| steps::mesh(m, ask, warns));
194 let failed = i32::from(plan.mesh.is_some() && mesh.is_none());
195 steps::vectors(o.no_vectors, ask, warns);
196 let code = steps::wire(&plan.wiring, stamp).max(failed);
197 steps::import(ask);
198 (code, mesh)
199}
200
201fn detect(t: &Targets) -> Vec<&'static dyn Harness> {
203 let found: Vec<&'static dyn Harness> = harness::visible().filter(|h| install::which(h.probe().cli).is_some()).collect();
204 for h in harness::visible() {
205 let cli = if found.iter().any(|f| f.name() == h.name()) { "installed" } else { "not installed" };
206 println!("{:<9} {cli}; {}", h.name(), install::wiring(h, t));
207 }
208 let spec = std::env::var("BORG_PREDICT_MODEL").ok();
209 println!("{}", runner::availability(spec.as_deref(), &runner::on_path).unwrap_or_else(|e| e));
210 let cwd = std::env::current_dir().map(|d| d.display().to_string()).unwrap_or_default();
211 match learn::engineer(&cwd) {
212 e if e.is_empty() => println!("git: no user.email; lessons will name no author (git config --global user.email ...)"),
213 e => println!("git: you are {e}"),
214 }
215 let path = collective::config_path();
216 if path.exists() {
217 println!("config: {} exists; setup only adds to it", path.display());
218 } else {
219 println!("config: none yet at {}", path.display());
220 }
221 found
222}
223
224fn harnesses(o: &Opts, ask: &mut Prompt, found: &[&'static dyn Harness]) -> Result<Vec<&'static dyn Harness>, String> {
227 if let Some(h) = &o.harness {
228 println!("wire borg into: {h}");
229 return pick(h);
230 }
231 let installed = found.iter().map(|h| h.name()).collect::<Vec<_>>().join(",");
232 let q = format!("Wire borg into ({}, all, or - for none)", harness::names(", "));
233 loop {
234 match pick(&ask.line(&q, if installed.is_empty() { "-" } else { &installed })) {
235 Err(e) if !ask.yes() => println!("{e}"),
236 picked => return picked,
237 }
238 }
239}
240
241fn pick(answer: &str) -> Result<Vec<&'static dyn Harness>, String> {
243 let mut out: Vec<&'static dyn Harness> = Vec::new();
244 for w in prompt::split(answer) {
245 let hs: Vec<_> = if w == "all" { harness::visible().collect() } else { vec![harness::by_name(&w).ok_or(format!("unknown harness {w:?}"))?] };
246 out.extend(hs.into_iter().filter(|h| !out.iter().any(|o| o.name() == h.name())).collect::<Vec<_>>());
247 }
248 Ok(out)
249}
250
251fn wiring(hs: &[&'static dyn Harness], t: &Targets) -> Result<Vec<Wire>, String> {
253 let bin = install::bin();
254 let mut out = Vec::new();
255 for &h in hs {
256 let (changes, notes) = install::plan(Action::Install, h, t, &bin).map_err(|e| format!("{}: {e}", h.name()))?;
257 for n in ¬es {
258 println!("{}: {n}", h.name());
259 }
260 if changes.is_empty() {
261 println!("{}: already wired", h.name());
262 } else {
263 out.push((h, changes));
264 }
265 }
266 Ok(out)
267}
268
269fn show(p: &Plan, t: &Targets) -> bool {
272 p.config.iter().for_each(|c| print!("{}", install::diff(&c.path, c.before.as_deref(), c.after.as_deref())));
273 for (h, changes) in &p.wiring {
274 let skill = t.skill_dir(*h);
275 for c in changes {
276 if own(*h, c, skill.as_deref()) {
277 let n = c.after.as_deref().map_or(0, |a| a.lines().count());
278 println!("{}: {} ({n} lines of borg's own; `borg install {}` shows them)", h.name(), c.path.display(), h.name());
279 } else {
280 print!("{}", install::diff(&c.path, c.before.as_deref(), c.after.as_deref()));
281 }
282 }
283 }
284 if let Some(m) = &p.mesh {
285 println!("then: borg mesh {}", m.args().join(" "));
286 }
287 p.vectors.iter().for_each(|v| println!("then: {v}"));
288 p.config.is_some() || !p.wiring.is_empty() || p.mesh.is_some() || p.vectors.is_some()
289}
290
291fn own(h: &dyn Harness, c: &Change, skill: Option<&Path>) -> bool {
293 skill.is_some_and(|d| c.path.starts_with(d)) || (c.before.is_none() && c.after.as_deref() == Some(h.wiring().adapter()))
294}
295
296fn verify(o: &Opts, warns: &[String], mesh: Option<&Mesh>) -> i32 {
298 let home: Vec<&str> = o.home.as_deref().map(|h| vec!["--home", h]).unwrap_or_default();
299 let code = install::main("doctor", &home);
300 for w in warns {
301 println!("WARN {w}");
302 }
303 println!("\nnext:\n borg status what each collective holds\n borg collective [DIR] which collective a repo belongs to");
304 match mesh {
305 Some(Mesh::Init(name)) => {
306 println!(" borg mesh serve --collective {name} keep it running (a service); then `borg mesh invite --collective {name}`");
307 }
308 Some(Mesh::Join { .. }) => println!(" borg mesh serve keep it running inside one of the team's repos"),
309 None => {}
310 }
311 code
312}
313
314#[cfg(test)]
315mod tests {
316 use super::*;
317
318 #[test]
319 fn flags_parse_and_conflicts_are_refused() {
320 let o = parse(&["--yes", "--private", "~/a", "~/b", "--team", "corp", "--paths", "~/w", "--relay", "disabled", "--no-vectors"]).unwrap();
321 assert!(o.yes && o.no_vectors);
322 assert_eq!(o.private.as_deref(), Some(&["~/a".to_string(), "~/b".to_string()][..]));
323 assert_eq!((o.team.as_deref(), o.paths.as_slice(), o.relay.as_deref()), (Some("corp"), &["~/w".to_string()][..], Some("disabled")));
324 assert!(parse(&["--team", "a", "--join", "BORG1X"]).is_err());
325 assert!(parse(&["--paths", "~/w"]).is_err());
326 assert!(parse(&["--join", "BORG1X", "--relay", "disabled"]).is_err());
327 assert!(parse(&["--team", "a", "--relay", "relay.corp"]).is_err());
328 assert!(parse(&["--yes", "--team", "a"]).unwrap_err().contains("--paths"), "--yes founds no team without repos");
329 assert!(parse(&["--join", "notaticket"]).unwrap_err().contains("BORG1"));
330 assert!(parse(&["--team"]).unwrap_err().contains("needs a value"));
331 assert!(parse(&["--bogus"]).unwrap_err().contains("unknown flag"));
332 }
333
334 #[test]
335 fn every_listed_flag_parses() {
336 for f in FLAGS {
337 let rest: &[&str] = if matches!(*f, "--yes" | "--no-vectors") { &[] } else { &["x"] };
338 let mut o = Opts::default();
339 assert!(o.set(f, rest).is_ok(), "{f}");
340 }
341 }
342}