use crate::ankiconnect::{AnkiAction, AnkiConnect, Field, Note as AnkiNote, NoteId};
use crate::cache::{Cache, CacheEntry, Label, Sha256};
use crate::compile::{compile_temp_file, CompileConfig, Format};
use crate::error::{Error, Result};
use crate::generate::generate_temp_file;
use crate::metadata::{AnkiConnectChecks, CompletedNote, CompletedTypstAnkifyConfiguration};
use crate::query::{
complete_ankify_notes_metadata, query_ankify_configuration, query_ankify_notes,
};
use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use tokio::fs;
use tracing::{debug, info, warn};
#[derive(Debug, Clone)]
pub struct SyncConfig {
pub source_file: PathBuf,
pub verbose: bool,
pub cache_file: Option<PathBuf>,
pub ankiconnect_url: Option<String>,
pub extra_args: Vec<String>,
pub cli_mode: bool,
pub keep_artifacts: bool,
}
impl SyncConfig {
pub fn new<P: Into<PathBuf>>(source_file: P) -> Self {
Self {
source_file: source_file.into(),
verbose: false,
cache_file: None,
ankiconnect_url: None,
extra_args: Vec::new(),
cli_mode: false,
keep_artifacts: false,
}
}
pub fn with_verbose(mut self, verbose: bool) -> Self {
self.verbose = verbose;
self
}
pub fn with_cache_file<P: Into<PathBuf>>(mut self, cache_file: P) -> Self {
self.cache_file = Some(cache_file.into());
self
}
pub fn with_ankiconnect_url<S: Into<String>>(mut self, url: S) -> Self {
self.ankiconnect_url = Some(url.into());
self
}
pub fn with_extra_args(mut self, args: Vec<String>) -> Self {
self.extra_args = args;
self
}
pub fn with_cli_mode(mut self, cli_mode: bool) -> Self {
self.cli_mode = cli_mode;
self
}
pub fn with_keep_artifacts(mut self, keep_artifacts: bool) -> Self {
self.keep_artifacts = keep_artifacts;
self
}
}
#[derive(Debug)]
pub struct SyncResult {
pub notes_added: usize,
pub notes_updated: usize,
pub notes_unchanged: usize,
pub decks_created: usize,
pub warnings: Vec<String>,
}
impl SyncResult {
pub fn new() -> Self {
Self {
notes_added: 0,
notes_updated: 0,
notes_unchanged: 0,
decks_created: 0,
warnings: Vec::new(),
}
}
pub fn total_notes(&self) -> usize {
self.notes_added + self.notes_updated + self.notes_unchanged
}
}
impl Default for SyncResult {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RequestList {
pub multi: bool,
pub requests: Vec<RequestOrRequestList>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum RequestOrRequestList {
Single(serde_json::Value),
List(RequestList),
}
#[derive(Debug)]
struct ProcessedNote {
metadata: CompletedNote,
anki_note: AnkiNote,
field_hashes: HashMap<Field, Option<Sha256>>,
is_new: bool,
}
pub struct SyncContext {
config: SyncConfig,
anki_client: AnkiConnect,
http_client: Client,
cache: Cache,
cache_enabled: bool,
temp_files: Vec<PathBuf>,
output_files: HashMap<Format, Vec<PathBuf>>,
}
impl SyncContext {
async fn new(config: SyncConfig) -> Result<Self> {
let cache = if let Some(cache_file) = &config.cache_file {
Cache::load_from_file(cache_file).await?
} else {
let source_dir = config
.source_file
.parent()
.unwrap_or_else(|| std::path::Path::new("."));
let cache_file = source_dir.join(".ankify").join("cache.json");
Cache::load_from_file(cache_file).await?
};
let anki_client = if let Some(url) = &config.ankiconnect_url {
AnkiConnect::with_url(url.clone())
} else {
AnkiConnect::new()
};
let http_client = Client::new();
Ok(Self {
config,
anki_client,
http_client,
cache,
cache_enabled: true,
temp_files: Vec::new(),
output_files: HashMap::new(),
})
}
pub async fn cleanup(&self) -> Result<()> {
for file in &self.temp_files {
if file.exists() {
if let Err(e) = fs::remove_file(file).await {
warn!("Failed to remove temporary file {}: {}", file.display(), e);
}
}
}
for files in self.output_files.values() {
for file in files {
if file.exists() {
if let Err(e) = fs::remove_file(file).await {
warn!("Failed to remove output file {}: {}", file.display(), e);
}
}
}
}
Ok(())
}
async fn save_cache(&self) -> Result<()> {
if !self.cache_enabled {
return Ok(());
}
self.cache.save().await
}
async fn apply_document_configuration(
&mut self,
config: &CompletedTypstAnkifyConfiguration,
) -> Result<()> {
if self.config.verbose || config.verbose {
crate::logging::enable_verbose_logging();
}
if self.config.ankiconnect_url.is_none() {
self.anki_client = AnkiConnect::with_url(config.ankiconnect_url.clone());
}
if !config.cache.enabled.unwrap_or(true) {
warn!("Caching is disabled; every note will be treated as new");
self.cache = Cache::new();
self.cache_enabled = false;
} else if self.config.cache_file.is_none() {
if let Some(custom) = &config.cache.custom_file {
let custom_path = std::path::Path::new(custom);
let resolved = if custom_path.is_absolute() {
custom_path.to_path_buf()
} else {
self.config
.source_file
.parent()
.unwrap_or_else(|| std::path::Path::new("."))
.join(custom_path)
};
self.cache = Cache::load_from_file(&resolved).await?;
}
}
Ok(())
}
}
pub async fn sync(config: SyncConfig) -> Result<SyncResult> {
if config.cli_mode {
info!("Starting Ankify sync for {}", config.source_file.display());
}
let mut ctx = SyncContext::new(config).await?;
let mut result = SyncResult::new();
let sync_result = sync_internal(&mut ctx, &mut result).await;
if let Err(e) = ctx.save_cache().await {
warn!("Failed to save cache: {}", e);
}
if sync_result.is_ok() && !ctx.config.keep_artifacts {
if let Err(e) = ctx.cleanup().await {
warn!("Cleanup failed: {}", e);
}
}
sync_result.map(|_| result)
}
async fn sync_internal(ctx: &mut SyncContext, result: &mut SyncResult) -> Result<()> {
let mut typst_args: Vec<String> = ctx.config.extra_args.clone();
if !typst_args
.iter()
.any(|a| a == "--root" || a.starts_with("--root="))
{
let root = ctx
.config
.source_file
.parent()
.filter(|p| !p.as_os_str().is_empty())
.map(|p| p.to_path_buf())
.unwrap_or_else(|| PathBuf::from("."));
typst_args.insert(0, root.to_string_lossy().into_owned());
typst_args.insert(0, "--root".to_string());
}
let typst_arg_refs: Vec<&str> = typst_args.iter().map(String::as_str).collect();
let ankify_config =
query_ankify_configuration(&ctx.config.source_file, Some(&typst_arg_refs)).await?;
ctx.apply_document_configuration(&ankify_config).await?;
check_ankiconnect(&ctx.anki_client, &ctx.http_client).await?;
let temp_file = generate_temp_file(&crate::generate::GenerateConfig {
source_file: ctx.config.source_file.clone(),
output_dir: None,
})?;
ctx.temp_files.push(temp_file.clone());
let metadata_notes = query_ankify_notes(&ctx.config.source_file, Some(&typst_arg_refs)).await?;
if metadata_notes.is_empty() {
if ctx.config.cli_mode {
info!("No notes found in {}", ctx.config.source_file.display());
}
report_orphans(&ctx.cache, &HashSet::new(), result);
return Ok(());
}
if ctx.config.cli_mode {
info!("Found {} notes to process", metadata_notes.len());
}
let completed_metadata_notes = complete_ankify_notes_metadata(metadata_notes, &ankify_config);
if let Some(checks) = &ankify_config.checks.ankiconnect {
run_ankiconnect_checks(
checks,
&completed_metadata_notes,
&ctx.anki_client,
&ctx.http_client,
result,
)
.await?;
}
let compile_config = CompileConfig::new(
temp_file.clone(),
temp_file.parent().unwrap().join("output"),
completed_metadata_notes.clone(),
)?
.with_extra_args(typst_args.clone());
tokio::fs::create_dir_all(&compile_config.output_dir)
.await
.map_err(|e| Error::custom(format!("Failed to create output directory: {}", e)))?;
let compile_result = compile_temp_file(&compile_config).await?;
ctx.output_files.extend(compile_result.output_files);
let mut processed_notes =
process_notes_with_hashes(&completed_metadata_notes, &compile_result.notes, &ctx.cache)
.await?;
detect_renames_and_report_orphans(
&mut processed_notes,
&mut ctx.cache,
result,
ctx.config.cli_mode,
);
let request_list = create_request_list(&processed_notes, &ctx.cache, &ctx.anki_client).await?;
execute_requests(
&request_list,
&ctx.anki_client,
&ctx.http_client,
&mut ctx.cache,
result,
&processed_notes,
)
.await?;
for note in &processed_notes {
if note.is_new {
continue;
}
if let Some(id) = ctx.cache.get_note_id(¬e.metadata.label) {
ctx.cache
.update_from_note(¬e.metadata, id, note.field_hashes.clone())
.await?;
}
}
result.notes_unchanged = processed_notes
.len()
.saturating_sub(result.notes_added)
.saturating_sub(result.notes_updated);
if ctx.config.cli_mode {
info!(
"Sync completed: {} added, {} updated, {} unchanged",
result.notes_added, result.notes_updated, result.notes_unchanged
);
}
Ok(())
}
async fn check_ankiconnect(anki_client: &AnkiConnect, http_client: &Client) -> Result<()> {
let request = anki_client.action_to_request(AnkiAction::Version);
let response = http_client
.post(anki_client.url())
.json(&request)
.send()
.await
.map_err(|e| Error::anki_connect(format!("Failed to connect to AnkiConnect: {}", e)))?;
if !response.status().is_success() {
return Err(Error::anki_connect(format!(
"AnkiConnect returned status: {}",
response.status()
)));
}
let response_text = response
.text()
.await
.map_err(|e| Error::anki_connect(format!("Failed to read AnkiConnect response: {}", e)))?;
let version: u32 = anki_client
.parse_response(&response_text)
.map_err(|e| Error::anki_connect(format!("Failed to parse version response: {}", e)))?;
if version < 6 {
return Err(Error::anki_connect(format!(
"AnkiConnect version {} is too old, need at least version 6",
version
)));
}
Ok(())
}
async fn fetch_string_list(
anki_client: &AnkiConnect,
http_client: &Client,
action: AnkiAction,
) -> Result<Vec<String>> {
let request = anki_client.action_to_request(action);
let response = http_client
.post(anki_client.url())
.json(&request)
.send()
.await
.map_err(|e| Error::anki_connect(format!("Failed to send request: {}", e)))?;
let text = response
.text()
.await
.map_err(|e| Error::anki_connect(format!("Failed to read response: {}", e)))?;
anki_client
.parse_response::<Vec<String>>(&text)
.map_err(|e| Error::anki_connect(format!("Failed to parse response: {}", e)))
}
async fn run_ankiconnect_checks(
checks: &AnkiConnectChecks,
notes: &[CompletedNote],
anki_client: &AnkiConnect,
http_client: &Client,
result: &mut SyncResult,
) -> Result<()> {
if checks.model.unwrap_or(true) {
let models = fetch_string_list(anki_client, http_client, AnkiAction::ModelNames).await?;
let models: HashSet<&str> = models.iter().map(String::as_str).collect();
for note in notes {
if !models.contains(note.model.as_str()) {
return Err(Error::anki_connect(format!(
"note '{}' uses model '{}', which does not exist in Anki",
note.label, note.model
)));
}
}
}
if checks.deck.unwrap_or(true) {
let decks = fetch_string_list(anki_client, http_client, AnkiAction::DeckNames).await?;
let decks: HashSet<&str> = decks.iter().map(String::as_str).collect();
let missing: HashSet<&str> = notes
.iter()
.map(|n| n.deck.as_str())
.filter(|d| !decks.contains(d))
.collect();
for deck in missing {
result.warnings.push(format!(
"deck '{}' does not exist yet — it will be created",
deck
));
}
}
if checks.tags.unwrap_or(true) {
let tags = fetch_string_list(anki_client, http_client, AnkiAction::GetTags).await?;
let tags: HashSet<&str> = tags.iter().map(String::as_str).collect();
let missing: HashSet<&str> = notes
.iter()
.flat_map(|n| n.tags.iter())
.map(String::as_str)
.filter(|t| !tags.contains(t))
.collect();
for tag in missing {
result.warnings.push(format!(
"tag '{}' does not exist yet — it will be created",
tag
));
}
}
Ok(())
}
async fn process_notes_with_hashes(
metadata_notes: &[CompletedNote],
anki_notes: &[AnkiNote],
cache: &Cache,
) -> Result<Vec<ProcessedNote>> {
let futures: Vec<_> = metadata_notes
.iter()
.zip(anki_notes.iter())
.map(|(metadata_note, anki_note)| async move {
let field_hashes = cache.create_field_hashes(anki_note, metadata_note).await?;
let existing_entry = cache.get(&metadata_note.label);
let is_new = existing_entry.is_none();
Ok::<ProcessedNote, Error>(ProcessedNote {
metadata: (*metadata_note).clone(),
anki_note: (*anki_note).clone(),
field_hashes,
is_new,
})
})
.collect();
let results = futures::future::join_all(futures).await;
let mut processed_notes = Vec::new();
for result in results {
processed_notes.push(result?);
}
Ok(processed_notes)
}
type Fingerprint = Vec<(String, Option<String>)>;
fn fingerprint(hashes: &HashMap<Field, Option<Sha256>>) -> Fingerprint {
let mut fp: Fingerprint = hashes
.iter()
.map(|(field, hash)| {
(
field.as_str().to_string(),
hash.as_ref().map(|h| h.as_str().to_string()),
)
})
.collect();
fp.sort();
fp
}
fn match_renames(
fresh: &[(usize, Fingerprint)],
orphans: &[(String, Fingerprint)],
) -> Vec<(String, usize)> {
let mut fresh_by_fp: HashMap<&Fingerprint, Vec<usize>> = HashMap::new();
for (index, fp) in fresh {
fresh_by_fp.entry(fp).or_default().push(*index);
}
let mut orphans_by_fp: HashMap<&Fingerprint, Vec<&str>> = HashMap::new();
for (label, fp) in orphans {
orphans_by_fp.entry(fp).or_default().push(label.as_str());
}
let mut pairs = Vec::new();
for (fp, indices) in &fresh_by_fp {
if indices.len() != 1 {
continue;
}
let Some(labels) = orphans_by_fp.get(fp) else {
continue;
};
if labels.len() == 1 {
pairs.push((labels[0].to_string(), indices[0]));
}
}
pairs
}
fn detect_renames_and_report_orphans(
processed_notes: &mut [ProcessedNote],
cache: &mut Cache,
result: &mut SyncResult,
cli_mode: bool,
) {
let document_labels: HashSet<String> = processed_notes
.iter()
.map(|note| note.metadata.label.clone())
.collect();
let orphans: Vec<(String, Fingerprint)> = cache
.entries()
.iter()
.filter(|(label, _)| !document_labels.contains(label.as_str()))
.map(|(label, entry)| (label.clone(), fingerprint(&entry.hash)))
.collect();
if orphans.is_empty() {
return;
}
let fresh: Vec<(usize, Fingerprint)> = processed_notes
.iter()
.enumerate()
.filter(|(_, note)| note.is_new)
.map(|(index, note)| (index, fingerprint(¬e.field_hashes)))
.collect();
for (old_label, fresh_index) in match_renames(&fresh, &orphans) {
let Some(mut entry) = cache.remove(&old_label) else {
continue;
};
let new_label = processed_notes[fresh_index].metadata.label.clone();
entry.label = Label::new(new_label.clone());
cache.insert(new_label.clone(), entry);
processed_notes[fresh_index].is_new = false;
if cli_mode {
info!(
"note '{}' was renamed to '{}'; updating it in place",
old_label, new_label
);
}
}
report_orphans(cache, &document_labels, result);
}
fn report_orphans(cache: &Cache, document_labels: &HashSet<String>, result: &mut SyncResult) {
let mut orphan_labels: Vec<&str> = cache
.entries()
.keys()
.map(String::as_str)
.filter(|&label| !document_labels.contains(label))
.collect();
orphan_labels.sort_unstable();
for label in orphan_labels {
let message = format!(
"cached note '{}' is no longer in the document \
(deleted, or renamed alongside an edit); \
its Anki note was left untouched",
label
);
warn!("{}", message);
result.warnings.push(message);
}
}
fn note_changed(note: &ProcessedNote, cached: &CacheEntry) -> bool {
for (field, new_hash) in ¬e.field_hashes {
if cached.hash.get(field) != Some(new_hash) {
return true;
}
}
for field in cached.hash.keys() {
if !note.field_hashes.contains_key(field) {
return true;
}
}
if note.anki_note.tags.clone().unwrap_or_default() != cached.tags {
return true;
}
false
}
async fn create_request_list(
processed_notes: &[ProcessedNote],
cache: &Cache,
anki_client: &AnkiConnect,
) -> Result<RequestList> {
let mut requests = Vec::new();
let mut decks_to_create = HashSet::new();
let mut existing_decks = HashSet::new();
for entry in cache.entries().values() {
existing_decks.insert(entry.deck.as_str().to_string());
}
for note in processed_notes {
if note.is_new {
let deck_name = note.anki_note.deck_name.as_str().to_string();
if !existing_decks.contains(&deck_name) {
decks_to_create.insert(deck_name);
}
}
}
if !decks_to_create.is_empty() {
let mut deck_requests = Vec::new();
for deck in decks_to_create {
let request = anki_client.action_to_request(AnkiAction::CreateDeck { deck });
deck_requests.push(RequestOrRequestList::Single(request));
}
if deck_requests.len() == 1 {
requests.extend(deck_requests);
} else {
requests.push(RequestOrRequestList::List(RequestList {
multi: true,
requests: deck_requests,
}));
}
}
let truly_new_notes: Vec<_> = processed_notes
.iter()
.filter(|note| !cache.contains(¬e.metadata.label))
.collect();
if !truly_new_notes.is_empty() {
let notes: Vec<AnkiNote> = truly_new_notes
.iter()
.map(|pn| pn.anki_note.clone())
.collect();
let request = anki_client.action_to_request(AnkiAction::AddNotes { notes });
requests.push(RequestOrRequestList::Single(request));
}
let notes_to_update: Vec<_> = processed_notes
.iter()
.filter(|note| !note.is_new)
.filter(|note| match cache.get(¬e.metadata.label) {
Some(cached_entry) => note_changed(note, cached_entry),
None => true, })
.collect();
if !notes_to_update.is_empty() {
let mut update_requests = Vec::new();
for note in notes_to_update {
if let Some(cached_entry) = cache.get(¬e.metadata.label) {
let request = anki_client.action_to_request(AnkiAction::UpdateNote {
note: crate::ankiconnect::NoteUpdate {
id: cached_entry.id.clone(),
fields: Some(note.anki_note.fields.clone()),
tags: note.anki_note.tags.clone(),
model_name: Some(note.anki_note.model_name.as_str().to_string()),
audio: note.anki_note.audio.clone(),
video: note.anki_note.video.clone(),
picture: note.anki_note.picture.clone(),
},
});
update_requests.push(RequestOrRequestList::Single(request));
}
}
if !update_requests.is_empty() {
if update_requests.len() == 1 {
requests.extend(update_requests);
} else {
requests.push(RequestOrRequestList::List(RequestList {
multi: true,
requests: update_requests,
}));
}
}
}
Ok(RequestList {
multi: requests.len() > 1,
requests,
})
}
async fn execute_requests(
request_list: &RequestList,
anki_client: &AnkiConnect,
http_client: &Client,
cache: &mut Cache,
result: &mut SyncResult,
processed_notes: &[ProcessedNote],
) -> Result<()> {
if let Ok(pretty) = serde_json::to_string_pretty(request_list) {
debug!("Request list:\n{}", pretty);
}
for request_or_list in &request_list.requests {
match request_or_list {
RequestOrRequestList::Single(request) => {
let note_ids =
execute_single_request(request, anki_client, http_client, cache, result)
.await?;
if let Some(note_ids) = note_ids {
update_cache_with_added_notes(cache, ¬e_ids, processed_notes, result)
.await?;
}
}
RequestOrRequestList::List(nested_list) => {
Box::pin(execute_requests(
nested_list,
anki_client,
http_client,
cache,
result,
processed_notes,
))
.await?;
}
}
}
Ok(())
}
async fn execute_single_request(
request: &serde_json::Value,
anki_client: &AnkiConnect,
http_client: &Client,
_cache: &mut Cache,
result: &mut SyncResult,
) -> Result<Option<Vec<Option<u64>>>> {
let response = http_client
.post(anki_client.url())
.json(request)
.send()
.await
.map_err(|e| Error::anki_connect(format!("Failed to send request: {}", e)))?;
if !response.status().is_success() {
return Err(Error::anki_connect(format!(
"AnkiConnect returned status: {}",
response.status()
)));
}
let response_text = response
.text()
.await
.map_err(|e| Error::anki_connect(format!("Failed to read response: {}", e)))?;
let response_json: serde_json::Value = serde_json::from_str(&response_text)
.map_err(|e| Error::anki_connect(format!("Failed to parse response: {}", e)))?;
if let Some(error) = response_json.get("error") {
if !error.is_null() {
return Err(Error::anki_connect(format!("AnkiConnect error: {}", error)));
}
}
let mut returned_note_ids = None;
if let Some(action) = request.get("action").and_then(|a| a.as_str()) {
match action {
"createDeck" => {
result.decks_created += 1;
}
"addNotes" => {
if let Some(note_ids) = response_json.get("result").and_then(|r| r.as_array()) {
let ids: Vec<Option<u64>> = note_ids.iter().map(|v| v.as_u64()).collect();
result.notes_added += ids.iter().flatten().count();
returned_note_ids = Some(ids);
}
}
"updateNote" => {
result.notes_updated += 1;
}
_ => {
}
}
}
Ok(returned_note_ids)
}
async fn update_cache_with_added_notes(
cache: &mut Cache,
note_ids: &[Option<u64>],
processed_notes: &[ProcessedNote],
result: &mut SyncResult,
) -> Result<()> {
let new_notes: Vec<_> = processed_notes
.iter()
.filter(|note| !cache.contains(¬e.metadata.label))
.collect();
if note_ids.len() != new_notes.len() {
result.warnings.push(format!(
"AnkiConnect returned {} note IDs for {} submitted notes; \
some notes may not have been cached",
note_ids.len(),
new_notes.len()
));
}
for (i, maybe_id) in note_ids.iter().enumerate() {
let Some(processed_note) = new_notes.get(i) else {
continue;
};
match maybe_id {
Some(id) => {
cache
.update_from_note(
&processed_note.metadata,
NoteId(*id),
processed_note.field_hashes.clone(),
)
.await?;
}
None => {
let msg = format!(
"Anki could not add note '{}' — skipped",
processed_note.metadata.label
);
warn!("{}", msg);
result.warnings.push(msg);
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn fingerprint_of(content: &str) -> Fingerprint {
vec![("Front".to_string(), Some(content.to_string()))]
}
#[test]
fn match_renames_pairs_a_unique_rename() {
let fresh = [(0usize, fingerprint_of("alpha"))];
let orphans = [("old-label".to_string(), fingerprint_of("alpha"))];
assert_eq!(
match_renames(&fresh, &orphans),
vec![("old-label".to_string(), 0)]
);
}
#[test]
fn match_renames_ignores_different_content() {
let fresh = [(0usize, fingerprint_of("alpha"))];
let orphans = [("old-label".to_string(), fingerprint_of("beta"))];
assert!(match_renames(&fresh, &orphans).is_empty());
}
#[test]
fn match_renames_skips_ambiguous_orphans() {
let fresh = [(0usize, fingerprint_of("alpha"))];
let orphans = [
("old-a".to_string(), fingerprint_of("alpha")),
("old-b".to_string(), fingerprint_of("alpha")),
];
assert!(match_renames(&fresh, &orphans).is_empty());
}
#[test]
fn match_renames_skips_ambiguous_fresh_notes() {
let fresh = [
(0usize, fingerprint_of("alpha")),
(1usize, fingerprint_of("alpha")),
];
let orphans = [("old-label".to_string(), fingerprint_of("alpha"))];
assert!(match_renames(&fresh, &orphans).is_empty());
}
#[test]
fn match_renames_pairs_several_independent_renames() {
let fresh = [
(0usize, fingerprint_of("alpha")),
(1usize, fingerprint_of("beta")),
];
let orphans = [
("old-alpha".to_string(), fingerprint_of("alpha")),
("old-beta".to_string(), fingerprint_of("beta")),
];
let mut pairs = match_renames(&fresh, &orphans);
pairs.sort();
assert_eq!(
pairs,
vec![("old-alpha".to_string(), 0), ("old-beta".to_string(), 1)]
);
}
#[test]
fn match_renames_handles_no_fresh_notes() {
let orphans = [("old-label".to_string(), fingerprint_of("alpha"))];
assert!(match_renames(&[], &orphans).is_empty());
}
#[test]
fn fingerprint_is_independent_of_field_order() {
let mut one: HashMap<Field, Option<Sha256>> = HashMap::new();
one.insert(
Field::new("Front".to_string()),
Some(Sha256::from_text("q")),
);
one.insert(Field::new("Back".to_string()), Some(Sha256::from_text("a")));
let mut two: HashMap<Field, Option<Sha256>> = HashMap::new();
two.insert(Field::new("Back".to_string()), Some(Sha256::from_text("a")));
two.insert(
Field::new("Front".to_string()),
Some(Sha256::from_text("q")),
);
assert_eq!(fingerprint(&one), fingerprint(&two));
}
}