use crate::daemon::Client;
use crate::output::record::Record;
use crate::output::emit;
pub const USAGE: &str = "\
Usage: wsb peer <command> [options]
(no command) every peer, and what it is doing
list the same, with fingerprints and addresses
pair open a pairing window and print the code
pair <code> --at <host:port>
take a code to the machine that issued it
forget <peer> end a peer relationship and its baselines
sync <peer> carry this worktree's working tree with that peer
pause <peer> stop converging, keep the relationship
resume <peer> converge again
services <peer> which services run with that machine
service <peer> <service> <on|off>
switch one service on or off with that machine:
files, or a plugin's <plugin>.<service>
mode <peer> [manual|auto]
whether changes ARRIVING from that machine are applied
here by themselves. One direction: what reaches the
peer is settled by the peer's own mode
deltas <peer> every difference, and what has been said about each
take <peer> <path> carry that machine's version of one path
keep <peer> <path> carry this machine's version of one path
leave <peer> <path> leave one difference alone until either side changes
reconsider <peer> <path>
take back a take, a keep or a leave
diff <peer> what differs between this machine and that peer
diff <peer> --path <path> [--repository <repo>]
both versions of one path, as a diff
status <peer> every repository of this worktree, in detail
resolve <peer> <path> --keep here|there
settle one conflict
Continuity carries the WORKING TREE — the unfinished edit, the file not worth a
commit. Git carries history, and `wsb sync` is that: these never overlap, and
continuity never pulls, pushes, merges, rebases, commits or changes a branch.
When git state differs between the two machines it BLOCKS and says which, and
resumes by itself once both sides agree again.
Nothing crosses unasked until you ask for it. A workspace starts in
MANUAL mode — differences are found and listed, and each one waits for `take`,
`keep` or `leave`. `mode <peer> auto` is the other way — everything that is not a
conflict crosses by itself — and conflicts always wait for a person whichever
mode it is in.
Inside a workspace, these act on that workspace. From outside one, name it with
--workspace <id>.";
pub fn dispatch(args: &[String]) -> u8 {
let verb = args.get(2).map(String::as_str);
let rest = if args.len() > 3 { &args[3..] } else { &[] };
let result = match verb {
None => overview(),
Some("list") => list(),
Some("pair") => pair(rest),
Some("forget") => forget(rest),
Some("sync") => set_desired(rest, "sync"),
Some("pause") => set_desired(rest, "pause"),
Some("resume") => set_desired(rest, "resume"),
Some("services") => services(rest),
Some("service") => set_service(rest),
Some("mode") => mode(rest),
Some("deltas") => deltas(rest),
Some("take") => decide(rest, "there"),
Some("keep") => decide(rest, "here"),
Some("leave") => delta_action(rest, "dismiss"),
Some("reconsider") => delta_action(rest, "reconsider"),
Some("status") => status(rest),
Some("diff") => diff(rest),
Some("resolve") => resolve(rest),
Some("help") | Some("--help") | Some("-h") => {
crate::outln!("{USAGE}");
return 0;
}
Some(other) => {
crate::errln!("wsb peer: unknown command '{other}'\n\n{USAGE}");
return 2;
}
};
match result {
Ok(status) => status,
Err(message) => {
crate::errln!("wsb peer: {message}");
1
}
}
}
fn daemon() -> Result<Client, String> {
let machine_dir = crate::daemon::machine_dir()?;
let cwd = std::env::current_dir()
.map_err(|e| format!("cannot determine the current directory: {e}"))?;
let (client, _info, _started) = crate::daemon::ensure_running(
&machine_dir,
&cwd,
&crate::daemon::Spawn::default(),
)?;
Ok(client)
}
fn workspace(client: &Client, args: &[String]) -> Result<String, String> {
if let Some(named) = flag_value(args, "--workspace") {
return Ok(named.to_string());
}
let cwd = std::env::current_dir()
.map_err(|e| format!("cannot determine the current directory: {e}"))?;
let worktree = client.resolve_worktree(&cwd).map_err(|error| {
format!(
"{error}\n Run this inside a worktree, or name one with \
--workspace <family>@<worktree> (`wsb peer list` shows what this machine has)."
)
})?;
match (worktree.workspace, worktree.no_continuity) {
(Some(workspace), _) => Ok(workspace),
(None, why) => Err(format!(
"worktree {} cannot be carried to another machine: {}",
worktree.id,
why.unwrap_or_else(|| "it has no continuity id".to_string())
)),
}
}
fn emit_peers(peers: &[PeerView]) {
for peer in peers {
emit(Record::PeerState {
peer: peer.id.clone(),
name: peer.name.clone(),
fingerprint: peer.fingerprint.clone(),
address: peer.address.clone(),
state: peer.state.clone(),
detail: peer.detail.clone(),
});
}
}
fn overview() -> Result<u8, String> {
let client = daemon()?;
let peers: Vec<PeerView> = client.get_json(crate::peer::route::PEERS)?;
emit_peers(&peers);
if peers.is_empty() {
crate::outln!("no peers yet — run `wsb peer pair` here, and on the other machine");
return Ok(0);
}
let rows: Vec<crate::report::Row> = peers
.iter()
.map(|peer| {
crate::report::Row::new(
&peer.name,
peer.state.to_uppercase(),
format!(
"{} workspace(s) enabled{}",
peer.shared_workspaces,
peer.detail
.as_ref()
.map(|detail| format!(" — {detail}"))
.unwrap_or_default()
),
)
})
.collect();
crate::report::show("Peers", &rows, None);
Ok(0)
}
fn list() -> Result<u8, String> {
let client = daemon()?;
let peers: Vec<PeerView> = client.get_json(crate::peer::route::PEERS)?;
emit_peers(&peers);
if peers.is_empty() {
crate::outln!("no peers yet — run `wsb peer pair` here, and on the other machine");
return Ok(0);
}
for peer in &peers {
crate::outln!("{} ({})", peer.name, peer.id);
crate::outln!(" fingerprint {}", peer.fingerprint);
crate::outln!(" address {}", peer.address);
crate::outln!(" state {}", peer.state);
if let Some(detail) = &peer.detail {
crate::outln!(" detail {detail}");
}
}
Ok(0)
}
fn pair(args: &[String]) -> Result<u8, String> {
let client = daemon()?;
let code = args.iter().find(|arg| !arg.starts_with("--"));
match code {
None => {
let offer: PairingView = client.post_json("/api/v1/peers/pair", &serde_json::json!({}))?;
crate::outln!("Pairing code: {}", offer.code);
crate::outln!("");
crate::outln!(" this machine {}", offer.machine_name);
crate::outln!(" fingerprint {}", offer.fingerprint);
crate::outln!(" peers reach it on port {}", offer.peer_port);
crate::outln!("");
crate::outln!(
"On the other machine, within {} minutes:",
offer.expires_in_seconds / 60
);
crate::outln!(" wsb peer pair {} --at <this-host>:{}", offer.code, offer.peer_port);
Ok(0)
}
Some(code) => {
let address = flag_value(args, "--at").ok_or_else(|| {
"--at <host:port> is required: a code says WHO may pair, not where they are"
.to_string()
})?;
let peer: PeerView = client.post_json(
"/api/v1/peers/pair/join",
&serde_json::json!({ "code": code, "address": address }),
)?;
crate::outln!("paired with {} ({})", peer.name, peer.id);
crate::outln!(" fingerprint {}", peer.fingerprint);
crate::outln!("");
crate::outln!("Check that fingerprint on the other machine before you trust it.");
crate::outln!("Then, in a workspace both machines have:");
crate::outln!(" wsb peer sync {}", peer.name);
Ok(0)
}
}
}
fn forget(args: &[String]) -> Result<u8, String> {
let client = daemon()?;
let name = positional(args, "peer")?;
let peer = find_peer(&client, name)?;
let _: serde_json::Value = client.delete_json(&format!("/api/v1/peers/{}", peer.id))?;
crate::outln!("{} is no longer a peer; its baselines went with it", peer.name);
Ok(0)
}
fn set_desired(args: &[String], verb: &str) -> Result<u8, String> {
let client = daemon()?;
let name = positional(args, "peer")?;
let peer = find_peer(&client, name)?;
let workspace = workspace(&client, args)?;
let _: serde_json::Value = client.post_json(
&format!("/api/v1/peers/{}/{}", peer.id, if verb == "sync" { "sync" } else { verb }),
&serde_json::json!({ "workspace": workspace }),
)?;
match verb {
"sync" => {
crate::outln!("continuity is on between this workspace and {}", peer.name);
report_status(&client, &peer, &workspace)
}
"pause" => {
crate::outln!("paused: nothing converges with {} until you resume", peer.name);
Ok(0)
}
_ => {
crate::outln!("resumed with {}", peer.name);
report_status(&client, &peer, &workspace)
}
}
}
fn services(args: &[String]) -> Result<u8, String> {
let client = daemon()?;
let name = positional(args, "peer")?;
let peer = find_peer(&client, name)?;
let view: crate::peer::model::PeerServices = client.get_json(&crate::peer::route::services(&peer.id))?;
for service in &view.services {
emit(Record::PeerService {
peer: peer.id.clone(),
service: service.service.clone(),
switch: service.switch.as_str().to_string(),
});
crate::outln!(
"{:<16} {:<3} {}",
service.service,
service.switch.as_str(),
service.describes
);
}
Ok(0)
}
fn set_service(args: &[String]) -> Result<u8, String> {
let client = daemon()?;
let name = positional(args, "peer")?;
let bare: Vec<&String> = args.iter().filter(|arg| !arg.starts_with("--")).collect();
let service = bare
.get(1)
.ok_or_else(|| "which service: files, or a plugin's <plugin>.<service>".to_string())?;
let switch = bare
.get(2)
.ok_or_else(|| format!("wsb peer service <peer> {service} on|off"))?;
let service = crate::peer::model::Service::parse(service)?;
let switch = crate::peer::model::Switch::parse(switch)?;
let peer = find_peer(&client, name)?;
let _: serde_json::Value = client.post_json(
&format!("/api/v1/peers/{}/services/{}", peer.id, service.as_str()),
&serde_json::json!({ "switch": switch.as_str() }),
)?;
crate::outln!(
"{}: {} is {} — {}",
peer.name,
service.as_str(),
switch.as_str(),
service.describe()
);
let view: crate::peer::model::PeerServices = client.get_json(&crate::peer::route::services(&peer.id))?;
let others: Vec<String> = view
.services
.iter()
.filter(|other| other.service != service.as_str())
.map(|other| format!("{} {}", other.service, other.switch.as_str()))
.collect();
if !others.is_empty() {
crate::outln!(" unchanged: {}", others.join(", "));
}
Ok(0)
}
fn mode(args: &[String]) -> Result<u8, String> {
let client = daemon()?;
let name = positional(args, "peer")?;
let peer = find_peer(&client, name)?;
let workspace = workspace(&client, args)?;
let bare: Vec<&String> = args.iter().filter(|arg| !arg.starts_with("--")).collect();
let Some(wanted) = bare.get(1) else {
let status: StatusView =
client.get_json(&format!("/api/v1/peers/{}/status?workspace={workspace}", peer.id))?;
crate::outln!("{}: {}", peer.name, status.mode);
return Ok(0);
};
let wanted = crate::peer::model::SyncMode::parse(wanted)?;
let _: serde_json::Value = client.post_json(
&format!("/api/v1/peers/{}/mode", peer.id),
&serde_json::json!({ "workspace": workspace, "mode": wanted.as_str() }),
)?;
match wanted {
crate::peer::model::SyncMode::Auto => crate::outln!(
"{}: changes arriving FROM {} are now applied here by themselves \
(conflicts still wait).\n \
Nothing is pushed the other way unasked — whether {} takes this machine's \
changes is its own setting.",
peer.name,
peer.name,
peer.name
),
crate::peer::model::SyncMode::Manual => crate::outln!(
"{}: differences are listed and nothing crosses until you say so, either way",
peer.name
),
}
Ok(0)
}
fn deltas(args: &[String]) -> Result<u8, String> {
let client = daemon()?;
let name = positional(args, "peer")?;
let peer = find_peer(&client, name)?;
let workspace = workspace(&client, args)?;
let view: DeltasView =
client.get_json(&format!("/api/v1/peers/{}/deltas?workspace={workspace}", peer.id))?;
crate::outln!("{} · {} · files {}", peer.name, view.continuity, view.files);
let mut waiting = 0usize;
for repository in &view.repositories {
if !repository.compared {
crate::outln!("{}: {} — nothing compared", repository.label, repository.observed);
continue;
}
if repository.deltas.is_empty() {
continue;
}
crate::outln!("{}:", repository.label);
for delta in &repository.deltas {
let said = match (delta.decision.as_deref(), delta.dismissed) {
(Some("here"), _) => "keep mine",
(Some("there"), _) => "take theirs",
(Some(_), _) => "decided",
(None, true) => "left alone",
(None, false) => {
waiting += 1;
"waiting"
}
};
emit(Record::SyncDiff {
peer: peer.id.clone(),
workspace: workspace.to_string(),
repository: repository.repository.clone(),
path: delta.path.clone(),
direction: delta.direction.clone(),
size: delta.size,
});
crate::outln!(
" {:<10} {:<11} {}",
arrow(&delta.direction),
said,
delta.path
);
}
}
if waiting == 0 {
crate::outln!("nothing is waiting for a decision");
return Ok(0);
}
crate::outln!(
"{waiting} difference{} waiting — wsb peer take|keep|leave {} <path>",
if waiting == 1 { "" } else { "s" },
peer.name
);
Ok(3)
}
fn decide(args: &[String], keep: &str) -> Result<u8, String> {
let client = daemon()?;
let name = positional(args, "peer")?;
let path = delta_path(args)?;
let repository = flag_value(args, "--repository").unwrap_or("");
let peer = find_peer(&client, name)?;
let workspace = workspace(&client, args)?;
let _: serde_json::Value = client.post_json(
&format!("/api/v1/peers/{}/deltas/decide", peer.id),
&serde_json::json!({
"workspace": workspace,
"repository": repository,
"path": path,
"keep": keep,
}),
)?;
let whose = if keep == "here" {
"this machine's version".to_string()
} else {
format!("{}'s version", peer.name)
};
crate::outln!("{path}: carrying {whose} at the next pass");
Ok(0)
}
fn delta_action(args: &[String], action: &str) -> Result<u8, String> {
let client = daemon()?;
let name = positional(args, "peer")?;
let path = delta_path(args)?;
let repository = flag_value(args, "--repository").unwrap_or("");
let peer = find_peer(&client, name)?;
let workspace = workspace(&client, args)?;
let _: serde_json::Value = client.post_json(
&format!("/api/v1/peers/{}/deltas/{action}", peer.id),
&serde_json::json!({
"workspace": workspace,
"repository": repository,
"path": path,
}),
)?;
if action == "dismiss" {
crate::outln!("{path}: left alone — it comes back if either machine changes it");
} else {
crate::outln!("{path}: waiting for a decision again");
}
Ok(0)
}
fn delta_path(args: &[String]) -> Result<&str, String> {
args.iter()
.filter(|arg| !arg.starts_with("--"))
.nth(1)
.map(String::as_str)
.ok_or_else(|| "a path is required".to_string())
}
fn status(args: &[String]) -> Result<u8, String> {
let client = daemon()?;
let name = positional(args, "peer")?;
let peer = find_peer(&client, name)?;
let workspace = workspace(&client, args)?;
report_status(&client, &peer, &workspace)
}
fn report_status(client: &Client, peer: &PeerView, workspace: &str) -> Result<u8, String> {
let status: StatusView =
client.get_json(&format!("/api/v1/peers/{}/status?workspace={workspace}", peer.id))?;
emit(Record::WorkspacePeerState {
peer: peer.id.clone(),
workspace: workspace.to_string(),
presence: "shared".to_string(),
desired: status.desired.clone(),
repositories: status.repositories.len(),
blocked: status
.repositories
.iter()
.filter(|repository| repository.blocked.is_some())
.count(),
conflicts: status.repositories.iter().map(|r| r.conflicts).sum(),
});
for repository in &status.repositories {
emit(Record::SyncState {
peer: peer.id.clone(),
workspace: workspace.to_string(),
repository: repository.repository.as_str().to_string(),
desired: repository.desired.as_str().to_string(),
observed: repository.observed.as_str().to_string(),
blocked: repository.blocked.as_ref().map(|reason| reason.code().to_string()),
reason: repository.blocked.as_ref().map(|reason| reason.describe()),
pending: repository.pending,
conflicts: repository.conflicts,
branch: repository.branch.clone(),
head: repository.head.clone(),
});
}
let moving: Vec<&crate::peer::model::RepositoryStatus> = status
.repositories
.iter()
.filter(|repository| repository.observed == crate::peer::model::Observed::Syncing)
.collect();
if !moving.is_empty() {
let diff: DiffView =
client.get_json(&format!("/api/v1/peers/{}/diff?workspace={workspace}", peer.id))?;
for repository in moving {
let id = repository.repository.as_str();
let bytes = diff
.repositories
.iter()
.find(|entry| entry.repository == id)
.map(|entry| entry.moves.iter().filter_map(|change| change.size).sum())
.unwrap_or(0);
emit(Record::SyncProgress {
peer: peer.id.clone(),
workspace: workspace.to_string(),
repository: id.to_string(),
files: repository.pending,
bytes,
});
}
}
if status.repositories.is_empty() {
crate::outln!("");
crate::outln!(" peer {}", status.peer_state);
crate::outln!(" desired {}", status.desired);
crate::outln!("");
crate::outln!(
"Nothing observed yet. The daemon reconciles within a few seconds of \
being enabled."
);
return Ok(0);
}
let rows: Vec<crate::report::Row> = status
.repositories
.iter()
.map(|repository| {
crate::report::Row::new(
repository.repository.label(),
repository.observed.as_str().to_uppercase(),
match &repository.blocked {
Some(reason) => reason.describe(),
None => match (repository.pending, repository.conflicts) {
(0, 0) => repository
.branch
.clone()
.map(|branch| {
format!(
"{branch} · {}",
repository
.head
.as_deref()
.map(crate::peer::model::short_commit)
.unwrap_or_default()
)
})
.unwrap_or_default(),
(pending, 0) => format!("{pending} file(s) to carry"),
(0, conflicts) => format!("{conflicts} conflict(s)"),
(pending, conflicts) => {
format!("{pending} file(s) to carry, {conflicts} conflict(s)")
}
},
},
)
})
.collect();
let blocked = status
.repositories
.iter()
.filter(|repository| repository.blocked.is_some())
.count();
let conflicts: usize = status.repositories.iter().map(|r| r.conflicts).sum();
let verdict = if blocked > 0 {
(
format!("{blocked} repository/repositories blocked"),
crate::report::Verdict::Fail,
)
} else if conflicts > 0 {
(
format!("{conflicts} conflict(s) waiting for a decision"),
crate::report::Verdict::Warn,
)
} else {
("converged".to_string(), crate::report::Verdict::Ok)
};
crate::report::show(
&format!("{workspace} ↔ {}", peer.name),
&rows,
Some((verdict.0.as_str(), verdict.1)),
);
Ok(u8::from(blocked > 0 || conflicts > 0))
}
fn diff(args: &[String]) -> Result<u8, String> {
let client = daemon()?;
let name = positional(args, "peer")?;
let peer = find_peer(&client, name)?;
let workspace = workspace(&client, args)?;
if let Some(path) = flag_value(args, "--path") {
return one_path(&client, &peer, &workspace, flag_value(args, "--repository").unwrap_or(""), path);
}
let diff: DiffView =
client.get_json(&format!("/api/v1/peers/{}/diff?workspace={workspace}", peer.id))?;
let mut any = false;
for repository in &diff.repositories {
if !repository.planned {
crate::outln!(
"{}: {} — nothing compared",
repository.label,
repository.observed
);
any = true;
continue;
}
if repository.moves.is_empty() {
continue;
}
any = true;
crate::outln!("{}:", repository.label);
for change in &repository.moves {
emit(Record::SyncDiff {
peer: peer.id.clone(),
workspace: workspace.to_string(),
repository: repository.repository.clone(),
path: change.path.clone(),
direction: change.direction.clone(),
size: change.size,
});
crate::outln!(
" {:<10} {}{}",
arrow(&change.direction),
change.path,
change
.size
.map(|size| format!(" ({size} bytes)"))
.unwrap_or_default()
);
}
}
let conflicts: ConflictsView =
client.get_json(&format!("/api/v1/peers/{}/conflicts?workspace={workspace}", peer.id))?;
for conflict in &conflicts.conflicts {
any = true;
emit(Record::SyncConflict {
peer: peer.id.clone(),
workspace: workspace.to_string(),
repository: conflict.repository.clone(),
path: conflict.path.clone(),
here: conflict.here.clone(),
there: conflict.there.clone(),
});
crate::outln!(
" CONFLICT {}/{} (here: {}, there: {})",
if conflict.repository.is_empty() { "superproject" } else { &conflict.repository },
conflict.path,
conflict.here,
conflict.there
);
}
if !any {
crate::outln!("no differences between this machine and {}", peer.name);
}
Ok(0)
}
fn one_path(
client: &Client,
peer: &PeerView,
workspace: &str,
repository: &str,
path: &str,
) -> Result<u8, String> {
let query = format!(
"/api/v1/peers/{}/conflicts/versions?workspace={workspace}&repository={}&path={}",
peer.id,
urlencode(repository),
urlencode(path)
);
let versions: VersionsView = client.get_json(&query)?;
emit(Record::SyncDiff {
peer: peer.id.clone(),
workspace: workspace.to_string(),
repository: versions.repository.clone(),
path: versions.path.clone(),
direction: "conflict".to_string(),
size: Some(versions.here.size),
});
match (&versions.diff, &versions.reason) {
(Some(diff), _) => {
crate::outln!("- this machine + {}", peer.name);
crate::outln!("");
crate::outln!("{diff}");
Ok(0)
}
(None, Some(reason)) => {
crate::outln!("{}", describe_undiffable(reason, &peer.name, &versions));
Ok(0)
}
(None, None) => Err(format!("{path}: neither a diff nor a reason for there being none")),
}
}
fn describe_undiffable(reason: &str, peer: &str, versions: &VersionsView) -> String {
match reason {
"deleted_here" => format!(
"this machine deleted it; {peer} still has it ({} bytes)",
versions.there.size
),
"deleted_there" => format!(
"{peer} deleted it; this machine still has it ({} bytes)",
versions.here.size
),
"binary" => "not text on at least one machine, so there is nothing to show line by line"
.to_string(),
"too_large" => format!(
"too large to compare here ({} bytes here, {} there)",
versions.here.size, versions.there.size
),
"identical" => {
"both machines now hold the same bytes; this conflict has resolved itself".to_string()
}
other => format!("the two versions could not be compared ({other})"),
}
}
fn urlencode(value: &str) -> String {
let mut out = String::with_capacity(value.len());
for byte in value.as_bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' | b'/' => {
out.push(*byte as char)
}
other => out.push_str(&format!("%{other:02X}")),
}
}
out
}
fn arrow(direction: &str) -> &'static str {
match direction {
"send" => "→ peer",
"receive" => "← peer",
"send_deletion" => "→ delete",
"receive_deletion" => "← delete",
"conflict" => "CONFLICT",
_ => "?",
}
}
fn resolve(args: &[String]) -> Result<u8, String> {
let client = daemon()?;
let name = positional(args, "peer")?;
let path = args
.iter()
.filter(|arg| !arg.starts_with("--"))
.nth(1)
.ok_or_else(|| "a path to resolve is required".to_string())?;
let keep = flag_value(args, "--keep").ok_or_else(|| {
"--keep here|there is required: a conflict is settled by choosing, and both \
choices overwrite the other machine's version"
.to_string()
})?;
let repository = flag_value(args, "--repository").unwrap_or("");
let peer = find_peer(&client, name)?;
let workspace = workspace(&client, args)?;
let _: serde_json::Value = client.post_json(
&format!("/api/v1/peers/{}/conflicts/resolve", peer.id),
&serde_json::json!({
"workspace": workspace,
"repository": repository,
"path": path,
"keep": keep,
}),
)?;
let kept = if keep == "here" {
"this machine's version".to_string()
} else {
format!("{}'s version", peer.name)
};
crate::outln!("{path}: keeping {kept}");
Ok(0)
}
fn find_peer(client: &Client, name: &str) -> Result<PeerView, String> {
let peers: Vec<PeerView> = client.get_json(crate::peer::route::PEERS)?;
let exact: Vec<&PeerView> = peers
.iter()
.filter(|peer| peer.name.eq_ignore_ascii_case(name) || peer.id == name)
.collect();
if let [one] = exact.as_slice() {
return Ok((*one).clone());
}
let matches: Vec<&PeerView> = peers
.iter()
.filter(|peer| peer.id.starts_with(name) || peer.name.starts_with(name))
.collect();
match matches.as_slice() {
[one] => Ok((*one).clone()),
[] => Err(format!(
"no peer called '{name}'. This machine knows: {}",
if peers.is_empty() {
"none — run `wsb peer pair`".to_string()
} else {
peers
.iter()
.map(|peer| peer.name.as_str())
.collect::<Vec<_>>()
.join(", ")
}
)),
many => Err(format!(
"'{name}' matches {} peers: {}. Name one exactly.",
many.len(),
many.iter().map(|peer| peer.name.as_str()).collect::<Vec<_>>().join(", ")
)),
}
}
fn positional<'a>(args: &'a [String], what: &str) -> Result<&'a str, String> {
args.iter()
.find(|arg| !arg.starts_with("--"))
.map(String::as_str)
.ok_or_else(|| format!("a {what} is required\n\n{USAGE}"))
}
fn flag_value<'a>(args: &'a [String], name: &str) -> Option<&'a str> {
args.iter()
.position(|arg| arg == name)
.and_then(|index| args.get(index + 1))
.map(String::as_str)
}
#[derive(Debug, Clone, serde::Deserialize)]
pub struct PeerView {
pub id: String,
pub name: String,
pub fingerprint: String,
pub address: String,
pub state: String,
pub detail: Option<String>,
pub shared_workspaces: usize,
}
#[derive(Debug, serde::Deserialize)]
struct PairingView {
code: String,
expires_in_seconds: u64,
machine_name: String,
fingerprint: String,
peer_port: u16,
}
#[derive(Debug, serde::Deserialize)]
struct StatusView {
peer_state: String,
desired: String,
mode: String,
repositories: Vec<crate::peer::model::RepositoryStatus>,
}
#[derive(Debug, serde::Deserialize)]
struct DeltasView {
continuity: String,
files: String,
repositories: Vec<RepositoryDeltasView>,
}
#[derive(Debug, serde::Deserialize)]
struct RepositoryDeltasView {
repository: String,
label: String,
observed: String,
compared: bool,
deltas: Vec<DeltaView>,
}
#[derive(Debug, serde::Deserialize)]
struct DeltaView {
path: String,
direction: String,
size: Option<u64>,
decision: Option<String>,
dismissed: bool,
}
#[derive(Debug, serde::Deserialize)]
struct ConflictsView {
conflicts: Vec<ConflictView>,
}
#[derive(Debug, serde::Deserialize)]
struct ConflictView {
repository: String,
path: String,
here: String,
there: String,
}
#[derive(Debug, serde::Deserialize)]
struct DiffView {
repositories: Vec<RepositoryDiffView>,
}
#[derive(Debug, serde::Deserialize)]
struct RepositoryDiffView {
repository: String,
label: String,
observed: String,
planned: bool,
moves: Vec<MoveView>,
}
#[derive(Debug, serde::Deserialize)]
struct VersionsView {
repository: String,
path: String,
here: SideView,
there: SideView,
diff: Option<String>,
reason: Option<String>,
}
#[derive(Debug, serde::Deserialize)]
struct SideView {
size: u64,
}
#[derive(Debug, serde::Deserialize)]
struct MoveView {
path: String,
direction: String,
size: Option<u64>,
}
#[cfg(test)]
#[path = "peer_tests.rs"]
mod tests;