sprawling 0.0.10

A local multi-agent harness for persistent agent teams: long-running project work, agent communication, and experiments in coordination and social simulation.
Documentation
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
// Copyright (c) 2026 2youg1 and the sprawling contributors

//! The first screen a person sees when the binary carries no command,
//! where a city goes when nobody named one, and handing a URL to the
//! desktop's default handler.
//!
//! Launching this binary from a file manager gives it no arguments and a
//! console that closes the moment it exits. Telling that apart from a
//! person typing `sprawling` in a shell needs `GetConsoleProcessList`,
//! which needs `unsafe`, which the workspace forbids. So nothing here
//! guesses how it was started: the three ways in are each named, and they
//! meet at `up` (`crates/sprawling/spec/Firstrun.lean` ยง8-8).

use std::io::{BufRead, Write};
use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use std::time::Duration;

use accounting::home::Home;

/// How long the WebUI waits for its own city. Everything a city does
/// before it binds - verifying the chain, folding the views, starting the
/// worker - happens first, and on a long history that is seconds. A city
/// slower than this is one to watch starting rather than open behind.
const PROBE_ATTEMPTS: u32 = 60;
const PROBE_INTERVAL: Duration = Duration::from_millis(200);

/// What the person answered on the first screen.
///
/// A city is created only through `Start`, so the genesis write - the one
/// irreversible act in this system - always has somebody behind it.
pub enum FirstScreen {
    Start(PathBuf),
    /// A folder the person already works in. The city forms around it
    /// and every folder inside becomes a building; no file already there
    /// is moved or rewritten, and a `.gitignore` only gains the lines
    /// that keep the city's own records out of git.
    Use(PathBuf),
    Quit,
}

/// Whether the binary's own directory takes a city.
///
/// The answer of [`writability`], and the only thing `default_city`
/// asks about that directory: a boolean parameter at this call site
/// read as `true` at one end and "writable" at the other, which is two
/// spellings of one fact.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BesideBinary {
    /// The directory accepted a file, so the city goes there.
    Writable,
    /// The directory refused one - unpacked into `Program Files`, on a
    /// read-only mount, or on a full disk.
    ReadOnly,
}

/// Where a city goes when the person named none.
///
/// Beside the binary, so the whole thing stays one folder that can be
/// moved, copied or deleted as a unit. When that directory cannot be
/// written the city goes under the home directory instead, at the path
/// `Home::default_city` states. The caller shows the result before
/// creating anything, so the fallback is visible rather than silent.
///
/// With no home to fall back to, this still answers beside the binary:
/// the screen shows that path and starting there fails with the reason,
/// which beats inventing a location nobody asked for.
#[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(),
    }
}

/// Whether a directory accepts a file today. Probes rather than reads
/// permissions: a read-only mount, an ACL and a full disk all end the
/// same way, and only writing finds out.
#[must_use]
pub fn writability(dir: &Path) -> BesideBinary {
    let probe = dir.join(WRITE_PROBE);
    match std::fs::write(&probe, b"") {
        Ok(()) => {
            // The probe is empty and its name says what it is, so a
            // removal this platform refuses leaves nothing a person has
            // to understand, and the directory is writable either way.
            match std::fs::remove_file(&probe) {
                Ok(()) | Err(_) => BesideBinary::Writable,
            }
        }
        Err(_) => BesideBinary::ReadOnly,
    }
}

/// The file `writability` writes and removes. Named after what it is,
/// because a machine that refuses the removal leaves it behind.
const WRITE_PROBE: &str = ".sprawling-write-probe";

/// Draws the first screen and reads the one answer it asks for.
///
/// The path is on the screen before the question, so nobody consents to a
/// location they were not shown. End of input is `Quit`: a piped or
/// unattended stdin has nobody to ask, and creating a city would be
/// acting on silence.
///
/// # Errors
/// Propagates the failure of writing the screen or reading the answer.
/// # Errors
/// Propagates what the terminal reports: a screen nobody can be shown,
/// or an answer that cannot be read, is not an answer to guess at.
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);
    }
    // Anything else is a path. Whether it exists is the caller's to
    // check and to report: a screen that guessed would either refuse a
    // real folder over a typo in its own reading, or create one where
    // somebody meant to point at work they already had.
    Ok(FirstScreen::Use(PathBuf::from(strip_quotes(answer))))
}

/// A path pasted from a file manager arrives wrapped in quotes on every
/// platform that has spaces in its directory names, which is all of
/// them.
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)
}

/// The URL a person on this machine can open.
///
/// A city bound to every interface still has to be reachable from the
/// browser in front of it, and `http://0.0.0.0:8787` is not an address a
/// browser can use.
#[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}"),
    }
}

/// The address something on this machine can actually connect to. A city
/// bound to every interface is still opened from the browser in front of
/// it, and `0.0.0.0` is not an address any client can dial.
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
}

/// Opens the WebUI once the city is really listening, on a thread of its
/// own so serving is not held up by a browser starting.
///
/// Waits for the port to accept rather than guessing when the bind will
/// happen, and gives up in silence when it never does - the URL was
/// printed before this started, so nothing is lost but the convenience.
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() {
                // Never fatal: a machine with no handler for URLs still
                // has a working city, and its address is on the screen.
                drop(open_in_browser(&url));
                return;
            }
            std::thread::sleep(PROBE_INTERVAL);
        }
    });
}

/// Hands a URL to whatever this desktop opens URLs with.
///
/// # Errors
/// Propagates the failure of starting the handler. Callers treat this as
/// a notice rather than a fault: the URL is printed before this runs, so
/// a machine with no handler still has a working city.
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");
    // The empty argument is `start`'s title slot: without it a quoted URL
    // becomes the window title and nothing opens.
    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())
    }

    /// Stand-ins for a binary's own directory and for the fallback root;
    /// neither is a real path on any machine.
    const UNPACKED: &str = "/unpacked";
    const ELSEWHERE: &str = "/elsewhere";

    /// A home that is nowhere real: these tests compare paths and
    /// never touch a disk.
    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"));
    }

    /// The fallback is the home's own answer rather than a second
    /// spelling of it, so a city under the home directory has one
    /// definition (`accounting::home::Home::default_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());
    }

    /// With nowhere to fall back to, the answer stays beside the binary
    /// rather than becoming a location nobody asked for.
    #[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"),
        }
    }

    /// The case a person with a workspace is in: they paste the folder
    /// they already work in, and the city forms around it.
    #[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}"
        );
    }

    /// A file manager wraps a pasted path in quotes the moment it has a
    /// space in it, which is most of them.
    #[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");
    }
}