use std::io::{ErrorKind, IsTerminal};
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use std::sync::Arc;
use std::time::Duration;
use anyhow::Context;
use chrono::Utc;
use clap::Parser;
use crate::app::Asked;
use crate::collect::agents::Agents;
use crate::collect::bd;
use crate::collect::changes;
use crate::collect::discovery;
use crate::collect::herdr;
use crate::collect::run::{RealRunner, Runner};
use crate::config::Config;
use crate::model::snapshot::Filter;
use crate::tui::{Armed, Arming, Reload, CHECKED_EVERY};
const DEFAULT_CONFIG: &str = "~/.config/beady-eye/config.toml";
const PROJECT_IN_THE_ENVIRONMENT: &str = "BDI_PROJECT";
const NO_TERMINAL: u8 = 2;
#[derive(Parser)]
#[command(name = "bdi", version, about = "A tree of work in flight")]
struct Cli {
#[arg(value_name = "BEAD-ID")]
beads: Vec<String>,
#[arg(long)]
config: Option<String>,
#[arg(long = "project", value_name = "NAME")]
projects: Vec<String>,
#[arg(long = "all-projects", conflicts_with = "projects")]
all_projects: bool,
#[arg(long)]
json: bool,
#[arg(long)]
all: bool,
#[arg(long, conflicts_with = "no_poll")]
poll: bool,
#[arg(long = "no-poll")]
no_poll: bool,
#[arg(long, value_name = "PATH")]
socket: Option<PathBuf>,
}
fn told_to_listen_on(cli: &Cli, cfg: &Config) -> Option<PathBuf> {
cli.socket.clone().or_else(|| cfg.changes.socket.clone())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Polling {
AsConfigured,
Everything,
Nothing,
}
impl Polling {
fn asked_for(cli: &Cli) -> Self {
match (cli.poll, cli.no_poll) {
(true, _) => Polling::Everything,
(_, true) => Polling::Nothing,
_ => Polling::AsConfigured,
}
}
fn after_a_read(self, project: &crate::config::Project, every: Duration) -> Option<Duration> {
let polls = match self {
Polling::AsConfigured => project.poll,
Polling::Everything => true,
Polling::Nothing => false,
};
polls.then_some(every)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum Reading {
WhereBdiWasStarted,
EveryProject,
Named(Vec<String>),
}
impl Reading {
fn asked_for(cli: &Cli) -> Self {
if cli.all_projects {
Reading::EveryProject
} else if cli.projects.is_empty() {
Reading::WhereBdiWasStarted
} else {
Reading::Named(cli.projects.clone())
}
}
}
struct Launch<'a> {
cwd: &'a Path,
reading: Reading,
roots: &'a [String],
}
#[derive(Debug)]
struct Settled {
config: Config,
read_from: Option<PathBuf>,
}
pub fn run() -> anyhow::Result<ExitCode> {
let cli = Cli::parse();
let home = std::env::var_os("HOME").map(PathBuf::from);
let cwd = std::env::current_dir().context("finding the current directory")?;
let launch = Launch {
cwd: &cwd,
reading: Reading::asked_for(&cli),
roots: &cli.beads,
};
let Settled {
config: mut cfg,
read_from,
} = match &cli.config {
Some(named) => read_config(&RealRunner, &expand_tilde(named, home), &launch),
None => config_for_wherever_bdi_was_run(
&RealRunner,
&expand_tilde(DEFAULT_CONFIG, home),
&launch,
),
}?;
let filter = if cli.all {
Filter::All
} else {
Filter::LiveAgents
};
if cli.json {
let snapshot = crate::app::run(
&cfg,
&herdr::Herdr::new(&RealRunner as &dyn Runner),
&bd::Cli::new(&RealRunner),
filter,
Utc::now(),
);
println!("{}", serde_json::to_string_pretty(&snapshot)?);
return Ok(ExitCode::SUCCESS);
}
if !std::io::stdout().is_terminal() {
eprintln!("bdi's view needs a terminal; re-run with --json");
return Ok(ExitCode::from(NO_TERMINAL));
}
let started_on = cfg.clone();
let listening_on = changes::where_writers_find_bdi(told_to_listen_on(&cli, &cfg));
let polling = Polling::asked_for(&cli);
let arms: Arming = Box::new(move |cfg: &Config| {
cfg.read()
.map(|project| {
Armed::polling(
project.name.clone(),
polling.after_a_read(project, cfg.tui.refresh()),
)
})
.collect()
});
let reload = read_from.map(|path| {
let cwd = cwd.clone();
let reading = Reading::asked_for(&cli);
let roots = cli.beads.clone();
Reload::watching(
path,
CHECKED_EVERY,
cfg.clone(),
Box::new(move |text| {
config_for_this_run(
text,
&RealRunner,
&Launch {
cwd: &cwd,
reading: reading.clone(),
roots: &roots,
},
)
}),
Utc::now(),
)
});
let mut collection = crate::app::Collection::default();
let trackers = bd::Cli::new(&RealRunner);
let agents: Arc<dyn Agents> = Arc::new(herdr::Herdr::new(&RealRunner as &dyn Runner));
let listing = Arc::clone(&agents);
crate::tui::run(
&started_on,
filter,
arms,
agents,
listening_on,
Box::new(move |asked| match asked {
Asked::Reloaded(written) => {
cfg = *written;
None
}
Asked::Read(wanted) => {
Some(collection.collect(&cfg, &listing, &trackers, &wanted, filter, Utc::now()))
}
}),
reload,
)?;
Ok(ExitCode::SUCCESS)
}
fn read_config(runner: &dyn Runner, path: &Path, launch: &Launch<'_>) -> anyhow::Result<Settled> {
let text = std::fs::read_to_string(path)
.with_context(|| format!("reading the config at {}", path.display()))?;
Ok(Settled {
config: config_for_this_run(&text, runner, launch)?,
read_from: Some(path.to_path_buf()),
})
}
fn config_for_this_run(
text: &str,
runner: &dyn Runner,
launch: &Launch<'_>,
) -> anyhow::Result<Config> {
let cfg = scoped(Config::from_toml(text)?, runner, launch)?
.with_roots_named_on_the_command_line(launch.roots)?;
Ok(discovery::with_the_working_trees_git_lists(cfg, runner))
}
fn scoped(cfg: Config, runner: &dyn Runner, launch: &Launch<'_>) -> anyhow::Result<Config> {
match &launch.reading {
Reading::Named(names) => cfg.scoped_to(names),
Reading::EveryProject => Ok(cfg),
Reading::WhereBdiWasStarted => {
Ok(discovery::scoped_to_the_directory(cfg, runner, launch.cwd))
}
}
}
fn config_for_wherever_bdi_was_run(
runner: &dyn Runner,
path: &Path,
launch: &Launch<'_>,
) -> anyhow::Result<Settled> {
match std::fs::read_to_string(path) {
Ok(text) => Ok(Settled {
config: config_for_this_run(&text, runner, launch)?,
read_from: Some(path.to_path_buf()),
}),
Err(absent) if absent.kind() == ErrorKind::NotFound => {
let named = std::env::var(PROJECT_IN_THE_ENVIRONMENT).ok();
let discovered =
discovery::from_the_current_directory(runner, launch.cwd, named.as_deref())
.with_context(|| {
format!(
"there is no config at {}, so bdi read the current directory",
path.display()
)
})?;
let cfg = match &launch.reading {
Reading::Named(names) => discovered.scoped_to(names)?,
Reading::EveryProject | Reading::WhereBdiWasStarted => discovered,
};
Ok(Settled {
config: cfg.with_roots_named_on_the_command_line(launch.roots)?,
read_from: None,
})
}
Err(unreadable) => {
Err(unreadable).with_context(|| format!("reading the config at {}", path.display()))
}
}
}
fn expand_tilde(path: &str, home: Option<PathBuf>) -> PathBuf {
match (path.strip_prefix("~/"), home) {
(Some(rest), Some(home)) => home.join(rest),
_ => PathBuf::from(path),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::collect::run::testing::FakeRunner;
use crate::collect::run::{Env, RunFailure};
use crate::config::Scope;
use clap::CommandFactory;
use std::collections::BTreeMap;
const ONE_PROJECT: &str = r#"
[[projects]]
name = "orbital"
path = "/srv/work/orbital"
"#;
const TWO_PROJECTS: &str = r#"
[[projects]]
name = "orbital"
path = "/srv/work/orbital"
[[projects]]
name = "ferry"
path = "/srv/work/ferry"
"#;
const A_WORKTREE_PER_SEAT: &str = "\
worktree /srv/work/orbital
HEAD 4d3c1f0e9b8a7c6d5e4f3a2b1c0d9e8f7a6b5c4d
branch refs/heads/main
worktree /tmp/seat-a/wt
HEAD 1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b
detached
";
fn a_config_file(named: &str) -> PathBuf {
a_config_file_holding(named, ONE_PROJECT)
}
fn a_config_file_holding(named: &str, text: &str) -> PathBuf {
let path = std::env::temp_dir().join(format!("bdi-{named}-{}.toml", std::process::id()));
std::fs::write(&path, text).expect("the file is ours to write");
path
}
fn started_in(cwd: &'static str) -> Launch<'static> {
Launch {
cwd: Path::new(cwd),
reading: Reading::WhereBdiWasStarted,
roots: &[],
}
}
fn every_project() -> Launch<'static> {
Launch {
reading: Reading::EveryProject,
..started_in("/home/elsewhere")
}
}
fn read_by(cfg: &Config) -> Vec<&str> {
cfg.read().map(|p| p.name.as_str()).collect()
}
fn directories_entered(runner: &FakeRunner) -> Vec<Option<PathBuf>> {
runner.calls().into_iter().map(|c| c.cwd).collect()
}
struct NeverEntering {
forbidden: PathBuf,
inner: FakeRunner,
}
fn never_entering(forbidden: &str, inner: FakeRunner) -> NeverEntering {
NeverEntering {
forbidden: PathBuf::from(forbidden),
inner,
}
}
impl Runner for NeverEntering {
fn run(
&self,
program: &str,
args: &[&str],
cwd: Option<&Path>,
env: &Env,
) -> Result<String, RunFailure> {
if let Some(entered) = cwd.filter(|cwd| cwd.starts_with(&self.forbidden)) {
panic!(
"`{program} {}` was run in {}, which the scope left out",
args.join(" "),
entered.display()
);
}
self.inner.run(program, args, cwd, env)
}
}
fn two_repositories() -> FakeRunner {
FakeRunner::default().with("git worktree list --porcelain", A_WORKTREE_PER_SEAT)
}
#[test]
fn bdi_started_under_a_configured_project_reads_that_project_alone() {
let path = a_config_file_holding("started-in-orbital", TWO_PROJECTS);
let runner = never_entering("/srv/work/ferry", two_repositories());
let cfg = read_config(&runner, &path, &started_in("/srv/work/orbital/src"))
.expect("the config is ours to read")
.config;
assert_eq!(read_by(&cfg), ["orbital"]);
assert_eq!(
cfg.scope,
Scope::Directory {
project: "orbital".to_string(),
widened: Vec::new(),
}
);
assert!(
cfg.projects[0]
.holds(Path::new("/tmp/seat-a/wt/src"))
.is_some(),
"the project read still learns its working trees"
);
std::fs::remove_file(&path).expect("the file is ours to remove");
}
#[test]
fn bdi_started_in_a_repository_inside_a_project_reads_that_project() {
let path = a_config_file_holding("started-inside", TWO_PROJECTS);
let runner = never_entering("/srv/work/ferry", two_repositories());
let cfg = read_config(
&runner,
&path,
&started_in("/srv/work/orbital/ground-station/src"),
)
.expect("the config is ours to read")
.config;
assert_eq!(read_by(&cfg), ["orbital"]);
std::fs::remove_file(&path).expect("the file is ours to remove");
}
#[test]
fn bdi_started_in_a_linked_worktree_of_a_project_reads_that_project() {
let path = a_config_file_holding("started-in-a-worktree", TWO_PROJECTS);
let runner = never_entering("/srv/work/ferry", two_repositories());
let cfg = read_config(&runner, &path, &started_in("/tmp/seat-a/wt/src"))
.expect("the config is ours to read")
.config;
assert_eq!(read_by(&cfg), ["orbital"]);
let from_the_directory: Vec<String> = runner
.inner
.calls()
.into_iter()
.filter(|c| c.cwd.as_deref() == Some(Path::new("/tmp/seat-a/wt/src")))
.map(|c| c.argv)
.collect();
assert_eq!(
from_the_directory,
["git worktree list --porcelain"],
"one git call from the directory, and nothing else runs there"
);
std::fs::remove_file(&path).expect("the file is ours to remove");
}
#[test]
fn bdi_started_outside_every_configured_project_reads_all_of_them() {
let path = a_config_file_holding("started-elsewhere", TWO_PROJECTS);
let runner = FakeRunner::default().with(
"git worktree list --porcelain",
"worktree /home/elsewhere\nHEAD 4d3c1f0e9b8a7c6d5e4f3a2b1c0d9e8f7a6b5c4d\nbranch refs/heads/main\n",
);
let cfg = read_config(&runner, &path, &started_in("/home/elsewhere/notes"))
.expect("the config is ours to read")
.config;
assert_eq!(read_by(&cfg), ["orbital", "ferry"]);
assert_eq!(cfg.scope, Scope::Everything);
std::fs::remove_file(&path).expect("the file is ours to remove");
}
#[test]
fn all_projects_reads_every_configured_project_wherever_bdi_was_started() {
let path = a_config_file_holding("all-projects", TWO_PROJECTS);
let runner = two_repositories();
let cfg = read_config(
&runner,
&path,
&Launch {
reading: Reading::EveryProject,
..started_in("/srv/work/orbital/src")
},
)
.expect("the config is ours to read")
.config;
assert_eq!(read_by(&cfg), ["orbital", "ferry"]);
assert_eq!(cfg.scope, Scope::Everything);
assert!(
!directories_entered(&runner).contains(&Some(PathBuf::from("/srv/work/orbital/src"))),
"the directory was consulted when the command line had already decided"
);
std::fs::remove_file(&path).expect("the file is ours to remove");
}
#[test]
fn an_explicit_project_outranks_the_directory() {
let path = a_config_file_holding("project-outranks", TWO_PROJECTS);
let runner = never_entering("/srv/work/orbital", two_repositories());
let cfg = read_config(
&runner,
&path,
&Launch {
reading: Reading::Named(vec!["ferry".to_string()]),
..started_in("/srv/work/orbital/src")
},
)
.expect("the config is ours to read")
.config;
assert_eq!(read_by(&cfg), ["ferry"]);
assert_eq!(cfg.scope, Scope::Asked(vec!["ferry".to_string()]));
std::fs::remove_file(&path).expect("the file is ours to remove");
}
#[test]
fn a_root_under_a_project_the_directory_left_out_widens_the_read_set() {
let path = a_config_file_holding("root-widens", TWO_PROJECTS);
let runner = two_repositories();
let cfg = read_config(
&runner,
&path,
&Launch {
roots: &["ferry:fer-1".to_string()],
..started_in("/srv/work/orbital/src")
},
)
.expect("a root elsewhere widens a scope the directory chose")
.config;
assert_eq!(read_by(&cfg), ["orbital", "ferry"]);
assert_eq!(
cfg.roots.explicit,
BTreeMap::from([("ferry".to_string(), vec!["fer-1".to_string()])])
);
assert!(
directories_entered(&runner).contains(&Some(PathBuf::from("/srv/work/ferry"))),
"ferry is read now, so git was asked where it is worked"
);
std::fs::remove_file(&path).expect("the file is ours to remove");
}
#[test]
fn a_root_under_a_project_an_explicit_scope_left_out_is_still_refused() {
let path = a_config_file_holding("root-refused", TWO_PROJECTS);
let runner = never_entering("/srv/work/ferry", two_repositories());
let refused = read_config(
&runner,
&path,
&Launch {
reading: Reading::Named(vec!["orbital".to_string()]),
roots: &["ferry:fer-1".to_string()],
..started_in("/srv/work/orbital/src")
},
)
.expect_err("asking for ferry's root and asking not to read ferry");
assert!(format!("{refused:#}").contains("ferry"), "got: {refused:#}");
std::fs::remove_file(&path).expect("the file is ours to remove");
}
#[test]
fn a_bare_root_belongs_to_the_project_the_directory_chose() {
let path = a_config_file_holding("bare-root", TWO_PROJECTS);
let runner = never_entering("/srv/work/ferry", two_repositories());
let cfg = read_config(
&runner,
&path,
&Launch {
roots: &["orb-7".to_string()],
..started_in("/srv/work/orbital/src")
},
)
.expect("the directory leaves only orbital")
.config;
assert_eq!(
cfg.roots.explicit,
BTreeMap::from([("orbital".to_string(), vec!["orb-7".to_string()])])
);
std::fs::remove_file(&path).expect("the file is ours to remove");
}
#[test]
fn a_run_with_no_config_file_reads_the_discovered_project_as_everything() {
let absent =
std::env::temp_dir().join(format!("bdi-absent-everything-{}.toml", std::process::id()));
let runner = FakeRunner::default()
.with("bd where --json", "{}")
.with("git rev-parse --show-toplevel", "/srv/work/orbital")
.with("git worktree list --porcelain", A_WORKTREE_PER_SEAT)
.with("git remote get-url origin", "git@host:owner/orbital.git");
let cfg =
config_for_wherever_bdi_was_run(&runner, &absent, &started_in("/srv/work/orbital/src"))
.expect("the directory is a project")
.config;
assert_eq!(read_by(&cfg), ["orbital"]);
assert_eq!(cfg.scope, Scope::Everything);
}
struct ARepositoryWorkedInTwoPlaces;
impl Runner for ARepositoryWorkedInTwoPlaces {
fn run(
&self,
_program: &str,
_args: &[&str],
_cwd: Option<&Path>,
_env: &Env,
) -> Result<String, RunFailure> {
Ok(A_WORKTREE_PER_SEAT.to_string())
}
}
#[test]
fn a_config_the_command_line_names_learns_its_working_trees() {
let path = a_config_file("named-config");
let cfg = read_config(&ARepositoryWorkedInTwoPlaces, &path, &every_project())
.expect("the config is ours to read")
.config;
assert_eq!(
cfg.projects[0].worktrees,
vec![
PathBuf::from("/srv/work/orbital"),
PathBuf::from("/tmp/seat-a/wt"),
]
);
std::fs::remove_file(&path).expect("the file is ours to remove");
}
#[test]
fn the_config_found_where_bdi_looks_learns_its_working_trees() {
let path = a_config_file("default-config");
let cfg =
config_for_wherever_bdi_was_run(&ARepositoryWorkedInTwoPlaces, &path, &every_project())
.expect("the config is ours to read")
.config;
assert!(
cfg.projects[0]
.holds(Path::new("/tmp/seat-a/wt/src"))
.is_some(),
"a seat in a linked worktree is working in the project"
);
std::fs::remove_file(&path).expect("the file is ours to remove");
}
#[test]
fn a_config_that_will_not_open_is_an_error_rather_than_a_fallback() {
let a_directory = std::env::temp_dir();
let refused = config_for_wherever_bdi_was_run(
&ARepositoryWorkedInTwoPlaces,
&a_directory,
&every_project(),
)
.expect_err("a directory is not a config file");
assert!(
format!("{refused:#}")
.contains(&format!("reading the config at {}", a_directory.display())),
"a config that will not open should be reported as itself, not as \
a missing one; said: {refused:#}"
);
}
#[test]
fn a_project_the_scope_left_out_is_not_even_asked_where_it_is_worked() {
let path = a_config_file_holding("scoped-worktrees", TWO_PROJECTS);
let runner =
FakeRunner::default().with("git worktree list --porcelain", A_WORKTREE_PER_SEAT);
read_config(
&runner,
&path,
&Launch {
reading: Reading::Named(vec!["orbital".to_string()]),
..every_project()
},
)
.expect("the config is ours to read");
let asked = directories_entered(&runner);
assert!(
!asked.contains(&Some(PathBuf::from("/srv/work/ferry"))),
"ferry was scoped out, so nothing should have gone to its directory; asked {asked:?}"
);
assert!(
asked.contains(&Some(PathBuf::from("/srv/work/orbital"))),
"orbital was scoped in, so git was asked where it is worked; asked {asked:?}"
);
std::fs::remove_file(&path).expect("the file is ours to remove");
}
#[test]
fn a_scope_naming_no_discovered_project_is_refused_with_no_config_file() {
let absent = std::env::temp_dir().join(format!("bdi-absent-{}.toml", std::process::id()));
let runner = FakeRunner::default()
.with("bd where --json", "{}")
.with("git rev-parse --show-toplevel", "/srv/work/orbital")
.with("git worktree list --porcelain", A_WORKTREE_PER_SEAT)
.with("git remote get-url origin", "git@host:owner/orbital.git");
let refused = config_for_wherever_bdi_was_run(
&runner,
&absent,
&Launch {
reading: Reading::Named(vec!["nothing-of-the-sort".to_string()]),
..started_in("/srv/work/orbital")
},
)
.expect_err("the scope names no project the current directory is in");
assert!(
format!("{refused:#}").contains("nothing-of-the-sort"),
"got: {refused:#}"
);
}
#[test]
fn the_command_line_is_well_formed() {
Cli::command().debug_assert();
}
#[test]
fn no_config_is_named_unless_one_is_asked_for() {
assert_eq!(Cli::parse_from(["bdi"]).config, None);
}
#[test]
fn a_config_named_on_the_command_line_is_taken_as_written() {
let cli = Cli::parse_from(["bdi", "--config", "/etc/beady-eye.toml"]);
assert_eq!(cli.config.as_deref(), Some("/etc/beady-eye.toml"));
}
#[test]
fn bead_ids_are_taken_as_arguments() {
let cli = Cli::parse_from(["bdi", "orb-7", "ferry:fer-9", "--json"]);
assert_eq!(cli.beads, ["orb-7", "ferry:fer-9"]);
assert!(cli.json);
}
#[test]
fn the_projects_to_draw_are_taken_as_a_repeatable_option() {
let cli = Cli::parse_from(["bdi", "--project", "orbital", "--project", "ferry"]);
assert_eq!(cli.projects, ["orbital", "ferry"]);
}
#[test]
fn no_project_is_named_unless_one_is_asked_for() {
assert!(Cli::parse_from(["bdi"]).projects.is_empty());
}
#[test]
fn a_run_that_names_no_project_leaves_it_to_the_directory() {
assert_eq!(
Reading::asked_for(&Cli::parse_from(["bdi"])),
Reading::WhereBdiWasStarted
);
}
#[test]
fn a_run_can_ask_for_every_project_or_name_the_ones_it_wants() {
assert_eq!(
Reading::asked_for(&Cli::parse_from(["bdi", "--all-projects"])),
Reading::EveryProject
);
assert_eq!(
Reading::asked_for(&Cli::parse_from(["bdi", "--project", "ferry"])),
Reading::Named(vec!["ferry".to_string()])
);
}
#[test]
fn a_run_cannot_ask_for_every_project_and_name_only_some() {
assert!(Cli::try_parse_from(["bdi", "--all-projects", "--project", "ferry"]).is_err());
}
#[test]
fn all_is_the_filter_and_not_the_read_set() {
let cli = Cli::parse_from(["bdi", "--all"]);
assert!(cli.all);
assert_eq!(Reading::asked_for(&cli), Reading::WhereBdiWasStarted);
}
#[test]
fn a_run_that_says_nothing_about_polling_leaves_it_to_the_config() {
assert_eq!(
Polling::asked_for(&Cli::parse_from(["bdi"])),
Polling::AsConfigured
);
}
#[test]
fn a_run_can_turn_the_poll_on_or_off_for_every_project() {
assert_eq!(
Polling::asked_for(&Cli::parse_from(["bdi", "--poll"])),
Polling::Everything
);
assert_eq!(
Polling::asked_for(&Cli::parse_from(["bdi", "--no-poll"])),
Polling::Nothing
);
}
#[test]
fn a_run_cannot_ask_for_the_poll_and_against_it_at_once() {
assert!(Cli::try_parse_from(["bdi", "--poll", "--no-poll"]).is_err());
}
#[test]
fn what_the_run_said_outranks_what_each_project_says() {
let every = Duration::from_secs(30);
let polled = a_project(true);
let pushed = a_project(false);
assert_eq!(
Polling::AsConfigured.after_a_read(&polled, every),
Some(every)
);
assert_eq!(Polling::AsConfigured.after_a_read(&pushed, every), None);
assert_eq!(
Polling::Everything.after_a_read(&pushed, every),
Some(every)
);
assert_eq!(Polling::Nothing.after_a_read(&polled, every), None);
}
#[test]
fn the_socket_this_run_was_told_outranks_the_one_its_config_names() {
let configured = a_config_listening_on(Some("/run/user/1000/first.sock"));
assert_eq!(
told_to_listen_on(
&Cli::parse_from(["bdi", "--socket", "/run/user/1000/second.sock"]),
&configured
),
Some(PathBuf::from("/run/user/1000/second.sock"))
);
}
#[test]
fn a_run_that_says_nothing_listens_where_its_config_says() {
let configured = a_config_listening_on(Some("/var/folders/T/bdi.sock"));
assert_eq!(
told_to_listen_on(&Cli::parse_from(["bdi"]), &configured),
Some(PathBuf::from("/var/folders/T/bdi.sock"))
);
}
#[test]
fn a_run_neither_told_nor_configured_is_told_nothing() {
assert_eq!(
told_to_listen_on(&Cli::parse_from(["bdi"]), &a_config_listening_on(None)),
None
);
}
fn a_config_listening_on(socket: Option<&str>) -> Config {
let mut cfg = Config::naming(Vec::new());
cfg.changes.socket = socket.map(PathBuf::from);
cfg
}
fn a_project(poll: bool) -> crate::config::Project {
crate::config::Project {
name: "orbital".to_string(),
path: PathBuf::from("/srv/work/orbital"),
environment_command: None,
credential_command: None,
poll,
badges: Vec::new(),
worktrees: Vec::new(),
}
}
#[test]
fn a_leading_tilde_becomes_the_home_directory() {
let path = expand_tilde(DEFAULT_CONFIG, Some(PathBuf::from("/home/pilot")));
assert_eq!(
path,
PathBuf::from("/home/pilot/.config/beady-eye/config.toml")
);
}
#[test]
fn a_path_that_names_no_home_is_left_as_written() {
for path in ["/etc/beady-eye.toml", "~elsewhere/config.toml"] {
assert_eq!(
expand_tilde(path, Some(PathBuf::from("/home/pilot"))),
PathBuf::from(path)
);
}
}
#[test]
fn without_a_home_the_path_is_left_as_written() {
assert_eq!(
expand_tilde(DEFAULT_CONFIG, None),
PathBuf::from(DEFAULT_CONFIG)
);
}
}