Files
yaak-mountain-loop/crates/yaak/src/import.rs
T
Gregory SchierandClaude Opus 5 bd932ce85f feat(import): remember the user's import selection
Yaak now remembers, per item of a linked import source, whether the user
wants it. A mapping row that still points at a model means wanted, so
re-imports merge into it; a row with no model behind it means not wanted,
so re-imports leave it alone. Wanted-ness is structural rather than a flag:
deleting the model locally, or turning the item down in the preview, is
what makes it not wanted.

- import_source_resources drops `snapshot` for a nullable `content_hash`
  and lets `model_id` be NULL. The migration keeps every beta row's
  key to model mapping and starts hashes empty; until a hash is recorded,
  a difference can't be attributed to either side, so the item is offered
  once as a conflict that keeps local changes.
- comparable() ignores sortPriority. Importers number it from source
  order, so inserting one operation used to plan a fake update for
  everything after it. The trade is that pure reorders don't propagate.
- Keys the user turned down come back as unchecked "not imported" rows
  instead of being re-offered as new resources or resurrected. Checking
  one imports it under the same source key and pulls in the folders it
  needs.
- An imported resource the source moves into a folder that isn't imported
  is offered as a deletion, with a tooltip pointing at the folder.
- Plan-time source resolution binds by source-key overlap instead of by
  path, so a renamed or moved file merges into what it created and heals
  the stored origin. Several sources sharing keys never guess-merge: the
  plan says so and imports everything as new.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 10:56:34 -07:00

2672 lines
106 KiB
Rust

use crate::Result;
use chrono::Utc;
use log::info;
use serde_json::Value;
use sha2::{Digest, Sha256};
use std::collections::{BTreeMap, BTreeSet};
use yaak_models::client_db::ClientDb;
use yaak_models::models::{
AnyModel, DEFAULT_REQUEST_MESSAGE_SIZE, Environment, Folder, GrpcRequest, HttpRequest,
ImportSource, ImportSourceResource, UpsertModelInfo, WebsocketRequest, Workspace,
};
use yaak_models::query_manager::QueryManager;
use yaak_models::util::{
BatchUpsertResult, ImportConflictResolution, ImportDestination, ImportOrigin, ImportPlan,
ImportPlanAction, ImportPlanItem, ImportPlanReason, ImportPlanWarning, ImportResourceType,
UpdateSource,
};
use yaak_plugins::events::{ImportResources, PluginContext};
use yaak_plugins::manager::PluginManager;
pub struct PlanImportDataParams<'a> {
pub query_manager: &'a QueryManager,
pub plugin_manager: &'a PluginManager,
pub plugin_context: &'a PluginContext,
pub destination: ImportDestination,
pub contents: &'a str,
pub origin: Option<ImportOrigin>,
}
/// Parse importer output and turn it into a commit-ready plan without mutating the database.
pub async fn plan_import_data(params: PlanImportDataParams<'_>) -> Result<ImportPlan> {
let import_result =
params.plugin_manager.import_data(params.plugin_context, params.contents).await?;
plan_import_resources(
params.query_manager,
import_result.importer,
params.destination,
import_result.resources,
import_result.source_keys,
params.origin,
)
}
/// Remap parsed importer resources into their selected destination.
///
/// Every imported model gets a fresh ID. This prevents an import from accidentally updating an
/// existing model and also makes the plan safe to inspect before it is committed.
pub fn plan_import_resources(
query_manager: &QueryManager,
importer: String,
destination: ImportDestination,
resources: ImportResources,
source_keys: Option<BTreeMap<String, String>>,
origin: Option<ImportOrigin>,
) -> Result<ImportPlan> {
let mut warnings = Vec::new();
validate_destination(query_manager, &destination)?;
let plugin_keys = source_keys.unwrap_or_default();
// Source keys and merge decisions describe the document as the importer produced it, not as
// this destination reshapes it, so keep the original around.
let original = resources.clone();
let source_folder_ids = resources.folders.iter().map(|v| v.id.clone()).collect::<BTreeSet<_>>();
let mut folder_ids = BTreeMap::new();
for folder in &resources.folders {
folder_ids.insert(folder.id.clone(), Folder::generate_id());
}
let mut workspace_ids = BTreeMap::new();
let mut workspaces = Vec::new();
let (default_workspace_id, target_folder_id) = match &destination {
ImportDestination::NewWorkspace => {
for source in &resources.workspaces {
let mut workspace = source.clone();
workspace.id = Workspace::generate_id();
workspace_ids.insert(source.id.clone(), workspace.id.clone());
workspaces.push(workspace);
}
if workspaces.is_empty() {
let workspace = Workspace {
id: Workspace::generate_id(),
model: "workspace".to_string(),
name: format!("{} Import", display_importer_name(&importer)),
setting_follow_redirects: true,
setting_request_message_size: DEFAULT_REQUEST_MESSAGE_SIZE,
setting_validate_certificates: true,
setting_send_cookies: true,
setting_store_cookies: true,
..Default::default()
};
workspaces.push(workspace);
}
(workspaces[0].id.clone(), None)
}
ImportDestination::ExistingWorkspace { workspace_id, folder_id } => {
for source in &resources.workspaces {
workspace_ids.insert(source.id.clone(), workspace_id.clone());
}
if !resources.workspaces.is_empty() {
let destination_workspace = query_manager.connect().get_workspace(workspace_id)?;
let skipped_fields = resources
.workspaces
.iter()
.flat_map(|source| {
workspace_fields_not_imported(source, &destination_workspace)
})
.collect::<BTreeSet<_>>();
if !skipped_fields.is_empty() {
let source = if resources.workspaces.len() == 1 {
resources.workspaces[0].name.clone()
} else {
format!("{} imported workspaces", resources.workspaces.len())
};
warnings.push(ImportPlanWarning {
title: "Workspace settings skipped".to_string(),
detail: format!("{source} · {}", display_list(&skipped_fields)),
});
}
}
(workspace_id.clone(), folder_id.clone())
}
};
let resolve_workspace_id = |source_id: &str| {
workspace_ids.get(source_id).cloned().unwrap_or_else(|| default_workspace_id.clone())
};
let resolve_folder_id = |source_id: Option<String>| match source_id {
Some(source_id) if source_folder_ids.contains(&source_id) => {
folder_ids.get(&source_id).cloned()
}
_ => target_folder_id.clone(),
};
let folders = resources
.folders
.into_iter()
.map(|mut folder| {
folder.id = folder_ids.get(&folder.id).cloned().unwrap_or_else(Folder::generate_id);
folder.workspace_id = resolve_workspace_id(&folder.workspace_id);
folder.folder_id = resolve_folder_id(folder.folder_id);
folder
})
.collect();
let http_requests = resources
.http_requests
.into_iter()
.map(|mut request| {
request.id = HttpRequest::generate_id();
request.workspace_id = resolve_workspace_id(&request.workspace_id);
request.folder_id = resolve_folder_id(request.folder_id);
request
})
.collect();
let grpc_requests = resources
.grpc_requests
.into_iter()
.map(|mut request| {
request.id = GrpcRequest::generate_id();
request.workspace_id = resolve_workspace_id(&request.workspace_id);
request.folder_id = resolve_folder_id(request.folder_id);
request
})
.collect();
let websocket_requests = resources
.websocket_requests
.into_iter()
.map(|mut request| {
request.id = WebsocketRequest::generate_id();
request.workspace_id = resolve_workspace_id(&request.workspace_id);
request.folder_id = resolve_folder_id(request.folder_id);
request
})
.collect();
let importing_into_existing =
matches!(destination, ImportDestination::ExistingWorkspace { .. });
let mut separated_base_environments = Vec::new();
let mut converted_duplicate_base_environment = false;
let mut converted_duplicate_folder_environment = false;
let mut base_environment_workspaces = BTreeSet::new();
let mut folder_environment_ids = BTreeSet::new();
let environments = resources
.environments
.into_iter()
.map(|mut environment| {
environment.id = Environment::generate_id();
environment.workspace_id = resolve_workspace_id(&environment.workspace_id);
match (environment.parent_model.as_str(), environment.parent_id.clone()) {
("workspace", _) if importing_into_existing => {
environment.parent_model = "environment".to_string();
environment.parent_id = None;
let source_name = environment.name.clone();
environment.name = format!("{} (Imported)", environment.name);
separated_base_environments.push((
source_name,
environment.name.clone(),
environment.variables.len(),
));
}
("workspace", _) => {
environment.parent_id = None;
if !base_environment_workspaces.insert(environment.workspace_id.clone()) {
environment.parent_model = "environment".to_string();
environment.name = format!("{} (Imported)", environment.name);
converted_duplicate_base_environment = true;
}
}
("folder", Some(parent_id)) if source_folder_ids.contains(&parent_id) => {
environment.parent_id = folder_ids.get(&parent_id).cloned();
if let Some(parent_id) = &environment.parent_id
&& !folder_environment_ids.insert(parent_id.clone())
{
environment.parent_model = "environment".to_string();
environment.parent_id = None;
converted_duplicate_folder_environment = true;
}
}
("folder", _) => {
// Never attach an imported folder environment to an existing folder: the model
// layer permits only one and would otherwise delete the destination's value.
environment.parent_model = "environment".to_string();
environment.parent_id = None;
}
("environment", _) => {
environment.parent_id = None;
}
_ => {
environment.parent_model = "environment".to_string();
environment.parent_id = None;
}
}
environment
})
.collect();
for (source_name, imported_name, variable_count) in separated_base_environments {
let variables = if variable_count == 1 { "variable" } else { "variables" };
warnings.push(ImportPlanWarning {
title: "Base environment kept separate".to_string(),
detail: format!("{source_name} → {imported_name} · {variable_count} {variables}"),
});
}
if converted_duplicate_base_environment {
warnings.push(ImportPlanWarning {
title: "Base environments separated".to_string(),
detail: "Only the first remains the base environment".to_string(),
});
}
if converted_duplicate_folder_environment {
warnings.push(ImportPlanWarning {
title: "Folder environments separated".to_string(),
detail: "Only the first remains attached to each folder".to_string(),
});
}
let resources = BatchUpsertResult {
workspaces,
environments,
folders,
http_requests,
grpc_requests,
websocket_requests,
};
let mut plan = ImportPlan {
importer,
destination,
source_keys: assign_source_keys(&resources, &original, &plugin_keys),
resources,
warnings,
items: Vec::new(),
origin,
};
merge_with_linked_source(query_manager, &mut plan, &original)?;
Ok(plan)
}
/// Commit a previously prepared plan in one transaction, applying only its selected items.
pub fn commit_import_plan(
query_manager: &QueryManager,
plan: ImportPlan,
) -> Result<BatchUpsertResult> {
validate_plan(&plan)?;
info!("Committing staged import from {}", plan.importer);
query_manager.with_tx(|tx| {
validate_destination_db(tx, &plan.destination)?;
commit_plan_in_tx(tx, plan)
})
}
fn commit_plan_in_tx(db: &ClientDb, plan: ImportPlan) -> Result<BatchUpsertResult> {
let items: BTreeMap<String, ImportPlanItem> =
plan.items.iter().map(|item| (item.model_id.clone(), item.clone())).collect();
// A resource without an item (workspaces, plans from older callers) always applies.
// A selected keep-local item is an explicit request to revert the local edits.
let applies = |id: &str| match items.get(id) {
None => true,
Some(item) => match item.action {
ImportPlanAction::Create
| ImportPlanAction::Update
| ImportPlanAction::KeepLocal
| ImportPlanAction::NotImported => item.selected,
ImportPlanAction::Conflict => {
item.resolution == Some(ImportConflictResolution::TakeSource)
}
ImportPlanAction::Delete | ImportPlanAction::Unchanged => false,
},
};
// A folder that will not exist takes its planned descendants with it: nothing can be
// created inside it.
let is_new_folder = |id: &str| {
items.get(id).is_some_and(|i| {
matches!(i.action, ImportPlanAction::Create | ImportPlanAction::NotImported)
})
};
let mut missing_folders: BTreeSet<String> = plan
.resources
.folders
.iter()
.filter(|f| is_new_folder(&f.id) && !applies(&f.id))
.map(|f| f.id.clone())
.collect();
loop {
let before = missing_folders.len();
for folder in &plan.resources.folders {
if let Some(parent_id) = &folder.folder_id
&& missing_folders.contains(parent_id)
{
missing_folders.insert(folder.id.clone());
}
}
if missing_folders.len() == before {
break;
}
}
let folder_available = |folder_id: &Option<String>| match folder_id {
Some(id) => !missing_folders.contains(id),
None => true,
};
let resources = &plan.resources;
let upserted = db.batch_upsert(
resources.workspaces.clone(),
resources
.environments
.iter()
.filter(|v| applies(&v.id))
.filter(|v| v.parent_model != "folder" || folder_available(&v.parent_id))
.cloned()
.collect(),
resources
.folders
.iter()
.filter(|v| applies(&v.id) && !missing_folders.contains(&v.id))
.cloned()
.collect(),
resources
.http_requests
.iter()
.filter(|v| applies(&v.id) && folder_available(&v.folder_id))
.cloned()
.collect(),
resources
.grpc_requests
.iter()
.filter(|v| applies(&v.id) && folder_available(&v.folder_id))
.cloned()
.collect(),
resources
.websocket_requests
.iter()
.filter(|v| applies(&v.id) && folder_available(&v.folder_id))
.cloned()
.collect(),
&UpdateSource::Import,
)?;
let selected_deletes = plan
.items
.iter()
.filter(|i| i.action == ImportPlanAction::Delete && i.selected)
.collect::<Vec<_>>();
// Folders last so their cascade only has to cover what was not deleted explicitly.
for item in selected_deletes.iter().filter(|i| i.model != ImportResourceType::Folder) {
delete_existing_model(db, item.model, &item.model_id)?;
}
for item in selected_deletes.iter().filter(|i| i.model == ImportResourceType::Folder) {
delete_existing_model(db, item.model, &item.model_id)?;
}
record_import_source(db, &plan, &items, &upserted)?;
Ok(upserted)
}
/// A folder deletion may have already cascaded over the model, so absent models are skipped.
fn delete_existing_model(db: &ClientDb, resource: ImportResourceType, id: &str) -> Result<()> {
use ImportResourceType::*;
let source = &UpdateSource::Import;
match resource {
Environment => {
if db.get_environment(id).is_ok() {
db.delete_environment_by_id(id, source)?;
}
}
Folder => {
if db.get_folder(id).is_ok() {
db.delete_folder_by_id(id, source)?;
}
}
HttpRequest => {
if db.get_http_request(id).is_ok() {
db.delete_http_request_by_id(id, source)?;
}
}
GrpcRequest => {
if db.get_grpc_request(id).is_ok() {
db.delete_grpc_request_by_id(id, source)?;
}
}
WebsocketRequest => {
if db.get_websocket_request(id).is_ok() {
db.delete_websocket_request_by_id(id, source)?;
}
}
// The destination workspace is never a plan item, so there is nothing to delete
Workspace => {}
}
Ok(())
}
/// Link the committed workspace to the import's source and record, per source key, whether the
/// user wants that resource and which version of it they last decided on.
///
/// Hashes advance for everything the user decided on this round — applied items and keep-mine
/// conflicts alike — while deselected updates and deletions keep their old hash so they are
/// offered again next time. A resource the user turned down is remembered as a row without a
/// model, so it is neither re-offered nor resurrected.
fn record_import_source(
db: &ClientDb,
plan: &ImportPlan,
items: &BTreeMap<String, ImportPlanItem>,
upserted: &BatchUpsertResult,
) -> Result<()> {
let Some(origin) = &plan.origin else {
return Ok(());
};
let workspace_id = match &plan.destination {
ImportDestination::ExistingWorkspace { workspace_id, .. } => workspace_id.clone(),
ImportDestination::NewWorkspace => match upserted.workspaces.first() {
Some(workspace) => workspace.id.clone(),
None => return Ok(()),
},
};
let incoming_keys: BTreeSet<String> = plan.source_keys.values().cloned().collect();
let existing = match resolve_linked_source(
db,
&workspace_id,
&plan.importer,
origin,
&incoming_keys,
)? {
LinkedSource::Linked(source) => Some(source),
LinkedSource::Ambiguous(_) | LinkedSource::Unlinked => None,
};
let import_source = db.upsert_import_source(
&ImportSource {
id: existing.map(|s| s.id).unwrap_or_default(),
workspace_id,
importer: plan.importer.clone(),
origin: origin.origin.clone(),
origin_label: origin.label.clone(),
last_imported_at: Utc::now().naive_utc(),
..Default::default()
},
&UpdateSource::Import,
)?;
let mut committed: BTreeMap<&str, String> = BTreeMap::new();
for v in &upserted.environments {
committed.insert(&v.id, content_hash(serde_json::to_value(v)?));
}
for v in &upserted.folders {
committed.insert(&v.id, content_hash(serde_json::to_value(v)?));
}
for v in &upserted.http_requests {
committed.insert(&v.id, content_hash(serde_json::to_value(v)?));
}
for v in &upserted.grpc_requests {
committed.insert(&v.id, content_hash(serde_json::to_value(v)?));
}
for v in &upserted.websocket_requests {
committed.insert(&v.id, content_hash(serde_json::to_value(v)?));
}
let write_row = |model_id: &str,
resource: ImportResourceType,
incoming: &dyn Fn() -> Result<Value>|
-> Result<()> {
let Some(source_key) = plan.source_keys.get(model_id) else {
return Ok(());
};
let item = items.get(model_id);
let next_row = match committed.get(model_id) {
Some(hash) => Some((Some(model_id.to_string()), Some(hash.clone()))),
None => match item.map(|i| i.action) {
Some(
ImportPlanAction::Unchanged
| ImportPlanAction::KeepLocal
| ImportPlanAction::Conflict,
) => Some((Some(model_id.to_string()), Some(content_hash(incoming()?)))),
// Turned down, so remember it as not wanted rather than offering it again
Some(ImportPlanAction::Create | ImportPlanAction::NotImported) => Some((None, None)),
Some(ImportPlanAction::Delete) if item.is_some_and(|i| i.selected) => {
Some((None, None))
}
// A deselected update or deletion stays offered next import
Some(ImportPlanAction::Update | ImportPlanAction::Delete) | None => None,
},
};
let Some((model_id, content_hash)) = next_row else {
return Ok(());
};
db.upsert_import_source_resource(&ImportSourceResource {
import_source_id: import_source.id.clone(),
source_key: source_key.clone(),
model_type: resource.as_str().to_string(),
model_id,
content_hash,
..Default::default()
})?;
Ok(())
};
for v in &plan.resources.environments {
write_row(&v.id, ImportResourceType::Environment, &|| Ok(serde_json::to_value(v)?))?;
}
for v in &plan.resources.folders {
write_row(&v.id, ImportResourceType::Folder, &|| Ok(serde_json::to_value(v)?))?;
}
for v in &plan.resources.http_requests {
write_row(&v.id, ImportResourceType::HttpRequest, &|| Ok(serde_json::to_value(v)?))?;
}
for v in &plan.resources.grpc_requests {
write_row(&v.id, ImportResourceType::GrpcRequest, &|| Ok(serde_json::to_value(v)?))?;
}
for v in &plan.resources.websocket_requests {
write_row(&v.id, ImportResourceType::WebsocketRequest, &|| Ok(serde_json::to_value(v)?))?;
}
for row in db.list_import_source_resources(&import_source.id)? {
if incoming_keys.contains(&row.source_key) {
continue;
}
// Keep only rows that back a deletion the user deselected; it will be offered again.
let keep = match (ImportResourceType::from_str(&row.model_type), &row.model_id) {
(Some(resource), Some(model_id)) => {
items
.get(model_id)
.is_some_and(|i| i.action == ImportPlanAction::Delete && !i.selected)
&& existing_model_json(db, resource, model_id)?.is_some()
}
_ => false,
};
if !keep {
db.delete_import_source_resource(&import_source.id, &row.source_key)?;
}
}
Ok(())
}
/// How an import's contents relate to what the destination workspace already has linked.
enum LinkedSource {
Linked(ImportSource),
/// Several linked sources contain these resources, so merging would have to guess
Ambiguous(Vec<ImportSource>),
Unlinked,
}
/// A re-import is recognized by the source keys it carries rather than by where it was read from,
/// so a file that moved or was renamed still merges into what it created.
fn resolve_linked_source(
db: &ClientDb,
workspace_id: &str,
importer: &str,
origin: &ImportOrigin,
incoming_keys: &BTreeSet<String>,
) -> Result<LinkedSource> {
let sources = db.list_import_sources(workspace_id)?;
let same_origin = |source: &ImportSource| {
source.importer == importer && source.origin == origin.origin
};
let mut overlapping = Vec::new();
for source in &sources {
let overlaps = db
.list_import_source_resources(&source.id)?
.iter()
.any(|row| incoming_keys.contains(&row.source_key));
if overlaps {
overlapping.push(source.clone());
}
}
Ok(match overlapping.len() {
0 => match sources.into_iter().find(same_origin) {
Some(source) => LinkedSource::Linked(source),
None => LinkedSource::Unlinked,
},
1 => LinkedSource::Linked(overlapping.remove(0)),
_ => match overlapping.iter().find(|s| same_origin(s)) {
Some(source) => LinkedSource::Linked(source.clone()),
None => LinkedSource::Ambiguous(overlapping),
},
})
}
fn ambiguous_source_warning(sources: &[ImportSource]) -> ImportPlanWarning {
let labels = sources.iter().map(|s| s.origin_label.as_str()).collect::<BTreeSet<_>>();
ImportPlanWarning {
title: "Imported as new".to_string(),
detail: format!("{} already contain these resources", display_list(&labels)),
}
}
/// Rewrite a plan against the destination's linked import source, if it has one: resources whose
/// source key still has a model adopt that model's ID, and every resource gets a plan item
/// describing the decision to preview. Keys the user has turned down come back as offers to
/// import them after all, rather than as new resources.
fn merge_with_linked_source(
query_manager: &QueryManager,
plan: &mut ImportPlan,
original: &ImportResources,
) -> Result<()> {
let db = query_manager.connect();
let incoming_keys: BTreeSet<String> = plan.source_keys.values().cloned().collect();
let linked = match (&plan.origin, &plan.destination) {
(Some(origin), ImportDestination::ExistingWorkspace { workspace_id, .. }) => {
resolve_linked_source(&db, workspace_id, &plan.importer, origin, &incoming_keys)?
}
(None, _) | (Some(_), ImportDestination::NewWorkspace) => LinkedSource::Unlinked,
};
let source = match linked {
LinkedSource::Linked(source) => source,
LinkedSource::Ambiguous(sources) => {
plan.warnings.push(ambiguous_source_warning(&sources));
plan.items = create_only_items(plan);
return Ok(());
}
LinkedSource::Unlinked => {
plan.items = create_only_items(plan);
return Ok(());
}
};
let workspace_id = source.workspace_id.clone();
let rows: BTreeMap<String, ImportSourceResource> = db
.list_import_source_resources(&source.id)?
.into_iter()
.map(|row| (row.source_key.clone(), row))
.collect();
// Resources whose key maps to a model that still exists adopt that model's ID.
let mut remap: BTreeMap<String, String> = BTreeMap::new();
let mut current_models: BTreeMap<String, Value> = BTreeMap::new();
{
let mut consider = |planned_id: &str, resource: ImportResourceType| -> Result<()> {
let Some(key) = plan.source_keys.get(planned_id) else { return Ok(()) };
let Some(row) = rows.get(key) else { return Ok(()) };
if ImportResourceType::from_str(&row.model_type) != Some(resource) {
return Ok(());
}
let Some(model_id) = row.model_id.as_deref() else { return Ok(()) };
let Some(current) = existing_model_json(&db, resource, model_id)? else {
return Ok(());
};
if current.get("workspaceId").and_then(|v| v.as_str()) != Some(workspace_id.as_str()) {
return Ok(());
}
remap.insert(planned_id.to_string(), model_id.to_string());
current_models.insert(model_id.to_string(), current);
Ok(())
};
for v in &plan.resources.folders {
consider(&v.id, ImportResourceType::Folder)?;
}
for v in &plan.resources.environments {
consider(&v.id, ImportResourceType::Environment)?;
}
for v in &plan.resources.http_requests {
consider(&v.id, ImportResourceType::HttpRequest)?;
}
for v in &plan.resources.grpc_requests {
consider(&v.id, ImportResourceType::GrpcRequest)?;
}
for v in &plan.resources.websocket_requests {
consider(&v.id, ImportResourceType::WebsocketRequest)?;
}
}
let remap_ref = |id: Option<String>| id.map(|v| remap.get(&v).cloned().unwrap_or(v));
for v in &mut plan.resources.folders {
if let Some(existing_id) = remap.get(&v.id) {
v.id = existing_id.clone();
}
v.folder_id = remap_ref(v.folder_id.take());
}
for v in &mut plan.resources.environments {
if let Some(existing_id) = remap.get(&v.id) {
v.id = existing_id.clone();
}
if v.parent_model == "folder" {
v.parent_id = remap_ref(v.parent_id.take());
}
}
for v in &mut plan.resources.http_requests {
if let Some(existing_id) = remap.get(&v.id) {
v.id = existing_id.clone();
}
v.folder_id = remap_ref(v.folder_id.take());
}
for v in &mut plan.resources.grpc_requests {
if let Some(existing_id) = remap.get(&v.id) {
v.id = existing_id.clone();
}
v.folder_id = remap_ref(v.folder_id.take());
}
for v in &mut plan.resources.websocket_requests {
if let Some(existing_id) = remap.get(&v.id) {
v.id = existing_id.clone();
}
v.folder_id = remap_ref(v.folder_id.take());
}
plan.source_keys = plan
.source_keys
.iter()
.map(|(id, key)| (remap.get(id).cloned().unwrap_or_else(|| id.clone()), key.clone()))
.collect();
// A mapped environment that is currently the destination's base environment stays the base
// environment: it came from this source, so the imported-copy separation does not apply.
let mut restored_base_names = BTreeSet::new();
for (i, v) in plan.resources.environments.iter_mut().enumerate() {
let is_current_base = current_models
.get(&v.id)
.and_then(|m| m.get("parentModel"))
.and_then(|p| p.as_str())
== Some("workspace");
if !is_current_base {
continue;
}
if let Some(source) = original.environments.get(i) {
v.name = source.name.clone();
}
v.parent_model = "workspace".to_string();
v.parent_id = None;
restored_base_names.insert(v.name.clone());
}
if !restored_base_names.is_empty() {
plan.warnings.retain(|w| {
!(w.title == "Base environment kept separate"
&& restored_base_names.iter().any(|n| w.detail.starts_with(&format!("{n} →"))))
});
}
// A key whose row still points at a model in this workspace is one the user wants; a key
// whose row has lost its model is one they turned down.
let status = |planned_id: &str, resource: ImportResourceType| -> KeyStatus {
let Some(row) = plan.source_keys.get(planned_id).and_then(|key| rows.get(key)) else {
return KeyStatus::New;
};
if ImportResourceType::from_str(&row.model_type) != Some(resource) {
return KeyStatus::New;
}
if row.model_id.as_deref() == Some(planned_id) && current_models.contains_key(planned_id) {
KeyStatus::Wanted(row)
} else {
KeyStatus::NotWanted
}
};
let not_imported_folders = plan
.resources
.folders
.iter()
.filter(|v| matches!(status(&v.id, ImportResourceType::Folder), KeyStatus::NotWanted))
.map(|v| v.id.as_str())
.collect::<BTreeSet<_>>();
let folder_parents = plan
.resources
.folders
.iter()
.map(|v| (v.id.as_str(), v.folder_id.as_deref()))
.collect::<BTreeMap<_, _>>();
let inside_a_not_imported_folder = |parent_id: Option<&str>| {
let mut seen = BTreeSet::new();
let mut next = parent_id;
while let Some(id) = next {
if !seen.insert(id) {
break;
}
if not_imported_folders.contains(id) {
return true;
}
next = folder_parents.get(id).copied().flatten();
}
false
};
let mut items = Vec::new();
{
let mut classify = |any: AnyModel,
resource: ImportResourceType,
parent_id: Option<String>|
-> Result<()> {
let planned_id = any.id().to_string();
let item = |action, selected, resolution, reason| ImportPlanItem {
action,
model: resource,
model_id: planned_id.clone(),
name: any.resolved_name(),
parent_id: parent_id.clone(),
selected,
resolution,
reason,
};
// Nothing can be created inside a folder that isn't imported, so it starts unchecked
// and comes along only if the folder does.
let reachable = !inside_a_not_imported_folder(parent_id.as_deref());
let row = match status(&planned_id, resource) {
KeyStatus::New => {
items.push(item(ImportPlanAction::Create, reachable, None, None));
return Ok(());
}
KeyStatus::NotWanted => {
items.push(item(ImportPlanAction::NotImported, false, None, None));
return Ok(());
}
KeyStatus::Wanted(row) => row,
};
// The source put it somewhere the workspace has no folder for, so it can't stay
if !reachable {
items.push(item(
ImportPlanAction::Delete,
false,
None,
Some(ImportPlanReason::MovedIntoNotImportedFolder),
));
return Ok(());
}
let incoming = serde_json::to_value(&any)?;
let current = current_models.get(&planned_id).cloned().unwrap_or_default();
let (source_changed, local_changed) = match recorded_hash(row) {
Some(hash) => (content_hash(incoming) != hash, content_hash(current) != hash),
// Without a recorded version, all that can be told is whether the two sides differ
None => {
let differs = comparable(incoming) != comparable(current);
(differs, differs)
}
};
let (action, selected, resolution) = match (source_changed, local_changed) {
(false, false) => (ImportPlanAction::Unchanged, false, None),
(true, false) => (ImportPlanAction::Update, true, None),
(false, true) => (ImportPlanAction::KeepLocal, false, None),
(true, true) => (
ImportPlanAction::Conflict,
true,
Some(ImportConflictResolution::KeepMine),
),
};
items.push(item(action, selected, resolution, None));
Ok(())
};
for v in &plan.resources.folders {
classify(AnyModel::Folder(v.clone()), ImportResourceType::Folder, v.folder_id.clone())?;
}
for v in &plan.resources.http_requests {
classify(AnyModel::HttpRequest(v.clone()), ImportResourceType::HttpRequest, v.folder_id.clone())?;
}
for v in &plan.resources.grpc_requests {
classify(AnyModel::GrpcRequest(v.clone()), ImportResourceType::GrpcRequest, v.folder_id.clone())?;
}
for v in &plan.resources.websocket_requests {
classify(AnyModel::WebsocketRequest(v.clone()), ImportResourceType::WebsocketRequest, v.folder_id.clone())?;
}
for v in &plan.resources.environments {
classify(AnyModel::Environment(v.clone()), ImportResourceType::Environment, v.parent_id.clone())?;
}
}
// Mapped models the source no longer has become deletion offers, deselected by default.
for (key, row) in &rows {
if incoming_keys.contains(key) {
continue;
}
let Some(resource) = ImportResourceType::from_str(&row.model_type) else {
continue;
};
let Some(model_id) = row.model_id.as_deref() else {
continue;
};
let Some(current) = existing_model_json(&db, resource, model_id)? else {
continue;
};
if current.get("workspaceId").and_then(|v| v.as_str()) != Some(workspace_id.as_str()) {
continue;
}
let name = serde_json::from_value::<AnyModel>(current.clone())
.map(|m| m.resolved_name())
.unwrap_or_else(|_| "Unknown".to_string());
let parent_id = current
.get("folderId")
.or_else(|| current.get("parentId"))
.and_then(|v| v.as_str())
.map(str::to_string);
items.push(ImportPlanItem {
action: ImportPlanAction::Delete,
model: resource,
model_id: model_id.to_string(),
name,
parent_id,
selected: false,
resolution: None,
reason: None,
});
}
plan.items = items;
Ok(())
}
fn create_only_items(plan: &ImportPlan) -> Vec<ImportPlanItem> {
let mut items = Vec::new();
let mut push = |any: AnyModel, resource: ImportResourceType, parent_id: Option<String>| {
items.push(ImportPlanItem {
action: ImportPlanAction::Create,
model: resource,
model_id: any.id().to_string(),
name: any.resolved_name(),
parent_id,
selected: true,
resolution: None,
reason: None,
});
};
for v in &plan.resources.folders {
push(AnyModel::Folder(v.clone()), ImportResourceType::Folder, v.folder_id.clone());
}
for v in &plan.resources.http_requests {
push(AnyModel::HttpRequest(v.clone()), ImportResourceType::HttpRequest, v.folder_id.clone());
}
for v in &plan.resources.grpc_requests {
push(AnyModel::GrpcRequest(v.clone()), ImportResourceType::GrpcRequest, v.folder_id.clone());
}
for v in &plan.resources.websocket_requests {
push(AnyModel::WebsocketRequest(v.clone()), ImportResourceType::WebsocketRequest, v.folder_id.clone());
}
for v in &plan.resources.environments {
push(AnyModel::Environment(v.clone()), ImportResourceType::Environment, v.parent_id.clone());
}
items
}
/// What a source key means for the resource the plan wants to put behind it.
enum KeyStatus<'a> {
/// The source has never been imported under this key
New,
Wanted(&'a ImportSourceResource),
/// Imported under this key before, and since turned down or deleted
NotWanted,
}
/// Strip identity and bookkeeping fields so equality means "same content in the same place".
/// The deprecated environment `base` flag mirrors `parentModel`, which is compared already.
/// Importers number `sortPriority` from source order, so comparing it would turn one insertion
/// into an update of everything after it.
fn comparable(mut value: Value) -> Value {
if let Some(object) = value.as_object_mut() {
for field in
["id", "model", "workspaceId", "createdAt", "updatedAt", "base", "sortPriority"]
{
object.remove(field);
}
}
value
}
const CONTENT_HASH_VERSION: &str = "v1:";
/// Identifies a resource's content well enough to tell "changed since the last import" from
/// "unchanged", without keeping a copy of every imported resource around.
fn content_hash(value: Value) -> String {
let canonical = serde_json::to_string(&sorted_keys(comparable(value))).unwrap_or_default();
format!("{CONTENT_HASH_VERSION}{:x}", Sha256::digest(canonical.as_bytes()))
}
/// A hash written by a version this build doesn't understand says nothing about the resource.
fn recorded_hash(row: &ImportSourceResource) -> Option<&str> {
let hash = row.content_hash.as_deref()?;
hash.starts_with(CONTENT_HASH_VERSION).then_some(hash)
}
fn sorted_keys(value: Value) -> Value {
match value {
Value::Object(object) => {
let mut entries = object.into_iter().collect::<Vec<_>>();
entries.sort_by(|(a, _), (b, _)| a.cmp(b));
Value::Object(entries.into_iter().map(|(k, v)| (k, sorted_keys(v))).collect())
}
Value::Array(items) => Value::Array(items.into_iter().map(sorted_keys).collect()),
other => other,
}
}
fn existing_model_json(
db: &ClientDb,
resource: ImportResourceType,
id: &str,
) -> Result<Option<Value>> {
use ImportResourceType::*;
let value = match resource {
Environment => db.get_environment(id).ok().map(|m| serde_json::to_value(&m)),
Folder => db.get_folder(id).ok().map(|m| serde_json::to_value(&m)),
HttpRequest => db.get_http_request(id).ok().map(|m| serde_json::to_value(&m)),
GrpcRequest => db.get_grpc_request(id).ok().map(|m| serde_json::to_value(&m)),
WebsocketRequest => db.get_websocket_request(id).ok().map(|m| serde_json::to_value(&m)),
Workspace => None,
};
Ok(value.transpose()?)
}
fn validate_destination(
query_manager: &QueryManager,
destination: &ImportDestination,
) -> Result<()> {
let db = query_manager.connect();
validate_destination_db(&db, destination)
}
fn validate_destination_db(db: &ClientDb<'_>, destination: &ImportDestination) -> Result<()> {
let ImportDestination::ExistingWorkspace { workspace_id, folder_id } = destination else {
return Ok(());
};
db.get_workspace(workspace_id)?;
if let Some(folder_id) = folder_id {
let folder = db.get_folder(folder_id)?;
if folder.workspace_id != *workspace_id {
return Err(yaak_models::error::Error::GenericError(format!(
"Folder {folder_id} does not belong to workspace {workspace_id}"
))
.into());
}
}
Ok(())
}
fn validate_plan(plan: &ImportPlan) -> Result<()> {
let invalid = |message: String| -> Result<()> {
Err(yaak_models::error::Error::GenericError(message).into())
};
match &plan.destination {
ImportDestination::ExistingWorkspace { workspace_id, .. } => {
if !plan.resources.workspaces.is_empty() {
return invalid(
"An existing-workspace import plan must not contain workspace updates"
.to_string(),
);
}
let all_workspace_ids = plan
.resources
.environments
.iter()
.map(|v| &v.workspace_id)
.chain(plan.resources.folders.iter().map(|v| &v.workspace_id))
.chain(plan.resources.http_requests.iter().map(|v| &v.workspace_id))
.chain(plan.resources.grpc_requests.iter().map(|v| &v.workspace_id))
.chain(plan.resources.websocket_requests.iter().map(|v| &v.workspace_id));
if all_workspace_ids.into_iter().any(|id| id != workspace_id) {
return invalid(
"An existing-workspace import plan contains resources for another workspace"
.to_string(),
);
}
// A merging plan may update the base environment it created earlier, which its plan
// item records; anything else must not replace the destination's base environment.
let updates_own_base = |id: &str| {
plan.items.iter().any(|i| {
i.model_id == id
&& !matches!(
i.action,
ImportPlanAction::Create | ImportPlanAction::NotImported
)
})
};
if plan
.resources
.environments
.iter()
.any(|v| v.parent_model == "workspace" && !updates_own_base(&v.id))
{
return invalid(
"An existing-workspace import plan must not replace the base environment"
.to_string(),
);
}
}
ImportDestination::NewWorkspace => {
let workspace_ids =
plan.resources.workspaces.iter().map(|v| v.id.as_str()).collect::<BTreeSet<_>>();
if workspace_ids.is_empty() {
return invalid("A new-workspace import plan has no workspace".to_string());
}
let all_workspace_ids = plan
.resources
.environments
.iter()
.map(|v| v.workspace_id.as_str())
.chain(plan.resources.folders.iter().map(|v| v.workspace_id.as_str()))
.chain(plan.resources.http_requests.iter().map(|v| v.workspace_id.as_str()))
.chain(plan.resources.grpc_requests.iter().map(|v| v.workspace_id.as_str()))
.chain(plan.resources.websocket_requests.iter().map(|v| v.workspace_id.as_str()));
if all_workspace_ids.into_iter().any(|id| !workspace_ids.contains(id)) {
return invalid(
"A new-workspace import plan contains resources outside its workspaces"
.to_string(),
);
}
let mut base_environment_workspaces = BTreeSet::new();
if plan.resources.environments.iter().any(|v| {
v.parent_model == "workspace"
&& !base_environment_workspaces.insert(v.workspace_id.as_str())
}) {
return invalid(
"A new-workspace import plan contains multiple base environments for one workspace"
.to_string(),
);
}
}
}
let planned_folder_ids =
plan.resources.folders.iter().map(|v| v.id.as_str()).collect::<BTreeSet<_>>();
if plan.resources.environments.iter().any(|v| {
v.parent_model == "folder"
&& v.parent_id.as_deref().is_none_or(|id| !planned_folder_ids.contains(id))
}) {
return invalid(
"An import plan must not replace an existing folder environment".to_string(),
);
}
Ok(())
}
fn display_importer_name(importer: &str) -> &str {
importer.strip_prefix("@yaak/importer-").unwrap_or(importer)
}
fn workspace_fields_not_imported(source: &Workspace, destination: &Workspace) -> Vec<&'static str> {
let mut fields = Vec::new();
if source.name != destination.name {
fields.push("workspace name");
}
if source.description != destination.description {
fields.push("description");
}
if source.authentication != destination.authentication
|| source.authentication_type != destination.authentication_type
{
fields.push("authentication");
}
if source.headers != destination.headers {
fields.push("default headers");
}
if source.encryption_key_challenge != destination.encryption_key_challenge {
fields.push("encryption configuration");
}
if source.setting_validate_certificates != destination.setting_validate_certificates {
fields.push("certificate validation");
}
if source.setting_follow_redirects != destination.setting_follow_redirects {
fields.push("redirect behavior");
}
if source.setting_request_timeout != destination.setting_request_timeout {
fields.push("request timeout");
}
if source.setting_request_message_size != destination.setting_request_message_size {
fields.push("request message size");
}
if source.setting_dns_overrides != destination.setting_dns_overrides {
fields.push("DNS overrides");
}
if source.setting_send_cookies != destination.setting_send_cookies
|| source.setting_store_cookies != destination.setting_store_cookies
{
fields.push("cookie behavior");
}
fields
}
fn display_list(items: &BTreeSet<&str>) -> String {
let items = items.iter().copied().collect::<Vec<_>>();
match items.as_slice() {
[] => String::new(),
[item] => (*item).to_string(),
[first, second] => format!("{first} and {second}"),
_ => format!("{}, and {}", items[..items.len() - 1].join(", "), items[items.len() - 1]),
}
}
/// Keys are derived from the document as the importer produced it — names, routes, and folder
/// ancestry before any destination re-rooting or renaming — so the same document maps onto the
/// same keys no matter which workspace it is imported into. Collections are positional: planned
/// entry `i` came from original entry `i`.
fn assign_source_keys(
resources: &BatchUpsertResult,
original: &ImportResources,
plugin_keys: &BTreeMap<String, String>,
) -> BTreeMap<String, String> {
let folder_tree = original
.folders
.iter()
.map(|v| (v.id.clone(), (v.name.clone(), v.folder_id.clone())))
.collect::<BTreeMap<_, _>>();
let plugin_key = |source_id: Option<&String>| source_id.and_then(|id| plugin_keys.get(id));
// (model ID, importer key, key by name, key by name and content)
let mut candidates: Vec<(&str, Option<&String>, String, String)> = Vec::new();
for (i, v) in resources.workspaces.iter().enumerate() {
let source = original.workspaces.get(i);
let name = source.map(|s| s.name.as_str()).unwrap_or(v.name.as_str());
let key = fallback_key("workspace", &[], name);
candidates.push((&v.id, plugin_key(source.map(|s| &s.id)), key.clone(), key));
}
for (i, v) in resources.environments.iter().enumerate() {
let source = original.environments.get(i);
let name = source.map(|s| s.name.as_str()).unwrap_or(v.name.as_str());
let parent_id = source.and_then(|s| {
if s.parent_model == "folder" { s.parent_id.as_deref() } else { None }
});
let ancestry = ancestry_path(&folder_tree, parent_id);
let key = fallback_key("environment", &ancestry, name);
candidates.push((&v.id, plugin_key(source.map(|s| &s.id)), key.clone(), key));
}
for (i, v) in resources.folders.iter().enumerate() {
let source = original.folders.get(i);
let name = source.map(|s| s.name.as_str()).unwrap_or(v.name.as_str());
let ancestry =
ancestry_path(&folder_tree, source.and_then(|s| s.folder_id.as_deref()));
let key = fallback_key("folder", &ancestry, name);
candidates.push((&v.id, plugin_key(source.map(|s| &s.id)), key.clone(), key));
}
for (i, v) in resources.http_requests.iter().enumerate() {
let source = original.http_requests.get(i);
let s = source.unwrap_or(v);
let ancestry = ancestry_path(&folder_tree, s.folder_id.as_deref());
let method = if s.method.is_empty() { "GET" } else { s.method.as_str() };
let route = format!("{method} {}", s.url);
let (by_name, by_content) = derived_pair("http_request", &ancestry, &s.name, &route);
candidates.push((&v.id, plugin_key(source.map(|s| &s.id)), by_name, by_content));
}
for (i, v) in resources.grpc_requests.iter().enumerate() {
let source = original.grpc_requests.get(i);
let s = source.unwrap_or(v);
let ancestry = ancestry_path(&folder_tree, s.folder_id.as_deref());
let service = s.service.clone().unwrap_or_default();
let method = s.method.clone().unwrap_or_default();
let route = format!("{} {service}/{method}", s.url);
let (by_name, by_content) = derived_pair("grpc_request", &ancestry, &s.name, &route);
candidates.push((&v.id, plugin_key(source.map(|s| &s.id)), by_name, by_content));
}
for (i, v) in resources.websocket_requests.iter().enumerate() {
let source = original.websocket_requests.get(i);
let s = source.unwrap_or(v);
let ancestry = ancestry_path(&folder_tree, s.folder_id.as_deref());
let (by_name, by_content) = derived_pair("websocket_request", &ancestry, &s.name, &s.url);
candidates.push((&v.id, plugin_key(source.map(|s| &s.id)), by_name, by_content));
}
// A name shared by several resources identifies none of them, so every member of the group
// falls back to its own content. Counting the whole document first keeps that decision
// independent of the order the resources happen to be listed in.
let mut shared_names: BTreeMap<&str, usize> = BTreeMap::new();
for (_, plugin_key, by_name, _) in &candidates {
if plugin_key.is_none() {
*shared_names.entry(by_name.as_str()).or_default() += 1;
}
}
let mut keys = BTreeMap::new();
let mut used = BTreeSet::new();
for (model_id, plugin_key, by_name, by_content) in &candidates {
let key = match plugin_key {
Some(plugin_key) => (*plugin_key).clone(),
None if shared_names.get(by_name.as_str()).is_some_and(|n| *n > 1) => {
by_content.clone()
}
None => by_name.clone(),
};
// A key identifies one model, so a repeat has to be broken apart rather than overwrite.
// Reaching here means the resources are indistinguishable by name and content alike.
let mut unique = key.clone();
let mut attempt = 1;
while !used.insert(unique.clone()) {
attempt += 1;
unique = format!("{key}~{attempt}");
}
keys.insert((*model_id).to_string(), unique);
}
keys
}
const IDENTITY_SEP: char = '\u{1f}';
/// A resource's key by name, plus the one to use when another resource already took it.
fn derived_pair(model: &str, ancestry: &[String], name: &str, route: &str) -> (String, String) {
let by_name = if name.is_empty() { route } else { name };
let by_content = format!("{by_name}{IDENTITY_SEP}{route}");
(fallback_key(model, ancestry, by_name), fallback_key(model, ancestry, &by_content))
}
/// Stops at the first folder outside the plan, which is the existing folder an import targets.
fn ancestry_path(
folders: &BTreeMap<String, (String, Option<String>)>,
folder_id: Option<&str>,
) -> Vec<String> {
let mut path = Vec::new();
let mut seen = BTreeSet::new();
let mut next = folder_id.map(str::to_string);
while let Some(id) = next {
if !seen.insert(id.clone()) {
break;
}
let Some((name, parent_id)) = folders.get(&id) else {
break;
};
path.push(name.clone());
next = parent_id.clone();
}
path.reverse();
path
}
/// Known limitation: this changes when the source document renames or moves the resource, so a
/// re-import sees a rename as a delete plus an add.
fn fallback_key(model: &str, ancestry: &[String], identity: &str) -> String {
const RECORD: char = '\u{1e}';
let ancestry = ancestry.join(RECORD.to_string().as_str());
format!(
"fb:{:x}",
md5::compute(format!("{model}{IDENTITY_SEP}{ancestry}{IDENTITY_SEP}{identity}"))
)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use yaak_models::models::{EnvironmentVariable, HttpRequestHeader};
fn destination_workspace() -> Workspace {
Workspace {
id: "wk_destination".to_string(),
model: "workspace".to_string(),
name: "Destination".to_string(),
authentication: BTreeMap::from([("token".to_string(), json!("keep-me"))]),
authentication_type: Some("bearer".to_string()),
headers: vec![HttpRequestHeader {
enabled: true,
name: "X-Destination".to_string(),
value: "preserved".to_string(),
id: None,
}],
setting_validate_certificates: false,
setting_follow_redirects: false,
setting_request_timeout: 1234,
..Default::default()
}
}
fn imported_resources() -> ImportResources {
ImportResources {
workspaces: vec![Workspace {
id: "wk_source".to_string(),
model: "workspace".to_string(),
name: "Imported".to_string(),
authentication_type: Some("basic".to_string()),
setting_validate_certificates: true,
..Default::default()
}],
environments: vec![Environment {
id: "ev_source_base".to_string(),
model: "environment".to_string(),
workspace_id: "wk_source".to_string(),
name: "Global Variables".to_string(),
parent_model: "workspace".to_string(),
variables: vec![EnvironmentVariable {
enabled: true,
name: "imported".to_string(),
value: "yes".to_string(),
id: None,
}],
..Default::default()
}],
folders: vec![Folder {
id: "fl_source".to_string(),
model: "folder".to_string(),
workspace_id: "wk_source".to_string(),
name: "Imported Folder".to_string(),
..Default::default()
}],
http_requests: vec![
HttpRequest {
id: "rq_root".to_string(),
model: "http_request".to_string(),
workspace_id: "wk_source".to_string(),
name: "Root Request".to_string(),
method: "GET".to_string(),
url: "https://example.com/root".to_string(),
..Default::default()
},
HttpRequest {
id: "rq_nested".to_string(),
model: "http_request".to_string(),
workspace_id: "wk_source".to_string(),
folder_id: Some("fl_source".to_string()),
name: "Nested Request".to_string(),
method: "GET".to_string(),
url: "https://example.com/nested".to_string(),
..Default::default()
},
],
..Default::default()
}
}
#[test]
fn existing_workspace_plan_does_not_mutate_and_preserves_workspace_settings() {
let (query_manager, _blob_manager, _rx) =
yaak_models::init_in_memory().expect("initialize database");
let mut destination = destination_workspace();
let selected_folder = Folder {
id: "fl_selected".to_string(),
model: "folder".to_string(),
workspace_id: destination.id.clone(),
name: "Selected Folder".to_string(),
..Default::default()
};
{
let db = query_manager.connect();
destination = db
.upsert_workspace(&destination, &UpdateSource::Import)
.expect("create destination");
db.upsert_folder(&selected_folder, &UpdateSource::Import)
.expect("create selected folder");
db.upsert_environment(
&Environment {
id: "ev_destination_base".to_string(),
model: "environment".to_string(),
workspace_id: destination.id.clone(),
name: "Destination Variables".to_string(),
parent_model: "workspace".to_string(),
variables: vec![EnvironmentVariable {
enabled: true,
name: "destination".to_string(),
value: "keep".to_string(),
id: None,
}],
..Default::default()
},
&UpdateSource::Import,
)
.expect("create base environment");
}
let plan = plan_import_resources(
&query_manager,
"OpenAPI".to_string(),
ImportDestination::ExistingWorkspace {
workspace_id: destination.id.clone(),
folder_id: Some(selected_folder.id.clone()),
},
imported_resources(),
None,
None,
)
.expect("plan import");
// Planning performed only reads.
{
let db = query_manager.connect();
assert_eq!(db.list_workspaces().expect("list workspaces").len(), 1);
assert_eq!(db.list_folders(&destination.id).expect("list folders").len(), 1);
assert!(db.list_http_requests(&destination.id).expect("list requests").is_empty());
assert_eq!(
db.list_environments_ensure_base(&destination.id).expect("list environments").len(),
1
);
assert_eq!(db.get_workspace(&destination.id).expect("get destination"), destination);
}
assert!(plan.resources.workspaces.is_empty());
assert_eq!(plan.resources.folders[0].workspace_id, destination.id);
assert_eq!(
plan.resources.folders[0].folder_id.as_deref(),
Some(selected_folder.id.as_str())
);
let root_request = plan
.resources
.http_requests
.iter()
.find(|v| v.name == "Root Request")
.expect("root request");
assert_eq!(root_request.folder_id.as_deref(), Some(selected_folder.id.as_str()));
let nested_request = plan
.resources
.http_requests
.iter()
.find(|v| v.name == "Nested Request")
.expect("nested request");
assert_eq!(nested_request.folder_id, Some(plan.resources.folders[0].id.clone()));
assert_eq!(plan.resources.environments[0].parent_model, "environment");
assert!(plan.resources.environments[0].name.ends_with("(Imported)"));
assert_eq!(plan.warnings.len(), 2);
assert!(plan.warnings.iter().any(|warning| {
warning.title == "Workspace settings skipped"
&& warning.detail.starts_with("Imported ·")
&& warning.detail.contains("authentication")
&& warning.detail.contains("default headers")
}));
assert!(plan.warnings.iter().any(|warning| {
warning.title == "Base environment kept separate"
&& warning.detail == "Global Variables → Global Variables (Imported) · 1 variable"
}));
let committed = commit_import_plan(&query_manager, plan).expect("commit import");
assert!(committed.workspaces.is_empty());
assert_eq!(committed.http_requests.len(), 2);
assert_eq!(
query_manager
.connect()
.get_workspace(&destination.id)
.expect("get destination after commit"),
destination
);
}
#[test]
fn environment_collisions_are_explicit_and_do_not_overwrite() {
let (query_manager, _blob_manager, _rx) =
yaak_models::init_in_memory().expect("initialize database");
let mut resources = imported_resources();
resources.environments.extend([
Environment {
id: "ev_second_base".to_string(),
model: "environment".to_string(),
workspace_id: "wk_source".to_string(),
name: "Second Base".to_string(),
parent_model: "workspace".to_string(),
..Default::default()
},
Environment {
id: "ev_folder_one".to_string(),
model: "environment".to_string(),
workspace_id: "wk_source".to_string(),
name: "Folder One".to_string(),
parent_model: "folder".to_string(),
parent_id: Some("fl_source".to_string()),
..Default::default()
},
Environment {
id: "ev_folder_two".to_string(),
model: "environment".to_string(),
workspace_id: "wk_source".to_string(),
name: "Folder Two".to_string(),
parent_model: "folder".to_string(),
parent_id: Some("fl_source".to_string()),
..Default::default()
},
]);
let plan = plan_import_resources(
&query_manager,
"Yaak".to_string(),
ImportDestination::NewWorkspace,
resources,
None,
None,
)
.expect("plan import");
assert_eq!(
plan.resources.environments.iter().filter(|v| v.parent_model == "workspace").count(),
1
);
assert_eq!(
plan.resources.environments.iter().filter(|v| v.parent_model == "folder").count(),
1
);
assert_eq!(plan.warnings.len(), 2);
}
#[test]
fn importer_id_conventions_all_flow_through_the_same_planner() {
let (query_manager, _blob_manager, _rx) =
yaak_models::init_in_memory().expect("initialize database");
let destination = destination_workspace();
query_manager
.connect()
.upsert_workspace(&destination, &UpdateSource::Import)
.expect("create destination");
let resources = ImportResources {
workspaces: vec![
Workspace {
id: "GENERATE_ID::WORKSPACE_0".to_string(),
model: "workspace".to_string(),
name: "Generated ID Importer".to_string(),
..Default::default()
},
Workspace {
id: "wk_exported".to_string(),
model: "workspace".to_string(),
name: "Stable ID Importer".to_string(),
..Default::default()
},
],
http_requests: [
"GENERATE_ID::WORKSPACE_0",
"wk_exported",
"CURRENT_WORKSPACE",
]
.into_iter()
.enumerate()
.map(|(index, workspace_id)| HttpRequest {
id: format!("GENERATE_ID::HTTP_REQUEST_{index}"),
model: "http_request".to_string(),
workspace_id: workspace_id.to_string(),
name: format!("Request {index}"),
method: "GET".to_string(),
..Default::default()
})
.collect(),
..Default::default()
};
let plan = plan_import_resources(
&query_manager,
"Compatibility".to_string(),
ImportDestination::ExistingWorkspace {
workspace_id: destination.id.clone(),
folder_id: None,
},
resources,
None,
None,
)
.expect("plan import");
assert!(plan.resources.workspaces.is_empty());
assert!(plan.resources.http_requests.iter().all(|v| v.workspace_id == destination.id));
assert_eq!(
plan.resources
.http_requests
.iter()
.map(|v| v.id.as_str())
.collect::<BTreeSet<_>>()
.len(),
3
);
}
#[test]
fn commit_rolls_back_every_resource_when_a_late_write_fails() {
let dir = tempfile::tempdir().expect("create temp directory");
let db_path = dir.path().join("models.sqlite");
let blob_path = dir.path().join("blobs.sqlite");
let (query_manager, _blob_manager, _rx) =
yaak_models::init_standalone(&db_path, &blob_path).expect("initialize database");
let plan = plan_import_resources(
&query_manager,
"OpenAPI".to_string(),
ImportDestination::NewWorkspace,
imported_resources(),
None,
Some(linked_origin()),
)
.expect("plan import");
let workspace_id = plan.resources.workspaces[0].id.clone();
let environment_id = plan.resources.environments[0].id.clone();
let connection = rusqlite::Connection::open(&db_path).expect("open test database");
connection
.execute_batch(&format!(
"CREATE TRIGGER fail_import_environment BEFORE INSERT ON environments \
WHEN NEW.id = '{environment_id}' BEGIN SELECT RAISE(FAIL, 'forced failure'); END;"
))
.expect("install failure trigger");
drop(connection);
assert!(commit_import_plan(&query_manager, plan).is_err());
let db = query_manager.connect();
assert!(db.get_workspace(&workspace_id).is_err(), "workspace insert must roll back");
assert!(db.get_environment(&environment_id).is_err(), "environment must not exist");
assert!(
db.list_import_sources(&workspace_id).expect("list import sources").is_empty(),
"import source must roll back"
);
}
fn request_key<'a>(plan: &'a ImportPlan, name: &str) -> &'a str {
let request = plan
.resources
.http_requests
.iter()
.find(|v| v.name == name)
.unwrap_or_else(|| panic!("no planned request named {name}"));
plan.source_keys.get(&request.id).expect("request has a source key")
}
fn plan_with_keys(
resources: ImportResources,
source_keys: Option<BTreeMap<String, String>>,
) -> ImportPlan {
let (query_manager, _blob_manager, _rx) =
yaak_models::init_in_memory().expect("initialize database");
plan_import_resources(
&query_manager,
"Yaak".to_string(),
ImportDestination::NewWorkspace,
resources,
source_keys,
None,
)
.expect("plan import")
}
#[test]
fn every_planned_model_gets_a_source_key() {
let plan = plan_with_keys(imported_resources(), None);
let planned_ids = plan
.resources
.workspaces
.iter()
.map(|v| v.id.clone())
.chain(plan.resources.environments.iter().map(|v| v.id.clone()))
.chain(plan.resources.folders.iter().map(|v| v.id.clone()))
.chain(plan.resources.http_requests.iter().map(|v| v.id.clone()))
.collect::<BTreeSet<_>>();
assert_eq!(plan.source_keys.keys().cloned().collect::<BTreeSet<_>>(), planned_ids);
assert!(
plan.source_keys.values().all(|key| key.starts_with("fb:")),
"an importer that supplied no keys should leave every key derived: {:?}",
plan.source_keys,
);
}
#[test]
fn importer_keys_win_over_derived_ones() {
let source_keys = BTreeMap::from([("rq_nested".to_string(), "op:listPets".to_string())]);
let plan = plan_with_keys(imported_resources(), Some(source_keys));
assert_eq!(request_key(&plan, "Nested Request"), "op:listPets");
assert!(request_key(&plan, "Root Request").starts_with("fb:"));
}
#[test]
fn derived_keys_survive_a_re_parse_that_mints_new_ids() {
let first = plan_with_keys(imported_resources(), None);
let mut edited = imported_resources();
for (i, request) in edited.http_requests.iter_mut().enumerate() {
request.id = format!("reparsed_{i}");
request.url = format!("{}?added=1", request.url);
}
edited.folders[0].id = "reparsed_folder".to_string();
edited.http_requests[1].folder_id = Some("reparsed_folder".to_string());
let second = plan_with_keys(edited, None);
assert_eq!(request_key(&first, "Root Request"), request_key(&second, "Root Request"));
assert_eq!(request_key(&first, "Nested Request"), request_key(&second, "Nested Request"));
}
#[test]
fn derived_keys_distinguish_same_named_requests_by_folder() {
let mut resources = imported_resources();
resources.http_requests[1].name = resources.http_requests[0].name.clone();
let plan = plan_with_keys(resources, None);
let keys = plan.source_keys.values().collect::<BTreeSet<_>>();
assert_eq!(keys.len(), plan.source_keys.len(), "keys collided: {:?}", plan.source_keys);
}
#[test]
fn duplicate_keys_are_broken_apart_so_none_are_lost() {
let mut resources = imported_resources();
resources.http_requests[1].folder_id = None;
resources.http_requests[1].name = resources.http_requests[0].name.clone();
resources.http_requests[1].url = resources.http_requests[0].url.clone();
let plan = plan_with_keys(resources, None);
assert_eq!(plan.source_keys.len(), 5);
let keys = plan.source_keys.values().collect::<BTreeSet<_>>();
assert_eq!(keys.len(), 5, "keys collided: {:?}", plan.source_keys);
assert!(
plan.source_keys.values().any(|key| key.ends_with("~2")),
"the repeat should be suffixed: {:?}",
plan.source_keys,
);
}
#[test]
fn same_named_siblings_keep_their_keys_when_the_document_reorders() {
let build = |swap: bool| {
let mut resources = imported_resources();
resources.http_requests[0].name = "Get".to_string();
resources.http_requests[1].name = "Get".to_string();
resources.http_requests[0].folder_id = Some("fl_source".to_string());
resources.http_requests[0].url = "https://example.com/a".to_string();
resources.http_requests[1].url = "https://example.com/b".to_string();
if swap {
resources.http_requests.swap(0, 1);
}
let plan = plan_with_keys(resources, None);
plan.resources
.http_requests
.iter()
.map(|r| (r.url.clone(), plan.source_keys[&r.id].clone()))
.collect::<BTreeMap<_, _>>()
};
// Listing the pair the other way round must not hand each other's key over, or a later
// re-import would credit one request's edits to the other.
assert_eq!(build(false), build(true));
}
#[test]
fn derived_keys_are_prefixed_so_importer_keys_stay_distinguishable() {
let source_keys = BTreeMap::from([("rq_root".to_string(), "op:root".to_string())]);
let plan = plan_with_keys(imported_resources(), Some(source_keys));
assert!(!request_key(&plan, "Root Request").starts_with("fb:"));
assert!(request_key(&plan, "Nested Request").starts_with("fb:"));
}
fn linked_origin() -> ImportOrigin {
ImportOrigin { origin: "/tmp/api.yaml".to_string(), label: "api.yaml".to_string() }
}
fn importer_keys() -> BTreeMap<String, String> {
BTreeMap::from([
("ev_source_base".to_string(), "env:base".to_string()),
("fl_source".to_string(), "folder:src".to_string()),
("rq_root".to_string(), "op:root".to_string()),
("rq_nested".to_string(), "op:nested".to_string()),
("rq_extra".to_string(), "op:extra".to_string()),
("fl_extra".to_string(), "folder:extra".to_string()),
])
}
fn first_import(query_manager: &QueryManager) -> BatchUpsertResult {
let plan = plan_import_resources(
query_manager,
"OpenAPI".to_string(),
ImportDestination::NewWorkspace,
imported_resources(),
Some(importer_keys()),
Some(linked_origin()),
)
.expect("plan first import");
commit_import_plan(query_manager, plan).expect("commit first import")
}
fn replan(
query_manager: &QueryManager,
workspace_id: &str,
resources: ImportResources,
) -> ImportPlan {
replan_from(query_manager, workspace_id, resources, linked_origin())
}
fn replan_from(
query_manager: &QueryManager,
workspace_id: &str,
resources: ImportResources,
origin: ImportOrigin,
) -> ImportPlan {
plan_import_resources(
query_manager,
"OpenAPI".to_string(),
ImportDestination::ExistingWorkspace {
workspace_id: workspace_id.to_string(),
folder_id: None,
},
resources,
Some(importer_keys()),
Some(origin),
)
.expect("plan re-import")
}
fn extra_request() -> HttpRequest {
HttpRequest {
id: "rq_extra".to_string(),
model: "http_request".to_string(),
workspace_id: "wk_source".to_string(),
name: "Extra Request".to_string(),
method: "GET".to_string(),
url: "https://example.com/extra".to_string(),
..Default::default()
}
}
fn select(plan: &mut ImportPlan, name: &str, selected: bool) {
let item = plan
.items
.iter_mut()
.find(|i| i.name == name)
.unwrap_or_else(|| panic!("no plan item named {name}"));
item.selected = selected;
}
fn source_rows(query_manager: &QueryManager, workspace_id: &str) -> Vec<ImportSourceResource> {
let db = query_manager.connect();
let sources = db.list_import_sources(workspace_id).expect("list import sources");
assert_eq!(sources.len(), 1, "expected one linked source: {sources:?}");
db.list_import_source_resources(&sources[0].id).expect("list resource rows")
}
fn item_by_name<'a>(plan: &'a ImportPlan, name: &str) -> &'a ImportPlanItem {
plan.items
.iter()
.find(|i| i.name == name)
.unwrap_or_else(|| panic!("no plan item named {name}: {:?}", plan.items))
}
#[test]
fn commit_records_the_linked_source_and_hashes() {
let (query_manager, _blob_manager, _rx) =
yaak_models::init_in_memory().expect("initialize database");
let committed = first_import(&query_manager);
let workspace_id = committed.workspaces[0].id.clone();
let source = {
let db = query_manager.connect();
let source = db
.find_import_source(&workspace_id, "OpenAPI", "/tmp/api.yaml")
.expect("query import source")
.expect("import source recorded");
assert_eq!(source.origin_label, "api.yaml");
let rows = db.list_import_source_resources(&source.id).expect("list resource rows");
assert_eq!(rows.len(), 4, "one row per non-workspace resource: {rows:?}");
for row in &rows {
let model_id = row.model_id.as_deref().expect("row is wanted");
let resource = ImportResourceType::from_str(&row.model_type)
.expect("row has a known resource type");
let current = existing_model_json(&db, resource, model_id)
.expect("query current model")
.expect("row target exists");
assert_eq!(row.content_hash.as_deref(), Some(content_hash(current).as_str()));
}
source
};
// Re-importing the identical document is a no-op offer: everything unchanged,
// ancestry re-rooting does not count as a change, and the base environment it
// created stays the base environment.
let plan = replan(&query_manager, &workspace_id, imported_resources());
assert!(
plan.items.iter().all(|i| i.action == ImportPlanAction::Unchanged),
"expected all unchanged: {:?}",
plan.items
);
let base = plan
.resources
.environments
.iter()
.find(|e| e.name == "Global Variables")
.expect("base environment keeps its original name");
assert_eq!(base.parent_model, "workspace");
commit_import_plan(&query_manager, plan).expect("commit re-import");
let db = query_manager.connect();
assert_eq!(db.list_http_requests(&workspace_id).expect("list requests").len(), 2);
assert_eq!(db.list_folders(&workspace_id).expect("list folders").len(), 1);
assert_eq!(
db.list_environments_ensure_base(&workspace_id).expect("list environments").len(),
1
);
let rows = db.list_import_source_resources(&source.id).expect("list resource rows");
assert_eq!(rows.len(), 4, "re-commit replaces rows instead of accumulating");
}
#[test]
fn re_import_merges_source_and_local_changes() {
let (query_manager, _blob_manager, _rx) =
yaak_models::init_in_memory().expect("initialize database");
let committed = first_import(&query_manager);
let workspace_id = committed.workspaces[0].id.clone();
let root_id = committed
.http_requests
.iter()
.find(|r| r.name == "Root Request")
.expect("root request")
.id
.clone();
{
let db = query_manager.connect();
let nested = db
.list_http_requests(&workspace_id)
.expect("list requests")
.into_iter()
.find(|r| r.name == "Nested Request")
.expect("nested request");
db.upsert_http_request(
&HttpRequest {
url: "https://example.com/nested-local".to_string(),
..nested.clone()
},
&UpdateSource::Background,
)
.expect("edit nested request locally");
}
let mut resources = imported_resources();
resources.http_requests[0].url = "https://example.com/root-v2".to_string();
resources.http_requests.push(HttpRequest {
id: "rq_extra".to_string(),
model: "http_request".to_string(),
workspace_id: "wk_source".to_string(),
name: "Extra Request".to_string(),
method: "GET".to_string(),
url: "https://example.com/extra".to_string(),
..Default::default()
});
let plan = replan(&query_manager, &workspace_id, resources);
let root = item_by_name(&plan, "Root Request");
assert_eq!(root.action, ImportPlanAction::Update);
assert!(root.selected);
assert_eq!(root.model_id, root_id, "update targets the mapped model");
let nested = item_by_name(&plan, "Nested Request");
assert_eq!(nested.action, ImportPlanAction::KeepLocal);
assert!(!nested.selected);
let extra = item_by_name(&plan, "Extra Request");
assert_eq!(extra.action, ImportPlanAction::Create);
assert!(extra.selected);
assert_eq!(item_by_name(&plan, "Imported Folder").action, ImportPlanAction::Unchanged);
commit_import_plan(&query_manager, plan).expect("commit merge");
let db = query_manager.connect();
let requests = db.list_http_requests(&workspace_id).expect("list requests");
assert_eq!(requests.len(), 3);
assert_eq!(
requests.iter().find(|r| r.name == "Root Request").expect("root").url,
"https://example.com/root-v2"
);
assert_eq!(
requests.iter().find(|r| r.name == "Nested Request").expect("nested").url,
"https://example.com/nested-local"
);
}
#[test]
fn rename_in_source_updates_the_same_model() {
let (query_manager, _blob_manager, _rx) =
yaak_models::init_in_memory().expect("initialize database");
let committed = first_import(&query_manager);
let workspace_id = committed.workspaces[0].id.clone();
let root_id = committed
.http_requests
.iter()
.find(|r| r.name == "Root Request")
.expect("root request")
.id
.clone();
let mut resources = imported_resources();
resources.http_requests[0].name = "Root Request Renamed".to_string();
let plan = replan(&query_manager, &workspace_id, resources);
let renamed = item_by_name(&plan, "Root Request Renamed");
assert_eq!(renamed.action, ImportPlanAction::Update);
assert_eq!(renamed.model_id, root_id);
commit_import_plan(&query_manager, plan).expect("commit rename");
let requests =
query_manager.connect().list_http_requests(&workspace_id).expect("list requests");
assert_eq!(requests.len(), 2, "rename must not duplicate");
assert!(requests.iter().any(|r| r.id == root_id && r.name == "Root Request Renamed"));
}
#[test]
fn keep_mine_conflicts_advance_and_are_not_offered_again() {
let (query_manager, _blob_manager, _rx) =
yaak_models::init_in_memory().expect("initialize database");
let committed = first_import(&query_manager);
let workspace_id = committed.workspaces[0].id.clone();
let root_id = committed
.http_requests
.iter()
.find(|r| r.name == "Root Request")
.expect("root request")
.id
.clone();
{
let db = query_manager.connect();
let root = db.get_http_request(&root_id).expect("get root");
db.upsert_http_request(
&HttpRequest { url: "https://example.com/root-local".to_string(), ..root },
&UpdateSource::Background,
)
.expect("edit root locally");
}
let mut resources = imported_resources();
resources.http_requests[0].url = "https://example.com/root-v2".to_string();
let plan = replan(&query_manager, &workspace_id, resources.clone());
let root = item_by_name(&plan, "Root Request");
assert_eq!(root.action, ImportPlanAction::Conflict);
assert_eq!(root.resolution, Some(ImportConflictResolution::KeepMine));
commit_import_plan(&query_manager, plan).expect("commit keep-mine");
assert_eq!(
query_manager.connect().get_http_request(&root_id).expect("get root").url,
"https://example.com/root-local",
"keep-mine must not overwrite the local edit"
);
// The decision was recorded, so the same source version stops nagging.
let plan = replan(&query_manager, &workspace_id, resources);
assert_eq!(item_by_name(&plan, "Root Request").action, ImportPlanAction::KeepLocal);
// A newer source version conflicts again; taking it overwrites the local edit.
let mut resources = imported_resources();
resources.http_requests[0].url = "https://example.com/root-v3".to_string();
let mut plan = replan(&query_manager, &workspace_id, resources.clone());
assert_eq!(item_by_name(&plan, "Root Request").action, ImportPlanAction::Conflict);
for item in plan.items.iter_mut() {
if item.action == ImportPlanAction::Conflict {
item.resolution = Some(ImportConflictResolution::TakeSource);
}
}
commit_import_plan(&query_manager, plan).expect("commit take-source");
assert_eq!(
query_manager.connect().get_http_request(&root_id).expect("get root").url,
"https://example.com/root-v3"
);
let plan = replan(&query_manager, &workspace_id, resources);
assert_eq!(item_by_name(&plan, "Root Request").action, ImportPlanAction::Unchanged);
}
#[test]
fn deselected_update_is_offered_again_next_import() {
let (query_manager, _blob_manager, _rx) =
yaak_models::init_in_memory().expect("initialize database");
let committed = first_import(&query_manager);
let workspace_id = committed.workspaces[0].id.clone();
let mut resources = imported_resources();
resources.http_requests[0].url = "https://example.com/root-v2".to_string();
let mut plan = replan(&query_manager, &workspace_id, resources.clone());
for item in plan.items.iter_mut() {
if item.action == ImportPlanAction::Update {
item.selected = false;
}
}
commit_import_plan(&query_manager, plan).expect("commit with deselected update");
let db = query_manager.connect();
let root = db
.list_http_requests(&workspace_id)
.expect("list requests")
.into_iter()
.find(|r| r.name == "Root Request")
.expect("root request");
assert_eq!(root.url, "https://example.com/root", "deselected update must not apply");
drop(db);
let plan = replan(&query_manager, &workspace_id, resources);
assert_eq!(item_by_name(&plan, "Root Request").action, ImportPlanAction::Update);
}
#[test]
fn source_removals_are_deselected_offers_until_applied() {
let (query_manager, _blob_manager, _rx) =
yaak_models::init_in_memory().expect("initialize database");
let committed = first_import(&query_manager);
let workspace_id = committed.workspaces[0].id.clone();
let mut resources = imported_resources();
resources.http_requests.remove(1);
let plan = replan(&query_manager, &workspace_id, resources.clone());
let removal = item_by_name(&plan, "Nested Request");
assert_eq!(removal.action, ImportPlanAction::Delete);
assert!(!removal.selected, "deletions default to deselected");
commit_import_plan(&query_manager, plan).expect("commit with default selection");
assert_eq!(
query_manager.connect().list_http_requests(&workspace_id).expect("list").len(),
2,
"deselected deletion must not delete"
);
let mut plan = replan(&query_manager, &workspace_id, resources.clone());
assert_eq!(item_by_name(&plan, "Nested Request").action, ImportPlanAction::Delete);
for item in plan.items.iter_mut() {
if item.action == ImportPlanAction::Delete {
item.selected = true;
}
}
commit_import_plan(&query_manager, plan).expect("commit with deletion");
assert_eq!(
query_manager.connect().list_http_requests(&workspace_id).expect("list").len(),
1
);
let plan = replan(&query_manager, &workspace_id, resources);
assert!(
plan.items.iter().all(|i| i.action != ImportPlanAction::Delete),
"applied deletion must not be offered again: {:?}",
plan.items
);
}
#[test]
fn locally_deleted_mapped_model_is_not_resurrected() {
let (query_manager, _blob_manager, _rx) =
yaak_models::init_in_memory().expect("initialize database");
let committed = first_import(&query_manager);
let workspace_id = committed.workspaces[0].id.clone();
let root_id = committed
.http_requests
.iter()
.find(|r| r.name == "Root Request")
.expect("root request")
.id
.clone();
query_manager
.connect()
.delete_http_request_by_id(&root_id, &UpdateSource::Background)
.expect("delete root locally");
let plan = replan(&query_manager, &workspace_id, imported_resources());
let root = item_by_name(&plan, "Root Request");
assert_eq!(root.action, ImportPlanAction::NotImported, "deleting it means not wanting it");
assert!(!root.selected);
assert_ne!(root.model_id, root_id);
commit_import_plan(&query_manager, plan).expect("commit re-import");
assert_eq!(
query_manager.connect().list_http_requests(&workspace_id).expect("list").len(),
1,
"a locally deleted resource must not come back"
);
let plan = replan(&query_manager, &workspace_id, imported_resources());
assert_eq!(item_by_name(&plan, "Root Request").action, ImportPlanAction::NotImported);
}
#[test]
fn deselected_create_is_not_offered_again() {
let (query_manager, _blob_manager, _rx) =
yaak_models::init_in_memory().expect("initialize database");
let committed = first_import(&query_manager);
let workspace_id = committed.workspaces[0].id.clone();
let mut resources = imported_resources();
resources.http_requests.push(extra_request());
let mut plan = replan(&query_manager, &workspace_id, resources.clone());
assert_eq!(item_by_name(&plan, "Extra Request").action, ImportPlanAction::Create);
select(&mut plan, "Extra Request", false);
commit_import_plan(&query_manager, plan).expect("commit with deselected create");
assert_eq!(
query_manager.connect().list_http_requests(&workspace_id).expect("list").len(),
2,
"a deselected create must not be created"
);
let plan = replan(&query_manager, &workspace_id, resources);
let extra = item_by_name(&plan, "Extra Request");
assert_eq!(extra.action, ImportPlanAction::NotImported, "deselecting it is remembered");
assert!(!extra.selected);
}
#[test]
fn restoring_a_not_imported_item_relinks_its_source_key() {
let (query_manager, _blob_manager, _rx) =
yaak_models::init_in_memory().expect("initialize database");
let committed = first_import(&query_manager);
let workspace_id = committed.workspaces[0].id.clone();
let mut resources = imported_resources();
resources.http_requests.push(extra_request());
let mut plan = replan(&query_manager, &workspace_id, resources.clone());
select(&mut plan, "Extra Request", false);
commit_import_plan(&query_manager, plan).expect("commit with deselected create");
let mut plan = replan(&query_manager, &workspace_id, resources.clone());
assert_eq!(item_by_name(&plan, "Extra Request").action, ImportPlanAction::NotImported);
select(&mut plan, "Extra Request", true);
commit_import_plan(&query_manager, plan).expect("commit with restored item");
let extra = query_manager
.connect()
.list_http_requests(&workspace_id)
.expect("list")
.into_iter()
.find(|r| r.name == "Extra Request")
.expect("restored request exists");
let rows = source_rows(&query_manager, &workspace_id);
let row = rows.iter().find(|r| r.source_key == "op:extra").expect("row for the same key");
assert_eq!(row.model_id.as_deref(), Some(extra.id.as_str()), "the key links to the model");
let plan = replan(&query_manager, &workspace_id, resources);
assert_eq!(item_by_name(&plan, "Extra Request").action, ImportPlanAction::Unchanged);
}
#[test]
fn moving_into_a_not_imported_folder_offers_a_delete() {
let (query_manager, _blob_manager, _rx) =
yaak_models::init_in_memory().expect("initialize database");
let committed = first_import(&query_manager);
let workspace_id = committed.workspaces[0].id.clone();
let with_extra_folder = |root_inside: bool| {
let mut resources = imported_resources();
resources.folders.push(Folder {
id: "fl_extra".to_string(),
model: "folder".to_string(),
workspace_id: "wk_source".to_string(),
name: "Extra Folder".to_string(),
..Default::default()
});
if root_inside {
resources.http_requests[0].folder_id = Some("fl_extra".to_string());
}
resources
};
let mut plan = replan(&query_manager, &workspace_id, with_extra_folder(false));
select(&mut plan, "Extra Folder", false);
commit_import_plan(&query_manager, plan).expect("commit without the new folder");
// The source moves an imported request into the folder that was turned down.
let plan = replan(&query_manager, &workspace_id, with_extra_folder(true));
assert_eq!(item_by_name(&plan, "Extra Folder").action, ImportPlanAction::NotImported);
let root = item_by_name(&plan, "Root Request");
assert_eq!(root.action, ImportPlanAction::Delete);
assert!(!root.selected, "a deletion is never applied by default");
assert_eq!(root.reason, Some(ImportPlanReason::MovedIntoNotImportedFolder));
// Anything new inside that folder can't be created either, so it waits for the folder.
let mut resources = with_extra_folder(true);
resources.http_requests.push(HttpRequest {
folder_id: Some("fl_extra".to_string()),
..extra_request()
});
let plan = replan(&query_manager, &workspace_id, resources);
let extra = item_by_name(&plan, "Extra Request");
assert_eq!(extra.action, ImportPlanAction::Create);
assert!(!extra.selected, "a create with nowhere to go starts unchecked");
let mut plan = replan(&query_manager, &workspace_id, with_extra_folder(true));
select(&mut plan, "Root Request", true);
commit_import_plan(&query_manager, plan).expect("commit the move as a deletion");
let db = query_manager.connect();
let requests = db.list_http_requests(&workspace_id).expect("list");
assert!(!requests.iter().any(|r| r.name == "Root Request"), "accepting deletes it");
drop(db);
let plan = replan(&query_manager, &workspace_id, with_extra_folder(true));
assert_eq!(
item_by_name(&plan, "Root Request").action,
ImportPlanAction::NotImported,
"accepting the deletion means the resource is no longer wanted"
);
}
#[test]
fn a_source_that_moved_still_merges_and_updates_its_origin() {
let (query_manager, _blob_manager, _rx) =
yaak_models::init_in_memory().expect("initialize database");
let committed = first_import(&query_manager);
let workspace_id = committed.workspaces[0].id.clone();
let moved =
ImportOrigin { origin: "/tmp/api/v1.yaml".to_string(), label: "v1.yaml".to_string() };
let mut resources = imported_resources();
resources.http_requests[0].url = "https://example.com/root-v2".to_string();
let plan = replan_from(&query_manager, &workspace_id, resources, moved.clone());
assert!(plan.warnings.iter().all(|w| w.title != "Imported as new"), "{:?}", plan.warnings);
assert_eq!(item_by_name(&plan, "Root Request").action, ImportPlanAction::Update);
commit_import_plan(&query_manager, plan).expect("commit merge from the new path");
let db = query_manager.connect();
assert_eq!(
db.list_http_requests(&workspace_id).expect("list").len(),
2,
"a renamed file must not duplicate what it created"
);
let sources = db.list_import_sources(&workspace_id).expect("list import sources");
assert_eq!(sources.len(), 1, "the link follows the file: {sources:?}");
assert_eq!(sources[0].origin, moved.origin);
assert_eq!(sources[0].origin_label, moved.label);
}
#[test]
fn an_ambiguous_overlap_warns_instead_of_merging() {
let (query_manager, _blob_manager, _rx) =
yaak_models::init_in_memory().expect("initialize database");
let committed = first_import(&query_manager);
let workspace_id = committed.workspaces[0].id.clone();
// A second source claiming the same keys leaves nothing to merge into safely.
{
let db = query_manager.connect();
let other = db
.upsert_import_source(
&ImportSource {
workspace_id: workspace_id.clone(),
importer: "OpenAPI".to_string(),
origin: "/tmp/copy.yaml".to_string(),
origin_label: "copy.yaml".to_string(),
..Default::default()
},
&UpdateSource::Import,
)
.expect("create second source");
db.upsert_import_source_resource(&ImportSourceResource {
import_source_id: other.id,
source_key: "op:root".to_string(),
model_type: "http_request".to_string(),
..Default::default()
})
.expect("claim the same key");
}
let third =
ImportOrigin { origin: "/tmp/third.yaml".to_string(), label: "third.yaml".to_string() };
let plan = replan_from(&query_manager, &workspace_id, imported_resources(), third);
assert!(
plan.items.iter().all(|i| i.action == ImportPlanAction::Create),
"an ambiguous link must never guess: {:?}",
plan.items
);
let warning = plan
.warnings
.iter()
.find(|w| w.title == "Imported as new")
.expect("ambiguity is surfaced");
assert!(warning.detail.contains("api.yaml"), "{warning:?}");
assert!(warning.detail.contains("copy.yaml"), "{warning:?}");
}
#[test]
fn an_unrecognized_hash_degrades_to_a_conflict() {
let (query_manager, _blob_manager, _rx) =
yaak_models::init_in_memory().expect("initialize database");
let committed = first_import(&query_manager);
let workspace_id = committed.workspaces[0].id.clone();
{
let db = query_manager.connect();
let sources = db.list_import_sources(&workspace_id).expect("list import sources");
let row = db
.list_import_source_resources(&sources[0].id)
.expect("list rows")
.into_iter()
.find(|r| r.source_key == "op:root")
.expect("row for the root request");
db.upsert_import_source_resource(&ImportSourceResource {
content_hash: Some("v99:from-the-future".to_string()),
..row
})
.expect("write an unreadable hash");
}
let plan = replan(&query_manager, &workspace_id, imported_resources());
assert_eq!(
item_by_name(&plan, "Root Request").action,
ImportPlanAction::Unchanged,
"with nothing to compare against, matching sides are still unchanged"
);
let mut resources = imported_resources();
resources.http_requests[0].url = "https://example.com/root-v2".to_string();
let plan = replan(&query_manager, &workspace_id, resources);
let root = item_by_name(&plan, "Root Request");
assert_eq!(root.action, ImportPlanAction::Conflict, "a difference can't be attributed");
assert_eq!(root.resolution, Some(ImportConflictResolution::KeepMine));
}
#[test]
fn reordering_the_source_document_changes_nothing() {
let (query_manager, _blob_manager, _rx) =
yaak_models::init_in_memory().expect("initialize database");
let committed = first_import(&query_manager);
let workspace_id = committed.workspaces[0].id.clone();
// Inserting anything ahead of an existing resource renumbers everything after it.
let mut resources = imported_resources();
for (i, request) in resources.http_requests.iter_mut().enumerate() {
request.sort_priority = (i as f64 + 1.0) * 1000.0;
}
resources.folders[0].sort_priority = 500.0;
let plan = replan(&query_manager, &workspace_id, resources);
assert!(
plan.items.iter().all(|i| i.action == ImportPlanAction::Unchanged),
"a reorder is not a change: {:?}",
plan.items
);
}
#[test]
fn deselecting_a_new_folder_skips_its_descendants() {
let (query_manager, _blob_manager, _rx) =
yaak_models::init_in_memory().expect("initialize database");
let mut plan = plan_import_resources(
&query_manager,
"OpenAPI".to_string(),
ImportDestination::NewWorkspace,
imported_resources(),
Some(importer_keys()),
Some(linked_origin()),
)
.expect("plan import");
assert_eq!(plan.items.len(), 4, "one create item per non-workspace resource");
assert!(plan.items.iter().all(|i| i.action == ImportPlanAction::Create && i.selected));
for item in plan.items.iter_mut() {
if item.model == ImportResourceType::Folder {
item.selected = false;
}
}
let committed = commit_import_plan(&query_manager, plan).expect("commit import");
let workspace_id = committed.workspaces[0].id.clone();
let db = query_manager.connect();
assert!(db.list_folders(&workspace_id).expect("list folders").is_empty());
let requests = db.list_http_requests(&workspace_id).expect("list requests");
assert_eq!(requests.len(), 1, "requests inside the skipped folder are skipped too");
assert_eq!(requests[0].name, "Root Request");
let source = db
.find_import_source(&workspace_id, "OpenAPI", "/tmp/api.yaml")
.expect("query import source")
.expect("import source recorded");
let rows = db.list_import_source_resources(&source.id).expect("list resource rows");
assert_eq!(rows.len(), 4, "every key is remembered, wanted or not: {rows:?}");
let not_wanted = rows.iter().filter(|r| r.model_id.is_none()).count();
assert_eq!(not_wanted, 2, "the skipped folder and its request are remembered: {rows:?}");
}
#[test]
fn desktop_style_json_roundtrip_records_source() {
let (query_manager, _blob_manager, _rx) =
yaak_models::init_in_memory().expect("initialize database");
let plan = plan_import_resources(
&query_manager,
"OpenAPI".to_string(),
ImportDestination::NewWorkspace,
imported_resources(),
None,
Some(linked_origin()),
)
.expect("plan import");
let json = serde_json::to_string(&plan).expect("serialize plan");
let plan: ImportPlan = serde_json::from_str(&json).expect("deserialize plan");
let committed = commit_import_plan(&query_manager, plan).expect("commit");
let workspace_id = committed.workspaces[0].id.clone();
let source = query_manager
.connect()
.find_import_source(&workspace_id, "OpenAPI", "/tmp/api.yaml")
.expect("query")
.expect("source recorded after JSON round-trip");
assert_eq!(source.origin_label, "api.yaml");
}
#[test]
fn selected_keep_local_reverts_the_local_edit() {
let (query_manager, _blob_manager, _rx) =
yaak_models::init_in_memory().expect("initialize database");
let committed = first_import(&query_manager);
let workspace_id = committed.workspaces[0].id.clone();
let root_id = committed
.http_requests
.iter()
.find(|r| r.name == "Root Request")
.expect("root request")
.id
.clone();
{
let db = query_manager.connect();
let root = db.get_http_request(&root_id).expect("get root");
db.upsert_http_request(
&HttpRequest { url: "https://example.com/root-local".to_string(), ..root },
&UpdateSource::Background,
)
.expect("edit root locally");
}
let mut plan = replan(&query_manager, &workspace_id, imported_resources());
let root = item_by_name(&plan, "Root Request");
assert_eq!(root.action, ImportPlanAction::KeepLocal);
assert!(!root.selected, "keep-local defaults to keeping the local edit");
for item in plan.items.iter_mut() {
if item.action == ImportPlanAction::KeepLocal {
item.selected = true;
}
}
commit_import_plan(&query_manager, plan).expect("commit revert");
assert_eq!(
query_manager.connect().get_http_request(&root_id).expect("get root").url,
"https://example.com/root",
"selected keep-local must revert to the source version"
);
let plan = replan(&query_manager, &workspace_id, imported_resources());
assert_eq!(item_by_name(&plan, "Root Request").action, ImportPlanAction::Unchanged);
}
}