use std::io::{BufRead, Write};
use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use std::time::Duration;
use accounting::home::Home;
const PROBE_ATTEMPTS: u32 = 60;
const PROBE_INTERVAL: Duration = Duration::from_millis(200);
pub enum FirstScreen {
Start(PathBuf),
Use(PathBuf),
Quit,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BesideBinary {
Writable,
ReadOnly,
}
#[must_use]
pub fn default_city(exe_dir: &Path, home: Option<&Home>, beside: BesideBinary) -> PathBuf {
match (beside, home) {
(BesideBinary::Writable, _) | (BesideBinary::ReadOnly, None) => {
exe_dir.join(accounting::home::CITY_DIR)
}
(BesideBinary::ReadOnly, Some(home)) => home.default_city(),
}
}
#[must_use]
pub fn writability(dir: &Path) -> BesideBinary {
let probe = dir.join(WRITE_PROBE);
match std::fs::write(&probe, b"") {
Ok(()) => {
match std::fs::remove_file(&probe) {
Ok(()) | Err(_) => BesideBinary::Writable,
}
}
Err(_) => BesideBinary::ReadOnly,
}
}
const WRITE_PROBE: &str = ".sprawling-write-probe";
pub fn ask<R: BufRead, W: Write>(
city: &Path,
input: &mut R,
out: &mut W,
) -> std::io::Result<FirstScreen> {
writeln!(
out,
"\n sprawling - an agent city that runs on this machine.\n"
)?;
writeln!(out, " No city was named. Start one here?\n")?;
writeln!(out, " {}\n", city.display())?;
writeln!(out, " [Enter] start it, and open the WebUI")?;
writeln!(
out,
" [path] use a folder you already work in: the city forms"
)?;
writeln!(
out,
" around it, every folder inside becomes a building,"
)?;
writeln!(
out,
" and your files stay as they are: a .gitignore"
)?;
writeln!(
out,
" only gains the lines that keep the city out of git"
)?;
writeln!(out, " [q] quit, and print the command list\n")?;
write!(out, " > ")?;
out.flush()?;
let mut answer = String::new();
let read = input.read_line(&mut answer)?;
if read == 0 {
return Ok(FirstScreen::Quit);
}
let answer = answer.trim();
if answer.is_empty() || answer.eq_ignore_ascii_case("y") || answer.eq_ignore_ascii_case("yes") {
return Ok(FirstScreen::Start(city.to_path_buf()));
}
if answer.eq_ignore_ascii_case("q") || answer.eq_ignore_ascii_case("quit") {
return Ok(FirstScreen::Quit);
}
Ok(FirstScreen::Use(PathBuf::from(strip_quotes(answer))))
}
fn strip_quotes(answer: &str) -> &str {
answer
.strip_prefix('"')
.and_then(|rest| rest.strip_suffix('"'))
.or_else(|| {
answer
.strip_prefix('\'')
.and_then(|rest| rest.strip_suffix('\''))
})
.unwrap_or(answer)
}
#[must_use]
pub fn local_url(bind: SocketAddr) -> String {
let addr = reachable(bind);
let port = addr.port();
match addr.ip() {
std::net::IpAddr::V4(v4) => format!("http://{v4}:{port}"),
std::net::IpAddr::V6(v6) => format!("http://[{v6}]:{port}"),
}
}
fn reachable(bind: SocketAddr) -> SocketAddr {
if bind.ip().is_unspecified() {
let loopback = std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST);
return SocketAddr::new(loopback, bind.port());
}
bind
}
pub fn open_when_ready(bind: SocketAddr, url: String) {
let addr = reachable(bind);
std::thread::spawn(move || {
for _ in 0..PROBE_ATTEMPTS {
if std::net::TcpStream::connect_timeout(&addr, PROBE_INTERVAL).is_ok() {
drop(open_in_browser(&url));
return;
}
std::thread::sleep(PROBE_INTERVAL);
}
});
}
pub(crate) fn open_in_browser(url: &str) -> std::io::Result<()> {
let mut command = handler(url);
command.status().map(|_| ())
}
#[cfg(target_os = "windows")]
fn handler(url: &str) -> std::process::Command {
let mut command = std::process::Command::new("cmd");
command.args(["/C", "start", "", url]);
command
}
#[cfg(target_os = "macos")]
fn handler(url: &str) -> std::process::Command {
let mut command = std::process::Command::new("open");
command.arg(url);
command
}
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
fn handler(url: &str) -> std::process::Command {
let mut command = std::process::Command::new("xdg-open");
command.arg(url);
command
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::indexing_slicing,
clippy::wildcard_enum_match_arm,
clippy::let_underscore_must_use,
clippy::let_underscore_untyped,
reason = "test code"
)]
mod tests {
use super::{BesideBinary, FirstScreen, Home, ask, default_city, local_url};
use std::path::{Path, PathBuf};
fn screen(answer: &str) -> (FirstScreen, String) {
let city = PathBuf::from("/tmp/here/city");
let mut input = answer.as_bytes();
let mut out: Vec<u8> = Vec::new();
let outcome = ask(&city, &mut input, &mut out).unwrap();
(outcome, String::from_utf8(out).unwrap())
}
const UNPACKED: &str = "/unpacked";
const ELSEWHERE: &str = "/elsewhere";
fn elsewhere() -> Home {
Home::at(Path::new(ELSEWHERE))
}
#[test]
fn city_goes_beside_the_binary_when_that_directory_is_writable() {
let beside = default_city(
Path::new(UNPACKED),
Some(&elsewhere()),
BesideBinary::Writable,
);
assert_eq!(beside, PathBuf::from("/unpacked/city"));
}
#[test]
fn city_falls_back_when_the_binary_directory_is_read_only() {
let fallback = default_city(
Path::new(UNPACKED),
Some(&elsewhere()),
BesideBinary::ReadOnly,
);
assert_eq!(fallback, elsewhere().default_city());
}
#[test]
fn with_no_fallback_the_answer_is_still_beside_the_binary() {
let beside = default_city(Path::new(UNPACKED), None, BesideBinary::ReadOnly);
assert_eq!(beside, PathBuf::from("/unpacked/city"));
}
#[test]
fn the_path_is_on_the_screen_before_the_question_is_answered() {
let (_, drawn) = screen("\n");
assert!(drawn.contains("/tmp/here/city"), "drawn: {drawn}");
}
#[test]
fn enter_starts_the_city_at_the_path_the_screen_showed() {
let (outcome, _) = screen("\n");
match outcome {
FirstScreen::Start(path) => assert_eq!(path, PathBuf::from("/tmp/here/city")),
_ => panic!("Enter starts the city"),
}
}
#[test]
fn a_pasted_path_is_a_folder_to_use_rather_than_a_place_to_start() {
let (outcome, drawn) = screen("/tmp/already/work\n");
match outcome {
FirstScreen::Use(path) => assert_eq!(path, PathBuf::from("/tmp/already/work")),
_ => panic!("a path is a folder to use"),
}
assert!(
drawn.contains("your files stay as they are"),
"the screen has to say what will happen to their work: {drawn}"
);
}
#[test]
fn a_pasted_path_keeps_its_spaces_and_loses_its_quotes() {
let (outcome, _) = screen("\"/tmp/already/my work\"\n");
match outcome {
FirstScreen::Use(path) => assert_eq!(path, PathBuf::from("/tmp/already/my work")),
_ => panic!("a quoted path is still a path"),
}
}
#[test]
fn q_quits_without_creating_anything() {
let (outcome, _) = screen("q\n");
assert!(matches!(outcome, FirstScreen::Quit));
}
#[test]
fn end_of_input_quits_rather_than_acting_on_silence() {
let (outcome, _) = screen("");
assert!(matches!(outcome, FirstScreen::Quit));
}
#[test]
fn a_city_bound_to_every_interface_is_shown_as_loopback() {
let every: std::net::SocketAddr = "0.0.0.0:8787".parse().unwrap();
assert_eq!(local_url(every), "http://127.0.0.1:8787");
}
#[test]
fn a_city_bound_to_one_address_is_shown_at_that_address() {
let one: std::net::SocketAddr = "192.168.1.9:8787".parse().unwrap();
assert_eq!(local_url(one), "http://192.168.1.9:8787");
}
}