clankerdiff-protocol 0.3.3

Versioned subprocess and live session protocols for Clankerdiff
Documentation
use clankerdiff_core::{
    DiffDocument, DiffScope, RepositoryAction, Review, SourceUnavailable, StageState,
    testing::DocumentBuilder,
};
use clankerdiff_protocol::{
    client::{ClientCommand, DocumentCache},
    server::{SentDocument, ServerMessage},
    shared::{
        DiffSnapshot, DocumentUpdate, Event, FileEntry, LIVE_PROTOCOL_VERSION, ProtocolError,
        RemoteError, RemoteErrorCode,
    },
};
use std::{error::Error, sync::Arc};

#[test]
fn commands_and_events_round_trip() -> Result<(), Box<dyn Error>> {
    for message in [
        ClientCommand::Initialize {
            protocol_version: LIVE_PROTOCOL_VERSION,
            scope: DiffScope::Staged,
        },
        ClientCommand::SetScope(DiffScope::Unstaged),
        ClientCommand::Apply(RepositoryAction::Commit {
            message: "🦀\n".to_owned(),
        }),
        ClientCommand::Refresh,
        ClientCommand::Submit(Review::default().submission()),
        ClientCommand::Cancel,
    ] {
        let decoded = ClientCommand::decode(&message.encode()?)?;
        assert_eq!(format!("{decoded:?}"), format!("{message:?}"));
    }

    let update = SentDocument::default().encode(&DiffSnapshot {
        scope: DiffScope::Both,
        document: DocumentBuilder::new()
            .changed("a", "old\r\n", "new🦀")
            .build(),
    });
    for message in [
        Event::Initialize {
            protocol_version: LIVE_PROTOCOL_VERSION,
            repository_root: "/remote/repo".to_owned(),
        },
        ServerMessage::Document(update),
        Event::RequestResult(Err(RemoteError::new(RemoteErrorCode::Busy, "busy"))),
        Event::RequestResult(Ok(())),
        Event::Health { error: None },
        Event::Error(RemoteError::new(RemoteErrorCode::Watcher, "stopped")),
    ] {
        let decoded = ServerMessage::decode(&message.encode()?)?;
        assert_eq!(format!("{decoded:?}"), format!("{message:?}"));
    }
    Ok(())
}

#[test]
fn round_trip_preserves_metadata_custom_patches_and_order() -> Result<(), Box<dyn Error>> {
    let mut pair = SyncPair::default();
    let first = DocumentBuilder::new()
        .changed("a", "old\r\n", "new🦀\n")
        .untracked("b", "added\n")
        .build();
    assert_eq!(pair.publish(first.clone())?.received.document, first);

    let mut second = DocumentBuilder::new()
        .deleted("c", "gone")
        .renamed("a", "d", "old\r\n", "new\r\n")
        .binary("b")
        .build();
    let files = &mut Arc::make_mut(&mut second).files;
    files[0].hunks[0].header = "custom header".to_owned();
    files[0].no_newline_at_end = false;
    files[1].staged = StageState::PartiallyStaged;
    files[1].omitted_bytes = Some(32);
    files[2].old_source = Err(SourceUnavailable::TooLarge { bytes: 900_000_000 });
    assert_eq!(pair.publish(second.clone())?.received.document, second);
    Ok(())
}

#[test]
fn unchanged_files_are_not_resent() -> Result<(), Box<dyn Error>> {
    let mut pair = SyncPair::default();
    let document = DocumentBuilder::new()
        .changed("a", "old", "new")
        .changed("b", "old", "new")
        .build();
    pair.publish(document.clone())?;

    let mut edited = document.clone();
    Arc::make_mut(&mut edited).files[0] = clankerdiff_core::FileDiff::from_texts("a", "old", "y")?;
    let published = pair.publish(edited)?;
    assert!(matches!(
        published.update.files.as_slice(),
        [FileEntry::Changed(_), FileEntry::Unchanged(_)]
    ));

    let published = pair.publish(document)?;
    assert!(matches!(
        published.update.files.as_slice(),
        [FileEntry::Changed(_), FileEntry::Unchanged(_)]
    ));
    Ok(())
}

#[test]
fn a_scope_change_alone_reuses_every_file() -> Result<(), Box<dyn Error>> {
    let mut pair = SyncPair::default();
    let document = DocumentBuilder::new().changed("a", "old", "new").build();
    pair.publish(document.clone())?;
    let published = pair.publish_scope(DiffScope::Staged, document)?;
    assert_eq!(published.received.scope, DiffScope::Staged);
    assert!(matches!(
        published.update.files.as_slice(),
        [FileEntry::Unchanged(_)]
    ));
    Ok(())
}

#[test]
fn updates_referring_to_absent_or_repeated_files_are_rejected() {
    let document = DocumentBuilder::new().changed("a", "old", "new").build();
    let missing = update(vec![FileEntry::Unchanged(document.files[0].path.clone())]);
    assert!(DocumentCache::default().apply(&missing).is_err());

    let file = Arc::new(document.files[0].clone());
    let repeated = update(vec![
        FileEntry::Changed(Arc::clone(&file)),
        FileEntry::Changed(file),
    ]);
    assert!(DocumentCache::default().apply(&repeated).is_err());
}

#[test]
fn a_failed_update_leaves_the_previous_document_installed() -> Result<(), Box<dyn Error>> {
    let mut cache = DocumentCache::default();
    let document = DocumentBuilder::new().changed("a", "old", "new").build();
    let installed = cache.apply(&bootstrap(&document))?;
    let mut broken = bootstrap(&document);
    broken
        .files
        .push(FileEntry::Unchanged(document.files[0].path.clone()));
    assert!(cache.apply(&broken).is_err());
    assert_eq!(cache.apply(&bootstrap(&document))?, installed);
    Ok(())
}

#[test]
fn truncated_and_foreign_payloads_are_rejected() -> Result<(), Box<dyn Error>> {
    let text = ClientCommand::Refresh.encode()?;
    assert!(ClientCommand::decode(&text[..text.len() - 1]).is_err());
    assert!(ServerMessage::decode("").is_err());
    assert!(ClientCommand::decode("{\"Unknown\":1}").is_err());
    Ok(())
}

#[derive(Debug, Default)]
struct SyncPair {
    sent: SentDocument,
    cache: DocumentCache,
}

impl SyncPair {
    fn publish(&mut self, document: Arc<DiffDocument>) -> Result<Publication, ProtocolError> {
        self.publish_scope(DiffScope::Both, document)
    }

    fn publish_scope(
        &mut self,
        scope: DiffScope,
        document: Arc<DiffDocument>,
    ) -> Result<Publication, ProtocolError> {
        let update = self.sent.encode(&DiffSnapshot { scope, document });
        let message = ServerMessage::Document(update.clone()).encode()?;
        let Event::Document(received) = self.cache.decode_event(&message)? else {
            unreachable!()
        };
        Ok(Publication { update, received })
    }
}

struct Publication {
    update: DocumentUpdate,
    received: Arc<DiffSnapshot>,
}

fn update(files: Vec<FileEntry>) -> DocumentUpdate {
    DocumentUpdate {
        scope: DiffScope::Both,
        repo_root: "/repo".to_owned(),
        files,
    }
}

fn bootstrap(document: &DiffDocument) -> DocumentUpdate {
    update(
        document
            .files
            .iter()
            .map(|file| FileEntry::Changed(Arc::new(file.clone())))
            .collect(),
    )
}

#[test]
fn document_updates_reuse_unchanged_files() -> Result<(), Box<dyn Error>> {
    let document = DocumentBuilder::new()
        .changed("a", "old", "new")
        .changed("b", "old", "new")
        .build();
    let mut sent = SentDocument::default();
    let mut cache = DocumentCache::default();
    let first = sent.encode(&DiffSnapshot {
        scope: DiffScope::Both,
        document: document.clone(),
    });
    let message: ServerMessage = Event::Document(first);
    let Event::Document(received) = cache.decode_event(&message.encode()?)? else {
        return Err("expected document event".into());
    };
    assert_eq!(received.document, document);

    let second = sent.encode(&DiffSnapshot {
        scope: DiffScope::Staged,
        document,
    });
    assert!(
        second
            .files
            .iter()
            .all(|entry| matches!(entry, FileEntry::Unchanged(_)))
    );
    let received = cache.apply(&second)?;
    assert_eq!(received.scope, DiffScope::Staged);
    assert_eq!(received.document.files.len(), 2);
    assert_eq!(LIVE_PROTOCOL_VERSION, 2);
    Ok(())
}

#[test]
fn encoded_events_rebuild_through_the_cache() -> Result<(), Box<dyn Error>> {
    let mut sent = SentDocument::default();
    let mut cache = DocumentCache::default();
    let document = DocumentBuilder::new()
        .changed("a", "old", "new")
        .changed("b", "old", "new")
        .build();
    let snapshot = |scope| {
        Event::Document(Arc::new(DiffSnapshot {
            scope,
            document: document.clone(),
        }))
    };

    let Event::Document(first) = cache.apply_event(sent.encode_event(snapshot(DiffScope::Both)))?
    else {
        return Err("expected document event".into());
    };
    assert_eq!(first.document, document);

    let second = sent.encode_event(snapshot(DiffScope::Staged));
    let Event::Document(update) = &second else {
        return Err("expected document event".into());
    };
    assert!(
        update
            .files
            .iter()
            .all(|entry| matches!(entry, FileEntry::Unchanged(_)))
    );
    let Event::Document(second) = cache.apply_event(second)? else {
        return Err("expected document event".into());
    };
    assert_eq!(second.scope, DiffScope::Staged);
    assert_eq!(second.document, document);

    assert!(matches!(
        cache.apply_event(sent.encode_event(Event::RequestResult(Ok(()))))?,
        Event::RequestResult(Ok(()))
    ));
    Ok(())
}