use crate::config::Config;
use crate::db::Database;
use crate::records::{self, EdgeRecord, NodeRecord, RecordStore};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::path::Path;
pub const TOKEN_ENV: &str = "DECIDUOUS_MCP_TOKEN";
pub const FALLBACK_WORKSPACE: &str = "scratch";
#[derive(Debug, Deserialize, Serialize, Default, Clone)]
pub struct RemoteConfig {
#[serde(default)]
pub url: Option<String>,
#[serde(default)]
pub workspace: Option<String>,
}
impl RemoteConfig {
pub fn is_configured(&self) -> bool {
self.url.is_some()
}
}
pub fn credentials_path() -> Option<std::path::PathBuf> {
let base = std::env::var_os("XDG_CONFIG_HOME")
.map(std::path::PathBuf::from)
.or_else(|| {
std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".config"))
})?;
Some(base.join("deciduous").join("credentials"))
}
pub fn token() -> Result<String, String> {
if let Ok(t) = std::env::var(TOKEN_ENV) {
if !t.trim().is_empty() {
return Ok(t.trim().to_string());
}
}
if let Some(path) = credentials_path() {
if let Ok(contents) = std::fs::read_to_string(&path) {
let t = contents.trim();
if !t.is_empty() {
return Ok(t.to_string());
}
}
}
Err(format!(
"no token for the shared graph.\n\n\
Store one (kept outside every repository, mode 0600):\n\n \
deciduous remote login\n\n\
or set {TOKEN_ENV} for a one-off."
))
}
pub fn store_token(token: &str) -> Result<std::path::PathBuf, String> {
let token = token.trim();
if token.is_empty() {
return Err("refusing to store an empty token".to_string());
}
let path = credentials_path().ok_or("cannot determine a config directory (is HOME set?)")?;
let dir = path.parent().ok_or("credentials path has no parent")?;
std::fs::create_dir_all(dir).map_err(|e| format!("creating {}: {e}", dir.display()))?;
let mut opts = std::fs::OpenOptions::new();
opts.write(true).create(true).truncate(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(0o600);
}
let mut file = opts
.open(&path)
.map_err(|e| format!("opening {}: {e}", path.display()))?;
use std::io::Write;
writeln!(file, "{token}").map_err(|e| format!("writing {}: {e}", path.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600));
}
Ok(path)
}
pub fn forget_token() -> Result<bool, String> {
let Some(path) = credentials_path() else {
return Ok(false);
};
match std::fs::remove_file(&path) {
Ok(()) => Ok(true),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(e) => Err(format!("removing {}: {e}", path.display())),
}
}
pub fn workspace_for(dir: &Path) -> String {
let root = std::process::Command::new("git")
.args([
"-C",
&dir.display().to_string(),
"rev-parse",
"--show-toplevel",
])
.output()
.ok()
.filter(|o| o.status.success())
.and_then(|o| String::from_utf8(o.stdout).ok());
match root {
Some(path) => Path::new(path.trim())
.file_name()
.map(|n| n.to_string_lossy().to_lowercase())
.unwrap_or_else(|| FALLBACK_WORKSPACE.to_string()),
None => FALLBACK_WORKSPACE.to_string(),
}
}
#[derive(Debug)]
pub struct Remote {
pub url: String,
pub workspace: String,
token: String,
}
impl Remote {
pub fn resolve(config: &Config, dir: &Path) -> Result<Self, String> {
let url = config.remote.url.clone().ok_or_else(|| {
"no remote configured for this project.\n\nRun:\n\n deciduous remote init <url>"
.to_string()
})?;
Ok(Self {
url: url.trim_end_matches('/').to_string(),
workspace: config
.remote
.workspace
.clone()
.unwrap_or_else(|| workspace_for(dir)),
token: token()?,
})
}
pub fn events_url(&self) -> String {
let ws_base = if let Some(rest) = self.url.strip_prefix("https://") {
format!("wss://{rest}")
} else if let Some(rest) = self.url.strip_prefix("http://") {
format!("ws://{rest}")
} else {
self.url.clone()
};
format!(
"{ws_base}/events?workspace={}&token={}",
urlencode(&self.workspace),
urlencode(&self.token)
)
}
fn get(&self, path: &str) -> ureq::Request {
ureq::get(&format!("{}{}", self.url, path))
.set("authorization", &format!("Bearer {}", self.token))
}
fn post(&self, path: &str) -> ureq::Request {
ureq::post(&format!("{}{}", self.url, path))
.set("authorization", &format!("Bearer {}", self.token))
}
pub fn health(&self) -> Result<(), String> {
ureq::get(&format!("{}/health", self.url))
.timeout(std::time::Duration::from_secs(15))
.call()
.map(|_| ())
.map_err(|e| format!("cannot reach {}: {}", self.url, describe(e)))
}
pub fn check(&self) -> Result<RemoteCounts, String> {
self.health()?;
self.counts()
.map_err(|e| format!("reached {} but {}", self.url, e))
}
pub fn counts(&self) -> Result<RemoteCounts, String> {
let graph = self.export()?;
Ok(RemoteCounts {
nodes: graph.nodes.len(),
edges: graph.edges.len(),
documents: graph.documents.len(),
})
}
pub fn export(&self) -> Result<RemoteGraph, String> {
let resp = self
.get(&format!("/export?workspace={}", urlencode(&self.workspace)))
.timeout(std::time::Duration::from_secs(300))
.call()
.map_err(describe)?;
resp.into_json::<RemoteGraph>()
.map_err(|e| format!("the server's response was not a graph: {e}"))
}
pub fn import(&self, graph: Value) -> Result<ImportReport, String> {
let payload = serde_json::json!({
"workspace": self.workspace,
"graph": graph,
});
self.post("/import")
.timeout(std::time::Duration::from_secs(600))
.send_json(payload)
.map_err(describe)?
.into_json::<ImportReport>()
.map_err(|e| format!("the server's response was not an import report: {e}"))
}
}
pub fn missing_on_server(local: &Value, server: &RemoteGraph) -> (Value, usize, usize) {
use std::collections::{HashMap, HashSet};
let have_nodes: HashSet<&str> = server.nodes.iter().map(|n| n.change_id.as_str()).collect();
let have_edges: HashSet<(&str, &str, &str)> = server
.edges
.iter()
.filter_map(|e| {
Some((
e.from_change_id.as_deref()?,
e.to_change_id.as_deref()?,
e.edge_type.as_str(),
))
})
.collect();
let have_docs: HashSet<&str> = server
.documents
.iter()
.filter_map(|d| d["change_id"].as_str())
.collect();
let empty = Vec::new();
let nodes = local["nodes"].as_array().unwrap_or(&empty);
let by_id: HashMap<i64, &str> = nodes
.iter()
.filter_map(|n| Some((n["id"].as_i64()?, n["change_id"].as_str()?)))
.collect();
let send_nodes: Vec<Value> = nodes
.iter()
.filter(|n| {
n["change_id"]
.as_str()
.is_some_and(|c| !have_nodes.contains(c))
})
.cloned()
.collect();
let mut send_edges = Vec::new();
for e in local["edges"].as_array().unwrap_or(&empty) {
let end = |id: &str, cid: &str| {
e[id]
.as_i64()
.and_then(|i| by_id.get(&i).copied())
.or_else(|| e[cid].as_str())
};
let (Some(f), Some(t)) = (
end("from_node_id", "from_change_id"),
end("to_node_id", "to_change_id"),
) else {
continue;
};
let kind = e["edge_type"].as_str().unwrap_or("leads_to");
if have_edges.contains(&(f, t, kind)) {
continue;
}
let mut e = e.clone();
e["from_change_id"] = Value::String(f.to_string());
e["to_change_id"] = Value::String(t.to_string());
send_edges.push(e);
}
let send_docs: Vec<Value> = local["documents"]
.as_array()
.unwrap_or(&empty)
.iter()
.filter(|d| {
d["change_id"]
.as_str()
.is_some_and(|c| !have_docs.contains(c))
})
.cloned()
.collect();
let (n, m) = (send_nodes.len(), send_edges.len());
(
serde_json::json!({"nodes": send_nodes, "edges": send_edges, "documents": send_docs}),
n,
m,
)
}
pub fn push_missing(remote: &Remote, graph: &Value) -> Result<Option<ImportReport>, String> {
let server = remote.export()?;
let (missing, nodes, edges) = missing_on_server(graph, &server);
if nodes == 0 && edges == 0 {
return Ok(None);
}
remote.import(missing).map(Some)
}
pub fn push_after_write(db: &Database, touched: &[i32]) {
use colored::Colorize;
let cfg = Config::load();
if !cfg.remote.is_configured() {
return;
}
let dir = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
let pushed = Remote::resolve(&cfg, &dir).and_then(|remote| {
let graph = db
.get_graph()
.map_err(|e| format!("reading the local graph: {e}"))
.and_then(|g| {
serde_json::to_value(&g).map_err(|e| format!("serializing the local graph: {e}"))
})?;
let server = remote.export()?;
let (mut payload, _, _) = missing_on_server(&graph, &server);
let added = add_touched_nodes(&mut payload, &graph, touched);
if payload["nodes"].as_array().is_some_and(|n| n.is_empty())
&& payload["edges"].as_array().is_some_and(|e| e.is_empty())
&& added == 0
{
return Ok((remote, None));
}
remote.import(payload).map(|r| (remote, Some(r)))
});
match pushed {
Ok((_, None)) => {}
Ok((remote, Some(r))) => {
if r.nodes.upserted + r.edges.upserted > 2 {
println!(
" {} {} node(s), {} edge(s) to {}",
"Pushed".green(),
r.nodes.upserted,
r.edges.upserted,
remote.workspace.cyan()
);
}
}
Err(e) => eprintln!(
"{} the local write succeeded but the server did not get it: {e}\n\
{} `deciduous remote push` once the server is reachable. Until then the \
local graph and the graph the agents read are different graphs.",
"Warning:".yellow(),
"Run:".yellow(),
),
}
}
fn add_touched_nodes(payload: &mut Value, graph: &Value, touched: &[i32]) -> usize {
use std::collections::HashSet;
if touched.is_empty() {
return 0;
}
let already: HashSet<String> = payload["nodes"]
.as_array()
.map(|n| {
n.iter()
.filter_map(|n| n["change_id"].as_str().map(str::to_string))
.collect()
})
.unwrap_or_default();
let extra: Vec<Value> = graph["nodes"]
.as_array()
.map(|nodes| {
nodes
.iter()
.filter(|n| {
n["id"]
.as_i64()
.is_some_and(|id| touched.contains(&(id as i32)))
&& n["change_id"]
.as_str()
.is_some_and(|c| !already.contains(c))
})
.cloned()
.collect()
})
.unwrap_or_default();
let added = extra.len();
if let Some(nodes) = payload["nodes"].as_array_mut() {
nodes.extend(extra);
}
added
}
pub fn warn_removal_is_local_only(what: &str) {
use colored::Colorize;
let cfg = Config::load();
if !cfg.remote.is_configured() {
return;
}
let url = cfg.remote.url.unwrap_or_default();
eprintln!(
"{} the {what} was removed locally only. {url} still has it, and \
`deciduous remote push` will not remove it there: the server's import \
applies additions and edits, not removals. Remove it through the agent \
(the deciduous MCP tools) as well, or the two graphs stay different.",
"Note:".yellow(),
);
}
#[derive(Debug, Default)]
pub struct RemoteCounts {
pub nodes: usize,
pub edges: usize,
pub documents: usize,
}
#[derive(Debug, Deserialize)]
pub struct RemoteGraph {
#[serde(default)]
pub nodes: Vec<RemoteNode>,
#[serde(default)]
pub edges: Vec<RemoteEdge>,
#[serde(default)]
pub documents: Vec<Value>,
}
#[derive(Debug, Deserialize)]
pub struct RemoteNode {
pub change_id: String,
pub node_type: String,
pub title: String,
pub description: Option<String>,
pub status: String,
#[serde(default)]
pub metadata: Option<Value>,
pub created_at: String,
pub updated_at: String,
#[serde(default)]
pub deleted_at: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct RemoteEdge {
pub from_change_id: Option<String>,
pub to_change_id: Option<String>,
pub edge_type: String,
#[serde(default)]
pub rationale: Option<String>,
pub created_at: String,
}
#[derive(Debug, Deserialize)]
pub struct ImportReport {
pub nodes: CountPair,
pub edges: EdgeReport,
#[serde(default)]
pub documents: Option<DocReport>,
}
#[derive(Debug, Deserialize)]
pub struct CountPair {
pub received: usize,
pub upserted: usize,
}
#[derive(Debug, Deserialize)]
pub struct EdgeReport {
pub received: usize,
pub upserted: usize,
pub unresolved: usize,
#[serde(default)]
pub stale_change_ids: usize,
}
#[derive(Debug, Deserialize)]
pub struct DocReport {
pub received: usize,
pub upserted: usize,
#[serde(default)]
pub content_missing: usize,
}
pub fn pull(remote: &Remote, db: &Database, store: &RecordStore) -> Result<PullReport, String> {
let graph = remote.export()?;
let mut written = 0usize;
let result = store.batch(|| -> Result<usize, String> {
for n in &graph.nodes {
let rec = NodeRecord {
change_id: n.change_id.clone(),
node_type: n.node_type.clone(),
title: n.title.clone(),
description: n.description.clone(),
status: n.status.clone(),
metadata: n.metadata.clone(),
created_at: n.created_at.clone(),
updated_at: n.updated_at.clone(),
author: None,
deleted_at: n.deleted_at.clone(),
};
if store.write_node(&rec).map_err(|e| e.to_string())? {
written += 1;
}
}
for e in &graph.edges {
let (Some(from), Some(to)) = (&e.from_change_id, &e.to_change_id) else {
continue;
};
let rec = EdgeRecord {
edge_id: records::edge_id(from, to, &e.edge_type),
from_change_id: from.clone(),
to_change_id: to.clone(),
edge_type: e.edge_type.clone(),
rationale: e.rationale.clone(),
weight: None,
created_at: e.created_at.clone(),
author: None,
deleted_at: None,
};
if store.write_edge(&rec).map_err(|e| e.to_string())? {
written += 1;
}
}
Ok(written)
});
let records_written = match result {
Ok(Ok(n)) => n,
Ok(Err(e)) => return Err(e),
Err(e) => return Err(format!("record store: {e}")),
};
let report = records::reconcile(db, store, false)?;
Ok(PullReport {
fetched_nodes: graph.nodes.len(),
fetched_edges: graph.edges.len(),
records_written,
imported_nodes: report.nodes_imported,
imported_edges: report.edges_imported,
})
}
#[derive(Debug, Default)]
pub struct PullReport {
pub fetched_nodes: usize,
pub fetched_edges: usize,
pub records_written: usize,
pub imported_nodes: usize,
pub imported_edges: usize,
}
fn describe(e: ureq::Error) -> String {
match e {
ureq::Error::Status(code, resp) => {
let body = resp.into_string().unwrap_or_default();
let detail = serde_json::from_str::<Value>(&body)
.ok()
.and_then(|v| v.get("error").and_then(|e| e.as_str()).map(str::to_string))
.unwrap_or_else(|| body.chars().take(200).collect());
match code {
401 => format!("the server rejected the token (401). Check {TOKEN_ENV}."),
404 => "no such endpoint (404). Is the URL right, including any path prefix?"
.to_string(),
_ if detail.is_empty() => format!("the server returned {code}"),
_ => format!("the server returned {code}: {detail}"),
}
}
ureq::Error::Transport(t) => format!("transport error: {t}"),
}
}
fn urlencode(s: &str) -> String {
s.chars()
.map(|c| match c {
'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' | '.' | '~' => c.to_string(),
'*' => "%2A".to_string(),
c => format!("%{:02X}", c as u32),
})
.collect()
}
pub fn find_projects(root: &Path) -> Vec<std::path::PathBuf> {
fn walk(dir: &Path, depth: usize, out: &mut Vec<std::path::PathBuf>) {
if depth > 3 {
return;
}
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
let name = entry.file_name();
let name = name.to_string_lossy();
if name == ".deciduous" {
if path.join("deciduous.db").exists() || path.join("config.toml").exists() {
if let Some(project) = path.parent() {
out.push(project.to_path_buf());
}
}
continue;
}
if name == "node_modules" || name == "target" || name == "_build" || name == "deps" {
continue;
}
walk(&path, depth + 1, out);
}
}
let mut out = Vec::new();
walk(root, 0, &mut out);
out.sort();
out.dedup();
out
}
pub fn read_remote_url(project: &Path) -> Option<String> {
let text = std::fs::read_to_string(project.join(".deciduous").join("config.toml")).ok()?;
let doc: toml::Value = toml::from_str(&text).ok()?;
doc.get("remote")?.get("url")?.as_str().map(str::to_string)
}
pub fn write_remote_url(project: &Path, url: &str) -> Result<(), String> {
let dir = project.join(".deciduous");
let path = dir.join("config.toml");
let existing = std::fs::read_to_string(&path).unwrap_or_default();
let mut doc = existing
.parse::<toml_edit::DocumentMut>()
.map_err(|e| format!("{} is not valid TOML: {e}", path.display()))?;
doc["remote"].or_insert(toml_edit::table())["url"] = toml_edit::value(url.to_string());
std::fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
std::fs::write(&path, doc.to_string()).map_err(|e| format!("{}: {e}", path.display()))
}
#[cfg(test)]
mod tests {
fn server(nodes: &[&str], edges: &[(&str, &str, &str)]) -> RemoteGraph {
RemoteGraph {
nodes: nodes
.iter()
.map(|c| RemoteNode {
change_id: c.to_string(),
node_type: "goal".into(),
title: "t".into(),
description: None,
status: "pending".into(),
metadata: None,
created_at: "x".into(),
updated_at: "x".into(),
deleted_at: None,
})
.collect(),
edges: edges
.iter()
.map(|(f, t, k)| RemoteEdge {
from_change_id: Some(f.to_string()),
to_change_id: Some(t.to_string()),
edge_type: k.to_string(),
rationale: None,
created_at: "x".into(),
})
.collect(),
documents: vec![],
}
}
#[test]
fn push_sends_only_what_the_server_lacks_and_never_an_existing_node() {
let local = serde_json::json!({
"nodes": [{"id": 1, "change_id": "a"}, {"id": 2, "change_id": "b"}, {"id": 3, "change_id": "c"}],
"edges": [
{"from_node_id": 1, "to_node_id": 2, "edge_type": "leads_to"},
{"from_node_id": 2, "to_node_id": 3, "edge_type": "leads_to"}
],
"documents": []
});
let (g, n, m) = missing_on_server(&local, &server(&["a", "b"], &[("a", "b", "leads_to")]));
assert_eq!((n, m), (1, 1));
assert_eq!(g["nodes"][0]["change_id"], "c");
assert_eq!(g["edges"][0]["from_change_id"], "b");
assert_eq!(g["edges"][0]["to_change_id"], "c");
}
#[test]
fn a_changed_node_the_server_already_has_is_still_sent_when_touched() {
let local = serde_json::json!({
"nodes": [{"id": 1, "change_id": "a"}, {"id": 2, "change_id": "b"}],
"edges": [],
"documents": []
});
let (mut payload, n, m) = missing_on_server(&local, &server(&["a", "b"], &[]));
assert_eq!((n, m), (0, 0), "nothing is missing, by construction");
assert_eq!(add_touched_nodes(&mut payload, &local, &[2]), 1);
assert_eq!(payload["nodes"][0]["change_id"], "b");
}
#[test]
fn a_touched_node_the_diff_already_sends_is_not_sent_twice() {
let local = serde_json::json!({
"nodes": [{"id": 1, "change_id": "a"}],
"edges": [],
"documents": []
});
let (mut payload, n, _) = missing_on_server(&local, &server(&[], &[]));
assert_eq!(n, 1);
assert_eq!(add_touched_nodes(&mut payload, &local, &[1]), 0);
assert_eq!(payload["nodes"].as_array().unwrap().len(), 1);
}
#[test]
fn touching_nothing_adds_nothing() {
let local = serde_json::json!({"nodes": [{"id": 1, "change_id": "a"}], "edges": []});
let (mut payload, _, _) = missing_on_server(&local, &server(&["a"], &[]));
assert_eq!(add_touched_nodes(&mut payload, &local, &[]), 0);
assert_eq!(add_touched_nodes(&mut payload, &local, &[99]), 0);
assert!(payload["nodes"].as_array().unwrap().is_empty());
}
#[test]
fn a_stale_stored_change_id_does_not_make_an_existing_edge_look_missing() {
let local = serde_json::json!({
"nodes": [{"id": 1, "change_id": "a"}, {"id": 2, "change_id": "b"}],
"edges": [{"from_node_id": 1, "to_node_id": 2, "from_change_id": "old-a", "to_change_id": "b", "edge_type": "leads_to"}]
});
let (_, n, m) = missing_on_server(&local, &server(&["a", "b"], &[("a", "b", "leads_to")]));
assert_eq!((n, m), (0, 0));
}
use super::*;
#[test]
fn workspace_falls_back_outside_a_git_repo() {
let dir = std::env::temp_dir().join("deciduous-remote-not-a-repo");
std::fs::create_dir_all(&dir).unwrap();
assert_eq!(workspace_for(&dir), FALLBACK_WORKSPACE);
}
#[test]
fn workspace_names_are_lowercased() {
let name = "MixedCase";
assert_eq!(name.to_lowercase(), "mixedcase");
}
#[test]
fn the_global_token_is_escaped_so_it_cannot_widen_a_query() {
assert_eq!(urlencode("*"), "%2A");
assert_eq!(urlencode("blog"), "blog");
assert_eq!(urlencode("a b"), "a%20b");
}
#[test]
fn adopt_skips_vendored_trees() {
let root = std::env::temp_dir().join(format!("deciduous-adopt-{}", std::process::id()));
let real = root.join("my-project").join(".deciduous");
let vendored = root
.join("my-project")
.join("node_modules")
.join("dep")
.join(".deciduous");
std::fs::create_dir_all(&real).unwrap();
std::fs::create_dir_all(&vendored).unwrap();
std::fs::write(real.join("config.toml"), "").unwrap();
std::fs::write(vendored.join("config.toml"), "").unwrap();
let found = find_projects(&root);
assert_eq!(found.len(), 1, "found: {found:?}");
assert!(found[0].ends_with("my-project"));
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn writing_a_remote_url_preserves_the_rest_of_the_file() {
let root = std::env::temp_dir().join(format!("deciduous-wr-{}", std::process::id()));
let dir = root.join(".deciduous");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("config.toml"),
"# keep me\n[branch]\nmain_branches = [\"main\"]\n",
)
.unwrap();
write_remote_url(&root, "https://example.com/mcp").unwrap();
let after = std::fs::read_to_string(dir.join("config.toml")).unwrap();
assert!(after.contains("# keep me"), "comment lost:\n{after}");
assert!(after.contains("main_branches = [\"main\"]"));
assert_eq!(
read_remote_url(&root).as_deref(),
Some("https://example.com/mcp")
);
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn a_remote_without_a_url_explains_how_to_configure_one() {
let cfg = Config::default();
let err = Remote::resolve(&cfg, Path::new(".")).unwrap_err();
assert!(err.contains("deciduous remote init"), "got: {err}");
}
#[test]
fn events_url_converts_https_to_wss() {
let r = Remote {
url: "https://example.com/deciduous-mcp".to_string(),
workspace: "blog".to_string(),
token: "abc123".to_string(),
};
assert_eq!(
r.events_url(),
"wss://example.com/deciduous-mcp/events?workspace=blog&token=abc123"
);
}
#[test]
fn events_url_converts_plain_http_to_ws_for_a_dev_server() {
let r = Remote {
url: "http://localhost:4111".to_string(),
workspace: "blog".to_string(),
token: "abc123".to_string(),
};
assert_eq!(
r.events_url(),
"ws://localhost:4111/events?workspace=blog&token=abc123"
);
}
#[test]
fn events_url_encodes_a_workspace_name_with_special_characters() {
let r = Remote {
url: "https://example.com".to_string(),
workspace: "a b".to_string(),
token: "tok".to_string(),
};
assert!(
r.events_url().contains("workspace=a%20b"),
"got: {}",
r.events_url()
);
}
}