bonsaiviewer-autodesk: satisfy cargo fmt and clippy

The crate had never been run through rustfmt, so the CI job's first step
(`cargo fmt --all -- --check`) failed and masked 12 clippy errors behind
it. Fix both.

Beyond the mechanical reformat and the redundant-closure/div_ceil/Default
lints, two changes carry meaning:

- SettingsDialog::on_reload is UI-thread only, so it becomes an Rc. The
  Arc was never shared across threads and clippy rightly flagged it as
  an Arc over a non-Send/Sync closure.
- WorkerMsg variants lose their shared `Loaded` postfix; the enum's doc
  comment already says these are worker completions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Dion Moult
2026-07-27 20:39:21 +10:00
parent a84957fd45
commit 0aef201d50
12 changed files with 750 additions and 258 deletions
+64 -14
View File
@@ -77,7 +77,11 @@ impl ApsClient {
.and_then(|v| v.as_array())
.map(|arr| arr.iter().map(entry_from_jsonapi).collect())
.unwrap_or_default();
folders.sort_by(|a, b| a.display_name.to_lowercase().cmp(&b.display_name.to_lowercase()));
folders.sort_by(|a, b| {
a.display_name
.to_lowercase()
.cmp(&b.display_name.to_lowercase())
});
Ok(folders)
}
@@ -120,7 +124,11 @@ impl ApsClient {
_ => break,
}
}
entries.sort_by(|a, b| a.display_name.to_lowercase().cmp(&b.display_name.to_lowercase()));
entries.sort_by(|a, b| {
a.display_name
.to_lowercase()
.cmp(&b.display_name.to_lowercase())
});
Ok(entries)
}
@@ -136,10 +144,17 @@ impl ApsClient {
.get("data")
.cloned()
.ok_or_else(|| RpcError::internal("Item response missing 'data'."))?;
let item_attrs = item.get("attributes").cloned().unwrap_or_else(|| serde_json::json!({}));
let item_attrs = item
.get("attributes")
.cloned()
.unwrap_or_else(|| serde_json::json!({}));
let parent_folder_id = relationship_id(&item, "parent");
let tip_id = relationship_id(&item, "tip");
let id = item.get("id").and_then(|v| v.as_str()).unwrap_or_default().to_string();
let id = item
.get("id")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
let tip = payload
.get("included")
.and_then(|v| v.as_array())
@@ -171,7 +186,10 @@ impl ApsClient {
parent_folder_id,
});
};
let tip_attrs = tip.get("attributes").cloned().unwrap_or_else(|| serde_json::json!({}));
let tip_attrs = tip
.get("attributes")
.cloned()
.unwrap_or_else(|| serde_json::json!({}));
let display = tip_attrs
.get("displayName")
.and_then(|v| v.as_str())
@@ -182,12 +200,21 @@ impl ApsClient {
Ok(ItemTip {
id,
display_name: display,
hidden: item_attrs.get("hidden").and_then(|v| v.as_bool()).unwrap_or(false),
hidden: item_attrs
.get("hidden")
.and_then(|v| v.as_bool())
.unwrap_or(false),
version_id: tip.get("id").and_then(|v| v.as_str()).map(String::from),
storage_id: relationship_id(&tip, "storage"),
version_number: tip_attrs.get("versionNumber").cloned(),
last_modified_time_utc: tip_attrs.get("lastModifiedTime").and_then(|v| v.as_str()).map(String::from),
last_modified_user_name: tip_attrs.get("lastModifiedUserName").and_then(|v| v.as_str()).map(String::from),
last_modified_time_utc: tip_attrs
.get("lastModifiedTime")
.and_then(|v| v.as_str())
.map(String::from),
last_modified_user_name: tip_attrs
.get("lastModifiedUserName")
.and_then(|v| v.as_str())
.map(String::from),
parent_folder_id,
})
}
@@ -195,8 +222,16 @@ impl ApsClient {
fn hub_from_jsonapi(item: &Value) -> Hub {
Hub {
id: item.get("id").and_then(|v| v.as_str()).unwrap_or_default().to_string(),
name: item.pointer("/attributes/name").and_then(|v| v.as_str()).unwrap_or_default().to_string(),
id: item
.get("id")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string(),
name: item
.pointer("/attributes/name")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string(),
extension_type: item
.pointer("/attributes/extension/type")
.and_then(|v| v.as_str())
@@ -207,8 +242,16 @@ fn hub_from_jsonapi(item: &Value) -> Hub {
fn project_from_jsonapi(item: &Value) -> Project {
Project {
id: item.get("id").and_then(|v| v.as_str()).unwrap_or_default().to_string(),
name: item.pointer("/attributes/name").and_then(|v| v.as_str()).unwrap_or_default().to_string(),
id: item
.get("id")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string(),
name: item
.pointer("/attributes/name")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string(),
extension_type: item
.pointer("/attributes/extension/type")
.and_then(|v| v.as_str())
@@ -223,7 +266,10 @@ fn project_from_jsonapi(item: &Value) -> Project {
}
pub(crate) fn entry_from_jsonapi(item: &Value) -> Entry {
let attrs = item.get("attributes").cloned().unwrap_or_else(|| serde_json::json!({}));
let attrs = item
.get("attributes")
.cloned()
.unwrap_or_else(|| serde_json::json!({}));
let display = attrs
.get("displayName")
.and_then(|v| v.as_str())
@@ -237,7 +283,11 @@ pub(crate) fn entry_from_jsonapi(item: &Value) -> Entry {
_ => EntryType::Other,
};
Entry {
id: item.get("id").and_then(|v| v.as_str()).unwrap_or_default().to_string(),
id: item
.get("id")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string(),
entry_type,
display_name: display,
name: attrs.get("name").and_then(|v| v.as_str()).map(String::from),
+3 -1
View File
@@ -23,7 +23,9 @@ impl ApsClient {
pub fn with_base_url(auth: Arc<AuthSessionService>, base_url: String) -> Self {
Self {
auth,
agent: ureq::AgentBuilder::new().timeout(Duration::from_secs(120)).build(),
agent: ureq::AgentBuilder::new()
.timeout(Duration::from_secs(120))
.build(),
base_url,
}
}
+117 -36
View File
@@ -43,9 +43,13 @@ impl ApsClient {
local_path.display()
)));
}
let file_name = display_name
.map(str::to_string)
.unwrap_or_else(|| local_path.file_name().unwrap_or_default().to_string_lossy().into_owned());
let file_name = display_name.map(str::to_string).unwrap_or_else(|| {
local_path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.into_owned()
});
let storage_id = self.create_storage(project_id, folder_id, &file_name)?;
let (bucket, object) = parse_storage_id(&storage_id)?;
self.upload_local_file_to_oss(&bucket, &object, local_path, progress)?;
@@ -68,7 +72,9 @@ impl ApsClient {
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(String::from)
.ok_or_else(|| RpcError::internal("Signed download URL response did not contain a URL."))
.ok_or_else(|| {
RpcError::internal("Signed download URL response did not contain a URL.")
})
}
fn download_to_file(
@@ -81,7 +87,11 @@ impl ApsClient {
Ok(r) => r,
Err(ureq::Error::Status(code, resp)) => {
let body = resp.into_string().unwrap_or_default();
let msg = if body.trim().is_empty() { format!("HTTP {code}") } else { body.trim().to_string() };
let msg = if body.trim().is_empty() {
format!("HTTP {code}")
} else {
body.trim().to_string()
};
return Err(RpcError::internal(msg));
}
Err(e) => return Err(RpcError::internal(e.to_string())),
@@ -93,8 +103,9 @@ impl ApsClient {
.to_string_lossy()
.into_owned();
let mut reader = resp.into_reader();
let mut file = File::create(destination)
.map_err(|e| RpcError::internal(format!("Cannot create '{}': {e}", destination.display())))?;
let mut file = File::create(destination).map_err(|e| {
RpcError::internal(format!("Cannot create '{}': {e}", destination.display()))
})?;
let mut buf = vec![0u8; READ_BUF];
let mut downloaded: u64 = 0;
loop {
@@ -108,16 +119,26 @@ impl ApsClient {
.map_err(|e| RpcError::internal(format!("Download write failed: {e}")))?;
downloaded += n as u64;
if let Some(cb) = &progress {
let percent = total_bytes.map(|t| min(100, ((downloaded as f64 / t as f64) * 100.0) as i32));
let percent =
total_bytes.map(|t| min(100, ((downloaded as f64 / t as f64) * 100.0) as i32));
cb(&name, percent, Some(downloaded), total_bytes);
}
}
Ok(())
}
fn create_storage(&self, project_id: &str, folder_id: &str, file_name: &str) -> Result<String, RpcError> {
fn create_storage(
&self,
project_id: &str,
folder_id: &str,
file_name: &str,
) -> Result<String, RpcError> {
let payload = self.post_json(
&format!("{}/data/v1/projects/{}/storage", self.base_url, url_enc(project_id)),
&format!(
"{}/data/v1/projects/{}/storage",
self.base_url,
url_enc(project_id)
),
&json!({
"jsonapi": {"version": "1.0"},
"data": {
@@ -145,7 +166,7 @@ impl ApsClient {
let file_size = std::fs::metadata(local_path)
.map_err(|e| RpcError::internal(format!("stat '{}': {e}", local_path.display())))?
.len();
let total_parts = ((file_size + CHUNK_SIZE - 1) / CHUNK_SIZE).max(1);
let total_parts = file_size.div_ceil(CHUNK_SIZE).max(1);
let file_name = local_path
.file_name()
.unwrap_or_default()
@@ -170,13 +191,18 @@ impl ApsClient {
parts_to_request,
)?;
if upload_key.is_none() {
upload_key = signed.get("uploadKey").and_then(|v| v.as_str()).map(String::from);
upload_key = signed
.get("uploadKey")
.and_then(|v| v.as_str())
.map(String::from);
}
let urls = signed
.get("urls")
.and_then(|v| v.as_array())
.filter(|a| !a.is_empty())
.ok_or_else(|| RpcError::internal("Upload URL response did not contain upload URLs."))?
.ok_or_else(|| {
RpcError::internal("Upload URL response did not contain upload URLs.")
})?
.clone();
for url in &urls {
if parts_uploaded >= total_parts {
@@ -197,14 +223,23 @@ impl ApsClient {
let percent = if file_size == 0 {
100
} else {
min(100, ((bytes_uploaded as f64 / file_size as f64) * 100.0) as i32)
min(
100,
((bytes_uploaded as f64 / file_size as f64) * 100.0) as i32,
)
};
cb(&file_name, Some(percent), Some(bytes_uploaded), Some(file_size));
cb(
&file_name,
Some(percent),
Some(bytes_uploaded),
Some(file_size),
);
}
}
}
let key = upload_key.ok_or_else(|| RpcError::internal("Upload did not return an upload key."))?;
let key =
upload_key.ok_or_else(|| RpcError::internal("Upload did not return an upload key."))?;
self.complete_signed_upload(bucket, object, &key)
}
@@ -216,7 +251,9 @@ impl ApsClient {
first_part: u32,
parts: u32,
) -> Result<Value, RpcError> {
let token = self.auth.ensure_access_token(crate::progress::noop_auth())?;
let token = self
.auth
.ensure_access_token(crate::progress::noop_auth())?;
let mut url = format!(
"{}/oss/v2/buckets/{}/objects/{}/signeds3upload?minutesExpiration=10&firstPart={}&parts={}",
self.base_url,
@@ -236,8 +273,15 @@ impl ApsClient {
decode_json(resp, &url)
}
fn complete_signed_upload(&self, bucket: &str, object: &str, upload_key: &str) -> Result<(), RpcError> {
let token = self.auth.ensure_access_token(crate::progress::noop_auth())?;
fn complete_signed_upload(
&self,
bucket: &str,
object: &str,
upload_key: &str,
) -> Result<(), RpcError> {
let token = self
.auth
.ensure_access_token(crate::progress::noop_auth())?;
let url = format!(
"{}/oss/v2/buckets/{}/objects/{}/signeds3upload",
self.base_url,
@@ -254,7 +298,11 @@ impl ApsClient {
Ok(_) => Ok(()),
Err(ureq::Error::Status(code, resp)) => {
let body = resp.into_string().unwrap_or_default();
let msg = if body.trim().is_empty() { format!("HTTP {code}") } else { body.trim().to_string() };
let msg = if body.trim().is_empty() {
format!("HTTP {code}")
} else {
body.trim().to_string()
};
Err(RpcError::internal(msg))
}
Err(e) => Err(RpcError::internal(e.to_string())),
@@ -271,7 +319,11 @@ impl ApsClient {
Ok(_) => Ok(()),
Err(ureq::Error::Status(code, resp)) => {
let body = resp.into_string().unwrap_or_default();
let msg = if body.trim().is_empty() { format!("HTTP {code}") } else { body.trim().to_string() };
let msg = if body.trim().is_empty() {
format!("HTTP {code}")
} else {
body.trim().to_string()
};
Err(RpcError::internal(msg))
}
Err(e) => Err(RpcError::internal(e.to_string())),
@@ -299,7 +351,11 @@ impl ApsClient {
storage_id: &str,
) -> Result<UploadResult, RpcError> {
let payload = self.post_json(
&format!("{}/data/v1/projects/{}/versions", self.base_url, url_enc(project_id)),
&format!(
"{}/data/v1/projects/{}/versions",
self.base_url,
url_enc(project_id)
),
&json!({
"jsonapi": {"version": "1.0"},
"data": {
@@ -318,14 +374,27 @@ impl ApsClient {
let version = payload
.get("data")
.ok_or_else(|| RpcError::internal("Create version response missing 'data'."))?;
let attrs = version.get("attributes").cloned().unwrap_or_else(|| json!({}));
let attrs = version
.get("attributes")
.cloned()
.unwrap_or_else(|| json!({}));
Ok(UploadResult {
item_id: item_id.to_string(),
version_id: version.get("id").and_then(|v| v.as_str()).unwrap_or("").to_string(),
version_id: version
.get("id")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
display_name: file_name.to_string(),
version_number: attrs.get("versionNumber").cloned(),
last_modified_time_utc: attrs.get("lastModifiedTime").and_then(|v| v.as_str()).map(String::from),
last_modified_user_name: attrs.get("lastModifiedUserName").and_then(|v| v.as_str()).map(String::from),
last_modified_time_utc: attrs
.get("lastModifiedTime")
.and_then(|v| v.as_str())
.map(String::from),
last_modified_user_name: attrs
.get("lastModifiedUserName")
.and_then(|v| v.as_str())
.map(String::from),
})
}
@@ -337,7 +406,11 @@ impl ApsClient {
storage_id: &str,
) -> Result<UploadResult, RpcError> {
let payload = self.post_json(
&format!("{}/data/v1/projects/{}/items", self.base_url, url_enc(project_id)),
&format!(
"{}/data/v1/projects/{}/items",
self.base_url,
url_enc(project_id)
),
&json!({
"jsonapi": {"version": "1.0"},
"data": {
@@ -386,8 +459,14 @@ impl ApsClient {
if let Some(v) = attrs.get("versionNumber") {
version_number = Some(v.clone());
}
last_modified_time = attrs.get("lastModifiedTime").and_then(|v| v.as_str()).map(String::from);
last_modified_user = attrs.get("lastModifiedUserName").and_then(|v| v.as_str()).map(String::from);
last_modified_time = attrs
.get("lastModifiedTime")
.and_then(|v| v.as_str())
.map(String::from);
last_modified_user = attrs
.get("lastModifiedUserName")
.and_then(|v| v.as_str())
.map(String::from);
}
}
}
@@ -404,14 +483,16 @@ impl ApsClient {
pub(crate) fn parse_storage_id(storage_id: &str) -> Result<(String, String), RpcError> {
let marker = "urn:adsk.objects:os.object:";
let path = storage_id
.strip_prefix(marker)
.ok_or_else(|| RpcError::internal(format!("Unsupported storage identifier '{storage_id}'.")))?;
let slash = path
.find('/')
.ok_or_else(|| RpcError::internal(format!("Malformed storage identifier '{storage_id}'.")))?;
let path = storage_id.strip_prefix(marker).ok_or_else(|| {
RpcError::internal(format!("Unsupported storage identifier '{storage_id}'."))
})?;
let slash = path.find('/').ok_or_else(|| {
RpcError::internal(format!("Malformed storage identifier '{storage_id}'."))
})?;
if slash == 0 || slash == path.len() - 1 {
return Err(RpcError::internal(format!("Malformed storage identifier '{storage_id}'.")));
return Err(RpcError::internal(format!(
"Malformed storage identifier '{storage_id}'."
)));
}
Ok((path[..slash].to_string(), path[slash + 1..].to_string()))
}
+61 -20
View File
@@ -16,7 +16,8 @@ use crate::progress::AuthProgress;
use crate::rpc::RpcError;
pub const KEYRING_SERVICE: &str = "bonsaiviewer-autodesk";
pub const AUTHORIZE_ENDPOINT: &str = "https://developer.api.autodesk.com/authentication/v2/authorize";
pub const AUTHORIZE_ENDPOINT: &str =
"https://developer.api.autodesk.com/authentication/v2/authorize";
pub const TOKEN_ENDPOINT: &str = "https://developer.api.autodesk.com/authentication/v2/token";
const REFRESH_TTL_DEFAULT_SECONDS: i64 = 15 * 24 * 60 * 60;
@@ -44,7 +45,10 @@ pub struct KeyringTokenStore {
impl KeyringTokenStore {
pub fn new(username: impl Into<String>) -> Self {
Self { service: KEYRING_SERVICE.to_string(), username: username.into() }
Self {
service: KEYRING_SERVICE.to_string(),
username: username.into(),
}
}
fn entry(&self) -> Result<keyring::Entry, RpcError> {
@@ -128,8 +132,9 @@ pub fn wait_for_oauth_callback(
path: &str,
expected_state: &str,
) -> Result<String, RpcError> {
let listener = TcpListener::bind((host, port))
.map_err(|e| RpcError::internal(format!("Cannot bind OAuth callback to {host}:{port}: {e}")))?;
let listener = TcpListener::bind((host, port)).map_err(|e| {
RpcError::internal(format!("Cannot bind OAuth callback to {host}:{port}: {e}"))
})?;
let (mut stream, _) = listener
.accept()
.map_err(|e| RpcError::internal(format!("OAuth callback accept failed: {e}")))?;
@@ -137,7 +142,9 @@ pub fn wait_for_oauth_callback(
let request_line = {
let mut reader = BufReader::new(
stream.try_clone().map_err(|e| RpcError::internal(e.to_string()))?,
stream
.try_clone()
.map_err(|e| RpcError::internal(e.to_string()))?,
);
let mut line = String::new();
reader
@@ -184,7 +191,9 @@ pub fn wait_for_oauth_callback(
}
if let Some(err) = error.filter(|s| !s.is_empty()) {
return Err(RpcError::internal(format!("Autodesk returned OAuth error '{err}'.")));
return Err(RpcError::internal(format!(
"Autodesk returned OAuth error '{err}'."
)));
}
if state.as_deref() != Some(expected_state) {
return Err(RpcError::internal("OAuth state mismatch."));
@@ -195,10 +204,11 @@ pub fn wait_for_oauth_callback(
/// Pluggable callback strategy — production binds a localhost socket, tests
/// inject a stub that returns a canned code.
pub type CallbackWaiter = Arc<dyn Fn(&str, u16, &str, &str) -> Result<String, RpcError> + Send + Sync>;
pub type CallbackWaiter =
Arc<dyn Fn(&str, u16, &str, &str) -> Result<String, RpcError> + Send + Sync>;
pub fn default_callback_waiter() -> CallbackWaiter {
Arc::new(|host, port, path, state| wait_for_oauth_callback(host, port, path, state))
Arc::new(wait_for_oauth_callback)
}
/// Pluggable "open this URL in a browser" — production opens it for real,
@@ -235,7 +245,12 @@ pub struct AuthBuilder {
}
impl AuthBuilder {
pub fn new(client_id: String, callback_url: String, scope: String, token_store: Box<dyn TokenStore>) -> Self {
pub fn new(
client_id: String,
callback_url: String,
scope: String,
token_store: Box<dyn TokenStore>,
) -> Self {
Self {
client_id,
callback_url,
@@ -317,7 +332,9 @@ impl AuthSessionService {
if callback.scheme() != "http"
|| !matches!(callback.host_str(), Some("127.0.0.1") | Some("localhost"))
{
return Err(RpcError::internal("Callback URL must be http://localhost or http://127.0.0.1."));
return Err(RpcError::internal(
"Callback URL must be http://localhost or http://127.0.0.1.",
));
}
let authorize_url = {
@@ -356,7 +373,11 @@ impl AuthSessionService {
Ok(token)
}
fn refresh(&self, token: &StoredToken, progress: AuthProgress) -> Result<StoredToken, RpcError> {
fn refresh(
&self,
token: &StoredToken,
progress: AuthProgress,
) -> Result<StoredToken, RpcError> {
progress("auth", "Refreshing Autodesk session", None);
let payload = self
.post_form(&[
@@ -408,7 +429,8 @@ impl AuthSessionService {
access_token: access_token.to_string(),
refresh_token: refresh_token.to_string(),
access_token_expires_at: now + ChronoDuration::seconds(expires_in - TOKEN_SKEW_SECONDS),
refresh_token_expires_at: now + ChronoDuration::seconds(refresh_ttl - TOKEN_SKEW_SECONDS),
refresh_token_expires_at: now
+ ChronoDuration::seconds(refresh_ttl - TOKEN_SKEW_SECONDS),
scope: self.scope.clone(),
})
}
@@ -421,10 +443,14 @@ pub struct InMemoryTokenStore {
impl InMemoryTokenStore {
pub fn empty() -> Self {
Self { inner: std::sync::Mutex::new(None) }
Self {
inner: std::sync::Mutex::new(None),
}
}
pub fn preloaded(token: StoredToken) -> Self {
Self { inner: std::sync::Mutex::new(Some(token)) }
Self {
inner: std::sync::Mutex::new(Some(token)),
}
}
}
@@ -483,7 +509,11 @@ mod tests {
assert!(store.load().unwrap().is_none());
}
fn make_service(store: Box<dyn TokenStore>, token_endpoint: String, code: &'static str) -> AuthSessionService {
fn make_service(
store: Box<dyn TokenStore>,
token_endpoint: String,
code: &'static str,
) -> AuthSessionService {
AuthBuilder::new(
"client-xyz".into(),
"http://127.0.0.1:8080/".into(),
@@ -539,15 +569,22 @@ mod tests {
inner: InMemoryTokenStore,
}
impl TokenStore for PeekStore {
fn load(&self) -> Result<Option<StoredToken>, RpcError> { self.inner.load() }
fn load(&self) -> Result<Option<StoredToken>, RpcError> {
self.inner.load()
}
fn save(&self, t: &StoredToken) -> Result<(), RpcError> {
*self.shadow.lock().unwrap() = Some(t.clone());
self.inner.save(t)
}
fn delete(&self) -> Result<(), RpcError> { self.inner.delete() }
fn delete(&self) -> Result<(), RpcError> {
self.inner.delete()
}
}
let _ = store;
let peek = Box::new(PeekStore { shadow: saved_check.clone(), inner: InMemoryTokenStore::empty() });
let peek = Box::new(PeekStore {
shadow: saved_check.clone(),
inner: InMemoryTokenStore::empty(),
});
let svc = make_service(peek, endpoint, "AUTH_CODE");
let token = svc.login_interactive(crate::progress::noop_auth()).unwrap();
@@ -570,7 +607,9 @@ mod tests {
};
let store = Box::new(InMemoryTokenStore::preloaded(token));
let svc = make_service(store, "http://127.0.0.1:1/never".into(), "x");
let access = svc.ensure_access_token(crate::progress::noop_auth()).unwrap();
let access = svc
.ensure_access_token(crate::progress::noop_auth())
.unwrap();
assert_eq!(access, "CACHED");
}
@@ -591,7 +630,9 @@ mod tests {
};
let store = Box::new(InMemoryTokenStore::preloaded(token));
let svc = make_service(store, endpoint, "unused");
let access = svc.ensure_access_token(crate::progress::noop_auth()).unwrap();
let access = svc
.ensure_access_token(crate::progress::noop_auth())
.unwrap();
assert_eq!(access, "FRESH");
}
}
+6 -2
View File
@@ -46,13 +46,17 @@ fn hash_parts(parts: &[&str]) -> String {
/// Stable directory for an .ifcfed; re-downloads overwrite in place so the
/// viewer's open path remains valid across sync operations.
pub fn ifcfed_dir(project_id: &str, item_id: &str) -> PathBuf {
cache_root().join("ifcfeds").join(hash_parts(&[project_id, item_id]))
cache_root()
.join("ifcfeds")
.join(hash_parts(&[project_id, item_id]))
}
/// Per-version directory for a model. A new resolved version → a new
/// directory, so sidecars regenerate when the model file changes.
pub fn model_dir(project_id: &str, item_id: &str, version_id: &str) -> PathBuf {
cache_root().join("models").join(hash_parts(&[project_id, item_id, version_id]))
cache_root()
.join("models")
.join(hash_parts(&[project_id, item_id, version_id]))
}
/// Clear the directory so the file we write is the only child.
+234 -93
View File
@@ -83,9 +83,17 @@ pub struct AutodeskConnector {
inner: RefCell<Option<ClientPair>>,
}
impl Default for AutodeskConnector {
fn default() -> Self {
Self::new()
}
}
impl AutodeskConnector {
pub fn new() -> Self {
let me = Self { inner: RefCell::new(None) };
let me = Self {
inner: RefCell::new(None),
};
me.reload_credentials();
me
}
@@ -144,7 +152,7 @@ impl AutodeskConnector {
// SAFETY: SettingsDialog::run blocks the calling thread, and the
// callback fires only on this thread before run returns. The pointer
// outlives the call because `self` is borrowed by the RPC dispatch.
let on_reload: Arc<dyn Fn() -> Result<(), RpcError>> = Arc::new(move || {
let on_reload: Rc<dyn Fn() -> Result<(), RpcError>> = Rc::new(move || {
let cell: &RefCell<Option<ClientPair>> = unsafe { &*inner_ptr };
let client_id = cfg::load_client_id();
if client_id.is_empty() {
@@ -181,7 +189,14 @@ impl AutodeskConnector {
let display_name = entry.display_name.clone();
let aps = pair.aps.clone();
let path = run_with_progress("Downloading project", move |report| {
download_ifcfed(&aps, &hub_id, &project_id, &item_id, &display_name, Some(download_callback(report, 0, 0)))
download_ifcfed(
&aps,
&hub_id,
&project_id,
&item_id,
&display_name,
Some(download_callback(report, 0, 0)),
)
})?;
Ok(json!({"path": path.to_string_lossy()}))
}
@@ -190,13 +205,19 @@ impl AutodeskConnector {
fn pull_ifcfed(&self, params: Value) -> Result<Value, RpcError> {
let pair = self.require()?;
let p: PullIfcfedParams = serde_json::from_value(params).map_err(|e| {
RpcError::invalid_params(format!("pull_ifcfed: {e}"))
})?;
let p: PullIfcfedParams = serde_json::from_value(params)
.map_err(|e| RpcError::invalid_params(format!("pull_ifcfed: {e}")))?;
let display = p.display_name.unwrap_or_else(|| p.item_id.clone());
let aps = pair.aps.clone();
let path = run_with_progress("Downloading project", move |report| {
download_ifcfed(&aps, &p.hub_id, &p.project_id, &p.item_id, &display, Some(download_callback(report, 0, 0)))
download_ifcfed(
&aps,
&p.hub_id,
&p.project_id,
&p.item_id,
&display,
Some(download_callback(report, 0, 0)),
)
})?;
Ok(json!({"path": path.to_string_lossy()}))
}
@@ -205,9 +226,8 @@ impl AutodeskConnector {
fn pull_models(&self, params: Value) -> Result<Value, RpcError> {
let pair = self.require()?;
let entries: Vec<PullModelEntry> = serde_json::from_value(params).map_err(|e| {
RpcError::invalid_params(format!("pull_models: {e}"))
})?;
let entries: Vec<PullModelEntry> = serde_json::from_value(params)
.map_err(|e| RpcError::invalid_params(format!("pull_models: {e}")))?;
let total = entries.len();
let aps = pair.aps.clone();
let results = run_with_progress("Downloading models", move |report| {
@@ -241,7 +261,14 @@ impl AutodeskConnector {
let mut out: Vec<Value> = Vec::new();
for (index, entry) in chosen.entries.into_iter().enumerate() {
let cb = download_callback(report.clone(), index + 1, total);
match download_picked_model(&aps, &hub.id, &project.id, &entry.id, &entry.display_name, Some(cb)) {
match download_picked_model(
&aps,
&hub.id,
&project.id,
&entry.id,
&entry.display_name,
Some(cb),
) {
Ok(Some(v)) => out.push(v),
Ok(None) => {}
Err(e) => eprintln!("pull_models_interactive[{index}] skipped: {}", e.message),
@@ -256,16 +283,18 @@ impl AutodeskConnector {
fn push_ifcfed_interactive(&self, params: Value) -> Result<Value, RpcError> {
let pair = self.require()?;
let p: PushInteractiveParams = serde_json::from_value(params).map_err(|e| {
RpcError::invalid_params(format!("push_ifcfed_interactive: {e}"))
})?;
let p: PushInteractiveParams = serde_json::from_value(params)
.map_err(|e| RpcError::invalid_params(format!("push_ifcfed_interactive: {e}")))?;
check_local_exists(&p.path)?;
let default_name = file_name_of(&p.path);
if !default_name.to_lowercase().ends_with(".ifcfed") {
return Err(RpcError::invalid_params("push_ifcfed_interactive expects an .ifcfed file."));
return Err(RpcError::invalid_params(
"push_ifcfed_interactive expects an .ifcfed file.",
));
}
let chosen = BrowseDialog::new(pair.auth.clone(), pair.aps.clone(), Mode::Destination).run()?;
let chosen =
BrowseDialog::new(pair.auth.clone(), pair.aps.clone(), Mode::Destination).run()?;
let folder = chosen
.entries
.first()
@@ -274,7 +303,11 @@ impl AutodeskConnector {
let raw = prompt_for_filename("Save Project", "Save .ifcfed as:", &default_name)
.ok_or_else(|| RpcError::internal("User cancelled save to cloud."))?;
let file_name = if raw.to_lowercase().ends_with(".ifcfed") { raw } else { format!("{raw}.ifcfed") };
let file_name = if raw.to_lowercase().ends_with(".ifcfed") {
raw
} else {
format!("{raw}.ifcfed")
};
let project_id = chosen.project.id.clone();
let folder_id = folder.id.clone();
@@ -282,17 +315,28 @@ impl AutodeskConnector {
let local_path = p.path.clone();
let upload_name = file_name.clone();
let uploaded = run_with_progress("Uploading project", move |report| {
aps.upload_file_to_folder(&project_id, &folder_id, &local_path, Some(&upload_name), Some(upload_callback(report)))
aps.upload_file_to_folder(
&project_id,
&folder_id,
&local_path,
Some(&upload_name),
Some(upload_callback(report)),
)
})?;
let cached_path = cache_ifcfed_locally(&chosen.project.id, &uploaded.item_id, &file_name, &p.path)?;
cache::write_manifest(&cached_path, &Manifest {
connector: CONNECTOR_ID.into(),
hub_id: chosen.hub.id,
project_id: chosen.project.id,
item_id: uploaded.item_id,
display_name: file_name,
}).map_err(|e| RpcError::internal(format!("Manifest write failed: {e}")))?;
let cached_path =
cache_ifcfed_locally(&chosen.project.id, &uploaded.item_id, &file_name, &p.path)?;
cache::write_manifest(
&cached_path,
&Manifest {
connector: CONNECTOR_ID.into(),
hub_id: chosen.hub.id,
project_id: chosen.project.id,
item_id: uploaded.item_id,
display_name: file_name,
},
)
.map_err(|e| RpcError::internal(format!("Manifest write failed: {e}")))?;
Ok(json!({"path": cached_path.to_string_lossy()}))
}
@@ -300,23 +344,30 @@ impl AutodeskConnector {
fn push_ifcfed(&self, params: Value) -> Result<Value, RpcError> {
let pair = self.require()?;
let p: PushIfcfedParams = serde_json::from_value(params).map_err(|e| {
RpcError::invalid_params(format!("push_ifcfed: {e}"))
})?;
let p: PushIfcfedParams = serde_json::from_value(params)
.map_err(|e| RpcError::invalid_params(format!("push_ifcfed: {e}")))?;
check_local_exists(&p.path)?;
if !file_name_of(&p.path).to_lowercase().ends_with(".ifcfed") {
return Err(RpcError::invalid_params("push_ifcfed expects an .ifcfed file."));
return Err(RpcError::invalid_params(
"push_ifcfed expects an .ifcfed file.",
));
}
if p.manifest.connector != CONNECTOR_ID {
return Err(RpcError::invalid_params(format!("Manifest connector is not '{CONNECTOR_ID}'.")));
return Err(RpcError::invalid_params(format!(
"Manifest connector is not '{CONNECTOR_ID}'."
)));
}
let item = pair.aps.get_item(&p.manifest.project_id, &p.manifest.item_id)?;
let item = pair
.aps
.get_item(&p.manifest.project_id, &p.manifest.item_id)?;
ensure_visible(&item)?;
let folder_id = item
.parent_folder_id
.clone()
.ok_or_else(|| RpcError::internal(format!("Cannot resolve parent folder for item '{}'.", p.manifest.item_id)))?;
let folder_id = item.parent_folder_id.clone().ok_or_else(|| {
RpcError::internal(format!(
"Cannot resolve parent folder for item '{}'.",
p.manifest.item_id
))
})?;
let file_name = if p.manifest.display_name.is_empty() {
item.display_name.clone()
} else {
@@ -328,17 +379,32 @@ impl AutodeskConnector {
let project_id = p.manifest.project_id.clone();
let upload_name = file_name.clone();
let uploaded = run_with_progress("Uploading project", move |report| {
aps.upload_file_to_folder(&project_id, &folder_id, &local_path, Some(&upload_name), Some(upload_callback(report)))
aps.upload_file_to_folder(
&project_id,
&folder_id,
&local_path,
Some(&upload_name),
Some(upload_callback(report)),
)
})?;
let cached_path = cache_ifcfed_locally(&p.manifest.project_id, &uploaded.item_id, &file_name, &p.path)?;
cache::write_manifest(&cached_path, &Manifest {
connector: CONNECTOR_ID.into(),
hub_id: p.manifest.hub_id,
project_id: p.manifest.project_id,
item_id: uploaded.item_id,
display_name: file_name,
}).map_err(|e| RpcError::internal(format!("Manifest write failed: {e}")))?;
let cached_path = cache_ifcfed_locally(
&p.manifest.project_id,
&uploaded.item_id,
&file_name,
&p.path,
)?;
cache::write_manifest(
&cached_path,
&Manifest {
connector: CONNECTOR_ID.into(),
hub_id: p.manifest.hub_id,
project_id: p.manifest.project_id,
item_id: uploaded.item_id,
display_name: file_name,
},
)
.map_err(|e| RpcError::internal(format!("Manifest write failed: {e}")))?;
Ok(json!({"path": cached_path.to_string_lossy()}))
}
@@ -346,12 +412,12 @@ impl AutodeskConnector {
fn push_model_interactive(&self, params: Value) -> Result<Value, RpcError> {
let pair = self.require()?;
let p: PushInteractiveParams = serde_json::from_value(params).map_err(|e| {
RpcError::invalid_params(format!("push_model_interactive: {e}"))
})?;
let p: PushInteractiveParams = serde_json::from_value(params)
.map_err(|e| RpcError::invalid_params(format!("push_model_interactive: {e}")))?;
check_local_exists(&p.path)?;
let chosen = BrowseDialog::new(pair.auth.clone(), pair.aps.clone(), Mode::Destination).run()?;
let chosen =
BrowseDialog::new(pair.auth.clone(), pair.aps.clone(), Mode::Destination).run()?;
let folder = chosen
.entries
.first()
@@ -368,10 +434,22 @@ impl AutodeskConnector {
let local_path = p.path.clone();
let upload_name = file_name.clone();
let uploaded = run_with_progress("Uploading model", move |report| {
aps.upload_file_to_folder(&project_id, &folder_id, &local_path, Some(&upload_name), Some(upload_callback(report)))
aps.upload_file_to_folder(
&project_id,
&folder_id,
&local_path,
Some(&upload_name),
Some(upload_callback(report)),
)
})?;
let cached_path = cache_model_locally(&chosen.project.id, &uploaded.item_id, &uploaded.version_id, &file_name, &p.path)?;
let cached_path = cache_model_locally(
&chosen.project.id,
&uploaded.item_id,
&uploaded.version_id,
&file_name,
&p.path,
)?;
Ok(json!({
"display_name": file_name,
"path": cached_path.to_string_lossy(),
@@ -389,31 +467,50 @@ impl AutodeskConnector {
fn push_model(&self, params: Value) -> Result<Value, RpcError> {
let pair = self.require()?;
let p: PushModelParams = serde_json::from_value(params).map_err(|e| {
RpcError::invalid_params(format!("push_model: {e}"))
})?;
let p: PushModelParams = serde_json::from_value(params)
.map_err(|e| RpcError::invalid_params(format!("push_model: {e}")))?;
check_local_exists(&p.path)?;
if p.source.connector != CONNECTOR_ID {
return Err(RpcError::invalid_params(format!("Source connector is not '{CONNECTOR_ID}'.")));
return Err(RpcError::invalid_params(format!(
"Source connector is not '{CONNECTOR_ID}'."
)));
}
let item = pair.aps.get_item(&p.source.project_id, &p.source.item_id)?;
ensure_visible(&item)?;
let folder_id = item
.parent_folder_id
.clone()
.ok_or_else(|| RpcError::internal(format!("Cannot resolve parent folder for item '{}'.", p.source.item_id)))?;
let file_name = if item.display_name.is_empty() { file_name_of(&p.path) } else { item.display_name.clone() };
let folder_id = item.parent_folder_id.clone().ok_or_else(|| {
RpcError::internal(format!(
"Cannot resolve parent folder for item '{}'.",
p.source.item_id
))
})?;
let file_name = if item.display_name.is_empty() {
file_name_of(&p.path)
} else {
item.display_name.clone()
};
let aps = pair.aps.clone();
let local_path = p.path.clone();
let project_id = p.source.project_id.clone();
let upload_name = file_name.clone();
let uploaded = run_with_progress("Uploading model", move |report| {
aps.upload_file_to_folder(&project_id, &folder_id, &local_path, Some(&upload_name), Some(upload_callback(report)))
aps.upload_file_to_folder(
&project_id,
&folder_id,
&local_path,
Some(&upload_name),
Some(upload_callback(report)),
)
})?;
let _cached_path = cache_model_locally(&p.source.project_id, &uploaded.item_id, &uploaded.version_id, &file_name, &p.path)?;
let _cached_path = cache_model_locally(
&p.source.project_id,
&uploaded.item_id,
&uploaded.version_id,
&file_name,
&p.path,
)?;
Ok(json!({
"source": Source {
connector: CONNECTOR_ID.into(),
@@ -438,10 +535,11 @@ fn download_ifcfed(
) -> Result<PathBuf, RpcError> {
let item = aps.get_item(project_id, item_id)?;
ensure_visible(&item)?;
let storage_id = item
.storage_id
.clone()
.ok_or_else(|| RpcError::internal(format!("Autodesk item '{item_id}' has no downloadable storage.")))?;
let storage_id = item.storage_id.clone().ok_or_else(|| {
RpcError::internal(format!(
"Autodesk item '{item_id}' has no downloadable storage."
))
})?;
let file_name = if !item.display_name.is_empty() {
item.display_name.clone()
} else if !display_name_hint.is_empty() {
@@ -450,19 +548,25 @@ fn download_ifcfed(
item_id.to_string()
};
if !file_name.to_lowercase().ends_with(".ifcfed") {
return Err(RpcError::internal(format!("Item '{file_name}' is not an .ifcfed file.")));
return Err(RpcError::internal(format!(
"Item '{file_name}' is not an .ifcfed file."
)));
}
let directory = cache::prepare_sole_child_dir(&cache::ifcfed_dir(project_id, item_id))
.map_err(|e| RpcError::internal(format!("Cache dir prep failed: {e}")))?;
let ifcfed_path = directory.join(&file_name);
aps.download_storage_to_file(&storage_id, &ifcfed_path, progress)?;
cache::write_manifest(&ifcfed_path, &Manifest {
connector: CONNECTOR_ID.into(),
hub_id: hub_id.into(),
project_id: project_id.into(),
item_id: item_id.into(),
display_name: file_name,
}).map_err(|e| RpcError::internal(format!("Manifest write failed: {e}")))?;
cache::write_manifest(
&ifcfed_path,
&Manifest {
connector: CONNECTOR_ID.into(),
hub_id: hub_id.into(),
project_id: project_id.into(),
item_id: item_id.into(),
display_name: file_name,
},
)
.map_err(|e| RpcError::internal(format!("Manifest write failed: {e}")))?;
Ok(ifcfed_path)
}
@@ -472,7 +576,9 @@ fn resolve_scripted_model(
progress: Option<crate::progress::ApsProgress>,
) -> Result<Option<Value>, RpcError> {
if entry.source.connector != CONNECTOR_ID {
return Err(RpcError::invalid_params(format!("Source connector is not '{CONNECTOR_ID}'.")));
return Err(RpcError::invalid_params(format!(
"Source connector is not '{CONNECTOR_ID}'."
)));
}
let display_hint = entry
.display_name
@@ -481,14 +587,23 @@ fn resolve_scripted_model(
let item = aps.get_item(&entry.source.project_id, &entry.source.item_id)?;
if item.hidden {
eprintln!("Autodesk item '{}' is hidden/deleted; returning null.", entry.source.item_id);
eprintln!(
"Autodesk item '{}' is hidden/deleted; returning null.",
entry.source.item_id
);
return Ok(None);
}
let storage_id = item
.storage_id
.clone()
.ok_or_else(|| RpcError::internal(format!("Autodesk item '{}' has no downloadable storage.", entry.source.item_id)))?;
let file_name = if item.display_name.is_empty() { display_hint } else { item.display_name.clone() };
let storage_id = item.storage_id.clone().ok_or_else(|| {
RpcError::internal(format!(
"Autodesk item '{}' has no downloadable storage.",
entry.source.item_id
))
})?;
let file_name = if item.display_name.is_empty() {
display_hint
} else {
item.display_name.clone()
};
let version_id = item.version_id.clone().unwrap_or_default();
let directory = cache::model_dir(&entry.source.project_id, &entry.source.item_id, &version_id);
@@ -514,10 +629,11 @@ fn download_picked_model(
) -> Result<Option<Value>, RpcError> {
let item = aps.get_item(project_id, entry_id)?;
ensure_visible(&item)?;
let storage_id = item
.storage_id
.clone()
.ok_or_else(|| RpcError::internal(format!("Autodesk item '{entry_id}' has no downloadable storage.")))?;
let storage_id = item.storage_id.clone().ok_or_else(|| {
RpcError::internal(format!(
"Autodesk item '{entry_id}' has no downloadable storage."
))
})?;
let file_name = if !item.display_name.is_empty() {
item.display_name.clone()
} else if !entry_display.is_empty() {
@@ -548,20 +664,33 @@ fn download_picked_model(
})))
}
fn cache_ifcfed_locally(project_id: &str, item_id: &str, file_name: &str, src: &Path) -> Result<PathBuf, RpcError> {
fn cache_ifcfed_locally(
project_id: &str,
item_id: &str,
file_name: &str,
src: &Path,
) -> Result<PathBuf, RpcError> {
let directory = cache::prepare_sole_child_dir(&cache::ifcfed_dir(project_id, item_id))
.map_err(|e| RpcError::internal(format!("Cache dir prep failed: {e}")))?;
let cached_path = directory.join(file_name);
std::fs::copy(src, &cached_path).map_err(|e| RpcError::internal(format!("Cache copy failed: {e}")))?;
std::fs::copy(src, &cached_path)
.map_err(|e| RpcError::internal(format!("Cache copy failed: {e}")))?;
Ok(cached_path)
}
fn cache_model_locally(project_id: &str, item_id: &str, version_id: &str, file_name: &str, src: &Path) -> Result<PathBuf, RpcError> {
fn cache_model_locally(
project_id: &str,
item_id: &str,
version_id: &str,
file_name: &str,
src: &Path,
) -> Result<PathBuf, RpcError> {
let directory = cache::model_dir(project_id, item_id, version_id);
cache::prepare_sole_child_dir(&directory)
.map_err(|e| RpcError::internal(format!("Cache dir prep failed: {e}")))?;
let cached_path = directory.join(file_name);
std::fs::copy(src, &cached_path).map_err(|e| RpcError::internal(format!("Cache copy failed: {e}")))?;
std::fs::copy(src, &cached_path)
.map_err(|e| RpcError::internal(format!("Cache copy failed: {e}")))?;
Ok(cached_path)
}
@@ -569,7 +698,10 @@ fn build_metadata_from_item(item: &ItemTip) -> Value {
let mut metadata = serde_json::Map::new();
if let Some(v) = &item.version_number {
if !v.is_null() {
metadata.insert("revision".into(), json!(format!("v{}", json_value_to_display(v))));
metadata.insert(
"revision".into(),
json!(format!("v{}", json_value_to_display(v))),
);
}
}
if let Some(s) = &item.last_modified_time_utc {
@@ -589,7 +721,10 @@ fn build_metadata_from_upload(uploaded: &UploadResult) -> Value {
let mut metadata = serde_json::Map::new();
if let Some(v) = &uploaded.version_number {
if !v.is_null() {
metadata.insert("revision".into(), json!(format!("v{}", json_value_to_display(v))));
metadata.insert(
"revision".into(),
json!(format!("v{}", json_value_to_display(v))),
);
}
}
if let Some(s) = &uploaded.last_modified_time_utc {
@@ -616,14 +751,20 @@ fn json_value_to_display(v: &Value) -> String {
fn ensure_visible(item: &ItemTip) -> Result<(), RpcError> {
if item.hidden {
return Err(RpcError::internal(format!("Autodesk item '{}' has been deleted.", item.id)));
return Err(RpcError::internal(format!(
"Autodesk item '{}' has been deleted.",
item.id
)));
}
Ok(())
}
fn check_local_exists(path: &Path) -> Result<(), RpcError> {
if !path.exists() {
return Err(RpcError::invalid_params(format!("Local file '{}' does not exist.", path.display())));
return Err(RpcError::invalid_params(format!(
"Local file '{}' does not exist.",
path.display()
)));
}
Ok(())
}
+35 -8
View File
@@ -38,7 +38,9 @@ pub fn progress_detail(percent: Option<i32>, done: Option<u64>, total: Option<u6
parts.push(format!("{p}%"));
}
match (done, total) {
(Some(d), Some(t)) if t > 0 => parts.push(format!("{} / {}", format_bytes(d), format_bytes(t))),
(Some(d), Some(t)) if t > 0 => {
parts.push(format!("{} / {}", format_bytes(d), format_bytes(t)))
}
(Some(d), _) => parts.push(format_bytes(d)),
_ => {}
}
@@ -49,10 +51,18 @@ pub fn progress_detail(percent: Option<i32>, done: Option<u64>, total: Option<u6
/// "(i/N)" when batching. Pass `total = 0` for single-file transfers.
pub fn download_callback(report: Report, index: usize, total: usize) -> ApsProgress {
Arc::new(move |name, percent, done, total_bytes| {
let suffix = if total != 0 { format!(" ({index}/{total})") } else { String::new() };
let suffix = if total != 0 {
format!(" ({index}/{total})")
} else {
String::new()
};
let detail = progress_detail(percent, done, total_bytes);
let msg = format!("Downloading {name}{suffix}");
let detail_ref = if detail.is_empty() { None } else { Some(detail.as_str()) };
let detail_ref = if detail.is_empty() {
None
} else {
Some(detail.as_str())
};
report("download", &msg, percent, detail_ref);
})
}
@@ -61,7 +71,11 @@ pub fn upload_callback(report: Report) -> ApsProgress {
Arc::new(move |name, percent, done, total_bytes| {
let detail = progress_detail(percent, done, total_bytes);
let msg = format!("Uploading {name}");
let detail_ref = if detail.is_empty() { None } else { Some(detail.as_str()) };
let detail_ref = if detail.is_empty() {
None
} else {
Some(detail.as_str())
};
report("upload", &msg, percent, detail_ref);
})
}
@@ -73,21 +87,31 @@ pub fn auth_to_report(report: Report) -> AuthProgress {
})
}
/// A single coalesced report: `(phase, message, percent, detail)`.
type PendingReport = (String, String, Option<i32>, Option<String>);
/// Latest-wins hand-off from worker thread → UI thread. Intermediate updates
/// are coalesced: only the freshest matters for a progress bar.
pub struct ProgressBridge {
pending: Mutex<Option<(String, String, Option<i32>, Option<String>)>>,
pending: Mutex<Option<PendingReport>>,
}
impl ProgressBridge {
pub fn new() -> Arc<Self> {
Arc::new(Self { pending: Mutex::new(None) })
Arc::new(Self {
pending: Mutex::new(None),
})
}
pub fn report_fn(self: Arc<Self>) -> Report {
Arc::new(move |phase, message, percent, detail| {
let mut slot = self.pending.lock().unwrap();
*slot = Some((phase.to_string(), message.to_string(), percent, detail.map(str::to_string)));
*slot = Some((
phase.to_string(),
message.to_string(),
percent,
detail.map(str::to_string),
));
})
}
@@ -110,7 +134,10 @@ mod tests {
#[test]
fn progress_detail_combines_parts() {
assert_eq!(progress_detail(Some(50), Some(512), Some(1024)), "50%, 512 B / 1.0 KB");
assert_eq!(
progress_detail(Some(50), Some(512), Some(1024)),
"50%, 512 B / 1.0 KB"
);
assert_eq!(progress_detail(None, Some(512), None), "512 B");
assert_eq!(progress_detail(None, None, None), "");
}
+71 -15
View File
@@ -18,7 +18,11 @@ pub struct RpcError {
impl RpcError {
pub fn new(code: i32, message: impl Into<String>) -> Self {
Self { code, message: message.into(), data: None }
Self {
code,
message: message.into(),
data: None,
}
}
pub fn internal(message: impl Into<String>) -> Self {
@@ -48,7 +52,11 @@ pub struct JsonRpcHost<R: Read, W: Write> {
impl<R: Read, W: Write> JsonRpcHost<R, W> {
pub fn new(handlers: HashMap<String, Handler>, stdin: R, stdout: W) -> Self {
Self { handlers, stdin: BufReader::new(stdin), stdout }
Self {
handlers,
stdin: BufReader::new(stdin),
stdout,
}
}
pub fn run(&mut self) -> i32 {
@@ -72,7 +80,12 @@ impl<R: Read, W: Write> JsonRpcHost<R, W> {
let message: Value = match serde_json::from_str(line) {
Ok(v) => v,
Err(e) => {
self.respond_error(Value::Null, JSONRPC_PARSE_ERROR, &format!("Parse error: {e}"), None);
self.respond_error(
Value::Null,
JSONRPC_PARSE_ERROR,
&format!("Parse error: {e}"),
None,
);
return;
}
};
@@ -80,7 +93,12 @@ impl<R: Read, W: Write> JsonRpcHost<R, W> {
let obj = match message.as_object() {
Some(o) => o,
None => {
self.respond_error(Value::Null, JSONRPC_INVALID_REQUEST, "Request must be a JSON object", None);
self.respond_error(
Value::Null,
JSONRPC_INVALID_REQUEST,
"Request must be a JSON object",
None,
);
return;
}
};
@@ -88,28 +106,48 @@ impl<R: Read, W: Write> JsonRpcHost<R, W> {
let message_id = obj.get("id").cloned().unwrap_or(Value::Null);
if obj.get("jsonrpc").and_then(|v| v.as_str()) != Some("2.0") {
self.respond_error(message_id, JSONRPC_INVALID_REQUEST, "Missing or wrong 'jsonrpc' version", None);
self.respond_error(
message_id,
JSONRPC_INVALID_REQUEST,
"Missing or wrong 'jsonrpc' version",
None,
);
return;
}
let method = match obj.get("method").and_then(|v| v.as_str()) {
Some(m) => m.to_string(),
None => {
self.respond_error(message_id, JSONRPC_INVALID_REQUEST, "Missing 'method' string", None);
self.respond_error(
message_id,
JSONRPC_INVALID_REQUEST,
"Missing 'method' string",
None,
);
return;
}
};
let params = obj.get("params").cloned().unwrap_or(Value::Null);
if !params.is_null() && !params.is_object() && !params.is_array() {
self.respond_error(message_id, JSONRPC_INVALID_PARAMS, "'params' must be a JSON object or array", None);
self.respond_error(
message_id,
JSONRPC_INVALID_PARAMS,
"'params' must be a JSON object or array",
None,
);
return;
}
let outcome = match self.handlers.get(&method) {
Some(handler) => handler(params),
None => {
self.respond_error(message_id, JSONRPC_METHOD_NOT_FOUND, &format!("Unknown method '{method}'"), None);
self.respond_error(
message_id,
JSONRPC_METHOD_NOT_FOUND,
&format!("Unknown method '{method}'"),
None,
);
return;
}
};
@@ -168,15 +206,21 @@ mod tests {
fn echo_handlers() -> HashMap<String, Handler> {
let mut m: HashMap<String, Handler> = HashMap::new();
m.insert("echo".into(), Box::new(|p| Ok(p)));
m.insert("echo".into(), Box::new(Ok));
m.insert("boom".into(), Box::new(|_| Err(RpcError::internal("bang"))));
m.insert("bad_params".into(), Box::new(|_| Err(RpcError::invalid_params("nope"))));
m.insert(
"bad_params".into(),
Box::new(|_| Err(RpcError::invalid_params("nope"))),
);
m
}
#[test]
fn dispatches_result() {
let out = run_once(echo_handlers(), "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"echo\",\"params\":{\"x\":1}}\n");
let out = run_once(
echo_handlers(),
"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"echo\",\"params\":{\"x\":1}}\n",
);
assert_eq!(out.len(), 1);
assert_eq!(out[0]["result"], json!({"x": 1}));
assert_eq!(out[0]["id"], json!(1));
@@ -184,13 +228,19 @@ mod tests {
#[test]
fn notification_no_response() {
let out = run_once(echo_handlers(), "{\"jsonrpc\":\"2.0\",\"method\":\"echo\",\"params\":{}}\n");
let out = run_once(
echo_handlers(),
"{\"jsonrpc\":\"2.0\",\"method\":\"echo\",\"params\":{}}\n",
);
assert!(out.is_empty());
}
#[test]
fn unknown_method() {
let out = run_once(echo_handlers(), "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"nope\"}\n");
let out = run_once(
echo_handlers(),
"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"nope\"}\n",
);
assert_eq!(out[0]["error"]["code"], json!(JSONRPC_METHOD_NOT_FOUND));
}
@@ -209,13 +259,19 @@ mod tests {
#[test]
fn invalid_params_shape() {
let out = run_once(echo_handlers(), "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"echo\",\"params\":42}\n");
let out = run_once(
echo_handlers(),
"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"echo\",\"params\":42}\n",
);
assert_eq!(out[0]["error"]["code"], json!(JSONRPC_INVALID_PARAMS));
}
#[test]
fn handler_error_passes_code() {
let out = run_once(echo_handlers(), "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"bad_params\"}\n");
let out = run_once(
echo_handlers(),
"{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"bad_params\"}\n",
);
assert_eq!(out[0]["error"]["code"], json!(JSONRPC_INVALID_PARAMS));
assert_eq!(out[0]["error"]["message"], json!("nope"));
}
+68 -36
View File
@@ -64,10 +64,10 @@ const PLACEHOLDER_LABEL: &str = "Loading…";
/// cannot touch FLTK widgets.
#[derive(Clone, Debug)]
enum WorkerMsg {
HubsLoaded(Result<Vec<Hub>, RpcError>),
ProjectsLoaded(Result<Vec<Project>, RpcError>),
TopFoldersLoaded(Result<Vec<Entry>, RpcError>),
FolderContentsLoaded(String, Result<Vec<Entry>, RpcError>),
Hubs(Result<Vec<Hub>, RpcError>),
Projects(Result<Vec<Project>, RpcError>),
TopFolders(Result<Vec<Entry>, RpcError>),
FolderContents(String, Result<Vec<Entry>, RpcError>),
}
struct State {
@@ -198,7 +198,6 @@ impl BrowseDialog {
sign_in.set_callback({
let aps = aps.clone();
let auth = auth.clone();
let worker_tx = worker_tx;
let mut status = status_mut.clone();
move |_| {
status.set_label("Signing in to Autodesk…");
@@ -208,7 +207,7 @@ impl BrowseDialog {
let outcome = auth
.login_interactive(noop_auth())
.and_then(|_| aps.list_hubs());
worker_tx.send(WorkerMsg::HubsLoaded(outcome));
worker_tx.send(WorkerMsg::Hubs(outcome));
});
}
});
@@ -216,7 +215,6 @@ impl BrowseDialog {
hub_combo.set_callback({
let aps = aps.clone();
let state = state.clone();
let worker_tx = worker_tx;
let mut projects_browser = projects_browser.clone();
let mut tree = tree.clone();
let mut status = status_mut.clone();
@@ -247,7 +245,7 @@ impl BrowseDialog {
status.set_label(&format!("Loading projects in {hub_name}"));
let aps = aps.clone();
thread::spawn(move || {
worker_tx.send(WorkerMsg::ProjectsLoaded(aps.list_projects(&hub_id)));
worker_tx.send(WorkerMsg::Projects(aps.list_projects(&hub_id)));
});
}
});
@@ -255,7 +253,6 @@ impl BrowseDialog {
projects_browser.clone().set_callback({
let aps = aps.clone();
let state = state.clone();
let worker_tx = worker_tx;
let mut tree = tree.clone();
let mut status = status_mut.clone();
let action_btn_clone = action_btn.clone();
@@ -286,8 +283,9 @@ impl BrowseDialog {
status.set_label(&format!("Loading top folders in {project_name}"));
let aps = aps.clone();
thread::spawn(move || {
worker_tx
.send(WorkerMsg::TopFoldersLoaded(aps.list_top_folders(&hub_id, &project_id)));
worker_tx.send(WorkerMsg::TopFolders(
aps.list_top_folders(&hub_id, &project_id),
));
});
}
});
@@ -295,7 +293,6 @@ impl BrowseDialog {
tree.clone().set_callback({
let aps = aps.clone();
let state = state.clone();
let worker_tx = worker_tx;
let mut status = status_mut.clone();
let action_btn_clone = action_btn.clone();
move |t| match t.callback_reason() {
@@ -306,10 +303,16 @@ impl BrowseDialog {
refresh_action_button(&mut action_btn_clone.clone(), &s, mode);
}
TreeReason::Opened => {
let Some(item) = t.callback_item() else { return };
let Ok(path) = t.item_pathname(&item) else { return };
let Some(item) = t.callback_item() else {
return;
};
let Ok(path) = t.item_pathname(&item) else {
return;
};
let mut s = state.borrow_mut();
let Some(entry) = s.tree_entries.get(&path).cloned() else { return };
let Some(entry) = s.tree_entries.get(&path).cloned() else {
return;
};
if entry.entry_type != EntryType::Folders {
return;
}
@@ -334,8 +337,9 @@ impl BrowseDialog {
let folder_id = entry.id.clone();
let aps = aps.clone();
thread::spawn(move || {
let result = list_folder_contents_for_mode(&aps, &project_id, &folder_id, mode);
worker_tx.send(WorkerMsg::FolderContentsLoaded(path, result));
let result =
list_folder_contents_for_mode(&aps, &project_id, &folder_id, mode);
worker_tx.send(WorkerMsg::FolderContents(path, result));
});
}
_ => {}
@@ -373,7 +377,7 @@ impl BrowseDialog {
if auth.get_token().ok().flatten().is_some() {
let aps = aps.clone();
thread::spawn(move || {
worker_tx.send(WorkerMsg::HubsLoaded(aps.list_hubs()));
worker_tx.send(WorkerMsg::Hubs(aps.list_hubs()));
});
}
@@ -416,15 +420,15 @@ fn apply_worker_msg(
mode: Mode,
) {
match msg {
WorkerMsg::HubsLoaded(Err(e)) => show_error("Sign In Failed", &e.message),
WorkerMsg::HubsLoaded(Ok(hubs)) => {
WorkerMsg::Hubs(Err(e)) => show_error("Sign In Failed", &e.message),
WorkerMsg::Hubs(Ok(hubs)) => {
let mut s = state.borrow_mut();
s.hubs = hubs;
refill_hub_combo(hub_combo, &s.hubs);
status.set_label("Signed in. Select a hub.");
}
WorkerMsg::ProjectsLoaded(Err(e)) => show_error("Load Projects Failed", &e.message),
WorkerMsg::ProjectsLoaded(Ok(projects)) => {
WorkerMsg::Projects(Err(e)) => show_error("Load Projects Failed", &e.message),
WorkerMsg::Projects(Ok(projects)) => {
let mut s = state.borrow_mut();
s.projects = projects;
projects_browser.clear();
@@ -437,8 +441,8 @@ fn apply_worker_msg(
}
}
}
WorkerMsg::TopFoldersLoaded(Err(e)) => show_error("Load Project Failed", &e.message),
WorkerMsg::TopFoldersLoaded(Ok(folders)) => {
WorkerMsg::TopFolders(Err(e)) => show_error("Load Project Failed", &e.message),
WorkerMsg::TopFolders(Ok(folders)) => {
let mut s = state.borrow_mut();
for entry in folders {
insert_tree_entry(tree, &mut s.tree_entries, "", &entry);
@@ -457,11 +461,11 @@ fn apply_worker_msg(
}
}
}
WorkerMsg::FolderContentsLoaded(parent, Err(e)) => {
WorkerMsg::FolderContents(parent, Err(e)) => {
state.borrow_mut().loaded_folders.remove(&parent);
show_error("Load Folder Failed", &e.message);
}
WorkerMsg::FolderContentsLoaded(parent, Ok(children)) => {
WorkerMsg::FolderContents(parent, Ok(children)) => {
let mut s = state.borrow_mut();
for entry in &children {
insert_tree_entry(tree, &mut s.tree_entries, &parent, entry);
@@ -474,6 +478,9 @@ fn apply_worker_msg(
// ---- worker helpers -------------------------------------------------------
/// Per-mode predicate deciding which listed entries survive.
type EntryFilter = Box<dyn Fn(&Entry) -> bool>;
fn list_folder_contents_for_mode(
aps: &Arc<ApsClient>,
project_id: &str,
@@ -485,7 +492,7 @@ fn list_folder_contents_for_mode(
} else {
&["folders", "items"]
};
let filter_box: Option<Box<dyn Fn(&Entry) -> bool>> = match mode {
let filter_box: Option<EntryFilter> = match mode {
Mode::Ifcfed => Some(Box::new(|e: &Entry| {
e.display_name.to_lowercase().ends_with(".ifcfed")
})),
@@ -517,7 +524,9 @@ fn insert_tree_entry(
parent_canonical: &str,
entry: &Entry,
) -> String {
let parent_add = parent_canonical.strip_prefix('/').unwrap_or(parent_canonical);
let parent_add = parent_canonical
.strip_prefix('/')
.unwrap_or(parent_canonical);
let label = if entry.display_name.is_empty() {
entry.id.clone()
} else {
@@ -565,7 +574,9 @@ fn add_unique(tree: &mut Tree, parent_add: &str, label: &str) -> String {
}
fn collect_selected_entries(tree: &Tree, tree_entries: &HashMap<String, Entry>) -> Vec<Entry> {
let Some(items) = tree.get_selected_items() else { return Vec::new() };
let Some(items) = tree.get_selected_items() else {
return Vec::new();
};
items
.into_iter()
.filter_map(|item| tree.item_pathname(&item).ok())
@@ -586,7 +597,10 @@ fn update_status_for_selection(status: &mut Frame, entries: &[Entry], mode: Mode
status.set_label(&format!("Selected {kind}: {}", entry.display_name));
}
n => {
let valid = entries.iter().filter(|e| is_valid_selection(mode, e)).count();
let valid = entries
.iter()
.filter(|e| is_valid_selection(mode, e))
.count();
status.set_label(&format!("Selected {valid} of {n} items."));
}
}
@@ -636,11 +650,29 @@ mod tests {
#[test]
fn is_valid_selection_modes() {
assert!(is_valid_selection(Mode::Ifcfed, &entry("a.ifcfed", EntryType::Items)));
assert!(!is_valid_selection(Mode::Ifcfed, &entry("a.ifc", EntryType::Items)));
assert!(is_valid_selection(Mode::Model, &entry("a.ifc", EntryType::Items)));
assert!(is_valid_selection(Mode::Model, &entry("a.rdb", EntryType::Items)));
assert!(is_valid_selection(Mode::Destination, &entry("folder", EntryType::Folders)));
assert!(!is_valid_selection(Mode::Destination, &entry("file.ifc", EntryType::Items)));
assert!(is_valid_selection(
Mode::Ifcfed,
&entry("a.ifcfed", EntryType::Items)
));
assert!(!is_valid_selection(
Mode::Ifcfed,
&entry("a.ifc", EntryType::Items)
));
assert!(is_valid_selection(
Mode::Model,
&entry("a.ifc", EntryType::Items)
));
assert!(is_valid_selection(
Mode::Model,
&entry("a.rdb", EntryType::Items)
));
assert!(is_valid_selection(
Mode::Destination,
&entry("folder", EntryType::Folders)
));
assert!(!is_valid_selection(
Mode::Destination,
&entry("file.ifc", EntryType::Items)
));
}
}
+4 -10
View File
@@ -1,15 +1,9 @@
//! Settings dialog: APS client id + OAuth callback port + sign-out.
use std::sync::Arc;
use std::rc::Rc;
use fltk::{
app,
button::Button,
enums::Align,
frame::Frame,
group::Flex,
input::Input,
prelude::*,
app, button::Button, enums::Align, frame::Frame, group::Flex, input::Input, prelude::*,
window::Window,
};
@@ -20,11 +14,11 @@ use crate::ui::dialogs::{center_on_screen, confirm, drain_after_close, show_erro
use crate::ui::ensure_app;
pub struct SettingsDialog {
pub on_reload: Arc<dyn Fn() -> Result<(), RpcError>>,
pub on_reload: Rc<dyn Fn() -> Result<(), RpcError>>,
}
impl SettingsDialog {
pub fn new(on_reload: Arc<dyn Fn() -> Result<(), RpcError>>) -> Self {
pub fn new(on_reload: Rc<dyn Fn() -> Result<(), RpcError>>) -> Self {
Self { on_reload }
}
+72 -20
View File
@@ -57,7 +57,10 @@ fn spawn_stub(routes: Vec<(&'static str, &'static str, u16, String)>) -> StubSer
if h == "\r\n" || h == "\n" {
break;
}
if let Some(v) = h.strip_prefix("Content-Length: ").or_else(|| h.strip_prefix("content-length: ")) {
if let Some(v) = h
.strip_prefix("Content-Length: ")
.or_else(|| h.strip_prefix("content-length: "))
{
content_length = v.trim().parse().unwrap_or(0);
}
}
@@ -72,7 +75,9 @@ fn spawn_stub(routes: Vec<(&'static str, &'static str, u16, String)>) -> StubSer
let method = parts.first().copied().unwrap_or("");
let path = parts.get(1).copied().unwrap_or("/");
let idx = routes.iter().position(|(m, p, _, _)| *m == method && path.starts_with(p));
let idx = routes
.iter()
.position(|(m, p, _, _)| *m == method && path.starts_with(p));
let (status, body) = match idx {
Some(i) => {
let (_, _, status, body) = routes.remove(i);
@@ -156,9 +161,15 @@ fn list_folder_contents_filters_ifcfed() {
{"id": "i2", "type": "items", "attributes": {"displayName": "model.ifcfed"}}
]
}"#;
let server = spawn_stub(vec![("GET", "/data/v1/projects/P/folders/F/contents", 200, body.into())]);
let server = spawn_stub(vec![(
"GET",
"/data/v1/projects/P/folders/F/contents",
200,
body.into(),
)]);
let client = make_client(server.base_url.clone());
let filter = |e: &bonsaiviewer_autodesk::aps::Entry| e.display_name.to_lowercase().ends_with(".ifcfed");
let filter =
|e: &bonsaiviewer_autodesk::aps::Entry| e.display_name.to_lowercase().ends_with(".ifcfed");
let entries = client
.list_folder_contents("P", "F", &["folders", "items"], Some(&filter))
.unwrap();
@@ -193,12 +204,20 @@ fn get_item_returns_tip_metadata() {
}
}]
}"#;
let server = spawn_stub(vec![("GET", "/data/v1/projects/P/items/I", 200, body.into())]);
let server = spawn_stub(vec![(
"GET",
"/data/v1/projects/P/items/I",
200,
body.into(),
)]);
let client = make_client(server.base_url.clone());
let item = client.get_item("P", "I").unwrap();
assert!(!item.hidden);
assert_eq!(item.version_id.as_deref(), Some("V"));
assert_eq!(item.storage_id.as_deref(), Some("urn:adsk.objects:os.object:bk/obj"));
assert_eq!(
item.storage_id.as_deref(),
Some("urn:adsk.objects:os.object:bk/obj")
);
assert_eq!(item.last_modified_user_name.as_deref(), Some("alice"));
assert_eq!(item.parent_folder_id.as_deref(), Some("F"));
}
@@ -216,7 +235,12 @@ fn get_item_handles_missing_tip_as_hidden() {
}
}
}"#;
let server = spawn_stub(vec![("GET", "/data/v1/projects/P/items/I", 200, body.into())]);
let server = spawn_stub(vec![(
"GET",
"/data/v1/projects/P/items/I",
200,
body.into(),
)]);
let client = make_client(server.base_url.clone());
let item = client.get_item("P", "I").unwrap();
assert!(item.hidden);
@@ -228,10 +252,7 @@ fn get_item_handles_missing_tip_as_hidden() {
fn download_signed_then_get_writes_file() {
let tmp = tempfile::tempdir().unwrap();
let dst = tmp.path().join("out.bin");
let signed = format!(
r#"{{"url": "{base}/payload"}}"#,
base = "PLACEHOLDER"
);
let signed = format!(r#"{{"url": "{base}/payload"}}"#, base = "PLACEHOLDER");
// We need two-stage: first signed url returns a pointer into the same
// stub, then the GET against that path returns the bytes.
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
@@ -240,8 +261,18 @@ fn download_signed_then_get_writes_file() {
let signed_with_url = signed.replace("PLACEHOLDER", &base);
let payload_bytes = b"hello-bytes-12345";
let mut routes: Vec<(&'static str, &'static str, u16, String)> = vec![
("GET", "/oss/v2/buckets/bk/objects/obj/signeds3download", 200, signed_with_url),
("GET", "/payload", 200, String::from_utf8(payload_bytes.to_vec()).unwrap()),
(
"GET",
"/oss/v2/buckets/bk/objects/obj/signeds3download",
200,
signed_with_url,
),
(
"GET",
"/payload",
200,
String::from_utf8(payload_bytes.to_vec()).unwrap(),
),
];
// Hand-roll a stub on the bound listener so we can re-use the same port.
let handle = thread::spawn(move || {
@@ -262,7 +293,10 @@ fn download_signed_then_get_writes_file() {
let parts: Vec<&str> = request_line.split_whitespace().collect();
let method = parts[0];
let path = parts[1];
let idx = routes.iter().position(|(m, p, _, _)| *m == method && path.starts_with(p)).unwrap();
let idx = routes
.iter()
.position(|(m, p, _, _)| *m == method && path.starts_with(p))
.unwrap();
let (_, _, status, body) = routes.remove(idx);
let header = format!(
"HTTP/1.1 {status} OK\r\nContent-Type: application/octet-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
@@ -298,10 +332,12 @@ fn upload_small_file_creates_new_item() {
let base = format!("http://127.0.0.1:{port}");
let presigned_url = format!("{base}/presigned/0");
let put_bytes_received: Arc<std::sync::Mutex<Vec<u8>>> = Arc::new(std::sync::Mutex::new(Vec::new()));
let put_bytes_received: Arc<std::sync::Mutex<Vec<u8>>> =
Arc::new(std::sync::Mutex::new(Vec::new()));
let put_bytes_clone = put_bytes_received.clone();
let create_storage_body = r#"{"data":{"id":"urn:adsk.objects:os.object:bk/obj","type":"objects"}}"#.to_string();
let create_storage_body =
r#"{"data":{"id":"urn:adsk.objects:os.object:bk/obj","type":"objects"}}"#.to_string();
let signed_upload_body = format!(r#"{{"uploadKey":"UK1","urls":["{presigned_url}"]}}"#);
let complete_body = r#"{}"#.to_string();
let folder_contents_body = r#"{"data":[]}"#.to_string();
@@ -316,10 +352,22 @@ fn upload_small_file_creates_new_item() {
let handle = thread::spawn(move || {
let expected = vec![
("POST", "/data/v1/projects/P/storage", create_storage_body),
("GET", "/oss/v2/buckets/bk/objects/obj/signeds3upload", signed_upload_body),
(
"GET",
"/oss/v2/buckets/bk/objects/obj/signeds3upload",
signed_upload_body,
),
("PUT", "/presigned/0", String::new()),
("POST", "/oss/v2/buckets/bk/objects/obj/signeds3upload", complete_body),
("GET", "/data/v1/projects/P/folders/F/contents", folder_contents_body),
(
"POST",
"/oss/v2/buckets/bk/objects/obj/signeds3upload",
complete_body,
),
(
"GET",
"/data/v1/projects/P/folders/F/contents",
folder_contents_body,
),
("POST", "/data/v1/projects/P/items", create_item_body),
];
for (method, prefix, body) in expected {
@@ -350,7 +398,11 @@ fn upload_small_file_creates_new_item() {
}
let parts: Vec<&str> = request_line.split_whitespace().collect();
assert_eq!(parts[0], method, "request_line={request_line:?}");
assert!(parts[1].starts_with(prefix), "got {} expected prefix {prefix}", parts[1]);
assert!(
parts[1].starts_with(prefix),
"got {} expected prefix {prefix}",
parts[1]
);
let header = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
body.len()
+15 -3
View File
@@ -10,14 +10,26 @@ use serde_json::Value;
fn binary_path() -> std::path::PathBuf {
let mut path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
path.push("target");
path.push(if cfg!(debug_assertions) { "debug" } else { "release" });
path.push(if cfg!(windows) { "bonsaiviewer-autodesk.exe" } else { "bonsaiviewer-autodesk" });
path.push(if cfg!(debug_assertions) {
"debug"
} else {
"release"
});
path.push(if cfg!(windows) {
"bonsaiviewer-autodesk.exe"
} else {
"bonsaiviewer-autodesk"
});
path
}
fn exchange(requests: &[&str]) -> Vec<Value> {
let path = binary_path();
assert!(path.exists(), "expected binary at {}; build with `cargo build --bin bonsaiviewer-autodesk` first", path.display());
assert!(
path.exists(),
"expected binary at {}; build with `cargo build --bin bonsaiviewer-autodesk` first",
path.display()
);
let mut child = Command::new(&path)
.stdin(Stdio::piped())
.stdout(Stdio::piped())