use std::fmt;
use std::path::{Path, PathBuf};
use crate::output::{Exit, Format, Report, fail};
pub(crate) const HELP: &str = "\
sipx peers — list what can be called
USAGE:
sipx peers [OPTIONS]
OPTIONS:
--book <FILE> Read this peer book rather than the default one
--json Report as JSON, one object per peer
--help Show this message
THE PEER BOOK:
One peer per line: a name, whitespace, and the URI to dial. Blank lines and lines
starting with `#` are ignored.
# who this phone knows about
alice sip:alice@192.0.2.17:5060
Looked for in --book, then $SIPX_PEERS, then $XDG_CONFIG_HOME/sipx/peers, then
$HOME/.config/sipx/peers. A book that cannot be read is an error and not an empty
list — a fresh machine with no book has not told you there is nobody to call.
";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Source {
Book,
}
impl Source {
fn as_str(self) -> &'static str {
match self {
Self::Book => "book",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Peer {
pub(crate) name: String,
pub(crate) uri: String,
pub(crate) source: Source,
}
impl Peer {
fn report(&self) -> Report {
Report::new()
.text("status", "peer")
.text("name", self.name.as_str())
.text("uri", self.uri.as_str())
.text("source", self.source.as_str())
}
}
#[derive(Debug)]
pub(crate) enum Error {
NoLocation,
Unreadable {
path: PathBuf,
cause: std::io::Error,
},
Malformed {
path: PathBuf,
line: usize,
reason: &'static str,
},
}
impl Error {
fn exit(&self) -> Exit {
match self {
Self::NoLocation => Exit::Usage,
Self::Unreadable { .. } | Self::Malformed { .. } => Exit::Failed,
}
}
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::NoLocation => write!(
f,
"no peer book: pass --book <FILE> or set SIPX_PEERS, since neither \
XDG_CONFIG_HOME nor HOME is set"
),
Self::Unreadable { path, cause } => {
write!(f, "cannot read the peer book {}: {cause}", path.display())
}
Self::Malformed { path, line, reason } => {
write!(f, "{}:{line}: {reason}", path.display())
}
}
}
}
impl std::error::Error for Error {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Unreadable { cause, .. } => Some(cause),
Self::NoLocation | Self::Malformed { .. } => None,
}
}
}
pub(crate) fn run(raw: &[String], format: Format) -> Exit {
let args = match crate::arguments(raw, HELP, format) {
Ok(args) => args,
Err(exit) => return exit,
};
let peers = match load(args.value("book")) {
Ok(peers) => peers,
Err(error) => return fail(format, error.exit(), &error.to_string()),
};
for (index, peer) in peers.iter().enumerate() {
if format == Format::Text && index > 0 {
println!();
}
peer.report().emit(format);
}
Exit::Success
}
fn load(explicit: Option<&str>) -> Result<Vec<Peer>, Error> {
let path = locate(
explicit,
std::env::var("SIPX_PEERS").ok(),
std::env::var("XDG_CONFIG_HOME").ok(),
std::env::var("HOME").ok(),
)?;
let contents = std::fs::read_to_string(&path).map_err(|cause| Error::Unreadable {
path: path.clone(),
cause,
})?;
parse(&path, &contents)
}
fn locate(
explicit: Option<&str>,
from_env: Option<String>,
xdg_config_home: Option<String>,
home: Option<String>,
) -> Result<PathBuf, Error> {
if let Some(path) = explicit {
return Ok(PathBuf::from(path));
}
if let Some(path) = from_env.filter(|path| !path.is_empty()) {
return Ok(PathBuf::from(path));
}
if let Some(config) = xdg_config_home.filter(|path| !path.is_empty()) {
return Ok(PathBuf::from(config).join("sipx").join("peers"));
}
if let Some(home) = home.filter(|path| !path.is_empty()) {
return Ok(PathBuf::from(home)
.join(".config")
.join("sipx")
.join("peers"));
}
Err(Error::NoLocation)
}
fn parse(path: &Path, contents: &str) -> Result<Vec<Peer>, Error> {
let mut peers = Vec::new();
for (index, raw) in contents.lines().enumerate() {
let line = raw.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let malformed = |reason| Error::Malformed {
path: path.to_path_buf(),
line: index + 1,
reason,
};
let mut fields = line.split_whitespace();
let (Some(name), Some(uri)) = (fields.next(), fields.next()) else {
return Err(malformed(
"a peer is a name and a URI, e.g. `alice sip:alice@192.0.2.17:5060`",
));
};
if fields.next().is_some() {
return Err(malformed(
"a peer is two fields; comments go on their own line, starting with `#`",
));
}
if !(uri.starts_with("sip:") || uri.starts_with("sips:")) {
return Err(malformed("a peer's URI must start with `sip:` or `sips:`"));
}
peers.push(Peer {
name: name.to_owned(),
uri: uri.to_owned(),
source: Source::Book,
});
}
Ok(peers)
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::indexing_slicing
)]
mod tests {
use super::*;
fn book(contents: &str) -> Result<Vec<Peer>, Error> {
parse(Path::new("/tmp/peers"), contents)
}
#[test]
fn a_line_becomes_a_peer_carrying_the_source_it_came_from() {
let peers = book("alice sip:alice@192.0.2.17:5060\n").expect("a peer");
assert_eq!(
peers,
vec![Peer {
name: "alice".to_owned(),
uri: "sip:alice@192.0.2.17:5060".to_owned(),
source: Source::Book,
}]
);
assert_eq!(peers[0].source.as_str(), "book");
}
#[test]
fn peers_are_listed_in_the_order_the_book_gives_them() {
let peers = book("bob sip:bob@example.com\nalice sips:alice@example.com\n").expect("peers");
assert_eq!(
peers.iter().map(|p| p.name.as_str()).collect::<Vec<_>>(),
vec!["bob", "alice"]
);
}
#[test]
fn comments_and_blank_lines_are_ignored() {
let peers = book(
"# who this phone knows about\n\
\n\
alice sip:alice@example.com\n\
\t \n\
# indented comments too\n",
)
.expect("peers");
assert_eq!(peers.len(), 1);
}
#[test]
fn an_empty_book_is_an_empty_list_and_not_an_error() {
assert_eq!(book("").expect("no peers"), vec![]);
assert_eq!(book("# nobody yet\n").expect("no peers"), vec![]);
}
#[test]
fn a_line_that_is_not_a_peer_names_the_line_it_failed_on() {
let error = book("alice sip:alice@example.com\nbob\n").expect_err("not a peer");
let Error::Malformed { line, .. } = error else {
panic!("expected a malformed line, got {error:?}");
};
assert_eq!(line, 2, "counted the way an editor counts");
}
#[test]
fn a_trailing_comment_is_refused_rather_than_glued_onto_the_uri() {
let error = book("alice sip:alice@example.com # home\n").expect_err("two fields only");
assert!(
error.to_string().contains("own line"),
"the error must say where a comment goes: {error}"
);
}
#[test]
fn something_that_is_not_a_sip_uri_is_refused() {
for bad in ["alice example.com", "alice tel:+15551234", "alice alice"] {
let error = book(bad).expect_err("not a URI");
assert!(error.to_string().contains("sip:"), "{bad}: {error}");
}
}
#[test]
fn an_explicit_path_wins_over_everything_else() {
let path = locate(
Some("/books/mine"),
Some("/books/env".to_owned()),
Some("/config".to_owned()),
Some("/home/someone".to_owned()),
)
.expect("a path");
assert_eq!(path, PathBuf::from("/books/mine"));
}
#[test]
fn the_environment_wins_over_the_config_directory() {
let path = locate(
None,
Some("/books/env".to_owned()),
Some("/config".to_owned()),
Some("/home/someone".to_owned()),
)
.expect("a path");
assert_eq!(path, PathBuf::from("/books/env"));
}
#[test]
fn the_default_is_the_xdg_config_path() {
let path = locate(None, None, Some("/config".to_owned()), None).expect("a path");
assert_eq!(path, PathBuf::from("/config/sipx/peers"));
let path = locate(None, None, None, Some("/home/someone".to_owned())).expect("a path");
assert_eq!(path, PathBuf::from("/home/someone/.config/sipx/peers"));
}
#[test]
fn an_empty_variable_is_the_same_as_an_unset_one() {
let path = locate(
None,
Some(String::new()),
Some(String::new()),
Some("/home/someone".to_owned()),
)
.expect("a path");
assert_eq!(path, PathBuf::from("/home/someone/.config/sipx/peers"));
assert!(matches!(
locate(None, None, None, None),
Err(Error::NoLocation)
));
}
#[test]
fn a_book_that_cannot_be_read_never_exits_zero() {
for error in [
Error::NoLocation,
Error::Unreadable {
path: PathBuf::from("/tmp/peers"),
cause: std::io::Error::from(std::io::ErrorKind::NotFound),
},
Error::Malformed {
path: PathBuf::from("/tmp/peers"),
line: 1,
reason: "nope",
},
] {
assert_ne!(error.exit(), Exit::Success, "{error}");
}
assert_eq!(Error::NoLocation.exit(), Exit::Usage);
}
#[test]
fn both_forms_carry_the_name_the_uri_and_the_source() {
let peer = Peer {
name: "alice".to_owned(),
uri: "sip:alice@192.0.2.17:5060".to_owned(),
source: Source::Book,
};
let json = peer.report().render(Format::Json);
let text = peer.report().render(Format::Text);
for fact in ["alice", "sip:alice@192.0.2.17:5060", "book"] {
assert!(json.contains(fact), "{fact} missing from {json}");
assert!(text.contains(fact), "{fact} missing from {text}");
}
assert!(!json.contains('\n'), "one line per peer: {json}");
}
}